feat(mindmap): 引入官方工具栏/侧栏并修复节点选择

This commit is contained in:
liaibo
2025-12-28 22:05:37 +08:00
parent bddef15242
commit af58c12d81
24 changed files with 2548 additions and 211 deletions
@@ -7,6 +7,12 @@ 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";
type MindMapInstance = {
execCommand: (...args: unknown[]) => void;
@@ -14,11 +20,21 @@ type MindMapInstance = {
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 };
renderer: { activeNodeList: unknown[] };
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 = {
@@ -48,11 +64,22 @@ const applyToActiveNodes = (
mindmap: MindMapInstance | null,
handler: (node: unknown) => void,
) => {
if (!mindmap?.renderer?.activeNodeList || mindmap.renderer.activeNodeList.length === 0) {
window.alert("请选择至少一个节点再执行该操作");
if (!mindmap) {
window.alert("思维导图尚未初始化");
return;
}
mindmap.renderer.activeNodeList.forEach(handler);
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 MindmapBlockView = ({
@@ -69,8 +96,17 @@ const MindmapBlockView = ({
const [mindmap, setMindmap] = useState<MindMapInstance | null>(null);
const [canBack, setCanBack] = useState(false);
const [canForward, setCanForward] = useState(false);
const [activeCount, setActiveCount] = useState(1);
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 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) {
@@ -99,6 +135,19 @@ const MindmapBlockView = ({
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 () => {
@@ -112,6 +161,10 @@ const MindmapBlockView = ({
{ default: Formula },
{ default: FormulaStyle },
{ default: RichText },
{ default: MiniMapPlugin },
{ default: Select },
{ default: Drag },
{ default: KeyboardNavigation },
] = await Promise.all([
import("simple-mind-map"),
import("simple-mind-map/src/plugins/Painter.js"),
@@ -121,9 +174,13 @@ const MindmapBlockView = ({
import("simple-mind-map/src/plugins/Formula.js"),
import("simple-mind-map/src/plugins/FormulaStyle.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"),
]);
[
const plugins = [
{ name: "Painter", plugin: Painter },
{ name: "AssociativeLine", plugin: AssociativeLine },
{ name: "OuterFrame", plugin: OuterFrame },
@@ -131,15 +188,21 @@ const MindmapBlockView = ({
{ name: "Formula", plugin: Formula },
{ name: "FormulaStyle", plugin: FormulaStyle },
{ name: "RichText", plugin: RichText },
].forEach(({ name, plugin }) => {
{ name: "MiniMap", plugin: MiniMapPlugin },
{ name: "Select", plugin: Select },
{ name: "Drag", plugin: Drag },
{ name: "KeyboardNavigation", plugin: KeyboardNavigation },
];
plugins.forEach(({ name, plugin }) => {
if (!plugin) {
console.warn(`思维导图插件加载失败:${name}`);
return;
}
// @ts-expect-error 第三方库缺少类型定义
if (MindMap.hasPlugin(plugin) === -1) {
console.log(`注册插件: ${name}`);
// @ts-expect-error 第三方库缺少类型定义
// eslint-disable-next-line react-hooks/rules-of-hooks
MindMap.usePlugin(plugin);
}
});
@@ -153,7 +216,9 @@ const MindmapBlockView = ({
enableFreeDrag: true,
enableCtrlKeyNodeSelection: true,
fit: true,
useLeftKeySelectionRightKeyDrag: true,
}) as MindMapInstance;
instance.setMode?.("edit");
if (typeof window !== "undefined") {
// 便于开发阶段在控制台直接调试实例
@@ -161,40 +226,42 @@ const MindmapBlockView = ({
window.__mindmapInstance = instance;
}
instance.execCommand("RESET_LAYOUT");
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[]) => {
console.debug("node_active", list?.length ?? 0);
setActiveCount(list.length);
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 = instance.renderer?.renderTree?._node;
if (rootNode && (!instance.renderer.activeNodeList || instance.renderer.activeNodeList.length === 0)) {
instance.renderer.activeNodeList = [rootNode];
}
const initialActive =
(instance.renderer && instance.renderer.activeNodeList && instance.renderer.activeNodeList.length) ||
(rootNode ? 1 : 0);
if (initialActive > 0) {
setActiveCount(initialActive);
}
const rootNode = getRootNode(instance);
if (rootNode) {
setTimeout(() => {
if (instance.renderer) {
instance.renderer.activeNodeList = [rootNode];
instance.emit?.("node_active", rootNode, [rootNode]);
setActiveCount(1);
}
}, 0);
setActiveNodes([rootNode]);
}
if (destroyed) {
@@ -221,12 +288,36 @@ const MindmapBlockView = ({
}
mindmap.painter.startPainter();
};
const handleSibling = () => mindmap?.execCommand("INSERT_NODE");
const handleChild = () => mindmap?.execCommand("INSERT_CHILD_NODE");
const handleDelete = () => mindmap?.execCommand("REMOVE_NODE");
const handleSummary = () => mindmap?.execCommand("ADD_GENERALIZATION");
const handleAssociativeLine = () => mindmap?.execCommand("ADD_ASSOCIATIVE_LINE");
const handleOuterFrame = () => mindmap?.execCommand("ADD_OUTER_FRAME");
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();
if (mm) {
window.setTimeout(() => mm.execCommand("ADD_GENERALIZATION"), 0);
}
};
const handleAssociativeLine = () => {
const mm = ensureActiveBefore();
if (mm) {
window.setTimeout(() => mm.execCommand("ADD_ASSOCIATIVE_LINE"), 0);
}
};
const handleOuterFrame = () => {
const mm = ensureActiveBefore();
if (mm) {
window.setTimeout(() => mm.execCommand("ADD_OUTER_FRAME"), 0);
}
};
const handleImage = () => {
const url = createSimplePrompt("请输入图片链接");
@@ -373,8 +464,29 @@ const MindmapBlockView = ({
<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="absolute inset-0">
<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} />
</div>
</div>
);
@@ -389,6 +501,27 @@ const MindmapBlockView = ({
</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} />
</div>
</div>
);
@@ -407,4 +540,4 @@ export const mindmapBlock = createReactBlockSpec(
{
render: (props) => <MindmapBlockView {...props} />,
},
);
);
@@ -0,0 +1,72 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useEffect, useState } from "react";
type CountProps = {
mindmap: any;
};
export const MindmapCount = ({ mindmap }: CountProps) => {
const [counts, setCounts] = useState({ words: 0, nodes: 0 });
useEffect(() => {
if (!mindmap) return;
const calculateCounts = (data: any) => {
let nodes = 0;
let textStr = "";
const walk = (nodeData: any) => {
if (!nodeData) return;
nodes++;
// simple-mind-map node data structure: { data: { text: ... }, children: [...] }
const text = String(nodeData.data?.text || "");
textStr += text;
if (nodeData.children && Array.isArray(nodeData.children)) {
nodeData.children.forEach(walk);
}
};
walk(data);
// Remove HTML tags for word count
const tempDiv = document.createElement("div");
tempDiv.innerHTML = textStr;
const words = tempDiv.textContent?.length || 0;
setCounts({ words, nodes });
};
const onDataChange = (data: any) => {
calculateCounts(data);
};
mindmap.on("data_change", onDataChange);
// Initial calculation if data is available
// mindmap.getData() might return full data object
try {
const initialData = mindmap.getData();
if (initialData) calculateCounts(initialData);
} catch (e) {
console.warn("Failed to get initial mindmap data for count", e);
}
return () => {
mindmap.off("data_change", onDataChange);
};
}, [mindmap]);
return (
<div className="absolute bottom-4 left-4 flex items-center gap-4 rounded-md border border-gray-200 bg-white/90 px-3 py-1 text-xs text-gray-600 shadow-sm backdrop-blur-sm select-none z-10">
<div className="flex items-center gap-1">
<span></span>
<span className="font-medium text-gray-900">{counts.words}</span>
</div>
<div className="flex items-center gap-1">
<span></span>
<span className="font-medium text-gray-900">{counts.nodes}</span>
</div>
</div>
);
};
@@ -0,0 +1,101 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable jsx-a11y/alt-text */
/* eslint-disable @next/next/no-img-element */
import React, { useEffect, useRef, useState } from "react";
type MiniMapProps = {
mindmap: any;
show: boolean;
};
export const MindmapMiniMap = ({ mindmap, show }: MiniMapProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const [imgUrl, setImgUrl] = useState("");
const [viewBoxStyle, setViewBoxStyle] = useState<any>({});
const [svgBoxStyle, setSvgBoxStyle] = useState<any>({});
useEffect(() => {
if (!mindmap || !show || !containerRef.current) return;
let timer: NodeJS.Timeout;
const drawMiniMap = () => {
if (!containerRef.current || !mindmap.miniMap) return;
const { width, height } = containerRef.current.getBoundingClientRect();
// simple-mind-map miniMap plugin API check
// Assuming calculationMiniMap exists
if (typeof mindmap.miniMap.calculationMiniMap !== 'function') return;
const {
getImgUrl,
viewBoxStyle: newViewBoxStyle,
miniMapBoxScale,
miniMapBoxLeft,
miniMapBoxTop
} = mindmap.miniMap.calculationMiniMap(width, height);
if (getImgUrl) {
getImgUrl((img: string) => {
setImgUrl(img);
});
}
setViewBoxStyle(newViewBoxStyle);
setSvgBoxStyle({
transform: `scale(${miniMapBoxScale})`,
left: miniMapBoxLeft,
top: miniMapBoxTop,
});
};
// Initial draw with delay to ensure rendering
timer = setTimeout(drawMiniMap, 300);
const onDataChange = () => {
clearTimeout(timer);
timer = setTimeout(drawMiniMap, 500);
};
const onViewBoxPositionChange = (pos: any) => {
setViewBoxStyle((prev: any) => ({ ...prev, ...pos }));
};
// Listeners
mindmap.on('data_change', onDataChange);
mindmap.on('view_data_change', onDataChange);
mindmap.on('node_tree_render_end', onDataChange);
mindmap.on('mini_map_view_box_position_change', onViewBoxPositionChange);
return () => {
clearTimeout(timer);
mindmap.off('data_change', onDataChange);
mindmap.off('view_data_change', onDataChange);
mindmap.off('node_tree_render_end', onDataChange);
mindmap.off('mini_map_view_box_position_change', onViewBoxPositionChange);
};
}, [mindmap, show]);
if (!show) return null;
return (
<div
ref={containerRef}
className="absolute bottom-20 right-4 h-48 w-64 border border-gray-200 bg-white shadow-lg rounded overflow-hidden cursor-pointer z-50 select-none"
onMouseDown={(e) => mindmap?.miniMap?.onMousedown(e)}
onMouseMove={(e) => mindmap?.miniMap?.onMousemove(e)}
onMouseUp={(e) => mindmap?.miniMap?.onMouseup(e)}
onMouseLeave={(e) => mindmap?.miniMap?.onMouseup(e)} // Fallback for leaving container
>
<div className="absolute origin-top-left" style={svgBoxStyle}>
<img src={imgUrl} draggable={false} className="select-none pointer-events-none" />
</div>
<div
className="absolute border-2 border-red-500 bg-red-500/20"
style={viewBoxStyle}
onMouseDown={(e) => { e.stopPropagation(); mindmap?.miniMap?.onViewBoxMousedown(e); }}
onMouseMove={(e) => { e.stopPropagation(); mindmap?.miniMap?.onViewBoxMousemove(e); }}
/>
</div>
);
};
@@ -0,0 +1,129 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useState, useEffect } from "react";
import { Minus, Plus, Maximize, Map, MapPin, Minimize, Eye, EyeOff } from "lucide-react";
type NavigatorProps = {
mindmap: any;
fullscreen: boolean;
toggleFullscreen?: () => void; // Optional for now
onToggleMiniMap?: () => void;
miniMapOpen?: boolean;
};
export const MindmapNavigator = ({
mindmap,
fullscreen,
toggleFullscreen,
onToggleMiniMap,
miniMapOpen,
}: NavigatorProps) => {
const [scale, setScale] = useState(100);
const [isReadonly, setIsReadonly] = useState(false);
useEffect(() => {
if (!mindmap) return;
const handleScale = (s: number) => {
setScale(Math.round(s * 100));
};
mindmap.on("scale", handleScale);
// eslint-disable-next-line react-hooks/set-state-in-effect
setScale(Math.round(mindmap.view.scale * 100));
// Handle readonly state sync if needed
// mindmap.on("mode_change", (mode) => setIsReadonly(mode === 'readonly'));
return () => {
mindmap.off("scale", handleScale);
};
}, [mindmap]);
const handleZoomIn = () => mindmap?.view.enlarge();
const handleZoomOut = () => mindmap?.view.narrow();
const handleCenter = () => mindmap?.renderer.setRootNodeCenter();
const handleReadonly = () => {
const newMode = !isReadonly;
setIsReadonly(newMode);
mindmap?.setMode(newMode ? 'readonly' : 'edit');
};
const handleScaleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseInt(e.target.value);
if (!isNaN(val) && val > 0) {
setScale(val);
const cx = mindmap.width / 2;
const cy = mindmap.height / 2;
mindmap.view.setScale(val / 100, cx, cy);
}
};
return (
<div className="absolute bottom-4 right-4 flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-2 shadow-md text-gray-600">
<button
onClick={handleCenter}
className="rounded p-1 hover:bg-gray-100"
title="回到中心"
>
<MapPin className="h-4 w-4" />
</button>
<div className="mx-1 h-4 w-px bg-gray-200" />
<button
onClick={onToggleMiniMap}
className={`rounded p-1 hover:bg-gray-100 ${miniMapOpen ? "bg-blue-50 text-blue-600" : ""}`}
title={miniMapOpen ? "关闭小地图" : "打开小地图"}
>
<Map className="h-4 w-4" />
</button>
<button
onClick={handleReadonly}
className={`rounded p-1 hover:bg-gray-100 ${isReadonly ? "bg-blue-50 text-blue-600" : ""}`}
title={isReadonly ? "切换编辑模式" : "切换只读模式"}
>
{isReadonly ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
{toggleFullscreen && (
<button
onClick={toggleFullscreen}
className="rounded p-1 hover:bg-gray-100"
title={fullscreen ? "退出全屏" : "全屏"}
>
{fullscreen ? <Minimize className="h-4 w-4" /> : <Maximize className="h-4 w-4" />}
</button>
)}
<div className="mx-1 h-4 w-px bg-gray-200" />
<button
onClick={handleZoomOut}
className="rounded p-1 hover:bg-gray-100"
title="缩小"
>
<Minus className="h-4 w-4" />
</button>
<div className="flex items-center text-xs">
<input
type="number"
value={scale}
onChange={handleScaleInput}
className="w-8 text-center bg-transparent outline-none appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<span>%</span>
</div>
<button
onClick={handleZoomIn}
className="rounded p-1 hover:bg-gray-100"
title="放大"
>
<Plus className="h-4 w-4" />
</button>
</div>
);
};
@@ -0,0 +1,679 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useEffect, useMemo, useState } from "react";
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 {
fontFamilyList,
fontSizeList,
lineHeightList,
colorList,
borderDasharrayList,
borderRadiusList,
borderWidthList,
lineWidthList,
shapeList,
lineStyleList,
themeList,
layoutList,
outlineDepthOptions,
} from "./mindmapOptions";
type MindMapNode = {
getStyle: (prop: string, checkRoot?: boolean) => any;
setStyle: (prop: string, value: any) => void;
setIcon: (icons: string[]) => void;
getData: (key: string) => any;
};
type SidebarProps = {
mindmap: any;
activeNodes: MindMapNode[];
activeTab: SidebarPanel | null;
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,
label,
}: {
value: string;
onChange: (val: string) => void;
label?: string;
}) => (
<div className="flex items-center gap-2">
{label && <span className="text-xs text-gray-500">{label}</span>}
<div className="relative h-6 w-full flex-1 overflow-hidden rounded border border-gray-200">
<input
type="color"
value={value || "#000000"}
onChange={(e) => onChange(e.target.value)}
className="absolute inset-[-50%] h-[200%] w-[200%] cursor-pointer border-0 p-0"
/>
</div>
</div>
);
const NativeSelect = ({
value,
onChange,
options,
placeholder,
disabled,
}: {
value: string | number;
onChange: (val: string) => void;
options: { label: string | number; value: string | number }[];
placeholder?: string;
disabled?: boolean;
}) => (
<div className="relative">
<select
disabled={disabled}
value={value}
onChange={(e) => onChange(e.target.value)}
className="flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 appearance-none"
>
{placeholder && <option value="" disabled>{placeholder}</option>}
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
const StylePanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
const [style, setStyle] = useState<Record<string, any>>({});
useEffect(() => {
if (activeNodes.length > 0) {
const node = activeNodes[0];
const newStyle: Record<string, any> = {};
[
"fontFamily", "fontSize", "lineHeight", "color", "fontWeight", "fontStyle",
"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);
}
}, [activeNodes]);
const updateStyle = (prop: string, value: any) => {
setStyle((prev) => ({ ...prev, [prop]: value }));
activeNodes.forEach((node) => {
node.setStyle(prop, value);
});
};
if (activeNodes.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center p-4 text-center text-gray-400">
<Type className="mb-2 h-10 w-10 opacity-20" />
<p className="text-sm"></p>
</div>
);
}
return (
<div className="space-y-6">
<div className="space-y-3">
<Label className="text-xs font-semibold text-gray-500"></Label>
<NativeSelect
value={style.fontFamily || fontFamilyList[0]?.value}
options={fontFamilyList.map((f) => ({ label: f.name, value: f.value }))}
onChange={(v) => updateStyle("fontFamily", v)}
/>
<div className="grid grid-cols-2 gap-2">
<NativeSelect
value={style.fontSize || 14}
options={fontSizeList.map((s) => ({ label: `${s}px`, value: s }))}
onChange={(v) => updateStyle("fontSize", Number(v))}
/>
<NativeSelect
value={style.lineHeight || 1.5}
options={lineHeightList.map((h) => ({ label: h, value: h }))}
onChange={(v) => updateStyle("lineHeight", Number(v))}
/>
</div>
<div className="flex items-center gap-2">
<Toggle
size="sm"
pressed={style.fontWeight === "bold"}
onPressedChange={(pressed) => updateStyle("fontWeight", pressed ? "bold" : "normal")}
>
<Bold className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={style.fontStyle === "italic"}
onPressedChange={(pressed) => updateStyle("fontStyle", pressed ? "italic" : "normal")}
>
<Italic className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={style.textDecoration === "underline"}
onPressedChange={(pressed) => updateStyle("textDecoration", pressed ? "underline" : "none")}
>
<Underline className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={style.textDecoration === "line-through"}
onPressedChange={(pressed) => updateStyle("textDecoration", pressed ? "line-through" : "none")}
>
<Strikethrough className="h-4 w-4" />
</Toggle>
</div>
<ColorInput
label="颜色"
value={style.color}
onChange={(v) => updateStyle("color", v)}
/>
</div>
<div className="space-y-3">
<Label className="text-xs font-semibold text-gray-500"> & </Label>
<div className="grid grid-cols-2 gap-2">
<NativeSelect
value={style.shape || "rectangle"}
options={shapeList.map(s => ({label: s.name, value: s.value}))}
onChange={(v) => updateStyle("shape", v)}
/>
<NativeSelect
value={style.borderDasharray || "none"}
options={borderDasharrayList.map(s => ({label: s.name, value: s.value}))}
onChange={(v) => updateStyle("borderDasharray", v)}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<NativeSelect
value={Number.isFinite(style.borderWidth) ? style.borderWidth : (style.borderWidth ?? 0)}
options={borderWidthList.map((n) => ({ label: `${n}px`, value: n }))}
onChange={(v) => updateStyle("borderWidth", Number(v))}
/>
<NativeSelect
value={Number.isFinite(style.borderRadius) ? style.borderRadius : (style.borderRadius ?? 0)}
options={borderRadiusList.map((n) => ({ label: `${n}px`, value: n }))}
onChange={(v) => updateStyle("borderRadius", Number(v))}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<ColorInput
label="边框"
value={style.borderColor}
onChange={(v) => updateStyle("borderColor", v)}
/>
<ColorInput
label="填充"
value={style.fillColor}
onChange={(v) => updateStyle("fillColor", v)}
/>
</div>
</div>
<div className="space-y-3">
<Label className="text-xs font-semibold text-gray-500">线</Label>
<div className="grid grid-cols-2 gap-2">
<NativeSelect
value={style.lineDasharray || "none"}
options={borderDasharrayList.map(s => ({label: s.name, value: s.value}))}
onChange={(v) => updateStyle("lineDasharray", v)}
/>
<NativeSelect
value={Number.isFinite(style.lineWidth) ? style.lineWidth : (style.lineWidth ?? 0)}
options={lineWidthList.map((n) => ({ label: `${n}px`, value: n }))}
onChange={(v) => updateStyle("lineWidth", Number(v))}
/>
</div>
<ColorInput
label="连线颜色"
value={style.lineColor}
onChange={(v) => updateStyle("lineColor", v)}
/>
</div>
</div>
);
};
const BaseStylePanel = ({ mindmap }: { mindmap: any }) => {
const [style, setStyle] = useState<Record<string, any>>({});
useEffect(() => {
if (!mindmap) return;
const initStyle = () => {
const newStyle: Record<string, any> = {};
[
"backgroundColor", "lineColor", "lineWidth", "lineStyle",
"paddingX", "paddingY"
].forEach(key => {
newStyle[key] = mindmap.getThemeConfig(key);
});
setStyle(newStyle);
};
initStyle();
}, [mindmap]);
const updateThemeConfig = (key: string, value: any) => {
if (!mindmap) return;
setStyle(prev => ({ ...prev, [key]: value }));
mindmap.setThemeConfig({ [key]: value });
};
if (!mindmap) return null;
return (
<div className="space-y-6">
<div className="space-y-3">
<Label className="text-xs font-semibold text-gray-500"></Label>
<ColorInput
label="背景颜色"
value={style.backgroundColor}
onChange={(v) => updateThemeConfig("backgroundColor", v)}
/>
</div>
<div className="space-y-3">
<Label className="text-xs font-semibold text-gray-500">线 ()</Label>
<div className="grid grid-cols-2 gap-2">
<NativeSelect
value={style.lineStyle || "curve"}
options={lineStyleList.map(s => ({ label: s.name, value: s.value }))}
onChange={(v) => updateThemeConfig("lineStyle", v)}
/>
<NativeSelect
value={Number.isFinite(style.lineWidth) ? style.lineWidth : (style.lineWidth ?? 0)}
options={lineWidthList.map((n) => ({ label: `${n}px`, value: n }))}
onChange={(v) => updateThemeConfig("lineWidth", Number(v))}
/>
</div>
<ColorInput
label="连线颜色"
value={style.lineColor}
onChange={(v) => updateThemeConfig("lineColor", v)}
/>
</div>
<div className="space-y-3">
<Label className="text-xs font-semibold text-gray-500"> ()</Label>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 whitespace-nowrap"></span>
<Input
type="number"
className="h-8"
min={0}
value={parseInt(style.paddingX) || 0}
onChange={e => updateThemeConfig("paddingX", Number(e.target.value))}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 whitespace-nowrap"></span>
<Input
type="number"
className="h-8"
min={0}
value={parseInt(style.paddingY) || 0}
onChange={e => updateThemeConfig("paddingY", Number(e.target.value))}
/>
</div>
</div>
</div>
</div>
);
};
const ThemePanel = ({ mindmap }: { mindmap: any }) => {
const changeTheme = (theme: string) => {
if (!mindmap) return;
mindmap.setTheme(theme);
};
if (!mindmap) return null;
return (
<div className="grid grid-cols-2 gap-3">
{themeList.map(t => (
<button
key={t.value}
onClick={() => changeTheme(t.value)}
className="flex flex-col items-center justify-center rounded border border-gray-100 bg-gray-50 p-3 hover:border-blue-500 hover:bg-blue-50"
>
<Palette className="mb-2 h-6 w-6 text-gray-400" />
<span className="text-xs text-gray-600">{t.name}</span>
</button>
))}
</div>
)
}
const StructurePanel = ({ mindmap }: { mindmap: any }) => {
const changeLayout = (layout: string) => {
if (!mindmap) return;
mindmap.setLayout(layout);
};
if (!mindmap) return null;
return (
<div className="grid grid-cols-2 gap-3">
{layoutList.map(l => (
<button
key={l.value}
onClick={() => changeLayout(l.value)}
className="flex flex-col items-center justify-center rounded border border-gray-100 bg-gray-50 p-3 hover:border-blue-500 hover:bg-blue-50"
>
<Network className="mb-2 h-6 w-6 text-gray-400" />
<span className="text-xs text-gray-600">{l.name}</span>
</button>
))}
</div>
)
}
const IconPanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => {
const addIcon = (type: string, name: string) => {
// simple-mind-map icon key format usually: type_name (e.g. priority_1)
const key = `${type}_${name}`;
activeNodes.forEach(node => {
const icons = node.getData('icon') || [];
// Remove existing icon of same type
const newIcons = icons.filter((i: string) => !i.startsWith(type + '_'));
newIcons.push(key);
node.setIcon(newIcons);
});
};
const removeIcon = (type: string) => {
activeNodes.forEach(node => {
const icons = node.getData('icon') || [];
const newIcons = icons.filter((i: string) => !i.startsWith(type + '_'));
node.setIcon(newIcons);
});
}
if (activeNodes.length === 0) return <div className="p-4 text-center text-gray-400"></div>;
return (
<div className="space-y-4 py-2">
{predefinedIcons.map(group => (
<div key={group.type}>
<div className="mb-2 flex items-center justify-between">
<Label className="text-xs font-bold text-gray-600">{group.name}</Label>
<button onClick={() => removeIcon(group.type)} className="text-[10px] text-red-500 hover:underline"></button>
</div>
<div className="flex flex-wrap gap-2">
{group.list.map(item => (
<button
key={item}
onClick={() => addIcon(group.type, item)}
className="flex h-8 w-8 items-center justify-center rounded border border-gray-200 bg-gray-50 text-xs hover:bg-blue-50 hover:border-blue-300"
>
{item}
</button>
))}
</div>
</div>
))}
<div className="text-xs text-gray-400 mt-4">
* SVG
</div>
</div>
);
}
const OutlinePanel = ({ mindmap }: { mindmap: any }) => {
const [depth, setDepth] = useState<number>(-1);
const nodes = useMemo(() => {
const root = mindmap?.renderer?.renderTree?._node;
const list: { node: any; text: string; depth: number }[] = [];
const walk = (n: any, d: number) => {
if (!n) return;
const raw =
n.nodeData?.data?.text ||
n.nodeData?.text ||
n.data?.text ||
n.data?.data?.text ||
"未命名";
const text =
typeof raw === "string"
? raw.replace(/<[^>]+>/g, "").trim() || "未命名"
: "未命名";
list.push({ node: n, text, depth: d });
(n.children || []).forEach((c: any) => walk(c, d + 1));
};
if (root) walk(root, 0);
return list;
}, [mindmap]);
const visibleNodes = useMemo(
() => nodes.filter((item) => depth < 0 || item.depth < depth),
[nodes, depth],
);
const activate = (node: any) => {
const r = mindmap?.renderer;
if (!node || !r) return;
r.activeNodeList = [node];
r.lastActiveNodeList = [node];
mindmap.emit?.("node_active", node, [node]);
r.setRootNodeCenter?.();
};
if (!nodes.length) return <div className="p-4 text-sm text-gray-400"></div>;
return (
<div className="space-y-3">
<div>
<Label className="text-xs text-gray-500"></Label>
<NativeSelect
value={depth}
options={outlineDepthOptions.map((o) => ({ label: o.name, value: o.value }))}
onChange={(v) => setDepth(Number(v))}
/>
</div>
<div className="max-h-[70vh] space-y-1 overflow-auto pr-2">
{visibleNodes.map((item, idx) => (
<button
key={`${item.text}-${idx}`}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1 text-left text-sm hover:bg-gray-50"
onClick={() => activate(item.node)}
>
<span className="text-gray-300" style={{ paddingLeft: item.depth * 12 }}>
{"•".repeat(Math.max(1, item.depth + 1))}
</span>
<span className="text-gray-700">{item.text}</span>
</button>
))}
</div>
</div>
);
};
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 updateFreeDrag = (val: boolean) => {
setFreeDrag(val);
if (mindmap) mindmap.opt.enableFreeDrag = val;
};
const updateWheel = (val: string) => {
setWheel(val);
if (mindmap) mindmap.opt.mousewheelAction = 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>
<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>
<div className="flex items-center justify-between">
<Label className="text-xs text-gray-500">AI </Label>
<Toggle size="sm" pressed={aiOn} onPressedChange={setAiOn}>
{aiOn ? "开" : "关"}
</Toggle>
</div>
<p className="text-xs text-gray-400"></p>
</div>
);
};
const FormulaPanel = ({ mindmap }: { mindmap: any }) => {
const [formula, setFormula] = useState("a^2+b^2=c^2");
const insert = () => {
const active = mindmap?.renderer?.activeNodeList || [];
mindmap?.execCommand?.("INSERT_FORMULA", formula, active);
};
return (
<div className="space-y-3">
<Label className="text-xs text-gray-500">LaTeX </Label>
<Input value={formula} onChange={(e) => setFormula(e.target.value)} />
<button className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600" onClick={insert}>
</button>
</div>
);
};
const NotePanel = ({ mindmap }: { mindmap: any }) => {
const [note, setNote] = useState("备注内容");
const apply = () => {
const active = mindmap?.renderer?.activeNodeList || [];
active.forEach((node: any) => mindmap?.execCommand?.("SET_NODE_NOTE", node, note));
};
return (
<div className="space-y-3">
<Label className="text-xs text-gray-500"></Label>
<Input value={note} onChange={(e) => setNote(e.target.value)} />
<button className="w-full rounded-md bg-blue-500 px-3 py-2 text-sm text-white hover:bg-blue-600" onClick={apply}>
</button>
</div>
);
};
const AiPanel = () => {
const [prompt, setPrompt] = useState("");
const send = () => window.alert("AI 对话占位,后续接入大模型:\n" + prompt);
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 生成/优化节点"
/>
<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>
);
};
import { sidebarTitles, type SidebarPanel } from "./mindmapSidebarConfig";
export const MindmapSidebar = ({ mindmap, activeNodes, activeTab, onClose }: SidebarProps) => {
const content = useMemo(() => {
switch (activeTab as SidebarPanel | null) {
case "style":
return <StylePanel activeNodes={activeNodes} />;
case "base":
return <BaseStylePanel mindmap={mindmap} />;
case "theme":
return <ThemePanel mindmap={mindmap} />;
case "structure":
return <StructurePanel mindmap={mindmap} />;
case "icons":
return <IconPanel activeNodes={activeNodes} />;
case "outline":
return <OutlinePanel mindmap={mindmap} />;
case "settings":
return <SettingsPanel mindmap={mindmap} />;
case "formula":
return <FormulaPanel mindmap={mindmap} />;
case "note":
return <NotePanel mindmap={mindmap} />;
case "ai":
return <AiPanel />;
default:
return null;
}
}, [activeTab, mindmap, activeNodes]);
const title = useMemo(() => {
if (!activeTab) return "";
return sidebarTitles[activeTab as SidebarPanel] ?? "";
}, [activeTab]);
return (
<div
className={`absolute top-0 bottom-0 right-0 z-20 w-[300px] flex flex-col border-l border-gray-200 bg-white transition-transform duration-300 ease-in-out shadow-lg ${
activeTab ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="flex items-center justify-between border-b px-4 py-3 shrink-0">
<span className="font-medium text-gray-700">{title}</span>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4">
{content}
</div>
</div>
);
};
@@ -0,0 +1,49 @@
import React from "react";
import { ChevronRight } from "lucide-react";
import { sidebarTriggers, type SidebarPanel } from "./mindmapSidebarConfig";
type Props = {
activeSidebar: SidebarPanel | null;
onSelect: (value: SidebarPanel | null) => void;
};
export const MindmapSidebarTrigger = ({ activeSidebar, onSelect }: Props) => {
const [show, setShow] = React.useState(true);
return (
<div
className={`absolute top-1/2 z-30 flex -translate-y-1/2 transition-all duration-300 ${
activeSidebar ? "right-[300px]" : "right-0"
}`}
>
<div
className="absolute -left-4 top-1/2 flex h-12 w-4 -translate-y-1/2 cursor-pointer items-center justify-center rounded-l-md bg-blue-500 text-white shadow-md hover:w-6 hover:-left-6 transition-all"
onClick={() => setShow(!show)}
style={{ display: show ? "flex" : "none" }}
>
<ChevronRight className="h-3 w-3" />
</div>
<div className="flex flex-col overflow-hidden rounded-l-lg border border-gray-200 bg-white shadow-lg">
{sidebarTriggers
.filter((item) => item.visible !== false)
.map((item) => {
const target = item.target;
const isActive = activeSidebar === target;
return (
<button
key={item.value}
onClick={() => onSelect(isActive ? null : target)}
className={`flex h-16 w-16 flex-col items-center justify-center gap-1 border-b border-gray-100 p-2 text-gray-600 transition-colors hover:bg-gray-50 last:border-0 ${
isActive ? "bg-blue-50 text-blue-600 font-medium" : ""
}`}
>
<i className={`iconfont ${item.iconClass} text-[18px] leading-none`} />
<span className="text-xs whitespace-nowrap">{item.label}</span>
</button>
);
})}
</div>
</div>
);
};
@@ -1,26 +1,12 @@
import React from "react";
import {
BoxSelect,
Braces,
Cable,
Download,
FileText,
FolderOpen,
Image as ImageIcon,
Link as LinkIcon,
PaintRoller,
Paperclip,
Plus,
Redo2,
Save,
Sigma,
Smile,
Sparkles,
StickyNote,
Tag,
Trash2,
Undo2,
} from "lucide-react";
fileToolbarMeta,
fileToolbarOrder,
nodeToolbarMeta,
nodeToolbarOrder,
type FileToolbarKey,
type NodeToolbarKey,
} from "./mindmapToolbarConfig";
type ToolbarProps = {
canBack: boolean;
@@ -43,6 +29,7 @@ type ToolbarProps = {
onFormula: () => void;
onAttachment: () => void;
onOuterFrame: () => void;
onAnnotation?: () => void;
onAi: () => void;
onImport: (e: React.ChangeEvent<HTMLInputElement>) => void;
onNew: () => void;
@@ -54,14 +41,14 @@ type ToolbarProps = {
};
const ToolbarButton = ({
icon: Icon,
iconClass,
label,
onClick,
disabled = false,
active = false,
className = "",
}: {
icon: React.ElementType;
iconClass: string;
label: string;
onClick?: () => void;
disabled?: boolean;
@@ -82,9 +69,11 @@ const ToolbarButton = ({
active ? "border-blue-200 bg-white" : "border-gray-200 bg-white"
}`}
>
<Icon className="h-4 w-4" />
<i className={`iconfont ${iconClass} text-[15px] leading-none`} />
</div>
<span className="text-[10px] font-medium leading-tight">{label}</span>
<span className="text-[10px] font-medium leading-tight whitespace-nowrap">
{label}
</span>
</button>
);
@@ -111,6 +100,7 @@ export const MindmapToolbar = ({
onFormula,
onAttachment,
onOuterFrame,
onAnnotation,
onAi,
onImport,
onNew,
@@ -120,133 +110,77 @@ export const MindmapToolbar = ({
onExportPng,
fileInputRef,
}: ToolbarProps) => {
const nodeHandlers: Record<NodeToolbarKey, () => void> = {
back: onUndo,
forward: onRedo,
painter: onPainter,
siblingNode: onSibling,
childNode: onChild,
deleteNode: onDelete,
image: onImage,
icon: onIcon,
link: onLink,
note: onNote,
tag: onTag,
summary: onSummary,
associativeLine: onAssociativeLine,
formula: onFormula,
attachment: onAttachment,
outerFrame: onOuterFrame,
annotation: onAnnotation ?? (() => window.alert("标注功能开发中")),
ai: onAi,
};
const fileHandlers: Record<FileToolbarKey, () => void> = {
directory: onOpenDirectory,
newFile: onNew,
openFile: () => fileInputRef.current?.click(),
import: () => fileInputRef.current?.click(),
saveAs: onSaveAs,
exportJson: onExportJson,
exportPng: onExportPng,
};
const getNodeDisabled = (key: NodeToolbarKey) => {
if (key === "back") return !canBack;
if (key === "forward") return !canForward;
return false;
};
return (
<div className="flex w-full items-center justify-between gap-4 overflow-x-auto pb-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-gray-200">
<div className="flex w-full items-center justify-between gap-4 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
{/* Left Section: Edit & Node Operations */}
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-gray-100 bg-white px-2 py-1 shadow-sm">
<ToolbarButton
icon={Undo2}
label="回退"
onClick={onUndo}
disabled={!canBack}
/>
<ToolbarButton
icon={Redo2}
label="前进"
onClick={onRedo}
disabled={!canForward}
/>
<ToolbarButton
icon={PaintRoller}
label="格式刷"
onClick={onPainter}
active={painterMode}
/>
<Divider />
<ToolbarButton
icon={Plus} // Sibling
label="同级"
onClick={onSibling}
disabled={!activeCount}
/>
<ToolbarButton
icon={Sparkles} // Child
label="下级"
onClick={onChild}
disabled={!activeCount}
/>
<ToolbarButton
icon={Trash2}
label="删除"
onClick={onDelete}
disabled={!activeCount}
/>
<Divider />
<ToolbarButton
icon={ImageIcon}
label="图片"
onClick={onImage}
disabled={!activeCount}
/>
<ToolbarButton
icon={Smile}
label="图标"
onClick={onIcon}
disabled={!activeCount}
/>
<ToolbarButton
icon={LinkIcon}
label="链接"
onClick={onLink}
disabled={!activeCount}
/>
<ToolbarButton
icon={StickyNote}
label="备注"
onClick={onNote}
disabled={!activeCount}
/>
<ToolbarButton
icon={Tag}
label="标签"
onClick={onTag}
disabled={!activeCount}
/>
<ToolbarButton
icon={Braces}
label="概要"
onClick={onSummary}
disabled={!activeCount}
/>
<ToolbarButton
icon={Cable}
label="关联线"
onClick={onAssociativeLine}
disabled={!activeCount}
/>
<ToolbarButton
icon={Sigma}
label="公式"
onClick={onFormula}
disabled={!activeCount}
/>
<ToolbarButton
icon={Paperclip}
label="附件"
onClick={onAttachment}
disabled={!activeCount}
/>
<ToolbarButton
icon={BoxSelect}
label="外框"
onClick={onOuterFrame}
disabled={!activeCount}
/>
<ToolbarButton
icon={Sparkles} // AI
label="AI"
onClick={onAi}
/>
{nodeToolbarOrder.map((key) => {
const meta = nodeToolbarMeta[key];
const onClick = nodeHandlers[key];
return (
<ToolbarButton
key={key}
iconClass={meta.iconClass}
label={meta.label}
onClick={onClick}
disabled={getNodeDisabled(key)}
active={key === "painter" ? painterMode : false}
/>
);
})}
</div>
{/* Right Section: File & Export Actions */}
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-gray-100 bg-white px-2 py-1 shadow-sm">
<ToolbarButton
icon={FolderOpen}
label="目录"
onClick={onOpenDirectory}
/>
<ToolbarButton icon={FileText} label="新建" onClick={onNew} />
<ToolbarButton
icon={FolderOpen}
label="打开"
onClick={() => fileInputRef.current?.click()}
/>
{/* Hidden Input */}
{fileToolbarOrder.map((key) => {
const meta = fileToolbarMeta[key];
const onClick = fileHandlers[key];
return (
<ToolbarButton
key={key}
iconClass={meta.iconClass}
label={meta.label}
onClick={onClick}
/>
);
})}
<input
ref={fileInputRef}
type="file"
@@ -254,25 +188,7 @@ export const MindmapToolbar = ({
className="hidden"
onChange={onImport}
/>
<Divider />
<ToolbarButton
icon={Save} // Save As
label="另存为"
onClick={onSaveAs}
/>
<ToolbarButton
icon={Download} // Export
label="导出"
onClick={onExportJson}
/>
<ToolbarButton
icon={ImageIcon} // Export PNG
label="PNG"
onClick={onExportPng}
/>
</div>
</div>
);
};
};
@@ -0,0 +1,121 @@
// 从官方 cankao/lx-doc-main/mind-map/src/config/zh.js 拷贝核心列表,供 React 侧栏复用
export const fontFamilyList = [
{ name: "宋体", value: "宋体, SimSun, Songti SC" },
{ name: "微软雅黑", value: "微软雅黑, Microsoft YaHei" },
{ name: "楷体", value: "楷体, 楷体_GB2312, SimKai, STKaiti" },
{ name: "黑体", value: "黑体, SimHei, Heiti SC" },
{ name: "隶书", value: "隶书, SimLi" },
{ name: "Andale Mono", value: "andale mono" },
{ name: "Arial", value: "arial, helvetica, sans-serif" },
{ name: "arialBlack", value: "arial black, avant garde" },
{ name: "Comic Sans Ms", value: "comic sans ms" },
{ name: "Impact", value: "impact, chicago" },
{ name: "Times New Roman", value: "times new roman" },
{ name: "Sans-Serif", value: "sans-serif" },
{ name: "serif", value: "serif" },
];
export const fontSizeList = [10, 12, 16, 18, 24, 32, 48];
export const lineHeightList = [1, 1.5, 2, 2.5, 3];
export const colorList = [
"#4D4D4D",
"#999999",
"#FFFFFF",
"#F44E3B",
"#FE9200",
"#FCDC00",
"#DBDF00",
"#A4DD00",
"#68CCCA",
"#73D8FF",
"#AEA1FF",
"#FDA1FF",
"#333333",
"#808080",
"#cccccc",
"#D33115",
"#E27300",
"#FCC400",
"#B0BC00",
"#68BC00",
"#16A5A5",
"#009CE0",
"#7B64FF",
"#FA28FF",
"#000000",
"#666666",
"#B3B3B3",
"#9F0500",
"#C45100",
"#FB9E00",
"#808900",
"#194D33",
"#0C797D",
"#0062B1",
"#653294",
"#AB149E",
"transparent",
];
export const borderWidthList = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
export const borderRadiusList = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
export const lineWidthList = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
export const borderDasharrayList = [
{ name: "实线", value: "none" },
{ name: "虚线1", value: "5,5" },
{ name: "虚线2", value: "10,10" },
{ name: "虚线3", value: "20,10,5,5,5,10" },
{ name: "虚线4", value: "5, 5, 1, 5" },
{ name: "虚线5", value: "15, 10, 5, 10, 15" },
{ name: "虚线6", value: "1, 5" },
];
export const lineStyleList = [
{ name: "直线", value: "straight" },
{ name: "曲线", value: "curve" },
{ name: "直连", value: "direct" },
];
export const shapeList = [
{ name: "矩形", value: "rectangle" },
{ name: "圆角矩形", value: "roundedRectangle" },
{ name: "椭圆", value: "ellipse" },
{ name: "圆形", value: "circle" },
{ name: "菱形", value: "diamond" },
{ name: "平行四边形", value: "parallelogram" },
];
export const themeList = [
{ name: "经典", value: "classic" },
{ name: "深色", value: "dark" },
{ name: "朴素", value: "simple" },
{ name: "商务", value: "business" },
{ name: "清新红", value: "freshRed" },
{ name: "清新绿", value: "freshGreen" },
{ name: "经典蓝", value: "classicBlue" },
{ name: "经典绿", value: "classicGreen" },
{ name: "泥土黄", value: "earthYellow" },
{ name: "粉红葡萄", value: "pinkGrape" },
{ name: "薄荷", value: "mint" },
{ name: "金色", value: "gold" },
];
export const layoutList = [
{ name: "逻辑结构图", value: "logicalStructure" },
{ name: "思维导图", value: "mindMap" },
{ name: "组织结构图", value: "organizationStructure" },
{ name: "目录组织图", value: "catalogOrganization" },
{ name: "时间轴", value: "timeline" },
{ name: "鱼骨图", value: "fishbone" },
{ name: "竖向时间轴", value: "verticalTimeline" },
];
export const outlineDepthOptions = [
{ name: "全部展开", value: -1 },
{ name: "仅根节点", value: 1 },
{ name: "两级", value: 2 },
{ name: "三级", value: 3 },
];
@@ -0,0 +1,47 @@
export type SidebarTrigger = {
value: string; // 官方配置标识
target: SidebarPanel; // React 内部面板 key
label: string;
iconClass: string;
visible?: boolean; // 可选,默认 true;官方之外的扩展可设为 false 不显示在触发条
};
export type SidebarPanel =
| "style"
| "base"
| "theme"
| "structure"
| "outline"
| "settings"
| "icons"
| "formula"
| "note"
| "ai";
// 侧栏触发配置,来源于官方 config/zh.js 的 sidebarTriggerList,保持顺序
export const sidebarTriggers: SidebarTrigger[] = [
{ value: "nodeStyle", target: "style", label: "节点样式", iconClass: "iconzhuti" },
{ value: "baseStyle", target: "base", label: "基础样式", iconClass: "iconyangshi" },
{ value: "theme", target: "theme", label: "主题", iconClass: "iconjingzi" },
{ value: "structure", target: "structure", label: "结构", iconClass: "iconjiegou" },
{ value: "outline", target: "outline", label: "大纲", iconClass: "iconfuhao-dagangshu" },
{ value: "shortcutKey", target: "settings", label: "快捷键/设置", iconClass: "iconjianpan" },
// 扩展:图标/公式/备注/AI,官方通过按钮唤起,这里隐藏不展示但支持被触发
{ value: "icons", target: "icons", label: "图标/贴纸", iconClass: "iconxiaolian", visible: false },
{ value: "formula", target: "formula", label: "公式", iconClass: "icongongshi", visible: false },
{ value: "note", target: "note", label: "备注", iconClass: "iconflow-Mark", visible: false },
{ value: "ai", target: "ai", label: "AI", iconClass: "iconstar", visible: false },
];
export const sidebarTitles: Record<SidebarPanel, string> = {
style: "节点样式",
base: "基础样式",
theme: "主题",
structure: "结构",
outline: "大纲",
settings: "设置",
icons: "图标/贴纸",
formula: "公式",
note: "备注",
ai: "AI 对话",
};
@@ -0,0 +1,100 @@
export type NodeToolbarKey =
| "back"
| "forward"
| "painter"
| "siblingNode"
| "childNode"
| "deleteNode"
| "image"
| "icon"
| "link"
| "note"
| "tag"
| "summary"
| "associativeLine"
| "formula"
| "attachment"
| "outerFrame"
| "annotation"
| "ai";
export type FileToolbarKey =
| "directory"
| "newFile"
| "openFile"
| "import"
| "saveAs"
| "exportJson"
| "exportPng";
type ToolbarMeta = {
label: string;
iconClass: string;
};
type ToolbarConfig = Record<NodeToolbarKey, ToolbarMeta>;
type FileToolbarConfig = Record<FileToolbarKey, ToolbarMeta>;
// 按官方 Toolbar.vue 的顺序定义节点工具按钮
export const nodeToolbarOrder: NodeToolbarKey[] = [
"back",
"forward",
"painter",
"siblingNode",
"childNode",
"deleteNode",
"image",
"icon",
"link",
"note",
"tag",
"summary",
"associativeLine",
"formula",
"attachment",
"outerFrame",
// 官方还有注释/AI 占位,这里先留钩子
"annotation",
"ai",
];
export const nodeToolbarMeta: ToolbarConfig = {
back: { label: "回退", iconClass: "iconhoutui-shi" },
forward: { label: "前进", iconClass: "iconqianjin1" },
painter: { label: "格式刷", iconClass: "iconjiedian" },
siblingNode: { label: "同级节点", iconClass: "iconjiedian" },
childNode: { label: "子节点", iconClass: "icontianjiazijiedian" },
deleteNode: { label: "删除节点", iconClass: "iconshanchu" },
image: { label: "图片", iconClass: "iconimage" },
icon: { label: "图标", iconClass: "iconxiaolian" },
link: { label: "超链接", iconClass: "iconchaolianjie" },
note: { label: "备注", iconClass: "iconflow-Mark" },
tag: { label: "标签", iconClass: "iconbiaoqian" },
summary: { label: "概要", iconClass: "icongaikuozonglan" },
associativeLine: { label: "关联线", iconClass: "iconlianjiexian" },
formula: { label: "公式", iconClass: "icongongshi" },
attachment: { label: "附件", iconClass: "iconfujian" },
outerFrame: { label: "外框", iconClass: "iconwaikuang" },
annotation: { label: "标注", iconClass: "iconhighlight" },
ai: { label: "AI", iconClass: "iconstar" },
};
export const fileToolbarOrder: FileToolbarKey[] = [
"directory",
"newFile",
"openFile",
"import",
"saveAs",
"exportJson",
"exportPng",
];
export const fileToolbarMeta: FileToolbarConfig = {
directory: { label: "目录", iconClass: "iconwenjian" },
newFile: { label: "新建", iconClass: "iconxinjian" },
openFile: { label: "打开", iconClass: "icondakai" },
import: { label: "导入", iconClass: "icondaoru" },
saveAs: { label: "另存为", iconClass: "iconlingcunwei" },
exportJson: { label: "导出 JSON", iconClass: "iconexport" },
exportPng: { label: "导出 PNG", iconClass: "iconPNG" },
};