'use client'; import { useCallback, useMemo } from "react"; import type { Block, PartialBlock } from "@blocknote/core"; import { BlockColorsItem, SideMenu, TableColumnHeaderItem, TableRowHeaderItem, useBlockNoteEditor, useComponentsContext, type DragHandleMenuProps, type SideMenuProps, } from "@blocknote/react"; import { useRouter } from "next/navigation"; import type { CustomBlockSchema } from "../schema"; import { deleteOnlineTable } from "@/lib/online-table"; import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events"; type InlineNode = { text?: unknown }; type TableMenuBlock = Parameters< typeof TableRowHeaderItem >[0]["block"]; type DraftBlock = PartialBlock & { id?: string }; type ConvertOption = { label: string; type?: Block["type"]; props?: Record; shortcut?: string; action?: () => void; }; type CustomDragProps = DragHandleMenuProps & { currentDocumentId: string; }; const extractText = (block: Block) => { const inlineNodes = block.content as InlineNode[] | undefined; const maybeText = inlineNodes?.[0]?.text; if (typeof maybeText === "string" && maybeText.trim().length > 0) { return maybeText.trim(); } return "未命名页面"; }; const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) => { const Components = useComponentsContext()!; const editor = useBlockNoteEditor(); const router = useRouter(); const duplicateBlock = useCallback(() => { const blockWithoutId: DraftBlock = { ...block }; delete blockWithoutId.id; editor.insertBlocks([blockWithoutId], block, "after"); }, [block, editor]); const removePageReference = useCallback(async () => { if (block.type === "pageReference") { const pageId = block.props.pageId; if (pageId) { await fetch("/api/documents/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ documentId: pageId }), }); if (typeof window !== "undefined") { emitDocumentsChanged(pageId); } } } editor.removeBlocks([block.id]); router.refresh(); }, [block, editor, router]); const handleDeleteBlock = useCallback(async () => { if (block.type === "pageReference") { void removePageReference(); return; } if (block.type === "onlineTable") { const tableId = block.props.tableId as string | undefined; if (tableId) { void deleteOnlineTable(tableId) .catch((error) => console.error("删除在线表格失败", error)); if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId } })); emitAssetsChanged(currentDocumentId); } } editor.removeBlocks([block.id]); return; } if (block.type === "media") { const assetId = block.props.assetId as string | undefined; if (assetId) { const resp = await fetch("/api/media/batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "delete", assetIds: [assetId] }), }); if (!resp.ok) { const payload = await resp.json().catch(() => ({})); window.alert(payload?.error ?? "删除附件失败"); return; } emitAssetsChanged(currentDocumentId); } editor.removeBlocks([block.id]); return; } if (block.type === "mindmap") { const resp = await fetch(`/api/mindmap/${currentDocumentId}/${block.id}`, { method: "DELETE" }); if (!resp.ok) { const payload = await resp.json().catch(() => ({})); window.alert(payload?.error ?? "删除思维导图失败"); return; } emitAssetsChanged(currentDocumentId, undefined, undefined, false, [block.id]); // 侧边栏/全局删除监听也会尝试移除对应块,这里做 try/catch 避免重复删除导致报错 try { editor.removeBlocks([block.id]); } catch { // ignore } return; } editor.removeBlocks([block.id]); }, [block, currentDocumentId, editor, removePageReference]); const turnToPage = useCallback(async () => { const response = await fetch("/api/documents/create-child", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ parentId: currentDocumentId, title: extractText(block), blocks: [block], }), }); if (!response.ok) { return; } const { pageId, title } = await response.json(); editor.replaceBlocks( [block.id], [ { type: "pageReference", props: { pageId, title }, } as PartialBlock, ], ); router.refresh(); }, [block, currentDocumentId, editor, router]); const moveOrEmbedBlock = useCallback(async () => { if (typeof window === "undefined") { return; } const targetParent = window.prompt("输入目标页面 ID(将在该页面末尾插入新子页面)", currentDocumentId); if (!targetParent) { return; } const response = await fetch("/api/documents/create-child", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ parentId: targetParent.trim(), title: extractText(block), blocks: [block], }), }); if (!response.ok) { window.alert("移动失败,请确认页面 ID"); return; } const { pageId, title } = await response.json(); editor.replaceBlocks( [block.id], [ { type: "pageReference", props: { pageId, title }, } as PartialBlock, ], ); router.refresh(); }, [block, currentDocumentId, editor, router]); const convertOptions = useMemo( () => [ { label: "文本", type: "paragraph", shortcut: "Ctrl+Alt+0" }, { label: "待办列表", type: "checkListItem", shortcut: "Ctrl+Shift+5" }, { label: "高级待办列表", type: "advancedTodo" }, { label: "主标题", type: "heading", props: { level: 1 }, shortcut: "Ctrl+Shift+1" }, { label: "大标题", type: "heading", props: { level: 2 }, shortcut: "Ctrl+Shift+2" }, { label: "中标题", type: "heading", props: { level: 3 }, shortcut: "Ctrl+Shift+3" }, { label: "小标题", type: "heading", props: { level: 4 }, shortcut: "Ctrl+Shift+4" }, { label: "页面", action: turnToPage }, { label: "列表", type: "bulletListItem", shortcut: "Ctrl+Shift+6" }, { label: "数字列表", type: "numberedListItem", shortcut: "Ctrl+Shift+7" }, { label: "折叠列表", type: "toggleListItem", shortcut: "Ctrl+Shift+8" }, { label: "折叠标题", type: "heading", props: { level: 2, isToggleable: true } }, { label: "引述文字", type: "quote" }, { label: "代码片段", type: "codeBlock" }, ], [turnToPage], ); const convertBlock = useCallback( (option: ConvertOption) => { if (option.action) { option.action(); return; } if (!option.type) return; editor.updateBlock(block as any, { type: option.type as any, props: (option.props ?? {}) as any, } as any); }, [block, editor], ); const copyBlockLink = useCallback(async () => { if (typeof window === "undefined") { return; } const url = `${window.location.origin}/documents/${currentDocumentId}#block-${block.id}`; try { await navigator.clipboard.writeText(url); window.alert("块链接已复制"); } catch { window.prompt("复制失败,请手动复制", url); } }, [block.id, currentDocumentId]); const openOnRight = useCallback(() => { if (typeof window === "undefined") { return; } const url = `${window.location.origin}/documents/${currentDocumentId}?preview=sidebar&focus=${block.id}`; window.open(url, "_blank", "noopener,noreferrer"); }, [block.id, currentDocumentId]); const setAdvancedTodoStatus = useCallback( (status: "todo" | "doing" | "done" | "cancelled") => { if (block.type !== "advancedTodo") return; editor.updateBlock(block, { props: { status }, }); }, [block, editor], ); const toggleProgressMode = useCallback(() => { if (block.type !== "progressMeter") return; editor.updateBlock(block, { props: { auto: !block.props.auto }, }); }, [block, editor]); const setManualProgress = useCallback(() => { if (block.type !== "progressMeter") return; const value = Number.parseInt(window.prompt("手动设置进度(0-100)", String(block.props.percent ?? 0)) ?? "", 10); if (Number.isNaN(value)) return; const clamped = Math.min(100, Math.max(0, value)); editor.updateBlock(block, { props: { percent: clamped }, }); }, [block, editor]); return ( 在右侧边栏打开 转换为 {convertOptions.map((option) => ( convertBlock(option)} > {option.label} {option.shortcut && {option.shortcut}} ))} 拷贝副本 复制链接 移动/嵌入到... window.alert("块历史功能开发中,敬请期待")} > 块历史... window.alert("评论功能暂未开放")} > 评论 删除 颜色 表头(行) 表头(列) {block.type === "advancedTodo" && ( <> {[ { label: "设为未开始", status: "todo" as const }, { label: "设为进行中", status: "doing" as const }, { label: "设为已完成", status: "done" as const }, { label: "设为取消", status: "cancelled" as const }, ].map((item) => ( setAdvancedTodoStatus(item.status)} > {item.label} ))} )} {block.type === "progressMeter" && ( <> {block.props.auto ? "切换为手动进度" : "切换为自动进度"} {!block.props.auto && ( 手动设置百分比 )} )} ); }; type CustomSideMenuProps = SideMenuProps & { currentDocumentId: string; }; export const CustomSideMenu = (props: CustomSideMenuProps) => ( ( )} currentDocumentId={props.currentDocumentId} /> )} /> );