WholeProcessPlatform/frontend/src/modules/MapStudio/composables/useUndoRedo.ts

446 lines
14 KiB
TypeScript
Raw Normal View History

2026-07-28 18:28:07 +08:00
/**
* 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
};
}