0.1.07 文件树拖拽与多思维导图
This commit is contained in:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user