'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 } 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')) } } // ── 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.importMarkdown')} { 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 */} ) }