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..3b9c3cdd2 --- /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", { kind: "count" }); + 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/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/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/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index 1210819e1..afae89f51 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -34,7 +34,7 @@ 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 { 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'; @@ -376,10 +376,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 +398,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 +445,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 +462,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/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index 7cd7b839c..cd842e839 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"; @@ -498,7 +499,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; } @@ -509,7 +510,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 170a20e14..b1ccbb958 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts @@ -12,6 +12,7 @@ import { createButton, showToast } from "@ui/ui_template_elements"; import { createText } from "@ui/ui_template_text"; +import { keyIs } from "../A00_Common/key_registry"; import { distanceAlong, lineLength, @@ -306,7 +307,7 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { select, key(event) { if (!selected || !params.enabled()) return false; - if (event.key === "Delete" && params.editable()) { + if (keyIs(event, "plan.delete") && params.editable()) { apply((network, index) => removeLine(network, index)); return true; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 7d3f0670b..f72b50b58 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -31,6 +31,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 { stationLabel } from "../common_util/common_util_station"; @@ -596,16 +597,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/resources/tester/cad/input-prefs-cad.test.ts b/resources/tester/cad/input-prefs-cad.test.ts index c6ad55a20..6ff3723a8 100644 --- a/resources/tester/cad/input-prefs-cad.test.ts +++ b/resources/tester/cad/input-prefs-cad.test.ts @@ -2,6 +2,7 @@ * CAD 설정 배선(PLAN 332-3) — ☰ [설정] 휠 확대 방향 · 내 별칭(`A00_Common/input_prefs.ts`). * 휠 = 기본 밂(앞으로 굴리면 확대 · 오토캐드 · 337) · 당김 저장 뒤 뒤집힘 · 핀치는 늘 벌리면 확대. * 별칭 = 명령 이름 > 내 별칭 > 기본 별칭 · 저장 즉시 따름. + * 바꾼 키(588-3) = 새 키만 먹음 · 옛 키는 안 먹음 · 한글 자판도 자판 자리로. * 실행: 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 +12,7 @@ import { matchCommandPrefixes, resolveCommandInput, } from "../../../B07_DesignDetail/openwebcad/src/commands/registry"; -import { getActiveToolActor } from "../../../B07_DesignDetail/openwebcad/src/state"; +import { getActiveToolActor, getSnapEnabled } from "../../../B07_DesignDetail/openwebcad/src/state"; import { initApplication } from "../../../B07_DesignDetail/openwebcad/test/helpers/init-application"; const store = new Map(); @@ -96,3 +97,34 @@ 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); + }); +}); 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..d580f86ea --- /dev/null +++ b/resources/tester/test_588_key_registry.py @@ -0,0 +1,131 @@ +"""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") 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.",