- Ctrl+Z · Y · 편집기 [되돌리기] · [다시하기] · [반영 전으로] = 페이지 사진첩(_Profile_History) — 평면 · 종단 · 횡단 · 관 · 구조물을 만진 차례 거꾸로 - 편집기 쌓기 · [반영 전으로] 뒤 사진첩 한 걸음(_Draft.watch) · 사진첩이 되쓴 route-draft 는 fromPage 로 편집기 새 걸음(같은 선 옛 걸음의 고정 · 추천 꼬리표째) - 쌓는 줄 없는 조작(횡단 카드 등) = 놓은 뒤 · change 뒤 400 ms 한 번 찍음 · 고름 · 관 id · 종류 캐시 · 구조물 넘김 · b08 · b09 칸은 사진 밖 - 「마지막에 만진 판만」(_RouteEdit_UndoOwner) 지움 · 편집기는 단축키 안 들음 - 시험 test_346_4_one_undo.py · 345-3 시험 · 219-1 헬퍼 새 꼴로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
232 lines
9.5 KiB
TypeScript
232 lines
9.5 KiB
TypeScript
/* =============================================================================
|
|
* 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<string, string>;
|
|
|
|
/** `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<typeof setTimeout> | 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<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);
|
|
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;
|
|
}
|