diff --git a/A00_Common/app_settings.css b/A00_Common/app_settings.css index c6b727fa6..742b2bfe8 100644 --- a/A00_Common/app_settings.css +++ b/A00_Common/app_settings.css @@ -28,3 +28,33 @@ top: 0; background: var(--color-surface-raised); } + +/* 588 — 찾기 줄 · 묶음 사이 · 키 배지 줄 */ +.app-settings__findbar { + display: flex; + gap: var(--spacing-8); + align-items: flex-start; + position: sticky; + top: 0; + z-index: 1; + padding-bottom: var(--spacing-8); + background: var(--color-surface-raised); +} + +.app-settings__findbar > .ui-field { + flex: 1; +} + +.app-settings__group + .app-settings__group { + margin-top: var(--spacing-4); +} + +.app-settings__group[hidden] { + display: none; +} + +.app-settings__keys { + display: inline-flex; + flex-wrap: wrap; + gap: var(--spacing-4); +} diff --git a/A00_Common/app_settings.ts b/A00_Common/app_settings.ts index f0942acda..5c7dcb478 100644 --- a/A00_Common/app_settings.ts +++ b/A00_Common/app_settings.ts @@ -1,9 +1,10 @@ /* ============================================================================= * app_settings.ts - * ☰ [설정] 창(PLAN 332-2 · 요구 224-8) — 탭 셋: ① 마우스 ② CAD 단축키 ③ 다른 곳 단축키(읽기 전용). + * ☰ [설정] 창(PLAN 332-2 · 588 · 요구 224-8 · 445) — 탭 셋: ① 마우스(휠 방향 · 화면 옮기기 단추) ② CAD 단축키 ③ 다른 곳 단축키. * * - 고침은 창 안 사본에만 · [저장] 때만 계정 pref(`input-prefs`)로 한 번 씀 — 읽기는 `input_prefs.ts` - * - 저장 안 한 고침이 있으면 닫을 때 물음(askLeave) · 잘못 있는 별칭은 저장 안 함 + * - 저장 안 한 고침이 있으면 닫을 때 물음(askLeave) · 잘못 있는 별칭 · 겹친 키는 저장 안 함 + * - ② · ③ 표 = 단축키 등록부(`key_registry.ts`) — 바꾼 키만 `keys` 에 * - 로그인 전엔 이 브라우저에만 남음(계정 올리기는 `b_page_state` 가 로그인 뒤에) * ========================================================================== */ @@ -15,9 +16,11 @@ import { askLeave } from "@ui/ui_template_leave_dialog"; import { t as L } from "@ui/ui_template_locale"; import { openModal } from "@ui/ui_template_modal"; import { writeState } from "./b_page_state"; -import { inputPrefs, type InputPrefs, type WheelZoom } from "./input_prefs"; +import { inputPrefs, type InputPrefs, type PanButton, type WheelZoom } from "./input_prefs"; import { fillTabs } from "./b_page_tabs"; import { buildCadAliasTab, checkCadAliases } from "./app_settings_cad"; +import { findPanel, keyRows } from "./app_settings_keys"; +import { KEY_ACTIONS, checkKeys, type KeyScope } from "./key_registry"; import "./app_settings.css"; type LocaleKey = Parameters[0]; @@ -34,6 +37,12 @@ const WHEEL: [WheelZoom, LocaleKey][] = [ ["push", "App_Settings_Wheel_Push"], ]; +const PAN: [PanButton, LocaleKey][] = [ + ["left", "App_Settings_Pan_Left"], + ["middle", "App_Settings_Pan_Middle"], + ["right", "App_Settings_Pan_Right"], +]; + const MOUSE_RULES: LocaleKey[] = [ "App_Settings_Rule_Pan", "App_Settings_Rule_Pick", @@ -41,17 +50,10 @@ const MOUSE_RULES: LocaleKey[] = [ "App_Settings_Rule_Pinch", ]; -/** ③ 안내 — [자리, 키] */ -const KEY_ROWS: [LocaleKey, LocaleKey][] = [ - ["App_Settings_Keys_All", "App_Settings_Keys_All_Keys"], - ["App_Settings_Keys_Profile", "App_Settings_Keys_Profile_Keys"], - ["App_Settings_Keys_RouteEdit", "App_Settings_Keys_RouteEdit_Keys"], - ["App_Settings_Keys_Plan", "App_Settings_Keys_Plan_Keys"], - ["App_Settings_Keys_Section", "App_Settings_Keys_Section_Keys"], - ["App_Settings_Keys_Cad", "App_Settings_Keys_Cad_Keys"], - ["App_Settings_Keys_Table", "App_Settings_Keys_Table_Keys"], - ["App_Settings_Keys_M02", "App_Settings_Keys_M02_Keys"], -]; +/** ③ 자리 = CAD 뺀 등록부 자리(CAD 는 ② 탭 「키 · 마우스」) */ +const OTHER_SCOPES = [...new Set(KEY_ACTIONS.map((a) => a.scope))].filter( + (scope): scope is KeyScope => scope !== "cad", +); /** 비교 · 저장 꼴 — 별칭 명령 차례를 맞춤 */ const normalize = (p: InputPrefs): InputPrefs => ({ @@ -76,46 +78,69 @@ function ruleList(keys: LocaleKey[]): HTMLElement { return createKv(rows, { rule: true }); } -function mouseTab(draft: InputPrefs): HTMLElement { +function radios( + name: string, + label: LocaleKey, + options: [T, LocaleKey][], + value: T, + onChange: (value: T) => void, +): HTMLElement { const group = el("div", { className: "app-settings__radios", - attrs: { role: "radiogroup", "aria-label": L("App_Settings_Wheel") }, + attrs: { role: "radiogroup", "aria-label": L(label) }, }); - for (const [mode, key] of WHEEL) { + for (const [mode, key] of options) { group.append( createCheckbox(L(key), { - radio: "app-settings-wheel", + radio: name, value: mode, - checked: draft.wheelZoom === mode, - onChange: () => (draft.wheelZoom = mode), + checked: value === mode, + onChange: () => onChange(mode), }).root, ); } + return group; +} + +function mouseTab(draft: InputPrefs): HTMLElement { return el("div", { children: [ el("h4", { text: L("App_Settings_Wheel") }), - group, + radios("app-settings-wheel", "App_Settings_Wheel", WHEEL, draft.wheelZoom, (mode) => { + draft.wheelZoom = mode; + }), + el("h4", { text: L("App_Settings_Pan") }), + radios("app-settings-pan", "App_Settings_Pan", PAN, draft.panButton, (mode) => { + draft.panButton = mode; + }), el("h4", { text: L("App_Settings_Rules") }), ruleList(MOUSE_RULES), ], }); } -function keysTab(): HTMLElement { - return el("div", { - children: KEY_ROWS.flatMap(([place, keys]) => [el("h4", { text: L(place) }), ruleList([keys])]), - }); -} - /** ☰ [설정] — 창을 열고 지금 설정을 사본으로 고침 */ export function openAppSettings(): void { const draft = normalize(inputPrefs()); let saved = snapshot(draft); - const cad = buildCadAliasTab(draft.cadAliases); + /** 키를 고칠 때마다 — 두 탭 칸 글 · 겹침 표시 다시 */ + const keysChanged = (): void => { + const errors = checkKeys(draft.keys); + for (const rows of [cad.keys, other]) { + rows.refresh(); + rows.mark(errors); + } + }; + const cad = buildCadAliasTab(draft.cadAliases, draft.keys, keysChanged); + const other = keyRows(OTHER_SCOPES, draft.keys, keysChanged); + const keysPanel = findPanel(other.groups, [el("p", { text: L("App_Settings_Key_Help") })], () => { + for (const id of Object.keys(draft.keys)) if (!id.startsWith("cad.")) delete draft.keys[id]; + keysChanged(); + }); const panels: Record = { mouse: mouseTab(draft), cad: cad.root, - keys: keysTab(), + keys: keysPanel.root, }; const tabs = el("div", { className: "app-settings__tabs" }); const show = (id: TabId): void => { @@ -129,7 +154,7 @@ export function openAppSettings(): void { for (const [tab, panel] of Object.entries(panels)) panel.hidden = tab !== id; }; - /** [저장] — 잘못 있는 별칭이면 CAD 탭을 보이고 안 씀 */ + /** [저장] — 잘못 있는 별칭 · 겹친 키면 그 탭을 보이고 안 씀 */ const save = (): boolean => { const errors = checkCadAliases(draft.cadAliases); cad.mark(errors); @@ -138,6 +163,13 @@ export function openAppSettings(): void { showToast(L("App_Settings_Cad_Invalid"), "error"); return false; } + const keyErrors = Object.keys(checkKeys(draft.keys)); + if (keyErrors.length) { + keysChanged(); + show(keyErrors.some((id) => id.startsWith("cad.")) ? "cad" : "keys"); + showToast(L("App_Settings_Key_Invalid"), "error"); + return false; + } if (snapshot(draft) !== saved) writeState("input-prefs", normalize(draft)); saved = snapshot(draft); showToast(L("App_Settings_Saved"), "success"); diff --git a/A00_Common/app_settings_cad.ts b/A00_Common/app_settings_cad.ts index b32884f86..7eae5b8b3 100644 --- a/A00_Common/app_settings_cad.ts +++ b/A00_Common/app_settings_cad.ts @@ -1,6 +1,6 @@ /* ============================================================================= * app_settings_cad.ts - * ☰ [설정] ② CAD 단축키 탭(PLAN 332-2) — 명령 표(명령 · 한글 이름 · 기본 별칭 · 내 별칭) · 검사. + * ☰ [설정] ② CAD 단축키 탭(PLAN 332-2 · 588-1) — 찾기 · 키 · 마우스 묶음 · 리본 이름 묶음 명령 표(명령 · 한글 이름 · 기본 별칭 · 내 별칭) · 검사. * * - 명령 목록 = `openwebcad/src/commands/command-catalog.json`(registry 거울 · commands.*.ts 는 xstate 가 딸려 와서 JSON 한 벌) * - acad.pgp 식 덮어쓰기 — CAD 는 명령 이름 > 내 별칭 > 기본 별칭 순서로 찾음(배선은 332-3) @@ -8,11 +8,12 @@ * ========================================================================== */ import catalog from "../B07_DesignDetail/openwebcad/src/commands/command-catalog.json"; -import { setUiSize } from "@ui/ui_template_button"; import { el } from "@ui/ui_template_elements"; -import { createTable } from "@ui/ui_template_table"; +import { createInputField } from "@ui/ui_template_input"; import { createText } from "@ui/ui_template_text"; import { t as L } from "@ui/ui_template_locale"; +import { cadCommandGroups } from "./app_settings_find"; +import { findGroup, findPanel, keyRows, type FindRow, type KeyRows } from "./app_settings_keys"; export interface CadCommandRow { id: string; @@ -65,20 +66,25 @@ export function checkCadAliases( return errors; } -/** 탭 몸 — `aliases` 를 칸 입력대로 고쳐 씀 · `mark` 로 잘못 칸 표시 */ -export function buildCadAliasTab(aliases: Record): { +/** 탭 몸(588-1) — 찾기 · 「키 · 마우스」 묶음(등록부 CAD 줄) · 리본 이름 묶음 · `aliases` · `keys` 를 칸 입력대로 고쳐 씀 */ +export function buildCadAliasTab( + aliases: Record, + keys: Record, + onKeysChange: () => void, +): { root: HTMLElement; mark: (errors: Record) => void; + keys: KeyRows; + count: () => number; } { const message = createText("", "error"); message.hidden = true; const inputs = new Map(); - const rows = CAD_COMMANDS.map((command) => { - const input = el("input", { - className: "ui-input", - attrs: { type: "text", "aria-label": `${command.id} ${L("App_Settings_Cad_Mine")}` }, - }); - setUiSize(input, 24); + const byId = new Map(CAD_COMMANDS.map((command) => [command.id, command])); + const row = (command: CadCommandRow): FindRow => { + const field = createInputField({ size: 24 }); + const input = field.input; + input.setAttribute("aria-label", `${command.id} ${L("App_Settings_Cad_Mine")}`); input.value = (aliases[command.id] ?? []).join(", "); input.dataset.cadCommand = command.id; input.addEventListener("input", () => { @@ -87,21 +93,30 @@ export function buildCadAliasTab(aliases: Record): { else delete aliases[command.id]; }); inputs.set(command.id, input); - return [command.id, command.label, command.aliases.join(", "), input]; - }); - const table = createTable( - ( - [ - "App_Settings_Cad_Command", - "App_Settings_Cad_Label", - "App_Settings_Cad_Default", - "App_Settings_Cad_Mine", - ] as const - ).map((key) => ({ label: L(key) })), - rows, - { density: "data" }, - ); - table.classList.add("app-settings__table"); + return { + cells: [command.id, command.label, command.aliases.join(", "), field.root], + texts: () => [command.id, command.label, ...command.aliases, input.value], + }; + }; + const columns = ( + [ + "App_Settings_Cad_Command", + "App_Settings_Cad_Label", + "App_Settings_Cad_Default", + "App_Settings_Cad_Mine", + ] as const + ).map((key) => ({ label: L(key) })); + const keyRowsCad = keyRows(["cad"], keys, onKeysChange, () => L("App_Settings_Cad_Keys")); + const groups = [ + ...keyRowsCad.groups, + ...cadCommandGroups(CAD_COMMANDS.map((c) => c.id)).map((group) => + findGroup( + group.label || L("App_Settings_Cad_Other"), + columns, + group.ids.map((id) => row(byId.get(id)!)), + ), + ), + ]; const mark = (errors: Record): void => { for (const [id, input] of inputs) { const error = errors[id]; @@ -113,8 +128,18 @@ export function buildCadAliasTab(aliases: Record): { message.lastChild!.textContent = kinds.map((k) => L(ERROR_TEXT[k])).join(" · "); message.hidden = !kinds.length; }; - const root = el("div", { - children: [el("p", { text: L("App_Settings_Cad_Help") }), message, table], - }); - return { root, mark }; + /** [모두 기본값] — 내 별칭 · CAD 바꾼 키 비움 */ + const resetAll = (): void => { + for (const id of Object.keys(aliases)) delete aliases[id]; + for (const input of inputs.values()) input.value = ""; + for (const id of Object.keys(keys)) if (id.startsWith("cad.")) delete keys[id]; + mark({}); + onKeysChange(); + }; + const panel = findPanel( + groups, + [el("p", { text: L("App_Settings_Cad_Help") }), message], + resetAll, + ); + return { root: panel.root, mark, keys: keyRowsCad, count: panel.count }; } diff --git a/A00_Common/app_settings_find.ts b/A00_Common/app_settings_find.ts new file mode 100644 index 000000000..d4b91dc4a --- /dev/null +++ b/A00_Common/app_settings_find.ts @@ -0,0 +1,44 @@ +/* ============================================================================= + * app_settings_find.ts + * ☰ [설정] 단축키 탭 셈(PLAN 588-1) — CAD 명령 묶음 · 찾기 판정. 화면 · 저장소 안 씀. + * + * - 묶음 = 리본 탭 › 패널 이름 그대로(`openwebcad/src/ribbon/ribbon.config.ts` · 순수 자료) · 두 패널에 있으면 앞 패널 · 리본에 없는 명령 = 기타 + * - 찾기 = 띄어 나눈 낱말이 모두 어느 글에든 들면 맞음 · 대소문자 무시 + * 시험 = `resources/tester/test_588_key_registry.py` + * ========================================================================== */ + +import { RIBBON_TABS } from "../B07_DesignDetail/openwebcad/src/ribbon/ribbon.config"; + +export interface CadGroup { + /** 「홈 › 그리기」 · 기타는 빈 글 */ + label: string; + ids: string[]; +} + +/** 명령 id 들 → 리본 묶음(리본 차례) + 끝에 기타(`label` 빈 글) */ +export function cadCommandGroups(ids: string[]): CadGroup[] { + const known = new Set(ids); + const seen = new Set(); + const groups: CadGroup[] = []; + for (const tab of RIBBON_TABS) { + for (const panel of tab.panels) { + const mine = [...(panel.big ?? []), ...(panel.commands ?? []), ...(panel.overflow ?? [])] + .filter((id) => known.has(id) && !seen.has(id)) + .filter((id) => seen.add(id)); + if (mine.length) groups.push({ label: `${tab.label} › ${panel.label}`, ids: mine }); + } + } + const rest = ids.filter((id) => !seen.has(id)); + if (rest.length) groups.push({ label: "", ids: rest }); + return groups; +} + +/** 찾기 — 빈 찾기 = 맞음 · 낱말마다 어느 글에든 들어야 */ +export function matchesFind(query: string, texts: string[]): boolean { + const hay = texts.join("\n").toLowerCase(); + return query + .toLowerCase() + .split(/\s+/) + .filter(Boolean) + .every((word) => hay.includes(word)); +} diff --git a/A00_Common/app_settings_keys.ts b/A00_Common/app_settings_keys.ts new file mode 100644 index 000000000..9aae057a2 --- /dev/null +++ b/A00_Common/app_settings_keys.ts @@ -0,0 +1,191 @@ +/* ============================================================================= + * app_settings_keys.ts + * ☰ [설정] 단축키 표(PLAN 588) — 접이 묶음 · 찾기 · 키 기록 칸 · 줄마다 [기본값]. CAD 탭 · 다른 곳 탭이 같이 씀. + * + * - 표 줄 = 등록부(`key_registry.ts`) — 손글 안내 없음 · 못 바꾸는 줄은 키 배지만 + * - 키 기록 = 칸을 누르고 새 키를 누름(Tab = 초점 넘김 · Esc = 그만) · 기본과 같으면 바꾼 키에서 뺌 + * - 고침은 창 안 사본(`keys`)에만 · [저장] 은 `app_settings.ts` + * ========================================================================== */ + +import { createBadge } from "@ui/ui_template_badge"; +import { createButton } from "@ui/ui_template_button"; +import { attachCollapsible, createCollapsibleHead } from "@ui/ui_template_collapsible"; +import { el } from "@ui/ui_template_elements"; +import { createEmpty } from "@ui/ui_template_empty"; +import { createInputField } from "@ui/ui_template_input"; +import { currentLanguageIndex, t as L } from "@ui/ui_template_locale"; +import { tableRow, tableShell, type Cell, type Column } from "@ui/ui_template_table"; +import { matchesFind } from "./app_settings_find"; +import { + KEY_ACTIONS, + KEY_SCOPES, + actionKeys, + chordOf, + keyLabel, + type KeyError, + type KeyScope, +} from "./key_registry"; + +type LocaleKey = Parameters[0]; + +const KEY_ERROR_TEXT: Record = { + dup: "App_Settings_Key_ErrDup", + reserved: "App_Settings_Key_ErrReserved", + letter: "App_Settings_Key_ErrLetter", +}; + +export interface FindRow { + cells: Cell[]; + /** 찾기에 쓰는 글 — 찾을 때마다 읽음(칸에 친 글도) */ + texts: () => string[]; +} + +export interface FindGroup { + root: HTMLElement; + /** 맞는 줄 수 */ + filter: (query: string) => number; +} + +/** 접이 묶음 하나 — 머리(이름 · 줄 수 배지) + 표 · 처음엔 접힘 · 찾는 동안 맞는 줄만 펼침 */ +export function findGroup(title: string, columns: Column[], rows: FindRow[]): FindGroup { + const badge = createBadge(String(rows.length), "neutral"); + const head = createCollapsibleHead({ title, actions: [badge], level: 4 }); + const { root: table, tbody } = tableShell(columns, { density: "data" }); + table.classList.add("app-settings__table"); + const trs = rows.map((row) => tableRow(columns, row.cells)); + tbody.append(...trs); + const root = el("section", { + className: "ui-collapsible is-collapsed app-settings__group", + children: [head.root, table], + }); + const filter = (query: string): number => { + let shown = 0; + rows.forEach((row, i) => { + const hit = matchesFind(query, row.texts()); + trs[i].hidden = !hit; + if (hit) shown += 1; + }); + badge.textContent = String(shown); + root.hidden = !shown; + root.classList.toggle("is-collapsed", !query.trim()); + return shown; + }; + return { root, filter }; +} + +/** 찾기 칸 + [모두 기본값] + 묶음들 + 0건 안내 — `count` = 맞는 줄 수(시험 · 검증) */ +export function findPanel( + groups: FindGroup[], + head: HTMLElement[], + onResetAll: () => void, +): { root: HTMLElement; count: () => number } { + const empty = createEmpty({ text: L("App_Settings_Find_Empty"), small: true }); + empty.hidden = true; + let count = 0; + const find = createInputField({ + type: "search", + size: 28, + placeholder: L("App_Settings_Find"), + onInput: (query) => { + count = groups.reduce((sum, group) => sum + group.filter(query), 0); + empty.hidden = count > 0; + }, + }); + find.input.setAttribute("aria-label", L("App_Settings_Find")); + find.input.dataset.settingsFind = ""; + const reset = createButton({ + label: L("App_Settings_Reset_All"), + variant: "ghost", + size: 28, + onClick: onResetAll, + }); + const bar = el("div", { className: "app-settings__findbar", children: [find.root, reset] }); + const root = el("div", { children: [bar, ...head, ...groups.map((g) => g.root), empty] }); + attachCollapsible(root); + count = groups.reduce((sum, group) => sum + group.filter(""), 0); + return { root, count: () => count }; +} + +const text = (pair: readonly [string, string]): string => pair[currentLanguageIndex] ?? pair[0]; + +const badges = (keys: readonly string[]): HTMLElement => + el("span", { + className: "app-settings__keys", + children: keys.map((key) => createBadge(keyLabel(key), "neutral")), + }); + +export interface KeyRows { + groups: FindGroup[]; + /** 잘못 표시 */ + mark: (errors: Record) => void; + /** 사본이 바뀐 뒤 칸 글 · [기본값] 다시 맞춤 */ + refresh: () => void; +} + +/** 등록부 자리들의 묶음 — `keys` 사본을 고쳐 씀 · `onChange` = 고칠 때마다(겹침 다시 셈) */ +export function keyRows( + scopes: KeyScope[], + keys: Record, + onChange: () => void, + titleOf: (scope: KeyScope) => string = (scope) => text(KEY_SCOPES[scope].label), +): KeyRows { + const columns: Column[] = [ + { label: L("App_Settings_Key_Action") }, + { label: L("App_Settings_Key_Key"), width: "13rem" }, + { label: "", width: "6rem" }, + ]; + const fields = new Map void; setError: (msg?: string) => void }>(); + const groups = scopes.map((scope) => { + const rows = KEY_ACTIONS.filter((a) => a.scope === scope).map((action): FindRow => { + const label = text(action.label); + const texts = [label, ...action.keys, ...action.keys.map(keyLabel)]; + if (action.fixed) return { cells: [label, badges(action.keys), ""], texts: () => texts }; + const field = createInputField({ size: 24 }); + const input = field.input; + input.readOnly = true; + input.dataset.keyAction = action.id; + input.setAttribute("aria-label", `${label} ${L("App_Settings_Key_Key")}`); + const defaults = action.keys.map(keyLabel).join(" · "); + const reset = createButton({ + label: L("App_Settings_Reset"), + variant: "ghost", + size: 24, + onClick: () => { + delete keys[action.id]; + onChange(); + }, + }); + reset.title = `${L("App_Settings_Reset")}: ${defaults}`; + reset.dataset.keyReset = action.id; + input.addEventListener("keydown", (event) => { + if (event.key === "Tab") return; + event.preventDefault(); + event.stopPropagation(); // 창 닫기 · 페이지 키로 안 샘 + if (event.key === "Escape") return input.blur(); + const chord = chordOf(event); + if (!chord) return; + if (action.keys.length === 1 && action.keys[0] === chord) delete keys[action.id]; + else keys[action.id] = [chord]; + onChange(); + }); + fields.set(action.id, { + set: () => { + input.value = actionKeys(action.id, keys).map(keyLabel).join(" · "); + reset.disabled = !keys[action.id]; + }, + setError: field.setError, + }); + return { cells: [label, field.root, reset], texts: () => [...texts, input.value] }; + }); + return findGroup(titleOf(scope), columns, rows); + }); + const refresh = (): void => fields.forEach((field) => field.set()); + const mark = (errors: Record): void => { + for (const [id, field] of fields) { + const error = errors[id]; + field.setError(error ? L(KEY_ERROR_TEXT[error]) : undefined); + } + }; + refresh(); + return { groups, mark, refresh }; +} diff --git a/A00_Common/b_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts index 256cbe96b..1fc026412 100644 --- a/A00_Common/b_svg_zoom_pan.ts +++ b/A00_Common/b_svg_zoom_pan.ts @@ -15,7 +15,7 @@ * 그리는 일은 안 한다 — 넘겨받은 `layer` 에 `transform` 만 적는다. * ========================================================================== */ -import { wheelEventIn } from "./input_prefs"; +import { isPanButton, swallowNextMenu, wheelEventIn } from "./input_prefs"; /** 줌·팬 상태 — 다시 그릴 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */ export interface ZoomPanState { @@ -75,7 +75,7 @@ export interface SvgZoomPanOptions { /** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. * `"ctrl"` = Ctrl+휠만 줌 · 맨 휠은 페이지 스크롤에 둠. */ wheelZoom?: boolean | "ctrl"; - /** 팬을 시작할 누름인지 — 안 주면 가운데 버튼만. 고르기가 없는 빈 곳 왼쪽 끌기를 더할 때 쓴다. */ + /** 팬을 시작할 누름인지 — 안 주면 ☰ [설정] 옮기기 버튼(가운데는 늘). 고르기가 없는 빈 곳 왼쪽 끌기를 더할 때 쓴다. */ panStart?: (event: PointerEvent) => boolean; } @@ -166,9 +166,14 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { svg.addEventListener("pointerdown", (event) => { // 누름마다 풂 — 가운데 단추 팬 뒤엔 click 이 안 와 `moved` 가 남아 왼쪽 누름을 삼켰음(259-1) moved = false; - // 팬은 **가운데 버튼**만(기본). 좌클릭은 그 화면의 고르기 몫이다. - if (!(options.panStart ? options.panStart(event) : event.button === 1)) return; - event.preventDefault(); + // 팬 = ☰ [설정] 옮기기 버튼(가운데는 늘 · 588-4) · 왼쪽이면 안 도형이 잡은 누름은 비킴 · 끈 뒤 click 은 아래서 막음 + const claimed = + event.button === 0 && + (event.defaultPrevented || + (event.target as Element | null)?.hasPointerCapture?.(event.pointerId)); + if (!(options.panStart ? options.panStart(event) : isPanButton(event.button) && !claimed)) + return; + if (event.button !== 0) event.preventDefault(); // 왼쪽은 안 도형의 mousedown 을 살림 panning = true; moved = false; lastX = event.clientX; @@ -191,6 +196,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { const endPan = (event: PointerEvent): void => { if (!panning) return; panning = false; + if (moved && event.button === 2) swallowNextMenu(); // 오른쪽 끌어 옮김 뒤 메뉴 안 뜸 svg.classList.remove("is-panning"); try { svg.releasePointerCapture(event.pointerId); diff --git a/A00_Common/input_prefs.ts b/A00_Common/input_prefs.ts index fee8b3e38..d80e7ea96 100644 --- a/A00_Common/input_prefs.ts +++ b/A00_Common/input_prefs.ts @@ -86,9 +86,35 @@ export const panButton = (): PanButton => inputPrefs().panButton; export const isPanButton = (button: number): boolean => button === 1 || button === PAN_BUTTONS.indexOf(panButton()); +/** 왼쪽 아닌 옮기기 버튼(가운데 · 오른쪽을 고른 때) — 왼쪽이 고르기 · 돌림 몫인 화면용 */ +export const isAuxPanButton = (button: number): boolean => button !== 0 && isPanButton(button); + /** 단추 번호 → `buttons` 비트(왼 1 · 오른 2 · 가운데 4) — 끄는 중 놓침 알아채기 */ export const buttonMask = (button: number): number => [1, 4, 2][button] ?? 0; +/** 이만큼(px) 끌어야 옮김 — 덜 움직이고 떼면 그 단추 제 몫(고름 · 메뉴 · Enter) */ +export const PAN_MOVE_PX = 4; + +/** 오른쪽 끌기로 옮긴 뒤 놓을 때 뜨는 메뉴를 한 번 막음 */ +export function swallowNextMenu(): void { + const stop = (event: Event): void => { + event.preventDefault(); + event.stopImmediatePropagation(); + }; + globalThis.addEventListener("contextmenu", stop, { capture: true, once: true }); + setTimeout(() => globalThis.removeEventListener("contextmenu", stop, true), 400); +} + +/** 오른쪽 누름으로 옮기기를 시작할 때 부름 — 끌고 놓으면 그 뒤 메뉴를 막음 */ +export function guardMenuAfterDrag(down: PointerEvent): void { + if (down.button !== 2) return; + const up = (event: PointerEvent): void => { + const moved = Math.hypot(event.clientX - down.clientX, event.clientY - down.clientY); + if (moved > PAN_MOVE_PX) swallowNextMenu(); + }; + globalThis.addEventListener("pointerup", up, { capture: true, once: true }); +} + /** 휠 한 번이 확대인가 — 설정 방향을 따름 */ export const wheelIn = (deltaY: number): boolean => inputPrefs().wheelZoom === "pull" ? deltaY > 0 : deltaY < 0; diff --git a/A00_Common/key_registry.ts b/A00_Common/key_registry.ts new file mode 100644 index 000000000..9ae2a0d2b --- /dev/null +++ b/A00_Common/key_registry.ts @@ -0,0 +1,346 @@ +/* ============================================================================= + * key_registry.ts + * 단축키 등록부(PLAN 588-2 · 3) — 화면마다 실제로 받는 키 · 마우스 동작 한 벌 · ☰ [설정] 표가 여기서 그려짐. + * + * - 줄 = 동작 id · 자리 · 기본 키 · 이름 · `fixed`(못 바꿈 — Esc · 방향키 · 마우스 · 글 입력 · 시트 키) + * - 판정 = `keyIs(evt, id)` — 사용자가 바꾼 키(`input-prefs` 의 `keys`) 있으면 그것만 · 없으면 기본 키 + * - 키 글 = 「Ctrl+Shift+Z」 꼴 — 글자 · 숫자는 자판 자리(`keyLetter` · 한글 자판 안전) · 차례 Ctrl · Alt · Shift · 키 + * - 겹침 = 같은 화면에서 함께 듣는 자리끼리 · 「모든 화면」과 · 브라우저 몫 · CAD 글자 단독(명령행 몫) + * - 불러올 때 window 안 만짐(CAD 도 import) · 시험 = `resources/tester/test_588_key_registry.py` + * ========================================================================== */ + +import { inputPrefs } from "./input_prefs"; +import { keyLetter } from "./key_shortcut"; + +export type KeyScope = + | "all" + | "b05" + | "profile" + | "routeEdit" + | "drainage" + | "section" + | "plan" + | "d02" + | "b04" + | "cad" + | "b08" + | "b09" + | "m02" + | "stab" + | "etc"; + +type Text = readonly [ko: string, en: string]; + +export interface KeyAction { + id: string; + scope: KeyScope; + /** 기본 키(키 글) · 못 바꾸는 줄은 보일 글(마우스 · 「글자」 등) */ + keys: readonly string[]; + label: Text; + fixed?: true; +} + +/** 자리 이름 · 함께 듣는 화면(같은 화면이면 키가 겹치면 안 됨) — 표 차례도 이대로 */ +export const KEY_SCOPES: Record = { + all: { label: ["모든 화면", "All views"], page: "all" }, + b05: { label: ["B05 · B06 공통", "B05 · B06 common"], page: "b05" }, + profile: { label: ["B05 종단", "B05 profile"], page: "b05" }, + routeEdit: { label: ["B05 노선 편집(평면)", "B05 route edit (plan)"], page: "b05" }, + drainage: { label: ["B05 배수 · 3D", "B05 drainage · 3D"], page: "b05" }, + section: { label: ["B06 횡단", "B06 cross section"], page: "b05" }, + plan: { label: ["D03 노선분석", "D03 route analysis"], page: "plan" }, + d02: { label: ["D02 기준지정", "D02 base points"], page: "d02" }, + b04: { label: ["B04 지도 · 3D", "B04 map · 3D"], page: "b04" }, + cad: { label: ["CAD", "CAD"], page: "cad" }, + b08: { label: ["B08 수량 · 이정표", "B08 quantity · milestones"], page: "b08" }, + b09: { label: ["B09 내역", "B09 estimate"], page: "b09" }, + m02: { label: ["M02 양식", "M02 templates"], page: "m02" }, + stab: { label: ["E03 · E04 · E07 안정성", "E03 · E04 · E07 stability"], page: "stab" }, + etc: { label: ["그 밖", "Other"], page: "etc" }, +}; + +const ARROWS = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"] as const; +const SHIFT_ARROWS = ARROWS.map((key) => `Shift+${key}`); +/** 화면 옮기기 — ☰ [설정] 마우스 탭에서 고른 버튼(가운데는 늘) */ +const PAN = "옮기기 버튼 끌기"; + +const A = ( + id: string, + scope: KeyScope, + keys: readonly string[], + label: Text, + fixed?: true, +): KeyAction => (fixed ? { id, scope, keys, label, fixed } : { id, scope, keys, label }); +const F = true as const; + +/* prettier-ignore */ +export const KEY_ACTIONS: readonly KeyAction[] = [ + A("all.escape", "all", ["Escape"], ["☰ · 창 · 메뉴 닫기 · 고름 풀기", "☰ · close window · menu · clear selection"], F), + A("all.loupe", "all", ["Z"], ["누른 채 = 돋보기", "Hold = loupe"], F), + A("all.loupeRight", "all", ["오른쪽 0.4초 누름"], ["돋보기", "Loupe"], F), + A("all.loupeWheel", "all", ["돋보기 중 휠"], ["돋보기 배율", "Loupe zoom"], F), + A("all.zoomPan", "all", [PAN], ["그림 칸 화면 옮김(횡단 · 단면 · 미리 보기)", "Pan drawings (sections · previews)"], F), + A("all.zoomReset", "all", ["두 번 누름"], ["그림 칸 배율 처음대로", "Reset drawing zoom"], F), + A("all.resizer", "all", ["칸 경계 두 번 누름"], ["칸 크기 처음대로", "Reset panel size"], F), + A("all.grip", "all", ["ArrowLeft", "ArrowRight", "Shift+ArrowLeft", "Shift+ArrowRight"], ["칸 경계 손잡이에서 크기 바꿈", "Resize at a panel grip"], F), + A("all.touchLong", "all", ["손가락 길게 누름"], ["오른쪽 누름(메뉴)", "Right click (menu)"], F), + A("all.touchDouble", "all", ["손가락 두 번 톡"], ["두 번 누름", "Double click"], F), + A("all.touchPinch", "all", ["두 손가락 벌림"], ["확대", "Zoom in"], F), + + A("b05.undo", "b05", ["Ctrl+Z"], ["되돌림(평면 · 종단 · 횡단 · 관 · 구조물)", "Undo (plan · profile · section · pipes · structures)"]), + A("b05.redo", "b05", ["Ctrl+Shift+Z", "Ctrl+Y"], ["다시", "Redo"]), + A("b05.escape", "b05", ["Escape"], ["모든 보기의 고름 풀기", "Clear selection in every view"], F), + + A("profile.grade", "profile", ["ArrowUp", "ArrowDown"], ["계획고 0.1 m · [쉬프트] 중 구간 평행 옮김 · 누른 채 = 되풀이", "Grade 0.1 m · parallel shift · hold = repeat"], F), + A("profile.station", "profile", ["ArrowLeft", "ArrowRight"], ["구조물 · 부정 측점 옮김(종단을 마지막에 누른 때)", "Move structure · irregular station"], F), + A("profile.freeFinish", "profile", ["Enter"], ["자유지정 끝", "Finish free mode"]), + A("profile.freeBack", "profile", ["Backspace"], ["자유지정 마지막 점 빼기", "Free mode: remove last point"]), + A("profile.escape", "profile", ["Escape"], ["자유지정 취소 · 직선화 시작 측점 취소", "Cancel free mode · straighten start"], F), + A("profile.wheel", "profile", ["휠"], ["가로 스크롤(그래프 · 유토곡선 · 측점 표)", "Horizontal scroll"], F), + A("profile.drag", "profile", ["왼쪽 끌기", PAN], ["가로 옮김", "Horizontal pan"], F), + A("profile.menu", "profile", ["오른쪽 누름"], ["구조물 메뉴(넣기 · 지움)", "Structure menu"], F), + A("profile.pill", "profile", ["알약 끌기", "알약 오른쪽 누름"], ["구조물 옮김 · 지움", "Move · delete structure"], F), + A("profile.marker", "profile", ["측점 표식 끌기"], ["측점 옮김", "Move station"], F), + A("profile.markerPick", "profile", ["Enter", "Space"], ["초점 측점 표식 고름", "Pick focused station marker"], F), + A("profile.balloon", "profile", ["풍선 두 번 누름"], ["유토곡선 풍선 자동 자리", "Balloon back to auto"], F), + A("profile.hold", "profile", ["▲▼ 누른 채"], ["되풀이(0.5초 뒤 초당 10번)", "Repeat while held"], F), + + A("routeEdit.nudge", "routeEdit", ARROWS, ["교각점 0.1 m(측점 맞춤이면 한 칸) · 고른 마커 옮김", "Move IP 0.1 m · picked marker"], F), + A("routeEdit.nudgeBig", "routeEdit", SHIFT_ARROWS, ["교각점 1 m 옮김", "Move IP 1 m"], F), + A("routeEdit.unmark", "routeEdit", ["Escape"], ["마커 고름 풀기", "Clear marker pick"], F), + A("routeEdit.zoom", "routeEdit", ["휠"], ["커서 자리 확대 · 축소", "Zoom at cursor"], F), + A("routeEdit.rotate", "routeEdit", ["Ctrl+휠"], ["지도 5° 돌림", "Rotate map 5°"], F), + A("routeEdit.pan", "routeEdit", [PAN], ["화면 옮김", "Pan"], F), + A("routeEdit.measure", "routeEdit", ["Shift+누름"], ["재기", "Measure"], F), + A("routeEdit.drag", "routeEdit", ["노드 · 손잡이 끌기"], ["교각점 옮김 · 반지름 바꿈", "Move IP · change radius"], F), + A("routeEdit.tick", "routeEdit", ["측점 눈금 누름"], ["그 측점 횡단 미리 보기", "Preview that station's section"], F), + A("routeEdit.splice", "routeEdit", ["선 두 번 누름"], ["노드 넣기", "Add node"], F), + A("routeEdit.unsplice", "routeEdit", ["노드 오른쪽 누름"], ["노드 지움", "Delete node"], F), + A("routeEdit.menu", "routeEdit", ["노선 오른쪽 누름"], ["구조물 메뉴 · 마커 지움", "Structure menu · delete marker"], F), + A("routeEdit.contour", "routeEdit", ["등고선 누름"], ["등고선 고정 단추", "Contour lock button"], F), + + A("drainage.zoom", "drainage", ["휠"], ["배수유역도 확대 · 축소", "Drainage map zoom"], F), + A("drainage.pan", "drainage", [PAN], ["배수유역도 옮김", "Drainage map pan"], F), + A("drainage.pipe", "drainage", ["관 표식 끌기"], ["관 옮김", "Move pipe"], F), + A("drainage.basin", "drainage", ["누름"], ["유역 고름 · 풂", "Pick · unpick basin"], F), + A("drainage.menu", "drainage", ["오른쪽 누름"], ["배수 · 3D 노선 메뉴", "Drainage · 3D route menu"], F), + A("drainage.orbit", "drainage", ["3D 왼쪽 끌기"], ["누른 점 둘레로 돌림", "Orbit around picked point"], F), + A("drainage.pan3d", "drainage", [`3D ${PAN}`], ["3D 화면 옮김", "3D pan"], F), + A("drainage.zoom3d", "drainage", ["3D 휠"], ["3D 커서 자리 확대", "3D zoom at cursor"], F), + + A("section.pad", "section", ARROWS, ["열린 조정 판 ▲▼◀▶", "Open adjust pad ▲▼◀▶"], F), + A("section.zoom", "section", ["Ctrl+휠"], ["카드 확대(휠만 = 카드 목록 스크롤)", "Card zoom (wheel alone scrolls)"], F), + A("section.detailEnter", "section", ["Enter"], ["상세 창 고르기 칸에 넣음", "Detail: put the pick in the cell"], F), + A("section.detailEscape", "section", ["Escape"], ["상세 창 고르기 취소", "Detail: cancel picking"], F), + A("section.detailAdd", "section", ["Shift+누름", "Ctrl+누름"], ["상세 창 선 조각 · 면 더함", "Detail: add line pieces · faces"], F), + A("section.detailMenu", "section", ["표 칸 오른쪽 누름"], ["상세 표 칸 메뉴", "Detail table cell menu"], F), + A("section.hold", "section", ["▲▼ 누른 채"], ["선 칸 되풀이", "Line cell repeat"], F), + + A("plan.undo", "plan", ["Ctrl+Z"], ["되돌림", "Undo"]), + A("plan.redo", "plan", ["Ctrl+Shift+Z", "Ctrl+Y"], ["다시", "Redo"]), + A("plan.drawFinish", "plan", ["Enter"], ["그리기 끝", "Finish drawing"]), + A("plan.drawBack", "plan", ["Backspace"], ["그리기 마지막 점 빼기", "Drawing: remove last point"]), + A("plan.delete", "plan", ["Delete"], ["고른 것 지움", "Delete selection"]), + A("plan.escape", "plan", ["Escape"], ["그리기 취소 · 고름 풀기 · 재기 끝", "Cancel drawing · clear selection · end measure"], F), + A("plan.zoom", "plan", ["휠"], ["커서 자리 확대 · 축소", "Zoom at cursor"], F), + A("plan.rotate", "plan", ["Ctrl+휠"], ["지도 돌림", "Rotate map"], F), + A("plan.pan", "plan", [PAN], ["화면 옮김", "Pan"], F), + A("plan.vertex", "plan", ["고른 선 누름", "꼭짓점 끌기"], ["꼭짓점 넣고 끌기 · 옮김", "Insert · drag vertex"], F), + A("plan.unvertex", "plan", ["꼭짓점 · 마커 오른쪽 누름"], ["지움", "Delete"], F), + A("plan.measure", "plan", ["재기: 누름 · 누름"], ["시점 · 종점", "Start · end"], F), + + A("d02.place", "d02", ["지도 누름"], ["지점 놓기", "Place point"], F), + A("d02.drag", "d02", ["표식 끌기"], ["지점 옮김", "Move point"], F), + + A("b04.zoom", "b04", ["휠"], ["지도 · 3D 확대 · 축소", "Map · 3D zoom"], F), + A("b04.pan", "b04", [PAN], ["지도 · 3D 옮김", "Map · 3D pan"], F), + A("b04.pick", "b04", ["누름"], ["유역 · 흐름 점 고름", "Pick basin · flow point"], F), + A("b04.pipe", "b04", ["관 표식 끌기"], ["관 옮김", "Move pipe"], F), + A("b04.menu", "b04", ["오른쪽 누름"], ["노선 · 관 메뉴", "Route · pipe menu"], F), + A("b04.orbit", "b04", ["3D 왼쪽 끌기"], ["누른 점 둘레로 돌림", "Orbit around picked point"], F), + + A("cad.snap", "cad", ["F3"], ["스냅", "Snap"]), + A("cad.grid", "cad", ["F7"], ["그리드", "Grid"]), + A("cad.ortho", "cad", ["F8"], ["직교", "Ortho"]), + A("cad.polar45", "cad", ["F10"], ["45°", "45°"]), + A("cad.undo", "cad", ["Ctrl+Z"], ["되돌림", "Undo"]), + A("cad.redo", "cad", ["Ctrl+Y", "Ctrl+Shift+Z"], ["다시", "Redo"]), + A("cad.save", "cad", ["Ctrl+S"], ["저장(M02)", "Save (M02)"]), + A("cad.selectAll", "cad", ["Ctrl+A"], ["모두 고름", "Select all"]), + A("cad.delete", "cad", ["Delete"], ["고른 것 지움", "Delete selection"]), + A("cad.copy", "cad", ["Ctrl+C"], ["복사", "Copy"]), + A("cad.copyBase", "cad", ["Ctrl+Shift+C"], ["기준점 복사", "Copy with base point"]), + A("cad.cut", "cad", ["Ctrl+X"], ["잘라 냄", "Cut"]), + A("cad.pasteBlock", "cad", ["Ctrl+Shift+V"], ["블록으로 붙여넣기", "Paste as block"]), + A("cad.properties", "cad", ["Ctrl+1"], ["특성", "Properties"]), + A("cad.paste", "cad", ["Ctrl+V"], ["붙여넣기", "Paste"], F), + A("cad.enter", "cad", ["Enter", "Space", "오른쪽 누름"], ["입력 끝 · 쉴 때 명령 반복", "Submit · repeat command when idle"], F), + A("cad.escape", "cad", ["Escape"], ["명령 취소 · 쉴 때 고름 풀기", "Cancel command · clear selection when idle"], F), + A("cad.type", "cad", ["글자"], ["명령 · 값 입력", "Type command · value"], F), + A("cad.backspace", "cad", ["Backspace"], ["입력 글 지움", "Erase typed text"], F), + A("cad.tab", "cad", ["Tab"], ["명령 후보 돌림 · 동적 입력 칸 넘김", "Cycle candidates · next dynamic field"], F), + A("cad.recall", "cad", ["ArrowUp", "ArrowDown"], ["기록 · 최근 값 · 후보", "History · recent values · candidates"], F), + A("cad.arrowPan", "cad", ["Shift+ArrowUp", "Shift+ArrowDown", "ArrowLeft", "ArrowRight"], ["화면 옮김", "Pan"], F), + A("cad.zoom", "cad", ["휠"], ["확대 · 축소", "Zoom"], F), + A("cad.pan", "cad", [PAN], ["화면 옮김", "Pan"], F), + A("cad.snapMenu", "cad", ["Shift+오른쪽 누름"], ["점 물음에서 스냅 메뉴", "Snap menu at a point prompt"], F), + A("cad.extents", "cad", ["가운데 두 번 누름"], ["범위 줌", "Zoom extents"], F), + A("cad.box", "cad", ["왼쪽 끌기"], ["고르기 상자(오른쪽으로 = 안 · 왼쪽으로 = 걸침)", "Selection window · crossing"], F), + + A("b08.zoom", "b08", ["휠"], ["이정표 그림 확대 · 축소", "Milestone diagram zoom"], F), + A("b08.pan", "b08", [PAN, "빈 곳 왼쪽 끌기"], ["이정표 그림 옮김", "Milestone diagram pan"], F), + A("b08.reset", "b08", ["빈 곳 두 번 누름"], ["배율 처음대로", "Reset zoom"], F), + A("b08.leg", "b08", ["구간 글 두 번 누름", "Enter", "Escape"], ["노선 종류 · km 고침 · 적용 · 취소", "Edit road type · km · apply · cancel"], F), + A("b08.box", "b08", ["상자 · 잇는 점 끌기"], ["옮김 · 이음", "Move · link"], F), + A("b08.rowMenu", "b08", ["이정표 표 줄 오른쪽 누름"], ["이음 메뉴", "Link menu"], F), + A("b08.cell", "b08", ["두 번 누름", "Enter", "F2"], ["표 칸 입력 · Enter = 넣음", "Edit table cell · Enter = commit"], F), + + A("b09.indent", "b09", ["Tab", "Shift+Tab"], ["내역 줄 들임 · 내어씀", "Indent · outdent row"], F), + A("b09.level", "b09", ["ArrowLeft", "ArrowRight"], ["내역 줄 단 바꿈", "Change row level"], F), + A("b09.move", "b09", ["ArrowUp", "ArrowDown", "Alt+ArrowUp", "Alt+ArrowDown"], ["내역 줄 위 · 아래로", "Move row up · down"], F), + A("b09.edit", "b09", ["Enter", "F2", "두 번 누름"], ["고른 줄 · 칸 입력", "Edit picked row · cell"], F), + A("b09.escape", "b09", ["Escape"], ["입력 끝 · 고름 풀기 · 빨강 표시 지움 · 뜬 창 닫기", "End edit · unpick · clear highlight · close popups"], F), + A("b09.menu", "b09", ["칸 · 줄 오른쪽 누름"], ["칸 · 줄 메뉴", "Cell · row menu"], F), + A("b09.drag", "b09", ["줄 끌기"], ["세로 = 자리 · 가로 = 단", "Vertical = position · horizontal = level"], F), + + A("m02.save", "m02", ["Ctrl+S"], ["카드 저장", "Save card"], F), + A("m02.treeMove", "m02", ARROWS, ["나무: ↑↓ 초점 · →← 펼침 · 접음", "Tree: ↑↓ focus · →← expand · collapse"], F), + A("m02.rename", "m02", ["F2"], ["나무 이름 바꿈", "Tree rename"], F), + A("m02.delete", "m02", ["Delete"], ["나무 · 흐름도 고른 것 지움", "Delete selected (tree · data flow)"], F), + A("m02.copy", "m02", ["Ctrl+C", "Ctrl+V"], ["나무 복사 · 붙여넣기", "Tree copy · paste"], F), + A("m02.multi", "m02", ["Ctrl+누름", "Shift+누름"], ["나무 여럿 고름", "Tree multi-select"], F), + A("m02.menu", "m02", ["나무 빈 곳 오른쪽 누름"], ["맨 위 메뉴", "Top menu"], F), + A("m02.zoom", "m02", ["휠", "Ctrl+휠"], ["흐름도 · 노임 그림 배율", "Data flow · labor diagram zoom"], F), + A("m02.pan", "m02", [PAN, "Space+끌기"], ["흐름도 · 노임 그림 옮김", "Data flow · labor diagram pan"], F), + A("m02.boxMove", "m02", ARROWS, ["흐름도 고른 상자 10 px(Shift 50)", "Move selected box 10 px (Shift 50)"], F), + A("m02.undo", "m02", ["Ctrl+Z"], ["흐름도 되돌림", "Data flow undo"], F), + A("m02.escape", "m02", ["Escape"], ["잇기 · 알약 취소 · 크게 보기 줄임 · 열린 창 닫기", "Cancel link · pill · shrink full view · close window"], F), + A("m02.sheet", "m02", ["시트 안"], ["엑셀 키 그대로", "Excel keys"], F), + + A("stab.zoom", "stab", ["휠"], ["지도 · 그림 확대 · 축소", "Map · drawing zoom"], F), + A("stab.pan", "stab", ["왼쪽 끌기", PAN], ["E04 · E07 지도 · E03 그림 옮김", "Pan"], F), + A("stab.fit", "stab", ["두 번 누름"], ["전체 보기", "Fit"], F), + + A("etc.chat", "etc", ["Enter", "Shift+Enter"], ["B01 지식 물음 보냄 · 줄 바꿈", "B01 chat send · new line"], F), + A("etc.help", "etc", ["두 번 누름", "Ctrl+Enter", "Escape"], ["도움말 고침(관리자) · 저장 · 취소", "Help edit (admin) · save · cancel"], F), + A("etc.cadTab", "etc", ["CAD 탭 두 번 누름"], ["탭 이름 바꿈", "Rename tab"], F), + A("etc.sheet", "etc", ["항목 고르기 표"], ["방향키 · Tab · Enter · F2 · Delete · 글자 = 입력", "Arrows · Tab · Enter · F2 · Delete · typing"], F), + A("etc.toolbar", "etc", ["지도 도구줄 휠"], ["넘친 도구줄 가로 스크롤", "Scroll overflowing toolbar"], F), +]; + +const BY_ID = new Map(KEY_ACTIONS.map((action) => [action.id, action])); + +/** 브라우저 몫 — 못 씀 */ +export const RESERVED_KEYS = [ + "F5", + "F11", + "F12", + "Ctrl+W", + "Ctrl+T", + "Ctrl+N", + "Ctrl+R", + "Ctrl+Shift+T", +]; + +const MODIFIERS = new Set(["Control", "Shift", "Alt", "Meta", "AltGraph", "CapsLock", "Process"]); +const NAMED: Record = { + " ": "Space", + Spacebar: "Space", + Esc: "Escape", + Del: "Delete", +}; + +/** 누른 키 → 키 글 · 수정키만 · 한글 조합 = 빈 글 */ +export function chordOf( + evt: Pick & + Partial>, +): string { + const letter = keyLetter(evt); + const raw = NAMED[evt.key] ?? evt.key; + if (!letter && (!raw || MODIFIERS.has(raw))) return ""; + const key = letter ? letter.toUpperCase() : raw.length === 1 ? raw.toUpperCase() : raw; + const parts = [ + evt.ctrlKey || evt.metaKey ? "Ctrl" : "", + evt.altKey ? "Alt" : "", + evt.shiftKey ? "Shift" : "", + ]; + return [...parts.filter(Boolean), key].join("+"); +} + +/** 지금 쓰는 키 — 바꾼 것(`keys`) 있으면 그것 · 없으면 기본 */ +export function actionKeys( + id: string, + keys: Record = inputPrefs().keys, +): readonly string[] { + const action = BY_ID.get(id); + if (!action) return []; + return (!action.fixed && keys[id]) || action.keys; +} + +/** 이 키가 그 동작인가 */ +export const keyIs = ( + evt: Parameters[0], + id: string, + keys?: Record, +): boolean => { + const chord = chordOf(evt); + return !!chord && actionKeys(id, keys).includes(chord); +}; + +/** 그 자리 바꿀 수 있는 동작 중 이 키에 맞는 첫 동작 id */ +export function keyAction(evt: Parameters[0], scope: KeyScope): string | null { + const chord = chordOf(evt); + if (!chord) return null; + const keys = inputPrefs().keys; + return ( + KEY_ACTIONS.find((a) => a.scope === scope && !a.fixed && actionKeys(a.id, keys).includes(chord)) + ?.id ?? null + ); +} + +export type KeyError = "dup" | "reserved" | "letter"; + +/** 함께 듣는 자리인가 — 같은 화면 · 「모든 화면」 */ +const together = (a: KeyScope, b: KeyScope): boolean => + a === "all" || b === "all" || KEY_SCOPES[a].page === KEY_SCOPES[b].page; + +/** 바꾼 키 검사 — `{동작 id: 첫 잘못}` · 비면 통과 · 겹침은 바꾼 줄에 붙임(상대가 못 바꾸는 줄이어도) */ +export function checkKeys(keys: Record): Record { + const errors: Record = {}; + const live = KEY_ACTIONS.map((a) => ({ a, list: actionKeys(a.id, keys) })); + for (const { a, list } of live) { + if (a.fixed || !keys[a.id]) continue; + for (const key of list) { + const error: KeyError | null = RESERVED_KEYS.includes(key) + ? "reserved" + : a.scope === "cad" && /^(Shift\+)?.$/.test(key) + ? "letter" + : live.some( + ({ a: b, list: other }) => + b !== a && together(a.scope, b.scope) && other.includes(key), + ) + ? "dup" + : null; + if (error) { + errors[a.id] ??= error; + break; + } + } + } + return errors; +} + +const LABEL: Record = { + ArrowUp: "↑", + ArrowDown: "↓", + ArrowLeft: "←", + ArrowRight: "→", + Escape: "Esc", +}; + +/** 키 글 → 보일 글(↑ · Esc) */ +export const keyLabel = (chord: string): string => + chord + .split("+") + .map((part) => LABEL[part] ?? part) + .join("+"); diff --git a/B04_PreProcess/B04_PreProcess_UI_Camera.ts b/B04_PreProcess/B04_PreProcess_UI_Camera.ts index 232f05569..1af9b1497 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Camera.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Camera.ts @@ -1,7 +1,7 @@ import * as THREE from "three"; import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import type { SurfaceBounds } from "./B04_PreProcess_Api_Fetch"; -import { wheelEventIn } from "../A00_Common/input_prefs"; +import { guardMenuAfterDrag, isAuxPanButton, wheelEventIn } from "../A00_Common/input_prefs"; export const SURFACE_CAMERA_FOV = 50; const LIGHT_VIEWER_BACKGROUND = 0xf5f7f9; @@ -224,7 +224,9 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void // 가운데 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 팬과 겹치므로 막는다. if (event.button === 1) event.preventDefault(); if (options.blocked?.() || !controls.enabled) return; - if (event.button === 1 && panPointerId === null) { + // 그랩 팬 = 가운데 · ☰ [설정] 옮기기 버튼이 오른쪽이면 오른쪽도(왼쪽은 늘 돌림 · 588-4) + if (isAuxPanButton(event.button) && panPointerId === null) { + guardMenuAfterDrag(event); // 그랩 팬 시작 — 커서 아래 지형점을 잡고, 시선 수직 평면 위에서 따라오게 한다. startGrabPan(event); panPointerId = event.pointerId; diff --git a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts index b5321c224..742e62311 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts @@ -1,6 +1,7 @@ import { createButton } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { wheelZoomsIn } from "../A00_Common/touch_bridge"; +import { PAN_MOVE_PX, isPanButton, swallowNextMenu } from "../A00_Common/input_prefs"; import { createProgressCircle } from "@ui/ui_template_progress"; import { createToolbar } from "@ui/ui_template_toolbar"; import { @@ -611,8 +612,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { // 터치·펜은 팬도 겸하므로 여기서 반환하지 않고 기록만 남긴다 — 그래야 손가락으로도 마커를 // 고를 수 있다(끌었으면 이동량 판정에서 걸러진다). if (event.button === 0) clickStart = { x: event.clientX, y: event.clientY }; - if (event.pointerType === "mouse" && event.button !== 1) { - // 마우스 좌버튼은 선택 전용 — 팬을 시작하지 않는다. + if (event.pointerType === "mouse" && !isPanButton(event.button)) { + // 팬 = ☰ [설정] 옮기기 버튼(가운데는 늘 · 588-4) — 왼쪽이면 끌지 않고 뗀 것은 위 고르기 그대로. return; } dragStart = { x: event.clientX, y: event.clientY, offsetX, offsetY }; @@ -651,7 +652,12 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { offsetY = dragStart.offsetY + event.clientY - dragStart.y; scheduleDraw(); }); - const stopDragging = (): void => { + const stopDragging = (event?: PointerEvent): void => { + // 오른쪽 끌어 옮김 뒤 메뉴 안 뜸(588-4) + if (dragStart && event?.button === 2) { + const moved = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y); + if (moved > PAN_MOVE_PX) swallowNextMenu(); + } dragStart = null; viewport.style.removeProperty("cursor"); }; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts index 8818be515..f5bf796fe 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Interact.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Interact.ts @@ -9,6 +9,7 @@ * ========================================================================== */ import { wheelZoomsIn } from "../A00_Common/touch_bridge"; +import { PAN_MOVE_PX, isPanButton, swallowNextMenu } from "../A00_Common/input_prefs"; import { computeMaxScale, lonLatToScreen, @@ -116,7 +117,8 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void { // 팬은 가운데(휠) 버튼 전용이다. 좌버튼은 유역선 핸들·배관 마커를 고르고 끄는 데만 쓴다 // (좌버튼이 팬까지 겸하면 마커를 집으려다 지도가 딸려 움직인다 — 2026-08-01 사용자 지시). // 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 할 수 있게 둔다. - if (event.pointerType === "mouse" && event.button !== 1) return; + // 팬 = ☰ [설정] 옮기기 버튼(가운데는 늘 · 588-4) — 왼쪽이면 끌지 않고 뗀 것은 유역 고르기 그대로. + if (event.pointerType === "mouse" && !isPanButton(event.button)) return; const offset = params.getOffset(); dragStart = { x: event.clientX, y: event.clientY, offsetX: offset.x, offsetY: offset.y }; viewport.style.cursor = "grabbing"; @@ -169,6 +171,10 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void { basinClickStart = null; const dragged = pipeClickStart !== null; pipeClickStart = null; + if (dragStart && event.button === 2) { + const moved = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y); + if (moved > PAN_MOVE_PX) swallowNextMenu(); // 오른쪽 끌어 옮김 뒤 메뉴 안 뜸(588-4) + } if (!dragged && start && event.button === 0) { const rect = viewport.getBoundingClientRect(); // 끌었으면 지도 조작이지 고르기가 아니다. diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 10473b078..909ea080d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -9,7 +9,7 @@ * 되돌리기 범위가 B05 조작 전부인 이유는 `B05_Profile_UI_Profile_History` 머리말 참조. * ========================================================================== */ -import { isCtrlKey } from "../A00_Common/key_shortcut"; +import { keyIs } from "../A00_Common/key_registry"; import { viewOf } from "../common_util/common_util_view_window"; import type { AlignmentBase, @@ -341,24 +341,32 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { // 입력칸 안에서는 방향키가 값 조작이므로 손대지 않는다. if (target && target.closest("input, select, textarea")) return; const key = event.key; - // [자유지정] 중 Enter 끝 · Esc 취소 · Backspace 점 빼기 — 단추 초점의 Enter 누름도 막는다. - if (tools.mode() === "free" && free.key(key)) { + // [자유지정] 중 Enter 끝 · Esc 취소 · Backspace 점 빼기 — 단추 초점의 Enter 누름도 막는다(키는 등록부 · 588-3). + const freeKey = keyIs(event, "profile.freeFinish") + ? "Enter" + : keyIs(event, "profile.freeBack") + ? "Backspace" + : key === "Escape" + ? key + : ""; + if (tools.mode() === "free" && freeKey && free.key(freeKey)) { event.preventDefault(); freeView.redraw(); return; } // 한 줄 되돌리기(346-4) — 평면 · 종단 · 횡단 · 관 · 구조물을 만진 차례 거꾸로. - if (isCtrlKey(event, "z") || isCtrlKey(event, "y")) { + const redo = keyIs(event, "b05.redo"); + if (redo || keyIs(event, "b05.undo")) { event.preventDefault(); // 자유지정 동안은 자유지정 안만 — 되돌릴 것 없어도 큰 이력으로 안 감(367-3) if (tools.mode() === "free") { - if (event.shiftKey || isCtrlKey(event, "y")) free.redo(); + if (redo) free.redo(); else free.undo(); freeView.redraw(); return; } flushHold(); - if (event.shiftKey || isCtrlKey(event, "y")) history.redo(); + if (redo) history.redo(); else history.undo(); return; } diff --git a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts index cdbfef24f..65db81545 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Wheel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Wheel.ts @@ -19,6 +19,7 @@ * ========================================================================== */ import { viewOf } from "../common_util/common_util_view_window"; +import { guardMenuAfterDrag, isPanButton } from "../A00_Common/input_prefs"; /** 프레임마다 남은 거리에서 좁히는 비율. 0.35면 60fps에서 약 0.13초에 도착한다 — * 눈으로는 이어져 보이면서, 멈춘 뒤 세로 눈금이 따라오는 시간도 예전만큼 짧다. */ @@ -170,8 +171,10 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void { } scroller.addEventListener("pointerdown", (event) => { - if (dragId !== null || (event.button !== 0 && event.button !== 1)) return; + // 왼쪽 · 가운데 · ☰ [설정] 옮기기 버튼(오른쪽이면 끈 뒤 메뉴 안 뜸 · 588-4) + if (dragId !== null || (event.button !== 0 && !isPanButton(event.button))) return; if ((event.target as HTMLElement | null)?.closest(DRAG_BLOCK_SELECTOR)) return; + guardMenuAfterDrag(event); // 끌기가 시작되면 휠 관성 이동은 접는다 — 둘이 같은 scrollLeft를 다투면 튄다. stop(); dragId = event.pointerId; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 1e44b643c..7c9449072 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -575,7 +575,6 @@ export async function mountRouteEditor( }, }); - // 확대·이동은 배수유역도와 같은 동작으로 — 휠은 당기면 확대, 팬은 가운데 버튼 전용. bindRouteEditNavigation({ canvas, getView: () => view, @@ -586,6 +585,7 @@ export async function mountRouteEditor( // 돌린 지도에서는 손이 민 방향과 그림이 움직일 방향이 다르다 — 거꾸로 돌려 넘긴다. unrotateDelta: rotation.unrotateDelta, rotateWheel: rotation.wheel, // Ctrl+휠 = 5° 돌리기(390-3) + claimed: pointer.claimed, // 왼쪽 옮기기 = 노드 · 손잡이 · 마커 · 재기 아닌 누름만(588-4) draw, }); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index 8966c080f..59e14f86a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -6,15 +6,22 @@ * (2026-09-07 사용자 지시 ⑤). 한쪽만 고치면 두 지도가 서로 다르게 움직인다. * · 휠을 **당기면 확대**(`deltaY > 0` → 확대) · 계수 1.15 / 0.87 * · 배율 상한은 「화면 폭 16m」로 계산(`computeMaxScale`), 하한 0.5 - * · **팬은 가운데(휠) 버튼 전용** — 왼쪽 버튼이 팬까지 겸하면 노드를 집으려다 지도가 - * 딸려 움직인다. 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 한다. + * · **팬 = ☰ [설정] 화면 옮기기 버튼**(기본 가운데 · 588-4) — 왼쪽을 고르면 페이지가 안 잡은 누름만 + * (노드 · 마커 · 그리기는 그대로) · 오른쪽은 끈 뒤 메뉴 안 뜸 · 덜 끌고 떼면 그 버튼 제 몫. + * 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 한다. * * ⚠ 커서 고정은 **화면 중심 기준**으로 셈한다 — `affineOf` 가 `centerX*(1−scale)` 을 품고 * 있어 좌상단 기준으로 셈하면 확대할수록 커서 아래 지점이 밀린다(옛 모달의 버그). * ========================================================================== */ import { isPinchWheel, pickPx, wheelZoomsIn } from "../A00_Common/touch_bridge"; -import { isTrackpadPinch } from "../A00_Common/input_prefs"; +import { + PAN_MOVE_PX, + buttonMask, + isPanButton, + isTrackpadPinch, + swallowNextMenu, +} from "../A00_Common/input_prefs"; import { computeMaxScale, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { StationLabelBox } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; @@ -36,8 +43,10 @@ export interface RouteEditNavigationParams { /** 돌린 지도 보정 — 화면에서 민 만큼(dx, dy)을 **그림 좌표의 만큼**으로 바꾼다. * 안 주면 안 돌린 것으로 본다(배수유역도는 회전이 없다). */ unrotateDelta?: (dx: number, dy: number) => [number, number]; - /** 마우스 왼쪽 버튼으로도 팬 — 집을 노드가 없는 미리 보기 지도용. 편집 창은 가운데 버튼만. */ + /** 마우스 왼쪽 버튼으로도 팬 — 집을 노드가 없는 미리 보기 지도용. 편집 창은 설정 버튼만. */ leftPan?: boolean; + /** 왼쪽이 옮기기 버튼일 때 페이지가 그 누름을 잡았나(노드 · 마커) — 안 주면 포인터를 잡은 겹이 있으면 잡음 */ + claimed?: () => boolean; /** Ctrl+휠 = 돌리기(390-3) — 안 주면 Ctrl+휠도 확대(추천 지도). 손가락 · 트랙패드 벌림은 늘 확대. */ rotateWheel?: (sign: 1 | -1) => void; draw: () => void; @@ -49,7 +58,14 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void const unrotateDelta = params.unrotateDelta ?? ((dx: number, dy: number) => [dx, dy]); /** 팬 중인 포인터 — 직전 자리만 쥔다(401). 누를 때 offset 을 쥐고 「처음 + 민 만큼」으로 덮으면 * 끄는 중 휠 확대 · Ctrl+휠 회전이 바꾼 화면을 옛 값이 덮어 지도가 튄다 → 걸음마다 지금 화면에 더함. */ - let drag: { id: number; x: number; y: number; mask: number } | null = null; + let drag: { + id: number; + x: number; + y: number; + mask: number; + button: number; + moved: number; + } | null = null; canvas.addEventListener( "wheel", @@ -87,20 +103,21 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void canvas.addEventListener("pointerdown", (event) => { // 가운데 버튼 기본 동작(페이지 자동 스크롤)이 팬과 겹치지 않게 막는다. if (event.button === 1) event.preventDefault(); - // 마우스는 가운데 버튼만 팬이다 — 왼쪽 버튼은 노드·손잡이 조작 몫. - if ( - event.pointerType === "mouse" && - event.button !== 1 && - !(params.leftPan && event.button === 0) - ) - return; + // 마우스는 설정 버튼(가운데는 늘)만 팬 — 왼쪽이면 페이지(노드 · 손잡이 · 겹)가 안 잡은 누름만. + const mouse = event.pointerType === "mouse"; + const left = event.button === 0 && !!params.leftPan; + if (mouse && !left && !isPanButton(event.button)) return; + const claimed = params.claimed ?? (() => canvas.hasPointerCapture(event.pointerId)); + if (mouse && event.button === 0 && !left && claimed()) return; if (drag) return; // 둘째 손가락은 팬을 넘겨받지 않음(벌림 확대 몫) - // 쥔 단추(buttons 비트) — 가운데 4 · 왼쪽 1. 놓침(창 밖에서 놓고 들어옴)을 move 에서 알아챔. + // 쥔 단추(buttons 비트). 놓침(창 밖에서 놓고 들어옴)을 move 에서 알아챔. drag = { id: event.pointerId, x: event.clientX, y: event.clientY, - mask: event.button === 1 ? 4 : 1, + mask: mouse ? buttonMask(event.button) : 1, + button: mouse ? event.button : -1, + moved: 0, }; canvas.style.cursor = "grabbing"; canvas.setPointerCapture(event.pointerId); @@ -110,6 +127,7 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void if (!drag || event.pointerId !== drag.id) return; if (event.pointerType === "mouse" && !(event.buttons & drag.mask)) return stop(); const [dx, dy] = unrotateDelta(event.clientX - drag.x, event.clientY - drag.y); + drag.moved += Math.abs(event.clientX - drag.x) + Math.abs(event.clientY - drag.y); drag.x = event.clientX; drag.y = event.clientY; const view = getView(); @@ -119,6 +137,7 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void const stop = (event?: PointerEvent): void => { if (!drag || (event && event.pointerId !== drag.id)) return; + if (drag.button === 2 && drag.moved > PAN_MOVE_PX) swallowNextMenu(); // 끈 뒤 메뉴 안 뜸 drag = null; canvas.style.removeProperty("cursor"); }; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index c5c0c2e11..b0aed6601 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -116,6 +116,8 @@ export interface RouteEditPointerParams { export interface RouteEditPointer { /** 노드나 손잡이를 끌고 있나 — 끄는 동안 기울기 라벨을 숨긴다. */ dragging: () => boolean; + /** 마지막 왼쪽 누름을 잡았나(재기 · 마커 · 노드 · 손잡이) — 왼쪽 옮기기(588-4)가 비킴 */ + claimed: () => boolean; nodeAt: (px: number, py: number) => number; segmentAt: (px: number, py: number) => number; } @@ -213,6 +215,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP let dragOff = 0; /** 구조물 측점 마커(399) — 집기 차례 = 재기 다음 맨 앞(409) · 끌기 · 방향키 1 m. */ const marks = bindStructureMarks(params, () => dragNode >= 0 || dragHandle !== null); + let claimed = false; bindPlanStructureMenu(params, nodeAt); // 우클릭 = 종단 그래프와 같은 구조물 메뉴(513-1) /** 누름 · 맴돔 한 판정 — 노드 가운데 6 px = 노드 · 밖 = 마커 · 노드 · 손잡이 중 가까운 것(`pressWinner`). */ const chooseAt = (px: number, py: number) => { @@ -234,6 +237,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP canvas.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; + claimed = true; // 왼쪽 옮기기(588-4)는 잡은 누름(재기 · 마커 · 노드 · 손잡이)에선 안 함 const rect = canvas.getBoundingClientRect(); const [px, py] = params.unrotate(event.clientX - rect.left, event.clientY - rect.top); if (event.shiftKey || params.measureMode()) { @@ -270,6 +274,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP dragOff = params.offStation(); dragHandle = choice.handle; dragMoved = false; + claimed = dragNode >= 0 || dragHandle !== null; if (hitNode >= 0) { params.setPicked(hitNode); // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다. params.measure.clear(); // 잰 창과 곡선 패널은 같이 뜨지 않는다(㉔). @@ -504,5 +509,10 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP canvas.addEventListener("pointercancel", endDrag); bindRouteEditKeys(params, () => dragNode >= 0 || dragHandle !== null); // 교각점 방향키(383-2) - return { dragging: () => dragNode >= 0 || dragHandle !== null, nodeAt, segmentAt }; + return { + dragging: () => dragNode >= 0 || dragHandle !== null, + claimed: () => claimed, + nodeAt, + segmentAt, + }; } diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index 1210819e1..105276e07 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -32,14 +32,21 @@ import { import { Tool } from '../tools.ts'; import { SelectState } from '../tools/select-tool.ts'; import { ActorEvent, type MouseClickEvent } from '../tools/tool.types.ts'; -import { activeToolWantsFreeText, activeToolWantsIme, ImeSink, isImeKey, isPlainSpace } from './ime-sink.ts'; +import { + activeToolWantsFreeText, + activeToolWantsIme, + ImeSink, + isImeKey, + isPlainSpace, +} from './ime-sink.ts'; import { drawInputField, dynamicInputLine } from './input-field-draw.ts'; -import { cadCtrlCommand, ctrlShortcut } from './input-shortcut.ts'; +import { CAD_KEY_COMMANDS, cadKeyAction } from './input-shortcut.ts'; import { wheelZoom } from './input-wheel.ts'; import { isObjectSnap, noteShift, snapAt } from './ortho.ts'; import { isWaitingForSecondSelectPoint, selectingActor } from './selecting.ts'; import { openSnapMenu } from './snap-menu.ts'; import { isMiddleDouble, zoomExtents } from './zoom-prompt.ts'; +import { isPanButton, PAN_MOVE_PX } from '../../../../A00_Common/input_prefs'; import { attachTouch } from './input-touch.ts'; import { NUMBER_REGEXP, type PointTextEvent } from './input-patterns.ts'; import { commandCandidate, pushInput, recallInput } from './input-history.ts'; @@ -82,6 +89,8 @@ export class InputController { private candidateIndex = 0; /** 왼쪽 버튼을 누른 화면 좌표 — 놓을 때 끌었는지 판단한다 */ private leftPressLocation: Point | null = null; + /** ☰ [설정] 옮기기 버튼이 왼쪽 · 오른쪽일 때 누른 자리(588-4) — 끌면 옮김 · 안 끌고 떼면 그 버튼 제 몫 */ + private panPress: { button: number; at: Point } | null = null; public readonly ime = new ImeSink((data) => { this.text += data; }); @@ -168,6 +177,21 @@ export class InputController { } public handleMouseUp(evt: MouseEvent) { + const pan = this.panPress; + if (pan && evt.button === pan.button) { + this.panPress = null; + setPanStartLocation(null); + const at = this.getCanvasPoint(evt); + if (Math.hypot(at.x - pan.at.x, at.y - pan.at.y) > PAN_MOVE_PX) { + evt.preventDefault(); // 끌어 옮김 — Enter · 클릭 안 보냄 + return; + } + if (evt.button === MouseButton.Left) { + this.sendMouseClick(evt); + this.ime.sync(activeToolWantsIme()); + return; + } + } if (evt.button === MouseButton.Right) { // 우클릭 = Enter(명령 중 확정 · 쉴 때 반복) · Shift+우클릭 = 점 물음에서 한 번 스냅 메뉴 evt.preventDefault(); @@ -319,6 +343,17 @@ export class InputController { setPanStartLocation(this.getCanvasPoint(evt)); return; } + if ( + isPanButton(evt.button) && + !isViewOnly() && + !(evt.target as HTMLElement | null)?.closest('.controls') + ) { + // 왼쪽 · 오른쪽 옮기기 — 끌면 옮김 · 떼는 자리에서 클릭(왼쪽) · Enter(오른쪽) + evt.preventDefault(); + this.panPress = { button: evt.button, at: this.getCanvasPoint(evt) }; + setPanStartLocation(this.panPress.at); + return; + } if (evt.button !== MouseButton.Left) return; if ((evt.target as HTMLElement | null)?.closest('.controls')) return; if (isViewOnly()) { @@ -376,10 +411,11 @@ export class InputController { if (target !== this.ime.element && target?.closest('input, textarea, select')) { return; } - const shortcut = ctrlShortcut(evt); - const cadCommand = cadCtrlCommand(evt); - // 한글 조합 중 — 단축키가 먹지 않게 IME 에 넘김(Ctrl 단축키는 조합 중에도 먹음) - if (isImeKey(evt) && !shortcut && !cadCommand) return; + // 키 동작 = ☰ [설정] 등록부(588-3) — 바꾼 키만 먹고 옛 키는 아래 글자 · 기본 처리로 감 + const action = cadKeyAction(evt); + const cadCommand = action ? CAD_KEY_COMMANDS[action] : undefined; + // 한글 조합 중 — 단축키가 먹지 않게 IME 에 넘김(키 동작은 조합 중에도 먹음) + if (isImeKey(evt) && !action) return; if (!['Shift', 'Control', 'Alt', 'Meta'].includes(evt.key)) notice(null); // 알림 줄은 다음 키까지 console.log(`key pressed: ${evt.key}`); if (evt.key === 'F12') { @@ -397,22 +433,22 @@ export class InputController { return; } // 제도 보조 토글 (AutoCAD 상태막대 기능키) - if (evt.key === 'F3') { + if (action === 'cad.snap') { evt.preventDefault(); setSnapEnabled(!getSnapEnabled()); return; } - if (evt.key === 'F7') { + if (action === 'cad.grid') { evt.preventDefault(); setGridEnabled(!getGridEnabled()); return; } - if (evt.key === 'F8') { + if (action === 'cad.ortho') { evt.preventDefault(); setAngleStep(getAngleStep() === 90 ? 0 : 90); return; } - if (evt.key === 'F10') { + if (action === 'cad.polar45') { evt.preventDefault(); setAngleStep(getAngleStep() === 45 ? 0 : 45); return; @@ -444,16 +480,16 @@ export class InputController { } evt.preventDefault(); evt.stopPropagation(); - if (shortcut === 'undo') { + if (action === 'cad.undo') { // User wants to undo the last action this.handleUndo(evt); - } else if (shortcut === 'redo') { + } else if (action === 'cad.redo') { // User wants to redo the last action this.handleRedo(evt); - } else if (shortcut === 'save') { + } else if (action === 'cad.save') { // 부모 페이지가 저장을 맡은 화면(M02 양식)만 — B07 은 예전처럼 아무 일 없음 if (isHostSave()) requestHostSave(); - } else if (shortcut === 'selectAll') { + } else if (action === 'cad.selectAll') { // User wants to select everything setSelectedEntityIds(getPickableEntities().map((entity) => entity.id)); } else if (evt.key === 'Backspace') { @@ -461,7 +497,7 @@ export class InputController { evt.preventDefault(); this.text = this.text.slice(0, this.text.length - 1); this.browsing = null; - } else if (evt.key === 'Delete') { + } else if (action === 'cad.delete') { // User wants to delete the current selection evt.preventDefault(); getActiveToolActor()?.send({ diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-shortcut.ts b/B07_DesignDetail/openwebcad/src/inputController/input-shortcut.ts index f8fb80b06..4fcc772c4 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-shortcut.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-shortcut.ts @@ -2,7 +2,7 @@ * Ctrl 단축키 판정 — 공용 `A00_Common/key_shortcut.ts`(214-2)를 그대로 내보냄. * 한글 자판은 `key` 가 「ㅋ」·「ㅛ」로 와 글자로는 안 맞아 자판 자리(`code`)로 본다. */ -import { keyLetter } from '../../../../A00_Common/key_shortcut'; +import { keyAction } from '../../../../A00_Common/key_registry'; export { ctrlShortcut, @@ -11,22 +11,14 @@ export { type CtrlShortcut, } from '../../../../A00_Common/key_shortcut'; -/** CAD 만의 Ctrl 단축키 → 명령 이름(오토캐드) — Ctrl+V 는 브라우저 `paste` 사건 몫 */ -export function cadCtrlCommand( - evt: Pick & - Partial> -): string | null { - if (!(evt.ctrlKey || evt.metaKey) || evt.altKey) return null; - switch (keyLetter(evt)) { - case 'c': - return evt.shiftKey ? 'COPYBASE' : 'COPYCLIP'; - case 'x': - return evt.shiftKey ? null : 'CUTCLIP'; - case 'v': - return evt.shiftKey ? 'PASTEBLOCK' : null; - case '1': - return evt.shiftKey ? null : 'PROPERTIES'; - default: - return null; - } -} +/** CAD 키 동작(☰ [설정] 단축키 등록부 · 588-3) — 사용자가 바꾼 키 있으면 그것만 · 없으면 기본(F3 · Ctrl+Z …) */ +export const cadKeyAction = (evt: KeyboardEvent): string | null => keyAction(evt, 'cad'); + +/** 명령으로 가는 CAD 키 동작 → 명령 이름(오토캐드) — Ctrl+V 는 브라우저 `paste` 사건 몫 */ +export const CAD_KEY_COMMANDS: Record = { + 'cad.copy': 'COPYCLIP', + 'cad.copyBase': 'COPYBASE', + 'cad.cut': 'CUTCLIP', + 'cad.pasteBlock': 'PASTEBLOCK', + 'cad.properties': 'PROPERTIES', +}; diff --git a/B07_DesignDetail/openwebcad/tsconfig.app.json b/B07_DesignDetail/openwebcad/tsconfig.app.json index 79edf6ba4..ace40413e 100644 --- a/B07_DesignDetail/openwebcad/tsconfig.app.json +++ b/B07_DesignDetail/openwebcad/tsconfig.app.json @@ -29,6 +29,7 @@ "src", "../../A00_Common/key_shortcut.ts", "../../A00_Common/input_prefs.ts", + "../../A00_Common/key_registry.ts", "../../ui_template/ui_template_modal.ts", "../../ui_template/ui_template_button.ts", "../../ui_template/ui_template_icons.ts", diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index c2d933641..90af6bcab 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -22,6 +22,7 @@ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_ import { createFloatPanel } from "@ui/ui_template_float_panel"; import { createToolbar } from "@ui/ui_template_toolbar"; import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; +import { isAuxPanButton } from "../A00_Common/input_prefs"; import { midKm, roadOf, @@ -476,7 +477,8 @@ export function createDiagram( onChange: (state) => (zoomState = state), wheelZoom: true, content: panContent(vw, vh), // 원배율에서도 팬(244-6 · 범위 없으면 1배는 안 움직임) - panStart, // 가운데 버튼 · 빈 곳 왼쪽 끌기 + // 가운데 버튼 · 빈 곳 왼쪽 끌기 · ☰ [설정] 옮기기 버튼(588-4) + panStart: (e) => panStart(e) || isAuxPanButton(e.button), }); }; refresh(); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index c5ddfc3ae..2904f5874 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -18,6 +18,7 @@ * ========================================================================== */ import { createButton, showToast } from "@ui/ui_template_elements"; +import { keyIs } from "../A00_Common/key_registry"; import { createInputField } from "@ui/ui_template_input"; import { createSegment } from "@ui/ui_template_segment"; import { createText } from "@ui/ui_template_text"; @@ -444,7 +445,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { panelState(); }, key(event) { - if (mode === "draw" && event.key === "Enter") { + if (mode === "draw" && keyIs(event, "plan.drawFinish")) { setMode("idle"); return true; } @@ -455,7 +456,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { setMode("idle"); return true; } - if (mode === "draw" && active !== null && event.key === "Backspace") { + if (mode === "draw" && active !== null && keyIs(event, "plan.drawBack")) { const network = params.network(); const step = network.lines[active].vertices.length < 2 diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts index 53ea0225f..1c60f4cc6 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts @@ -18,6 +18,7 @@ import { showToast } from "@ui/ui_template_elements"; import { themeColor } from "@ui/ui_template_palette"; +import { keyIs } from "../A00_Common/key_registry"; import { distanceAlong, lineLength, @@ -527,7 +528,7 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { lineIndex, key(event) { if (!selected || !params.enabled()) return false; - if (event.key === "Delete" && params.editable()) { + if (keyIs(event, "plan.delete") && params.editable()) { dropSelected(); return true; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index a41c87120..e9c262a17 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -32,6 +32,7 @@ import { import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { currentProjectId } from "../A00_Common/b_project_switch"; import { mountGroupStep } from "../A00_Common/group_workflow"; +import { keyIs } from "../A00_Common/key_registry"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler"; import { @@ -513,16 +514,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { if (!root.isConnected) return window.removeEventListener("keydown", onKey); const typing = (event.target as HTMLElement | null)?.closest?.("input, textarea, select"); if (typing || !editable) return; - const key = event.key.toLowerCase(); - const ctrl = event.ctrlKey || event.metaKey; - const handled = - ctrl && key === "z" && !event.shiftKey - ? (undo(), true) - : ctrl && (key === "y" || (key === "z" && event.shiftKey)) - ? (redo(), true) - : measuring && event.key === "Escape" - ? (setMeasure(false), true) - : !ctrl && (draw.key(event) || edit.key(event)); + // 키 = 등록부(588-3 · 한글 자판도 자판 자리로) + const handled = keyIs(event, "plan.undo") + ? (undo(), true) + : keyIs(event, "plan.redo") + ? (redo(), true) + : measuring && event.key === "Escape" + ? (setMeasure(false), true) + : draw.key(event) || edit.key(event); if (handled) { event.preventDefault(); map.redraw(); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts index 453ef5ff0..51c8363f0 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts @@ -8,6 +8,7 @@ * ========================================================================== */ import { wheelZoomsIn } from "../../A00_Common/touch_bridge"; +import { guardMenuAfterDrag, isAuxPanButton } from "../../A00_Common/input_prefs"; import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; import type { CfBox, CfKind, CfLogic, CfPill, CfPort } from "./M02_DF_Model"; import { @@ -467,7 +468,8 @@ export function renderDiagram( (ev) => { // 손가락 빈 곳 끌기도 판 옮기기(기기대응 3-3) const touchBlank = ev.pointerType === "touch" && (ev.target === view || ev.target === world); - if (ev.button === 1 || (ev.button === 0 && spaceDown) || touchBlank) { + if (isAuxPanButton(ev.button) || (ev.button === 0 && spaceDown) || touchBlank) { + guardMenuAfterDrag(ev); ev.preventDefault(); ev.stopPropagation(); dragPan(ev); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts index 1a749344e..064e2f191 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts @@ -8,6 +8,7 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { wheelZoomsIn } from "../A00_Common/touch_bridge"; +import { guardMenuAfterDrag, isAuxPanButton } from "../A00_Common/input_prefs"; import { createButton, el } from "@ui/ui_template_elements"; import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; import "./M02_MasterTemplete_Labor_Diagram.css"; @@ -282,10 +283,11 @@ function mountShell(host: HTMLElement): State { }), viewport, ); - // 판 옮기기 — 가운데 단추 · Space + 왼쪽 끌기 · 손가락 빈 곳 끌기(기기대응 3-3) + // 판 옮기기 — 가운데 · ☰ [설정] 옮기기 버튼(588-4) · Space + 왼쪽 끌기 · 손가락 빈 곳 끌기(기기대응 3-3) viewport.addEventListener("pointerdown", (e) => { const touchBlank = e.pointerType === "touch" && (e.target === viewport || e.target === world); - if (!(e.button === 1 || (e.button === 0 && st.space) || touchBlank)) return; + if (!(isAuxPanButton(e.button) || (e.button === 0 && st.space) || touchBlank)) return; + guardMenuAfterDrag(e); e.preventDefault(); try { viewport.setPointerCapture(e.pointerId); diff --git a/resources/tester/cad/input-prefs-cad.test.ts b/resources/tester/cad/input-prefs-cad.test.ts index c6ad55a20..44f4bc110 100644 --- a/resources/tester/cad/input-prefs-cad.test.ts +++ b/resources/tester/cad/input-prefs-cad.test.ts @@ -2,6 +2,8 @@ * CAD 설정 배선(PLAN 332-3) — ☰ [설정] 휠 확대 방향 · 내 별칭(`A00_Common/input_prefs.ts`). * 휠 = 기본 밂(앞으로 굴리면 확대 · 오토캐드 · 337) · 당김 저장 뒤 뒤집힘 · 핀치는 늘 벌리면 확대. * 별칭 = 명령 이름 > 내 별칭 > 기본 별칭 · 저장 즉시 따름. + * 바꾼 키(588-3) = 새 키만 먹음 · 옛 키는 안 먹음 · 한글 자판도 자판 자리로. + * 옮기기 버튼(588-4) = 오른쪽을 고르면 오른쪽 끌기로 화면 옮김 · 기본(가운데)이면 오른쪽 끌기는 안 옮김. * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad input-prefs-cad */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -11,7 +13,12 @@ import { matchCommandPrefixes, resolveCommandInput, } from "../../../B07_DesignDetail/openwebcad/src/commands/registry"; -import { getActiveToolActor } from "../../../B07_DesignDetail/openwebcad/src/state"; +import { + getActiveToolActor, + getPanStartLocation, + getScreenCanvasDrawController, + getSnapEnabled, +} from "../../../B07_DesignDetail/openwebcad/src/state"; import { initApplication } from "../../../B07_DesignDetail/openwebcad/test/helpers/init-application"; const store = new Map(); @@ -96,3 +103,64 @@ describe("내 별칭", () => { expect(getActiveToolActor()?.getSnapshot()?.context.type).toBe("RECTANGLE"); }); }); + +describe("바꾼 키(588-3)", () => { + const key = (controller: ReturnType, extra: Partial) => + controller.handleKeyStroke({ + key: "", + code: "", + keyCode: 0, + isComposing: false, + shiftKey: false, + ctrlKey: false, + altKey: false, + metaKey: false, + repeat: false, + target: null, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + ...extra, + } as unknown as KeyboardEvent); + + it("스냅 F3 → Ctrl+Q — 새 키(한글 자판 「ㅂ」)만 켜고 끔 · F3 안 먹음", () => { + const controller = initApplication(); + const before = getSnapEnabled(); + key(controller, { key: "F3", code: "F3" }); + expect(getSnapEnabled()).toBe(!before); + savePrefs({ keys: { "cad.snap": ["Ctrl+Q"] } }); + key(controller, { key: "F3", code: "F3" }); + expect(getSnapEnabled()).toBe(!before); + key(controller, { key: "ㅂ", code: "KeyQ", ctrlKey: true }); + expect(getSnapEnabled()).toBe(before); + }); +}); + +describe("옮기기 버튼(588-4)", () => { + const mouse = (button: number, x: number, y = 100) => + ({ + button, + clientX: x, + clientY: y, + shiftKey: false, + ctrlKey: false, + target: null, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }) as unknown as MouseEvent; + const rightDrag = (controller: ReturnType) => { + const before = getScreenCanvasDrawController().getScreenOffset().clone(); + controller.handleMouseDown(mouse(2, 100)); + controller.handleMouseMove(mouse(2, 160)); + controller.handleMouseUp(mouse(2, 160)); + const after = getScreenCanvasDrawController().getScreenOffset(); + return Math.hypot(after.x - before.x, after.y - before.y); + }; + + it("기본(가운데) = 오른쪽 끌기 안 옮김 · 오른쪽 고름 = 옮김 · 놓으면 끝", () => { + const controller = initApplication(); + expect(rightDrag(controller)).toBe(0); + savePrefs({ panButton: "right" }); + expect(rightDrag(controller)).toBeGreaterThan(0); + expect(getPanStartLocation()).toBeNull(); + }); +}); diff --git a/resources/tester/helper_230_1_popout_wheel.cjs b/resources/tester/helper_230_1_popout_wheel.cjs index b05bb981b..223e8c1f3 100644 --- a/resources/tester/helper_230_1_popout_wheel.cjs +++ b/resources/tester/helper_230_1_popout_wheel.cjs @@ -82,6 +82,7 @@ globalThis.ResizeObserver = class { const viewWindow = load(path.join(ROOT, "common_util", "common_util_view_window.ts")); const wheelMod = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Wheel.ts"), { "../common_util/common_util_view_window": viewWindow, + "../A00_Common/input_prefs": load(path.join(ROOT, "A00_Common", "input_prefs.ts")), }); const viewport = load(path.join(ROOT, "A00_Common", "design", "design_profile_viewport.ts"), { "../../B05_Profile/B05_Profile_UI_Profile_Wheel": wheelMod, diff --git a/resources/tester/helper_369_3_update_press.cjs b/resources/tester/helper_369_3_update_press.cjs index 41ecd61ea..4f8ba6d86 100644 --- a/resources/tester/helper_369_3_update_press.cjs +++ b/resources/tester/helper_369_3_update_press.cjs @@ -36,6 +36,11 @@ global.Element = Element; const stubs = { "../A00_Common/touch_bridge": { wheelZoomsIn: () => true }, + "../A00_Common/input_prefs": { + isPanButton: (b) => b === 1, + PAN_MOVE_PX: 4, + swallowNextMenu() {}, + }, "../B04_PreProcess/B04_PreProcess_UI_MapRender": { computeMaxScale: () => 10, lonLatToScreen: () => ({ x: 0, y: 0 }), diff --git a/resources/tester/helper_588_key_registry.cjs b/resources/tester/helper_588_key_registry.cjs new file mode 100644 index 000000000..5f921e998 --- /dev/null +++ b/resources/tester/helper_588_key_registry.cjs @@ -0,0 +1,109 @@ +/* PLAN 588 지킴 헬퍼 — 단축키 등록부(`A00_Common/key_registry.ts`) · CAD 묶음 · 찾기(`app_settings_find.ts`) + * 를 그 자리에서 트랜스파일해 Node 로 돌림. 가짜 localStorage 로 바꾼 키를 넣어 봄. + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_588_key_registry.py`. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const mods = {}; +function load(rel) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, rel), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const req = (p) => mods[path.basename(p)] ?? {}; + new Function("exports", "module", "require", js)(box.exports, box, req); + return (mods[path.basename(rel, ".ts")] = box.exports); +} +load("A00_Common/input_prefs.ts"); +load("A00_Common/key_shortcut.ts"); +load("B07_DesignDetail/openwebcad/src/ribbon/ribbon.config.ts"); +const reg = load("A00_Common/key_registry.ts"); +const find = load("A00_Common/app_settings_find.ts"); +const catalog = require( + path.join(ROOT, "B07_DesignDetail/openwebcad/src/commands/command-catalog.json"), +); + +const store = new Map(); +globalThis.localStorage = { getItem: (k) => (store.has(k) ? store.get(k) : null) }; +const setKeys = (keys) => store.set(mods.input_prefs.INPUT_PREFS_KEY, JSON.stringify({ keys })); + +const ev = (key, code, extra = {}) => ({ + key, + code, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...extra, +}); +const out = {}; + +// ① CAD 묶음 · 찾기 +const groups = find.cadCommandGroups(catalog.map((c) => c.id)); +out.groups = groups.map((g) => ({ label: g.label, n: g.ids.length })); +out.groupIds = groups.flatMap((g) => g.ids); +out.other = groups.find((g) => !g.label)?.ids ?? []; +const hits = (q) => + catalog.filter((c) => find.matchesFind(q, [c.id, c.label, ...c.aliases])).map((c) => c.id); +out.find = { + sun: hits("선"), + L: hits("L"), + sunPl: hits("선 pl"), + blank: hits(" ").length, + none: hits("zzqq"), +}; + +// ② 키 글 +out.chord = { + hangulCtrlZ: reg.chordOf(ev("ㅋ", "KeyZ", { ctrlKey: true })), + ctrlShiftZ: reg.chordOf(ev("Z", "KeyZ", { ctrlKey: true, shiftKey: true })), + meta: reg.chordOf(ev("z", "KeyZ", { metaKey: true })), + space: reg.chordOf(ev(" ", "Space")), + shiftOnly: reg.chordOf(ev("Shift", "ShiftLeft", { shiftKey: true })), + process: reg.chordOf(ev("Process", "")), + f3: reg.chordOf(ev("F3", "F3")), + shiftDigit: reg.chordOf(ev("!", "Digit1", { ctrlKey: true, shiftKey: true })), + arrow: reg.chordOf(ev("ArrowUp", "ArrowUp", { shiftKey: true })), + label: reg.keyLabel("Shift+ArrowUp"), +}; + +// ③ 판정 — 기본 · 바꾼 키 · 못 바꾸는 줄은 바꾼 값 무시 +const del = ev("Delete", "Delete"); +out.keyIs = { + delDefault: reg.keyIs(del, "plan.delete"), + hangulUndo: reg.keyIs(ev("ㅋ", "KeyZ", { ctrlKey: true }), "plan.undo"), +}; +setKeys({ "plan.delete": ["Ctrl+D"], "all.escape": ["Q"] }); +out.keyIs.delAfter = reg.keyIs(del, "plan.delete"); +out.keyIs.ctrlDAfter = reg.keyIs(ev("ㅇ", "KeyD", { ctrlKey: true }), "plan.delete"); +out.keyIs.fixedIgnored = reg.actionKeys("all.escape"); +out.keyIs.cadAction = reg.keyAction(ev("F7", "F7"), "cad"); +store.clear(); + +// ④ 겹침 검사 +const chk = (keys) => reg.checkKeys(keys); +out.check = { + none: chk({}), + sameScope: chk({ "plan.delete": ["Ctrl+Z"] }), + withAll: chk({ "plan.delete": ["Escape"] }), + withLoupe: chk({ "plan.delete": ["Z"] }), + otherPage: chk({ "plan.delete": ["F3"] }), + reserved: chk({ "plan.delete": ["F5"] }), + cadLetter: chk({ "cad.snap": ["Q"] }), + cadShiftLetter: chk({ "cad.snap": ["Shift+Q"] }), + cadCtrl: chk({ "cad.snap": ["Ctrl+Q"] }), + samePage: chk({ "b05.undo": ["Enter"] }), + swap: chk({ "plan.undo": ["Ctrl+Y"], "plan.redo": ["Ctrl+Z"] }), + both: chk({ "plan.drawFinish": ["F2"], "plan.drawBack": ["F2"] }), +}; + +// ⑤ 등록부 꼴 +out.actions = reg.KEY_ACTIONS.map((a) => ({ + id: a.id, + scope: a.scope, + keys: a.keys, + fixed: !!a.fixed, +})); +out.scopes = Object.keys(reg.KEY_SCOPES); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_215_1_free_profile.py b/resources/tester/test_215_1_free_profile.py index 42c88571a..32c975885 100644 --- a/resources/tester/test_215_1_free_profile.py +++ b/resources/tester/test_215_1_free_profile.py @@ -137,7 +137,7 @@ def test_되돌리기_배선_자유지정_먼저(): panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") assert 'tools.mode() === "free" ? (free.undo(), freeView.redraw()) : history.undo()' in panel assert 'tools.mode() === "free" ? free.canUndo() : history.canUndo()' in panel - keys = panel[panel.index('isCtrlKey(event, "z") || isCtrlKey(event, "y")') :] + keys = panel[panel.index('if (redo || keyIs(event, "b05.undo")) {') :] # 588-3 등록부 free_at = keys.index('if (tools.mode() === "free") {') assert ( free_at < keys.index("history.undo()") @@ -159,7 +159,7 @@ def test_도구줄_직선화_오른쪽_자유지정(): def test_적용은_직선화와_같은_길_키_배선(): panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") assert "apply: (next) => ctx.applyEdits(next)" in panel - assert 'tools.mode() === "free" && free.key(key)' in panel + assert 'tools.mode() === "free" && freeKey && free.key(freeKey)' in panel profile = _read("A00_Common/design/design_profile.ts") # applyEdits = 캐시(store.replace) + 되돌리기 기록 — 자동저장 없음 assert "store.replace(next);\n history.record();" in profile diff --git a/resources/tester/test_345_3_route_body.py b/resources/tester/test_345_3_route_body.py index 18cdbd0de..2547415b5 100644 --- a/resources/tester/test_345_3_route_body.py +++ b/resources/tester/test_345_3_route_body.py @@ -47,7 +47,7 @@ def test_Ctrl_Z_는_사진첩_한_줄(out): def test_종단도_Ctrl_Z_줄이_사진첩_하나(): tools = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts") - at = tools.index('if (isCtrlKey(event, "z") || isCtrlKey(event, "y")) {') + at = tools.index('if (redo || keyIs(event, "b05.undo")) {') # 588-3 등록부 assert ( "undoOwner" not in tools and "history.undo()" in tools[at : at + 600] ) # 367-3 자유지정 분기 앞에 섬 diff --git a/resources/tester/test_346_4_one_undo.py b/resources/tester/test_346_4_one_undo.py index f2423b500..1a8c0b0f2 100644 --- a/resources/tester/test_346_4_one_undo.py +++ b/resources/tester/test_346_4_one_undo.py @@ -57,7 +57,7 @@ def test_다시하기는_앞으로(out) -> None: def test_단축키는_사진첩_한_곳(): tools = (ROOT / "B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts").read_text(encoding="utf-8") assert "undoOwner" not in tools - assert 'isCtrlKey(event, "y")' in tools and "setPageHistory(history)" in tools + assert 'keyIs(event, "b05.redo")' in tools and "setPageHistory(history)" in tools # 588-3 plan = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit_History.ts").read_text(encoding="utf-8") assert '"keydown"' not in plan assert not (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit_UndoOwner.ts").exists() diff --git a/resources/tester/test_588_key_registry.py b/resources/tester/test_588_key_registry.py new file mode 100644 index 000000000..34d1f6f1b --- /dev/null +++ b/resources/tester/test_588_key_registry.py @@ -0,0 +1,152 @@ +"""PLAN 588 — 단축키 등록부 · 겹침 판정 · CAD 리본 묶음 · 찾기 (요구 445 C2 · C3). + +· 등록부 = `A00_Common/key_registry.ts` — ☰ [설정] 표가 여기서 그려짐 · 바꿀 수 있는 줄은 코드가 부름 +· 키 글 = 한글 자판에서도 자판 자리로(Ctrl+ㅋ = Ctrl+Z) · 바꾼 키만 먹음 · 못 바꾸는 줄은 바꾼 값 무시 +· 겹침 = 같은 화면 자리끼리 · 「모든 화면」과 · 브라우저 몫 · CAD 글자 단독 — 다른 화면끼리는 같은 키 됨 +· CAD 묶음 = 리본 탭 › 패널 · 154 명령이 꼭 한 묶음 · 기타 여섯(리본에 없음) +짝: `helper_588_key_registry.cjs`(실제 TS 를 Node 로 돌림). +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(rel: str) -> str: + with open(os.path.join(ROOT, rel), encoding="utf-8") as f: + return f.read() + + +@pytest.fixture(scope="module") +def r() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_588_key_registry.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_CAD_154_명령_꼭_한_묶음_기타_여섯(r): + ids = r["groupIds"] + assert len(ids) == 154 and len(set(ids)) == 154 + assert sorted(r["other"]) == ["LAYER", "PLACETABLE", "RECOVER", "REDO", "TOAREA", "UNDO"] + assert r["groups"][0]["label"] == "홈 › 그리기" + assert r["groups"][-1]["label"] == "" # 기타 이름은 화면 글(locale) + + +def test_찾기_낱말_모두_대소문자_무시(r): + f = r["find"] + assert {"LINE", "PLINE"} <= set(f["sun"]) and {"LINE", "PLINE"} <= set(f["L"]) + assert "PLINE" in f["sunPl"] and "LINE" not in f["sunPl"] # 낱말 모두 + assert f["blank"] == 154 and f["none"] == [] + + +def test_키_글_한글_자판_안전(r): + c = r["chord"] + assert c["hangulCtrlZ"] == "Ctrl+Z" + assert c["ctrlShiftZ"] == "Ctrl+Shift+Z" + assert c["meta"] == "Ctrl+Z" + assert c["space"] == "Space" + assert c["shiftOnly"] == "" and c["process"] == "" + assert c["f3"] == "F3" + assert c["shiftDigit"] == "Ctrl+Shift+1" + assert c["arrow"] == "Shift+ArrowUp" and c["label"] == "Shift+↑" + + +def test_바꾼_키만_먹음_못_바꾸는_줄은_무시(r): + k = r["keyIs"] + assert k["delDefault"] is True and k["hangulUndo"] is True + assert k["delAfter"] is False and k["ctrlDAfter"] is True + assert k["fixedIgnored"] == ["Escape"] + assert k["cadAction"] == "cad.grid" + + +def test_겹침_판정(r): + c = r["check"] + assert c["none"] == {} + assert c["sameScope"] == {"plan.delete": "dup"} + assert c["withAll"] == {"plan.delete": "dup"} + assert c["withLoupe"] == {"plan.delete": "dup"} + assert c["otherPage"] == {} # D03 과 CAD 는 다른 화면 + assert c["reserved"] == {"plan.delete": "reserved"} + assert c["cadLetter"] == {"cad.snap": "letter"} + assert c["cadShiftLetter"] == {"cad.snap": "letter"} + assert c["cadCtrl"] == {} + assert c["samePage"] == {"b05.undo": "dup"} # B05 · B06 한 페이지 — 횡단 Enter 와 겹침 + assert c["swap"] == {} # 서로 바꿈은 됨 + assert c["both"] == {"plan.drawFinish": "dup", "plan.drawBack": "dup"} + + +def test_등록부_꼴(r): + ids = [a["id"] for a in r["actions"]] + assert len(ids) == len(set(ids)) + for a in r["actions"]: + assert a["scope"] in r["scopes"] and a["keys"] + assert a["id"].startswith(a["scope"] + ".") + + +# 바꿀 수 있는 줄 = 코드가 실제로 부름(등록부에만 있고 안 먹는 키 없음) +WIRED = { + "b05": "B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts", + "profile": "B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts", + "plan": "D03_PlanAnalysis", + "cad": "B07_DesignDetail/openwebcad/src/inputController", +} + + +def _sources(rel: str) -> str: + full = os.path.join(ROOT, rel) + if os.path.isfile(full): + return _read(rel) + names = sorted(n for n in os.listdir(full) if n.endswith(".ts")) + return "\n".join(_read(os.path.join(rel, n)) for n in names) + + +def test_바꿀_수_있는_줄은_배선됨(r): + for a in r["actions"]: + if a["fixed"]: + continue + src = _sources(WIRED[a["scope"]]) + assert f'"{a["id"]}"' in src or f"'{a['id']}'" in src, a["id"] + + +def test_손글_안내_지움_표는_등록부(): + assert "App_Settings_Keys_" not in _read("ui_template/ui_template_locale_common.ts") + assert "App_Settings_Keys_" not in _read("A00_Common/app_settings.ts") + + +# 588-4 — 화면 옮기기 자리는 모두 설정 버튼을 따름(가운데 고정 판정이 남지 않음) +PAN_SITES = [ + "B05_Profile/B05_Profile_UI_RouteEdit_Input.ts", # B05 · B06 겹침 평면 · D03 지도 + "B07_DesignDetail/openwebcad/src/inputController/input-controller.ts", + "A00_Common/b_svg_zoom_pan.ts", + "B04_PreProcess/B04_PreProcess_UI_MapViewer.ts", + "B04_PreProcess/B04_PreProcess_UI_Camera.ts", + "B05_Profile/B05_Profile_UI_Drainage_Interact.ts", + "B05_Profile/B05_Profile_UI_Profile_Wheel.ts", + "B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts", + "M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts", + "M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts", +] + + +@pytest.mark.parametrize("rel", PAN_SITES) +def test_옮기기_자리는_설정_버튼(rel): + src = _read(rel) + assert "isPanButton(" in src or "isAuxPanButton(" in src, rel diff --git a/ui_template/ui_template_locale_common.ts b/ui_template/ui_template_locale_common.ts index 7c9615f06..c0f02edd8 100644 --- a/ui_template/ui_template_locale_common.ts +++ b/ui_template/ui_template_locale_common.ts @@ -110,7 +110,7 @@ export const ui_locales_common = { "그림 위를 길게 누르면 메뉴 · 두 손가락으로 확대", "Long-press the drawing for the menu · pinch to zoom", ], - /* ☰ [설정] 창(332) — 마우스 · CAD 단축키 · 다른 곳 단축키 */ + /* ☰ [설정] 창(332 · 588) — 마우스 · CAD 단축키 · 다른 곳 단축키(표 = `A00_Common/key_registry.ts`) */ App_Settings: ["설정", "Settings"], App_Settings_Tab_Mouse: ["마우스", "Mouse"], App_Settings_Tab_Cad: ["CAD 단축키", "CAD shortcuts"], @@ -118,9 +118,35 @@ export const ui_locales_common = { App_Settings_Wheel: ["휠 확대 방향(모든 화면)", "Wheel zoom-in direction (all views)"], App_Settings_Wheel_Pull: ["당기면 확대(휠을 몸 쪽으로)", "Pull to zoom in (wheel toward you)"], App_Settings_Wheel_Push: ["밀면 확대(휠을 앞으로)", "Push to zoom in (wheel away)"], + App_Settings_Pan: ["화면 옮기기 버튼(모든 화면)", "Pan button (all views)"], + App_Settings_Pan_Left: ["왼쪽", "Left"], + App_Settings_Pan_Middle: ["가운데(휠)", "Middle (wheel)"], + App_Settings_Pan_Right: ["오른쪽", "Right"], + App_Settings_Find: ["찾기 — 이름 · 키 · 별칭", "Find — name · key · alias"], + App_Settings_Find_Empty: ["맞는 것 없음", "No match"], + App_Settings_Reset: ["기본값", "Default"], + App_Settings_Reset_All: ["모두 기본값", "All defaults"], + App_Settings_Key_Action: ["동작", "Action"], + App_Settings_Key_Key: ["키", "Key"], + App_Settings_Key_Help: [ + "칸을 누르고 새 키를 누름 · 배지 줄은 못 바꿈 · [저장] 때 계정에 남음", + "Click a box and press the new key · badge rows are fixed · saved to your account on [Save]", + ], + App_Settings_Key_ErrDup: ["같은 화면에서 이미 쓰는 키", "Key already used on this screen"], + App_Settings_Key_ErrReserved: ["브라우저가 쓰는 키", "Reserved by the browser"], + App_Settings_Key_ErrLetter: [ + "CAD 는 글자 하나만으로 못 씀(명령 입력 몫)", + "CAD cannot use a single letter (command line)", + ], + App_Settings_Key_Invalid: ["키가 겹치거나 못 쓰는 키라 저장 안 함", "Not saved — fix the keys"], + App_Settings_Cad_Keys: ["키 · 마우스", "Keys · mouse"], + App_Settings_Cad_Other: ["기타", "Other"], /* 아래 규칙 값 = 한 줄에 「입력 = 동작」 · 줄바꿈(\n)으로 줄 나눔 · ko · en 줄 수 같게 */ App_Settings_Rules: ["마우스 버튼", "Mouse buttons"], - App_Settings_Rule_Pan: ["가운데 버튼 끌기 = 화면 옮김", "Middle button drag = pan"], + App_Settings_Rule_Pan: [ + "고른 버튼 끌기 = 화면 옮김(가운데는 늘)\n고른 버튼 안 끌고 뗌 = 그 버튼 제 몫", + "Drag with the chosen button = pan (middle always)\nClick without drag = the button's own role", + ], App_Settings_Rule_Pick: ["왼쪽 버튼 = 고르기", "Left button = select"], App_Settings_Rule_Right: [ "오른쪽 버튼 = 메뉴\n오른쪽 버튼(CAD 명령 중) = Enter\n오른쪽 버튼(CAD 쉴 때) = 반복\nShift+오른쪽 버튼(CAD) = 스냅 메뉴\n휠 두 번 누름(CAD) = 범위", @@ -144,46 +170,6 @@ export const ui_locales_common = { App_Settings_Cad_Invalid: ["별칭에 잘못이 있어 저장 안 함", "Not saved — fix the aliases"], App_Settings_Saved: ["설정 저장함", "Settings saved"], App_Settings_Leave: ["저장 안 한 설정이 있음", "You have unsaved settings"], - App_Settings_Keys_All: ["모든 화면", "All views"], - App_Settings_Keys_All_Keys: [ - "Esc = ☰ · 창 닫기 · 고름 풀기\nZ 누른 채 = 돋보기\n오른쪽 버튼 0.4초 누름 = 돋보기\n돋보기 중 휠 = 배율", - "Esc = ☰ · close window · clear selection\nHold Z = loupe\nRight button 0.4 s = loupe\nWheel in loupe = zoom", - ], - App_Settings_Keys_Profile: ["B05 종단", "B05 profile"], - App_Settings_Keys_Profile_Keys: [ - "Ctrl+Z = 되돌림\nCtrl+Shift+Z = 다시\n방향키 = 옮김\nEnter(자유지정) = 끝\nEsc(자유지정) = 취소\nBackspace(자유지정) = 마지막 점 빼기", - "Ctrl+Z = undo\nCtrl+Shift+Z = redo\nArrows = move\nEnter (free mode) = finish\nEsc (free mode) = cancel\nBackspace (free mode) = remove last point", - ], - App_Settings_Keys_RouteEdit: ["B05 노선 편집", "B05 route edit"], - App_Settings_Keys_RouteEdit_Keys: [ - "Ctrl+Z = 되돌림\nCtrl+Y = 다시\nCtrl+Shift+Z = 다시", - "Ctrl+Z = undo\nCtrl+Y = redo\nCtrl+Shift+Z = redo", - ], - App_Settings_Keys_Plan: ["D03 노선분석", "D03 route analysis"], - App_Settings_Keys_Plan_Keys: [ - "Enter(그리기) = 끝\nEsc(그리기) = 취소\nBackspace(그리기) = 마지막 점 빼기\nCtrl+Z = 되돌림\nCtrl+Y = 다시\nCtrl+Shift+Z = 다시", - "Enter (drawing) = finish\nEsc (drawing) = cancel\nBackspace (drawing) = remove last point\nCtrl+Z = undo\nCtrl+Y = redo\nCtrl+Shift+Z = redo", - ], - App_Settings_Keys_Section: ["B06 횡단", "B06 cross section"], - App_Settings_Keys_Section_Keys: [ - "방향키 = 측점 옮김\n▲▼ = 측점 옮김\nEnter = 고름\nSpace = 고름\nEsc = 고름 풀기\nEsc(상세 창 고르기 중) = 고르기 취소", - "Arrows = move station\n▲▼ = move station\nEnter = pick\nSpace = pick\nEsc = clear selection\nEsc (detail window, while picking) = cancel picking", - ], - App_Settings_Keys_Cad: ["CAD", "CAD"], - App_Settings_Keys_Cad_Keys: [ - "F3 = 스냅\nF7 = 그리드\nF8 = 직교\nF10 = 45°\nCtrl+Z = 되돌림\nCtrl+Y = 다시\nCtrl+S(M02) = 저장\nSpace = Enter\n↑↓ = 기록 · 최근 값\nShift+↑↓ = 화면 옮김\n글자 = 명령\nCtrl+A = 모두 고름\nDelete = 고른 것 지움\nEsc = 명령 취소 · 쉴 때 고름 풀기\nEnter = 입력 끝\nCtrl+C = 복사\nCtrl+X = 잘라 냄\nCtrl+V = 붙여넣기\nCtrl+1 = 특성\n← → = 화면 옮김", - "F3 = snap\nF7 = grid\nF8 = ortho\nF10 = 45°\nCtrl+Z = undo\nCtrl+Y = redo\nCtrl+S (M02) = save\nSpace = Enter\n↑↓ = history · recent values\nShift+↑↓ = pan\nLetters = command\nCtrl+A = select all\nDelete = delete selection\nEsc = cancel command · clear selection when idle\nEnter = submit input\nCtrl+C = copy\nCtrl+X = cut\nCtrl+V = paste\nCtrl+1 = properties\n← → = pan", - ], - App_Settings_Keys_Table: ["B08 · B09 표", "B08 · B09 tables"], - App_Settings_Keys_Table_Keys: [ - "Enter = 입력\nF2 = 입력\n방향키 = 칸 옮김\nEsc = 그만", - "Enter = edit\nF2 = edit\nArrows = move cell\nEsc = cancel", - ], - App_Settings_Keys_M02: ["M02 양식", "M02 templates"], - App_Settings_Keys_M02_Keys: [ - "Ctrl+S = 저장\nSpace+끌기 = 화면 옮김\nCtrl+휠 = 배율\n시트 안 = 엑셀 키 그대로\nCtrl+C(구조물 나무) = 복사\nCtrl+V(구조물 나무) = 붙여넣기\nDelete = 고른 항목 지움\nEsc = 연결 · 열린 창 취소", - "Ctrl+S = save\nSpace+drag = pan\nCtrl+wheel = zoom\nInside a sheet = Excel keys\nCtrl+C (structure tree) = copy\nCtrl+V (structure tree) = paste\nDelete = delete selected item\nEsc = cancel link · close open window", - ], App_Footer_Copyright: [ "© 2026 임도설계 및 견적 자동화. All rights reserved.", "© 2026 Forest Road Design & Estimation. All rights reserved.",