From aaecfa34380a09d5da28bd23799b947d6bdc40a0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:45:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EC=84=A4=EC=A0=95):=20332-2=20=E2=98=B0?= =?UTF-8?q?=20[=EC=84=A4=EC=A0=95]=20=EC=B0=BD=20=E2=80=94=20=EB=A7=88?= =?UTF-8?q?=EC=9A=B0=EC=8A=A4=20=C2=B7=20CAD=20=EB=8B=A8=EC=B6=95=ED=82=A4?= =?UTF-8?q?=20=C2=B7=20=EB=8B=A4=EB=A5=B8=20=EA=B3=B3=20=EB=8B=A8=EC=B6=95?= =?UTF-8?q?=ED=82=A4=20=ED=83=AD=20=C2=B7=20[=EC=A0=80=EC=9E=A5]=20?= =?UTF-8?q?=EB=95=8C=EB=A7=8C=20=EA=B3=84=EC=A0=95=20pref=20=C2=B7=20?= =?UTF-8?q?=EB=8B=AB=EC=9D=84=20=EB=95=8C=20=EB=AC=BC=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app_shell ☰ 설정 묶음에 [설정] 단추(서랍 닫고 창은 처음 누를 때 불러옴) - 새 app_settings.ts(틀 · 탭 셋 · [저장] · askLeave) · app_settings_cad.ts(명령 표 · 내 별칭 · 검사) · app_settings.css - 새 openwebcad/src/commands/command-catalog.json(명령 153 · 한글 이름 · 기본 별칭) + 거울 시험 - locale 문구(ui_template_locale_common) - 시험: test_app_settings · cad/command-catalog 새로 · test_device_audit · test_touch_bridge 화면 주소 환경값(AISLO_UI_BASE · AISLO_UI_PROJECT) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- A00_Common/app_settings.css | 53 +++++ A00_Common/app_settings.ts | 159 +++++++++++++++ A00_Common/app_settings_cad.ts | 118 +++++++++++ A00_Common/app_shell.ts | 16 +- .../src/commands/command-catalog.json | 155 +++++++++++++++ resources/tester/cad/command-catalog.test.ts | 28 +++ resources/tester/helper_app_settings.cjs | 36 ++++ resources/tester/test_app_settings.py | 187 ++++++++++++++++++ resources/tester/test_device_audit.py | 6 +- resources/tester/test_touch_bridge.py | 3 +- ui_template/ui_template_locale_common.ts | 68 +++++++ 11 files changed, 824 insertions(+), 5 deletions(-) create mode 100644 A00_Common/app_settings.css create mode 100644 A00_Common/app_settings.ts create mode 100644 A00_Common/app_settings_cad.ts create mode 100644 B07_DesignDetail/openwebcad/src/commands/command-catalog.json create mode 100644 resources/tester/cad/command-catalog.test.ts create mode 100644 resources/tester/helper_app_settings.cjs create mode 100644 resources/tester/test_app_settings.py diff --git a/A00_Common/app_settings.css b/A00_Common/app_settings.css new file mode 100644 index 000000000..4ac71bdcf --- /dev/null +++ b/A00_Common/app_settings.css @@ -0,0 +1,53 @@ +/* ☰ [설정] 창(PLAN 332-2) — 탭 줄 · 휠 방향 고름 · 표 */ +.app-settings__tabs { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + padding-bottom: var(--spacing-8); + border-bottom: 1px solid var(--color-border); +} + +.app-settings__radios { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-16); +} + +.app-settings__radios > label { + display: inline-flex; + align-items: center; + gap: var(--spacing-8); + min-height: 32px; + cursor: pointer; +} + +.app-settings__table { + width: 100%; + border-collapse: collapse; +} + +.app-settings__table th, +.app-settings__table td { + padding: var(--spacing-4) var(--spacing-8); + border-bottom: 1px solid var(--color-border); + text-align: left; + vertical-align: middle; +} + +.app-settings__table thead th { + position: sticky; + top: 0; + background: var(--color-surface-raised); +} + +.app-settings__table input[aria-invalid="true"] { + border-color: var(--color-danger); +} + +.app-settings__error { + color: var(--color-danger); +} + +.app-settings__error:empty { + display: none; +} diff --git a/A00_Common/app_settings.ts b/A00_Common/app_settings.ts new file mode 100644 index 000000000..d5ab19607 --- /dev/null +++ b/A00_Common/app_settings.ts @@ -0,0 +1,159 @@ +/* ============================================================================= + * app_settings.ts + * ☰ [설정] 창(PLAN 332-2 · 요구 224-8) — 탭 셋: ① 마우스 ② CAD 단축키 ③ 다른 곳 단축키(읽기 전용). + * + * - 고침은 창 안 사본에만 · [저장] 때만 계정 pref(`input-prefs`)로 한 번 씀 — 읽기는 `input_prefs.ts` + * - 저장 안 한 고침이 있으면 닫을 때 물음(askLeave) · 잘못 있는 별칭은 저장 안 함 + * - 로그인 전엔 이 브라우저에만 남음(계정 올리기는 `b_page_state` 가 로그인 뒤에) + * ========================================================================== */ + +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 WheelZoom } from "./input_prefs"; +import { buildCadAliasTab, checkCadAliases } from "./app_settings_cad"; +import "./app_settings.css"; + +type LocaleKey = Parameters[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 MOUSE_RULES: LocaleKey[] = [ + "App_Settings_Rule_Pan", + "App_Settings_Rule_Pick", + "App_Settings_Rule_Right", + "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_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"], +]; + +/** 비교 · 저장 꼴 — 별칭 명령 차례를 맞춤 */ +const normalize = (p: InputPrefs): InputPrefs => ({ + wheelZoom: p.wheelZoom, + cadAliases: Object.fromEntries(Object.entries(p.cadAliases).sort(([a], [b]) => (a < b ? -1 : 1))), +}); +const snapshot = (p: InputPrefs): string => JSON.stringify(normalize(p)); + +function mouseTab(draft: InputPrefs): HTMLElement { + const group = el("div", { + className: "app-settings__radios", + attrs: { role: "radiogroup", "aria-label": L("App_Settings_Wheel") }, + }); + for (const [mode, key] of WHEEL) { + const input = el("input", { + attrs: { type: "radio", name: "app-settings-wheel", value: mode }, + }); + input.checked = draft.wheelZoom === mode; + input.addEventListener("change", () => (draft.wheelZoom = mode)); + group.append(el("label", { children: [input, L(key)] })); + } + return el("div", { + children: [ + el("h4", { text: L("App_Settings_Wheel") }), + group, + el("h4", { text: L("App_Settings_Rules") }), + el("ul", { children: MOUSE_RULES.map((key) => el("li", { text: L(key) })) }), + ], + }); +} + +function keysTab(): HTMLElement { + const rows = KEY_ROWS.map(([place, keys]) => + el("tr", { children: [el("th", { text: L(place) }), el("td", { text: L(keys) })] }), + ); + return el("table", { + className: "app-settings__table", + children: [el("tbody", { children: rows })], + }); +} + +/** ☰ [설정] — 창을 열고 지금 설정을 사본으로 고침 */ +export function openAppSettings(): void { + const draft = normalize(inputPrefs()); + let saved = snapshot(draft); + const cad = buildCadAliasTab(draft.cadAliases); + const panels: Record = { + mouse: mouseTab(draft), + cad: cad.root, + keys: keysTab(), + }; + const tabs = el("div", { + className: "app-settings__tabs ui-subtabs", + attrs: { role: "tablist" }, + }); + const show = (id: TabId): void => { + tabs.replaceChildren( + ...TABS.map(([tab, key]) => { + const button = createButton({ + label: L(key), + variant: tab === id ? "filled" : "ghost", + size: "sm", + onClick: () => show(tab), + }); + button.setAttribute("role", "tab"); + button.setAttribute("aria-selected", String(tab === id)); + button.dataset.settingsTab = tab; + return button; + }), + ); + for (const [tab, panel] of Object.entries(panels)) panel.hidden = tab !== id; + }; + + /** [저장] — 잘못 있는 별칭이면 CAD 탭을 보이고 안 씀 */ + 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; + } + 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", + size: "sm", + onClick: () => void save(), + }); + 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)); + }, + }); +} diff --git a/A00_Common/app_settings_cad.ts b/A00_Common/app_settings_cad.ts new file mode 100644 index 000000000..217623f03 --- /dev/null +++ b/A00_Common/app_settings_cad.ts @@ -0,0 +1,118 @@ +/* ============================================================================= + * app_settings_cad.ts + * ☰ [설정] ② CAD 단축키 탭(PLAN 332-2) — 명령 표(명령 · 한글 이름 · 기본 별칭 · 내 별칭) · 검사. + * + * - 명령 목록 = `openwebcad/src/commands/command-catalog.json`(registry 거울 · commands.*.ts 는 xstate 가 딸려 와서 JSON 한 벌) + * - acad.pgp 식 덮어쓰기 — CAD 는 명령 이름 > 내 별칭 > 기본 별칭 순서로 찾음(배선은 332-3) + * - 검사 = 영문 · 숫자만 · 내 별칭끼리 겹침 금지 · 명령 이름과 같은 별칭 금지 + * ========================================================================== */ + +import catalog from "../B07_DesignDetail/openwebcad/src/commands/command-catalog.json"; +import { el } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; + +export interface CadCommandRow { + id: string; + label: string; + aliases: string[]; +} + +export const CAD_COMMANDS = catalog as CadCommandRow[]; + +export type AliasError = "chars" | "dup" | "name"; + +const ERROR_TEXT: Record[0]> = { + chars: "App_Settings_Cad_ErrChars", + dup: "App_Settings_Cad_ErrDup", + name: "App_Settings_Cad_ErrName", +}; + +/** 칸 글 → 별칭 목록(쉼표 · 빈칸으로 가름 · 대문자) */ +export const parseAliases = (text: string): string[] => + text + .toUpperCase() + .split(/[\s,]+/) + .filter(Boolean); + +/** 내 별칭 검사 — `{명령: 첫 잘못}` · 비면 통과 */ +export function checkCadAliases( + aliases: Record, + commands: CadCommandRow[] = CAD_COMMANDS, +): Record { + const ids = new Set(commands.map((c) => c.id)); + const owner = new Map(); + const errors: Record = {}; + for (const [id, list] of Object.entries(aliases)) { + for (const alias of list) { + const first = owner.get(alias); + const error: AliasError | null = !/^[A-Z0-9]+$/.test(alias) + ? "chars" + : ids.has(alias) + ? "name" + : first !== undefined + ? "dup" + : null; + if (!error) owner.set(alias, id); + else { + errors[id] ??= error; + if (first !== undefined) errors[first] ??= "dup"; + } + } + } + return errors; +} + +/** 탭 몸 — `aliases` 를 칸 입력대로 고쳐 씀 · `mark` 로 잘못 칸 표시 */ +export function buildCadAliasTab(aliases: Record): { + root: HTMLElement; + mark: (errors: Record) => void; +} { + const message = el("p", { className: "app-settings__error", attrs: { role: "alert" } }); + const inputs = new Map(); + const rows = CAD_COMMANDS.map((command) => { + const input = el("input", { + className: "ui-input ui-input--sm", + attrs: { type: "text", "aria-label": `${command.id} ${L("App_Settings_Cad_Mine")}` }, + }); + input.value = (aliases[command.id] ?? []).join(", "); + input.dataset.cadCommand = command.id; + input.addEventListener("input", () => { + const list = parseAliases(input.value); + if (list.length) aliases[command.id] = list; + else delete aliases[command.id]; + }); + inputs.set(command.id, input); + const cells = [command.id, command.label, command.aliases.join(", ")].map((text) => + el("td", { text }), + ); + return el("tr", { children: [...cells, el("td", { children: [input] })] }); + }); + const head = el("tr", { + children: ( + [ + "App_Settings_Cad_Command", + "App_Settings_Cad_Label", + "App_Settings_Cad_Default", + "App_Settings_Cad_Mine", + ] as const + ).map((key) => el("th", { text: L(key) })), + }); + const table = el("table", { + className: "app-settings__table", + children: [el("thead", { children: [head] }), el("tbody", { children: rows })], + }); + const mark = (errors: Record): void => { + for (const [id, input] of inputs) { + const error = errors[id]; + if (error) input.setAttribute("aria-invalid", "true"); + else input.removeAttribute("aria-invalid"); + input.title = error ? L(ERROR_TEXT[error]) : ""; + } + const kinds = [...new Set(Object.values(errors))]; + message.textContent = kinds.map((k) => L(ERROR_TEXT[k])).join(" · "); + }; + const root = el("div", { + children: [el("p", { text: L("App_Settings_Cad_Help") }), message, table], + }); + return { root, mark }; +} diff --git a/A00_Common/app_shell.ts b/A00_Common/app_shell.ts index 13aec256f..8f257690f 100644 --- a/A00_Common/app_shell.ts +++ b/A00_Common/app_shell.ts @@ -171,10 +171,14 @@ function buildDrawer(header: HTMLElement): HTMLElement[] { burger.addEventListener("click", () => setOpen(!header.classList.contains("is-drawer-open"))); scrim.addEventListener("click", () => setOpen(false)); header.addEventListener("keydown", (e) => e.key === "Escape" && setOpen(false)); // ☰ 에 포커스가 있어도 닫힘 - // 로고 · 진행 단계 · 메뉴 · 로그인 항목을 누르면 닫힘(언어 · 테마 · 스위치는 그대로 둠) + // 로고 · 진행 단계 · 메뉴 · 로그인 · [설정] 을 누르면 닫힘(언어 · 테마 · 스위치는 그대로 둠) drawer.addEventListener("click", (e) => { const hit = (e.target as Element).closest("button"); - if (hit?.closest(".app-drawer__brand, .app-drawer__steps, .app-drawer__nav, .app-drawer__auth")) + if ( + hit?.closest( + ".app-drawer__brand, .app-drawer__steps, .app-drawer__nav, .app-drawer__auth, .app-drawer__settings", + ) + ) setOpen(false); }); @@ -199,6 +203,14 @@ function buildDrawer(header: HTMLElement): HTMLElement[] { onClick: onApp_Theme_Toggle_Click, }), ); + // ☰ [설정](332) — 마우스 · 단축키 창 · 처음 누를 때 불러옴 + const settingsButton = createButton({ + label: L("App_Settings"), + variant: "pill", + onClick: () => void import("./app_settings").then((m) => m.openAppSettings()), + }); + settingsButton.classList.add("app-drawer__settings"); + prefs.append(settingsButton); const auth = document.createElement("div"); auth.className = "app-drawer__row app-drawer__auth"; void fillAuthActions(auth); diff --git a/B07_DesignDetail/openwebcad/src/commands/command-catalog.json b/B07_DesignDetail/openwebcad/src/commands/command-catalog.json new file mode 100644 index 000000000..b430c9d19 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/commands/command-catalog.json @@ -0,0 +1,155 @@ +[ + {"id": "LINE", "label": "선", "aliases": ["L"]}, + {"id": "PLINE", "label": "폴리선", "aliases": ["PL"]}, + {"id": "CIRCLE", "label": "원", "aliases": ["C"]}, + {"id": "ARC", "label": "호", "aliases": ["A"]}, + {"id": "RECTANG", "label": "직사각형", "aliases": ["REC"]}, + {"id": "POLYGON", "label": "다각형", "aliases": ["POL"]}, + {"id": "ELLIPSE", "label": "타원", "aliases": ["EL"]}, + {"id": "SPLINE", "label": "스플라인", "aliases": ["SPL"]}, + {"id": "MLINE", "label": "다중선", "aliases": ["ML"]}, + {"id": "MLSTYLE", "label": "다중선 스타일", "aliases": []}, + {"id": "XLINE", "label": "구성선", "aliases": ["XL"]}, + {"id": "RAY", "label": "광선", "aliases": []}, + {"id": "POINT", "label": "점", "aliases": ["PO"]}, + {"id": "DONUT", "label": "도넛", "aliases": ["DO"]}, + {"id": "DIVIDE", "label": "등분", "aliases": ["DIV"]}, + {"id": "MEASURE", "label": "길이분할", "aliases": ["ME"]}, + {"id": "HATCH", "label": "해치", "aliases": ["H", "BH"]}, + {"id": "GRADIENT", "label": "그라데이션", "aliases": ["GD"]}, + {"id": "BOUNDARY", "label": "경계", "aliases": ["BO"]}, + {"id": "REGION", "label": "영역", "aliases": ["REG"]}, + {"id": "WIPEOUT", "label": "와이프아웃", "aliases": []}, + {"id": "MOVE", "label": "이동", "aliases": ["M"]}, + {"id": "COPY", "label": "복사", "aliases": ["CO", "CP"]}, + {"id": "ROTATE", "label": "회전", "aliases": ["RO"]}, + {"id": "SCALE", "label": "축척", "aliases": ["SC"]}, + {"id": "ALIGN", "label": "정렬", "aliases": ["AL"]}, + {"id": "MIRROR", "label": "대칭", "aliases": ["MI"]}, + {"id": "OFFSET", "label": "간격띄우기", "aliases": ["O"]}, + {"id": "ARRAY", "label": "배열", "aliases": ["AR"]}, + {"id": "TRIM", "label": "자르기", "aliases": ["TR"]}, + {"id": "EXTEND", "label": "연장", "aliases": ["EX"]}, + {"id": "STRETCH", "label": "신축", "aliases": ["S"]}, + {"id": "FILLET", "label": "모깎기", "aliases": ["F"]}, + {"id": "CHAMFER", "label": "모따기", "aliases": ["CHA"]}, + {"id": "BLEND", "label": "곡선 혼합", "aliases": []}, + {"id": "BREAK", "label": "끊기", "aliases": ["BR"]}, + {"id": "BREAKATPOINT", "label": "점에서 끊기", "aliases": []}, + {"id": "JOIN", "label": "결합", "aliases": ["J"]}, + {"id": "EXPLODE", "label": "분해", "aliases": ["X"]}, + {"id": "ERASE", "label": "지우기", "aliases": ["E"]}, + {"id": "LENGTHEN", "label": "길이조정", "aliases": ["LEN"]}, + {"id": "PEDIT", "label": "폴리선 편집", "aliases": ["PE"]}, + {"id": "HATCHEDIT", "label": "해치 편집", "aliases": ["HE"]}, + {"id": "DRAWORDER", "label": "맨 앞으로", "aliases": ["DR"]}, + {"id": "DRAWORDERBACK", "label": "맨 뒤로", "aliases": []}, + {"id": "MATCHPROP", "label": "특성 일치", "aliases": ["MA"]}, + {"id": "OVERKILL", "label": "중복 객체 삭제", "aliases": []}, + {"id": "REVERSE", "label": "방향 반전", "aliases": []}, + {"id": "SETBYLAYER", "label": "ByLayer로", "aliases": []}, + {"id": "ALIGNLEFT", "label": "왼쪽", "aliases": []}, + {"id": "ALIGNCENTERH", "label": "가로 중앙", "aliases": []}, + {"id": "ALIGNRIGHT", "label": "오른쪽", "aliases": []}, + {"id": "ALIGNTOP", "label": "위", "aliases": []}, + {"id": "ALIGNCENTERV", "label": "세로 중앙", "aliases": []}, + {"id": "ALIGNBOTTOM", "label": "아래", "aliases": []}, + {"id": "MTEXT", "label": "여러 줄 문자", "aliases": ["T", "MT"]}, + {"id": "TEXT", "label": "단일 행 문자", "aliases": ["DT"]}, + {"id": "STYLE", "label": "문자 스타일", "aliases": ["ST"]}, + {"id": "TEXTEDIT", "label": "문자 편집", "aliases": ["TEDIT"]}, + {"id": "FIND", "label": "찾기·대치", "aliases": []}, + {"id": "DIM", "label": "치수", "aliases": []}, + {"id": "DIMLINEAR", "label": "선형 치수", "aliases": ["DLI"]}, + {"id": "DIMALIGNED", "label": "정렬 치수", "aliases": ["DAL"]}, + {"id": "DIMANGULAR", "label": "각도 치수", "aliases": ["DAN"]}, + {"id": "DIMARC", "label": "호 길이 치수", "aliases": ["DAR"]}, + {"id": "DIMRADIUS", "label": "반지름 치수", "aliases": ["DRA"]}, + {"id": "DIMDIAMETER", "label": "지름 치수", "aliases": ["DDI"]}, + {"id": "DIMORDINATE", "label": "세로좌표 치수", "aliases": ["DOR"]}, + {"id": "DIMBASELINE", "label": "기준선 치수", "aliases": ["DBA"]}, + {"id": "DIMCONTINUE", "label": "연속 치수", "aliases": ["DCO"]}, + {"id": "QDIM", "label": "빠른 치수", "aliases": []}, + {"id": "CENTERMARK", "label": "중심 표식", "aliases": []}, + {"id": "CENTERLINE", "label": "중심선", "aliases": []}, + {"id": "DIMSTYLE", "label": "치수 스타일", "aliases": ["D"]}, + {"id": "DIMUPDATE", "label": "치수 업데이트", "aliases": []}, + {"id": "DIMSPACE", "label": "치수 간격", "aliases": []}, + {"id": "MLEADER", "label": "다중 지시선", "aliases": ["MLD"]}, + {"id": "MLEADERSTYLE", "label": "지시선 스타일", "aliases": ["MLS"]}, + {"id": "MLEADERALIGN", "label": "지시선 정렬", "aliases": []}, + {"id": "TABLE", "label": "테이블", "aliases": ["TB"]}, + {"id": "TABLESTYLE", "label": "테이블 스타일", "aliases": ["TS"]}, + {"id": "TABLEEDIT", "label": "칸 편집", "aliases": []}, + {"id": "TABLEROW", "label": "행 넣기·지우기", "aliases": []}, + {"id": "TABLECOL", "label": "열 넣기·지우기", "aliases": []}, + {"id": "TABLEMERGE", "label": "칸 병합", "aliases": []}, + {"id": "TABLEUNMERGE", "label": "병합 해제", "aliases": []}, + {"id": "PLACETABLE", "label": "표 놓기", "aliases": []}, + {"id": "REVCLOUD", "label": "구름형 리비전", "aliases": []}, + {"id": "ANNOSCALE", "label": "주석 축척", "aliases": []}, + {"id": "LAYER", "label": "도면층 특성", "aliases": ["LA"]}, + {"id": "LAYCURSET", "label": "현재 도면층 설정", "aliases": []}, + {"id": "LAYOFF", "label": "도면층 끄기", "aliases": []}, + {"id": "LAYON", "label": "모두 켜기", "aliases": []}, + {"id": "LAYFRZ", "label": "동결", "aliases": []}, + {"id": "LAYTHW", "label": "동결 해제", "aliases": []}, + {"id": "LAYLCK", "label": "잠금", "aliases": []}, + {"id": "LAYULK", "label": "잠금 해제", "aliases": []}, + {"id": "LAYISO", "label": "분리", "aliases": []}, + {"id": "LAYUNISO", "label": "분리 해제", "aliases": []}, + {"id": "LAYERP", "label": "이전 상태", "aliases": []}, + {"id": "LAYERSTATE", "label": "도면층 상태 저장", "aliases": ["LAS"]}, + {"id": "LAYERSTATERESTORE", "label": "도면층 상태 복원", "aliases": []}, + {"id": "LAYMCH", "label": "도면층 일치", "aliases": []}, + {"id": "LAYCUR", "label": "현재 도면층으로", "aliases": []}, + {"id": "LAYMRG", "label": "도면층 병합", "aliases": []}, + {"id": "LAYDEL", "label": "도면층 삭제", "aliases": []}, + {"id": "LAYWALK", "label": "도면층 탐색", "aliases": []}, + {"id": "COLOR", "label": "색상", "aliases": ["COL"]}, + {"id": "LINETYPE", "label": "선종류", "aliases": ["LT"]}, + {"id": "LWEIGHT", "label": "선가중치", "aliases": ["LW"]}, + {"id": "PROPERTIES", "label": "특성", "aliases": ["CH", "MO", "PR"]}, + {"id": "QUICKPROPERTIES", "label": "빠른 특성", "aliases": ["QP"]}, + {"id": "TRANSPARENCY", "label": "투명도", "aliases": []}, + {"id": "GROUP", "label": "그룹", "aliases": ["G"]}, + {"id": "UNGROUP", "label": "그룹 해제", "aliases": []}, + {"id": "DIST", "label": "거리", "aliases": ["DI"]}, + {"id": "RADIUS", "label": "반지름", "aliases": []}, + {"id": "ANGLE", "label": "각도", "aliases": []}, + {"id": "AREA", "label": "면적", "aliases": ["AA"]}, + {"id": "MEASUREGEOM", "label": "빠른 측정", "aliases": ["MEA"]}, + {"id": "ID", "label": "점 좌표", "aliases": []}, + {"id": "LIST", "label": "리스트", "aliases": ["LI"]}, + {"id": "QSELECT", "label": "빠른 선택", "aliases": []}, + {"id": "QUICKCALC", "label": "계산기", "aliases": ["QC"]}, + {"id": "SELECTSIMILAR", "label": "유사 선택", "aliases": []}, + {"id": "ISOLATEOBJECTS", "label": "객체 분리", "aliases": []}, + {"id": "HIDEOBJECTS", "label": "객체 숨기기", "aliases": []}, + {"id": "UNISOLATEOBJECTS", "label": "객체 분리 종료", "aliases": ["UNHIDE", "UNISOLATE"]}, + {"id": "COPYCLIP", "label": "복사", "aliases": []}, + {"id": "CUTCLIP", "label": "잘라내기", "aliases": []}, + {"id": "PASTECLIP", "label": "붙여넣기", "aliases": []}, + {"id": "COPYBASE", "label": "기준점 복사", "aliases": []}, + {"id": "PASTEORIG", "label": "원래 좌표로 붙여넣기", "aliases": []}, + {"id": "PASTEBLOCK", "label": "그룹으로 붙여넣기", "aliases": []}, + {"id": "IMAGEATTACH", "label": "이미지 부착", "aliases": ["IAT"]}, + {"id": "BLOCK", "label": "블록 작성", "aliases": ["B"]}, + {"id": "INSERT", "label": "블록 삽입", "aliases": ["I"]}, + {"id": "SELECT", "label": "선택", "aliases": ["SE"]}, + {"id": "ZOOM", "label": "범위 줌", "aliases": ["Z"]}, + {"id": "ZOOMIN", "label": "확대", "aliases": []}, + {"id": "ZOOMOUT", "label": "축소", "aliases": []}, + {"id": "REGEN", "label": "재생성", "aliases": ["RE"]}, + {"id": "PAN", "label": "초점이동", "aliases": ["P"]}, + {"id": "QSAVE", "label": "저장", "aliases": ["SAVE"]}, + {"id": "RECOVER", "label": "백업 복구", "aliases": []}, + {"id": "EXPORT", "label": "JSON 내보내기", "aliases": ["EXP"]}, + {"id": "EXPORTDXF", "label": "DXF 내보내기", "aliases": []}, + {"id": "EXPORTDWG", "label": "DWG 내보내기", "aliases": []}, + {"id": "EXPORTSVG", "label": "SVG 내보내기", "aliases": []}, + {"id": "EXPORTPNG", "label": "PNG 내보내기", "aliases": []}, + {"id": "TOAREA", "label": "면적산출로 보내기", "aliases": []}, + {"id": "UNDO", "label": "실행 취소", "aliases": ["U"]}, + {"id": "REDO", "label": "다시 실행", "aliases": []} +] diff --git a/resources/tester/cad/command-catalog.test.ts b/resources/tester/cad/command-catalog.test.ts new file mode 100644 index 000000000..69da00b2a --- /dev/null +++ b/resources/tester/cad/command-catalog.test.ts @@ -0,0 +1,28 @@ +/** + * CAD 명령 목록 거울(PLAN 332-2) — `commands/command-catalog.json` 이 registry(`commands.*.ts`)와 같은지. + * ☰ [설정] CAD 단축키 탭은 xstate 가 딸려 오지 않게 JSON 만 읽음 — 명령을 더하거나 별칭을 바꾸면 JSON 도 같이. + * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad command-catalog + */ +import { describe, expect, it } from "vitest"; + +import catalog from "../../../B07_DesignDetail/openwebcad/src/commands/command-catalog.json"; +import { getAllCommands } from "../../../B07_DesignDetail/openwebcad/src/commands/registry"; + +describe("command-catalog.json", () => { + it("registry 와 같은 명령 · 한글 이름 · 기본 별칭 · 차례", () => { + const live = getAllCommands().map((c) => ({ + id: c.id, + label: c.label, + aliases: c.aliases ?? [], + })); + expect(catalog).toEqual(live); + }); + + it("기본 별칭 = 영문 · 숫자 · 안 겹침 · 명령 이름과 다름", () => { + const ids = new Set(catalog.map((c) => c.id)); + const aliases = catalog.flatMap((c) => c.aliases); + expect(aliases.every((a) => /^[A-Z0-9]+$/.test(a))).toBe(true); + expect(new Set(aliases).size).toBe(aliases.length); + expect(aliases.filter((a) => ids.has(a))).toEqual([]); + }); +}); diff --git a/resources/tester/helper_app_settings.cjs b/resources/tester/helper_app_settings.cjs new file mode 100644 index 000000000..7c2fb6357 --- /dev/null +++ b/resources/tester/helper_app_settings.cjs @@ -0,0 +1,36 @@ +/* PLAN 332-2 단위검증 헬퍼 — ☰ [설정] CAD 별칭 검사(`A00_Common/app_settings_cad.ts`)를 Node 로 돌림. + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_app_settings.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 file = path.join(ROOT, "A00_Common", "app_settings_cad.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, +}).outputText; +const box = { exports: {} }; +const req = (p) => + p.endsWith(".json") + ? require(path.join(ROOT, "A00_Common", p)) + : p === "@ui/ui_template_locale" + ? { t: (k) => k } + : {}; +new Function("exports", "module", "require", js)(box.exports, box, req); +const m = box.exports; + +const check = (aliases) => m.checkCadAliases(aliases); +process.stdout.write( + JSON.stringify({ + commands: m.CAD_COMMANDS.length, + parse: [m.parseAliases(" ll, Cc x1 "), m.parseAliases(""), m.parseAliases(",, ")], + ok: check({ LINE: ["LL"], CIRCLE: ["CC", "C2"], COPY: ["C"] }), + chars: check({ LINE: ["L!"], CIRCLE: ["원"] }), + dup: check({ LINE: ["LL"], CIRCLE: ["LL"], ARC: ["AA", "AA"] }), + name: check({ CIRCLE: ["LINE"] }), + }), +); diff --git a/resources/tester/test_app_settings.py b/resources/tester/test_app_settings.py new file mode 100644 index 000000000..f78cab044 --- /dev/null +++ b/resources/tester/test_app_settings.py @@ -0,0 +1,187 @@ +"""PLAN 332-2 — ☰ [설정] 창(`A00_Common/app_settings.ts` · `app_settings_cad.ts` · `app_shell.ts`). + +① CAD 별칭 검사(Node) — 영문 · 숫자만 · 겹침 금지 · 명령 이름 금지 · 칸 글 나눔 +② 실제 브라우저(sub2 화면 서버 5177 · 로그인 없음 = 이 브라우저만 · 새 창이라 남는 것 없음) + ☰ → [설정] = 서랍 닫히고 창 · 탭 셋 전환 · 고쳐도 [저장] 전엔 안 씀 · 닫을 때 물음(취소 = 머묾 · + 버림 = 안 씀 · 저장 = 한 번) · [저장] 한 번 = 한 번 씀 · 저장 뒤 읽기 층 방향 뒤집힘 · 잘못 별칭은 안 씀 +""" + +from __future__ import annotations + +import json +import os +import subprocess +import urllib.request + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") +BASE = "http://localhost:5177" # sub2 화면 서버(OWNERS 자기 줄) +KEY = "aislo:pref:input-prefs" + +# 앱 스크립트보다 먼저 — 설정 키에 쓰는 횟수를 셈 +COUNT_WRITES = f""" +window.__writes = []; +const set = Storage.prototype.setItem; +Storage.prototype.setItem = function (k, v) {{ + if (k === {json.dumps(KEY)}) window.__writes.push(v); + return set.call(this, k, v); +}}; +""" +# 앱이 불러온 그 읽기 층 사본(vite 가 ?t= 를 붙였을 수 있음) +WHEEL_IN = ( + "async (d) => (await import(performance.getEntriesByType('resource')" + ".map((e) => e.name).find((n) => n.includes('/input_prefs.ts')))).wheelIn(d)" +) + + +@pytest.fixture(scope="module") +def node() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_app_settings.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_별칭_검사(node): + assert node["commands"] == 153 + assert node["parse"] == [["LL", "CC", "X1"], [], []] + assert node["ok"] == {} # 남의 기본 별칭(C = CIRCLE)을 내 것으로 덮는 건 됨(acad.pgp 식) + assert node["chars"] == {"LINE": "chars", "CIRCLE": "chars"} + assert node["dup"] == {"LINE": "dup", "CIRCLE": "dup", "ARC": "dup"} + assert node["name"] == {"CIRCLE": "name"} + + +def _up() -> bool: + try: + urllib.request.urlopen(BASE, timeout=3) + return True + except Exception: + return False + + +def _open_settings(page): + page.click(".app-burger") + page.click(".app-drawer__settings") + page.wait_for_selector(".app-settings") + assert not page.evaluate( + "document.querySelector('.app-header, header')?.classList.contains('is-drawer-open')" + ) + + +def _writes(page) -> list: + return page.evaluate("window.__writes") + + +def _visible_panel_has(page, selector: str) -> bool: + return page.evaluate( + "(s) => [...document.querySelectorAll('.app-settings .ui-modal__body > div:not([role]), " + ".app-settings .ui-modal__body > table')].some((p) => !p.hidden && p.querySelector(s))", + selector, + ) + + +def _save(page): + page.click(".app-settings .ui-modal__actions button") + + +def _close(page): + page.click(".app-settings .ui-modal__top > button") + + +@pytest.mark.skipif(not _up(), reason="sub2 화면 서버(5177) 꺼짐") +def test_설정_창_탭_저장_물음(): + from playwright.sync_api import sync_playwright + + with sync_playwright() as p: + browser = p.chromium.launch(channel="msedge", headless=True) + ctx = browser.new_context( + viewport={"width": 390, "height": 844}, has_touch=True, is_mobile=True + ) + ctx.add_init_script(COUNT_WRITES) + page = ctx.new_page() + page.goto(f"{BASE}/#/a01-home") + page.wait_for_selector(".app-burger") + assert page.evaluate(WHEEL_IN, 100) is True # 기본 = 당김 + + # ☰ → [설정] · 탭 셋 · 첫 탭 = 마우스 + _open_settings(page) + tabs = page.evaluate( + "[...document.querySelectorAll('.app-settings [role=tab]')]" + ".map((b) => [b.dataset.settingsTab, b.getAttribute('aria-selected')])" + ) + assert tabs == [["mouse", "true"], ["cad", "false"], ["keys", "false"]] + assert _visible_panel_has(page, "input[type=radio]") + page.click("[data-settings-tab=cad]") + assert _visible_panel_has(page, "input[data-cad-command]") + assert not _visible_panel_has(page, "input[type=radio]") + assert page.locator(".app-settings input[data-cad-command]").count() == 153 + page.click("[data-settings-tab=keys]") + assert ( + page.evaluate( + "document.querySelector('[data-settings-tab=keys]').getAttribute('aria-selected')" + ) + == "true" + ) + page.click("[data-settings-tab=mouse]") + + # 밀기 고름 — [저장] 전엔 안 씀 · 닫으면 물음 · 취소 = 머묾 + page.check("input[name=app-settings-wheel][value=push]") + assert _writes(page) == [] + page.keyboard.press("Escape") + page.wait_for_selector(".ui-confirm") + page.click(".ui-confirm button >> nth=0") # 취소 + assert page.locator(".app-settings").count() == 1 and _writes(page) == [] + + # [저장] = 한 번 씀 · 읽기 층 방향 뒤집힘 · 닫을 때 안 물음 + _save(page) + assert len(_writes(page)) == 1 + assert json.loads(_writes(page)[0]) == {"wheelZoom": "push", "cadAliases": {}} + assert page.evaluate(WHEEL_IN, 100) is False and page.evaluate(WHEEL_IN, -100) is True + _save(page) # 고친 것 없음 — 또 안 씀 + assert len(_writes(page)) == 1 + _close(page) + assert page.locator(".app-settings").count() == 0 + assert page.locator(".ui-confirm").count() == 0 + + # CAD 별칭 — 잘못이면 안 씀 · 칸 표시 + _open_settings(page) + assert page.is_checked("input[name=app-settings-wheel][value=push]") # 저장값으로 엶 + page.click("[data-settings-tab=cad]") + line = "input[data-cad-command=LINE]" + circle = "input[data-cad-command=CIRCLE]" + for line_v, circle_v in (("L!", ""), ("LL", "ll"), ("", "LINE")): + page.fill(line, line_v) + page.fill(circle, circle_v) + _save(page) + assert page.inner_text(".app-settings__error").strip(), (line_v, circle_v) + assert page.locator(".app-settings input[aria-invalid=true]").count() >= 1 + assert len(_writes(page)) == 1 + page.fill(line, "ll") + page.fill(circle, "") + _save(page) + assert page.inner_text(".app-settings__error").strip() == "" + assert len(_writes(page)) == 2 + assert json.loads(_writes(page)[1]) == {"wheelZoom": "push", "cadAliases": {"LINE": ["LL"]}} + + # 닫을 때 물음 — 버림 = 안 씀 · 저장 = 한 번 더 + page.click("[data-settings-tab=mouse]") + page.check("input[name=app-settings-wheel][value=pull]") + _close(page) + page.click(".ui-confirm button >> nth=1") # 버리고 넘어감 + assert page.locator(".app-settings").count() == 0 and len(_writes(page)) == 2 + assert page.evaluate(WHEEL_IN, 100) is False + _open_settings(page) + page.check("input[name=app-settings-wheel][value=pull]") + _close(page) + page.click(".ui-confirm button >> nth=2") # 저장 후 넘어감 + assert page.locator(".app-settings").count() == 0 and len(_writes(page)) == 3 + assert json.loads(_writes(page)[2])["wheelZoom"] == "pull" + assert page.evaluate(WHEEL_IN, 100) is True + browser.close() diff --git a/resources/tester/test_device_audit.py b/resources/tester/test_device_audit.py index dae54241d..aaf6d0404 100644 --- a/resources/tester/test_device_audit.py +++ b/resources/tester/test_device_audit.py @@ -12,14 +12,16 @@ sub1 서버(5174)에서 모든 페이지를 네 크기로 열어 문제 수를 """ import json +import os import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] -BASE = "http://localhost:5174" -PROJECT_NAME = "검증_sub1" +# 다른 창 서버로 돌릴 때 — AISLO_UI_BASE · AISLO_UI_PROJECT(OWNERS 자기 줄) +BASE = os.environ.get("AISLO_UI_BASE", "http://localhost:5174") +PROJECT_NAME = os.environ.get("AISLO_UI_PROJECT", "검증_sub1") PAGES = [ "a01-home", "a02-prog-detail", diff --git a/resources/tester/test_touch_bridge.py b/resources/tester/test_touch_bridge.py index a56c75620..d6c6b4f88 100644 --- a/resources/tester/test_touch_bridge.py +++ b/resources/tester/test_touch_bridge.py @@ -8,11 +8,12 @@ sub1 화면 서버(5174) 첫 화면을 터치 기기로 열고 시험용 상자 """ import json +import os import urllib.request import pytest -BASE = "http://localhost:5174" +BASE = os.environ.get("AISLO_UI_BASE", "http://localhost:5174") # 다른 창 서버 = 환경값 def _up() -> bool: diff --git a/ui_template/ui_template_locale_common.ts b/ui_template/ui_template_locale_common.ts index ed38a2035..f78b3f6c8 100644 --- a/ui_template/ui_template_locale_common.ts +++ b/ui_template/ui_template_locale_common.ts @@ -109,6 +109,74 @@ export const ui_locales_common = { "그림 위를 길게 누르면 메뉴 · 두 손가락으로 확대", "Long-press the drawing for the menu · pinch to zoom", ], + /* ☰ [설정] 창(332) — 마우스 · CAD 단축키 · 다른 곳 단축키 */ + App_Settings: ["설정", "Settings"], + App_Settings_Tab_Mouse: ["마우스", "Mouse"], + App_Settings_Tab_Cad: ["CAD 단축키", "CAD shortcuts"], + App_Settings_Tab_Keys: ["다른 곳 단축키", "Other shortcuts"], + 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_Rules: ["마우스 단추", "Mouse buttons"], + App_Settings_Rule_Pan: ["화면 옮기기 = 가운데 단추 끌기", "Pan = drag with the middle button"], + App_Settings_Rule_Pick: ["고르기 = 왼쪽 단추", "Select = left button"], + App_Settings_Rule_Right: [ + "오른쪽 단추 = 메뉴 · CAD 에서는 Enter", + "Right button = menu · Enter in CAD", + ], + App_Settings_Rule_Pinch: [ + "트랙패드 · 손가락 벌림 = 늘 확대(휠 방향과 상관없음)", + "Trackpad · finger spread = always zoom in (whatever the wheel direction)", + ], + App_Settings_Cad_Help: [ + "CAD 는 명령 이름 > 내 별칭 > 기본 별칭 차례로 찾음 · 영문 · 숫자만 · 여러 개는 쉼표로", + "CAD looks up command name > my alias > default alias · letters and digits only · comma for several", + ], + App_Settings_Cad_Command: ["명령", "Command"], + App_Settings_Cad_Label: ["이름", "Name"], + App_Settings_Cad_Default: ["기본 별칭", "Default alias"], + App_Settings_Cad_Mine: ["내 별칭", "My alias"], + App_Settings_Cad_ErrChars: ["영문 · 숫자만 씀", "Letters and digits only"], + App_Settings_Cad_ErrDup: ["같은 별칭이 두 번 있음", "Same alias used twice"], + App_Settings_Cad_ErrName: ["명령 이름과 같은 별칭은 못 씀", "Alias cannot be a command name"], + 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 = ☰ · 창 닫기 · 고름 풀기 · Z 누른 채 또는 오른쪽 0.4초 누름 = 돋보기(휠 = 배율)", + "Esc = ☰ · close window · clear selection · hold Z or right button 0.4 s = loupe (wheel = zoom)", + ], + App_Settings_Keys_Profile: ["B05 종단", "B05 profile"], + App_Settings_Keys_Profile_Keys: [ + "Ctrl+Z 되돌림 · Ctrl+Shift+Z 다시 · 방향키 = 옮김 · 자유지정 Enter = 끝 · Esc · Backspace", + "Ctrl+Z undo · Ctrl+Shift+Z redo · arrows = move · free mode Enter = finish · Esc · Backspace", + ], + App_Settings_Keys_RouteEdit: ["B05 노선 편집", "B05 route edit"], + App_Settings_Keys_RouteEdit_Keys: [ + "Ctrl+Z 되돌림 · Ctrl+Y · Ctrl+Shift+Z 다시", + "Ctrl+Z undo · Ctrl+Y · Ctrl+Shift+Z redo", + ], + App_Settings_Keys_Section: ["B06 횡단", "B06 cross section"], + App_Settings_Keys_Section_Keys: [ + "방향키 · ▲▼ = 측점 옮김 · Enter · Space = 고름 · Esc", + "Arrows · ▲▼ = move station · Enter · Space = pick · Esc", + ], + App_Settings_Keys_Cad: ["CAD", "CAD"], + App_Settings_Keys_Cad_Keys: [ + "F3 스냅 · F7 그리드 · F8 직교 · F10 45° · Ctrl+Z · Ctrl+Y · Ctrl+A · Ctrl+S(M02) · Delete · Esc · Enter · Space = Enter · 방향키 = 화면 옮김 · 글자 = 명령", + "F3 snap · F7 grid · F8 ortho · F10 45° · Ctrl+Z · Ctrl+Y · Ctrl+A · Ctrl+S (M02) · Delete · Esc · Enter · Space = Enter · arrows = pan · letters = command", + ], + App_Settings_Keys_Table: ["B08 · B09 표", "B08 · B09 tables"], + App_Settings_Keys_Table_Keys: [ + "Enter · F2 = 입력 · 방향키 = 칸 옮김 · Esc = 그만", + "Enter · F2 = edit · arrows = move cell · Esc = cancel", + ], + App_Settings_Keys_M02: ["M02 양식", "M02 templates"], + App_Settings_Keys_M02_Keys: [ + "Ctrl+S 저장 · Ctrl+C · Ctrl+V · Delete · Esc · Space+끌기 = 화면 옮김 · Ctrl+휠 = 배율 · 시트 = 엑셀 키", + "Ctrl+S save · Ctrl+C · Ctrl+V · Delete · Esc · Space+drag = pan · Ctrl+wheel = zoom · sheet = Excel keys", + ], App_Footer_Copyright: [ "© 2026 임도설계 및 견적 자동화. All rights reserved.", "© 2026 Forest Road Design & Estimation. All rights reserved.",