/* ============================================================================= * B05_Profile_UI_Profile_History.ts * B05 조작 되돌리기(undo)·다시하기(redo) — 세션 캐시 묶음 스냅샷. * * 왜 세션 캐시 통째인가(2026-09-02 사용자 지시): B05의 조작값은 화면마다 흩어진 것이 * 아니라 **세션 저장소 한 벌**로 모인다(계획선 편집 초안 · 구조물 대기분 · 상단측 * 오버라이드 · 최신 경로 캐시). 종단 계획선을 건드리면 구조물 자리와 횡단 설계 캐시가 * 함께 바뀌므로, 종단 편집만 되돌리면 나머지가 어긋난 채 남는다. 그래서 되돌리기 단위는 * **B05 조작 전부**이고, 스냅샷도 세션 키 묶음으로 뜬다. * * 저장하지 않는 것: 패널 접힘·높이·표시 토글 같은 **화면 배치 값**. 조작 이력이 아니라 * 보기 설정이라 되돌릴 대상이 아니다(`isLayoutKey`). * * 대상 = 초안 통(`aislo:draft:` 전부 — 노선 초안 · 계획고 · 관 · 구조물 · 횡단) + 옛 b05 · b06 키(346-4). * 끌기 · 누르고 있기 중간은 안 찍음 — 누름이 남아 있는 동안 record() 는 미루고 놓을 때 한 번. * 사진끼리 안 바뀐 값은 앞 사진 글을 같이 씀 — 칸마다 수백 KB × 100단이어도 바뀐 칸만 늘어남. * * 페이지 사진첩 하나(346-4) — 평면(노선 편집) · 종단 · 횡단 · 관 · 구조물이 같은 줄. 노선 편집기는 * `pageHistory()` 로 쌓고 되돌리며 `onPageHistory` 로 되살림 · 단추 켜짐을 받음. 고름 · 관 id 장부 · * 종류 캐시 · 구조물 넘김 · 다른 페이지(b08 · b09) 칸은 조작이 아니라 사진 밖. * 놓은 뒤 · 칸 값 바꾼 뒤(change) 잠깐 기다려 한 번 더 찍음 — 쌓는 줄이 따로 없는 조작(횡단 카드 등)도 한 걸음. * ========================================================================== */ /** 조작값 스냅샷 대상 세션 키 접두어. B05·B06은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */ const DRAFT_PREFIX = "aislo:draft:"; const LEGACY_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-"]; /** 초안 통이지만 조작이 아닌 칸 · 다른 페이지 칸 — 사진 밖. */ const NOT_EDIT = /^aislo:draft:(selection|pipe-ids|structure-type-cache|structure-pick|b08-[^:]*|b09-[^:]*):/; /** 놓은 뒤 · 칸 바꾼 뒤 기다렸다 찍는 시간(ms) — 서버 다시 셈 뒤 초안 쓰기까지 한 걸음에. */ const SETTLE_MS = 400; /** 조작이 아니라 보기 설정인 키 — 스냅샷에서 뺀다. */ 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 { if (key.startsWith(DRAFT_PREFIX)) return !NOT_EDIT.test(key); return LEGACY_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) && !isLayoutKey(key); } /** 한 시점의 조작값 — 키 → 값(JSON 문자열). */ export type HistorySnapshot = Record; /** `previous` 와 같은 값은 그 글을 그대로 담음(메모리 한 벌). */ function takeSnapshot(previous?: HistorySnapshot): 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] = previous?.[key] === value ? previous[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]); } /** 누름(마우스 · 터치 · 펜)이 남아 있나 — 창 하나에 한 번 듣고 놓으면 미룬 기록을 부름. */ let pointerHeld = false; const releaseWaiters = new Set<() => void>(); let pointerWatched = false; let settleTimer: ReturnType | undefined; /** 페이지 사진첩 — 마지막에 세운 것(종단도 패널이 감싼 것으로 바꿔 둠). */ let active: ProfileHistory | null = null; export const pageHistory = (): ProfileHistory | null => active; export function setPageHistory(history: ProfileHistory): void { active = history; } export type PageHistoryEvent = "undo" | "redo"; interface PageHistoryHooks { /** 세션을 되쓴 뒤 — 자기 화면 값을 다시 읽음(사진첩이 막고 있는 동안이라 쌓이지 않음). */ restore?: (kind: PageHistoryEvent) => void; /** 쌓음 · 되돌림 뒤 — 단추 켜짐 맞춤. */ change?: () => void; } const hooks = new Set(); export function onPageHistory(entry: PageHistoryHooks): () => void { hooks.add(entry); return () => hooks.delete(entry); } const changed = (): void => hooks.forEach((entry) => entry.change?.()); /** 쌓는 줄이 따로 없는 조작도 놓은 뒤 한 걸음 — 같으면 안 쌓임. */ function settle(): void { clearTimeout(settleTimer); settleTimer = setTimeout(() => active?.record(), SETTLE_MS); } function watchPointer(): void { if (pointerWatched || typeof window === "undefined") return; if (typeof window.addEventListener !== "function") return; pointerWatched = true; window.addEventListener("pointerdown", () => (pointerHeld = true), true); const release = (): void => { if (!pointerHeld) return; // 놓을 때 부르는 쪽(끌기 마무리 · 세션 쓰기)이 먼저 끝나게 한 틱 뒤. window.setTimeout(() => { pointerHeld = false; const waiters = [...releaseWaiters]; releaseWaiters.clear(); waiters.forEach((fn) => fn()); settle(); }, 0); }; window.addEventListener("pointerup", release, true); window.addEventListener("pointercancel", release, true); window.addEventListener("blur", release); window.addEventListener("change", settle, true); // 창 밖에서 놓아 pointerup 을 못 받은 때 — 단추 없이 움직이면 놓은 것. window.addEventListener( "pointermove", (event) => { if (pointerHeld && event.buttons === 0) release(); }, true, ); } /** 스냅샷을 세션에 되쓴다 — 스냅샷에 없던 조작 키는 지운다. */ 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 = 100): ProfileHistory { watchPointer(); 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; const kind: PageHistoryEvent = index < cursor ? "undo" : "redo"; cursor = index; restoring = true; restoreSnapshot(stack[cursor]); try { onRestore(); hooks.forEach((entry) => entry.restore?.(kind)); // 되살리며 다시 쓴 같은 값(정리된 꼴)을 이 걸음으로 — 다음 찍기가 헛걸음을 안 만듦. stack[cursor] = takeSnapshot(stack[cursor]); } finally { restoring = false; } changed(); return true; } const history: ProfileHistory = { record() { if (restoring || paused) return; if (pointerHeld) { releaseWaiters.add(history.record); return; } const snapshot = takeSnapshot(stack[cursor]); if (sameSnapshot(snapshot, stack[cursor])) return; stack.splice(cursor + 1); stack.push(snapshot); if (stack.length > limit) stack.shift(); cursor = stack.length - 1; changed(); }, pause() { paused = true; }, resume() { if (!paused) return; paused = false; history.record(); }, undo: () => apply(cursor - 1), redo: () => apply(cursor + 1), canUndo: () => cursor > 0, canRedo: () => cursor < stack.length - 1, }; active = history; return history; }