'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, } from 'lucide-react' import { replaceBlockWithStructuredView } from '@/components/tiptap-structured-view-block-extension' interface BlockActionMenuProps { editor: Editor onClose: () => void anchorRect: DOMRect blockPos: number blockNode: PMNode | null noteId?: string sourceNoteTitle?: string onBlockReferenceCopied?: (html: string) => void } type TurnIntoType = | 'heading1' | 'heading2' | 'heading3' | 'bulletList' | 'orderedList' | 'taskList' | 'blockquote' | 'codeBlock' | 'database' interface TurnIntoOption { id: TurnIntoType icon: typeof Heading1 command?: (editor: Editor) => void isDatabase?: boolean } const TURN_INTO_OPTIONS: TurnIntoOption[] = [ { id: 'heading1', icon: Heading1, command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() }, { id: 'heading2', icon: Heading2, command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() }, { id: 'heading3', icon: Heading3, command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() }, { id: 'bulletList', icon: List, command: (e) => e.chain().focus().toggleBulletList().run() }, { id: 'orderedList', icon: ListOrdered, command: (e) => e.chain().focus().toggleOrderedList().run() }, { id: 'taskList', icon: CheckSquare, command: (e) => e.chain().focus().toggleTaskList().run() }, { id: 'blockquote', icon: Quote, command: (e) => e.chain().focus().toggleBlockquote().run() }, { id: 'codeBlock', icon: CodeXml, command: (e) => e.chain().focus().toggleCodeBlock().run() }, { id: 'database', icon: Database, isDatabase: true }, ] 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() } 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', '\0').trim() } return node.textContent?.trim() ?? '' } 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 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 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 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]) 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) } }, [onClose]) const menuStyle: React.CSSProperties = { position: 'fixed', left: anchorRect.right + 6, top: anchorRect.top - 4, zIndex: 9999, } if (Number(menuStyle.left) > window.innerWidth - 220) { menuStyle.left = anchorRect.left - 210 } if (Number(menuStyle.top) + 300 > window.innerHeight) { menuStyle.top = window.innerHeight - 310 } return createPortal(
{showTurnInto && (
{TURN_INTO_OPTIONS.map((opt) => ( ))}
)}
, document.body ) }