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
@@ -145,6 +145,7 @@ const syncProgressMeters = (editorInstance: ReturnType<typeof useCreateBlockNote
progressStats.forEach((stat, progressId) => {
const block = findBlockById(blocks, progressId);
if (!block) return;
if (block.type !== "progressMeter") return;
const weightedDone = stat.done + stat.doing * 0.5;
const percent = stat.total === 0 ? 0 : Math.min(100, Math.round((weightedDone / stat.total) * 100));
const summary = stat.total === 0 ? "暂无条目" : `${stat.done}/${stat.total} 完成`;
@@ -237,17 +238,20 @@ export function BlockNoteEditor({
const debouncedSave = useDebouncedCallback(saveContent, 800);
const previousAssetsRef = useRef<Set<string>>(new Set());
const hadMindmapRef = useRef(false);
const clearMindmapAutosaveCache = useCallback((targetDocumentId: string) => {
const previousMindmapBlockIdsRef = useRef<Set<string>>(new Set());
const clearMindmapAutosaveCache = useCallback((targetDocumentId: string, mindmapId?: string) => {
if (typeof window === "undefined") return;
try {
const prefix = "wolai-mindmap-autosave-";
const targetPrefix = `${prefix}${targetDocumentId}`;
const directKey = mindmapId ? `${targetPrefix}:${mindmapId}` : targetPrefix;
const keys: string[] = [];
for (let i = 0; i < window.localStorage.length; i += 1) {
const k = window.localStorage.key(i);
if (!k) continue;
if (k === targetPrefix || k.startsWith(targetPrefix)) {
if (mindmapId) {
if (k === directKey) keys.push(k);
} else if (k === targetPrefix || k.startsWith(`${targetPrefix}:`)) {
keys.push(k);
}
}
@@ -256,19 +260,20 @@ export function BlockNoteEditor({
// ignore
}
}, []);
const markMindmapDeleting = useCallback((targetDocumentId: string) => {
const markMindmapDeleting = useCallback((targetDocumentId: string, mindmapId?: string) => {
if (typeof window === "undefined") return;
try {
const w = window as unknown as {
__wolaiMindmapDeletingDocIds?: Set<string>;
__wolaiMindmapDeletingKeys?: Set<string>;
};
if (!w.__wolaiMindmapDeletingDocIds) {
w.__wolaiMindmapDeletingDocIds = new Set<string>();
if (!w.__wolaiMindmapDeletingKeys) {
w.__wolaiMindmapDeletingKeys = new Set<string>();
}
w.__wolaiMindmapDeletingDocIds.add(targetDocumentId);
const key = mindmapId ? `${targetDocumentId}:${mindmapId}` : targetDocumentId;
w.__wolaiMindmapDeletingKeys.add(key);
window.setTimeout(() => {
try {
w.__wolaiMindmapDeletingDocIds?.delete(targetDocumentId);
w.__wolaiMindmapDeletingKeys?.delete(key);
} catch {
// ignore
}
@@ -280,7 +285,7 @@ export function BlockNoteEditor({
const collectAssets = useCallback((blocks: Block<CustomBlockSchema>[]) => {
const assetIds = new Set<string>();
let hasMindmap = false;
const mindmapBlockIds = new Set<string>();
const walk = (target: Block<CustomBlockSchema>[]) => {
target.forEach((b) => {
if (b.type === "media") {
@@ -288,7 +293,7 @@ export function BlockNoteEditor({
if (id) assetIds.add(id);
}
if (b.type === "mindmap") {
hasMindmap = true;
mindmapBlockIds.add(b.id);
}
if (Array.isArray(b.children) && b.children.length > 0) {
walk(b.children as Block<CustomBlockSchema>[]);
@@ -296,7 +301,7 @@ export function BlockNoteEditor({
});
};
walk(blocks);
return { assetIds, hasMindmap };
return { assetIds, mindmapBlockIds };
}, []);
const deleteAssets = useCallback(
@@ -319,17 +324,28 @@ export function BlockNoteEditor({
[documentId],
);
const deleteMindmap = useCallback(async () => {
const resp = await fetch(`/api/mindmap/${documentId}`, { method: "DELETE" });
if (!resp.ok) {
console.error("删除思维导图失败", await resp.text());
return;
}
// 重要:删除思维导图文件后也要清理本地 autosave,否则用户再次插入导图会从旧缓存恢复,表现为“删除不干净/重复出现”
markMindmapDeleting(documentId);
clearMindmapAutosaveCache(documentId);
emitAssetsChanged(documentId);
}, [clearMindmapAutosaveCache, documentId, markMindmapDeleting]);
const deleteMindmapAssets = useCallback(
async (mindmapIds: string[]) => {
if (mindmapIds.length === 0) return;
await Promise.all(
mindmapIds.map(async (mindmapId) => {
try {
const resp = await fetch(`/api/mindmap/${documentId}/${mindmapId}`, { method: "DELETE" });
if (!resp.ok) {
console.error("删除思维导图失败", mindmapId, await resp.text().catch(() => ""));
}
} catch (error) {
console.error("删除思维导图失败", mindmapId, error);
} finally {
markMindmapDeleting(documentId, mindmapId);
clearMindmapAutosaveCache(documentId, mindmapId);
}
}),
);
emitAssetsChanged(documentId, undefined, undefined, false, mindmapIds);
},
[clearMindmapAutosaveCache, documentId, markMindmapDeleting],
);
// 监听侧边栏删除事件,主动移除编辑区遗留块
useEffect(() => {
@@ -338,24 +354,42 @@ export function BlockNoteEditor({
docId?: string;
assetIds?: string[];
mindmapDeleted?: boolean;
mindmapAssetIds?: string[];
};
if (!detail || detail.docId !== documentId) return;
const assetIds = detail.assetIds ?? [];
const mindmapDeleted = Boolean(detail.mindmapDeleted);
if (assetIds.length === 0 && !mindmapDeleted) return;
if (mindmapDeleted) {
const mindmapAssetIds = Array.isArray(detail.mindmapAssetIds)
? (detail.mindmapAssetIds.filter((id) => typeof id === "string") as string[])
: [];
if (assetIds.length === 0 && !mindmapDeleted && mindmapAssetIds.length === 0) return;
if (mindmapDeleted || mindmapAssetIds.length > 0) {
// 标记“删除中”,避免 MindmapBlock 卸载清理里把 autosave 写回导致“复活”
markMindmapDeleting(documentId);
if (mindmapAssetIds.length > 0) {
mindmapAssetIds.forEach((id) => markMindmapDeleting(documentId, id));
} else {
markMindmapDeleting(documentId);
}
// 双保险:等卸载完成后再清一次 autosave,避免删除后再次插入出现旧内容(重复)
window.setTimeout(() => clearMindmapAutosaveCache(documentId), 0);
window.setTimeout(() => {
if (mindmapAssetIds.length > 0) {
mindmapAssetIds.forEach((id) => clearMindmapAutosaveCache(documentId, id));
} else {
clearMindmapAutosaveCache(documentId);
}
}, 0);
}
const blocks = editor?.topLevelBlocks as Block<CustomBlockSchema>[] | undefined;
if (!blocks || blocks.length === 0 || !editor) return;
const toRemove: string[] = [];
const walk = (target: Block<CustomBlockSchema>[]) => {
target.forEach((b) => {
if (mindmapDeleted && b.type === "mindmap") {
toRemove.push(b.id);
if (b.type === "mindmap") {
if (mindmapDeleted) {
toRemove.push(b.id);
} else if (mindmapAssetIds.length > 0 && mindmapAssetIds.includes(b.id)) {
toRemove.push(b.id);
}
}
if (assetIds.length > 0 && b.type === "media") {
const id = (b.props as { assetId?: string })?.assetId;
@@ -370,7 +404,11 @@ export function BlockNoteEditor({
};
walk(blocks);
if (toRemove.length > 0) {
editor.removeBlocks(toRemove);
try {
editor.removeBlocks(toRemove);
} catch {
// ignore:可能已被其它链路先行删除(例如块菜单/工具栏触发的 removeBlocks
}
}
};
window.addEventListener(ASSETS_CHANGED_EVENT, handler);
@@ -397,28 +435,30 @@ export function BlockNoteEditor({
onSnapshot?.({ blocks: blocks as Json, stats });
// 检测块删除 -> 同步删除附件/思维导图并刷新侧边栏
const { assetIds, hasMindmap } = collectAssets(typedBlocks);
const { assetIds, mindmapBlockIds } = collectAssets(typedBlocks);
const prevAssets = previousAssetsRef.current;
const removedAssets = [...prevAssets].filter((id) => !assetIds.has(id));
if (removedAssets.length > 0) {
void deleteAssets(removedAssets);
}
previousAssetsRef.current = assetIds;
if (hadMindmapRef.current && !hasMindmap) {
void deleteMindmap();
const prevMindmaps = previousMindmapBlockIdsRef.current;
const removedMindmaps = [...prevMindmaps].filter((id) => !mindmapBlockIds.has(id));
if (removedMindmaps.length > 0) {
void deleteMindmapAssets(removedMindmaps);
}
hadMindmapRef.current = hasMindmap;
previousMindmapBlockIdsRef.current = mindmapBlockIds;
};
runSync();
const unsubscribe = editor.onEditorContentChange(runSync);
const unsubscribe = editor.onEditorContentChange(runSync) as unknown as
| undefined
| (() => void);
return () => {
disposed = true;
if (typeof unsubscribe === "function") {
unsubscribe();
}
unsubscribe?.();
};
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmap, editor, onSnapshot, onStatsChange]);
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmapAssets, editor, onSnapshot, onStatsChange]);
const jumpToHeading = useCallback((headingId: string) => {
const target = document.querySelector<HTMLElement>(`[data-id="${headingId}"]`);
@@ -453,15 +493,15 @@ const trimTrailingCharacter = (
if (!editorInstance) {
return;
}
const content = Array.isArray(block.content) ? [...block.content] : [];
const content = (Array.isArray(block.content) ? [...block.content] : []) as any[];
for (let index = content.length - 1; index >= 0; index -= 1) {
const node = content[index] as { text?: string };
const node = content[index] as any;
if (typeof node?.text === "string" && node.text.endsWith(char)) {
const nextText = node.text.slice(0, -1);
if (nextText.length === 0) {
content.splice(index, 1);
} else {
content[index] = { ...node, text: nextText };
content[index] = { ...(node as any), text: nextText } as any;
}
editorInstance.updateBlock(block, { content });
break;
@@ -482,7 +522,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
const accumulate = (targetBlocks: Block<CustomBlockSchema>[]) => {
targetBlocks.forEach((block) => {
if (Array.isArray(block.content)) {
block.content.forEach((node: { text?: string }) => {
(block.content as any[]).forEach((node: any) => {
if (typeof node.text === "string") {
const text = node.text;
characterCount += text.length;
@@ -537,7 +577,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
assetId: asset.id,
assetType: asset.asset_type ?? "image",
fileName: asset.file_name ?? "",
fileSize: asset.file_size ?? null,
fileSize: asset.file_size ?? undefined,
mimeType: asset.mime_type ?? "",
ocrStatus: asset.ocr_status ?? "idle",
documentId,
@@ -598,7 +638,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
href: buildDocumentPath(target.id),
content: text,
},
{ type: "text", text: " " },
" ",
]);
return { blockId };
},
@@ -729,6 +769,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
<BlockNoteView
editor={editor}
theme="light"
slashMenu={false}
data-heading-numbering={pageOptions.showHeadingNumbers ? "true" : "false"}
editable={!pageOptions.protectEditing}
className={blocknoteClass}
@@ -30,7 +30,7 @@ import type { CustomBlockSchema } from "../schema";
type MediaAlign = "left" | "center" | "right";
type MediaBlockRenderProps = {
block: Block<CustomBlockSchema>;
block: Block<CustomBlockSchema> & { props: any };
editor: BlockNoteEditor<CustomBlockSchema>;
};
@@ -75,7 +75,7 @@ const formatFileSize = (size?: number | null) => {
return `${current.toFixed(current >= 10 ? 0 : 1)} ${units[idx]}`;
};
const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
const MediaBlockContent = ({ block, editor }: any) => {
const { openPicker } = useImagePicker();
const [busy, setBusy] = useState(false);
const fileUrl = block.props.fileUrl as string;
@@ -334,16 +334,17 @@ const MindmapBlockView = ({
: ""),
[block.props.docId],
);
const mindmapId = block.id;
useEffect(() => {
hasLocalEditsRef.current = false;
applyingRemoteRef.current = false;
}, [docId]);
const autosaveKey = useMemo(
() => `${STORAGE_PREFIX}${docId || block.id}`,
[block.id, docId],
);
const autosaveKey = useMemo(() => {
if (docId) return `${STORAGE_PREFIX}${docId}:${mindmapId}`;
return `${STORAGE_PREFIX}${mindmapId}`;
}, [docId, mindmapId]);
const initialDataRef = useRef<unknown>(null);
if (initialDataRef.current === null) {
const cached = typeof window !== "undefined" ? window.localStorage.getItem(autosaveKey) : null;
@@ -364,7 +365,8 @@ const MindmapBlockView = ({
if (!docId) return;
(async () => {
try {
const resp = await fetch(`/api/mindmap/${docId}`);
// 多导图:按 docId + mindmapId 拉取
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`);
if (!resp.ok) return;
const payload = await resp.json().catch(() => null);
const data = payload?.data;
@@ -390,7 +392,7 @@ const MindmapBlockView = ({
return () => {
cancelled = true;
};
}, [docId, mindmap]);
}, [docId, mindmap, mindmapId]);
// 记录“最近一次指针交互是否发生在思维导图块内”,用于键盘快捷键作用域
useEffect(() => {
@@ -617,26 +619,27 @@ const MindmapBlockView = ({
editor.updateBlock(block, { props: { ...block.props, data: safe } });
if (docId) {
// 同步到本地文件 + Supabase(弱依赖)
fetch(`/api/mindmap/${docId}`, {
fetch(`/api/mindmap/${docId}/${mindmapId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: safe }),
})
.then((resp) => {
if (resp.ok) {
const fileName = `mindmap-${mindmapId}.json`;
emitAssetsChanged(docId, {
id: `mindmap-${docId}`,
id: mindmapId,
document_id: docId,
asset_type: "mindmap",
file_name: "mindmap.json",
file_url: `/documents/${docId}`,
file_name: fileName,
file_url: `/documents/${docId}/${fileName}`,
});
}
})
.catch((err) => console.warn("思维导图同步失败", err));
}
},
[autosaveKey, block, docId, editor],
[autosaveKey, block, docId, editor, mindmapId],
);
const debouncedPersist = useDebouncedCallback((data: unknown) => {
@@ -652,7 +655,7 @@ const MindmapBlockView = ({
);
(async () => {
try {
const resp = await fetch(`/api/mindmap/${docId}`, {
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data }),
@@ -668,16 +671,17 @@ const MindmapBlockView = ({
console.warn("初次创建思维导图文件失败", err);
} finally {
// 即便远端失败,也通知侧边栏刷新,保证本地文件树及时更新
const fileName = `mindmap-${mindmapId}.json`;
emitAssetsChanged(docId, {
id: `mindmap-${docId}`,
id: mindmapId,
document_id: docId,
asset_type: "mindmap",
file_name: "mindmap.json",
file_url: `/documents/${docId}`,
file_name: fileName,
file_url: `/documents/${docId}/${fileName}`,
});
}
})();
}, [docId, mindmap, initialDataRef]);
}, [docId, mindmap, mindmapId, initialDataRef]);
// 初始选中根节点,后续不强制抢焦点,允许用户自由选择
useEffect(() => {
@@ -691,17 +695,18 @@ const MindmapBlockView = ({
}
}, [mindmap, activeNodes.length]);
// mindmap 实例一旦就绪,立刻向侧边栏广播,确保文件树即时显示 mindmap.json
// mindmap 实例一旦就绪,立刻向侧边栏广播,确保文件树即时显示 mindmap-<id>.json
useEffect(() => {
if (!docId || !mindmap) return;
const fileName = `mindmap-${mindmapId}.json`;
emitAssetsChanged(docId, {
id: `mindmap-${docId}`,
id: mindmapId,
document_id: docId,
asset_type: "mindmap",
file_name: "mindmap.json",
file_url: `/documents/${docId}`,
file_name: fileName,
file_url: `/documents/${docId}/${fileName}`,
});
}, [docId, mindmap]);
}, [docId, mindmap, mindmapId]);
useEffect(() => {
let destroyed = false;
@@ -982,6 +987,11 @@ const MindmapBlockView = ({
if (typeof window !== "undefined") {
// 便于开发阶段在控制台直接调试实例
window.__mindmapInstance = instance;
const w = window as unknown as {
__mindmapInstancesById?: Record<string, MindMapInstance>;
};
if (!w.__mindmapInstancesById) w.__mindmapInstancesById = {};
w.__mindmapInstancesById[mindmapId] = instance;
}
setMindmap(instance);
@@ -1087,9 +1097,13 @@ const MindmapBlockView = ({
if (!docId || typeof window === "undefined") return false;
try {
const w = window as unknown as {
__wolaiMindmapDeletingDocIds?: Set<string>;
__wolaiMindmapDeletingKeys?: Set<string>;
};
return Boolean(w.__wolaiMindmapDeletingDocIds?.has(docId));
const key = `${docId}:${mindmapId}`;
return Boolean(
w.__wolaiMindmapDeletingKeys?.has(docId) ||
w.__wolaiMindmapDeletingKeys?.has(key),
);
} catch {
return false;
}
@@ -1111,7 +1125,7 @@ const MindmapBlockView = ({
// ignore
}
if (docId) {
fetch(`/api/mindmap/${docId}`, {
fetch(`/api/mindmap/${docId}/${mindmapId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: safe }),
@@ -1129,6 +1143,14 @@ const MindmapBlockView = ({
if (window.__mindmapInstance === createdInstance) {
window.__mindmapInstance = null;
}
try {
const w = window as unknown as { __mindmapInstancesById?: Record<string, MindMapInstance> };
if (w.__mindmapInstancesById && w.__mindmapInstancesById[mindmapId] === createdInstance) {
delete w.__mindmapInstancesById[mindmapId];
}
} catch {
// ignore
}
}
setMindmap(null);
mindmapRef.current = null;
@@ -1628,7 +1650,7 @@ const MindmapBlockView = ({
const confirmed = window.confirm("确认删除当前思维导图?此操作会移除文件并清空侧边栏记录。");
if (!confirmed) return;
deletingRef.current = true;
const resp = await fetch(`/api/mindmap/${docId}`, { method: "DELETE" });
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, { method: "DELETE" });
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除思维导图失败");
@@ -1640,9 +1662,14 @@ const MindmapBlockView = ({
} catch {
// ignore
}
emitAssetsChanged(docId, undefined, undefined, true);
editor.removeBlocks([block.id]);
}, [autosaveKey, block.id, docId, editor]);
emitAssetsChanged(docId, undefined, undefined, false, [mindmapId]);
// 同时会触发全局删除监听(ASSETS_CHANGED_EVENT)进行块移除,这里做 try/catch 避免重复删除报错
try {
editor.removeBlocks([block.id]);
} catch {
// ignore
}
}, [autosaveKey, block.id, docId, editor, mindmapId]);
const toolbarProps = {
canBack,
@@ -1928,6 +1955,7 @@ const MindmapBlockView = ({
ref={containerRef}
className="h-full w-full"
data-testid="mindmap-canvas"
data-mindmap-id={mindmapId}
contentEditable={false}
/>
@@ -2011,6 +2039,7 @@ const MindmapBlockView = ({
ref={containerRef}
className="h-full w-full"
data-testid="mindmap-canvas"
data-mindmap-id={mindmapId}
contentEditable={false}
/>
@@ -24,22 +24,15 @@ import {
} from "./mindmapOptions";
import iconConfig from "./mindmapIconConfig";
import imageConfig from "./mindmapImageConfig";
import { sidebarTitles, type SidebarPanel } from "./mindmapSidebarConfig";
import { sidebarTitles, type SidebarPanel } from "./mindmapSidebarConfig";
import type { MindMapNode } from "./mindmapTypes";
// 避免 SSR 阶段触发浏览器依赖,改为运行时动态引入
// @ts-expect-error 第三方库缺少类型定义
const loadIconModules = async () => {
const { nodeIconList } = await import("simple-mind-map/src/svg/icons.js");
const { mergerIconList } = await import("simple-mind-map/src/utils/index.js");
return { nodeIconList, mergerIconList };
};
type MindMapNode = {
getStyle: (prop: string, checkRoot?: boolean) => any;
setStyle: (prop: string, value: any) => void;
setIcon: (icons: string[]) => void;
getData: (key: string) => any;
};
type SidebarProps = {
mindmap: any;
activeNodes: MindMapNode[];
@@ -118,12 +111,12 @@ const StylePanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
}
}, [activeNodes]);
const updateStyle = (prop: string, value: any) => {
setStyle((prev) => ({ ...prev, [prop]: value }));
activeNodes.forEach((node) => {
node.setStyle(prop, value);
});
};
const updateStyle = (prop: string, value: any) => {
setStyle((prev) => ({ ...prev, [prop]: value }));
activeNodes.forEach((node) => {
node.setStyle?.(prop, value);
});
};
if (activeNodes.length === 0) {
return (
@@ -446,26 +439,27 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
const addIcon = (type: string, name: string) => {
const key = `${type}_${name}`;
activeNodes.forEach((node) => {
const icons = node.getData("icon") || [];
const newIcons = icons.filter((i: string) => !i.startsWith(`${type}_`));
const rawIcons = node.getData("icon");
const icons = Array.isArray(rawIcons) ? (rawIcons as string[]) : [];
const newIcons = icons.filter((i) => !i.startsWith(`${type}_`));
newIcons.push(key);
node.setIcon(newIcons);
node.setIcon?.(newIcons);
});
};
const removeIcon = (type: string) => {
activeNodes.forEach((node) => {
const icons = node.getData("icon") || [];
const newIcons = icons.filter((i: string) => !i.startsWith(`${type}_`));
node.setIcon(newIcons);
const rawIcons = node.getData("icon");
const icons = Array.isArray(rawIcons) ? (rawIcons as string[]) : [];
const newIcons = icons.filter((i) => !i.startsWith(`${type}_`));
node.setIcon?.(newIcons);
});
};
const setSticker = (img: { url: string; width?: number; height?: number }) => {
activeNodes.forEach((node) => {
// simple-mind-map 支持 setImage 接收对象,包含 url/width/height
// @ts-expect-error 第三方库无类型
node.setImage({
node.setImage?.({
url: img.url,
width: img.width || 100,
height: img.height || 100,
@@ -476,8 +470,7 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
const clearSticker = () => {
activeNodes.forEach((node) => {
// 传入 null 以清除贴纸
// @ts-expect-error 第三方库无类型
node.setImage(null);
node.setImage?.(null);
});
};
@@ -511,7 +504,7 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
</button>
</div>
<div className="flex flex-wrap gap-2">
{group.list.map((item) => (
{group.list.map((item: any) => (
<button
key={`${group.type}-${item.name}`}
onClick={() => addIcon(group.type, item.name)}
@@ -521,7 +514,6 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
typeof item.icon === "string" && item.icon.trim().startsWith("<svg") ? (
<span
className="inline-flex h-6 w-6 items-center justify-center overflow-hidden"
// @ts-expect-error: dangerouslySetInnerHTML 用于复用官方 SVG 片段
dangerouslySetInnerHTML={{ __html: item.icon }}
/>
) : (
@@ -599,16 +591,13 @@ const OutlinePanel = ({ mindmap }: { mindmap: any }) => {
if (!node || !r) return;
// 仅通过已有方法触发激活,避免直接改 renderer 属性
if (typeof r.clearActiveNodeList === "function") {
// @ts-expect-error 第三方库缺少类型
r.clearActiveNodeList();
}
if (typeof r.addNodeToActiveList === "function") {
// @ts-expect-error 第三方库缺少类型
r.addNodeToActiveList(node, true);
} else {
// 兜底:仍保留最小副作用写入
try {
// @ts-expect-error 第三方库 renderer 缺类型
r?.setActiveNode?.(node);
} catch {
// 最后兜底:不再直接改引用,避免 lint 报错
@@ -1215,7 +1204,6 @@ const AiPanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapN
try {
const r = mindmap?.renderer;
if (r && Array.isArray((r as any).renderCallbackList)) {
// @ts-expect-error 第三方库内部字段
r.renderCallbackList = (r.renderCallbackList as any[]).filter(
(fn) => typeof fn === "function",
);
@@ -46,7 +46,7 @@ type ToolbarProps = {
onExportMd: () => void;
onExportTxt: () => void;
onExportXmind: () => void;
fileInputRef: React.RefObject<HTMLInputElement>;
fileInputRef: React.RefObject<HTMLInputElement | null>;
};
const ToolbarButton = ({
@@ -42,10 +42,7 @@ const handleMapping: Record<
const OnlineTableBlockComponent = ({
block,
editor,
}: {
block: Block<CustomBlockSchema, "onlineTable">;
editor: BlockNoteEditor<CustomBlockSchema>;
}) => {
}: any) => {
const { tableId } = block.props;
const openTableFullScreen = useEditorBridgeStore((state) => state.bridge?.openTableFullScreen);
const storedWidth = typeof block.props.width === "number" ? block.props.width : undefined;
@@ -177,7 +177,9 @@ export const parseMindManagerMmapFile = async (file: File): Promise<MindMapData>
const entry = zip.file(zipPath) ?? zip.file(`/${zipPath}`);
if (!entry) return null;
const bytes = await entry.async("uint8array");
const blob = new Blob([bytes], { type: mime });
const rawBuffer = bytes.buffer as ArrayBuffer;
const sliced = rawBuffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
const blob = new Blob([sliced], { type: mime });
const dataUrl = await readBlobAsDataUrl(blob);
const size = await getImageSizeFromBlob(blob);
const width = size?.width ?? 0;
@@ -230,4 +232,3 @@ export const parseMindManagerMmapFile = async (file: File): Promise<MindMapData>
const tree = await walkTopic(rootTopicEl, true);
return compactTree(tree, true);
};
@@ -109,14 +109,19 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
return;
}
if (block.type === "mindmap") {
const resp = await fetch(`/api/mindmap/${currentDocumentId}`, { method: "DELETE" });
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);
editor.removeBlocks([block.id]);
emitAssetsChanged(currentDocumentId, undefined, undefined, false, [block.id]);
// 侧边栏/全局删除监听也会尝试移除对应块,这里做 try/catch 避免重复删除导致报错
try {
editor.removeBlocks([block.id]);
} catch {
// ignore
}
return;
}
editor.removeBlocks([block.id]);
@@ -199,7 +204,7 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
{ label: "数字列表", type: "numberedListItem", shortcut: "Ctrl+Shift+7" },
{ label: "折叠列表", type: "toggleListItem", shortcut: "Ctrl+Shift+8" },
{ label: "折叠标题", type: "heading", props: { level: 2, isToggleable: true } },
{ label: "引述文字", type: "blockquote" },
{ label: "引述文字", type: "quote" },
{ label: "代码片段", type: "codeBlock" },
],
[turnToPage],
@@ -212,10 +217,10 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
return;
}
if (!option.type) return;
editor.updateBlock(block, {
type: option.type,
props: option.props ?? {},
});
editor.updateBlock(block as any, {
type: option.type as any,
props: (option.props ?? {}) as any,
} as any);
},
[block, editor],
);
@@ -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) => {