feat(설정 단축키): CAD 리본 묶음 · 찾기 · 다른 곳 = 실제 동작 등록부 표 · 키 바꾸기 · 겹침 막음 (PLAN 588-1~3 · 요구 445 C2 · C3)

- 새 key_registry.ts — 화면마다 받는 키 · 마우스 동작 한 벌 · keyIs(evt, id) · 바꾼 키만 input-prefs.keys
- ☰ [설정] CAD 탭 = 찾기 칸 + 「키 · 마우스」 + 리본 탭 › 패널 접이 묶음(기타 여섯) · 날 input → 부품 입력
- 다른 곳 탭 = 손글 안내 지우고 등록부 표(B05 방향키 · D03 Delete 등 빠진 것 포함) · 찾기
- 키 기록 칸 · 줄마다 [기본값] · [모두 기본값] · 같은 화면 · 모든 화면 겹침 · 브라우저 몫 · CAD 글자 단독은 저장 막음
- 배선: B05 되돌림 · 자유지정 Enter · Backspace · D03 되돌림 · 그리기 · 지움(한글 자판도 자판 자리) · CAD F3 · F7 · F8 · F10 · Ctrl 명령 · Delete
- 낡은 글 대조 시험 셋(215-1 · 345-3 · 346-4)은 새 배선 글로 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 18:50:41 +09:00
co-authored by Claude Opus 5.5
parent efa382ae06
commit 2e4316567c
19 changed files with 1088 additions and 160 deletions
+30
View File
@@ -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);
}
+62 -30
View File
@@ -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<typeof L>[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<T extends string>(
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<TabId, HTMLElement> = {
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");
+55 -30
View File
@@ -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<string, string[]>): {
/** 탭 몸(588-1) — 찾기 · 「키 · 마우스」 묶음(등록부 CAD 줄) · 리본 이름 묶음 · `aliases` · `keys` 를 칸 입력대로 고쳐 씀 */
export function buildCadAliasTab(
aliases: Record<string, string[]>,
keys: Record<string, string[]>,
onKeysChange: () => void,
): {
root: HTMLElement;
mark: (errors: Record<string, AliasError>) => void;
keys: KeyRows;
count: () => number;
} {
const message = createText("", "error");
message.hidden = true;
const inputs = new Map<string, HTMLInputElement>();
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<string, string[]>): {
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<string, AliasError>): void => {
for (const [id, input] of inputs) {
const error = errors[id];
@@ -113,8 +128,18 @@ export function buildCadAliasTab(aliases: Record<string, string[]>): {
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 };
}
+44
View File
@@ -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<string>();
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));
}
+191
View File
@@ -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<typeof L>[0];
const KEY_ERROR_TEXT: Record<KeyError, LocaleKey> = {
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<string, KeyError>) => void;
/** 사본이 바뀐 뒤 칸 글 · [기본값] 다시 맞춤 */
refresh: () => void;
}
/** 등록부 자리들의 묶음 — `keys` 사본을 고쳐 씀 · `onChange` = 고칠 때마다(겹침 다시 셈) */
export function keyRows(
scopes: KeyScope[],
keys: Record<string, string[]>,
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<string, { set: () => 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<string, KeyError>): 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 };
}
+346
View File
@@ -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<KeyScope, { label: Text; page: string }> = {
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<string, string> = {
" ": "Space",
Spacebar: "Space",
Esc: "Escape",
Del: "Delete",
};
/** 누른 키 → 키 글 · 수정키만 · 한글 조합 = 빈 글 */
export function chordOf(
evt: Pick<KeyboardEvent, "key" | "code" | "ctrlKey" | "altKey" | "shiftKey"> &
Partial<Pick<KeyboardEvent, "metaKey">>,
): 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<string, string[]> = 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<typeof chordOf>[0],
id: string,
keys?: Record<string, string[]>,
): boolean => {
const chord = chordOf(evt);
return !!chord && actionKeys(id, keys).includes(chord);
};
/** 그 자리 바꿀 수 있는 동작 중 이 키에 맞는 첫 동작 id */
export function keyAction(evt: Parameters<typeof chordOf>[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<string, string[]>): Record<string, KeyError> {
const errors: Record<string, KeyError> = {};
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<string, string> = {
ArrowUp: "↑",
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
Escape: "Esc",
};
/** 키 글 → 보일 글(↑ · Esc) */
export const keyLabel = (chord: string): string =>
chord
.split("+")
.map((part) => LABEL[part] ?? part)
.join("+");
@@ -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;
}
@@ -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({
@@ -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<KeyboardEvent, 'code' | 'ctrlKey' | 'altKey' | 'shiftKey'> &
Partial<Pick<KeyboardEvent, 'key' | 'metaKey'>>
): 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<string, string> = {
'cad.copy': 'COPYCLIP',
'cad.copyBase': 'COPYBASE',
'cad.cut': 'CUTCLIP',
'cad.pasteBlock': 'PASTEBLOCK',
'cad.properties': 'PROPERTIES',
};
+3 -2
View File
@@ -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
+2 -1
View File
@@ -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;
}
+9 -10
View File
@@ -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<void> {
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();
+33 -1
View File
@@ -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<string, string>();
@@ -96,3 +97,34 @@ describe("내 별칭", () => {
expect(getActiveToolActor()?.getSnapshot()?.context.type).toBe("RECTANGLE");
});
});
describe("바꾼 키(588-3)", () => {
const key = (controller: ReturnType<typeof initApplication>, extra: Partial<KeyboardEvent>) =>
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);
});
});
@@ -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));
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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 자유지정 분기 앞에 섬
+1 -1
View File
@@ -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()
+131
View File
@@ -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")
+28 -42
View File
@@ -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.",