/* ============================================================================= * B05_Profile_UI_Profile_History.ts * B05 조작 되돌리기(undo)·다시하기(redo) — 세션 캐시 묶음 스냅샷. * * 왜 세션 캐시 통째인가(2026-09-02 사용자 지시): B05의 조작값은 화면마다 흩어진 것이 * 아니라 **세션 저장소 한 벌**로 모인다(계획선 편집 초안 · 구조물 대기분 · 상단측 * 오버라이드 · 최신 경로 캐시). 종단 계획선을 건드리면 구조물 자리와 횡단 설계 캐시가 * 함께 바뀌므로, 종단 편집만 되돌리면 나머지가 어긋난 채 남는다. 그래서 되돌리기 단위는 * **B05 조작 전부**이고, 스냅샷도 세션 키 묶음으로 뜬다. * * 저장하지 않는 것: 패널 접힘·높이·표시 토글 같은 **화면 배치 값**. 조작 이력이 아니라 * 보기 설정이라 되돌릴 대상이 아니다(`isLayoutKey`). * ========================================================================== */ /** 조작값 스냅샷 대상 세션 키 접두어. B05·B06은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */ const DATA_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-"]; /** 조작이 아니라 보기 설정인 키 — 스냅샷에서 뺀다. */ function isLayoutKey(key: string): boolean { return ( key.includes("collapsed") || key.includes("width") || key.includes("height") || key.includes("open") || key.includes("visible") ); } function isDataKey(key: string): boolean { return DATA_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) && !isLayoutKey(key); } /** 한 시점의 조작값 — 키 → 값(JSON 문자열). */ export type HistorySnapshot = Record; function takeSnapshot(): HistorySnapshot { const snapshot: HistorySnapshot = {}; try { for (let index = 0; index < sessionStorage.length; index += 1) { const key = sessionStorage.key(index); if (!key || !isDataKey(key)) continue; const value = sessionStorage.getItem(key); if (value !== null) snapshot[key] = value; } } catch { // 세션 저장소를 못 쓰는 환경에서는 되돌리기만 비활성이 된다. } return snapshot; } function sameSnapshot(left: HistorySnapshot, right: HistorySnapshot): boolean { const leftKeys = Object.keys(left); const rightKeys = Object.keys(right); if (leftKeys.length !== rightKeys.length) return false; return leftKeys.every((key) => left[key] === right[key]); } /** 스냅샷을 세션에 되쓴다 — 스냅샷에 없던 조작 키는 지운다. */ function restoreSnapshot(snapshot: HistorySnapshot): void { try { const existing: string[] = []; for (let index = 0; index < sessionStorage.length; index += 1) { const key = sessionStorage.key(index); if (key && isDataKey(key)) existing.push(key); } existing.forEach((key) => { if (!(key in snapshot)) sessionStorage.removeItem(key); }); Object.entries(snapshot).forEach(([key, value]) => sessionStorage.setItem(key, value)); } catch { // 되쓰기 실패 시 화면 상태는 그대로 둔다 — 잘못 섞인 복원보다 낫다. } } export interface ProfileHistory { /** 조작이 끝난 뒤 현재 상태를 이력에 쌓는다(직전과 같으면 무시). */ record(): void; /** 연속 조작(길게 누르기) 동안 기록을 미룬다 — 20칸 이동이 되돌리기 20번이 되는 것을 막는다. */ pause(): void; /** 연속 조작이 끝났음을 알린다 — 그 동안의 변화를 **한 덩어리로** 한 번만 쌓는다. */ resume(): void; undo(): boolean; redo(): boolean; canUndo(): boolean; canRedo(): boolean; } /** * 되돌리기 이력을 만든다. * * `onRestore`는 세션 값이 바뀐 뒤 화면을 다시 세우는 콜백이다 — 세션이 정본이므로 * 여기서 각 패널이 자기 값을 다시 읽어 그린다. */ export function createProfileHistory(onRestore: () => void, limit = 50): ProfileHistory { const stack: HistorySnapshot[] = [takeSnapshot()]; let cursor = 0; /** 복원 중에 들어오는 record()를 무시한다 — 복원이 새 이력을 만들면 redo가 사라진다. */ let restoring = false; /** 연속 조작 중에는 record()를 흘려보내고 resume()에서 한 번만 쌓는다. */ let paused = false; function apply(index: number): boolean { if (index < 0 || index >= stack.length) return false; cursor = index; restoring = true; restoreSnapshot(stack[cursor]); try { onRestore(); } finally { restoring = false; } return true; } return { record() { if (restoring || paused) return; const snapshot = takeSnapshot(); if (sameSnapshot(snapshot, stack[cursor])) return; stack.splice(cursor + 1); stack.push(snapshot); if (stack.length > limit) stack.shift(); cursor = stack.length - 1; }, pause() { paused = true; }, resume() { if (!paused) return; paused = false; this.record(); }, undo: () => apply(cursor - 1), redo: () => apply(cursor + 1), canUndo: () => cursor > 0, canRedo: () => cursor < stack.length - 1, }; }