Files
Aislo/A00_Common/app_settings.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

200 lines
7.2 KiB
TypeScript

/* =============================================================================
* app_settings.ts
* ☰ [설정] 창(PLAN 332-2 · 588 · 요구 224-8 · 445) — 탭 셋: ① 마우스(휠 방향 · 화면 옮기기 단추) ② CAD 단축키 ③ 다른 곳 단축키.
*
* - 고침은 창 안 사본에만 · [저장] 때만 계정 pref(`input-prefs`)로 한 번 씀 — 읽기는 `input_prefs.ts`
* - 저장 안 한 고침이 있으면 닫을 때 물음(askLeave) · 잘못 있는 별칭 · 겹친 키는 저장 안 함
* - ② · ③ 표 = 단축키 등록부(`key_registry.ts`) — 바꾼 키만 `keys` 에
* - 로그인 전엔 이 브라우저에만 남음(계정 올리기는 `b_page_state` 가 로그인 뒤에)
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createKv } from "@ui/ui_template_kv";
import { createButton, el, showToast } from "@ui/ui_template_elements";
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 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];
type TabId = "mouse" | "cad" | "keys";
const TABS: [TabId, LocaleKey][] = [
["mouse", "App_Settings_Tab_Mouse"],
["cad", "App_Settings_Tab_Cad"],
["keys", "App_Settings_Tab_Keys"],
];
const WHEEL: [WheelZoom, LocaleKey][] = [
["pull", "App_Settings_Wheel_Pull"],
["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",
"App_Settings_Rule_Right",
"App_Settings_Rule_Pinch",
];
/** ③ 자리 = 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 => ({
wheelZoom: p.wheelZoom,
panButton: p.panButton,
keys: Object.fromEntries(Object.entries(p.keys).sort(([a], [b]) => (a < b ? -1 : 1))),
cadAliases: Object.fromEntries(Object.entries(p.cadAliases).sort(([a], [b]) => (a < b ? -1 : 1))),
});
const snapshot = (p: InputPrefs): string => JSON.stringify(normalize(p));
/** 규칙 목록 = 값 목록 부품 규칙 꼴 — 한 줄 = 「입력 = 동작」 · 입력은 키 칩(` · ` 로 여럿) · 동작 없는 줄은 칩만 */
function ruleList(keys: LocaleKey[]): HTMLElement {
const rows = keys
.flatMap((key) => L(key).split("\n"))
.map((line) => {
const at = line.indexOf(" = ");
const input = at < 0 ? line : line.slice(0, at);
const chips = document.createDocumentFragment();
chips.append(...input.split(" · ").map((text) => createBadge(text, "neutral")));
return { label: chips, value: at < 0 ? undefined : line.slice(at + 3) };
});
return createKv(rows, { rule: true });
}
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(label) },
});
for (const [mode, key] of options) {
group.append(
createCheckbox(L(key), {
radio: name,
value: 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") }),
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),
],
});
}
/** ☰ [설정] — 창을 열고 지금 설정을 사본으로 고침 */
export function openAppSettings(): void {
const draft = normalize(inputPrefs());
let saved = snapshot(draft);
/** 키를 고칠 때마다 — 두 탭 칸 글 · 겹침 표시 다시 */
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: keysPanel.root,
};
const tabs = el("div", { className: "app-settings__tabs" });
const show = (id: TabId): void => {
fillTabs(tabs, {
tabs: TABS.map(([tab, key]) => ({ key: tab, label: L(key) })),
value: id,
kind: "pill",
data: "settingsTab",
onChange: (tab) => show(tab as TabId),
});
for (const [tab, panel] of Object.entries(panels)) panel.hidden = tab !== id;
};
/** [저장] — 잘못 있는 별칭 · 겹친 키면 그 탭을 보이고 안 씀 */
const save = (): boolean => {
const errors = checkCadAliases(draft.cadAliases);
cad.mark(errors);
if (Object.keys(errors).length) {
show("cad");
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");
return true;
};
const saveButton = createButton({
label: L("Common_Btn_Save"),
variant: "filled",
onClick: () => save() && modal.close(),
});
const modal = openModal({
title: L("App_Settings"),
closeLabel: L("Common_Btn_Close"),
dialogClass: "app-settings",
actions: [saveButton],
beforeClose: async () => {
if (snapshot(draft) === saved) return true;
const choice = await askLeave(L("App_Settings_Leave"));
return choice === "discard" || (choice === "save" && save());
},
mount: (body) => {
show("mouse");
body.append(tabs, ...Object.values(panels));
},
});
}