0.1.07 文件树拖拽与多思维导图

This commit is contained in:
liaibo
2026-01-08 06:28:14 +08:00
parent 9de2d69c55
commit 9af8b9e489
30 changed files with 817 additions and 410 deletions
@@ -7,7 +7,7 @@ import {
getDefaultReactSlashMenuItems,
type DefaultReactSuggestionItem,
} from "@blocknote/react";
import { filterSuggestionItems, type Block, type BlockNoteEditor } from "@blocknote/core";
import { filterSuggestionItems, type BlockNoteEditor } from "@blocknote/core";
import { useRouter } from "next/navigation";
import {
FileImage,
@@ -39,6 +39,68 @@ const matchKeywords = (query: string, aliases: string[]) => {
return aliases.some((alias) => alias.toLowerCase().includes(lower));
};
function insertOrUpdateBlockForSlashMenuCompat(
editor: BlockNoteEditor<CustomBlockSchema>,
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<string, string> = {
"Headings": "标题",
"Subheadings": "副标题",
@@ -134,9 +196,9 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
const getItems = useCallback(
async (query: string) => {
const cursor = editor.getTextCursorPosition();
const referenceBlock = cursor?.block ?? editor.topLevelBlocks[0];
// 注意:不要把 cursor/referenceBlock 在 getItems 阶段“捕获”后长期复用。
// Slash 菜单打开后,BlockNote 会持续更新光标与块对象;若使用陈旧引用,
// 可能出现插入块“瞬间出现又消失/不落库”的现象(尤其是插入自定义块时)。
const createTableItem: DefaultReactSuggestionItem = {
title: "在线表格",
group: "高级",
@@ -144,19 +206,13 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
icon: <Table className="h-4 w-4 text-[#0f172a]" />,
onItemClick: async () => {
const documentId = currentDocumentId;
try {
const newTable = await createOnlineTable(documentId);
editor.insertBlocks(
[
{
type: "onlineTable",
props: { tableId: newTable.id, title: newTable.title },
},
],
referenceBlock,
"after",
);
insertOrUpdateBlockForSlashMenuCompat(editor, {
type: "onlineTable",
props: { tableId: newTable.id, title: newTable.title },
content: [],
});
} catch (error) {
console.error("Failed to create table:", error);
// TODO: 插入错误提示块
@@ -171,44 +227,11 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
aliases: ["mindmap", "swdt", "导图"],
icon: <Spline className="h-4 w-4 text-[#2563eb]" />,
onItemClick: () => {
// mindmap.json / /api/mindmap/:docId 是“按页面(documentId)维度”存储的,
// 同一页面插入多个导图会共享同一份数据,用户会感知为“重复/镜像”。
// 这里先限制一页只允许一个导图块,避免产生歧义。
const blocks = editor.topLevelBlocks as Block<CustomBlockSchema>[];
let existingMindmapId: string | null = null;
const walk = (target: Block<CustomBlockSchema>[]) => {
target.forEach((b) => {
if (existingMindmapId) return;
if (b.type === "mindmap") {
existingMindmapId = b.id;
return;
}
if (Array.isArray(b.children) && b.children.length > 0) {
walk(b.children as Block<CustomBlockSchema>[]);
}
});
};
walk(blocks);
if (existingMindmapId) {
try {
const el = document.querySelector<HTMLElement>(`[data-id="${existingMindmapId}"]`);
el?.scrollIntoView?.({ behavior: "smooth", block: "center" });
} catch {
// ignore
}
window.alert("当前页面已存在思维导图,暂不支持插入多个。");
return;
}
editor.insertBlocks(
[
{
type: "mindmap",
props: { docId: currentDocumentId },
},
],
referenceBlock,
"after",
);
insertOrUpdateBlockForSlashMenuCompat(editor, {
type: "mindmap",
props: { docId: currentDocumentId },
content: [],
});
},
};
@@ -218,28 +241,29 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
aliases: ["page", "ym", "子页面", "嵌入页面块"],
icon: <FilePlus2 className="h-4 w-4 text-[#2563eb]" />,
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: cursor?.block?.content?.[0]?.text ?? "未命名页面",
blocks: cursor ? [cursor.block] : [],
title: typeof firstText === "string" && firstText.trim() ? firstText : "未命名页面",
blocks: cursorBlock ? [cursorBlock] : [],
}),
});
if (!response.ok) return;
const { pageId, title } = await response.json();
editor.insertBlocks(
[
{
type: "pageReference",
props: { pageId, title },
},
],
referenceBlock,
"after",
);
insertOrUpdateBlockForSlashMenuCompat(editor, {
type: "pageReference",
props: { pageId, title },
content: [],
});
router.refresh();
},
};
@@ -251,16 +275,11 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
aliases: preset.aliases,
icon: <PilcrowSquare className="h-4 w-4 text-[#0f172a]" />,
onItemClick: () => {
editor.insertBlocks(
[
{
type: "heading",
props: { level: preset.level },
},
],
referenceBlock,
"after",
);
insertOrUpdateBlockForSlashMenuCompat(editor, {
type: "heading",
props: { level: preset.level },
content: [],
});
},
}));
@@ -270,16 +289,11 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
aliases: ["toggle", "zd", "fold"],
icon: <ListTree className="h-4 w-4 text-[#0f172a]" />,
onItemClick: () => {
editor.insertBlocks(
[
{
type: "heading",
props: { level: 2, isToggleable: true },
},
],
referenceBlock,
"after",
);
insertOrUpdateBlockForSlashMenuCompat(editor, {
type: "heading",
props: { level: 2, isToggleable: true },
content: [],
});
},
};
@@ -290,16 +304,11 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
aliases: ["gjdblb", "todopro", "gaoji", "todo+"],
icon: <SquareCheckBig className="h-4 w-4 text-[#2563eb]" />,
onItemClick: () => {
editor.insertBlocks(
[
{
type: "advancedTodo",
props: { status: "todo" },
},
],
referenceBlock,
"after",
);
insertOrUpdateBlockForSlashMenuCompat(editor, {
type: "advancedTodo",
props: { status: "todo" },
content: [],
});
},
};
@@ -310,16 +319,11 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
aliases: ["jdt", "progress", "jindu"],
icon: <Sparkles className="h-4 w-4 text-[#f59e0b]" />,
onItemClick: () => {
editor.insertBlocks(
[
{
type: "progressMeter",
props: { percent: 0, auto: true },
},
],
referenceBlock,
"after",
);
insertOrUpdateBlockForSlashMenuCompat(editor, {
type: "progressMeter",
props: { percent: 0, auto: true },
content: [],
});
},
};
@@ -329,16 +333,11 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
aliases: ["zdgjdb", "foldtodo"],
icon: <Play className="h-4 w-4 text-[#9f1239]" />,
onItemClick: () => {
editor.insertBlocks(
[
{
type: "advancedTodo",
props: { status: "todo" },
},
],
referenceBlock,
"after",
);
insertOrUpdateBlockForSlashMenuCompat(editor, {
type: "advancedTodo",
props: { status: "todo" },
content: [],
});
},
};
@@ -354,25 +353,20 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
].filter((item) => matchKeywords(query, item.aliases ?? []));
const insertMediaSelection = (selection: MediaSelection) => {
editor.insertBlocks(
[
{
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",
},
},
],
referenceBlock,
"after",
);
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) => {