feat(mindmap): 引入官方工具栏/侧栏并修复节点选择
This commit is contained in:
@@ -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