Files
Aislo/A00_Common/app_settings_keys.ts
T
eomsangdonandClaude Opus 5.5 2e4316567c 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>
2026-10-29 18:50:41 +09:00

192 lines
7.2 KiB
TypeScript

/* =============================================================================
* 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 };
}