'use client' import { useEffect, useRef, useState, useCallback } from 'react' import { createPortal } from 'react-dom' import { useLanguage } from '@/lib/i18n' import type { Editor } from '@tiptap/core' import type { Node as PMNode } from '@tiptap/pm/model' import { copyTextToClipboard } from '@/lib/editor/copy-text-to-clipboard' import { ensureBlockReferenceId } from '@/lib/editor/block-reference-id' import { rememberBlockReference } from '@/lib/editor/parse-block-reference' import { toast } from 'sonner' import { Trash2, Copy, Repeat, Link, ChevronRight, Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare, Quote, CodeXml, Database, ArrowUp, ArrowDown, AlignLeft, ClipboardCopy, Sparkles, ChevronsRightLeft, MessageSquareWarning, ListTree, Columns3, Plus, Minus, } from 'lucide-react' import { replaceBlockWithStructuredView } from '@/components/tiptap-structured-view-block-extension' import { insertToggleBlock, turnIntoToggleBlock } from '@/components/tiptap-toggle-extension' import { turnIntoCalloutBlock } from '@/components/tiptap-callout-extension' import { insertOutlineBlock } from '@/components/tiptap-outline-extension' import { insertColumnsBlock } from '@/components/tiptap-columns-extension' interface BlockActionMenuProps { editor: Editor onClose: () => void anchorRect: DOMRect blockPos: number blockNode: PMNode | null noteId?: string sourceNoteTitle?: string onBlockReferenceCopied?: (html: string) => void } type TurnIntoType = | 'paragraph' | 'heading1' | 'heading2' | 'heading3' | 'bulletList' | 'orderedList' | 'taskList' | 'blockquote' | 'codeBlock' | 'database' interface TurnIntoOption { id: TurnIntoType icon: typeof Heading1 command?: (editor: Editor) => void isDatabase?: boolean } /** Positionne le curseur dans le bloc avant d'appliquer une transformation */ function focusBlock(editor: Editor, blockPos: number) { const docSize = editor.state.doc.content.size const cursorPos = Math.min(blockPos + 1, docSize) editor.chain().focus().setTextSelection(cursorPos).run() } /** * Applique une transformation en passant d'abord par paragraphe. * Ceci corrige le cas heading → liste / blockquote / codeBlock qui échoue * avec un toggle direct. */ function makeTurnCommand(cmd: (e: Editor) => void): (e: Editor) => void { return (editor: Editor) => { // clearNodes remet en paragraphe sans perte de texte editor.chain().focus().clearNodes().run() cmd(editor) } } const TURN_INTO_OPTIONS: TurnIntoOption[] = [ { id: 'paragraph', icon: AlignLeft, command: (e) => e.chain().focus().clearNodes().run() }, { id: 'heading1', icon: Heading1, command: (e) => e.chain().focus().clearNodes().toggleHeading({ level: 1 }).run() }, { id: 'heading2', icon: Heading2, command: (e) => e.chain().focus().clearNodes().toggleHeading({ level: 2 }).run() }, { id: 'heading3', icon: Heading3, command: (e) => e.chain().focus().clearNodes().toggleHeading({ level: 3 }).run() }, { id: 'bulletList', icon: List, command: makeTurnCommand((e) => e.chain().focus().toggleBulletList().run()) }, { id: 'orderedList', icon: ListOrdered, command: makeTurnCommand((e) => e.chain().focus().toggleOrderedList().run()) }, { id: 'taskList', icon: CheckSquare, command: makeTurnCommand((e) => e.chain().focus().toggleTaskList().run()) }, { id: 'blockquote', icon: Quote, command: makeTurnCommand((e) => e.chain().focus().toggleBlockquote().run()) }, { id: 'codeBlock', icon: CodeXml, command: makeTurnCommand((e) => e.chain().focus().toggleCodeBlock().run()) }, { id: 'database', icon: Database, isDatabase: true }, ] function getBlockPlainContent(editor: Editor, blockPos: number, blockNode: PMNode | null): string { const node = blockNode ?? (blockPos >= 0 ? editor.state.doc.nodeAt(blockPos) : null) if (!node || blockPos < 0) return '' const from = blockPos + 1 const to = blockPos + node.nodeSize - 1 if (to > from) { return editor.state.doc.textBetween(from, to, '\n', '').trim() } return node.textContent?.trim() ?? '' } /** Déplace un bloc vers le haut (échange avec le frère précédent) */ function moveBlockUp(editor: Editor, blockPos: number, blockNode: PMNode): boolean { const { doc } = editor.state let prevPos = -1 let prevNode: PMNode | null = null doc.forEach((node, offset) => { if (offset + node.nodeSize === blockPos) { prevPos = offset prevNode = node } }) if (prevPos < 0 || !prevNode) return false const safePrev = prevNode as PMNode const tr = editor.state.tr const from = prevPos const to = blockPos + blockNode.nodeSize tr.replaceWith(from, to, [blockNode.copy(blockNode.content), safePrev.copy(safePrev.content)]) editor.view.dispatch(tr) // Repositionne le curseur dans le bloc déplacé (maintenant à prevPos) const docSize = editor.state.doc.content.size editor.chain().focus().setTextSelection(Math.min(prevPos + 1, docSize)).run() return true } /** Déplace un bloc vers le bas (échange avec le frère suivant) */ function moveBlockDown(editor: Editor, blockPos: number, blockNode: PMNode): boolean { const { doc } = editor.state const nextPos = blockPos + blockNode.nodeSize const nextNode = doc.nodeAt(nextPos) if (!nextNode) return false const tr = editor.state.tr const from = blockPos const to = nextPos + nextNode.nodeSize tr.replaceWith(from, to, [nextNode.copy(nextNode.content), blockNode.copy(blockNode.content)]) editor.view.dispatch(tr) const newPos = blockPos + nextNode.nodeSize const docSize = editor.state.doc.content.size editor.chain().focus().setTextSelection(Math.min(newPos + 1, docSize)).run() return true } export function BlockActionMenu({ editor, onClose, anchorRect, blockPos, blockNode, noteId, sourceNoteTitle, onBlockReferenceCopied, }: BlockActionMenuProps) { const { t } = useLanguage() const menuRef = useRef(null) const [showTurnInto, setShowTurnInto] = useState(false) const hoverTimerRef = useRef | null>(null) const handleDelete = useCallback(() => { if (blockNode && blockPos >= 0) { editor.chain().focus().deleteRange({ from: blockPos, to: blockPos + blockNode.nodeSize }).run() } onClose() }, [editor, blockNode, blockPos, onClose]) const handleDuplicate = useCallback(() => { if (blockNode && blockPos >= 0) { const insertPos = blockPos + blockNode.nodeSize editor.view.dispatch(editor.state.tr.insert(insertPos, blockNode.copy())) editor.commands.focus() } onClose() }, [editor, blockNode, blockPos, onClose]) const handleMoveUp = useCallback(() => { if (blockNode && blockPos >= 0) { const ok = moveBlockUp(editor, blockPos, blockNode) if (!ok) toast(t('blockAction.moveUpFirst')) } onClose() }, [editor, blockNode, blockPos, onClose, t]) const handleMoveDown = useCallback(() => { if (blockNode && blockPos >= 0) { const ok = moveBlockDown(editor, blockPos, blockNode) if (!ok) toast(t('blockAction.moveDownLast')) } onClose() }, [editor, blockNode, blockPos, onClose, t]) const handleCopyContent = useCallback(async () => { const text = getBlockPlainContent(editor, blockPos, blockNode) if (!text) { toast(t('blockAction.emptyBlock')); onClose(); return } const ok = await copyTextToClipboard(text) if (ok) toast.success(t('blockAction.contentCopied')) else toast.error(t('blockAction.copyRefFailed')) onClose() }, [editor, blockPos, blockNode, onClose, t]) const handleCopyRef = useCallback(async () => { if (!noteId?.trim()) { toast.error(t('blockAction.copyRefNoNote')) onClose() return } const blockId = ensureBlockReferenceId(editor, blockPos, blockNode) if (!blockId) { toast.error(t('blockAction.copyRefUnsupported')) onClose() return } const html = editor.getHTML() const blockContent = getBlockPlainContent(editor, blockPos, blockNode) onBlockReferenceCopied?.(html) const ref = `${window.location.origin}/home?openNote=${encodeURIComponent(noteId)}#block-${encodeURIComponent(blockId)}` const copied = await copyTextToClipboard(ref) if (copied) { rememberBlockReference(ref, { blockContent, sourceNoteTitle }) toast.success(t('blockAction.copied')) } else { toast.error(t('blockAction.copyRefFailed')) } onClose() }, [blockNode, blockPos, editor, noteId, onBlockReferenceCopied, onClose, sourceNoteTitle, t]) const handleCreateDiagram = useCallback(async () => { const text = getBlockPlainContent(editor, blockPos, blockNode) if (!text || text.trim().length < 5) { toast.error(t('blockAction.createDiagramEmpty') || "Le texte est trop court pour générer un diagramme.") onClose() return } onClose() const toastId = toast.loading(t('blockAction.createDiagramLoading') || "Génération du diagramme Excalidraw par l'IA...") try { const { generateDiagramFromText } = await import('@/app/actions/diagram') const res = await generateDiagramFromText(text) if (!res.success || !res.canvasId) { throw new Error(res.error || "La génération du diagramme a échoué.") } const canvasId = res.canvasId const canvasRes = await fetch(`/api/canvas?id=${encodeURIComponent(canvasId)}`) const canvasData = await canvasRes.json() if (!canvasRes.ok || !canvasData.canvas?.data) { throw new Error("Impossible de charger les données du diagramme généré.") } const { exportExcalidrawSceneToPngBlob } = await import('@/lib/client/excalidraw-export-image') const blob = await exportExcalidrawSceneToPngBlob(canvasData.canvas.data) if (!blob) { throw new Error("Échec du rendu du diagramme en image PNG.") } const fd = new FormData() fd.append('file', blob, `diagram-${canvasId.slice(-8)}.png`) const up = await fetch('/api/upload', { method: 'POST', body: fd }) const upJson = await up.json() if (!up.ok || !upJson.url) { throw new Error("Échec du téléversement du diagramme généré.") } const imageUrl = upJson.url const insertPos = blockPos + (blockNode ? blockNode.nodeSize : 0) const htmlToInsert = `

Diagramme Excalidraw

🎨 ${t('blockAction.createDiagramSuccess') || "Éditer le diagramme dans le Lab Excalidraw"}

`.trim(); editor.chain().focus().insertContentAt(insertPos, htmlToInsert).run() toast.success(t('blockAction.createDiagramSuccess') || "Diagramme généré et inséré avec succès !", { id: toastId }) } catch (err: any) { console.error('[handleCreateDiagram] Error:', err) toast.error(err.message || "Une erreur est survenue lors de la génération.", { id: toastId }) } }, [editor, blockPos, blockNode, onClose, t]) const handleTurnInto = useCallback((option: TurnIntoOption) => { if (blockPos >= 0 && blockNode) { if (option.isDatabase) { const notebookId = (editor.storage as any).structuredViewBlock?.notebookId as string | null replaceBlockWithStructuredView(editor, blockPos, blockNode, notebookId) } else if (option.command) { focusBlock(editor, blockPos) option.command(editor) } } setShowTurnInto(false) onClose() }, [editor, blockNode, blockPos, onClose]) const handleTurnIntoEnter = useCallback(() => { if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current) setShowTurnInto(true) }, []) const handleTurnIntoLeave = useCallback(() => { hoverTimerRef.current = setTimeout(() => setShowTurnInto(false), 200) }, []) const handleSubmenuEnter = useCallback(() => { if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current) }, []) useEffect(() => { function handleClickOutside(e: MouseEvent) { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { onClose() } } function handleKeyDown(e: KeyboardEvent) { if (e.key === 'Escape') onClose() } document.addEventListener('mousedown', handleClickOutside) document.addEventListener('keydown', handleKeyDown) return () => { document.removeEventListener('mousedown', handleClickOutside) document.removeEventListener('keydown', handleKeyDown) if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current) } }, [onClose]) const menuLeft = anchorRect.right + 6 const menuTop = anchorRect.top - 4 const menuStyle: React.CSSProperties = { position: 'fixed', left: menuLeft > window.innerWidth - 230 ? anchorRect.left - 215 : menuLeft, top: Math.max(10, Math.min(menuTop, window.innerHeight - 620)), zIndex: 9999, maxHeight: window.innerHeight - 20, overflowY: 'auto', } return createPortal(
{/* Actions de déplacement */}
{/* Transformer en */}
{showTurnInto && (
{TURN_INTO_OPTIONS.map((opt) => ( ))}
)}
{/* Section repliable */} {/* Encadré */} {/* Sommaire */} {/* Colonnes */}
{/* Création de diagramme */}
{/* Copier */}
{/* Actions destructives */}
, document.body ) }