From 784a9a329003b716b35c4c804b7047587370384d Mon Sep 17 00:00:00 2001 From: "vincent.wu" Date: Mon, 13 Apr 2026 08:18:09 +0800 Subject: [PATCH 1/2] refactor(web): separate view state persistence and optimize data saving performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api/index.js: 添加视图数据分离存储,新增 storeViewData 函数;Edit.vue: 实现空闲时保存机制和防抖优化;Navigator.vue: 改进小地图平移性能,减少不必要重绘 --- web/src/api/index.js | 45 +++++++- web/src/pages/Edit/components/Edit.vue | 109 ++++++++++++++++++-- web/src/pages/Edit/components/Navigator.vue | 57 +++++++++- 3 files changed, 197 insertions(+), 14 deletions(-) diff --git a/web/src/api/index.js b/web/src/api/index.js index 70ecb7a6f..1d4bd2fc9 100644 --- a/web/src/api/index.js +++ b/web/src/api/index.js @@ -4,6 +4,7 @@ import Vue from 'vue' import vuexStore from '@/store' const SIMPLE_MIND_MAP_DATA = 'SIMPLE_MIND_MAP_DATA' +const SIMPLE_MIND_MAP_VIEW = 'SIMPLE_MIND_MAP_VIEW' const SIMPLE_MIND_MAP_CONFIG = 'SIMPLE_MIND_MAP_CONFIG' const SIMPLE_MIND_MAP_LANG = 'SIMPLE_MIND_MAP_LANG' const SIMPLE_MIND_MAP_LOCAL_CONFIG = 'SIMPLE_MIND_MAP_LOCAL_CONFIG' @@ -22,14 +23,47 @@ export const getData = () => { return Vue.prototype.getCurrentData() } let store = localStorage.getItem(SIMPLE_MIND_MAP_DATA) + let view = null + const viewStore = localStorage.getItem(SIMPLE_MIND_MAP_VIEW) + if (viewStore) { + try { + view = JSON.parse(viewStore) + } catch (error) { + view = null + } + } if (store === null) { - return simpleDeepClone(exampleData) + const defaultData = simpleDeepClone(exampleData) + if (view) { + defaultData.view = view + } + return defaultData } else { try { - return JSON.parse(store) + const parsed = JSON.parse(store) + if (view) { + parsed.view = view + } + return parsed } catch (error) { - return simpleDeepClone(exampleData) + const defaultData = simpleDeepClone(exampleData) + if (view) { + defaultData.view = view + } + return defaultData + } + } +} + +// 存储视图数据 +export const storeViewData = view => { + try { + if (window.takeOverApp || vuexStore.state.isHandleLocalFile) { + return } + localStorage.setItem(SIMPLE_MIND_MAP_VIEW, JSON.stringify(view || null)) + } catch (error) { + console.log(error) } } @@ -49,6 +83,11 @@ export const storeData = data => { ...originData, ...data } + const { view } = originData + if (view !== undefined) { + storeViewData(view) + delete originData.view + } if (window.takeOverApp) { mindMapData = originData window.takeOverAppMethods.saveMindMapData(originData) diff --git a/web/src/pages/Edit/components/Edit.vue b/web/src/pages/Edit/components/Edit.vue index 626b786df..51bb5d76a 100644 --- a/web/src/pages/Edit/components/Edit.vue +++ b/web/src/pages/Edit/components/Edit.vue @@ -101,7 +101,7 @@ import ShortcutKey from './ShortcutKey.vue' import Contextmenu from './Contextmenu.vue' import RichTextToolbar from './RichTextToolbar.vue' import NodeNoteContentShow from './NodeNoteContentShow.vue' -import { getData, getConfig, storeData } from '@/api' +import { getData, getConfig, storeData, storeViewData } from '@/api' import Navigator from './Navigator.vue' import NodeImgPreview from './NodeImgPreview.vue' import SidebarTrigger from './SidebarTrigger.vue' @@ -195,6 +195,12 @@ export default { mindMapConfig: {}, prevImg: '', storeConfigTimer: null, + storeDataTimer: null, + storeViewTimer: null, + pendingStorePatch: null, + idleSaveTaskId: null, + isCanvasPointerDown: false, + pendingViewDataOnDrag: null, showDragMask: false } }, @@ -239,6 +245,8 @@ export default { this.$bus.$on('endTextEdit', this.handleEndTextEdit) this.$bus.$on('createAssociativeLine', this.handleCreateLineFromActiveNode) this.$bus.$on('startPainter', this.handleStartPainter) + this.$bus.$on('svg_mousedown', this.handleSvgMousedown) + this.$bus.$on('mouseup', this.handleCanvasMouseup) this.$bus.$on('node_tree_render_end', this.handleHideLoading) this.$bus.$on('showLoading', this.handleShowLoading) this.$bus.$on('localStorageExceeded', this.onLocalStorageExceeded) @@ -255,11 +263,17 @@ export default { this.$bus.$off('endTextEdit', this.handleEndTextEdit) this.$bus.$off('createAssociativeLine', this.handleCreateLineFromActiveNode) this.$bus.$off('startPainter', this.handleStartPainter) + this.$bus.$off('svg_mousedown', this.handleSvgMousedown) + this.$bus.$off('mouseup', this.handleCanvasMouseup) this.$bus.$off('node_tree_render_end', this.handleHideLoading) this.$bus.$off('showLoading', this.handleShowLoading) this.$bus.$off('localStorageExceeded', this.onLocalStorageExceeded) window.removeEventListener('resize', this.handleResize) this.$bus.$off('showDownloadTip', this.showDownloadTip) + clearTimeout(this.storeConfigTimer) + clearTimeout(this.storeDataTimer) + clearTimeout(this.storeViewTimer) + this.cancelIdleSaveTask() this.mindMap.destroy() }, methods: { @@ -288,6 +302,19 @@ export default { this.mindMap.painter.startPainter() }, + handleSvgMousedown() { + this.isCanvasPointerDown = true + }, + + handleCanvasMouseup() { + if (!this.isCanvasPointerDown && !this.pendingViewDataOnDrag) return + this.isCanvasPointerDown = false + if (!this.pendingViewDataOnDrag) return + const viewData = this.pendingViewDataOnDrag + this.pendingViewDataOnDrag = null + this.queueViewDataSave(viewData, true) + }, + handleResize() { this.mindMap.resize() }, @@ -312,18 +339,84 @@ export default { this.mindMapConfig = getConfig() || {} }, + // 在浏览器空闲时执行任务,减少对交互帧的抢占 + runWhenIdle(fn) { + this.cancelIdleSaveTask() + if (typeof window.requestIdleCallback === 'function') { + this.idleSaveTaskId = window.requestIdleCallback(() => { + this.idleSaveTaskId = null + fn() + }, { timeout: 1000 }) + } else { + this.idleSaveTaskId = window.setTimeout(() => { + this.idleSaveTaskId = null + fn() + }, 0) + } + }, + + cancelIdleSaveTask() { + if (!this.idleSaveTaskId) return + if (typeof window.cancelIdleCallback === 'function') { + window.cancelIdleCallback(this.idleSaveTaskId) + } else { + clearTimeout(this.idleSaveTaskId) + } + this.idleSaveTaskId = null + }, + + flushPendingStoreSave() { + if (!this.pendingStorePatch) return + this.runWhenIdle(() => { + if (!this.pendingStorePatch) return + const patch = this.pendingStorePatch + this.pendingStorePatch = null + if (patch.root !== undefined) { + storeData({ root: patch.root }) + } + if (patch.view !== undefined) { + storeViewData(patch.view) + } + }) + }, + + queueRootDataSave(data) { + this.pendingStorePatch = { + ...(this.pendingStorePatch || {}), + root: data + } + clearTimeout(this.storeDataTimer) + this.storeDataTimer = setTimeout(() => { + this.flushPendingStoreSave() + }, 1200) + }, + + queueViewDataSave(data, immediate = false) { + this.pendingStorePatch = { + ...(this.pendingStorePatch || {}), + view: data + } + clearTimeout(this.storeViewTimer) + if (immediate) { + this.flushPendingStoreSave() + return + } + this.storeViewTimer = setTimeout(() => { + this.flushPendingStoreSave() + }, 1200) + }, + // 存储数据当数据有变时 bindSaveEvent() { this.$bus.$on('data_change', data => { - storeData({ root: data }) + this.queueRootDataSave(data) }) this.$bus.$on('view_data_change', data => { - clearTimeout(this.storeConfigTimer) - this.storeConfigTimer = setTimeout(() => { - storeData({ - view: data - }) - }, 300) + if (this.isCanvasPointerDown) { + this.pendingViewDataOnDrag = data + return + } + this.queueViewDataSave(data) }) }, diff --git a/web/src/pages/Edit/components/Navigator.vue b/web/src/pages/Edit/components/Navigator.vue index fe5424a6c..fb1d75c92 100644 --- a/web/src/pages/Edit/components/Navigator.vue +++ b/web/src/pages/Edit/components/Navigator.vue @@ -56,7 +56,9 @@ export default { mindMapImg: '', width: 0, setSizeTimer: null, - withTransition: true + withTransition: true, + lastViewX: null, + lastViewY: null } }, computed: { @@ -69,8 +71,9 @@ export default { window.addEventListener('resize', this.setSize) this.$bus.$on('toggle_mini_map', this.toggle_mini_map) this.$bus.$on('data_change', this.data_change) - this.$bus.$on('view_data_change', this.data_change) this.$bus.$on('node_tree_render_end', this.data_change) + this.$bus.$on('scale', this.data_change) + this.$bus.$on('translate', this.onTranslate) window.addEventListener('mouseup', this.onMouseup) this.mindMap.on( 'mini_map_view_box_position_change', @@ -81,8 +84,9 @@ export default { window.removeEventListener('resize', this.setSize) this.$bus.$off('toggle_mini_map', this.toggle_mini_map) this.$bus.$off('data_change', this.data_change) - this.$bus.$off('view_data_change', this.data_change) this.$bus.$off('node_tree_render_end', this.data_change) + this.$bus.$off('scale', this.data_change) + this.$bus.$off('translate', this.onTranslate) window.removeEventListener('mouseup', this.onMouseup) this.mindMap.off( 'mini_map_view_box_position_change', @@ -93,6 +97,10 @@ export default { // 切换显示小地图 toggle_mini_map(show) { this.showMiniMap = show + if (!show) { + this.lastViewX = null + this.lastViewY = null + } this.$nextTick(() => { if (this.$refs.navigatorBox) { this.init() @@ -114,6 +122,46 @@ export default { }, 500) }, + // 画布平移时仅更新视口框,避免频繁重绘小地图图片 + onTranslate(x, y) { + if (!this.showMiniMap) return + if (this.lastViewX === null || this.lastViewY === null) { + this.lastViewX = x + this.lastViewY = y + return + } + const dx = x - this.lastViewX + const dy = y - this.lastViewY + this.lastViewX = x + this.lastViewY = y + if (dx === 0 && dy === 0) return + const currentState = this.mindMap.miniMap.currentState + if (!currentState) return + const { miniMapBoxScale, miniMapBoxLeft, miniMapBoxTop } = currentState + const boxDx = -dx * miniMapBoxScale + const boxDy = -dy * miniMapBoxScale + const left = Math.max( + miniMapBoxLeft, + Number.parseFloat(this.viewBoxStyle.left) + boxDx + ) + const right = Math.max( + miniMapBoxLeft, + Number.parseFloat(this.viewBoxStyle.right) - boxDx + ) + const top = Math.max( + miniMapBoxTop, + Number.parseFloat(this.viewBoxStyle.top) + boxDy + ) + const bottom = Math.max( + miniMapBoxTop, + Number.parseFloat(this.viewBoxStyle.bottom) - boxDy + ) + this.viewBoxStyle.left = left + 'px' + this.viewBoxStyle.right = right + 'px' + this.viewBoxStyle.top = top + 'px' + this.viewBoxStyle.bottom = bottom + 'px' + }, + // 计算容器宽度 setSize() { clearTimeout(this.setSizeTimer) @@ -152,6 +200,9 @@ export default { this.svgBoxScale = miniMapBoxScale this.svgBoxLeft = miniMapBoxLeft this.svgBoxTop = miniMapBoxTop + const viewData = this.mindMap.view.getTransformData() + this.lastViewX = viewData.state.x + this.lastViewY = viewData.state.y }, // 小地图鼠标按下事件 From 84b0a4fcc99df123640f0f3af310c395391a26f9 Mon Sep 17 00:00:00 2001 From: "vincent.wu" Date: Sun, 12 Apr 2026 22:01:00 +0800 Subject: [PATCH 2/2] perf(core): optimize text editing and improve JSON export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TextEdit.js: 添加文本变更检查避免不必要的重渲染;Export.js: 直接返回正确 MIME 类型的 Blob;utils/index.js: 增强 downloadFile() 支持 Blob 对象 --- simple-mind-map/src/core/render/TextEdit.js | 16 ++++++++++------ simple-mind-map/src/plugins/Export.js | 6 +++--- simple-mind-map/src/utils/index.js | 11 ++++++++++- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/simple-mind-map/src/core/render/TextEdit.js b/simple-mind-map/src/core/render/TextEdit.js index e5e6caa2f..d73e1240f 100644 --- a/simple-mind-map/src/core/render/TextEdit.js +++ b/simple-mind-map/src/core/render/TextEdit.js @@ -489,12 +489,16 @@ export default class TextEdit { this.textEditNode.style.fontWeight = 'normal' this.textEditNode.style.transform = 'translateY(0)' this.setIsShowTextEdit(false) - this.mindMap.execCommand('SET_NODE_TEXT', currentNode, text) - // if (currentNode.isGeneralization) { - // // 概要节点 - // currentNode.generalizationBelongNode.updateGeneralization() - // } - this.mindMap.render() + const lastText = currentNode.getData('text') + const hasChanged = text !== lastText + if (hasChanged) { + this.mindMap.execCommand('SET_NODE_TEXT', currentNode, text) + // if (currentNode.isGeneralization) { + // // 概要节点 + // currentNode.generalizationBelongNode.updateGeneralization() + // } + this.mindMap.render() + } this.mindMap.emit( 'hide_text_edit', this.textEditNode, diff --git a/simple-mind-map/src/plugins/Export.js b/simple-mind-map/src/plugins/Export.js index 88623da61..4c8b80ef6 100644 --- a/simple-mind-map/src/plugins/Export.js +++ b/simple-mind-map/src/plugins/Export.js @@ -425,9 +425,9 @@ class Export { async json(name, withConfig = true) { const data = this.mindMap.getData(withConfig) const str = JSON.stringify(data) - const blob = new Blob([str]) - const res = await readBlob(blob) - return res + return new Blob([str], { + type: 'application/json;charset=utf-8' + }) } // 专有文件,其实就是json文件 diff --git a/simple-mind-map/src/utils/index.js b/simple-mind-map/src/utils/index.js index b3524ad85..4e64a4f4a 100644 --- a/simple-mind-map/src/utils/index.js +++ b/simple-mind-map/src/utils/index.js @@ -272,8 +272,17 @@ export const parseDataUrl = data => { // 下载文件 export const downloadFile = (file, fileName) => { let a = document.createElement('a') - a.href = file a.download = fileName + if (file instanceof Blob) { + const url = URL.createObjectURL(file) + a.href = url + a.click() + setTimeout(() => { + URL.revokeObjectURL(url) + }, 0) + return + } + a.href = file a.click() }