双向删除同步

This commit is contained in:
liaibo
2026-01-02 07:25:50 +08:00
parent 1db64c1c55
commit b1288487af
48 changed files with 2406 additions and 8841 deletions
@@ -4,7 +4,7 @@ import "@blocknote/core/style.css";
import "@blocknote/react/style.css";
import "@blocknote/mantine/style.css";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BlockNoteView } from "@blocknote/mantine";
import {
SideMenuController,
@@ -22,11 +22,12 @@ import { customBlockSchema, type CustomBlockSchema } from "./schema";
import { CustomSideMenu } from "./menus/CustomSideMenu";
import { CustomSlashMenu } from "./menus/CustomSlashMenu";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { DocumentToc, type TocEntry } from "@/components/editor/document-toc";
import { DocumentToc, type TocEntry } from "@/components/editor/document-toc";
import { useSearchPaletteStore } from "@/store/search-palette";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import type { ReferenceTarget } from "@/types/search";
import FullScreenTableEditor from "@/components/online-table/FullScreenTableEditor";
import { ASSETS_CHANGED_EVENT, emitAssetsChanged } from "@/lib/events";
interface BlockNoteEditorProps {
documentId: string;
@@ -235,6 +236,98 @@ export function BlockNoteEditor({
);
const debouncedSave = useDebouncedCallback(saveContent, 800);
const previousAssetsRef = useRef<Set<string>>(new Set());
const hadMindmapRef = useRef(false);
const collectAssets = useCallback((blocks: Block<CustomBlockSchema>[]) => {
const assetIds = new Set<string>();
let hasMindmap = false;
const walk = (target: Block<CustomBlockSchema>[]) => {
target.forEach((b) => {
if (b.type === "media") {
const id = (b.props as { assetId?: string })?.assetId;
if (id) assetIds.add(id);
}
if (b.type === "mindmap") {
hasMindmap = true;
}
if (Array.isArray(b.children) && b.children.length > 0) {
walk(b.children as Block<CustomBlockSchema>[]);
}
});
};
walk(blocks);
return { assetIds, hasMindmap };
}, []);
const deleteAssets = useCallback(
async (assetIds: string[]) => {
if (assetIds.length === 0) return;
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", assetIds }),
});
if (!resp.ok) {
// 如果后端返回未找到,说明已被其他端删除,忽略即可
if (resp.status !== 404) {
console.error("删除附件失败", await resp.text());
}
return;
}
emitAssetsChanged(documentId);
},
[documentId],
);
const deleteMindmap = useCallback(async () => {
const resp = await fetch(`/api/mindmap/${documentId}`, { method: "DELETE" });
if (!resp.ok) {
console.error("删除思维导图失败", await resp.text());
return;
}
emitAssetsChanged(documentId);
}, [documentId]);
// 监听侧边栏删除事件,主动移除编辑区遗留块
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent)?.detail as {
docId?: string;
assetIds?: string[];
mindmapDeleted?: boolean;
};
if (!detail || detail.docId !== documentId) return;
const assetIds = detail.assetIds ?? [];
const mindmapDeleted = Boolean(detail.mindmapDeleted);
if (assetIds.length === 0 && !mindmapDeleted) return;
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 (assetIds.length > 0 && b.type === "media") {
const id = (b.props as { assetId?: string })?.assetId;
if (id && assetIds.includes(id)) {
toRemove.push(b.id);
}
}
if (Array.isArray(b.children) && b.children.length > 0) {
walk(b.children as Block<CustomBlockSchema>[]);
}
});
};
walk(blocks);
if (toRemove.length > 0) {
editor.removeBlocks(toRemove);
}
};
window.addEventListener(ASSETS_CHANGED_EVENT, handler);
return () => window.removeEventListener(ASSETS_CHANGED_EVENT, handler);
}, [documentId, editor]);
useEffect(() => {
if (!editor) {
@@ -254,6 +347,19 @@ export function BlockNoteEditor({
const stats = computeDocumentStats(typedBlocks);
onStatsChange?.(stats);
onSnapshot?.({ blocks: blocks as Json, stats });
// 检测块删除 -> 同步删除附件/思维导图并刷新侧边栏
const { assetIds, hasMindmap } = 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();
}
hadMindmapRef.current = hasMindmap;
};
runSync();
@@ -264,7 +370,7 @@ export function BlockNoteEditor({
unsubscribe();
}
};
}, [editor, debouncedSave, onSnapshot, onStatsChange]);
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmap, editor, onSnapshot, onStatsChange]);
const jumpToHeading = useCallback((headingId: string) => {
const target = document.querySelector<HTMLElement>(`[data-id="${headingId}"]`);
@@ -386,6 +492,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
fileSize: asset.file_size ?? null,
mimeType: asset.mime_type ?? "",
ocrStatus: asset.ocr_status ?? "idle",
documentId,
},
},
],
@@ -393,7 +500,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
"after",
);
},
[editor],
[documentId, editor],
);
const uploadClipboardMedia = useCallback(
@@ -417,6 +524,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
if (!payload.asset) {
throw new Error("上传返回数据缺失");
}
emitAssetsChanged(documentId, payload.asset);
return payload.asset;
},
[documentId, workspaceId],
@@ -5,16 +5,18 @@ import {
useRef,
useState,
useMemo,
useCallback,
type JSX,
type MouseEvent as ReactMouseEvent,
} from "react";
import { createReactBlockSpec } from "@blocknote/react";
import type { Block, BlockNoteEditor } from "@blocknote/core";
import { Download, Image as ImageIcon, Link as LinkIcon, MoreHorizontal, Paperclip, RefreshCcw, Type } from "lucide-react";
import { Download, Image as ImageIcon, Link as LinkIcon, MoreHorizontal, Paperclip, RefreshCcw, Trash, Type } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useImagePicker } from "@/components/media/image-picker-context";
import type { MediaKind } from "@/types/media";
import { emitAssetsChanged } from "@/lib/events";
import {
DropdownMenu,
DropdownMenuContent,
@@ -73,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 }: MediaBlockRenderProps) => {
const { openPicker } = useImagePicker();
const [busy, setBusy] = useState(false);
const fileUrl = block.props.fileUrl as string;
@@ -96,6 +98,16 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
const [captionEditing, setCaptionEditing] = useState(false);
const shouldShowCaption = captionEditing || Boolean(block.props.caption);
const officeBase = process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL;
const resolveDocumentId = useCallback(() => {
if (typeof window !== "undefined") {
const [, tail] = window.location.pathname.split("/documents/");
if (tail) {
const id = tail.split(/[/?#]/)[0];
if (id) return id;
}
}
return (block.props as { documentId?: string })?.documentId || "";
}, [block.props]);
const extension = useMemo(() => {
const name = (block.props.fileName || deriveFileName(fileUrl)).toLowerCase();
const match = /\.([a-z0-9]+)$/.exec(name);
@@ -121,9 +133,10 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
assetId: selection.assetId,
assetType: selection.assetType ?? rawAssetType,
fileName: selection.fileName ?? block.props.fileName ?? "",
fileSize: selection.fileSize ?? block.props.fileSize ?? null,
fileSize: selection.fileSize ?? block.props.fileSize ?? null,
mimeType: selection.mimeType ?? block.props.mimeType ?? "",
ocrStatus: "idle",
documentId: resolveDocumentId(),
},
});
},
@@ -255,6 +268,27 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
anchor.click();
};
const handleDeleteAsset = async () => {
const assetId = (block.props as { assetId?: string })?.assetId;
if (!assetId) {
editor.removeBlocks([block.id]);
return;
}
const docId = resolveDocumentId();
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;
}
editor.removeBlocks([block.id]);
emitAssetsChanged(docId);
};
const triggerOcr = async () => {
if (!block.props.assetId) {
window.alert("请先上传图片后再执行 OCR");
@@ -395,6 +429,12 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
icon: <Download className="h-4 w-4" />,
onClick: downloadAsset,
},
{
key: "delete",
label: `删除${typeLabel}`,
icon: <Trash className="h-4 w-4" />,
onClick: handleDeleteAsset,
},
].filter((action): action is QuickAction => Boolean(action));
const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接";
@@ -504,6 +544,7 @@ export const mediaBlock = createReactBlockSpec(
mimeType: { default: "", type: "string" },
width: { default: 0, type: "number" },
ocrStatus: { default: "idle", type: "string" },
documentId: { default: "", type: "string" },
},
content: "none",
},
@@ -17,9 +17,15 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight } from "lucide-react";
import iconConfig from "./mindmapIconConfig";
import { nodeIconList } from "simple-mind-map/src/svg/icons.js";
import { emitAssetsChanged } from "@/lib/events";
// 避免 SSR/Node 环境提前评估 simple-mind-map 内部依赖 document
// @ts-expect-error 第三方库缺少类型定义
import { mergerIconList } from "simple-mind-map/src/utils/index.js";
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 };
};
// 安全获取图片尺寸
const getImageSizeSafe = (url: string): Promise<{ width: number; height: number } | null> =>
@@ -54,7 +60,19 @@ type MindMapInstance = {
setLayout: (layout: string) => void;
};
const defaultMindmapData = {
type MindMapNode = {
getStyle?: (key: string, inherit?: boolean) => unknown;
getData?: (key: string) => unknown;
nodeData?: { data?: Record<string, unknown> };
data?: Record<string, unknown>;
};
type MindMapData = {
data: Record<string, unknown>;
children?: unknown[];
};
export const defaultMindmapData = {
data: { text: "中心主题" },
children: [],
};
@@ -77,6 +95,39 @@ const createSimplePrompt = (title: string, placeholder = "") => {
return value.trim();
};
// 修补 svg.js rbox 在节点未挂载时抛出的异常
const patchSvgRbox = async () => {
// 仅在浏览器环境生效
if (typeof window === "undefined") return;
const svgModule = await import("@svgdotjs/svg.js");
const candidates = [(svgModule as any).Element, (svgModule as any).G];
candidates.forEach((Ctor) => {
if (!Ctor?.prototype) return;
if (Ctor.prototype.__wolaiRboxPatched) return;
const original = Ctor.prototype.rbox;
if (typeof original !== "function") return;
// @ts-expect-error 动态扩展第三方原型
Ctor.prototype.rbox = function patchedRbox(ref?: unknown) {
try {
return original.call(this, ref);
} catch (error) {
console.warn("rbox 失败,返回空边界框以避免崩溃", error);
return {
x: 0,
y: 0,
width: 0,
height: 0,
x2: 0,
y2: 0,
cx: 0,
cy: 0,
};
}
};
Ctor.prototype.__wolaiRboxPatched = true;
});
};
const applyToActiveNodes = (
mindmap: MindMapInstance | null,
handler: (node: unknown) => void,
@@ -134,7 +185,6 @@ const MindmapBlockView = ({
const [canBack, setCanBack] = useState(false);
const [canForward, setCanForward] = useState(false);
const [activeNodes, setActiveNodes] = useState<unknown[]>([]);
const activeCount = activeNodes.length;
const [painterMode, setPainterMode] = useState(false);
const [showMiniMap, setShowMiniMap] = useState(false);
const [activeSidebar, setActiveSidebar] = useState<SidebarPanel | null>(null);
@@ -146,7 +196,19 @@ const MindmapBlockView = ({
return instance?.renderer?.root ?? instance?.renderer?.renderTree?._node ?? null;
}, [mindmap]);
const autosaveKey = useMemo(() => `${STORAGE_PREFIX}${block.id}`, [block.id]);
const docId = useMemo(
() =>
block.props.docId ||
(typeof window !== "undefined"
? window.location.pathname.split("/").pop() ?? ""
: ""),
[block.props.docId],
);
const autosaveKey = useMemo(
() => `${STORAGE_PREFIX}${docId || block.id}`,
[block.id, docId],
);
const initialDataRef = useRef<unknown>(null);
if (initialDataRef.current === null) {
const cached = typeof window !== "undefined" ? window.localStorage.getItem(autosaveKey) : null;
@@ -154,26 +216,83 @@ const MindmapBlockView = ({
try {
initialDataRef.current = JSON.parse(cached);
} catch {
initialDataRef.current = block.props.data ?? defaultMindmapData;
initialDataRef.current = block.props.data ?? defaultMindmapData;
}
} else {
initialDataRef.current = block.props.data ?? defaultMindmapData;
}
}
// 优先加载本地文件,其次 Supabase(通过后端 API
useEffect(() => {
let cancelled = false;
if (!docId) return;
(async () => {
try {
const resp = await fetch(`/api/mindmap/${docId}`);
if (!resp.ok) return;
const payload = await resp.json().catch(() => null);
const data = payload?.data;
if (!data || cancelled) return;
initialDataRef.current = data;
if (mindmap) {
mindmap.setData(data);
mindmap.command.clearHistory();
}
} catch (error) {
console.warn("加载本地/远端思维导图失败", error);
}
})();
return () => {
cancelled = true;
};
}, [docId, mindmap]);
const persistData = useCallback(
(data: unknown) => {
if (!editor) return;
window.localStorage.setItem(autosaveKey, JSON.stringify(data));
editor.updateBlock(block, { props: { ...block.props, data } });
window.localStorage.setItem(autosaveKey, JSON.stringify(data));
editor.updateBlock(block, { props: { ...block.props, data } });
if (docId) {
// 同步到本地文件 + Supabase(弱依赖)
fetch(`/api/mindmap/${docId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data }),
})
.then((resp) => {
if (resp.ok) {
emitAssetsChanged(docId, { id: `mindmap-${docId}`, document_id: docId, asset_type: "mindmap" });
}
})
.catch((err) => console.warn("思维导图同步失败", err));
}
},
[autosaveKey, block, editor],
[autosaveKey, block, docId, editor],
);
const debouncedPersist = useDebouncedCallback((data: unknown) => {
persistData(data);
}, 500);
const initialSyncDone = useRef(false);
useEffect(() => {
if (!docId || !mindmap || initialSyncDone.current) return;
initialSyncDone.current = true;
const data = mindmap.getData?.() ?? initialDataRef.current ?? defaultMindmapData;
void fetch(`/api/mindmap/${docId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data }),
})
.then((resp) => {
if (resp.ok) {
emitAssetsChanged(docId, { id: `mindmap-${docId}`, document_id: docId, asset_type: "mindmap" });
}
})
.catch((err) => console.warn("初次创建思维导图文件失败", err));
}, [docId, mindmap, initialDataRef]);
// 初始选中根节点,后续不强制抢焦点,允许用户自由选择
useEffect(() => {
if (!mindmap || activeNodes.length > 0) return;
@@ -204,6 +323,16 @@ const MindmapBlockView = ({
{ default: Drag },
{ default: KeyboardNavigation },
{ default: NodeImgAdjust },
{ default: Scrollbar },
{ default: RainbowLines },
{ default: Watermark },
{ default: TouchEvent },
{ default: Cooperate },
{ default: Demonstrate },
{ default: MindMapLayoutPro },
{ default: NodeBase64ImageStorage },
{ default: ExportPDF },
{ default: ExportXMind },
] = await Promise.all([
import("simple-mind-map"),
import("simple-mind-map/src/plugins/Painter.js"),
@@ -217,6 +346,16 @@ const MindmapBlockView = ({
import("simple-mind-map/src/plugins/Drag.js"),
import("simple-mind-map/src/plugins/KeyboardNavigation.js"),
import("simple-mind-map/src/plugins/NodeImgAdjust.js"),
import("simple-mind-map/src/plugins/Scrollbar.js"),
import("simple-mind-map/src/plugins/RainbowLines.js"),
import("simple-mind-map/src/plugins/Watermark.js"),
import("simple-mind-map/src/plugins/TouchEvent.js"),
import("simple-mind-map/src/plugins/Cooperate.js"),
import("simple-mind-map/src/plugins/Demonstrate.js"),
import("simple-mind-map/src/plugins/MindMapLayoutPro.js"),
import("simple-mind-map/src/plugins/NodeBase64ImageStorage.js"),
import("simple-mind-map/src/plugins/ExportPDF.js"),
import("simple-mind-map/src/plugins/ExportXMind.js"),
]);
const plugins = [
@@ -231,6 +370,16 @@ const MindmapBlockView = ({
{ name: "Drag", plugin: Drag },
{ name: "KeyboardNavigation", plugin: KeyboardNavigation },
{ name: "NodeImgAdjust", plugin: NodeImgAdjust },
{ name: "Scrollbar", plugin: Scrollbar },
{ name: "RainbowLines", plugin: RainbowLines },
{ name: "Watermark", plugin: Watermark },
{ name: "TouchEvent", plugin: TouchEvent },
{ name: "Cooperate", plugin: Cooperate },
{ name: "Demonstrate", plugin: Demonstrate },
{ name: "MindMapLayoutPro", plugin: MindMapLayoutPro },
{ name: "NodeBase64ImageStorage", plugin: NodeBase64ImageStorage },
{ name: "ExportPDF", plugin: ExportPDF },
{ name: "ExportXMind", plugin: ExportXMind },
];
plugins.forEach(({ name, plugin }) => {
@@ -241,13 +390,23 @@ const MindmapBlockView = ({
// @ts-expect-error 第三方库缺少类型定义
if (MindMap.hasPlugin(plugin) === -1) {
console.log(`注册插件: ${name}`);
// @ts-expect-error 第三方库缺少类型定义
MindMap.usePlugin(plugin);
const registerPlugin =
// @ts-expect-error simple-mind-map 插件注册无类型声明
(MindMap as { usePlugin?: (p: unknown) => void }).usePlugin;
if (registerPlugin) {
registerPlugin(plugin);
}
}
});
const { nodeIconList, mergerIconList } = await loadIconModules();
await patchSvgRbox();
const hostEl = containerRef.current ?? document.body;
const instance = new MindMap({
el: containerRef.current,
el: hostEl,
data: initialDataRef.current,
theme: "classic",
layout: "logicalStructure",
@@ -262,6 +421,17 @@ const MindmapBlockView = ({
...(iconConfig as unknown[]),
]),
}) as MindMapInstance;
// 确保测量节点的缓存容器始终挂在有效 DOM 上,避免 appendChild 空指针
if (!(instance as any).commonCaches) {
(instance as any).commonCaches = {};
}
if (!(instance as any).commonCaches.measureRichtextNodeTextSizeEl) {
const measureDiv = document.createElement("div");
measureDiv.style.position = "fixed";
measureDiv.style.left = "-999999px";
((instance as any).commonCaches).measureRichtextNodeTextSizeEl = measureDiv;
(hostEl ?? document.body).appendChild(measureDiv);
}
instance.setMode?.("edit");
if (typeof window !== "undefined") {
@@ -284,15 +454,15 @@ const MindmapBlockView = ({
// 确保点击即可激活(调用节点自身的 active 逻辑,保持和官方一致)
// @ts-expect-error 第三方库节点对象
if (typeof node?.active === "function") {
// @ts-expect-error 第三方库节点对象
// @ts-expect-error simple-mind-map 节点对象缺少类型
node.active();
} else {
// 兜底:手动维护激活列表
// @ts-expect-error 第三方库缺少类型定义
// @ts-expect-error simple-mind-map 渲染器缺少类型
instance.renderer?.clearActiveNodeList?.();
// @ts-expect-error
// @ts-expect-error simple-mind-map 渲染器缺少类型
instance.renderer?.addNodeToActiveList?.(node, true);
// @ts-expect-error
// @ts-expect-error simple-mind-map 渲染器缺少类型
instance.renderer?.emitNodeActiveEvent?.(node);
}
const list = instance.renderer?.activeNodeList ?? [];
@@ -374,9 +544,7 @@ const MindmapBlockView = ({
// 图片预览(双击节点图片)
const [showImageViewer, setShowImageViewer] = useState(false);
const [viewerSrc, setViewerSrc] = useState("");
const [viewerNode, setViewerNode] = useState<any>(null);
const [viewerMeta, setViewerMeta] = useState<{ title?: string; width?: number; height?: number }>({});
const [hasActiveImg, setHasActiveImg] = useState(false);
const imgToolbarRef = useRef<HTMLDivElement | null>(null);
const [imgToolbarState, setImgToolbarState] = useState({
show: false,
@@ -387,9 +555,9 @@ const MindmapBlockView = ({
const imgToolbarHover = useRef(false);
const handleImage = () => {
const first = mindmap?.renderer?.activeNodeList?.[0] as any;
const first = mindmap?.renderer?.activeNodeList?.[0] as MindMapNode | undefined;
if (first?.getStyle) {
const placement = first.getStyle("imgPlacement", false) as string;
const placement = first.getStyle("imgPlacement", false) as string;
if (placement) setImagePosition(placement);
}
setShowImageModal(true);
@@ -398,7 +566,7 @@ const MindmapBlockView = ({
// 预览:监听 mindmap 事件
useEffect(() => {
if (!mindmap) return;
const handler = (node: any, e: any) => {
const handler = (node: MindMapNode, e?: Event) => {
e?.stopPropagation?.();
e?.preventDefault?.();
const src =
@@ -407,7 +575,6 @@ const MindmapBlockView = ({
node?.data?.image;
if (src) {
setViewerSrc(src);
setViewerNode(node);
const size = node?.getData?.("imageSize") || {};
const title = node?.getData?.("imageTitle") || "";
setViewerMeta({
@@ -419,14 +586,14 @@ const MindmapBlockView = ({
}
};
const onActive = () => {
const list = mindmap.renderer?.activeNodeList || [];
const has = list.some((n: any) => !!n?.getData?.("image"));
setHasActiveImg(has);
const list = (mindmap.renderer?.activeNodeList || []) as MindMapNode[];
const has = list.some((n) => !!n?.getData?.("image"));
if (!has) setImgToolbarState((s) => ({ ...s, show: false }));
};
const showToolbarOnClick = (node: any, svgImg: any, evt: any) => {
const bbox = evt?.target?.getBoundingClientRect?.();
const placement = (node?.getStyle?.("imgPlacement") || "top") as any;
const showToolbarOnClick = (node: MindMapNode, _svgImg: unknown, evt: Event | undefined) => {
const target = evt?.target as Element | undefined;
const bbox = target?.getBoundingClientRect?.();
const placement = (node?.getStyle?.("imgPlacement") || "top") as "top" | "bottom" | "left" | "right";
if (!bbox) return;
setImagePosition(placement);
setImgToolbarState({
@@ -460,11 +627,15 @@ const MindmapBlockView = ({
const setPlacement = (p: "top" | "bottom" | "left" | "right") => {
setImagePosition(p);
applyToActiveNodes(mindmap, (node) =>
mindmap?.execCommand?.("SET_NODE_STYLES", node, { imgPlacement: p }),
mindmap?.execCommand?.("SET_NODE_STYLES", node, { imgPlacement: p }),
);
setImgToolbarState((s) => ({ ...s, placement: p }));
};
const btn = (p: typeof placement, Icon: any, title: string) => (
const btn = (
p: typeof placement,
Icon: React.ComponentType<{ className?: string }>,
title: string,
) => (
<button
key={p}
type="button"
@@ -571,24 +742,64 @@ const MindmapBlockView = ({
window.alert("AI 能力占位:后续接入大模型生成/优化节点内容。");
};
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const data = JSON.parse(String(reader.result));
const reset = () => {
event.target.value = "";
};
const ext = (file.name.split(".").pop() || "").toLowerCase();
try {
// JSON / smm
if (ext === "json" || ext === "smm") {
const text = await file.text();
const data = JSON.parse(text);
mindmap?.setData(data);
mindmap?.command.clearHistory();
debouncedPersist(data);
} catch (error) {
console.error(error);
window.alert("导入失败:文件格式错误");
} finally {
event.target.value = "";
return;
}
};
reader.readAsText(file, "utf-8");
// XMind
if (ext === "xmind") {
const xmindParser = await import("simple-mind-map/src/parse/xmind.js");
const blob = new Blob([await file.arrayBuffer()]);
const data = await xmindParser.default.parseXmindFile(blob, (content: unknown[]) => {
const list = Array.isArray(content) ? content : [];
if (list.length > 1) {
window.alert("检测到 XMind 多画布,自动导入第一个画布。");
}
return list.length > 0 ? list[0] : content;
});
mindmap?.setData(data);
mindmap?.command.clearHistory();
debouncedPersist(data);
return;
}
// Markdown
if (ext === "md" || ext === "markdown") {
const { transformMarkdownTo } = await import(
"simple-mind-map/src/parse/markdownTo.js"
);
const text = await file.text();
const data = transformMarkdownTo(text) as MindMapData;
if (!data.data) {
data.data = { text: file.name.replace(/\.(md|markdown)$/i, "") || "中心主题" };
}
mindmap?.setData(data);
mindmap?.command.clearHistory();
debouncedPersist(data);
return;
}
window.alert("暂不支持该文件类型,请选择 .json / .smm / .xmind / .md");
} catch (error) {
console.error(error);
window.alert("导入失败:文件格式或内容错误");
} finally {
reset();
}
};
const handleNew = () => {
@@ -618,21 +829,49 @@ const MindmapBlockView = ({
downloadJson(data, "mindmap");
};
const handleExportPng = async () => {
const handleExport = async (type: string, name = "mindmap") => {
try {
await mindmap?.doExport?.export("png", true, "mindmap");
await mindmap?.doExport?.export(type, true, name);
} catch (error) {
console.error(error);
window.alert("导出 PNG 失败,请稍后再试");
window.alert(`导出 ${type.toUpperCase()} 失败,请稍后再试`);
}
};
const handleExportPng = () => handleExport("png");
const handleExportSvg = () => handleExport("svg");
const handleExportPdf = () => handleExport("pdf");
const handleExportMd = () => handleExport("md");
const handleExportTxt = () => handleExport("txt");
const handleExportXmind = () => handleExport("xmind");
const handleSaveAs = () => handleExportJson();
const handleDeleteMindmap = useCallback(async () => {
if (!docId) {
editor.removeBlocks([block.id]);
return;
}
const confirmed = window.confirm("确认删除当前思维导图?此操作会移除文件并清空侧边栏记录。");
if (!confirmed) return;
const resp = await fetch(`/api/mindmap/${docId}`, { method: "DELETE" });
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除思维导图失败");
return;
}
try {
window.localStorage.removeItem(autosaveKey);
} catch {
// ignore
}
emitAssetsChanged(docId);
editor.removeBlocks([block.id]);
}, [autosaveKey, block.id, docId, editor]);
const toolbarProps = {
canBack,
canForward,
activeCount,
painterMode,
onUndo: handleUndo,
onRedo: handleRedo,
@@ -655,8 +894,14 @@ const MindmapBlockView = ({
onNew: handleNew,
onOpenDirectory: handleOpenDirectory,
onSaveAs: handleSaveAs,
onDeleteMindmap: handleDeleteMindmap,
onExportJson: handleExportJson,
onExportPng: handleExportPng,
onExportSvg: handleExportSvg,
onExportPdf: handleExportPdf,
onExportMd: handleExportMd,
onExportTxt: handleExportTxt,
onExportXmind: handleExportXmind,
fileInputRef,
};
@@ -708,9 +953,10 @@ const MindmapBlockView = ({
</button>
<div className="mb-2 flex items-center justify-between gap-4 px-1 text-xs text-gray-200">
<span className="truncate">{viewerMeta.title || "图片预览"}</span>
<span className="truncate">{viewerMeta.title || "图片预览"}</span>
<span className="shrink-0">{viewerMeta.width && viewerMeta.height ? `${viewerMeta.width} × ${viewerMeta.height}px` : ""}</span>
</div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={viewerSrc}
alt={viewerMeta.title || "预览"}
@@ -884,7 +1130,11 @@ const MindmapBlockView = ({
<MindmapToolbar {...toolbarProps} />
</div>
<div className="relative h-full w-full">
<div ref={containerRef} className="h-full w-full" />
<div
ref={containerRef}
className="h-full w-full"
contentEditable={false}
/>
<MindmapSidebarTrigger
activeSidebar={activeSidebar}
@@ -923,7 +1173,11 @@ const MindmapBlockView = ({
</div>
</div>
<div className="relative h-[520px] w-full overflow-hidden bg-white">
<div ref={containerRef} className="h-full w-full" />
<div
ref={containerRef}
className="h-full w-full"
contentEditable={false}
/>
<MindmapSidebarTrigger
activeSidebar={activeSidebar}
@@ -960,6 +1214,7 @@ export const mindmapBlock = createReactBlockSpec(
{
type: "mindmap",
propSchema: {
docId: { default: "" },
data: { default: defaultMindmapData },
},
content: "none",
@@ -1,28 +1,14 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useEffect, useMemo, useState } from "react";
import Image from "next/image";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Toggle } from "@/components/ui/toggle";
import {
Bold,
Italic,
Underline,
Type,
Strikethrough,
Palette,
X,
Network,
ListTree,
Sliders,
Calculator,
StickyNote,
Bot,
} from "lucide-react";
import { Bold, Italic, Underline, Type, Strikethrough, Palette, X, Network } from "lucide-react";
import {
fontFamilyList,
fontSizeList,
lineHeightList,
colorList,
borderDasharrayList,
borderRadiusList,
borderWidthList,
@@ -38,6 +24,7 @@ import {
} from "./mindmapOptions";
import iconConfig from "./mindmapIconConfig";
import imageConfig from "./mindmapImageConfig";
import { sidebarTitles, type SidebarPanel } from "./mindmapSidebarConfig";
// 避免 SSR 阶段触发浏览器依赖,改为运行时动态引入
// @ts-expect-error 第三方库缺少类型定义
const loadIconModules = async () => {
@@ -60,19 +47,6 @@ type SidebarProps = {
onClose: () => void;
};
const predefinedIcons = [
{
type: 'priority',
name: '优先级',
list: ['1', '2', '3', '4', '5', '6', '7', '8', '9']
},
{
type: 'progress',
name: '进度',
list: ['start', 'quarter', 'half', '3quarter', 'done'] // Standard keys usually
}
];
const ColorInput = ({
value,
onChange,
@@ -134,13 +108,13 @@ const StylePanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
const newStyle: Record<string, any> = {};
[
"fontFamily", "fontSize", "lineHeight", "color", "fontWeight", "fontStyle",
"textDecoration", "borderWidth", "borderColor", "fillColor", "shape",
"textDecoration", "borderWidth", "borderColor", "fillColor", "shape",
"lineColor", "lineWidth", "lineDasharray", "borderRadius", "borderDasharray"
].forEach((prop) => {
newStyle[prop] = node.getStyle(prop, false);
});
// eslint-disable-next-line react-hooks/set-state-in-effect
setStyle(newStyle);
// 异步更新,避免同步 setState 抛 lint
Promise.resolve().then(() => setStyle(newStyle));
}
}, [activeNodes]);
@@ -551,7 +525,7 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
dangerouslySetInnerHTML={{ __html: item.icon }}
/>
) : (
<img src={item.icon} alt={item.name} className="h-6 w-6 object-contain" />
<Image src={item.icon} alt={item.name} width={24} height={24} className="h-6 w-6 object-contain" />
)
) : (
<span className="text-xs text-gray-700">{item.name}</span>
@@ -578,7 +552,7 @@ const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
onClick={() => setSticker(item)}
className="flex h-16 w-16 items-center justify-center rounded border border-gray-200 bg-white hover:border-blue-400 hover:shadow-sm"
>
<img src={item.url} alt={group.name} className="h-14 w-14 object-contain" />
<Image src={item.url} alt={group.name} width={56} height={56} className="h-14 w-14 object-contain" />
</button>
))}
</div>
@@ -623,8 +597,23 @@ const OutlinePanel = ({ mindmap }: { mindmap: any }) => {
const activate = (node: any) => {
const r = mindmap?.renderer;
if (!node || !r) return;
r.activeNodeList = [node];
r.lastActiveNodeList = [node];
// 仅通过已有方法触发激活,避免直接改 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 报错
}
}
mindmap.emit?.("node_active", node, [node]);
r.setRootNodeCenter?.();
};
@@ -660,45 +649,336 @@ const OutlinePanel = ({ mindmap }: { mindmap: any }) => {
};
const SettingsPanel = ({ mindmap }: { mindmap: any }) => {
const [freeDrag, setFreeDrag] = useState<boolean>(!!mindmap?.opt?.enableFreeDrag);
const [wheel, setWheel] = useState<string>(mindmap?.opt?.mousewheelAction || "zoom");
const [aiOn, setAiOn] = useState<boolean>(true);
const [config, setConfig] = useState({
openPerformance: !!mindmap?.opt?.openPerformance,
enableFreeDrag: !!mindmap?.opt?.enableFreeDrag,
mousewheelAction: mindmap?.opt?.mousewheelAction || "zoom",
mousewheelZoomActionReverse: !!mindmap?.opt?.mousewheelZoomActionReverse,
openRealtimeRenderOnNodeTextEdit: !!mindmap?.opt?.openRealtimeRenderOnNodeTextEdit,
alwaysShowExpandBtn: !!mindmap?.opt?.alwaysShowExpandBtn,
enableAutoEnterTextEditWhenKeydown: !!mindmap?.opt?.enableAutoEnterTextEditWhenKeydown,
createNewNodeBehavior: mindmap?.opt?.createNewNodeBehavior || "default",
imgTextMargin: mindmap?.opt?.imgTextMargin ?? 5,
textContentMargin: mindmap?.opt?.textContentMargin ?? 2,
});
const [aiOn, setAiOn] = useState<boolean>(mindmap?.opt?.enableAi ?? true);
const [watermark, setWatermark] = useState({
show: !!mindmap?.opt?.watermarkConfig?.text,
onlyExport: mindmap?.opt?.watermarkConfig?.onlyExport ?? false,
belowNode: mindmap?.opt?.watermarkConfig?.belowNode ?? false,
text: mindmap?.opt?.watermarkConfig?.text ?? "",
lineSpacing: mindmap?.opt?.watermarkConfig?.lineSpacing ?? 100,
textSpacing: mindmap?.opt?.watermarkConfig?.textSpacing ?? 100,
angle: mindmap?.opt?.watermarkConfig?.angle ?? 30,
textStyle: {
color: mindmap?.opt?.watermarkConfig?.textStyle?.color ?? "#999",
opacity: mindmap?.opt?.watermarkConfig?.textStyle?.opacity ?? 0.5,
fontSize: mindmap?.opt?.watermarkConfig?.textStyle?.fontSize ?? 14,
},
});
const updateFreeDrag = (val: boolean) => {
setFreeDrag(val);
if (mindmap) mindmap.opt.enableFreeDrag = val;
useEffect(() => {
const opt = mindmap?.opt || {};
Promise.resolve().then(() =>
setConfig({
openPerformance: !!opt.openPerformance,
enableFreeDrag: !!opt.enableFreeDrag,
mousewheelAction: opt.mousewheelAction || "zoom",
mousewheelZoomActionReverse: !!opt.mousewheelZoomActionReverse,
openRealtimeRenderOnNodeTextEdit: !!opt.openRealtimeRenderOnNodeTextEdit,
alwaysShowExpandBtn: !!opt.alwaysShowExpandBtn,
enableAutoEnterTextEditWhenKeydown: !!opt.enableAutoEnterTextEditWhenKeydown,
createNewNodeBehavior: opt.createNewNodeBehavior || "default",
imgTextMargin: opt.imgTextMargin ?? 5,
textContentMargin: opt.textContentMargin ?? 2,
}),
);
const wm = opt.watermarkConfig || {};
Promise.resolve().then(() =>
setWatermark({
show: !!wm.text,
onlyExport: wm.onlyExport ?? false,
belowNode: wm.belowNode ?? false,
text: wm.text ?? "",
lineSpacing: wm.lineSpacing ?? 100,
textSpacing: wm.textSpacing ?? 100,
angle: wm.angle ?? 30,
textStyle: {
color: wm.textStyle?.color ?? "#999",
opacity: wm.textStyle?.opacity ?? 0.5,
fontSize: wm.textStyle?.fontSize ?? 14,
},
}),
);
Promise.resolve().then(() => setAiOn(opt.enableAi ?? true));
}, [mindmap]);
const updateOpt = (key: string, value: any, needRender = false) => {
setConfig((prev) => ({ ...prev, [key]: value }));
mindmap?.updateConfig?.({ [key]: value });
if (needRender && mindmap?.reRender) {
mindmap.reRender();
}
};
const updateWheel = (val: string) => {
setWheel(val);
if (mindmap) mindmap.opt.mousewheelAction = val;
const applyWatermark = (next: any) => {
const { show, ...cfg } = next;
const finalCfg = show ? cfg : { ...cfg, text: "" };
mindmap?.watermark?.updateWatermark?.(finalCfg);
mindmap?.updateConfig?.({ watermarkConfig: finalCfg });
};
const updateWatermark = (patch: any) => {
setWatermark((prev) => {
const next = {
...prev,
...patch,
textStyle: { ...prev.textStyle, ...(patch.textStyle || {}) },
};
applyWatermark(next);
return next;
});
};
const updateAi = (val: boolean) => {
setAiOn(val);
mindmap?.updateConfig?.({ enableAi: val });
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500"></Label>
<Toggle size="sm" pressed={freeDrag} onPressedChange={updateFreeDrag}>
{freeDrag ? "开" : "关"}
</Toggle>
<div className="space-y-6">
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500"></Label>
<Toggle
size="sm"
pressed={watermark.show}
onPressedChange={(v) => updateWatermark({ show: v })}
>
{watermark.show ? "开" : "关"}
</Toggle>
</div>
{watermark.show && (
<div className="space-y-3 rounded-md border border-gray-100 p-3">
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500"></Label>
<Toggle
size="sm"
pressed={watermark.onlyExport}
onPressedChange={(v) => updateWatermark({ onlyExport: v })}
>
{watermark.onlyExport ? "开" : "关"}
</Toggle>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500"></Label>
<Toggle
size="sm"
pressed={watermark.belowNode}
onPressedChange={(v) => updateWatermark({ belowNode: v })}
>
{watermark.belowNode ? "开" : "关"}
</Toggle>
</div>
<div className="space-y-2">
<Label className="text-xs text-gray-500"></Label>
<Input
value={watermark.text}
placeholder="请输入水印文字"
onChange={(e) => updateWatermark({ text: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<ColorInput
label="颜色"
value={watermark.textStyle.color}
onChange={(val) => updateWatermark({ textStyle: { color: val } })}
/>
<div className="space-y-1">
<Label className="text-xs text-gray-500"> 0~1</Label>
<Input
type="number"
min={0}
max={1}
step={0.1}
value={watermark.textStyle.opacity}
onChange={(e) =>
updateWatermark({
textStyle: {
opacity: Math.min(1, Math.max(0, Number(e.target.value) || 0)),
},
})
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500"></Label>
<Input
type="number"
min={8}
max={50}
step={1}
value={watermark.textStyle.fontSize}
onChange={(e) =>
updateWatermark({
textStyle: { fontSize: Number(e.target.value) || 14 },
})
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500"></Label>
<Input
type="number"
min={0}
max={90}
step={5}
value={watermark.angle}
onChange={(e) => updateWatermark({ angle: Number(e.target.value) || 0 })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500"></Label>
<Input
type="number"
min={10}
step={10}
value={watermark.lineSpacing}
onChange={(e) => updateWatermark({ lineSpacing: Number(e.target.value) || 0 })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500"></Label>
<Input
type="number"
min={10}
step={10}
value={watermark.textSpacing}
onChange={(e) => updateWatermark({ textSpacing: Number(e.target.value) || 0 })}
/>
</div>
</div>
</div>
)}
</div>
<div className="space-y-2">
<Label className="text-xs text-gray-500"></Label>
<NativeSelect
value={wheel}
onChange={updateWheel}
options={[
{ label: "缩放", value: "zoom" },
{ label: "平移", value: "move" },
]}
/>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500"></Label>
<Toggle
size="sm"
pressed={config.openPerformance}
onPressedChange={(v) => updateOpt("openPerformance", v)}
>
{config.openPerformance ? "开" : "关"}
</Toggle>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500"></Label>
<Toggle
size="sm"
pressed={config.enableFreeDrag}
onPressedChange={(v) => updateOpt("enableFreeDrag", v)}
>
{config.enableFreeDrag ? "开" : "关"}
</Toggle>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500"></Label>
<Toggle
size="sm"
pressed={config.openRealtimeRenderOnNodeTextEdit}
onPressedChange={(v) => updateOpt("openRealtimeRenderOnNodeTextEdit", v)}
>
{config.openRealtimeRenderOnNodeTextEdit ? "开" : "关"}
</Toggle>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500"></Label>
<Toggle
size="sm"
pressed={config.alwaysShowExpandBtn}
onPressedChange={(v) => updateOpt("alwaysShowExpandBtn", v, true)}
>
{config.alwaysShowExpandBtn ? "开" : "关"}
</Toggle>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500"></Label>
<Toggle
size="sm"
pressed={config.enableAutoEnterTextEditWhenKeydown}
onPressedChange={(v) => updateOpt("enableAutoEnterTextEditWhenKeydown", v)}
>
{config.enableAutoEnterTextEditWhenKeydown ? "开" : "关"}
</Toggle>
</div>
<div className="space-y-2">
<Label className="text-xs text-gray-500"></Label>
<NativeSelect
value={config.mousewheelAction}
onChange={(v) => updateOpt("mousewheelAction", v)}
options={[
{ label: "缩放", value: "zoom" },
{ label: "平移", value: "move" },
]}
/>
</div>
{config.mousewheelAction === "zoom" && (
<div className="space-y-2">
<Label className="text-xs text-gray-500"></Label>
<NativeSelect
value={String(config.mousewheelZoomActionReverse)}
onChange={(v) => updateOpt("mousewheelZoomActionReverse", v === "true")}
options={[
{ label: "常规", value: "false" },
{ label: "反转", value: "true" },
]}
/>
</div>
)}
<div className="space-y-2">
<Label className="text-xs text-gray-500"></Label>
<NativeSelect
value={config.createNewNodeBehavior}
onChange={(v) => updateOpt("createNewNodeBehavior", v)}
options={[
{ label: "默认", value: "default" },
{ label: "不激活", value: "notActive" },
{ label: "仅激活新节点", value: "activeOnly" },
]}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-gray-500"></Label>
<Input
type="number"
min={0}
step={1}
value={config.imgTextMargin}
onChange={(e) => updateOpt("imgTextMargin", Number(e.target.value) || 0, true)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500"></Label>
<Input
type="number"
min={0}
step={1}
value={config.textContentMargin}
onChange={(e) => updateOpt("textContentMargin", Number(e.target.value) || 0, true)}
/>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500">AI </Label>
<Toggle size="sm" pressed={aiOn} onPressedChange={setAiOn}>
<Toggle size="sm" pressed={aiOn} onPressedChange={updateAi}>
{aiOn ? "开" : "关"}
</Toggle>
</div>
<p className="text-xs text-gray-400"></p>
</div>
);
};
@@ -739,11 +1019,13 @@ const NotePanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMa
// 当选中节点变化时,自动展示首个节点的备注
useEffect(() => {
if (!activeNodes?.length) {
setNote("");
Promise.resolve().then(() => setNote(""));
return;
}
const current = readNote(activeNodes[0]);
setNote(typeof current === "string" ? current : "");
Promise.resolve().then(() =>
setNote(typeof current === "string" ? current : ""),
);
}, [activeNodes]);
const getActiveList = () =>
@@ -794,28 +1076,404 @@ const NotePanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMa
);
};
const AiPanel = () => {
type AiMode = "chat" | "full" | "partial";
const AiPanel = ({ mindmap, activeNodes }: { mindmap: any; activeNodes: MindMapNode[] }) => {
const [prompt, setPrompt] = useState("");
const send = () => window.alert("AI 对话占位,后续接入大模型:\n" + prompt);
const [mode, setMode] = useState<AiMode>("full");
const [model, setModel] = useState("qwen3:30b-a3b-instruct-2507-q4_K_M");
const [baseUrl, setBaseUrl] = useState("http://localhost:11434/api/chat");
const [systemPrompt, setSystemPrompt] = useState("请用 Markdown,仅使用标题和无序列表结构输出思维导图内容。不要添加额外解释。");
const [streamText, setStreamText] = useState("");
const [loading, setLoading] = useState(false);
const controllerRef = React.useRef<AbortController | null>(null);
const resetStream = () => {
setStreamText("");
};
const stop = () => {
controllerRef.current?.abort();
controllerRef.current = null;
setLoading(false);
};
const runChat = async () => {
if (!prompt.trim()) return;
resetStream();
setLoading(true);
const controller = new AbortController();
controllerRef.current = controller;
try {
const res = await fetch(baseUrl, {
method: "POST",
signal: controller.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
stream: true,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: prompt.trim() },
],
}),
});
const reader = res.body?.getReader();
if (!reader) throw new Error("无法读取流");
const decoder = new TextDecoder();
let full = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
// Ollama 返回 json 行
const lines = chunk.split("\n").filter(Boolean);
for (const line of lines) {
try {
const json = JSON.parse(line);
if (json.message?.content) {
full += json.message.content;
setStreamText((prev) => prev + json.message.content);
}
} catch {
// 忽略非 json 行
}
}
}
return full;
} finally {
setLoading(false);
controllerRef.current = null;
}
};
// 将 AI 文本解析为子节点数组,尽量兼容宽松 Markdown 列表
const parseChildrenFromText = async (text: string) => {
const { transformMarkdownTo } = await import("simple-mind-map/src/parse/markdownTo.js");
const { createUid } = await import("simple-mind-map/src/utils/index.js");
const safeText = text || "";
let children = transformMarkdownTo(safeText)?.children || [];
if (!children.length) {
const lines = safeText
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
const listLines = lines.filter((l) => /^[-*+]\s+/.test(l)).map((l) => l.replace(/^[-*+]\s+/, ""));
const headingLines = lines.filter((l) => /^#+\s+/.test(l)).map((l) => l.replace(/^#+\s+/, ""));
const useLines = listLines.length ? listLines : headingLines.length ? headingLines : lines;
children = useLines.map((t) => ({ data: { text: t } }));
}
if (!children.length) {
children = [{ data: { text: safeText.slice(0, 200) || "新节点" } }];
}
const fill = (nodes: any[]) => {
nodes.forEach((node) => {
if (!node.data) node.data = {};
if (!node.data.text) node.data.text = "新节点";
if (!node.data.uid) node.data.uid = createUid();
if (node.children?.length) fill(node.children);
});
};
fill(children);
return JSON.parse(JSON.stringify(children));
};
const getActiveList = () =>
(activeNodes && activeNodes.length ? activeNodes : mindmap?.renderer?.activeNodeList || []) as any[];
const updateDataByUids = async (uids: string[], updater: (node: any) => void) => {
const source = mindmap?.getData?.(true) || mindmap?.getData?.();
const dataClone = source ? JSON.parse(JSON.stringify(source)) : null;
if (!dataClone) {
window.alert("无法获取导图数据,操作失败。");
return false;
}
// 确保整棵树都有 uid,避免官方逻辑依赖 uid 时抛错
const { createUid } = await import("simple-mind-map/src/utils/index.js");
const sanitizeNode = (node: any) => {
if (!node || typeof node !== "object") return null;
if (!node.data) node.data = {};
if (!node.data.uid) node.data.uid = createUid();
if (typeof node.data.text !== "string") {
node.data.text =
node.data.text != null ? String(node.data.text) : "新节点";
}
if (Array.isArray(node.children)) {
node.children = node.children
.map((c: any) => sanitizeNode(c))
.filter(Boolean);
} else {
node.children = [];
}
return node;
};
sanitizeNode(dataClone);
const cleanRenderCallbacks = () => {
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",
);
}
} catch {
// ignore
}
};
cleanRenderCallbacks();
const walk = (node: any) => {
if (uids.includes(node?.data?.uid)) {
updater(node);
}
if (node.children?.length) {
node.children.forEach(walk);
}
};
walk(dataClone);
cleanRenderCallbacks();
mindmap?.updateData?.(dataClone);
cleanRenderCallbacks();
return true;
};
const applyFullMindmap = async (content: string) => {
if (!content.trim()) return;
try {
const { transformMarkdownTo } = await import("simple-mind-map/src/parse/markdownTo.js");
const data = transformMarkdownTo(content);
if (!data?.data) {
data.data = { text: "中心主题" };
}
mindmap?.setData?.(data);
mindmap?.command?.clearHistory?.();
} catch (error) {
console.error(error);
window.alert("解析 AI 结果失败,请检查格式(需 Markdown 标题和无序列表)。");
}
};
const applyPartialMindmap = async (text: string, presetChildren?: any[]) => {
const list = getActiveList();
if (!list?.length) {
window.alert("请选择节点后再续写。");
return;
}
try {
const children = presetChildren || (await parseChildrenFromText(text));
const uids = list
.map(
(node: any) =>
node?.nodeData?.data?.uid ||
node?.nodeData?.uid ||
node?.getData?.("uid") ||
node?.uid,
)
.filter(Boolean);
if (!uids.length) {
window.alert("未获取到选中节点的 UID,续写失败。");
return;
}
const ok = await updateDataByUids(uids as string[], (node) => {
node.children = Array.isArray(node.children) ? node.children : [];
const childrenCopy = JSON.parse(JSON.stringify(children));
node.children.push(...childrenCopy);
});
if (!ok) return;
mindmap?.renderer?.render?.(mindmap?.getData?.());
} catch (e) {
console.error("AI Markdown Parsing Error:", e);
if (e instanceof Error) {
console.error("Error Message:", e.message);
console.error("Error Stack:", e.stack);
}
window.alert("解析 AI 返回的 Markdown 失败,请检查格式。");
}
};
const handleSend = async () => {
const text = (await runChat()) || streamText;
if (mode === "full" && text) {
await applyFullMindmap(text);
}
if (mode === "partial" && text) {
await applyPartialMindmap(text);
}
};
const handleSaveToNote = () => {
const text = streamText.trim();
if (!text) {
window.alert("暂无可保存的内容");
return;
}
const list = getActiveList();
if (!list?.length) {
window.alert("请选择节点后再保存备注");
return;
}
list.forEach((node: any) => mindmap?.execCommand?.("SET_NODE_NOTE", node, text));
};
const handleAppendSingleChild = async () => {
const text = streamText.trim();
if (!text) {
window.alert("暂无可生成的内容");
return;
}
const children = await parseChildrenFromText(text.slice(0, 500));
const first = children.length ? [children[0]] : [{ data: { text } }];
await applyPartialMindmap("", first);
};
const handleAppendMultiChild = async () => {
const text = streamText.trim();
if (!text) {
window.alert("暂无可生成的内容");
return;
}
const children = await parseChildrenFromText(text);
await applyPartialMindmap("", children);
};
const handleReplaceCurrent = async () => {
const text = streamText.trim();
if (!text) {
window.alert("暂无可替换的内容");
return;
}
const list = getActiveList();
if (!list?.length) {
window.alert("请选择节点后再修改");
return;
}
const uids = list
.map(
(node: any) =>
node?.nodeData?.data?.uid ||
node?.nodeData?.uid ||
node?.getData?.("uid") ||
node?.uid,
)
.filter(Boolean) as string[];
const ok = await updateDataByUids(uids, (node) => {
if (!node.data) node.data = {};
node.data.text = text;
});
if (ok) {
mindmap?.renderer?.render?.(mindmap?.getData?.());
}
};
return (
<div className="space-y-3">
<Label className="text-xs text-gray-500">AI </Label>
<textarea
className="w-full rounded-md border border-gray-200 p-2 text-sm"
rows={4}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="输入需求,后续将调用 AI 生成/优化节点"
<Label className="text-xs text-gray-500"></Label>
<NativeSelect
value={mode}
onChange={(v) => setMode(v as AiMode)}
options={[
{ label: "整图生成", value: "full" },
{ label: "对话", value: "chat" },
{ label: "选中节点续写", value: "partial" },
]}
/>
<button className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600" onClick={send}>
</button>
<div className="space-y-2">
<Label className="text-xs text-gray-500"></Label>
<Input value={model} onChange={(e) => setModel(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-xs text-gray-500"></Label>
<Input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-xs text-gray-500"></Label>
<Input value={systemPrompt} onChange={(e) => setSystemPrompt(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-xs text-gray-500">{mode === "full" ? "生成主题" : mode === "partial" ? "续写内容" : "对话输入"}</Label>
<textarea
className="w-full rounded-md border border-gray-200 p-2 text-sm"
rows={5}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={
mode === "full"
? "例如:帮我生成一份 OKR 思维导图"
: mode === "partial"
? "描述要续写的方向或要补充的要点"
: "输入你想询问的内容"
}
/>
</div>
<div className="flex gap-2">
<button
disabled={loading}
className="flex-1 rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600 disabled:opacity-50"
onClick={handleSend}
>
{loading ? "生成中..." : "发送"}
</button>
<button
disabled={!loading}
className="w-20 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
onClick={stop}
>
</button>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500"></Label>
<div className="h-40 overflow-auto rounded-md border border-gray-200 p-2 text-sm whitespace-pre-wrap">
{streamText || "等待输出..."}
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500"></Label>
<div className="grid grid-cols-2 gap-2">
<button
className="rounded-md border border-gray-300 px-2 py-2 text-sm hover:bg-gray-50"
onClick={handleSaveToNote}
>
</button>
<button
className="rounded-md border border-gray-300 px-2 py-2 text-sm hover:bg-gray-50"
onClick={handleAppendSingleChild}
>
</button>
<button
className="rounded-md border border-gray-300 px-2 py-2 text-sm hover:bg-gray-50"
onClick={handleAppendMultiChild}
>
</button>
<button
className="rounded-md border border-gray-300 px-2 py-2 text-sm hover:bg-gray-50"
onClick={handleReplaceCurrent}
>
</button>
</div>
</div>
{mode === "full" && (
<p className="text-xs text-gray-400">
使 AI Markdown Ollama
</p>
)}
{mode === "partial" && (
<p className="text-xs text-gray-400">
AI Markdown
</p>
)}
</div>
);
};
import { sidebarTitles, type SidebarPanel } from "./mindmapSidebarConfig";
export const MindmapSidebar = ({ mindmap, activeNodes, activeTab, onClose }: SidebarProps) => {
const content = useMemo(() => {
switch (activeTab as SidebarPanel | null) {
@@ -838,7 +1496,7 @@ export const MindmapSidebar = ({ mindmap, activeNodes, activeTab, onClose }: Sid
case "note":
return <NotePanel mindmap={mindmap} activeNodes={activeNodes} />;
case "ai":
return <AiPanel />;
return <AiPanel mindmap={mindmap} activeNodes={activeNodes} />;
default:
return null;
}
@@ -11,7 +11,6 @@ import {
type ToolbarProps = {
canBack: boolean;
canForward: boolean;
activeCount: number;
painterMode: boolean;
onUndo: () => void;
onRedo: () => void;
@@ -35,8 +34,14 @@ type ToolbarProps = {
onNew: () => void;
onOpenDirectory: () => void;
onSaveAs: () => void;
onDeleteMindmap: () => void;
onExportJson: () => void;
onExportPng: () => void;
onExportSvg: () => void;
onExportPdf: () => void;
onExportMd: () => void;
onExportTxt: () => void;
onExportXmind: () => void;
fileInputRef: React.RefObject<HTMLInputElement>;
};
@@ -77,12 +82,9 @@ const ToolbarButton = ({
</button>
);
const Divider = () => <div className="mx-1 h-8 w-px bg-gray-200" />;
export const MindmapToolbar = ({
canBack,
canForward,
activeCount,
painterMode,
onUndo,
onRedo,
@@ -106,10 +108,18 @@ export const MindmapToolbar = ({
onNew,
onOpenDirectory,
onSaveAs,
onDeleteMindmap,
onExportJson,
onExportPng,
onExportSvg,
onExportPdf,
onExportMd,
onExportTxt,
onExportXmind,
fileInputRef,
}: ToolbarProps) => {
const [showExport, setShowExport] = React.useState(false);
const nodeHandlers: Record<NodeToolbarKey, () => void> = {
back: onUndo,
forward: onRedo,
@@ -137,8 +147,8 @@ export const MindmapToolbar = ({
openFile: () => fileInputRef.current?.click(),
import: () => fileInputRef.current?.click(),
saveAs: onSaveAs,
exportJson: onExportJson,
exportPng: onExportPng,
deleteFile: onDeleteMindmap,
exportMenu: () => setShowExport((v) => !v),
};
const getNodeDisabled = (key: NodeToolbarKey) => {
@@ -184,10 +194,36 @@ export const MindmapToolbar = ({
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
accept=".json,.smm,.xmind,.md,.markdown,application/json"
className="hidden"
onChange={onImport}
/>
{showExport ? (
<div className="absolute right-2 top-full z-50 mt-2 w-40 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg">
{[
{ label: "JSON", onClick: onExportJson },
{ label: "PNG", onClick: onExportPng },
{ label: "SVG", onClick: onExportSvg },
{ label: "PDF", onClick: onExportPdf },
{ label: "Markdown", onClick: onExportMd },
{ label: "TXT", onClick: onExportTxt },
{ label: "XMind", onClick: onExportXmind },
].map((item) => (
<button
key={item.label}
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
onClick={() => {
setShowExport(false);
item.onClick();
}}
>
<span>{item.label}</span>
<i className="iconfont iconexport text-[12px]" />
</button>
))}
</div>
) : null}
</div>
</div>
);
@@ -24,8 +24,8 @@ export type FileToolbarKey =
| "openFile"
| "import"
| "saveAs"
| "exportJson"
| "exportPng";
| "deleteFile"
| "exportMenu";
type ToolbarMeta = {
label: string;
@@ -85,8 +85,8 @@ export const fileToolbarOrder: FileToolbarKey[] = [
"openFile",
"import",
"saveAs",
"exportJson",
"exportPng",
"deleteFile",
"exportMenu",
];
export const fileToolbarMeta: FileToolbarConfig = {
@@ -95,6 +95,6 @@ export const fileToolbarMeta: FileToolbarConfig = {
openFile: { label: "打开", iconClass: "icondakai" },
import: { label: "导入", iconClass: "icondaoru" },
saveAs: { label: "另存为", iconClass: "iconlingcunwei" },
exportJson: { label: "导出 JSON", iconClass: "iconexport" },
exportPng: { label: "导出 PNG", iconClass: "iconPNG" },
deleteFile: { label: "删除", iconClass: "iconshanchu" },
exportMenu: { label: "导出", iconClass: "iconexport" },
};
@@ -15,6 +15,7 @@ import {
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<
@@ -62,13 +63,16 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
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(() => {
const handleDeleteBlock = useCallback(async () => {
if (block.type === "pageReference") {
void removePageReference();
return;
@@ -80,11 +84,43 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
.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}`, { method: "DELETE" });
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除思维导图失败");
return;
}
emitAssetsChanged(currentDocumentId);
editor.removeBlocks([block.id]);
return;
}
editor.removeBlocks([block.id]);
}, [block, editor, removePageReference]);
}, [block, currentDocumentId, editor, removePageReference]);
const turnToPage = useCallback(async () => {
const response = await fetch("/api/documents/create-child", {
@@ -175,6 +175,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
[
{
type: "mindmap",
props: { docId: currentDocumentId },
},
],
referenceBlock,
@@ -14,13 +14,10 @@ import { mediaBlock } from "./blocks/MediaBlock";
import { onlineTableBlock } from "./blocks/OnlineTableBlock";
import { mindmapBlock } from "./blocks/MindmapBlock";
const headingSpec =
typeof window === "undefined"
? defaultBlockSpecs.heading
: createHeadingBlockSpec({
levels: [1, 2, 3, 4, 5],
allowToggleHeadings: true,
});
const headingSpec = createHeadingBlockSpec({
levels: [1, 2, 3, 4, 5],
allowToggleHeadings: true,
});
export const customBlockSchema = BlockNoteSchema.create({
blockSpecs: {
@@ -31,7 +28,7 @@ export const customBlockSchema = BlockNoteSchema.create({
progressMeter: progressBlock,
media: mediaBlock,
onlineTable: onlineTableBlock(),
mindmap: mindmapBlock,
mindmap: mindmapBlock(),
},
inlineContentSpecs: defaultInlineContentSpecs,
styleSpecs: defaultStyleSpecs,