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
+8 -8
View File
@@ -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
+73
View File
@@ -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`
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
transpilePackages: ["simple-mind-map"],
};
export default nextConfig;
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

@@ -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";
}
Binary file not shown.
Binary file not shown.
@@ -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<CustomBlockSchema, "mindmap"> = {
id: "dev-mindmap",
type: "mindmap",
props: {
data: {
data: { text: "中心主题" },
children: [],
},
},
content: [],
children: [],
};
const editorStub = {
updateBlock: () => {
/* 开发沙盒中跳过持久化 */
},
} as unknown as BlockNoteEditor<CustomBlockSchema>;
export default function MindmapDevPage() {
return (
<div className="fixed inset-0 bg-white">
<MindmapBlockView block={stubBlock} editor={editorStub} fullscreen />
</div>
);
}
+1
View File
@@ -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 *));
+430
View File
@@ -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";
}
@@ -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" },
};
@@ -18,6 +18,7 @@ import {
Paperclip,
PilcrowSquare,
Play,
Spline,
Sparkles,
SquareCheckBig,
Table,
@@ -163,6 +164,25 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
},
};
const createMindmapItem: DefaultReactSuggestionItem = {
title: "思维导图",
group: "高级",
subtext: "插入可编辑导图(带顶栏工具)",
aliases: ["mindmap", "swdt", "导图"],
icon: <Spline className="h-4 w-4 text-[#2563eb]" />,
onItemClick: () => {
editor.insertBlocks(
[
{
type: "mindmap",
},
],
referenceBlock,
"after",
);
},
};
const createPageItem: DefaultReactSuggestionItem = {
title: "嵌入页面",
group: "嵌入",
@@ -298,6 +318,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
foldHeading,
createPageItem,
createTableItem,
createMindmapItem,
advancedTodo,
foldAdvancedTodo,
progressMeter,
@@ -12,6 +12,7 @@ import { advancedTodoBlock } from "./blocks/AdvancedTodoBlock";
import { progressBlock } from "./blocks/ProgressBlock";
import { mediaBlock } from "./blocks/MediaBlock";
import { onlineTableBlock } from "./blocks/OnlineTableBlock";
import { mindmapBlock } from "./blocks/MindmapBlock";
const headingSpec =
typeof window === "undefined"
@@ -30,6 +31,7 @@ export const customBlockSchema = BlockNoteSchema.create({
progressMeter: progressBlock,
media: mediaBlock,
onlineTable: onlineTableBlock(),
mindmap: mindmapBlock,
},
inlineContentSpecs: defaultInlineContentSpecs,
styleSpecs: defaultStyleSpecs,