971 lines
34 KiB
TypeScript
971 lines
34 KiB
TypeScript
"use client";
|
||
|
||
import "simple-mind-map/dist/simpleMindMap.esm.css";
|
||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { createReactBlockSpec } from "@blocknote/react";
|
||
import type { Block, BlockNoteEditor } from "@blocknote/core";
|
||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||
import type { CustomBlockSchema } from "../schema";
|
||
import { MindmapToolbar } from "./MindmapToolbar";
|
||
import { MindmapSidebar } from "./MindmapSidebar";
|
||
import { MindmapSidebarTrigger } from "./MindmapSidebarTrigger";
|
||
import { MindmapNavigator } from "./MindmapNavigator";
|
||
import { MindmapMiniMap } from "./MindmapMiniMap";
|
||
import { MindmapCount } from "./MindmapCount";
|
||
import type { SidebarPanel } from "./mindmapSidebarConfig";
|
||
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";
|
||
// @ts-expect-error 第三方库缺少类型定义
|
||
import { mergerIconList } from "simple-mind-map/src/utils/index.js";
|
||
|
||
// 安全获取图片尺寸
|
||
const getImageSizeSafe = (url: string): Promise<{ width: number; height: number } | null> =>
|
||
new Promise((resolve) => {
|
||
if (!url) return resolve(null);
|
||
const img = new Image();
|
||
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||
img.onerror = () => resolve(null);
|
||
img.src = url;
|
||
});
|
||
|
||
type MindMapInstance = {
|
||
execCommand: (...args: unknown[]) => void;
|
||
destroy: () => void;
|
||
setData: (data: unknown) => void;
|
||
getData: (withConfig?: boolean) => unknown;
|
||
on?: (event: string, handler: (...args: unknown[]) => void) => void;
|
||
emit?: (event: string, ...args: unknown[]) => void;
|
||
off?: (event: string, handler: (...args: unknown[]) => void) => void;
|
||
setMode?: (mode: string) => void;
|
||
command: { clearHistory: () => void };
|
||
view: { fit: () => void; scale: number; enlarge: () => void; narrow: () => void; setScale: (scale: number, cx: number, cy: number) => void };
|
||
renderer: { activeNodeList: unknown[]; renderTree: { _node: unknown }; setRootNodeCenter: () => void };
|
||
painter?: { startPainter: () => void };
|
||
doExport?: { export: (type: string, isDownload?: boolean, name?: string) => Promise<unknown> };
|
||
width: number;
|
||
height: number;
|
||
miniMap?: unknown;
|
||
getThemeConfig: (key: string) => unknown;
|
||
setThemeConfig: (config: unknown) => void;
|
||
setTheme: (theme: string) => void;
|
||
setLayout: (layout: string) => void;
|
||
};
|
||
|
||
const defaultMindmapData = {
|
||
data: { text: "中心主题" },
|
||
children: [],
|
||
};
|
||
|
||
const STORAGE_PREFIX = "wolai-mindmap-autosave-";
|
||
|
||
function downloadJson(data: unknown, name: string) {
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||
const url = URL.createObjectURL(blob);
|
||
const anchor = document.createElement("a");
|
||
anchor.href = url;
|
||
anchor.download = `${name}.json`;
|
||
anchor.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
const createSimplePrompt = (title: string, placeholder = "") => {
|
||
const value = window.prompt(title, placeholder);
|
||
if (!value || !value.trim()) return null;
|
||
return value.trim();
|
||
};
|
||
|
||
const applyToActiveNodes = (
|
||
mindmap: MindMapInstance | null,
|
||
handler: (node: unknown) => void,
|
||
) => {
|
||
if (!mindmap) {
|
||
window.alert("思维导图尚未初始化");
|
||
return;
|
||
}
|
||
const list = mindmap.renderer?.activeNodeList;
|
||
if (!list || list.length === 0) {
|
||
// 尝试兜底选中根节点
|
||
const root = mindmap.renderer?.root ?? mindmap.renderer?.renderTree?._node;
|
||
if (root) {
|
||
mindmap.execCommand?.("SET_NODE_ACTIVE", root, true);
|
||
} else {
|
||
window.alert("请选择至少一个节点再执行该操作");
|
||
return;
|
||
}
|
||
}
|
||
(mindmap.renderer?.activeNodeList ?? []).forEach(handler);
|
||
};
|
||
|
||
// 确保存在激活节点后再执行命令,若无则自动选中根节点
|
||
const ensureActiveBefore = (mindmap?: MindMapInstance | null) => {
|
||
const mm = mindmap ?? null;
|
||
if (!mm) {
|
||
window.alert("思维导图尚未初始化");
|
||
return null;
|
||
}
|
||
const list = mm.renderer?.activeNodeList ?? [];
|
||
if (!list || list.length === 0) {
|
||
const root = mm.renderer?.root ?? mm.renderer?.renderTree?._node;
|
||
if (root) {
|
||
mm.execCommand?.("SET_NODE_ACTIVE", root, true);
|
||
} else {
|
||
window.alert("请先选中一个节点");
|
||
return null;
|
||
}
|
||
}
|
||
return mm;
|
||
};
|
||
|
||
const MindmapBlockView = ({
|
||
block,
|
||
editor,
|
||
fullscreen = false,
|
||
}: {
|
||
block: Block<CustomBlockSchema, "mindmap">;
|
||
editor: BlockNoteEditor<CustomBlockSchema>;
|
||
fullscreen?: boolean;
|
||
}) => {
|
||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||
const [mindmap, setMindmap] = useState<MindMapInstance | null>(null);
|
||
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);
|
||
const [showNoteModal, setShowNoteModal] = useState(false);
|
||
const [noteContent, setNoteContent] = useState("");
|
||
|
||
const getRootNode = useCallback((mm?: MindMapInstance | null) => {
|
||
const instance = mm ?? mindmap;
|
||
return instance?.renderer?.root ?? instance?.renderer?.renderTree?._node ?? null;
|
||
}, [mindmap]);
|
||
|
||
const autosaveKey = useMemo(() => `${STORAGE_PREFIX}${block.id}`, [block.id]);
|
||
const initialDataRef = useRef<unknown>(null);
|
||
if (initialDataRef.current === null) {
|
||
const cached = typeof window !== "undefined" ? window.localStorage.getItem(autosaveKey) : null;
|
||
if (cached) {
|
||
try {
|
||
initialDataRef.current = JSON.parse(cached);
|
||
} catch {
|
||
initialDataRef.current = block.props.data ?? defaultMindmapData;
|
||
}
|
||
} else {
|
||
initialDataRef.current = block.props.data ?? defaultMindmapData;
|
||
}
|
||
}
|
||
|
||
const persistData = useCallback(
|
||
(data: unknown) => {
|
||
if (!editor) return;
|
||
window.localStorage.setItem(autosaveKey, JSON.stringify(data));
|
||
editor.updateBlock(block, { props: { ...block.props, data } });
|
||
},
|
||
[autosaveKey, block, editor],
|
||
);
|
||
|
||
const debouncedPersist = useDebouncedCallback((data: unknown) => {
|
||
persistData(data);
|
||
}, 500);
|
||
|
||
// 初始选中根节点,后续不强制抢焦点,允许用户自由选择
|
||
useEffect(() => {
|
||
if (!mindmap || activeNodes.length > 0) return;
|
||
const rootNode = mindmap.renderer?.root ?? mindmap.renderer?.renderTree?._node;
|
||
if (rootNode) {
|
||
setActiveNodes([rootNode]);
|
||
mindmap.renderer.activeNodeList = [rootNode];
|
||
// @ts-expect-error 第三方库字段
|
||
mindmap.renderer.lastActiveNodeList = [rootNode];
|
||
mindmap.emit?.("node_active", rootNode, [rootNode]);
|
||
}
|
||
}, [mindmap, activeNodes.length]);
|
||
|
||
useEffect(() => {
|
||
let destroyed = false;
|
||
(async () => {
|
||
if (!containerRef.current) return;
|
||
const [
|
||
{ default: MindMap },
|
||
{ default: Painter },
|
||
{ default: AssociativeLine },
|
||
{ default: OuterFrame },
|
||
{ default: Exporter },
|
||
{ default: Formula },
|
||
{ default: RichText },
|
||
{ default: MiniMapPlugin },
|
||
{ default: Select },
|
||
{ default: Drag },
|
||
{ default: KeyboardNavigation },
|
||
{ default: NodeImgAdjust },
|
||
] = await Promise.all([
|
||
import("simple-mind-map"),
|
||
import("simple-mind-map/src/plugins/Painter.js"),
|
||
import("simple-mind-map/src/plugins/AssociativeLine.js"),
|
||
import("simple-mind-map/src/plugins/OuterFrame.js"),
|
||
import("simple-mind-map/src/plugins/Export.js"),
|
||
import("simple-mind-map/src/plugins/Formula.js"),
|
||
import("simple-mind-map/src/plugins/RichText.js"),
|
||
import("simple-mind-map/src/plugins/MiniMap.js"),
|
||
import("simple-mind-map/src/plugins/Select.js"),
|
||
import("simple-mind-map/src/plugins/Drag.js"),
|
||
import("simple-mind-map/src/plugins/KeyboardNavigation.js"),
|
||
import("simple-mind-map/src/plugins/NodeImgAdjust.js"),
|
||
]);
|
||
|
||
const plugins = [
|
||
{ name: "Painter", plugin: Painter },
|
||
{ name: "AssociativeLine", plugin: AssociativeLine },
|
||
{ name: "OuterFrame", plugin: OuterFrame },
|
||
{ name: "Export", plugin: Exporter },
|
||
{ name: "Formula", plugin: Formula },
|
||
{ name: "RichText", plugin: RichText },
|
||
{ name: "MiniMap", plugin: MiniMapPlugin },
|
||
{ name: "Select", plugin: Select },
|
||
{ name: "Drag", plugin: Drag },
|
||
{ name: "KeyboardNavigation", plugin: KeyboardNavigation },
|
||
{ name: "NodeImgAdjust", plugin: NodeImgAdjust },
|
||
];
|
||
|
||
plugins.forEach(({ name, plugin }) => {
|
||
if (!plugin) {
|
||
console.warn(`思维导图插件加载失败:${name}`);
|
||
return;
|
||
}
|
||
// @ts-expect-error 第三方库缺少类型定义
|
||
if (MindMap.hasPlugin(plugin) === -1) {
|
||
console.log(`注册插件: ${name}`);
|
||
// @ts-expect-error 第三方库缺少类型定义
|
||
MindMap.usePlugin(plugin);
|
||
}
|
||
});
|
||
|
||
const instance = new MindMap({
|
||
el: containerRef.current,
|
||
data: initialDataRef.current,
|
||
theme: "classic",
|
||
layout: "logicalStructure",
|
||
mousewheelAction: "zoom",
|
||
enableFreeDrag: true,
|
||
enableCtrlKeyNodeSelection: true,
|
||
fit: true,
|
||
useLeftKeySelectionRightKeyDrag: true,
|
||
// 传入扩展图标表,和官方一致
|
||
iconList: mergerIconList([
|
||
...nodeIconList,
|
||
...(iconConfig as unknown[]),
|
||
]),
|
||
}) as MindMapInstance;
|
||
instance.setMode?.("edit");
|
||
|
||
if (typeof window !== "undefined") {
|
||
// 便于开发阶段在控制台直接调试实例
|
||
// @ts-expect-error 调试用全局变量
|
||
window.__mindmapInstance = instance;
|
||
}
|
||
|
||
setMindmap(instance);
|
||
|
||
instance.on?.("back_forward", (index: number, len: number) => {
|
||
setCanBack(index > 0);
|
||
setCanForward(index < len - 1);
|
||
});
|
||
instance.on?.("node_active", (_node: unknown, list: unknown[]) => {
|
||
if (!list || list.length === 0) return;
|
||
setActiveNodes(list || []);
|
||
});
|
||
instance.on?.("node_click", (node: unknown) => {
|
||
// 确保点击即可激活(调用节点自身的 active 逻辑,保持和官方一致)
|
||
// @ts-expect-error 第三方库节点对象
|
||
if (typeof node?.active === "function") {
|
||
// @ts-expect-error 第三方库节点对象
|
||
node.active();
|
||
} else {
|
||
// 兜底:手动维护激活列表
|
||
// @ts-expect-error 第三方库缺少类型定义
|
||
instance.renderer?.clearActiveNodeList?.();
|
||
// @ts-expect-error
|
||
instance.renderer?.addNodeToActiveList?.(node, true);
|
||
// @ts-expect-error
|
||
instance.renderer?.emitNodeActiveEvent?.(node);
|
||
}
|
||
const list = instance.renderer?.activeNodeList ?? [];
|
||
setActiveNodes(list.length === 0 && node ? [node] : (list as unknown[]));
|
||
});
|
||
instance.on?.("painter_start", () => setPainterMode(true));
|
||
instance.on?.("painter_end", () => setPainterMode(false));
|
||
instance.on?.("data_change", (data: unknown) => {
|
||
debouncedPersist(data);
|
||
});
|
||
const rootNode = getRootNode(instance);
|
||
if (rootNode) {
|
||
setActiveNodes([rootNode]);
|
||
}
|
||
|
||
if (destroyed) {
|
||
instance.destroy();
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
destroyed = true;
|
||
setMindmap((prev) => {
|
||
prev?.destroy();
|
||
return null;
|
||
});
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [block.id]);
|
||
|
||
const handleUndo = () => mindmap?.execCommand("BACK");
|
||
const handleRedo = () => mindmap?.execCommand("FORWARD");
|
||
const handlePainter = () => {
|
||
if (!mindmap?.painter) {
|
||
window.alert("格式刷插件未就绪");
|
||
return;
|
||
}
|
||
mindmap.painter.startPainter();
|
||
};
|
||
const handleSibling = () => {
|
||
if (!mindmap) return;
|
||
window.setTimeout(() => mindmap.execCommand("INSERT_NODE"), 0);
|
||
};
|
||
const handleChild = () => {
|
||
if (!mindmap) return;
|
||
window.setTimeout(() => mindmap.execCommand("INSERT_CHILD_NODE"), 0);
|
||
};
|
||
const handleDelete = () => {
|
||
if (!mindmap) return;
|
||
window.setTimeout(() => mindmap.execCommand("REMOVE_NODE"), 0);
|
||
};
|
||
const handleSummary = () => {
|
||
const mm = ensureActiveBefore(mindmap);
|
||
if (mm) {
|
||
window.setTimeout(() => mm.execCommand("ADD_GENERALIZATION"), 0);
|
||
}
|
||
};
|
||
const handleAssociativeLine = () => {
|
||
const mm = ensureActiveBefore(mindmap);
|
||
if (mm) {
|
||
window.setTimeout(() => mm.execCommand("ADD_ASSOCIATIVE_LINE"), 0);
|
||
}
|
||
};
|
||
const handleOuterFrame = () => {
|
||
const mm = ensureActiveBefore(mindmap);
|
||
if (mm) {
|
||
window.setTimeout(() => mm.execCommand("ADD_OUTER_FRAME"), 0);
|
||
}
|
||
};
|
||
|
||
const [showImageModal, setShowImageModal] = useState(false);
|
||
const [imageUrl, setImageUrl] = useState("");
|
||
const [imageWidth, setImageWidth] = useState<number | string>(260);
|
||
const [imageHeight, setImageHeight] = useState<number | string>(200);
|
||
const [imageTitle, setImageTitle] = useState("");
|
||
const [imagePosition, setImagePosition] = useState<string>("top");
|
||
const fileInputForImage = useRef<HTMLInputElement | null>(null);
|
||
|
||
// 图片预览(双击节点图片)
|
||
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,
|
||
x: 0,
|
||
y: 0,
|
||
placement: "top" as "top" | "bottom" | "left" | "right",
|
||
});
|
||
const imgToolbarHover = useRef(false);
|
||
|
||
const handleImage = () => {
|
||
const first = mindmap?.renderer?.activeNodeList?.[0] as any;
|
||
if (first?.getStyle) {
|
||
const placement = first.getStyle("imgPlacement", false) as string;
|
||
if (placement) setImagePosition(placement);
|
||
}
|
||
setShowImageModal(true);
|
||
};
|
||
|
||
// 预览:监听 mindmap 事件
|
||
useEffect(() => {
|
||
if (!mindmap) return;
|
||
const handler = (node: any, e: any) => {
|
||
e?.stopPropagation?.();
|
||
e?.preventDefault?.();
|
||
const src =
|
||
node?.nodeData?.data?.image ||
|
||
node?.getData?.("image") ||
|
||
node?.data?.image;
|
||
if (src) {
|
||
setViewerSrc(src);
|
||
setViewerNode(node);
|
||
const size = node?.getData?.("imageSize") || {};
|
||
const title = node?.getData?.("imageTitle") || "";
|
||
setViewerMeta({
|
||
title: typeof title === "string" ? title : "",
|
||
width: Number(size?.width) || undefined,
|
||
height: Number(size?.height) || undefined,
|
||
});
|
||
setShowImageViewer(true);
|
||
}
|
||
};
|
||
const onActive = () => {
|
||
const list = mindmap.renderer?.activeNodeList || [];
|
||
const has = list.some((n: any) => !!n?.getData?.("image"));
|
||
setHasActiveImg(has);
|
||
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;
|
||
if (!bbox) return;
|
||
setImagePosition(placement);
|
||
setImgToolbarState({
|
||
show: true,
|
||
x: bbox.left,
|
||
y: bbox.top,
|
||
placement,
|
||
});
|
||
};
|
||
const hideToolbar = () => {
|
||
if (!imgToolbarHover.current) {
|
||
setImgToolbarState((s) => ({ ...s, show: false }));
|
||
}
|
||
};
|
||
mindmap.on?.("node_img_dblclick", handler);
|
||
mindmap.on?.("node_active", onActive);
|
||
mindmap.on?.("node_img_click", showToolbarOnClick);
|
||
mindmap.on?.("draw_click", hideToolbar);
|
||
return () => {
|
||
mindmap.off?.("node_img_dblclick", handler);
|
||
mindmap.off?.("node_active", onActive);
|
||
mindmap.off?.("node_img_click", showToolbarOnClick);
|
||
mindmap.off?.("draw_click", hideToolbar);
|
||
};
|
||
}, [mindmap]);
|
||
|
||
// 悬浮图片位置工具条
|
||
const renderImgToolbar = () => {
|
||
if (!imgToolbarState.show) return null;
|
||
const { x, y, placement } = imgToolbarState;
|
||
const setPlacement = (p: "top" | "bottom" | "left" | "right") => {
|
||
setImagePosition(p);
|
||
applyToActiveNodes(mindmap, (node) =>
|
||
mindmap?.execCommand?.("SET_NODE_STYLES", node, { imgPlacement: p }),
|
||
);
|
||
setImgToolbarState((s) => ({ ...s, placement: p }));
|
||
};
|
||
const btn = (p: typeof placement, Icon: any, title: string) => (
|
||
<button
|
||
key={p}
|
||
type="button"
|
||
className={`flex h-8 w-8 items-center justify-center rounded border bg-white/90 text-gray-700 shadow ${
|
||
placement === p ? "border-blue-500 text-blue-600" : "border-gray-200"
|
||
}`}
|
||
onMouseDown={(e) => {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
setPlacement(p);
|
||
}}
|
||
title={title}
|
||
>
|
||
<Icon className="h-4 w-4" />
|
||
</button>
|
||
);
|
||
return (
|
||
<div
|
||
ref={imgToolbarRef}
|
||
className="pointer-events-auto fixed z-[9999] flex gap-1 rounded-lg bg-black/50 p-2 backdrop-blur"
|
||
style={{ left: x, top: y - 42 }}
|
||
onMouseEnter={() => {
|
||
imgToolbarHover.current = true;
|
||
setImgToolbarState((s) => ({ ...s, show: true }));
|
||
}}
|
||
onMouseLeave={() => {
|
||
imgToolbarHover.current = false;
|
||
setImgToolbarState((s) => ({ ...s, show: false }));
|
||
}}
|
||
>
|
||
{btn("top", ArrowUp, "顶部")}
|
||
{btn("bottom", ArrowDown, "底部")}
|
||
{btn("left", ArrowLeft, "靠左")}
|
||
{btn("right", ArrowRight, "靠右")}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const handleIcon = () => {
|
||
setActiveSidebar("icons");
|
||
};
|
||
|
||
const handleLink = () => {
|
||
const href = createSimplePrompt("请输入超链接");
|
||
if (!href) return;
|
||
applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_HYPERLINK", node, href, href));
|
||
};
|
||
|
||
const handleNote = () => {
|
||
setShowNoteModal(true);
|
||
};
|
||
|
||
const handleTag = () => {
|
||
const tagsRaw = createSimplePrompt("请输入标签,使用逗号分隔", "重点,待验证");
|
||
if (!tagsRaw) return;
|
||
const tags = tagsRaw.split(",").map((item) => item.trim()).filter(Boolean);
|
||
applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_TAG", node, tags));
|
||
};
|
||
|
||
const handleFormula = () => {
|
||
setActiveSidebar("formula");
|
||
};
|
||
|
||
const handleNoteConfirm = () => {
|
||
const note = noteContent.trim();
|
||
setShowNoteModal(false);
|
||
if (!note) return;
|
||
applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_NOTE", node, note));
|
||
};
|
||
|
||
const handleImageConfirm = () => {
|
||
const url = imageUrl.trim();
|
||
const width = Number(imageWidth) || 260;
|
||
const height = Number(imageHeight) || 200;
|
||
if (!url) {
|
||
window.alert("请输入图片链接");
|
||
return;
|
||
}
|
||
setShowImageModal(false);
|
||
applyToActiveNodes(mindmap, (node) =>
|
||
mindmap?.execCommand("SET_NODE_IMAGE", node, {
|
||
url,
|
||
width,
|
||
height,
|
||
title: imageTitle || "",
|
||
position: imagePosition || "top",
|
||
}),
|
||
);
|
||
applyToActiveNodes(mindmap, (node) =>
|
||
mindmap?.execCommand?.("SET_NODE_STYLES", node, {
|
||
imgPlacement: imagePosition || "top",
|
||
}),
|
||
);
|
||
};
|
||
|
||
const handleAttachment = () => {
|
||
const url = createSimplePrompt("附件链接(http/https)");
|
||
if (!url) return;
|
||
const name = createSimplePrompt("附件名称(可选)", "附件");
|
||
applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_ATTACHMENT", node, url, name ?? ""));
|
||
};
|
||
|
||
const handleAiPlaceholder = () => {
|
||
window.alert("AI 能力占位:后续接入大模型生成/优化节点内容。");
|
||
};
|
||
|
||
const handleImport = (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));
|
||
mindmap?.setData(data);
|
||
mindmap?.command.clearHistory();
|
||
debouncedPersist(data);
|
||
} catch (error) {
|
||
console.error(error);
|
||
window.alert("导入失败:文件格式错误");
|
||
} finally {
|
||
event.target.value = "";
|
||
}
|
||
};
|
||
reader.readAsText(file, "utf-8");
|
||
};
|
||
|
||
const handleNew = () => {
|
||
if (!window.confirm("确定要新建空白导图吗?当前未保存的修改将被覆盖。")) return;
|
||
mindmap?.setData(defaultMindmapData);
|
||
mindmap?.command.clearHistory();
|
||
debouncedPersist(defaultMindmapData);
|
||
};
|
||
|
||
const handleOpenDirectory = () => {
|
||
const saved = window.localStorage.getItem(autosaveKey);
|
||
if (!saved) {
|
||
window.alert("暂无本地目录记录,可使用“导入”载入文件。");
|
||
return;
|
||
}
|
||
try {
|
||
const data = JSON.parse(saved);
|
||
mindmap?.setData(data);
|
||
window.alert("已从本地目录恢复最新自动保存版本。");
|
||
} catch {
|
||
window.alert("本地目录数据损坏,建议重新导入。");
|
||
}
|
||
};
|
||
|
||
const handleExportJson = () => {
|
||
const data = mindmap?.getData?.(true) ?? block.props.data ?? defaultMindmapData;
|
||
downloadJson(data, "mindmap");
|
||
};
|
||
|
||
const handleExportPng = async () => {
|
||
try {
|
||
await mindmap?.doExport?.export("png", true, "mindmap");
|
||
} catch (error) {
|
||
console.error(error);
|
||
window.alert("导出 PNG 失败,请稍后再试");
|
||
}
|
||
};
|
||
|
||
const handleSaveAs = () => handleExportJson();
|
||
|
||
const toolbarProps = {
|
||
canBack,
|
||
canForward,
|
||
activeCount,
|
||
painterMode,
|
||
onUndo: handleUndo,
|
||
onRedo: handleRedo,
|
||
onPainter: handlePainter,
|
||
onSibling: handleSibling,
|
||
onChild: handleChild,
|
||
onDelete: handleDelete,
|
||
onImage: handleImage,
|
||
onIcon: handleIcon,
|
||
onLink: handleLink,
|
||
onNote: handleNote,
|
||
onTag: handleTag,
|
||
onSummary: handleSummary,
|
||
onAssociativeLine: handleAssociativeLine,
|
||
onFormula: handleFormula,
|
||
onAttachment: handleAttachment,
|
||
onOuterFrame: handleOuterFrame,
|
||
onAi: handleAiPlaceholder,
|
||
onImport: handleImport,
|
||
onNew: handleNew,
|
||
onOpenDirectory: handleOpenDirectory,
|
||
onSaveAs: handleSaveAs,
|
||
onExportJson: handleExportJson,
|
||
onExportPng: handleExportPng,
|
||
fileInputRef,
|
||
};
|
||
|
||
const noteModal = showNoteModal ? (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||
<div className="w-full max-w-4xl rounded-xl bg-white shadow-2xl">
|
||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||
<span className="text-lg font-semibold text-gray-800">备注</span>
|
||
<button className="text-gray-400 hover:text-gray-600" onClick={() => setShowNoteModal(false)}>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div className="px-4 py-3">
|
||
<textarea
|
||
className="h-64 w-full resize-none rounded-md border border-gray-200 p-3 text-sm focus:border-blue-400 focus:outline-none"
|
||
placeholder="支持富文本/Markdown,内容将写入节点备注"
|
||
value={noteContent}
|
||
onChange={(e) => setNoteContent(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-end gap-3 border-t px-4 py-3">
|
||
<button
|
||
className="rounded-md border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50"
|
||
onClick={() => setShowNoteModal(false)}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
className="rounded-md bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||
onClick={handleNoteConfirm}
|
||
>
|
||
确定
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null;
|
||
|
||
const imageViewer = showImageViewer ? (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={() => setShowImageViewer(false)}>
|
||
<div className="relative max-h-full max-w-5xl text-white">
|
||
<button
|
||
className="absolute -top-3 -right-3 rounded-full bg-white/90 px-2 py-1 text-sm text-gray-700 shadow"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setShowImageViewer(false);
|
||
}}
|
||
>
|
||
关闭
|
||
</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="shrink-0">{viewerMeta.width && viewerMeta.height ? `${viewerMeta.width} × ${viewerMeta.height}px` : ""}</span>
|
||
</div>
|
||
<img
|
||
src={viewerSrc}
|
||
alt={viewerMeta.title || "预览"}
|
||
className="max-h-[80vh] max-w-[80vw] rounded-lg shadow-2xl object-contain bg-white"
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : null;
|
||
|
||
const imgToolbar = renderImgToolbar();
|
||
|
||
const imageModal = showImageModal ? (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||
<div className="w-full max-w-3xl rounded-xl bg-white shadow-2xl">
|
||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||
<span className="text-lg font-semibold text-gray-800">插入图片</span>
|
||
<button className="text-gray-400 hover:text-gray-600" onClick={() => setShowImageModal(false)}>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div className="space-y-4 px-4 py-3">
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-semibold text-gray-600">方式一:上传图片</Label>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
className="rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||
onClick={() => fileInputForImage.current?.click()}
|
||
>
|
||
选择文件
|
||
</button>
|
||
<span className="text-xs text-gray-400 truncate">
|
||
{imageUrl.startsWith("data:") ? "已选择本地图片" : "未选择文件"}
|
||
</span>
|
||
<input
|
||
ref={fileInputForImage}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={async (e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = async () => {
|
||
const dataUrl = String(reader.result);
|
||
setImageUrl(dataUrl);
|
||
const size = await getImageSizeSafe(dataUrl);
|
||
if (size) {
|
||
setImageWidth(size.width);
|
||
setImageHeight(size.height);
|
||
}
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-semibold text-gray-600">方式二:图片地址</Label>
|
||
<Input
|
||
value={imageUrl}
|
||
onChange={(e) => setImageUrl(e.target.value)}
|
||
placeholder="https://example.com/image.png"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label className="text-xs font-semibold text-gray-600">快捷位置</Label>
|
||
<div className="flex gap-2">
|
||
<button
|
||
type="button"
|
||
className={`flex-1 rounded border px-2 py-2 text-sm ${imagePosition === "top" ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200"}`}
|
||
onClick={() => setImagePosition("top")}
|
||
title="顶部"
|
||
>
|
||
<ArrowUp className="mx-auto h-4 w-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`flex-1 rounded border px-2 py-2 text-sm ${imagePosition === "bottom" ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200"}`}
|
||
onClick={() => setImagePosition("bottom")}
|
||
title="底部"
|
||
>
|
||
<ArrowDown className="mx-auto h-4 w-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`flex-1 rounded border px-2 py-2 text-sm ${imagePosition === "left" ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200"}`}
|
||
onClick={() => setImagePosition("left")}
|
||
title="靠左"
|
||
>
|
||
<ArrowLeft className="mx-auto h-4 w-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`flex-1 rounded border px-2 py-2 text-sm ${imagePosition === "right" ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200"}`}
|
||
onClick={() => setImagePosition("right")}
|
||
title="靠右"
|
||
>
|
||
<ArrowRight className="mx-auto h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-1">
|
||
<Label className="text-xs text-gray-500">宽度(px)</Label>
|
||
<Input
|
||
type="number"
|
||
value={imageWidth}
|
||
onChange={(e) => setImageWidth(e.target.value)}
|
||
min={10}
|
||
placeholder="260"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs text-gray-500">高度(px)</Label>
|
||
<Input
|
||
type="number"
|
||
value={imageHeight}
|
||
onChange={(e) => setImageHeight(e.target.value)}
|
||
min={10}
|
||
placeholder="200"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs text-gray-500">标题(可选)</Label>
|
||
<Input
|
||
value={imageTitle}
|
||
onChange={(e) => setImageTitle(e.target.value)}
|
||
placeholder="图片标题"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs text-gray-500">位置</Label>
|
||
<select
|
||
className="h-9 w-full rounded-md border border-gray-200 px-3 text-sm"
|
||
value={imagePosition}
|
||
onChange={(e) => setImagePosition(e.target.value)}
|
||
>
|
||
<option value="center">居中</option>
|
||
<option value="top">顶部</option>
|
||
<option value="bottom">底部</option>
|
||
<option value="left">靠左</option>
|
||
<option value="right">靠右</option>
|
||
</select>
|
||
</div>
|
||
<p className="text-xs text-gray-400">可选择本地图片或粘贴 URL,默认尺寸 260×200。</p>
|
||
</div>
|
||
<div className="flex items-center justify-end gap-3 border-t px-4 py-3">
|
||
<button
|
||
className="rounded-md border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50"
|
||
onClick={() => setShowImageModal(false)}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
className="rounded-md bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||
onClick={handleImageConfirm}
|
||
>
|
||
插入
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null;
|
||
|
||
if (fullscreen) {
|
||
return (
|
||
<div className="relative h-screen w-screen overflow-hidden bg-white">
|
||
<div className="fixed left-1/2 top-4 z-20 flex w-fit -translate-x-1/2 transform items-center justify-center gap-4">
|
||
<MindmapToolbar {...toolbarProps} />
|
||
</div>
|
||
<div className="relative h-full w-full">
|
||
<div ref={containerRef} className="h-full w-full" />
|
||
|
||
<MindmapSidebarTrigger
|
||
activeSidebar={activeSidebar}
|
||
onSelect={setActiveSidebar}
|
||
/>
|
||
{/* @ts-expect-error mindmap type */}
|
||
<MindmapSidebar
|
||
mindmap={mindmap}
|
||
activeNodes={activeNodes} // @ts-expect-error type
|
||
activeTab={activeSidebar}
|
||
onClose={() => setActiveSidebar(null)}
|
||
/>
|
||
<MindmapNavigator
|
||
mindmap={mindmap}
|
||
fullscreen={fullscreen}
|
||
onToggleMiniMap={() => setShowMiniMap(!showMiniMap)}
|
||
miniMapOpen={showMiniMap}
|
||
/>
|
||
<MindmapCount mindmap={mindmap} />
|
||
{/* @ts-expect-error mindmap type */}
|
||
<MindmapMiniMap mindmap={mindmap} show={showMiniMap} />
|
||
{imgToolbar}
|
||
{noteModal}
|
||
{imageModal}
|
||
{imageViewer}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="not-prose my-4 w-full overflow-hidden rounded-xl border border-gray-200 bg-[#f8fafc] shadow-sm">
|
||
<div className="flex flex-col gap-3 border-b border-gray-100 bg-white px-4 py-3">
|
||
<div className="w-full">
|
||
<MindmapToolbar {...toolbarProps} />
|
||
</div>
|
||
</div>
|
||
<div className="relative h-[520px] w-full overflow-hidden bg-white">
|
||
<div ref={containerRef} className="h-full w-full" />
|
||
|
||
<MindmapSidebarTrigger
|
||
activeSidebar={activeSidebar}
|
||
onSelect={setActiveSidebar}
|
||
/>
|
||
{/* @ts-expect-error mindmap type */}
|
||
<MindmapSidebar
|
||
mindmap={mindmap}
|
||
activeNodes={activeNodes} // @ts-expect-error type
|
||
activeTab={activeSidebar}
|
||
onClose={() => setActiveSidebar(null)}
|
||
/>
|
||
<MindmapNavigator
|
||
mindmap={mindmap}
|
||
fullscreen={fullscreen}
|
||
onToggleMiniMap={() => setShowMiniMap(!showMiniMap)}
|
||
miniMapOpen={showMiniMap}
|
||
/>
|
||
<MindmapCount mindmap={mindmap} />
|
||
{/* @ts-expect-error mindmap type */}
|
||
<MindmapMiniMap mindmap={mindmap} show={showMiniMap} />
|
||
{imgToolbar}
|
||
{noteModal}
|
||
{imageModal}
|
||
{imageViewer}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export { MindmapBlockView };
|
||
|
||
export const mindmapBlock = createReactBlockSpec(
|
||
{
|
||
type: "mindmap",
|
||
propSchema: {
|
||
data: { default: defaultMindmapData },
|
||
},
|
||
content: "none",
|
||
},
|
||
{
|
||
render: (props) => <MindmapBlockView {...props} />,
|
||
},
|
||
);
|