diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index dd5325b8..0b6ce3e4 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -23,8 +23,8 @@ const DRAFT_KEY_PREFIX = "b05-profile-alignment-draft"; const BUTTON_CLEARANCE_PX = 23; const BUTTON_HALF_PX = 10; /** 길게 누르기: 이만큼 유지하면 반복이 시작되고, 그 뒤 초당 10회(0.1m씩)로 이어진다. */ -const HOLD_DELAY_MS = 500; -const HOLD_INTERVAL_MS = 100; +export const HOLD_DELAY_MS = 500; +export const HOLD_INTERVAL_MS = 100; /** * 길게 누르는 동안 같은 동작을 반복한다. diff --git a/B05_Profile/B05_Profile_UI_Profile_History.ts b/B05_Profile/B05_Profile_UI_Profile_History.ts index 701ae660..b1b41cd0 100644 --- a/B05_Profile/B05_Profile_UI_Profile_History.ts +++ b/B05_Profile/B05_Profile_UI_Profile_History.ts @@ -75,6 +75,10 @@ function restoreSnapshot(snapshot: HistorySnapshot): void { export interface ProfileHistory { /** 조작이 끝난 뒤 현재 상태를 이력에 쌓는다(직전과 같으면 무시). */ record(): void; + /** 연속 조작(길게 누르기) 동안 기록을 미룬다 — 20칸 이동이 되돌리기 20번이 되는 것을 막는다. */ + pause(): void; + /** 연속 조작이 끝났음을 알린다 — 그 동안의 변화를 **한 덩어리로** 한 번만 쌓는다. */ + resume(): void; undo(): boolean; redo(): boolean; canUndo(): boolean; @@ -92,6 +96,8 @@ export function createProfileHistory(onRestore: () => void, limit = 50): Profile 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; @@ -108,7 +114,7 @@ export function createProfileHistory(onRestore: () => void, limit = 50): Profile return { record() { - if (restoring) return; + if (restoring || paused) return; const snapshot = takeSnapshot(); if (sameSnapshot(snapshot, stack[cursor])) return; stack.splice(cursor + 1); @@ -116,6 +122,14 @@ export function createProfileHistory(onRestore: () => void, limit = 50): Profile 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, diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index f886aa09..8bdb8b2a 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -15,6 +15,7 @@ import type { ProfileAlignment, } from "./B05_Profile_UI_Profile_Alignment"; import { adjustStation } from "./B05_Profile_UI_Profile_Alignment"; +import { HOLD_DELAY_MS, HOLD_INTERVAL_MS } from "./B05_Profile_UI_Profile_Edit"; import { createProfileHistory, type ProfileHistory } from "./B05_Profile_UI_Profile_History"; import { createProfileTools, type ProfileTools } from "./B05_Profile_UI_Profile_Tools"; import { @@ -99,8 +100,14 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { ctx.restore(); restorePipes(); }); - /** 기록 직전에 관 목록을 세션으로 흘려 스냅샷에 같이 담기게 한다. */ - const history: ProfileHistory = { ...inner, record: () => (syncPipes(), inner.record()) }; + /** 기록 직전에 관 목록을 세션으로 흘려 스냅샷에 같이 담기게 한다. + * 연속 조작을 멈출 때(pause)도 같이 흘린다 — 그래야 "처음 본 관 목록"이 조작 **전** + * 값으로 잡혀 되돌리기가 시작 자리로 돌아온다(2026-09-04 실측: 한 칸 덜 돌아왔음). */ + const history: ProfileHistory = { + ...inner, + record: () => (syncPipes(), inner.record()), + pause: () => (syncPipes(), inner.pause()), + }; const tools = createProfileTools({ onStraighten: (fromM, toM) => { @@ -173,14 +180,110 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { /* 방향키 — 상·하는 계획고(라운드 포함 기존 틸팅 경로), 좌·우는 누가거리. * 좌우는 구조물·비정규 측점만 움직인다. 20m 정규 측점은 격자라 옮기면 수량·도면 - * 측점번호가 어긋난다(2026-09-02 사용자 확정). */ - ctx.root.addEventListener("keydown", (event) => { + * 측점번호가 어긋난다(2026-09-02 사용자 확정). + * 누르고 있으면 편집 버튼과 **같은 속도**로 이어진다(0.5초 뒤 초당 10회) — OS 키 반복에 + * 맡기면 기기마다 속도가 갈린다. 그 동안의 변화는 이력 한 덩어리다(2026-09-04 사용자 지시). */ + type ArrowKey = "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight"; + + /** 방향키 한 번의 조작 — 반복 타이머가 같은 함수를 다시 부른다. 대상이 없으면 false. */ + function nudge(key: ArrowKey): boolean { + const base = ctx.base(); + if (!base) return false; + if (key === "ArrowUp" || key === "ArrowDown") { + const delta = key === "ArrowUp" ? KEY_STEP_M : -KEY_STEP_M; + // [쉬프트]가 켜져 있으면 고른 직선 구간의 평행이동 — 도구 ▲▼와 같은 경로다. + if (tools.mode() === "shift") return tools.nudge(delta); + const chainage = selectedChainage(); + if (chainage === null) return false; + ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta)); + return true; + } + // 옮기는 동안에는 처음 잡은 측점을 계속 쓴다 — 관 측점 id 가 누가거리로 만들어져 + // (`pipe-85.59`) 한 번 옮기면 선택이 풀리고 두 번째 키부터 먹지 않았다(2026-09-04 실측). + const station = holdStation ?? selectedIrregular(); + if (!station) return false; // 규칙 측점은 좌우 이동 대상이 아니다 — 조용히 무시한다. + // 목록(정본)은 서버 재계산 뒤에야 새 누가거리를 들고 온다 — 연속 이동 중에는 그것을 + // 기다리지 못하므로 지금 자리를 여기서 센다. 안 그러면 매 반복이 같은 자리를 다시 지시해 + // 0.1m 만 움직이고 멈춤(2026-09-04 실측). + const fromM = holdChainageM ?? station.chainage_m; + const next = Number((fromM + (key === "ArrowRight" ? KEY_STEP_M : -KEY_STEP_M)).toFixed(3)); + if (next < 0) return false; + ctx.moveStation({ ...station, chainage_m: fromM }, next); + holdStation = station; + holdChainageM = next; + history.record(); + return true; + } + + let heldKey: ArrowKey | null = null; + /** 좌우 연속 이동 중의 현재 누가거리 — 정본 목록이 따라오기 전까지 여기서 센다. */ + let holdChainageM: number | null = null; + /** 옮기는 중인 구조물 측점 — 선택이 풀려도 키를 뗄 때까지 이 측점을 움직인다. */ + let holdStation: IrregularStation | null = null; + let holdDelayTimer = 0; + let holdRepeatTimer = 0; + + /** 구조물 이동은 서버 재계산을 거쳐 돌아오고 그 뒷정리(유령 변화점 삭제)도 이력을 + * 건드린다 — 키를 뗀 뒤 이만큼 기다렸다 한 덩어리로 기록한다. */ + const HOLD_SETTLE_MS = 700; + let settleTimer = 0; + + /** 반복을 끊고, 잠시 뒤 그 동안의 변화를 이력 한 덩어리로 남긴다. */ + function stopHold(): void { + window.clearTimeout(holdDelayTimer); + window.clearInterval(holdRepeatTimer); + holdDelayTimer = 0; + holdRepeatTimer = 0; + if (heldKey === null) return; + heldKey = null; + holdChainageM = null; + holdStation = null; + window.removeEventListener("keyup", onKeyUp); + window.removeEventListener("blur", stopHold); + window.clearTimeout(settleTimer); + settleTimer = window.setTimeout(() => { + settleTimer = 0; + history.resume(); + }, HOLD_SETTLE_MS); + } + + /** 기다리지 않고 지금 바로 한 덩어리를 닫는다(다음 조작·되돌리기 직전). */ + function flushHold(): void { + stopHold(); + if (!settleTimer) return; + window.clearTimeout(settleTimer); + settleTimer = 0; + history.resume(); + } + + function onKeyUp(event: KeyboardEvent): void { + if (event.key === heldKey) stopHold(); + } + + /** 첫 한 번은 부른 쪽에서 이미 움직였다 — 여기서는 이어지는 반복만 건다. */ + function startHold(key: ArrowKey): void { + heldKey = key; + window.addEventListener("keyup", onKeyUp); + window.addEventListener("blur", stopHold); + holdDelayTimer = window.setTimeout(() => { + holdRepeatTimer = window.setInterval(() => { + if (!nudge(key)) stopHold(); // 더 움직일 곳이 없으면 스스로 멈춘다. + }, HOLD_INTERVAL_MS); + }, HOLD_DELAY_MS); + } + + /* 듣는 자리는 **창(window)** 이다 — 패널에 걸어 두면 그래프를 눌러도 포커스가 body에 + * 남아(측점을 고르면 그래프가 다시 그려져 포커스가 풀림) 방향키가 아무 반응이 없었다 + * (2026-09-04 실측). 패널이 화면에서 빠지면(다른 페이지) 조용히 무시한다. */ + window.addEventListener("keydown", (event) => { + if (!ctx.root.isConnected) return; const target = event.target as HTMLElement | null; // 입력칸 안에서는 방향키가 값 조작이므로 손대지 않는다. if (target && target.closest("input, select, textarea")) return; const key = event.key; if ((event.ctrlKey || event.metaKey) && (key === "z" || key === "Z")) { event.preventDefault(); + flushHold(); if (event.shiftKey) history.redo(); else history.undo(); return; @@ -188,24 +291,20 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { if (key !== "ArrowUp" && key !== "ArrowDown" && key !== "ArrowLeft" && key !== "ArrowRight") { return; } - const chainage = selectedChainage(); - const base = ctx.base(); - if (chainage === null || !base) return; - if (key === "ArrowUp" || key === "ArrowDown") { + // OS 키 반복은 무시한다 — 속도는 위 타이머가 정한다. + if (event.repeat) { event.preventDefault(); - const delta = key === "ArrowUp" ? KEY_STEP_M : -KEY_STEP_M; - ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta)); return; } - const station = selectedIrregular(); - if (!station) return; // 규칙 측점은 좌우 이동 대상이 아니다 — 조용히 무시한다. + // 기록을 먼저 멈춘다 — 첫 한 번까지 같은 덩어리에 들어가야 되돌리기 1회로 원위치한다. + flushHold(); + history.pause(); + if (!nudge(key)) { + history.resume(); // 움직인 것이 없으므로 이력에 남지 않는다. + return; + } event.preventDefault(); - const next = Number( - (station.chainage_m + (key === "ArrowRight" ? KEY_STEP_M : -KEY_STEP_M)).toFixed(3), - ); - if (next < 0) return; - ctx.moveStation(station, next); - history.record(); + startHold(key); }); return { tools, history, handleToolPick }; diff --git a/B05_Profile/B05_Profile_UI_Profile_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Tools.ts index 47e1e13f..0487a666 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Tools.ts @@ -50,6 +50,8 @@ export interface ProfileTools { selectedRuns: () => StraightRun[]; /** 직선화 대기 중 첫 측점(강조 표시용). */ pendingStation: () => number | null; + /** 고른 직선을 ▲▼ 버튼과 같은 경로로 옮긴다(방향키가 같이 쓴다) — 대상이 없으면 false. */ + nudge: (delta: number) => boolean; /** 모드·선택을 모두 끈다. */ clear: () => void; } @@ -89,16 +91,19 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo callbacks.onChanged(); } - function step(delta: number): void { + function step(delta: number): boolean { if (mode === "shift" && runs.length) { callbacks.onShift(runs, delta); - return; + return true; } - if (runs.length === 1) callbacks.onTilt(runs[0], delta); + if (runs.length !== 1) return false; + callbacks.onTilt(runs[0], delta); + return true; } return { mode: () => mode, + nudge: step, selectedRuns: () => runs, pendingStation: () => pending, clear() { @@ -170,8 +175,8 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo ); shift.classList.toggle("is-active", mode === "shift"); - const up = toolButton("▲", "고른 직선을 0.1m 올림", () => step(0.1)); - const down = toolButton("▼", "고른 직선을 0.1m 내림", () => step(-0.1)); + const up = toolButton("▲", "고른 직선을 0.1m 올림", () => void step(0.1)); + const down = toolButton("▼", "고른 직선을 0.1m 내림", () => void step(-0.1)); const idle = mode === "none" || (!runs.length && mode === "shift"); up.disabled = idle || (mode === "straighten" && runs.length !== 1); down.disabled = up.disabled;