Files
Aislo/B05_Profile/B05_Profile_UI_Profile_History.ts
eomsangdonandClaude Opus 5 ecb3bcb9ce feat(B05): 종단도 방향키 연속 이동·이력 한 덩어리·쉬프트 상하
- 방향키 청취를 패널 루트에서 window 로 옮김 — 그래프 클릭 뒤 포커스가 body 에
  남아 방향키가 아무 반응이 없던 결함 수정 (패널이 화면에 없으면 무시)
- 길게 누르면 편집 버튼과 같은 속도로 연속 이동 (0.5초 뒤 0.1초 간격, OS 키 반복 무시)
- 이력에 pause()/resume() 추가 — 연속 이동 전체가 되돌리기 1회로 원위치.
  구조물 이동은 서버 재계산이 뒤따르므로 키를 뗀 뒤 700ms 뒤 기록
- pause() 에서도 관 목록을 세션에 흘려 되돌리기 기준을 조작 전 값으로 고정
- 좌우 연속 이동 중에는 처음 잡은 구조물 측점·자체 누가거리 카운터 사용 —
  관 측점 id 가 누가거리 기반이라 한 번 옮기면 선택이 풀려 한 칸만 움직이던 결함 수정
- ProfileTools.nudge(delta) 노출 — [쉬프트] 모드의 상·하 방향키가 도구 ▲▼ 와
  같은 경로(onShift)로 구간 평행이동

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 07:11:41 +09:00

139 lines
5.1 KiB
TypeScript

/* =============================================================================
* 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<string, string>;
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,
};
}