Files
Momento/memento-note/components/note-editor/note-editor-toolbar.tsx
Antigravity a623454347
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m32s
CI / Deploy production (on server) (push) Has been skipped
perf: memo GridCard, fuse save fns, fix slash tab active color
2026-06-14 14:06:05 +00:00

553 lines
24 KiB
TypeScript

'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 => `<p>${para.trim().replace(/\n/g, '<br />')}</p>`)
.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 (
<div className="px-4 sm:px-8 md:px-12 py-4 sm:py-6 md:py-8 flex items-center justify-between sticky top-0 bg-white/95 dark:bg-background/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
<button
onClick={handleCloseWithSave}
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
>
<ArrowLeft size={18} />
<span className="text-sm font-medium">{t('notes.backToCollection')}</span>
</button>
<div className="flex items-center gap-4">
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none">
{state.isSaving
? <><Loader2 className="h-3 w-3 animate-spin" /><span>{t('notes.saving')}</span></>
: state.isDirty
? <><span className="h-1.5 w-1.5 rounded-full bg-amber-400 inline-block" /><span>{t('notes.dirtyStatus')}</span></>
: <><Check className="h-3 w-3 text-emerald-500" /><span>{t('notes.savedStatus')}</span></>}
</span>
{state.isMarkdown && !readOnly && (
<button
title={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
aria-label={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
state.showMarkdownPreview
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Eye size={16} />
</button>
)}
{state.isMarkdown && !readOnly && (
<button
title={t('ai.convertToRichtext') || 'Convert to Rich Text'}
aria-label={t('ai.convertToRichtext') || 'Convert to Rich Text'}
onClick={handleConvertToRichtext}
disabled={isConverting}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
isConverting && 'opacity-50 cursor-not-allowed'
)}
>
{isConverting ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
</button>
)}
<button
title={t('ai.openAssistant')}
aria-label={t('ai.openAssistant')}
onClick={() => { actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
state.aiOpen
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Sparkles size={16} />
</button>
<button
title={t('notes.brainstormThisIdea')}
aria-label={t('notes.brainstormThisIdeaAria')}
onClick={() => {
const title = note.title || ''
const summary = state.content?.replace(/<[^>]*>/g, '').slice(0, 200) || ''
const seed = title ? `${title}. ${summary}` : summary
if (!seed.trim()) return
window.open(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`, '_self')
}}
className="p-1.5 rounded-full border border-brand-accent/30 dark:border-brand-accent/50 text-brand-accent hover:bg-brand-accent/10 dark:hover:bg-brand-accent/20 transition-all"
>
<Wind size={16} />
</button>
{!readOnly && (
<button
title={t('flashcards.toolbarGenerate')}
aria-label={t('flashcards.toolbarGenerate')}
onClick={() => setFlashcardsOpen(true)}
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
<GraduationCap size={16} />
</button>
)}
{!readOnly && voiceSupported && (
<button
title={voiceState === 'listening'
? (t('editor.voiceStop') || 'Arrêter la dictée')
: (t('editor.voiceStart') || 'Dicter du texte')}
aria-label={voiceState === 'listening' ? 'Stop voice' : 'Start voice'}
onClick={toggleVoice}
className={cn(
'p-1.5 rounded-full border transition-all',
voiceState === 'listening'
? 'border-red-400 bg-red-50 dark:bg-red-950/30 text-red-500 animate-pulse'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
)}
>
{voiceState === 'listening' ? <MicOff size={16} /> : <Mic size={16} />}
</button>
)}
{!readOnly && onToggleAttachments && (
<button
title={t('notes.attachments') || 'Attachments'}
aria-label={t('notes.attachments') || 'Attachments'}
onClick={onToggleAttachments}
className="relative p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
<Paperclip size={16} />
{(attachmentsCount ?? 0) > 0 && (
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-primary text-primary-foreground text-[8px] font-bold rounded-full flex items-center justify-center">
{attachmentsCount}
</span>
)}
</button>
)}
{!readOnly && (
<div className="flex items-center gap-1.5">
<button
title={state.isDirty ? t('notes.saveNow') : t('notes.noModification')}
aria-label={state.isDirty ? t('notes.saveNoteAria') : t('notes.noChangesToSaveAria')}
onClick={() => actions.handleSaveInPlace()}
disabled={state.isSaving || !state.isDirty}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
state.isDirty
? 'bg-foreground text-background border-foreground hover:opacity-80'
: 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
)}
>
{state.isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
</button>
</div>
)}
{!readOnly && (
<button
title={t('notes.shareNoteTitle')}
aria-label={t('notes.shareNoteAria')}
onClick={() => setShareOpen(true)}
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
<Share2 size={16} />
</button>
)}
{!readOnly && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button aria-label={t('notes.optionsMenuAria')} className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all">
<MoreHorizontal size={16} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onClick={handleExportMarkdown}>
<FileDown className="h-4 w-4 me-2" />
{t('richTextEditor.exportMarkdown')}
</DropdownMenuItem>
<DropdownMenuItem onClick={openMarkdownImport}>
<FileUp className="h-4 w-4 me-2" />
{t('richTextEditor.importMarkdown')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={actions.toggleAutoSave} className="flex items-center justify-between cursor-pointer">
<span className="flex items-center">
<Save className="h-4 w-4 me-2 text-muted-foreground" />
{t('settings.autoSave') || 'Auto-enregistrement'}
</span>
<span className={cn(
'text-[10px] px-1.5 py-0.5 rounded font-bold uppercase tracking-wider',
state.autoSaveEnabled
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-400'
: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400'
)}>
{state.autoSaveEnabled ? (t('common.on') || 'Actif') : (t('common.off') || 'Désactivé')}
</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={async () => {
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"
>
<Trash2 className="h-4 w-4 me-2" />
{t('notes.deleteNoteConfirmItem')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{shareOpen && (
<NoteShareDialog
noteId={note.id}
noteTitle={state.title}
onClose={() => setShareOpen(false)}
/>
)}
<FlashcardGenerateDialog
open={flashcardsOpen}
onClose={() => 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,
})
}}
/>
<button
aria-label={t('notes.documentInfoAria')}
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
state.infoOpen
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<PanelRight size={16} />
</button>
</div>
</div>
)
}
return (
<>
<div className="flex items-center justify-between pt-3 border-t border-border/30">
<div className="flex items-center gap-0.5">
{!readOnly && (
<>
<Button variant="ghost" size="icon" className={cn('h-8 w-8 rounded-md', state.currentReminder && 'text-primary')}
onClick={() => actions.setShowReminderDialog(true)} title={t('notes.setReminder')}>
<Bell className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => fileInputRef.current?.click()} title={t('notes.addImage')}>
<ImageIcon className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => actions.setShowLinkDialog(true)} title={t('notes.addLink')}>
<LinkIcon className="h-4 w-4" />
</Button>
{state.isMarkdown && (
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
title={state.showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
<Eye className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-all duration-200 rounded-md', state.aiOpen && 'bg-primary/10 text-primary')}
onClick={() => actions.setAiOpen(!state.aiOpen)}
title={t('ai.aiNoteTitle')}
>
<Sparkles className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t('ai.aiNoteTitle')}</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeSize')}>
<Maximize2 className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="flex flex-col gap-1 p-1">
{(['small', 'medium', 'large'] as const).map((s) => (
<Button key={s} variant="ghost" size="sm"
onClick={() => actions.setSize(s)}
className={cn('justify-start capitalize', state.size === s && 'bg-accent')}>
{s}
</Button>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeColor')}>
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
<button key={colorName}
className={cn('h-7 w-7 rounded-full border-2 transition-transform hover:scale-110', classes.bg,
state.color === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700')}
onClick={() => actions.setColor(colorName as NoteColor)} title={colorName} />
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
<LabelManager existingLabels={state.labels} notebookId={note.notebookId} onUpdate={actions.setLabels} />
</>
)}
{readOnly && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="text-xs">{t('notes.sharedReadOnly')}</span>
</div>
)}
</div>
<div className="flex gap-2">
{readOnly ? (
<>
<Button
variant="default"
onClick={actions.handleMakeCopy}
className="flex items-center gap-2"
>
<Copy className="h-4 w-4" />
{t('notes.makeCopy')}
</Button>
<Button
variant="ghost"
className="flex items-center gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30"
onClick={async () => {
try {
await leaveSharedNote(note.id, { skipRevalidation: true })
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
toast.success(t('notes.leftShare'))
onClose()
} catch {
toast.error(t('general.error'))
}
}}
>
<LogOut className="h-4 w-4" />
{t('notes.leaveShare')}
</Button>
<Button variant="ghost" onClick={onClose}>
{t('general.close')}
</Button>
</>
) : (
<>
<Button variant="ghost" onClick={onClose}>
{t('general.cancel')}
</Button>
<Button onClick={() => actions.handleSave()} disabled={state.isSaving}>
{state.isSaving ? t('notes.saving') : t('general.save')}
</Button>
</>
)}
</div>
</div>
{/* Hidden file input for Markdown import — remplacé par création dynamique dans openMarkdownImport */}
</>
)
}