"use client"; import { useCallback, useMemo } from "react"; import type { JSX } from "react"; import { SuggestionMenuController, getDefaultReactSlashMenuItems, type DefaultReactSuggestionItem, } from "@blocknote/react"; import { filterSuggestionItems, type BlockNoteEditor } from "@blocknote/core"; import { useRouter } from "next/navigation"; import { FileImage, FilePlus2, FileVideo, ListTree, Music, Paperclip, PilcrowSquare, Play, Spline, Sparkles, SquareCheckBig, Table, } from "lucide-react"; import type { CustomBlockSchema } from "../schema"; import { useImagePicker } from "@/components/media/image-picker-context"; import type { MediaKind, MediaSelection } from "@/types/media"; import { createOnlineTable } from "@/lib/online-table"; type Props = { editor: BlockNoteEditor; currentDocumentId: string; }; const matchKeywords = (query: string, aliases: string[]) => { const lower = query.trim().toLowerCase(); if (!lower) return true; return aliases.some((alias) => alias.toLowerCase().includes(lower)); }; function insertOrUpdateBlockForSlashMenuCompat( editor: BlockNoteEditor, partialBlock: unknown, ) { const cursor = editor.getTextCursorPosition(); const referenceBlock = cursor?.block ?? editor.topLevelBlocks[editor.topLevelBlocks.length - 1]; if (!referenceBlock) { return; } const nextType = (partialBlock as { type?: unknown })?.type; const shouldAppendParagraph = nextType === "mindmap"; const content = Array.isArray(referenceBlock.content) ? referenceBlock.content : []; const text = content .map((node) => (node && typeof (node as { text?: unknown }).text === "string" ? (node as { text: string }).text : "")) .join("") .trim(); const looksLikeSlashCommand = text === "" || text.startsWith("/"); if (referenceBlock.type === "paragraph" && looksLikeSlashCommand) { // 兼容默认 slash menu 行为:将当前段落“就地替换”为目标块类型,避免插入后又被 slash 菜单逻辑清理掉 editor.updateBlock(referenceBlock, partialBlock as never); if (shouldAppendParagraph) { const inserted = editor.insertBlocks( [{ type: "paragraph" } as never], referenceBlock, "after", ); const paragraph = inserted[0]; if (paragraph) { try { editor.setTextCursorPosition(paragraph, "start"); } catch { // ignore } } } return; } if (shouldAppendParagraph) { const inserted = editor.insertBlocks( [partialBlock as never, { type: "paragraph" } as never], referenceBlock, "after", ); const paragraph = inserted[1] ?? inserted[inserted.length - 1]; if (paragraph) { try { editor.setTextCursorPosition(paragraph, "start"); } catch { // ignore } } return; } editor.insertBlocks([partialBlock as never], referenceBlock, "after"); } const GROUP_TRANSLATIONS: Record = { "Headings": "标题", "Subheadings": "副标题", "Basic blocks": "基础块", "Advanced": "高级", "Media": "媒体", "Others": "其他", }; const DEFAULT_ITEM_TRANSLATIONS: Record< string, { title?: string; subtext?: string; group?: string; aliases?: string[] } > = { "Paragraph": { title: "正文", group: "基础块", aliases: ["zw", "paragraph", "body"] }, "Heading 1": { title: "主标题", group: "标题", aliases: ["biaoti", "bt1"] }, "Heading 2": { title: "大标题", group: "标题", aliases: ["biaoti", "bt2"] }, "Heading 3": { title: "中标题", group: "标题", aliases: ["biaoti", "bt3"] }, "Heading 4": { title: "小标题", group: "标题", aliases: ["biaoti", "bt4"] }, "Heading 5": { title: "极小标题", group: "标题", aliases: ["biaoti", "bt5"] }, "Heading 6": { title: "最小标题", group: "标题", aliases: ["biaoti", "bt6"] }, "Toggle Heading 1": { title: "可折叠主标题", group: "标题", aliases: ["toggle", "zd1"] }, "Toggle Heading 2": { title: "可折叠大标题", group: "标题", aliases: ["toggle", "zd2"] }, "Toggle Heading 3": { title: "可折叠中标题", group: "标题", aliases: ["toggle", "zd3"] }, "Quote": { title: "引用", group: "基础块", aliases: ["quote", "引用"] }, "Toggle List": { title: "折叠列表", group: "基础块", aliases: ["toggle list", "zd"] }, "Numbered List": { title: "数字列表", group: "基础块", aliases: ["ordered", "ol"] }, "Bullet List": { title: "符号列表", group: "基础块", aliases: ["ul", "list"] }, "Check List": { title: "任务列表", group: "基础块", aliases: ["todo", "checkbox"] }, "Code Block": { title: "代码块", group: "基础块", aliases: ["code", "pre"] }, "Table": { title: "表格", group: "高级", aliases: ["table", "biaoge"] }, "Image": { title: "插入图片", group: "媒体", subtext: "上传或引用图片资源", aliases: ["tupian", "image", "tp"] }, "Video": { title: "插入视频", group: "媒体", subtext: "上传或引用视频", aliases: ["shipin", "video", "sp"] }, "Audio": { title: "插入音频", group: "媒体", subtext: "上传或引用音频", aliases: ["yinpin", "audio", "yp"] }, "File": { title: "插入文件", group: "媒体", subtext: "上传附件并生成卡片", aliases: ["wenjian", "file", "wj"] }, "Emoji": { title: "插入表情", group: "其他", aliases: ["emoji", "biaoqing"] }, "Divider": { title: "分割线", group: "基础块", aliases: ["divider", "hr"] }, "Page Break": { title: "分页符", group: "基础块", aliases: ["page", "break"] }, }; const MEDIA_ICONS: Record = { image: , video: , audio: , file: , }; const HEADING_PRESETS = [ { level: 1, title: "主标题", subtext: "适合页面名称/顶层章节", aliases: ["biaoti1", "h1", "level1"], }, { level: 2, title: "大标题", subtext: "用于章节逻辑层", aliases: ["biaoti2", "h2", "level2"], }, { level: 3, title: "中标题", subtext: "用于小节和段落", aliases: ["biaoti3", "h3", "level3"], }, { level: 4, title: "小标题", subtext: "更细的结构说明", aliases: ["biaoti4", "h4", "level4"], }, { level: 5, title: "极小标题", subtext: "适合脚注/补充说明", aliases: ["biaoti5", "h5", "level5"], }, ]; const isHeadingDefaultItem = (item: DefaultReactSuggestionItem) => { const maybeKey = (item as { key?: string }).key ?? ""; if (maybeKey && (maybeKey === "heading" || maybeKey.startsWith("heading_") || maybeKey.startsWith("toggle_heading"))) { return true; } const title = item.title ?? ""; return title.includes("标题"); }; export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => { const defaultItems = useMemo(() => getDefaultReactSlashMenuItems(editor), [editor]); const router = useRouter(); const { openPicker } = useImagePicker(); const getItems = useCallback( async (query: string) => { // 注意:不要把 cursor/referenceBlock 在 getItems 阶段“捕获”后长期复用。 // Slash 菜单打开后,BlockNote 会持续更新光标与块对象;若使用陈旧引用, // 可能出现插入块“瞬间出现又消失/不落库”的现象(尤其是插入自定义块时)。 const createTableItem: DefaultReactSuggestionItem = { title: "在线表格", group: "高级", aliases: ["online table", "bg", "表格"], icon: , onItemClick: async () => { const documentId = currentDocumentId; try { const newTable = await createOnlineTable(documentId); insertOrUpdateBlockForSlashMenuCompat(editor, { type: "onlineTable", props: { tableId: newTable.id, title: newTable.title }, content: [], }); window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId: newTable.id } })); } catch (error) { console.error("Failed to create table:", error); // TODO: 插入错误提示块 } }, }; const createMindmapItem: DefaultReactSuggestionItem = { title: "思维导图", group: "高级", subtext: "插入可编辑导图(带顶栏工具)", aliases: ["mindmap", "swdt", "导图"], icon: , onItemClick: () => { insertOrUpdateBlockForSlashMenuCompat(editor, { type: "mindmap", props: { docId: currentDocumentId }, content: [], }); }, }; const createPageItem: DefaultReactSuggestionItem = { title: "嵌入页面", group: "嵌入", aliases: ["page", "ym", "子页面", "嵌入页面块"], icon: , onItemClick: async () => { const cursor = editor.getTextCursorPosition(); const cursorBlock = cursor?.block as any; const firstText = Array.isArray(cursorBlock?.content) && cursorBlock.content.length > 0 ? (cursorBlock.content[0] as any)?.text : undefined; const response = await fetch("/api/documents/create-child", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ parentId: currentDocumentId, title: typeof firstText === "string" && firstText.trim() ? firstText : "未命名页面", blocks: cursorBlock ? [cursorBlock] : [], }), }); if (!response.ok) return; const { pageId, title } = await response.json(); insertOrUpdateBlockForSlashMenuCompat(editor, { type: "pageReference", props: { pageId, title, asChildPage: true }, content: [], }); router.refresh(); }, }; const headingItems: DefaultReactSuggestionItem[] = HEADING_PRESETS.map((preset) => ({ title: preset.title, group: "标题", subtext: preset.subtext, aliases: preset.aliases, icon: , onItemClick: () => { insertOrUpdateBlockForSlashMenuCompat(editor, { type: "heading", props: { level: preset.level }, content: [], }); }, })); const foldHeading: DefaultReactSuggestionItem = { title: "折叠标题", group: "标题", aliases: ["toggle", "zd", "fold"], icon: , onItemClick: () => { insertOrUpdateBlockForSlashMenuCompat(editor, { type: "heading", props: { level: 2, isToggleable: true }, content: [], }); }, }; const advancedTodo: DefaultReactSuggestionItem = { title: "高级待办", group: "待办", subtext: "四态状态 · Alt 直接取消", aliases: ["gjdblb", "todopro", "gaoji", "todo+"], icon: , onItemClick: () => { insertOrUpdateBlockForSlashMenuCompat(editor, { type: "advancedTodo", props: { status: "todo" }, content: [], }); }, }; const progressMeter: DefaultReactSuggestionItem = { title: "进度条", group: "进度", subtext: "自动读取下方待办完成度", aliases: ["jdt", "progress", "jindu"], icon: , onItemClick: () => { insertOrUpdateBlockForSlashMenuCompat(editor, { type: "progressMeter", props: { percent: 0, auto: true }, content: [], }); }, }; const foldAdvancedTodo: DefaultReactSuggestionItem = { title: "折叠高级待办", group: "待办", aliases: ["zdgjdb", "foldtodo"], icon: , onItemClick: () => { insertOrUpdateBlockForSlashMenuCompat(editor, { type: "advancedTodo", props: { status: "todo" }, content: [], }); }, }; const customItems = [ ...headingItems, foldHeading, createPageItem, createTableItem, createMindmapItem, advancedTodo, foldAdvancedTodo, progressMeter, ].filter((item) => matchKeywords(query, item.aliases ?? [])); const insertMediaSelection = (selection: MediaSelection) => { insertOrUpdateBlockForSlashMenuCompat(editor, { type: "media", props: { fileUrl: selection.fileUrl, thumbnailUrl: selection.thumbnailUrl ?? selection.fileUrl, assetId: selection.assetId, assetType: selection.assetType ?? "image", fileName: selection.fileName ?? "", fileSize: selection.fileSize ?? null, mimeType: selection.mimeType ?? "", ocrStatus: "idle", }, content: [], }); }; const handleMediaPick = (mediaType: MediaKind) => { openPicker({ mediaType, multiple: true, onSelect: (selection) => { insertMediaSelection({ ...selection, assetType: selection.assetType ?? mediaType, }); }, }); }; const localizedDefaults = defaultItems.map((item) => { const translation = DEFAULT_ITEM_TRANSLATIONS[item.title]; const next: DefaultReactSuggestionItem = { ...item }; if (translation?.title) next.title = translation.title; if (translation?.subtext) next.subtext = translation.subtext; if (translation?.aliases) next.aliases = translation.aliases; if (translation?.group) { next.group = translation.group; } else if (item.group && GROUP_TRANSLATIONS[item.group]) { next.group = GROUP_TRANSLATIONS[item.group]; } if (["Image", "Video", "Audio", "File"].includes(item.title)) { const mediaType = item.title.toLowerCase() as MediaKind; next.icon = MEDIA_ICONS[mediaType]; next.group = translation?.group ?? "媒体"; next.subtext = translation?.subtext ?? next.subtext; next.aliases = translation?.aliases ?? next.aliases; next.onItemClick = () => handleMediaPick(mediaType); } return next; }); const sanitizedDefaults = localizedDefaults.filter( (item) => !isHeadingDefaultItem(item) && item.title !== "表格", ); const merged = [...customItems, ...sanitizedDefaults]; return filterSuggestionItems(merged, query); }, [currentDocumentId, defaultItems, editor, openPicker, router], ); return ; }