/** * undo / redo 管理器(模块级单例) * * 设计: * - 快照记录 RightDrawer 所有可撤回状态(legend / anchors / images / draw) * - 两条触发路径: * ① drawSource 的 addfeature/removefeature 事件 → drawCheckpoint() * 立即记录,不受节流限制,每个绘制图形增删为独立撤回步骤 * ② store ref deep watch(flush:'sync' + 前沿节流 200ms) * 无绘制事件干扰时,首次变更立即 checkpoint,200ms 内后续变更只重置定时器 * - suppressUntil 机制:drawCheckpoint 后抑制 store watcher 50ms,避免重复 * - drawToolActive 移出 watcher 列表(仅保存在快照中供 restore), * 单纯切换绘制工具不产生撤回步骤 * - 撤回时 restore 恢复快照,isRestoring 标记防循环 * - 最多保留 MAX_STEPS(50)步 * - 快捷键 Ctrl+Z / Ctrl+Shift+Z */ import { ref, watch, onMounted, onUnmounted } from 'vue'; import { storeToRefs } from 'pinia'; import { useMapStudioStore } from '../stores/mapStudioStore'; import { drawActions } from './useDrawTools'; import { anchorActions } from './useAnchorLayers'; import type { LegendConfig, AnchorGlobalStyle, SelectedAnchorInfo, ImportedImage, DrawStyle, DrawToolType, TableOverlayData } from '../types'; const MAX_STEPS = 50; const COOLDOWN_MS = 200; interface UndoSnapshot { legendConfig: LegendConfig; anchorGlobalStyle: AnchorGlobalStyle; selectedAnchor: SelectedAnchorInfo | null; importedImages: ImportedImage[]; selectedImageId: string | null; tableOverlays: TableOverlayData[]; selectedTableId: string | null; drawStyle: DrawStyle; // selectedDrawStyle / selectedDrawInfo / selectedDrawText 不参与快照 drawFeatures: any[]; drawToolActive: DrawToolType | null; activeTab: string; /** 锚点 features 序列化数据(位置、图标、标签等) */ anchorFeatures: any[]; /** 锚点样式覆盖序列化数据 */ anchorOverrides: any[]; } const undoStack: UndoSnapshot[] = []; const redoStack: UndoSnapshot[] = []; /** 上一次 checkpoint 时的状态(下一次 checkpoint 时 push 到 undoStack) */ let previousSnapshot: UndoSnapshot | null = null; /** 正在 restore 中,阻止一切快照 */ let isRestoring = false; /** store watcher 前沿节流定时器(null = 可发起 checkpoint) */ let cooldownTimer: ReturnType | null = null; /** * drawCheckpoint 后的抑制截止时间戳。 * drawSource 事件触发 drawCheckpoint 后,在此时间之前 store watcher 跳过, * 避免 drawend 处理程序中的 store 变更产生重复 checkpoint。 */ let suppressUntil = 0; /** * 手动抑制 store watcher(不触发 checkpoint)。 * 用于锚点显隐切换时 clearSelection 修改 selectedAnchor 但不应产生撤回步骤。 */ let suppressWatcher = false; const canUndo = ref(false); const canRedo = ref(false); function updateButtons() { canUndo.value = undoStack.length > 0; canRedo.value = redoStack.length > 0; } /** 深拷贝当前 store 状态 + 绘制图形序列化数据 */ function takeSnapshot(): UndoSnapshot { const store = useMapStudioStore(); const s = storeToRefs(store); return { legendConfig: JSON.parse(JSON.stringify(s.legendConfig.value)), anchorGlobalStyle: JSON.parse(JSON.stringify(s.anchorGlobalStyle.value)), selectedAnchor: (() => { if (!s.selectedAnchor.value) return null; const cloned = JSON.parse(JSON.stringify(s.selectedAnchor.value)); // 不保存 displayLabel(锚点名称),名称修改应独立于样式/位置撤回(问题2/3修复) delete cloned.displayLabel; return cloned; })(), importedImages: JSON.parse(JSON.stringify(s.importedImages.value)), selectedImageId: s.selectedImageId.value, tableOverlays: JSON.parse(JSON.stringify(s.tableOverlays.value)), selectedTableId: s.selectedTableId.value, drawStyle: JSON.parse(JSON.stringify(s.drawStyle.value)), // selectedDrawInfo / selectedDrawStyle / selectedDrawText 不参与快照— // 右侧绘制面板跟随地图选中状态自然变化,不应产生撤回步骤(问题8修复) drawFeatures: drawActions.getSerializedFeatures ? drawActions.getSerializedFeatures() : [], drawToolActive: s.drawToolActive.value, activeTab: s.activeTab.value, anchorFeatures: anchorActions.getSerializedAnchors ? anchorActions.getSerializedAnchors() : [], anchorOverrides: anchorActions.getSerializedOverrides ? anchorActions.getSerializedOverrides() : [] }; } /** 将快照恢复到 store + 绘制图层 */ function restoreSnapshot(snapshot: UndoSnapshot) { isRestoring = true; const store = useMapStudioStore(); const s = storeToRefs(store); // 保存当前锚点名称,恢复后重新覆盖(displayLabel 不参与快照,问题2/3修复) const currentDisplayLabel = s.selectedAnchor.value?.displayLabel ?? ''; s.legendConfig.value = JSON.parse(JSON.stringify(snapshot.legendConfig)); s.anchorGlobalStyle.value = JSON.parse( JSON.stringify(snapshot.anchorGlobalStyle) ); s.selectedAnchor.value = snapshot.selectedAnchor ? JSON.parse(JSON.stringify(snapshot.selectedAnchor)) : null; // 恢复后保留当前锚点名称(displayLabel 不参与快照,问题2/3修复) if (s.selectedAnchor.value && currentDisplayLabel) { s.selectedAnchor.value.displayLabel = currentDisplayLabel; } s.importedImages.value = JSON.parse(JSON.stringify(snapshot.importedImages)); s.selectedImageId.value = snapshot.selectedImageId; s.tableOverlays.value = JSON.parse(JSON.stringify(snapshot.tableOverlays)); s.selectedTableId.value = snapshot.selectedTableId; s.drawStyle.value = JSON.parse(JSON.stringify(snapshot.drawStyle)); // 若表格 tab 但无选中表格,切到别处避免空内容闪烁 if (s.activeTab.value === 'table' && !s.selectedTableId.value) { s.activeTab.value = 'baseMap'; } // 恢复绘制图形selectedDrawStyle / selectedDrawInfo / selectedDrawText 不参与恢复— // 右侧绘制面板的自然状态不应被撤回覆盖(问题8修复) // ⚠ 不恢复 drawToolActive / activeTab —— 撤回不应切换工具或标签页 // s.drawToolActive.value = snapshot.drawToolActive; // s.activeTab.value = snapshot.activeTab; // 恢复绘制图形 if (drawActions.clearAllDrawings && drawActions.restoreDrawingsFromSave) { drawActions.clearAllDrawings(); if (snapshot.drawFeatures.length > 0) { drawActions.restoreDrawingsFromSave(snapshot.drawFeatures, {}); } } // 撤回后清除绘制选中状态,让右侧面板自然跟随地图状态(问题8修复) s.selectedDrawInfo.value = null; // 恢复锚点 features(位置、图标、标签等) // 注意:如果 snapshot 没有锚点数据(anchorFeatures.length === 0), // 不清除当前锚点,避免撤回初始状态(锚点加载前)时丢失所有已加载的锚点(问题6修复) if (snapshot.anchorFeatures.length > 0) { if ( anchorActions.clearAllAnchors && anchorActions.restoreAnchorsFromSave && anchorActions.restoreAnchorOverridesFromSave ) { anchorActions.clearAllAnchors(); anchorActions.restoreAnchorsFromSave(snapshot.anchorFeatures); if (snapshot.anchorOverrides.length > 0) { anchorActions.restoreAnchorOverridesFromSave(snapshot.anchorOverrides); } } // 重新关联选中的锚点 feature(clearAllAnchors 后旧引用已失效,问题4修复) if (anchorActions.reselectSelectedAnchor) { anchorActions.reselectSelectedAnchor(); } // 同步选中锚点的覆盖样式回 OL feature if (anchorActions.syncSelectedOverrides) { anchorActions.syncSelectedOverrides(); } // 将保留的锚点名称同步回 feature(restoreAnchorsFromSave 的 _label = stnm 会覆盖自定义名称) if ( anchorActions.updateSelectedLabel && s.selectedAnchor.value?.displayLabel ) { anchorActions.updateSelectedLabel(s.selectedAnchor.value.displayLabel); } } // 恢复后刷新 previousSnapshot previousSnapshot = takeSnapshot(); // 所有同步操作完成后再恢复 watcher,避免中间状态触发不必要的 checkpoint isRestoring = false; } /** * store watcher 触发的 checkpoint——带调试日志,受 suppressWatcher 和节流控制 */ function checkpoint() { if (isRestoring) return; if (suppressWatcher) return; if (previousSnapshot) { const current = takeSnapshot(); // 去重:如果前后快照完全一致,不产生撤回步骤 if (JSON.stringify(previousSnapshot) !== JSON.stringify(current)) { undoStack.push(previousSnapshot); if (undoStack.length > MAX_STEPS) { undoStack.shift(); } redoStack.length = 0; // 新操作 → 清空 redo updateButtons(); } previousSnapshot = current; } else { previousSnapshot = takeSnapshot(); } } /** * 绘制图形增删专用 checkpoint —— 不受节流限制,每个增删操作为一个独立步骤。 * 同时设置 suppressUntil 抑制后续 store watcher 的重复 checkpoint。 */ function drawCheckpoint() { if (isRestoring) return; // 设置抑制窗口,阻止紧随其后的 store ref 变更再次 checkpoint suppressUntil = performance.now() + 50; if (previousSnapshot) { const current = takeSnapshot(); // 如果唯一变化是 drawToolActive/activeTab 且图形数未变,则跳过(不产生虚假撤回步骤) if ( (previousSnapshot.drawFeatures?.length ?? 0) === (current.drawFeatures?.length ?? 0) ) { const changedKeys: string[] = []; for (const key of Object.keys(current) as (keyof UndoSnapshot)[]) { if ( JSON.stringify(previousSnapshot[key]) !== JSON.stringify(current[key]) ) { changedKeys.push(key); } } const nonDisplayKeys = changedKeys.filter( k => k !== 'drawToolActive' && k !== 'activeTab' ); if (nonDisplayKeys.length === 0) { previousSnapshot = current; return; } } // 去重:如果前后快照完全一致,不产生撤回步骤 if (JSON.stringify(previousSnapshot) !== JSON.stringify(current)) { undoStack.push(previousSnapshot); if (undoStack.length > MAX_STEPS) { undoStack.shift(); } redoStack.length = 0; updateButtons(); } previousSnapshot = current; } else { previousSnapshot = takeSnapshot(); } } /** * 更新 previousSnapshot 为当前状态,不产生撤回步骤。 * 用于锚点数据首次加载完成后同步快照,确保后续撤回能正确恢复锚点位置。 */ function syncSnapshot() { if (isRestoring) return; previousSnapshot = takeSnapshot(); } /** 临时抑制 store watcher checkpoint(用于锚点显隐切换等不应产生撤回步骤的场景) */ function setSuppressWatcher(val: boolean) { suppressWatcher = val; } /** * 在 drawend 处理器中调用,在修改 store(updateSelectedDrawInfo)之前设置抑制窗口, * 阻止 store watcher 提前触发 checkpoint 产生重复撤回步骤。 * 与 setSuppressWatcher 的区别:不涉及 cooldownTimer,watcher 回调解耦前直接返回, * 不会设置 cooldownTimer,避免后续 watcher 行为受影响。 */ function beginDrawBatch() { suppressUntil = performance.now() + 50; } // ── 公开 API ── function undo() { if (undoStack.length === 0) return; // 清除可能的抑制窗口,避免 restore 后 watcher 被意外跳过 suppressUntil = 0; suppressWatcher = false; const snapshot = undoStack.pop()!; const currentSnapshot = takeSnapshot(); redoStack.push(currentSnapshot); restoreSnapshot(snapshot); updateButtons(); } function redo() { if (redoStack.length === 0) return; suppressUntil = 0; suppressWatcher = false; undoStack.push(previousSnapshot || takeSnapshot()); if (undoStack.length > MAX_STEPS) { undoStack.shift(); } const snapshot = redoStack.pop()!; restoreSnapshot(snapshot); updateButtons(); } /** 在 RightDrawer setup 中调用,启动对 store 状态变更的 deep watch */ function initUndoWatch() { const store = useMapStudioStore(); const { legendConfig, anchorGlobalStyle, selectedAnchor, importedImages, selectedImageId, drawStyle // selectedDrawStyle / selectedDrawInfo / selectedDrawText 不参与 watcher — // 右侧绘制面板状态变更不应产生撤回步骤(问题8修复) // drawToolActive 不在 watcher 中 — 单纯切换工具不产生撤回步骤 } = storeToRefs(store); // 初始化 previousSnapshot previousSnapshot = takeSnapshot(); watch( [ legendConfig, anchorGlobalStyle, selectedAnchor, importedImages, selectedImageId, drawStyle ], () => { if (isRestoring) return; // drawCheckpoint 已处理 → 跳过,避免重复 if (performance.now() < suppressUntil) return; // 前沿节流:冷却中 → 仅重置定时器 if (cooldownTimer !== null) { clearTimeout(cooldownTimer); cooldownTimer = setTimeout(() => { cooldownTimer = null; }, COOLDOWN_MS); return; } // 冷却结束 → 立即 checkpoint,启动新冷却 checkpoint(); cooldownTimer = setTimeout(() => { cooldownTimer = null; }, COOLDOWN_MS); }, { deep: true, flush: 'sync' } ); } // ── 键盘快捷键 ── function onKeyDown(e: KeyboardEvent) { if ( e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement ) { return; } if (e.ctrlKey || e.metaKey) { if (e.shiftKey && (e.key === 'z' || e.key === 'Z')) { e.preventDefault(); redo(); return; } if (e.key === 'z' || e.key === 'Z') { e.preventDefault(); undo(); return; } } } function registerShortcuts() { onMounted(() => { document.addEventListener('keydown', onKeyDown); }); onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); }); } export function useUndoRedo() { return { canUndo, canRedo, undo, redo, initUndoWatch, registerShortcuts, drawCheckpoint, setSuppressWatcher, beginDrawBatch, syncSnapshot }; }