446 lines
14 KiB
TypeScript
446 lines
14 KiB
TypeScript
/**
|
||
* 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<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);
|
||
}
|
||
}
|
||
|
||
// 重新关联选中的锚点 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
|
||
};
|
||
}
|