WholeProcessPlatform/frontend/src/modules/MapStudio/composables/useUndoRedo.ts
2026-07-28 18:28:07 +08:00

446 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* undo / redo 管理器(模块级单例)
*
* 设计:
* - 快照记录 RightDrawer 所有可撤回状态legend / anchors / images / draw
* - 两条触发路径:
* ① drawSource 的 addfeature/removefeature 事件 → drawCheckpoint()
* 立即记录,不受节流限制,每个绘制图形增删为独立撤回步骤
* ② store ref deep watchflush:'sync' + 前沿节流 200ms
* 无绘制事件干扰时,首次变更立即 checkpoint200ms 内后续变更只重置定时器
* - suppressUntil 机制drawCheckpoint 后抑制 store watcher 50ms避免重复
* - drawToolActive 移出 watcher 列表(仅保存在快照中供 restore
* 单纯切换绘制工具不产生撤回步骤
* - 撤回时 restore 恢复快照isRestoring 标记防循环
* - 最多保留 MAX_STEPS50
* - 快捷键 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<typeof setTimeout> | 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);
}
}
// 重新关联选中的锚点 featureclearAllAnchors 后旧引用已失效问题4修复
if (anchorActions.reselectSelectedAnchor) {
anchorActions.reselectSelectedAnchor();
}
// 同步选中锚点的覆盖样式回 OL feature
if (anchorActions.syncSelectedOverrides) {
anchorActions.syncSelectedOverrides();
}
// 将保留的锚点名称同步回 featurerestoreAnchorsFromSave 的 _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 处理器中调用,在修改 storeupdateSelectedDrawInfo之前设置抑制窗口
* 阻止 store watcher 提前触发 checkpoint 产生重复撤回步骤。
* 与 setSuppressWatcher 的区别:不涉及 cooldownTimerwatcher 回调解耦前直接返回,
* 不会设置 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
};
}