Files
Aislo/B05_Profile/B05_Profile_UI_Profile_History.ts
T
eomsangdonandClaude Opus 5.5 002d0e85ac refactor(387-1): 설계 화면 세션 직접 접근 → 등록부 · 프로젝트 없는 열쇠 걷음
- 되돌리기 사진 = 연 프로젝트 초안 칸만(projectStateKeys) · 옛 b05/b06 접두 지움
- 관 자리 b05:pipes 걷음 → 가게 pipes 초안으로 되돌림
- 조정창 · 연동 · 세월교 · BOX 세션 맵 = readByKey · writeByKey
- 노선 편집 횡단 반폭 = routeStateKeys · 계획고 옛 키(프로젝트 없음) 안 받음
- 새 test_384_1_session_keys · 사진첩 시험 헬퍼 = 진짜 등록부 부름

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-17 17:29:17 +09:00

264 lines
12 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Profile_History.ts
* B05 조작 되돌리기(undo)·다시하기(redo) — 세션 캐시 묶음 스냅샷.
*
* 왜 세션 캐시 통째인가(2026-09-02 사용자 지시): B05의 조작값은 화면마다 흩어진 것이
* 아니라 **세션 저장소 한 벌**로 모인다(계획선 편집 초안 · 구조물 대기분 · 상단측
* 오버라이드 · 최신 경로 캐시). 종단 계획선을 건드리면 구조물 자리와 횡단 설계 캐시가
* 함께 바뀌므로, 종단 편집만 되돌리면 나머지가 어긋난 채 남는다. 그래서 되돌리기 단위는
* **B05 조작 전부**이고, 스냅샷도 세션 키 묶음으로 뜬다.
*
* 저장하지 않는 것: 패널 접힘·높이·표시 토글 같은 **화면 배치 값**. 조작 이력이 아니라
* 보기 설정이라 되돌릴 대상이 아니다(`isLayoutKey`).
*
* 대상 = 지금 프로젝트(사진첩을 세울 때 연 것)의 초안 통(`aislo:draft:` — 노선 초안 · 계획고 · 관 · 구조물 · 횡단 · 346-4).
* 다른 프로젝트 칸은 찍지도 지우지도 않음 · 세션은 등록부(`b_page_state`) 창구로만(387-1).
* 끌기 · 누르고 있기 중간은 안 찍음 — 누름이 남아 있는 동안 record() 는 미루고 놓을 때 한 번.
* 사진끼리 안 바뀐 값은 앞 사진 글을 같이 씀 — 칸마다 수백 KB × 100단이어도 바뀐 칸만 늘어남.
*
* 페이지 사진첩 하나(346-4) — 평면(노선 편집) · 종단 · 횡단 · 관 · 구조물이 같은 줄. 노선 편집기는
* `pageHistory()` 로 쌓고 되돌리며 `onPageHistory` 로 되살림 · 단추 켜짐을 받음. 고름 · 관 id 장부 ·
* 종류 캐시 · 구조물 넘김 · 다른 페이지(b08 · b09) 칸은 조작이 아니라 사진 밖.
* 놓은 뒤 · 칸 값 바꾼 뒤(change) 잠깐 기다려 한 번 더 찍음 — 쌓는 줄이 따로 없는 조작(횡단 카드 등)도 한 걸음.
* 열 때(349) — 세운 뒤 첫 손길(누름 · 키 · 칸 바꿈) 전까지는 찍어도 첫 사진을 다시 뜨기만 함: 열 때 쓰는
* 초안(표준 횡단면 · 첫 계산 결과 · 되살린 노선 초안)은 걸음이 아니라 열 때 모습.
* 쉬는 동안 온 값(364-1) — 손길이 다 찍힌 뒤 새 손길이 닿기 전에 바뀐 칸(뒤에서 오는 파생 값 · 찍을 때만
* 옮겨 적는 관 자리)은 그 손길 직전에 지금 사진으로 덮음 — 판 열기 · 회전 같은 보기 손길이 걸음이 안 됨.
* 씨앗 값(364-1) — 비었을 때만 채우는 값(표준 횡단 · 횡단 보기 첫 열기)은 `seedPageHistory` 로 모든 사진에 넣음.
* ========================================================================== */
import { projectStateKeys, readByKey, writeByKey } from "../A00_Common/b_page_state";
import { draftProjectId } from "../A00_Common/design/design_store";
/** 초안 통이지만 조작이 아닌 칸 · 다른 페이지 칸 — 사진 밖. */
const NOT_EDIT =
/^aislo:draft:(selection|pipe-ids|structure-type-cache|structure-pick|b08-[^:]*|b09-[^:]*):/;
/** 놓은 뒤 · 칸 바꾼 뒤 기다렸다 찍는 시간(ms) — 서버 다시 셈 뒤 초안 쓰기까지 한 걸음에. */
const SETTLE_MS = 400;
/** 한 프로젝트의 사진 칸 키 — B05·B06 은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */
const editKeys = (projectId: string): string[] =>
projectStateKeys("draft", projectId).filter((key) => !NOT_EDIT.test(key));
const isEditKey = (key: string, projectId: string): boolean =>
key.startsWith("aislo:draft:") && key.split(":")[3] === projectId && !NOT_EDIT.test(key);
/** 한 시점의 조작값 — 키 → 값(JSON 문자열). */
export type HistorySnapshot = Record<string, string>;
/** `previous` 와 같은 값은 그 글을 그대로 담음(메모리 한 벌). 저장소가 막히면 빈 사진 — 되돌리기만 꺼짐. */
function takeSnapshot(projectId: string, previous?: HistorySnapshot): HistorySnapshot {
const snapshot: HistorySnapshot = {};
for (const key of editKeys(projectId)) {
const value = readByKey(key);
if (value !== null) snapshot[key] = previous?.[key] === value ? previous[key] : value;
}
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<typeof setTimeout> | undefined;
/** 놓은 뒤 · 칸 바꾼 뒤 찍기를 기다리는 중 — 그 사이 손길은 앞 조작의 값을 덮으면 안 됨. */
let settling = false;
/** 사용자 손길 수 — 사진첩마다 세운 때 값과 견줘 「열 때」를 가름. */
let gestures = 0;
/** 페이지 사진첩 — 마지막에 세운 것(종단도 패널이 감싼 것으로 바꿔 둠). */
let active: ProfileHistory | null = null;
/** 마지막에 세운 사진첩의 「쉬는 동안 온 값 덮기」 — 손길이 닿을 때 부름(364-1). */
let rebaseActive: (() => void) | null = null;
let seedActive: ((key: string) => void) | null = null;
/** 비었던 칸을 채운 씨앗 값 — 그 칸이 없는 사진 전부에 넣음(걸음 아님 · 되돌려도 안 지움). */
export const seedPageHistory = (key: string | null): void => void (key && seedActive?.(key));
export const pageHistory = (): ProfileHistory | null => active;
export function setPageHistory(history: ProfileHistory): void {
active = history;
}
/** 되돌리기 · 다시하기가 세션을 되쓰고 화면을 되살리는 중인가 — 그 사이 바뀐 노선은 새 조작이 아님(356-4). */
let replaying = false;
export const pageReplaying = (): boolean => replaying;
export type PageHistoryEvent = "undo" | "redo";
interface PageHistoryHooks {
/** 세션을 되쓴 뒤 — 자기 화면 값을 다시 읽음(사진첩이 막고 있는 동안이라 쌓이지 않음). */
restore?: (kind: PageHistoryEvent) => void;
/** 쌓음 · 되돌림 뒤 — 단추 켜짐 맞춤. */
change?: () => void;
}
const hooks = new Set<PageHistoryHooks>();
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);
settling = true;
settleTimer = setTimeout(() => {
settling = false;
active?.record();
}, SETTLE_MS);
}
function watchPointer(): void {
if (pointerWatched || typeof window === "undefined") return;
if (typeof window.addEventListener !== "function") return;
pointerWatched = true;
const touched = (): void => {
rebaseActive?.();
gestures += 1;
};
window.addEventListener("pointerdown", () => (touched(), (pointerHeld = true)), true);
window.addEventListener("keydown", touched, true);
window.addEventListener("change", touched, 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(projectId: string, snapshot: HistorySnapshot): void {
editKeys(projectId).forEach((key) => {
if (!(key in snapshot)) writeByKey(key, null);
});
Object.entries(snapshot).forEach(([key, value]) => writeByKey(key, value));
}
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,
/** 찍기 직전 — 세션 밖 값을 사진이 보는 칸으로 옮겨 적음(관 자리). */
sync: () => void = () => undefined,
): ProfileHistory {
watchPointer();
sync();
/** 사진첩을 세울 때 연 프로젝트 — 그 칸만 찍고 되씀. */
const projectId = draftProjectId();
const stack: HistorySnapshot[] = [takeSnapshot(projectId)];
let cursor = 0;
/** 복원 중에 들어오는 record()를 무시한다 — 복원이 새 이력을 만들면 redo가 사라진다. */
let restoring = false;
/** 연속 조작 중에는 record()를 흘려보내고 resume()에서 한 번만 쌓는다. */
let paused = false;
/** 세운 때 손길 수 — 손길을 못 듣는 환경(창 없음)은 열 때가 없음. */
const openedAt = pointerWatched ? gestures : -1;
/** 마지막으로 찍은 때 손길 수 — 같으면 그 뒤 손길이 없었음(쉬는 중). */
let restAt = gestures;
function apply(index: number): boolean {
if (index < 0 || index >= stack.length) return false;
const kind: PageHistoryEvent = index < cursor ? "undo" : "redo";
cursor = index;
restoring = replaying = true;
restoreSnapshot(projectId, stack[cursor]);
try {
onRestore();
hooks.forEach((entry) => entry.restore?.(kind));
// 되살리며 다시 쓴 같은 값(정리된 꼴)을 이 걸음으로 — 다음 찍기가 헛걸음을 안 만듦.
stack[cursor] = takeSnapshot(projectId, stack[cursor]);
} finally {
restoring = replaying = false;
}
restAt = gestures;
changed();
return true;
}
const history: ProfileHistory = {
record() {
if (restoring || paused) return;
if (pointerHeld) {
releaseWaiters.add(history.record);
return;
}
sync();
restAt = gestures;
const snapshot = takeSnapshot(projectId, stack[cursor]);
if (sameSnapshot(snapshot, stack[cursor])) return;
if (gestures === openedAt) {
stack[0] = snapshot; // 열 때 — 첫 사진만 다시 뜸
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,
};
rebaseActive = () => {
if (restoring || paused || pointerHeld || settling || gestures !== restAt) return;
sync();
stack[cursor] = takeSnapshot(projectId, stack[cursor]); // 쉬는 동안 온 값 = 지금 걸음의 모습
};
seedActive = (key) => {
const value = isEditKey(key, projectId) ? readByKey(key) : null;
if (value !== null) stack.forEach((snapshot) => void (snapshot[key] ??= value));
};
active = history;
return history;
}