'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 } from 'lucide-react' import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-generate-dialog' import { NoteShareDialog } from './note-share-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 } = useLanguage() const [isConverting, setIsConverting] = useState(false) const [shareOpen, setShareOpen] = useState(false) const [flashcardsOpen, setFlashcardsOpen] = 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 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 && ( )} {!readOnly && voiceSupported && ( )} {!readOnly && onToggleAttachments && ( )} {!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, }) }} />
) } 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 */} ) }