diff --git a/.gitignore b/.gitignore index 37e8c479..ecb87661 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ services/mineru/models/ /onlyoffice-pg-data/ # ONLYOFFICE 现已移至 src/components/onlyoffice 下,整目录忽略 /src/components/onlyoffice/ +# 参考资料目录 +cankao/ +# services 整体忽略(包含本地模型与临时服务) +/services/ diff --git a/design/mindmap-checklist.md b/design/mindmap-checklist.md index fea6e0d4..05b5eb84 100644 --- a/design/mindmap-checklist.md +++ b/design/mindmap-checklist.md @@ -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` 两套前端 diff --git a/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx b/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx new file mode 100644 index 00000000..3f5fdc78 --- /dev/null +++ b/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx @@ -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 }; +}; + +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; + editor: BlockNoteEditor; + fullscreen?: boolean; +}) => { + const containerRef = useRef(null); + const fileInputRef = useRef(null); + const [mindmap, setMindmap] = useState(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(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) => { + 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 ( +
+
+ +
+
+
+
+
+ ); + } + + return ( +
+
+
+ +
+
+
+
+
+
+ ); +}; + +export { MindmapBlockView }; + +export const mindmapBlock = createReactBlockSpec( + { + type: "mindmap", + propSchema: { + data: { default: defaultMindmapData }, + }, + content: "none", + }, + { + render: (props) => , + }, +); \ No newline at end of file diff --git a/wolai-frontend/src/components/editor/blocks/MindmapToolbar.tsx b/wolai-frontend/src/components/editor/blocks/MindmapToolbar.tsx new file mode 100644 index 00000000..99d4123d --- /dev/null +++ b/wolai-frontend/src/components/editor/blocks/MindmapToolbar.tsx @@ -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) => void; + onNew: () => void; + onOpenDirectory: () => void; + onSaveAs: () => void; + onExportJson: () => void; + onExportPng: () => void; + fileInputRef: React.RefObject; +}; + +const ToolbarButton = ({ + icon: Icon, + label, + onClick, + disabled = false, + active = false, + className = "", +}: { + icon: React.ElementType; + label: string; + onClick?: () => void; + disabled?: boolean; + active?: boolean; + className?: string; +}) => ( + +); + +const Divider = () =>
; + +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 ( +
+ {/* Left Section: Edit & Node Operations */} +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + {/* Right Section: File & Export Actions */} +
+ + + fileInputRef.current?.click()} + /> + {/* Hidden Input */} + + + + + + + +
+
+ ); +}; \ No newline at end of file