'use client' import { useState, useRef, useCallback } from 'react' import { useNoteEditorContext } from './note-editor-context' import { LabelManager } from '@/components/label-manager' import { LabelBadge } from '@/components/label-badge' import { GhostTags } from '@/components/ghost-tags' import { EditorImages } from '@/components/editor-images' import { TitleSuggestions } from '@/components/title-suggestions' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, ArrowLeft, ChevronRight, PanelRight, Check, Loader2, Save, MoreHorizontal, Trash2, LogOut, Wand2, Share2, Wind, Paperclip, GraduationCap, FileDown, FileUp, Mic, MicOff, Printer, PenTool, Loader2 as Loader2Icon, Globe } from 'lucide-react' import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog' import { NoteShareDialog } from './note-share-dialog' import { PublishDialog } from './publish-dialog' import { deleteNote, leaveSharedNote } from '@/app/actions/notes' import { emitNoteChange } from '@/lib/note-change-sync' import { useLanguage } from '@/lib/i18n' import { NOTE_COLORS, NoteColor, Note } from '@/lib/types' import { cn } from '@/lib/utils' import { useVoiceTranscription } from '@/hooks/use-voice-transcription' import { toast } from 'sonner' import { format } from 'date-fns' import { tiptapHTMLToMarkdown, markdownToHTML, extractMarkdownTitle } from '@/lib/editor/markdown-export' interface NoteEditorToolbarProps { mode: 'fullPage' | 'dialog' onClose: () => void onToggleAttachments?: () => void attachmentsCount?: number } export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachmentsCount }: NoteEditorToolbarProps) { const { state, actions, note, readOnly, fullPage, notebooks, fileInputRef, richTextEditorRef } = useNoteEditorContext() const { t, language } = useLanguage() const [isConverting, setIsConverting] = useState(false) const [shareOpen, setShareOpen] = useState(false) const [flashcardsOpen, setFlashcardsOpen] = useState(false) const [publishOpen, setPublishOpen] = useState(false) const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null const undoSnapshotRef = useRef<{ content: string; isMarkdown: boolean } | null>(null) // ── Voice transcription ────────────────────────────────────────────────── const handleTranscript = useCallback((text: string) => { const editor = richTextEditorRef?.current?.getEditor() if (editor) { editor.chain().focus().insertContent(' ' + text).run() } }, [richTextEditorRef]) const { state: voiceState, toggle: toggleVoice, isSupported: voiceSupported } = useVoiceTranscription({ onTranscript: handleTranscript, }) // ── Markdown export ─────────────────────────────────────────────────────── const handleExportMarkdown = () => { try { const editor = richTextEditorRef?.current?.getEditor() if (!editor) { toast.error(t('richTextEditor.markdownExportError')) return } const html = editor.getHTML() const title = state.title || note.title || 'note' const titleLine = title ? `# ${title}\n\n` : '' const markdown = titleLine + tiptapHTMLToMarkdown(html) const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `${title.replace(/[^a-z0-9\-_\s]/gi, '').trim().replace(/\s+/g, '-') || 'note'}.md` document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) toast.success(t('richTextEditor.markdownExportSuccess')) } catch { toast.error(t('richTextEditor.markdownExportError')) } } const handleExportPdf = async () => { const editor = richTextEditorRef?.current?.getEditor() if (!editor) return const title = state.title || note.title || 'Note' toast.loading(t('richTextEditor.pdfExportLoading') || 'Génération du PDF...', { id: 'pdf-export' }) try { const editorEl = document.querySelector('.ProseMirror') as HTMLElement if (!editorEl) throw new Error('Editor not found') // Clone the editor DOM to process it for print const clone = editorEl.cloneNode(true) as HTMLElement // Remove all action buttons, toolbars, and UI elements clone.querySelectorAll('button, .drag-handle, [contenteditable="false"], .opacity-0, .group-hover\\:opacity-100').forEach(el => el.remove()) clone.querySelectorAll('[title]').forEach(el => { const title = el.getAttribute('title') if (title && (title.includes('Supprimer') || title.includes('Delete') || title.includes('Désactiver') || title.includes('Disable') || title.includes('Modifier') || title.includes('Edit'))) { el.remove() } }) // Render KaTeX equations properly const katex = (await import('katex')).default clone.querySelectorAll('.math-equation-block').forEach(el => { const latex = el.getAttribute('data-latex') || el.textContent || '' try { el.innerHTML = katex.renderToString(latex, { displayMode: true, throwOnError: false }) } catch {} }) clone.querySelectorAll('.inline-math').forEach(el => { const latex = el.getAttribute('data-latex') || el.textContent || '' try { el.innerHTML = katex.renderToString(latex, { displayMode: false, throwOnError: false }) } catch {} }) // Force show all toggle content clone.querySelectorAll('[class*="hidden"]').forEach(el => { (el as HTMLElement).style.display = 'block' }) // Apply callout colors as inline styles (Tailwind classes won't work in print window) clone.querySelectorAll('[data-callout-type]').forEach(el => { const type = el.getAttribute('data-callout-type') const colors: Record = { info: { bg: '#eff6ff', border: '#93c5fd' }, warning: { bg: '#fffbeb', border: '#fcd34d' }, tip: { bg: '#faf5ff', border: '#c4b5fd' }, success: { bg: '#f0fdf4', border: '#86efac' }, danger: { bg: '#fef2f2', border: '#fca5a5' }, } const c = colors[type || 'info'] || colors.info const inner = el.querySelector('div') if (inner) { (inner as HTMLElement).style.background = c.bg (inner as HTMLElement).style.borderColor = c.border } }) const cloneHtml = clone.innerHTML const printWindow = window.open('', '_blank') if (!printWindow) { toast.error(t('richTextEditor.pdfExportBlocked') || 'Popup bloqué', { id: 'pdf-export' }) return } printWindow.document.write(`${title}

${title}

${cloneHtml}`) printWindow.document.close() setTimeout(() => { printWindow.focus() printWindow.print() toast.success(t('richTextEditor.pdfExportSuccess') || 'PDF prêt !', { id: 'pdf-export' }) }, 800) } catch (e: any) { toast.error(e.message || 'Erreur export PDF', { id: 'pdf-export' }) } } // ── Markdown import ─────────────────────────────────────────────────────── const openMarkdownImport = () => { const input = document.createElement('input') input.type = 'file' input.accept = '.md,text/markdown' input.onchange = (e) => { const file = (e.target as HTMLInputElement).files?.[0] if (!file) return const reader = new FileReader() reader.onload = (ev) => { try { const md = ev.target?.result as string const html = markdownToHTML(md) const extractedTitle = extractMarkdownTitle(md) const editor = richTextEditorRef?.current?.getEditor() if (editor) editor.commands.setContent(html) actions.setContent(html) if (extractedTitle) actions.setTitle(extractedTitle) toast.success(t('richTextEditor.markdownImportSuccess')) } catch { toast.error(t('richTextEditor.markdownExportError')) } } reader.readAsText(file) } input.click() } const [generatingExercises, setGeneratingExercises] = useState(false) const [showEduMenu, setShowEduMenu] = useState(false) const handleGenerateExercises = async () => { if (generatingExercises) return setGeneratingExercises(true) try { const res = await fetch('/api/ai/generate-exercises', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ noteId: note.id, count: 5, language }), }) const data = await res.json() if (!res.ok) { toast.error(data.errorKey === 'ai.featureLocked' ? (t('ai.featureLocked') || 'Plan requis') : (data.error || 'Erreur')) } else { toast.success(`${data.exercises?.length || 0} ${t('richTextEditor.exercisesGenerated') || 'exercices créés dans ce carnet !'}`, { action: { label: t('richTextEditor.seeExercises') || 'Voir', onClick: () => window.location.reload(), }, }) // Emit events so the note list refreshes for (const ex of data.exercises || []) { emitNoteChange({ type: 'created', note: { ...note, id: ex.id, title: ex.title, content: '

' } as any }) } } } catch (e: any) { toast.error(e.message || 'Erreur') } finally { setGeneratingExercises(false) } } const handleConvertToRichtext = async () => { if (isConverting || !state.content.trim()) return setIsConverting(true) const snapshot = { content: state.content, isMarkdown: state.isMarkdown } undoSnapshotRef.current = snapshot try { let html: string if (state.isMarkdown) { const { marked } = await import('marked') html = await marked(state.content, { async: false }) as string } else { html = state.content .split(/\n{2,}/) .map(para => `

${para.trim().replace(/\n/g, '
')}

`) .join('') } actions.setContent(html) actions.setIsMarkdown(false) toast.success(t('notes.convertedToRichText') || 'Converted to rich text', { duration: 8000, action: { label: t('notes.undo') || '↩ Undo', onClick: () => { const snap = undoSnapshotRef.current if (!snap) return actions.setContent(snap.content) if (snap.isMarkdown) actions.setIsMarkdown(true) undoSnapshotRef.current = null toast.info(t('ai.undoApplied') || 'Conversion undone') }, }, }) } catch { toast.error(t('notes.transformFailed') || 'Conversion failed') } finally { setIsConverting(false) } } if (mode === 'fullPage') { const handleCloseWithSave = async () => { if (state.isDirty && !state.isSaving) { await actions.handleSaveInPlace() } onClose() } return (
{state.isSaving ? <>{t('notes.saving')} : state.isDirty ? <>{t('notes.dirtyStatus')} : <>{t('notes.savedStatus')}} {state.isMarkdown && !readOnly && ( )} {state.isMarkdown && !readOnly && ( )} {!readOnly && (
{showEduMenu && ( <>
setShowEduMenu(false)} />
)}
)} {!readOnly && voiceSupported && ( )} {!readOnly && onToggleAttachments && ( )} {!readOnly && (
)} {!readOnly && ( )} {!readOnly && ( )} {!readOnly && ( {t('richTextEditor.exportMarkdown')} {t('richTextEditor.exportPdf') || 'Exporter en PDF'} {t('richTextEditor.importMarkdown')} {t('settings.autoSave') || 'Auto-enregistrement'} {state.autoSaveEnabled ? (t('common.on') || 'Actif') : (t('common.off') || 'Désactivé')} { try { await deleteNote(note.id, { skipRevalidation: true }) emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId }) toast.success(t('notes.noteDeletedToast')) onClose() } catch { toast.error(t('notes.deleteNoteFailedToast')) } }} className="text-red-600 dark:text-red-400 focus:text-red-600" > {t('notes.deleteNoteConfirmItem')} )} {shareOpen && ( setShareOpen(false)} /> )} setFlashcardsOpen(false)} noteId={note.id} noteTitle={state.title || note.title || 'Untitled'} onSaved={(deckId) => { toast.success(t('flashcards.savedCount', { count: '' }).replace('{count}', ''), { description: t('flashcards.reviewNow') || 'Review now', action: { label: t('flashcards.reviewNow') || 'Review now →', onClick: () => { window.open(`/revision?deckId=${encodeURIComponent(deckId)}`, '_self') }, }, duration: 8000, }) }} /> {publishOpen && ( setPublishOpen(false)} noteId={note.id} noteTitle={state.title || note.title || 'Untitled'} isPublic={note.isPublic} publicSlug={note.publicSlug ?? null} /> )}
) } return ( <>
{!readOnly && ( <> {state.isMarkdown && ( )}
{(['small', 'medium', 'large'] as const).map((s) => ( ))}
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
)} {readOnly && (
{t('notes.sharedReadOnly')}
)}
{readOnly ? ( <> ) : ( <> )}
{/* Hidden file input for Markdown import — remplacé par création dynamique dans openMarkdownImport */} ) }