diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index b3728cd..e94a8e5 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -68,7 +68,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -321,7 +320,6 @@
"resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.28.5.tgz",
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/types": "^7.28.5"
},
@@ -1895,7 +1893,6 @@
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.1.0.tgz",
"integrity": "sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.1.0",
@@ -2318,7 +2315,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3903,7 +3899,6 @@
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"copy-anything": "^2.0.1",
"parse-node-version": "^1.0.1",
@@ -5555,7 +5550,6 @@
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -6595,7 +6589,6 @@
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -7307,7 +7300,6 @@
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.25.tgz",
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-sfc": "3.5.25",
@@ -7546,7 +7538,6 @@
"resolved": "https://registry.npmmirror.com/yjs/-/yjs-13.6.27.tgz",
"integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"lib0": "^0.2.99"
},
diff --git a/frontend/src/assets/base.css b/frontend/src/assets/base.css
index 8816868..7c27c2e 100644
--- a/frontend/src/assets/base.css
+++ b/frontend/src/assets/base.css
@@ -55,7 +55,6 @@
*::after {
box-sizing: border-box;
margin: 0;
- font-weight: normal;
}
body {
diff --git a/frontend/src/locales/zh_cn.js b/frontend/src/locales/zh_cn.js
index 5a33a65..264bd5b 100644
--- a/frontend/src/locales/zh_cn.js
+++ b/frontend/src/locales/zh_cn.js
@@ -122,6 +122,8 @@ export default {
removeCustomStyles: '一键去除自定义样式',
removeAllNodeCustomStyles: '一键去除所有节点自定义样式',
exportNodeToPng: '导出该节点为图片',
+ convertToFreeNode: '转为自由节点',
+ convertToNormalNode: '转为非自由节点',
copyToClipboard: '复制到剪贴板',
copyToSmm: 'SMM',
copyToJson: 'JSON',
@@ -358,6 +360,7 @@ export default {
exportError: '导出失败',
dragTip: '在此释放以导入该文件',
deleteNodeImgTip: '是否确认删除该节点图片?',
+ freeNodeDefaultText: '自由节点',
autoOpenNodeRichTextTip: '检测到导入了富文本内容,已自动开启富文本模式',
localStorageExceededTip:
'你创建的思维导图体积已经超过浏览器允许存储的上限,请立即导出,否则数据将丢失!建议下载客户端进行使用,客户端无大小限制。',
diff --git a/frontend/src/pages/Edit/components/Contextmenu.vue b/frontend/src/pages/Edit/components/Contextmenu.vue
new file mode 100644
index 0000000..0099033
--- /dev/null
+++ b/frontend/src/pages/Edit/components/Contextmenu.vue
@@ -0,0 +1,243 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/pages/Edit/components/Editor/index.vue b/frontend/src/pages/Edit/components/Editor/index.vue
index 847cc73..0104fef 100644
--- a/frontend/src/pages/Edit/components/Editor/index.vue
+++ b/frontend/src/pages/Edit/components/Editor/index.vue
@@ -15,6 +15,8 @@
v-if="!appStore.localConfig.isZenMode"
:mindMap="mindMap"
/>
+
+
{
mindMap.value.execCommand(...args);
@@ -24,6 +29,115 @@ export default function useEventHandlers(mindMap, manualSave) {
mindMap.value.updateConfig(data);
};
+ const getCanvasPoint = (clientX, clientY) => {
+ const { x, y } = mindMap.value.toPos(clientX, clientY);
+ const { scaleX, scaleY, translateX, translateY } =
+ mindMap.value.draw.transform();
+ return {
+ x: (x - translateX) / scaleX,
+ y: (y - translateY) / scaleY,
+ };
+ };
+
+ const getCanvasCenter = () => {
+ const rect = mindMap.value.el.getBoundingClientRect();
+ return getCanvasPoint(
+ rect.left + rect.width / 2,
+ rect.top + rect.height / 2
+ );
+ };
+
+ const applyFreeNodeStyle = (node, position) => {
+ if (!node) {
+ return;
+ }
+ const left = position.x - node.width / 2;
+ const top = position.y - node.height / 2;
+ mindMap.value.execCommand("SET_NODE_CUSTOM_POSITION", node, left, top);
+ node.setStyle("lineWidth", 0);
+ node.setStyle("lineColor", "transparent");
+ };
+
+ const onNodeTreeRenderEnd = () => {
+ if (!mindMap.value) {
+ return;
+ }
+ if (pendingFreeNode) {
+ const { uid, position } = pendingFreeNode;
+ const node = mindMap.value.renderer.findNodeByUid(uid);
+ applyFreeNodeStyle(node, position);
+ pendingFreeNode = null;
+ }
+ if (expandAnchor) {
+ const node = mindMap.value.renderer.findNodeByUid(expandAnchor.uid);
+ if (node) {
+ const { scaleX, scaleY, translateX, translateY } =
+ mindMap.value.draw.transform();
+ const nextX = node.left * scaleX + translateX;
+ const nextY = node.top * scaleY + translateY;
+ const dx = expandAnchor.x - nextX;
+ const dy = expandAnchor.y - nextY;
+ if (dx || dy) {
+ mindMap.value.view.translateXY(dx, dy);
+ }
+ }
+ expandAnchor = null;
+ }
+ };
+
+ const onExpandBtnClick = (node) => {
+ if (!mindMap.value || !node || node.getData?.("isFreeNode")) {
+ return;
+ }
+ const { scaleX, scaleY, translateX, translateY } =
+ mindMap.value.draw.transform();
+ expandAnchor = {
+ uid: node.getData?.("uid") || node.uid,
+ x: node.left * scaleX + translateX,
+ y: node.top * scaleY + translateY,
+ };
+ };
+
+ const createFreeNode = (options = {}) => {
+ if (!mindMap.value || appStore.isReadonly) {
+ return;
+ }
+ if (!mindMap.value.getConfig("enableFreeDrag")) {
+ mindMap.value.updateConfig({ enableFreeDrag: true });
+ const config = getConfig() || {};
+ storeConfig({
+ ...config,
+ enableFreeDrag: true,
+ });
+ }
+ const position =
+ typeof options.clientX === "number" && typeof options.clientY === "number"
+ ? getCanvasPoint(options.clientX, options.clientY)
+ : getCanvasCenter();
+ const uid = createUid();
+ pendingFreeNode = { uid, position };
+ const root = mindMap.value.renderer.root;
+ mindMap.value.execCommand("INSERT_CHILD_NODE", false, [root], {
+ text: t("edit.freeNodeDefaultText"),
+ uid,
+ isFreeNode: true,
+ });
+ };
+
+ const onDrawDblclick = (event) => {
+ if (!mindMap.value || appStore.isReadonly) {
+ return;
+ }
+ const target = event.target;
+ if (target && target.closest && target.closest(".smm-node")) {
+ return;
+ }
+ createFreeNode({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ };
+
/** 导出,需要先注册Export插件 */
const exportMap = async (...args) => {
try {
@@ -103,14 +217,21 @@ export default function useEventHandlers(mindMap, manualSave) {
emitter.on("paddingChange", onPaddingChange);
emitter.on("export", exportMap);
emitter.on("setData", setData);
+ emitter.on("create_free_node", createFreeNode);
emitter.on("startTextEdit", handleStartTextEdit);
emitter.on("endTextEdit", handleEndTextEdit);
emitter.on("createAssociativeLine", handleCreateLineFromActiveNode);
emitter.on("startPainter", handleStartPainter);
emitter.on("node_tree_render_end", handleHideLoading);
+ emitter.on("node_tree_render_end", onNodeTreeRenderEnd);
+ emitter.on("expand_btn_click", onExpandBtnClick);
emitter.on("showLoading", handleShowLoading);
emitter.on("localStorageExceeded", onLocalStorageExceeded);
window.addEventListener("resize", handleResize);
+ svgNode = mindMap.value?.svg?.node || null;
+ if (svgNode) {
+ svgNode.addEventListener("dblclick", onDrawDblclick);
+ }
};
/** 解绑事件 */
@@ -119,14 +240,21 @@ export default function useEventHandlers(mindMap, manualSave) {
emitter.off("paddingChange", onPaddingChange);
emitter.off("export", exportMap);
emitter.off("setData", setData);
+ emitter.off("create_free_node", createFreeNode);
emitter.off("startTextEdit", handleStartTextEdit);
emitter.off("endTextEdit", handleEndTextEdit);
emitter.off("createAssociativeLine", handleCreateLineFromActiveNode);
emitter.off("startPainter", handleStartPainter);
emitter.off("node_tree_render_end", handleHideLoading);
+ emitter.off("node_tree_render_end", onNodeTreeRenderEnd);
+ emitter.off("expand_btn_click", onExpandBtnClick);
emitter.off("showLoading", handleShowLoading);
emitter.off("localStorageExceeded", onLocalStorageExceeded);
window.removeEventListener("resize", handleResize);
+ if (svgNode) {
+ svgNode.removeEventListener("dblclick", onDrawDblclick);
+ svgNode = null;
+ }
};
return {
diff --git a/frontend/src/pages/Edit/components/Editor/useMindMap.js b/frontend/src/pages/Edit/components/Editor/useMindMap.js
index 85bec84..747a456 100644
--- a/frontend/src/pages/Edit/components/Editor/useMindMap.js
+++ b/frontend/src/pages/Edit/components/Editor/useMindMap.js
@@ -71,6 +71,7 @@ export default function useMindMap(mindMapRef) {
const fileStore = useFileStore();
const route = useRoute();
const mindMap = ref(null);
+ const activeNodes = ref([]);
const mindMapData = ref(null);
const mindMapConfig = ref({});
const storeConfigTimer = ref(null);
@@ -78,6 +79,31 @@ export default function useMindMap(mindMapRef) {
let isDirty = false;
const IDLE_SAVE_MS = 20000;
+ const onNodeActive = (...args) => {
+ const list = Array.isArray(args[1])
+ ? args[1]
+ : Array.isArray(args[0])
+ ? args[0]
+ : [];
+ activeNodes.value = list.filter(Boolean);
+ };
+
+ const toggleBoldStyle = () => {
+ if (!activeNodes.value.length || !mindMap.value) {
+ return;
+ }
+ const shouldBold = activeNodes.value.some((node) => {
+ const weight = node.getStyle ? node.getStyle("fontWeight", false) : "";
+ return weight !== "bold";
+ });
+ activeNodes.value.forEach((node) => {
+ if (node && node.setStyle) {
+ node.setStyle("fontWeight", shouldBold ? "bold" : "normal");
+ }
+ });
+ emitter.emit("node_style_changed");
+ };
+
/** url中是否存在要打开的文件 */
const hasFileURL = () => {
const fileURL = route.query.fileURL;
@@ -124,6 +150,11 @@ export default function useMindMap(mindMapRef) {
},
openRealtimeRenderOnNodeTextEdit: true,
enableAutoEnterTextEditWhenKeydown: true,
+ beforeDragStart: (nodeList) => {
+ return nodeList.some((node) => {
+ return !node || !node.getData || !node.getData("isFreeNode");
+ });
+ },
demonstrateConfig: {
openBlankMode: false,
},
@@ -218,6 +249,9 @@ export default function useMindMap(mindMapRef) {
mindMap.value.keyCommand.addShortcut("Control+s", () => {
manualSave();
});
+ mindMap.value.keyCommand.addShortcut("Control+b", () => {
+ toggleBoldStyle();
+ });
// mindMap实例事件列表
// https://wanglin2.github.io/mind-map-docs/api/constructor/constructor-methods.html#on-event-fn
@@ -255,6 +289,7 @@ export default function useMindMap(mindMapRef) {
emitter.emit(event, ...args);
});
});
+ emitter.on("node_active", onNodeActive);
bindSaveEvent();
// 处理url中支持格式的文件
if (fileUrlExists) {
@@ -320,6 +355,7 @@ export default function useMindMap(mindMapRef) {
});
};
+
/** 手动保存数据 */
const manualSave = () => {
storeData(mindMap.value.getData(true));
diff --git a/frontend/src/pages/Edit/components/NavigatorToolbar/StyleDialog.vue b/frontend/src/pages/Edit/components/NavigatorToolbar/StyleDialog.vue
new file mode 100644
index 0000000..b4b7005
--- /dev/null
+++ b/frontend/src/pages/Edit/components/NavigatorToolbar/StyleDialog.vue
@@ -0,0 +1,158 @@
+
+
+
+
+ {{ $t('theme.title') }}
+
+
+
+
+
+ {{ $t('baseStyle.background') }}
+
+
+
+ {{ $t('baseStyle.line') }}
+
+
+
+ {{ $t('dialog.confirm') }}
+ {{ $t('dialog.cancel') }}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/pages/Edit/components/NavigatorToolbar/index.vue b/frontend/src/pages/Edit/components/NavigatorToolbar/index.vue
index bfaf70c..a03bdf1 100644
--- a/frontend/src/pages/Edit/components/NavigatorToolbar/index.vue
+++ b/frontend/src/pages/Edit/components/NavigatorToolbar/index.vue
@@ -44,6 +44,14 @@
:handleClick="handleIconClick"
/>
+
+
+
+
+
+
diff --git a/frontend/src/pages/Edit/components/Toolbar/useToolbar.js b/frontend/src/pages/Edit/components/Toolbar/useToolbar.js
index 973aea6..548675d 100644
--- a/frontend/src/pages/Edit/components/Toolbar/useToolbar.js
+++ b/frontend/src/pages/Edit/components/Toolbar/useToolbar.js
@@ -8,6 +8,7 @@ import {
RollbackIcon,
RollfrontIcon,
BrushIcon,
+ AddIcon,
TreeSquareDotVerticalIcon,
GitMergeIcon,
DeleteIcon,
@@ -89,6 +90,22 @@ export default function useToolbar() {
active: isInPainter.value,
handler: () => emitter.emit("startPainter"),
},
+ {
+ name: "freeNode",
+ icon: AddIcon,
+ label: "自由节点",
+ disabled: appStore.isReadonly,
+ handler: () => emitter.emit("create_free_node"),
+ },
+ {
+ name: "nodeStyle",
+ icon: PenIcon,
+ label: "节点样式",
+ handler: () =>
+ appStore.setActiveSidebar(
+ appStore.activeSidebar === "nodeStyle" ? "" : "nodeStyle"
+ ),
+ },
{
name: "siblingNode",
icon: TreeSquareDotVerticalIcon,