- 새 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>
192 lines
7.2 KiB
TypeScript
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 };
|
|
}
|