diff --git a/design/mindmap-checklist.md b/design/mindmap-checklist.md index 05b5eb84..7908fdf8 100644 --- a/design/mindmap-checklist.md +++ b/design/mindmap-checklist.md @@ -3,14 +3,14 @@ 思维导图布局: -- [] 阶段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 - - [ ] 节点样式、基础样式、连线、主题、结构(参考:`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`) +- [x] 阶段1:顶栏工具组 + - [x] 回退/前进/格式刷(参考:`cankao/lx-doc-main/mind-map/src/pages/Edit/components/ToolbarNodeBtnList.vue`) + - [x] 同级/子节点插入、删除(同上 `ToolbarNodeBtnList.vue` 插入/删除按钮) + - [x] 资源操作:图片、图标、超链接、备注、标签、概要、关联线、公式、外框、AI(AI 先占位)(同上文件 image/icon/link/note/tag/summary/associativeLine/formula/attachment/outerFrame 按钮) + - [x] 目录/新建/打开/另存为/导入/导出(参考:`cankao/lx-doc-main/mind-map/src/pages/Edit/components/Toolbar.vue` 导入/导出/更多) +- [x] 阶段2:右侧侧栏 Tabs + - [x] 节点样式、基础样式、连线、主题、结构(参考:`cankao/lx-doc-main/mind-map/src/pages/Edit/components/BaseStyle.vue`、`Style.vue`、`Theme.vue`、`Structure.vue`) + - [x] 大纲视图与编辑、设置(滚轮行为/自由拖拽/AI等)、图标/贴纸、公式、备注、AI 对话(参考:`OutlineSidebar.vue`、`SidebarTrigger.vue`;图标/贴纸 `NodeIconSidebar.vue`;公式 `FormulaSidebar.vue`) - [] 阶段2附加:组件化与文件化 - [] Mindmap 组件可嵌入笔记,创建时自动生成子文件并通过斜杠菜单插入 - [ ] 阶段3:浮动导航与 MiniMap diff --git a/desktop-hot.err b/desktop-hot.err new file mode 100644 index 00000000..e0bb3555 --- /dev/null +++ b/desktop-hot.err @@ -0,0 +1,73 @@ +INFO: Will watch for changes in these directories: ['F:\\SOFT\\MNOTE\\wolai-backend'] +INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) +INFO: Started reloader process [26764] using WatchFiles +[baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D` + ⚠ Warning: Next.js inferred your workspace root, but it may not be correct. + We detected multiple lockfiles and selected the directory of F:\SOFT\MNOTE\pnpm-lock.yaml as the root directory. + To silence this warning, set `turbopack.root` in your Next.js config, or consider removing one of the lockfiles if it's not needed. + See https://nextjs.org/docs/app/api-reference/config/next-config-js/turbopack#root-directory for more information. + Detected additional lockfiles: + * F:\SOFT\MNOTE\wolai-frontend\pnpm-lock.yaml + +[2025-12-27 22:47:28,531: WARNING/MainProcess] C:\Users\liaib\AppData\Roaming\Python\Python38\site-packages\celery\worker\consumer\consumer.py:508: CPendingDeprecationWarning: The broker_connection_retry configuration setting will no longer determine +whether broker connection retries are made during startup in Celery 6.0 and above. +If you wish to retain the existing behavior for retrying connections on startup, +you should set broker_connection_retry_on_startup to True. + warnings.warn( + +[2025-12-27 22:47:28,567: INFO/MainProcess] Connected to redis://localhost:6379/0 +[2025-12-27 22:47:28,570: WARNING/MainProcess] C:\Users\liaib\AppData\Roaming\Python\Python38\site-packages\celery\worker\consumer\consumer.py:508: CPendingDeprecationWarning: The broker_connection_retry configuration setting will no longer determine +whether broker connection retries are made during startup in Celery 6.0 and above. +If you wish to retain the existing behavior for retrying connections on startup, +you should set broker_connection_retry_on_startup to True. + warnings.warn( + +[2025-12-27 22:47:28,578: INFO/MainProcess] mingle: searching for neighbors +[2025-12-27 22:47:29,652: INFO/MainProcess] mingle: all alone +[2025-12-27 22:47:29,721: INFO/MainProcess] celery@LIX ready. +INFO: Started server process [25896] +INFO: Waiting for application startup. +INFO: Application startup complete. +[2025-12-27 22:47:31,536: INFO/SpawnPoolWorker-1] child process 27416 calling self.run() +[2025-12-27 22:47:31,680: INFO/SpawnPoolWorker-2] child process 10168 calling self.run() +[2025-12-27 22:47:31,778: INFO/SpawnPoolWorker-4] child process 16672 calling self.run() +[2025-12-27 22:47:31,802: INFO/SpawnPoolWorker-3] child process 18588 calling self.run() +[2025-12-27 22:47:31,897: INFO/SpawnPoolWorker-6] child process 27992 calling self.run() +[2025-12-27 22:47:31,985: INFO/SpawnPoolWorker-5] child process 14316 calling self.run() +[2025-12-27 22:47:31,986: INFO/SpawnPoolWorker-7] child process 28396 calling self.run() +[2025-12-27 22:47:31,999: INFO/SpawnPoolWorker-8] child process 1396 calling self.run() +[2025-12-27 22:47:32,014: INFO/SpawnPoolWorker-10] child process 21240 calling self.run() +[2025-12-27 22:47:32,117: INFO/SpawnPoolWorker-11] child process 27396 calling self.run() +[2025-12-27 22:47:32,135: INFO/SpawnPoolWorker-12] child process 12748 calling self.run() +[2025-12-27 22:47:32,160: INFO/SpawnPoolWorker-14] child process 24500 calling self.run() +[2025-12-27 22:47:32,167: INFO/SpawnPoolWorker-16] child process 26204 calling self.run() +[2025-12-27 22:47:32,171: INFO/SpawnPoolWorker-9] child process 20484 calling self.run() +[2025-12-27 22:47:32,177: INFO/SpawnPoolWorker-13] child process 15704 calling self.run() +[2025-12-27 22:47:32,230: INFO/SpawnPoolWorker-18] child process 23508 calling self.run() +[2025-12-27 22:47:32,230: INFO/SpawnPoolWorker-15] child process 8340 calling self.run() +[2025-12-27 22:47:32,234: INFO/SpawnPoolWorker-17] child process 26884 calling self.run() +[2025-12-27 22:47:32,266: INFO/SpawnPoolWorker-20] child process 10120 calling self.run() +[2025-12-27 22:47:32,308: INFO/SpawnPoolWorker-22] child process 16744 calling self.run() +[2025-12-27 22:47:32,333: INFO/SpawnPoolWorker-19] child process 27164 calling self.run() +[2025-12-27 22:47:32,350: INFO/SpawnPoolWorker-24] child process 16536 calling self.run() +[2025-12-27 22:47:32,359: INFO/SpawnPoolWorker-21] child process 22232 calling self.run() +[2025-12-27 22:47:32,374: INFO/SpawnPoolWorker-23] child process 21236 calling self.run() +Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server. +Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server. +Yjs was already imported. This breaks constructor checks and will lead to issues! - https://github.com/yjs/yjs/issues/438 +Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server. +Yjs was already imported. This breaks constructor checks and will lead to issues! - https://github.com/yjs/yjs/issues/438 +Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server. +Yjs was already imported. This breaks constructor checks and will lead to issues! - https://github.com/yjs/yjs/issues/438 +Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server. +Yjs was already imported. This breaks constructor checks and will lead to issues! - https://github.com/yjs/yjs/issues/438 +Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server. +Yjs was already imported. This breaks constructor checks and will lead to issues! - https://github.com/yjs/yjs/issues/438 +Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server. +Yjs was already imported. This breaks constructor checks and will lead to issues! - https://github.com/yjs/yjs/issues/438 +Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server. +Yjs was already imported. This breaks constructor checks and will lead to issues! - https://github.com/yjs/yjs/issues/438 +Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server. +Yjs was already imported. This breaks constructor checks and will lead to issues! - https://github.com/yjs/yjs/issues/438 +Using the user object as returned from supabase.auth.getSession() or from some supabase.auth.onAuthStateChange() events could be insecure! This value comes directly from the storage medium (usually cookies on the server) and may not be authentic. Use supabase.auth.getUser() instead which authenticates the data by contacting the Supabase Auth server. +[baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D` diff --git a/initial_state.png b/initial_state.png new file mode 100644 index 00000000..5879575f Binary files /dev/null and b/initial_state.png differ diff --git a/next.config.ts b/next.config.ts index e9ffa308..9918184b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + transpilePackages: ["simple-mind-map"], }; export default nextConfig; diff --git a/official_mindmap.png b/official_mindmap.png new file mode 100644 index 00000000..12b9a62c Binary files /dev/null and b/official_mindmap.png differ diff --git a/wolai-frontend/public/mindmap-iconfont/iconfont.css b/wolai-frontend/public/mindmap-iconfont/iconfont.css new file mode 100644 index 00000000..dadf7589 --- /dev/null +++ b/wolai-frontend/public/mindmap-iconfont/iconfont.css @@ -0,0 +1,431 @@ +@font-face { + font-family: "iconfont"; /* Project id 2479351 */ + src: url('iconfont.woff2?t=1719815803051') format('woff2'), + url('iconfont.woff?t=1719815803051') format('woff'), + url('iconfont.ttf?t=1719815803051') format('truetype'); +} + +.iconfont { + font-family: "iconfont" !important; + font-size: 16px; + font-style: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.iconwaikuang:before { + content: "\e640"; +} + +.iconhighlight:before { + content: "\e6b8"; +} + +.iconyanshibofang:before { + content: "\e648"; +} + +.iconfujian:before { + content: "\e88a"; +} + +.icongeshihua:before { + content: "\e7a3"; +} + +.iconyuanma:before { + content: "\e658"; +} + +.icongundongtiao:before { + content: "\e670"; +} + +.iconxietongwendang:before { + content: "\e60d"; +} + +.iconTXT:before { + content: "\e6e1"; +} + +.iconwenjian1:before { + content: "\e69f"; +} + +.icondodeparent:before { + content: "\e70f"; +} + +.icongongshi:before { + content: "\e617"; +} + +.icontouming:before { + content: "\e60c"; +} + +.iconlieri:before { + content: "\e60b"; +} + +.iconmoon_line:before { + content: "\e745"; +} + +.iconsousuo:before { + content: "\e693"; +} + +.iconjiantouyou:before { + content: "\e62d"; +} + +.iconbianji1:before { + content: "\e60a"; +} + +.icondaohang1:before { + content: "\e632"; +} + +.iconyanjing:before { + content: "\e8bf"; +} + +.iconwangzhan:before { + content: "\e628"; +} + +.iconcsdn:before { + content: "\e608"; +} + +.iconshejiaotubiao-10:before { + content: "\e644"; +} + +.iconstar:before { + content: "\e7df"; +} + +.iconfork:before { + content: "\e641"; +} + +.iconxiazai:before { + content: "\e613"; +} + +.iconteamwork:before { + content: "\e870"; +} + +.iconshuiyin:before { + content: "\e67a"; +} + +.iconxmind:before { + content: "\ea57"; +} + +.iconmouseR:before { + content: "\e6bd"; +} + +.iconmouseL:before { + content: "\e6c0"; +} + +.iconwenjian:before { + content: "\e607"; +} + +.iconpdf:before { + content: "\e740"; +} + +.iconPNG:before { + content: "\ec18"; +} + +.iconSVG:before { + content: "\e621"; +} + +.iconmarkdown:before { + content: "\ec04"; +} + +.iconjson:before { + content: "\ea42"; +} + +.iconlianjiexian:before { + content: "\e75b"; +} + +.iconbangzhu:before { + content: "\e620"; +} + +.iconshezhi:before { + content: "\e8b7"; +} + +.iconwushuju:before { + content: "\e643"; +} + +.iconzuijinliulan:before { + content: "\e62f"; +} + +.icon3zuidahua-3:before { + content: "\e692"; +} + +.iconzuixiaohua:before { + content: "\e650"; +} + +.iconzuidahua:before { + content: "\e651"; +} + +.iconguanbi:before { + content: "\e652"; +} + +.icondiannao:before { + content: "\eac0"; +} + +.iconzhuye:before { + content: "\e65c"; +} + +.iconbendi1x:before { + content: "\e606"; +} + +.iconbeijingyanse:before { + content: "\e6f8"; +} + +.iconqingchu:before { + content: "\e605"; +} + +.iconcase:before { + content: "\e6c6"; +} + +.iconxingzhuang-wenzi:before { + content: "\eb99"; +} + +.iconzitijiacu:before { + content: "\ec83"; +} + +.iconzitixiahuaxian:before { + content: "\ec85"; +} + +.iconzitixieti:before { + content: "\ec86"; +} + +.iconshanchuxian:before { + content: "\e612"; +} + +.iconzitiyanse:before { + content: "\e854"; +} + +.icongithub:before { + content: "\e64f"; +} + +.iconchoose1:before { + content: "\e6c5"; +} + +.iconzhuti:before { + content: "\e7aa"; +} + +.icondaochu1:before { + content: "\e63e"; +} + +.iconlingcunwei:before { + content: "\e657"; +} + +.iconexport:before { + content: "\e642"; +} + +.icondakai:before { + content: "\ebdf"; +} + +.iconxinjian:before { + content: "\e64e"; +} + +.iconjianqie:before { + content: "\e601"; +} + +.iconzhengli:before { + content: "\e83b"; +} + +.iconfuzhi:before { + content: "\e604"; +} + +.iconniantie:before { + content: "\e63f"; +} + +.iconshangyi:before { + content: "\e6be"; +} + +.iconxiayi:before { + content: "\e6bf"; +} + +.icongaikuozonglan:before { + content: "\e609"; +} + +.iconquanxuan:before { + content: "\f199"; +} + +.icondaoru:before { + content: "\e6a3"; +} + +.iconhoutui-shi:before { + content: "\e656"; +} + +.iconqianjin1:before { + content: "\e654"; +} + +.iconwithdraw:before { + content: "\e603"; +} + +.iconqianjin:before { + content: "\e600"; +} + +.iconhuifumoren:before { + content: "\e60e"; +} + +.iconhuanhang:before { + content: "\e61e"; +} + +.iconsuoxiao:before { + content: "\ec13"; +} + +.iconbianji:before { + content: "\e626"; +} + +.iconfangda:before { + content: "\e663"; +} + +.iconquanping1:before { + content: "\e664"; +} + +.icondingwei:before { + content: "\e616"; +} + +.icondaohang:before { + content: "\e611"; +} + +.iconjianpan:before { + content: "\e64d"; +} + +.iconquanping:before { + content: "\e602"; +} + +.icondaochu:before { + content: "\e63d"; +} + +.iconbiaoqian:before { + content: "\e63c"; +} + +.iconflow-Mark:before { + content: "\e65b"; +} + +.iconchaolianjie:before { + content: "\e6f4"; +} + +.iconjingzi:before { + content: "\e610"; +} + +.iconxiaolian:before { + content: "\e60f"; +} + +.iconimage:before { + content: "\e629"; +} + +.iconjiegou:before { + content: "\e61d"; +} + +.iconyangshi:before { + content: "\e631"; +} + +.iconfuhao-dagangshu:before { + content: "\e71f"; +} + +.icontianjiazijiedian:before { + content: "\e622"; +} + +.iconjiedian:before { + content: "\e655"; +} + +.iconshanchu:before { + content: "\e696"; +} + +.iconzhankai:before { + content: "\e64c"; +} + +.iconzhankai1:before { + content: "\e673"; +} + diff --git a/wolai-frontend/public/mindmap-iconfont/iconfont.ttf b/wolai-frontend/public/mindmap-iconfont/iconfont.ttf new file mode 100644 index 00000000..9b4dcc24 Binary files /dev/null and b/wolai-frontend/public/mindmap-iconfont/iconfont.ttf differ diff --git a/wolai-frontend/public/mindmap-iconfont/iconfont.woff b/wolai-frontend/public/mindmap-iconfont/iconfont.woff new file mode 100644 index 00000000..ae10beee Binary files /dev/null and b/wolai-frontend/public/mindmap-iconfont/iconfont.woff differ diff --git a/wolai-frontend/public/mindmap-iconfont/iconfont.woff2 b/wolai-frontend/public/mindmap-iconfont/iconfont.woff2 new file mode 100644 index 00000000..a21e2a92 Binary files /dev/null and b/wolai-frontend/public/mindmap-iconfont/iconfont.woff2 differ diff --git a/wolai-frontend/src/app/dev/mindmap/page.tsx b/wolai-frontend/src/app/dev/mindmap/page.tsx new file mode 100644 index 00000000..ae988a1c --- /dev/null +++ b/wolai-frontend/src/app/dev/mindmap/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { MindmapBlockView } from "@/components/editor/blocks/MindmapBlock"; +import type { BlockNoteEditor, Block } from "@blocknote/core"; +import type { CustomBlockSchema } from "@/components/editor/schema"; + +const stubBlock: Block = { + id: "dev-mindmap", + type: "mindmap", + props: { + data: { + data: { text: "中心主题" }, + children: [], + }, + }, + content: [], + children: [], +}; + +const editorStub = { + updateBlock: () => { + /* 开发沙盒中跳过持久化 */ + }, +} as unknown as BlockNoteEditor; + +export default function MindmapDevPage() { + return ( +
+ +
+ ); +} diff --git a/wolai-frontend/src/app/globals.css b/wolai-frontend/src/app/globals.css index 37352d35..9b0aca28 100644 --- a/wolai-frontend/src/app/globals.css +++ b/wolai-frontend/src/app/globals.css @@ -1,6 +1,7 @@ @import url("https://rsms.me/inter/inter.css"); @import "tailwindcss"; @import "tw-animate-css"; +@import "./iconfont.css"; @custom-variant dark (&:is(.dark *)); diff --git a/wolai-frontend/src/app/iconfont.css b/wolai-frontend/src/app/iconfont.css new file mode 100644 index 00000000..4a08ceec --- /dev/null +++ b/wolai-frontend/src/app/iconfont.css @@ -0,0 +1,430 @@ +@font-face { + font-family: "iconfont"; /* Project id 2479351 */ + src: url('/mindmap-iconfont/iconfont.woff2?t=1719815803051') format('woff2'), + url('/mindmap-iconfont/iconfont.woff?t=1719815803051') format('woff'), + url('/mindmap-iconfont/iconfont.ttf?t=1719815803051') format('truetype'); +} + +.iconfont { + font-family: "iconfont" !important; + font-size: 16px; + font-style: normal; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.iconwaikuang:before { + content: "\e640"; +} + +.iconhighlight:before { + content: "\e6b8"; +} + +.iconyanshibofang:before { + content: "\e648"; +} + +.iconfujian:before { + content: "\e88a"; +} + +.icongeshihua:before { + content: "\e7a3"; +} + +.iconyuanma:before { + content: "\e658"; +} + +.icongundongtiao:before { + content: "\e670"; +} + +.iconxietongwendang:before { + content: "\e60d"; +} + +.iconTXT:before { + content: "\e6e1"; +} + +.iconwenjian1:before { + content: "\e69f"; +} + +.icondodeparent:before { + content: "\e70f"; +} + +.icongongshi:before { + content: "\e617"; +} + +.icontouming:before { + content: "\e60c"; +} + +.iconlieri:before { + content: "\e60b"; +} + +.iconmoon_line:before { + content: "\e745"; +} + +.iconsousuo:before { + content: "\e693"; +} + +.iconjiantouyou:before { + content: "\e62d"; +} + +.iconbianji1:before { + content: "\e60a"; +} + +.icondaohang1:before { + content: "\e632"; +} + +.iconyanjing:before { + content: "\e8bf"; +} + +.iconwangzhan:before { + content: "\e628"; +} + +.iconcsdn:before { + content: "\e608"; +} + +.iconshejiaotubiao-10:before { + content: "\e644"; +} + +.iconstar:before { + content: "\e7df"; +} + +.iconfork:before { + content: "\e641"; +} + +.iconxiazai:before { + content: "\e613"; +} + +.iconteamwork:before { + content: "\e870"; +} + +.iconshuiyin:before { + content: "\e67a"; +} + +.iconxmind:before { + content: "\ea57"; +} + +.iconmouseR:before { + content: "\e6bd"; +} + +.iconmouseL:before { + content: "\e6c0"; +} + +.iconwenjian:before { + content: "\e607"; +} + +.iconpdf:before { + content: "\e740"; +} + +.iconPNG:before { + content: "\ec18"; +} + +.iconSVG:before { + content: "\e621"; +} + +.iconmarkdown:before { + content: "\ec04"; +} + +.iconjson:before { + content: "\ea42"; +} + +.iconlianjiexian:before { + content: "\e75b"; +} + +.iconbangzhu:before { + content: "\e620"; +} + +.iconshezhi:before { + content: "\e8b7"; +} + +.iconwushuju:before { + content: "\e643"; +} + +.iconzuijinliulan:before { + content: "\e62f"; +} + +.icon3zuidahua-3:before { + content: "\e692"; +} + +.iconzuixiaohua:before { + content: "\e650"; +} + +.iconzuidahua:before { + content: "\e651"; +} + +.iconguanbi:before { + content: "\e652"; +} + +.icondiannao:before { + content: "\eac0"; +} + +.iconzhuye:before { + content: "\e65c"; +} + +.iconbendi1x:before { + content: "\e606"; +} + +.iconbeijingyanse:before { + content: "\e6f8"; +} + +.iconqingchu:before { + content: "\e605"; +} + +.iconcase:before { + content: "\e6c6"; +} + +.iconxingzhuang-wenzi:before { + content: "\eb99"; +} + +.iconzitijiacu:before { + content: "\ec83"; +} + +.iconzitixiahuaxian:before { + content: "\ec85"; +} + +.iconzitixieti:before { + content: "\ec86"; +} + +.iconshanchuxian:before { + content: "\e612"; +} + +.iconzitiyanse:before { + content: "\e854"; +} + +.icongithub:before { + content: "\e64f"; +} + +.iconchoose1:before { + content: "\e6c5"; +} + +.iconzhuti:before { + content: "\e7aa"; +} + +.icondaochu1:before { + content: "\e63e"; +} + +.iconlingcunwei:before { + content: "\e657"; +} + +.iconexport:before { + content: "\e642"; +} + +.icondakai:before { + content: "\ebdf"; +} + +.iconxinjian:before { + content: "\e64e"; +} + +.iconjianqie:before { + content: "\e601"; +} + +.iconzhengli:before { + content: "\e83b"; +} + +.iconfuzhi:before { + content: "\e604"; +} + +.iconniantie:before { + content: "\e63f"; +} + +.iconshangyi:before { + content: "\e6be"; +} + +.iconxiayi:before { + content: "\e6bf"; +} + +.icongaikuozonglan:before { + content: "\e609"; +} + +.iconquanxuan:before { + content: "\f199"; +} + +.icondaoru:before { + content: "\e6a3"; +} + +.iconhoutui-shi:before { + content: "\e656"; +} + +.iconqianjin1:before { + content: "\e654"; +} + +.iconwithdraw:before { + content: "\e603"; +} + +.iconqianjin:before { + content: "\e600"; +} + +.iconhuifumoren:before { + content: "\e60e"; +} + +.iconhuanhang:before { + content: "\e61e"; +} + +.iconsuoxiao:before { + content: "\ec13"; +} + +.iconbianji:before { + content: "\e626"; +} + +.iconfangda:before { + content: "\e663"; +} + +.iconquanping1:before { + content: "\e664"; +} + +.icondingwei:before { + content: "\e616"; +} + +.icondaohang:before { + content: "\e611"; +} + +.iconjianpan:before { + content: "\e64d"; +} + +.iconquanping:before { + content: "\e602"; +} + +.icondaochu:before { + content: "\e63d"; +} + +.iconbiaoqian:before { + content: "\e63c"; +} + +.iconflow-Mark:before { + content: "\e65b"; +} + +.iconchaolianjie:before { + content: "\e6f4"; +} + +.iconjingzi:before { + content: "\e610"; +} + +.iconxiaolian:before { + content: "\e60f"; +} + +.iconimage:before { + content: "\e629"; +} + +.iconjiegou:before { + content: "\e61d"; +} + +.iconyangshi:before { + content: "\e631"; +} + +.iconfuhao-dagangshu:before { + content: "\e71f"; +} + +.icontianjiazijiedian:before { + content: "\e622"; +} + +.iconjiedian:before { + content: "\e655"; +} + +.iconshanchu:before { + content: "\e696"; +} + +.iconzhankai:before { + content: "\e64c"; +} + +.iconzhankai1:before { + content: "\e673"; +} diff --git a/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx b/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx index 3f5fdc78..741a4c7d 100644 --- a/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx +++ b/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx @@ -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 }; + 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(null); const [canBack, setCanBack] = useState(false); const [canForward, setCanForward] = useState(false); - const [activeCount, setActiveCount] = useState(1); + const [activeNodes, setActiveNodes] = useState([]); + const activeCount = activeNodes.length; const [painterMode, setPainterMode] = useState(false); + const [showMiniMap, setShowMiniMap] = useState(false); + const [activeSidebar, setActiveSidebar] = useState(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(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 = ({
-
+
+ + + {/* @ts-expect-error mindmap type */} + setActiveSidebar(null)} + /> + setShowMiniMap(!showMiniMap)} + miniMapOpen={showMiniMap} + /> + + {/* @ts-expect-error mindmap type */} +
); @@ -389,6 +501,27 @@ const MindmapBlockView = ({
+ + + {/* @ts-expect-error mindmap type */} + setActiveSidebar(null)} + /> + setShowMiniMap(!showMiniMap)} + miniMapOpen={showMiniMap} + /> + + {/* @ts-expect-error mindmap type */} +
); @@ -407,4 +540,4 @@ export const mindmapBlock = createReactBlockSpec( { render: (props) => , }, -); \ No newline at end of file +); diff --git a/wolai-frontend/src/components/editor/blocks/MindmapCount.tsx b/wolai-frontend/src/components/editor/blocks/MindmapCount.tsx new file mode 100644 index 00000000..00b9d0e8 --- /dev/null +++ b/wolai-frontend/src/components/editor/blocks/MindmapCount.tsx @@ -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 ( +
+
+ 字数 + {counts.words} +
+
+ 节点 + {counts.nodes} +
+
+ ); +}; diff --git a/wolai-frontend/src/components/editor/blocks/MindmapMiniMap.tsx b/wolai-frontend/src/components/editor/blocks/MindmapMiniMap.tsx new file mode 100644 index 00000000..cb059654 --- /dev/null +++ b/wolai-frontend/src/components/editor/blocks/MindmapMiniMap.tsx @@ -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(null); + const [imgUrl, setImgUrl] = useState(""); + const [viewBoxStyle, setViewBoxStyle] = useState({}); + const [svgBoxStyle, setSvgBoxStyle] = useState({}); + + 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 ( +
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 + > +
+ +
+
{ e.stopPropagation(); mindmap?.miniMap?.onViewBoxMousedown(e); }} + onMouseMove={(e) => { e.stopPropagation(); mindmap?.miniMap?.onViewBoxMousemove(e); }} + /> +
+ ); +}; diff --git a/wolai-frontend/src/components/editor/blocks/MindmapNavigator.tsx b/wolai-frontend/src/components/editor/blocks/MindmapNavigator.tsx new file mode 100644 index 00000000..d2942c9e --- /dev/null +++ b/wolai-frontend/src/components/editor/blocks/MindmapNavigator.tsx @@ -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) => { + 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 ( +
+ + +
+ + + + + + {toggleFullscreen && ( + + )} + +
+ + + +
+ + % +
+ + +
+ ); +}; diff --git a/wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx b/wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx new file mode 100644 index 00000000..c488e92e --- /dev/null +++ b/wolai-frontend/src/components/editor/blocks/MindmapSidebar.tsx @@ -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; +}) => ( +
+ {label && {label}} +
+ onChange(e.target.value)} + className="absolute inset-[-50%] h-[200%] w-[200%] cursor-pointer border-0 p-0" + /> +
+
+); + +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; +}) => ( +
+ +
+); + +const StylePanel = ({ activeNodes }: { activeNodes: MindMapNode[] }) => { + const [style, setStyle] = useState>({}); + + useEffect(() => { + if (activeNodes.length > 0) { + const node = activeNodes[0]; + const newStyle: Record = {}; + [ + "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 ( +
+ +

请选择一个节点以编辑样式

+
+ ); + } + + return ( +
+
+ + ({ label: f.name, value: f.value }))} + onChange={(v) => updateStyle("fontFamily", v)} + /> +
+ ({ label: `${s}px`, value: s }))} + onChange={(v) => updateStyle("fontSize", Number(v))} + /> + ({ label: h, value: h }))} + onChange={(v) => updateStyle("lineHeight", Number(v))} + /> +
+
+ updateStyle("fontWeight", pressed ? "bold" : "normal")} + > + + + updateStyle("fontStyle", pressed ? "italic" : "normal")} + > + + + updateStyle("textDecoration", pressed ? "underline" : "none")} + > + + + updateStyle("textDecoration", pressed ? "line-through" : "none")} + > + + +
+ updateStyle("color", v)} + /> +
+ +
+ +
+ ({label: s.name, value: s.value}))} + onChange={(v) => updateStyle("shape", v)} + /> + ({label: s.name, value: s.value}))} + onChange={(v) => updateStyle("borderDasharray", v)} + /> +
+
+ ({ label: `${n}px`, value: n }))} + onChange={(v) => updateStyle("borderWidth", Number(v))} + /> + ({ label: `${n}px`, value: n }))} + onChange={(v) => updateStyle("borderRadius", Number(v))} + /> +
+
+ updateStyle("borderColor", v)} + /> + updateStyle("fillColor", v)} + /> +
+
+ +
+ +
+ ({label: s.name, value: s.value}))} + onChange={(v) => updateStyle("lineDasharray", v)} + /> + ({ label: `${n}px`, value: n }))} + onChange={(v) => updateStyle("lineWidth", Number(v))} + /> +
+ updateStyle("lineColor", v)} + /> +
+
+ ); +}; + +const BaseStylePanel = ({ mindmap }: { mindmap: any }) => { + const [style, setStyle] = useState>({}); + + useEffect(() => { + if (!mindmap) return; + const initStyle = () => { + const newStyle: Record = {}; + [ + "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 ( +
+
+ + updateThemeConfig("backgroundColor", v)} + /> +
+ +
+ +
+ ({ label: s.name, value: s.value }))} + onChange={(v) => updateThemeConfig("lineStyle", v)} + /> + ({ label: `${n}px`, value: n }))} + onChange={(v) => updateThemeConfig("lineWidth", Number(v))} + /> +
+ updateThemeConfig("lineColor", v)} + /> +
+ +
+ +
+
+ 水平 + updateThemeConfig("paddingX", Number(e.target.value))} + /> +
+
+ 垂直 + updateThemeConfig("paddingY", Number(e.target.value))} + /> +
+
+
+
+ ); +}; + +const ThemePanel = ({ mindmap }: { mindmap: any }) => { + const changeTheme = (theme: string) => { + if (!mindmap) return; + mindmap.setTheme(theme); + }; + + if (!mindmap) return null; + + return ( +
+ {themeList.map(t => ( + + ))} +
+ ) +} + +const StructurePanel = ({ mindmap }: { mindmap: any }) => { + const changeLayout = (layout: string) => { + if (!mindmap) return; + mindmap.setLayout(layout); + }; + + if (!mindmap) return null; + + return ( +
+ {layoutList.map(l => ( + + ))} +
+ ) +} + +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
请选择节点
; + + return ( +
+ {predefinedIcons.map(group => ( +
+
+ + +
+
+ {group.list.map(item => ( + + ))} +
+
+ ))} +
+ * 更多图标需接入 SVG 资源库 +
+
+ ); +} + +const OutlinePanel = ({ mindmap }: { mindmap: any }) => { + const [depth, setDepth] = useState(-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
暂无节点
; + + return ( +
+
+ + ({ label: o.name, value: o.value }))} + onChange={(v) => setDepth(Number(v))} + /> +
+
+ {visibleNodes.map((item, idx) => ( + + ))} +
+
+ ); +}; + +const SettingsPanel = ({ mindmap }: { mindmap: any }) => { + const [freeDrag, setFreeDrag] = useState(!!mindmap?.opt?.enableFreeDrag); + const [wheel, setWheel] = useState(mindmap?.opt?.mousewheelAction || "zoom"); + const [aiOn, setAiOn] = useState(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 ( +
+
+ + + {freeDrag ? "开" : "关"} + +
+
+ + +
+
+ + + {aiOn ? "开" : "关"} + +
+

其余设置后续接入(滚轮缩放方向、拖拽导入等)。

+
+ ); +}; + +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 ( +
+ + setFormula(e.target.value)} /> + +
+ ); +}; + +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 ( +
+ + setNote(e.target.value)} /> + +
+ ); +}; + +const AiPanel = () => { + const [prompt, setPrompt] = useState(""); + const send = () => window.alert("AI 对话占位,后续接入大模型:\n" + prompt); + return ( +
+ +