fix(mindmap): force single-line toolbar layout with horizontal scroll

This commit is contained in:
liaibo
2025-12-28 10:01:30 +08:00
parent c0b7b40eee
commit bddef15242
4 changed files with 707 additions and 12 deletions
+4
View File
@@ -34,3 +34,7 @@ services/mineru/models/
/onlyoffice-pg-data/
# ONLYOFFICE 现已移至 src/components/onlyoffice 下,整目录忽略
/src/components/onlyoffice/
# 参考资料目录
cankao/
# services 整体忽略(包含本地模型与临时服务)
/services/
+15 -12
View File
@@ -1,20 +1,23 @@
# 思维导图官方功能落地 Checklist(阶段推进)
思维导图的要求:请参考cankao\kmind-plugin,思维导图应该是像当前luckysheet一样,能够在主页面中生成,并且可以使用斜杆菜单生成。当前主页面中的思维导图生成时会保存内容文件至当前的文件目录下。
- [x] 阶段1:顶栏工具组
- [x] 回退/前进/格式刷
- [x] 同级/子节点插入、删除
- [x] 资源操作:图片、图标、超链接、备注、标签、概要、关联线、公式、外框、AI(AI 先占位
- [x] 目录/新建/打开/另存为/导入/导出
思维导图布局:
- [] 阶段1:顶栏工具组
- [ ] 回退/前进/格式刷(参考:`cankao/lx-doc-main/mind-map/src/pages/Edit/components/ToolbarNodeBtnList.vue`
- [] 同级/子节点插入、删除(同上 `ToolbarNodeBtnList.vue` 插入/删除按钮)
- [] 资源操作:图片、图标、超链接、备注、标签、概要、关联线、公式、外框、AI(AI 先占位)(同上文件 image/icon/link/note/tag/summary/associativeLine/formula/attachment/outerFrame 按钮)
- [] 目录/新建/打开/另存为/导入/导出(参考:`cankao/lx-doc-main/mind-map/src/pages/Edit/components/Toolbar.vue` 导入/导出/更多)
- [ ] 阶段2:右侧侧栏 Tabs
- [ ] 节点样式、基础样式、连线、主题、结构
- [ ] 大纲视图与编辑、设置(滚轮行为/自由拖拽/AI等)、图标/贴纸、公式、备注、AI 对话
- [x] 阶段2附加:组件化与文件化
- [x] Mindmap 组件可嵌入笔记,创建时自动生成子文件并通过斜杠菜单插入
- [ ] 节点样式、基础样式、连线、主题、结构(参考:`cankao/lx-doc-main/mind-map/src/pages/Edit/components/BaseStyle.vue``Style.vue``Theme.vue``Structure.vue`
- [ ] 大纲视图与编辑、设置(滚轮行为/自由拖拽/AI等)、图标/贴纸、公式、备注、AI 对话(参考:`OutlineSidebar.vue``SidebarTrigger.vue`;图标/贴纸 `NodeIconSidebar.vue`;公式 `FormulaSidebar.vue`
- [] 阶段2附加:组件化与文件化
- [] Mindmap 组件可嵌入笔记,创建时自动生成子文件并通过斜杠菜单插入
- [ ] 阶段3:浮动导航与 MiniMap
- [ ] 右下导航条(回到根、适配画布、缩放、只读切换、mini map 开关、搜索、全屏、语言/滚轮模式等)
- [ ] MiniMap 视窗拖动
- [ ] 右下导航条(回到根、适配画布、缩放、只读切换、mini map 开关、搜索、全屏 、语言/滚轮模式等)(参考:`cankao/lx-doc-main/mind-map/src/pages/Edit/components/NavigatorToolbar.vue`
- [ ] MiniMap 视窗拖动(参考:`cankao/lx-doc-main/mind-map/src/pages/Edit/components/Navigator.vue`MiniMap 插件在 `Edit.vue` 注册)
- [ ] 阶段4:状态栏
- [ ] 左下字数/节点统计(监听 data_change
- [ ] 左下字数/节点统计(监听 data_change(参考:`cankao/lx-doc-main/mind-map/src/pages/Edit/components/Count.vue`
- [ ] 阶段5:清理与同步
- [ ] 移除旧自研画布/入口,保留新版实现
- [ ] 同步 `src``wolai-frontend` 两套前端
@@ -0,0 +1,410 @@
"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";
type MindMapInstance = {
execCommand: (...args: unknown[]) => void;
destroy: () => void;
setData: (data: unknown) => void;
getData: (withConfig?: boolean) => unknown;
on?: (event: string, handler: (...args: unknown[]) => void) => void;
command: { clearHistory: () => void };
view: { fit: () => void };
renderer: { activeNodeList: unknown[] };
painter?: { startPainter: () => void };
doExport?: { export: (type: string, isDownload?: boolean, name?: string) => Promise<unknown> };
};
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?.renderer?.activeNodeList || mindmap.renderer.activeNodeList.length === 0) {
window.alert("请选择至少一个节点再执行该操作");
return;
}
mindmap.renderer.activeNodeList.forEach(handler);
};
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 [activeCount, setActiveCount] = useState(1);
const [painterMode, setPainterMode] = useState(false);
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(() => {
let destroyed = false;
(async () => {
if (!containerRef.current) return;
const [
{ default: MindMap },
{ default: Painter },
{ default: AssociativeLine },
{ default: OuterFrame },
{ default: Exporter },
{ default: Formula },
{ default: FormulaStyle },
{ default: RichText },
] = 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/FormulaStyle.js"),
import("simple-mind-map/src/plugins/RichText.js"),
]);
[
{ name: "Painter", plugin: Painter },
{ name: "AssociativeLine", plugin: AssociativeLine },
{ name: "OuterFrame", plugin: OuterFrame },
{ name: "Export", plugin: Exporter },
{ name: "Formula", plugin: Formula },
{ name: "FormulaStyle", plugin: FormulaStyle },
{ name: "RichText", plugin: RichText },
].forEach(({ name, plugin }) => {
if (!plugin) {
console.warn(`思维导图插件加载失败:${name}`);
return;
}
// @ts-expect-error 第三方库缺少类型定义
if (MindMap.hasPlugin(plugin) === -1) {
// @ts-expect-error 第三方库缺少类型定义
// eslint-disable-next-line react-hooks/rules-of-hooks
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,
}) as MindMapInstance;
if (typeof window !== "undefined") {
// 便于开发阶段在控制台直接调试实例
// @ts-expect-error 调试用全局变量
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?.("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);
}
if (rootNode) {
setTimeout(() => {
if (instance.renderer) {
instance.renderer.activeNodeList = [rootNode];
instance.emit?.("node_active", rootNode, [rootNode]);
setActiveCount(1);
}
}, 0);
}
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 = () => 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 handleImage = () => {
const url = createSimplePrompt("请输入图片链接");
if (!url) return;
applyToActiveNodes(mindmap, (node) => {
mindmap?.execCommand("SET_NODE_IMAGE", node, { url, width: 260, height: 200, title: "" });
});
};
const handleIcon = () => {
const value = createSimplePrompt("请输入内置图标键(示例:priority_1 或 progress_2", "priority_1");
if (!value) return;
applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_ICON", node, [value]));
};
const handleLink = () => {
const href = createSimplePrompt("请输入超链接");
if (!href) return;
applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_HYPERLINK", node, href, href));
};
const handleNote = () => {
const note = createSimplePrompt("请输入备注内容");
if (!note) return;
applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_NOTE", node, note));
};
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 = () => {
const formula = createSimplePrompt("输入 LaTeX 公式(示例:a^2+b^2=c^2", "a^2+b^2=c^2");
if (!formula) return;
mindmap?.execCommand("INSERT_FORMULA", formula, mindmap?.renderer?.activeNodeList ?? []);
};
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,
};
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="absolute inset-0">
<div ref={containerRef} className="h-full w-full" />
</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" />
</div>
</div>
);
};
export { MindmapBlockView };
export const mindmapBlock = createReactBlockSpec(
{
type: "mindmap",
propSchema: {
data: { default: defaultMindmapData },
},
content: "none",
},
{
render: (props) => <MindmapBlockView {...props} />,
},
);
@@ -0,0 +1,278 @@
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";
type ToolbarProps = {
canBack: boolean;
canForward: boolean;
activeCount: number;
painterMode: boolean;
onUndo: () => void;
onRedo: () => void;
onPainter: () => void;
onSibling: () => void;
onChild: () => void;
onDelete: () => void;
onImage: () => void;
onIcon: () => void;
onLink: () => void;
onNote: () => void;
onTag: () => void;
onSummary: () => void;
onAssociativeLine: () => void;
onFormula: () => void;
onAttachment: () => void;
onOuterFrame: () => void;
onAi: () => void;
onImport: (e: React.ChangeEvent<HTMLInputElement>) => void;
onNew: () => void;
onOpenDirectory: () => void;
onSaveAs: () => void;
onExportJson: () => void;
onExportPng: () => void;
fileInputRef: React.RefObject<HTMLInputElement>;
};
const ToolbarButton = ({
icon: Icon,
label,
onClick,
disabled = false,
active = false,
className = "",
}: {
icon: React.ElementType;
label: string;
onClick?: () => void;
disabled?: boolean;
active?: boolean;
className?: string;
}) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`flex flex-col items-center justify-center gap-1 px-2 py-1 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 ${
active ? "bg-blue-50 text-blue-600" : "text-gray-600"
} ${className}`}
title={label}
>
<div
className={`flex h-7 w-7 items-center justify-center rounded border shadow-sm transition-colors ${
active ? "border-blue-200 bg-white" : "border-gray-200 bg-white"
}`}
>
<Icon className="h-4 w-4" />
</div>
<span className="text-[10px] font-medium leading-tight">{label}</span>
</button>
);
const Divider = () => <div className="mx-1 h-8 w-px bg-gray-200" />;
export const MindmapToolbar = ({
canBack,
canForward,
activeCount,
painterMode,
onUndo,
onRedo,
onPainter,
onSibling,
onChild,
onDelete,
onImage,
onIcon,
onLink,
onNote,
onTag,
onSummary,
onAssociativeLine,
onFormula,
onAttachment,
onOuterFrame,
onAi,
onImport,
onNew,
onOpenDirectory,
onSaveAs,
onExportJson,
onExportPng,
fileInputRef,
}: ToolbarProps) => {
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">
{/* 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}
/>
</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 */}
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
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>
);
};