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 @@ + + + + + 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,