feat(mindmap): 引入官方工具栏/侧栏并修复节点选择
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
+1
-1
@@ -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.
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,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 *));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user