From f8fff70d56b8e8fd86b42150f229abb87bf908a1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 15:32:58 +0900 Subject: [PATCH] =?UTF-8?q?fix(=EB=8B=A8=EC=B6=95=ED=82=A4):=20=EC=A0=84?= =?UTF-8?q?=EC=97=AD=20Ctrl=20=EB=8B=A8=EC=B6=95=ED=82=A4=20=ED=95=9C?= =?UTF-8?q?=EA=B8=80=20=EC=9E=90=ED=8C=90=20=EB=8C=80=EC=9D=91=20=C2=B7=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=ED=8C=90=EC=A0=95=20=ED=95=A8=EC=88=98(21?= =?UTF-8?q?4-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A00_Common/key_shortcut.ts 새로 — 영문이면 글자 · 아니면 자판 자리(code) · CAD 와 페이지가 같이 씀 - CAD input-shortcut.ts 는 공용을 다시 내보냄 · tsconfig.app.json 에 공용 파일 포함 - B05 종단 Ctrl+Z · 노선 편집 Ctrl+Z · Y · M02 흐름도 Ctrl+Z · 카드 Ctrl+S · 나무 Ctrl+C · V · 시트 Ctrl+글자 - 시트 키는 한글 조합 중에도 Ctrl+글자 판정 - 새 시험 ctrl-shortcut-hangul · ctrl-shortcut-sheet Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- A00_Common/key_shortcut.ts | 49 +++++++++++++++ A00_Common/spreadsheet/spreadsheet_keys.ts | 6 +- .../B05_Profile_UI_Profile_Panel_Tools.ts | 3 +- .../B05_Profile_UI_RouteEdit_History.ts | 4 +- .../src/inputController/input-shortcut.ts | 30 +++------- B07_DesignDetail/openwebcad/tsconfig.app.json | 2 +- M02_MasterTemplete/DataFlow/M02_DF_Panel.ts | 5 +- .../M02_MasterTemplete_CardShell.ts | 3 +- .../M02_MasterTemplete_TreePanel.ts | 7 ++- .../tester/cad/ctrl-shortcut-hangul.test.ts | 40 ++++++++++++- .../tester/cad/ctrl-shortcut-sheet.test.ts | 60 +++++++++++++++++++ resources/tester/cad/vitest.ui.config.ts | 8 +++ 12 files changed, 182 insertions(+), 35 deletions(-) create mode 100644 A00_Common/key_shortcut.ts create mode 100644 resources/tester/cad/ctrl-shortcut-sheet.test.ts create mode 100644 resources/tester/cad/vitest.ui.config.ts diff --git a/A00_Common/key_shortcut.ts b/A00_Common/key_shortcut.ts new file mode 100644 index 000000000..3957f90f7 --- /dev/null +++ b/A00_Common/key_shortcut.ts @@ -0,0 +1,49 @@ +/* ============================================================================= + * key_shortcut.ts — Ctrl(⌘)+글자 단축키 판정 공용(PLAN 214-2) + * 한글 자판은 `key` 가 「ㅋ」·「ㅛ」로 와 글자로는 안 맞음 — 영문 글자·숫자면 그 글자 · + * 아니면 자판 자리(`code`: KeyZ · Digit1)로 판정. Shift 의 대문자도 소문자로 맞춤. + * 쓰는 곳 — 페이지(B05 · M02 · 시트 키) + CAD(`B07_DesignDetail/openwebcad` 의 `input-shortcut.ts` 가 다시 내보냄). + * 시험 = `resources/tester/cad/ctrl-shortcut-hangul.test.ts` + * ========================================================================== */ + +type KeyLike = Pick & Partial>; + +/** 누른 글자·숫자(소문자) — 글자·숫자 자리가 아니면 빈 글 */ +export function keyLetter(evt: KeyLike): string { + const key = evt.key ?? ""; + if (/^[a-zA-Z0-9]$/.test(key)) return key.toLowerCase(); + const m = /^(?:Key([A-Z])|Digit([0-9]))$/.exec(evt.code ?? ""); + return m ? (m[1] ?? m[2]).toLowerCase() : ""; +} + +/** Ctrl(Mac ⌘)과 함께 누른 `letter`(소문자 글자·숫자)인가 — Shift · Alt 는 보지 않음 */ +export function isCtrlKey( + evt: KeyLike & Pick, + letter: string, +): boolean { + return (evt.ctrlKey || evt.metaKey) && keyLetter(evt) === letter; +} + +/** Ctrl 단축키 이름 */ +export type CtrlShortcut = "undo" | "redo" | "save" | "selectAll"; + +/** 공용 Ctrl 단축키 — Z · Shift+Z · Y · S · A (Alt 함께는 아님 · Shift 는 Z 만) */ +export function ctrlShortcut( + evt: KeyLike & + Pick & + Partial>, +): CtrlShortcut | null { + if (!(evt.ctrlKey || evt.metaKey) || evt.altKey) return null; + switch (keyLetter(evt)) { + case "z": + return evt.shiftKey ? "redo" : "undo"; + case "y": + return evt.shiftKey ? null : "redo"; + case "s": + return evt.shiftKey ? null : "save"; + case "a": + return evt.shiftKey ? null : "selectAll"; + default: + return null; + } +} diff --git a/A00_Common/spreadsheet/spreadsheet_keys.ts b/A00_Common/spreadsheet/spreadsheet_keys.ts index 829e128f6..8c6d1fb38 100644 --- a/A00_Common/spreadsheet/spreadsheet_keys.ts +++ b/A00_Common/spreadsheet/spreadsheet_keys.ts @@ -10,6 +10,7 @@ * 복사 · 붙여넣기(Ctrl+C/X/V)는 막지 않음 — E 클립보드가 copy · paste 사건으로 받음. * ========================================================================== */ +import { keyLetter } from "../key_shortcut"; import { cycleAbs } from "./spreadsheet_editor"; import { fillCells } from "./spreadsheet_fill"; import { @@ -127,7 +128,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks go(move(sheet, sel, arrow[0], arrow[1], { extend: e.shiftKey, jump: ctrl })); return true; } - const k = e.key.length === 1 ? e.key.toLowerCase() : e.key; + const k = keyLetter(e) || (e.key.length === 1 ? e.key.toLowerCase() : e.key); if (ctrl && !e.altKey) { switch (k) { case "z": @@ -233,7 +234,8 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks } function onKey(e: KeyboardEvent): void { - if (e.isComposing || e.keyCode === 229) return; // 한글 조합 중 — 브라우저 몫 + // 한글 조합 중 — 브라우저 몫 (Ctrl+글자 단축키는 조합 중에도 판정 · 한글 자판 대응 214-2) + if ((e.isComposing || e.keyCode === 229) && !((e.ctrlKey || e.metaKey) && keyLetter(e))) return; const inBar = e.target === ed.formula; const done = ed.editing() ? editingKey(e, inBar) : !inBar && navKey(e); if (done) { diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index d50323666..043b0094a 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -9,6 +9,7 @@ * 되돌리기 범위가 B05 조작 전부인 이유는 `B05_Profile_UI_Profile_History` 머리말 참조. * ========================================================================== */ +import { isCtrlKey } from "../A00_Common/key_shortcut"; import type { AlignmentBase, AlignmentEdits, @@ -283,7 +284,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools { // 입력칸 안에서는 방향키가 값 조작이므로 손대지 않는다. if (target && target.closest("input, select, textarea")) return; const key = event.key; - if ((event.ctrlKey || event.metaKey) && (key === "z" || key === "Z")) { + if (isCtrlKey(event, "z")) { event.preventDefault(); flushHold(); if (event.shiftKey) history.redo(); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts index fda74f078..d55eb6e18 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts @@ -10,6 +10,8 @@ * 쪼개면 되돌릴 때 어긋나기 쉽다. 노선 하나가 노드 수십 개라 사진 몇 벌은 가볍다. * ========================================================================== */ +import { keyLetter } from "../A00_Common/key_shortcut"; + export type Vertex = [number, number]; /** 되돌릴 수 있는 편집 상태 한 벌. */ @@ -154,7 +156,7 @@ export function bindHistoryControls(params: HistoryControlsParams): { const onKey = (event: KeyboardEvent): void => { if (!(event.ctrlKey || event.metaKey)) return; - const key = event.key.toLowerCase(); + const key = keyLetter(event); if (key === "z" && !event.shiftKey) step("undo"); else if (key === "y" || (key === "z" && event.shiftKey)) step("redo"); else return; diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-shortcut.ts b/B07_DesignDetail/openwebcad/src/inputController/input-shortcut.ts index c5efd82a0..aad585a8c 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-shortcut.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-shortcut.ts @@ -1,24 +1,10 @@ -/** Ctrl 단축키 이름 */ -export type CtrlShortcut = 'undo' | 'redo' | 'save' | 'selectAll'; - /** - * Ctrl 단축키 판정 — 글자(`key`)가 아니라 자판 자리(`code`). - * 한글 자판은 `key` 가 「ㅋ」·「ㅛ」로 오고, Shift 를 누르면 `key` 가 대문자(「Z」)라 글자로는 안 맞는다. + * Ctrl 단축키 판정 — 공용 `A00_Common/key_shortcut.ts`(214-2)를 그대로 내보냄. + * 한글 자판은 `key` 가 「ㅋ」·「ㅛ」로 와 글자로는 안 맞아 자판 자리(`code`)로 본다. */ -export function ctrlShortcut( - evt: Pick -): CtrlShortcut | null { - if (!evt.ctrlKey || evt.altKey) return null; - switch (evt.code) { - case 'KeyZ': - return evt.shiftKey ? 'redo' : 'undo'; - case 'KeyY': - return evt.shiftKey ? null : 'redo'; - case 'KeyS': - return evt.shiftKey ? null : 'save'; - case 'KeyA': - return evt.shiftKey ? null : 'selectAll'; - default: - return null; - } -} +export { + ctrlShortcut, + isCtrlKey, + keyLetter, + type CtrlShortcut, +} from '../../../../A00_Common/key_shortcut'; diff --git a/B07_DesignDetail/openwebcad/tsconfig.app.json b/B07_DesignDetail/openwebcad/tsconfig.app.json index 26a85a544..5ce3e3e8e 100644 --- a/B07_DesignDetail/openwebcad/tsconfig.app.json +++ b/B07_DesignDetail/openwebcad/tsconfig.app.json @@ -25,6 +25,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"], + "include": ["src", "../../A00_Common/key_shortcut.ts"], "exclude": ["node_modules"] } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts b/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts index 6462690e5..ec3901ac3 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Panel.ts @@ -8,6 +8,7 @@ * 고치는 핵심은 `M02_DF_Paper.ts`(판 DOM 을 옮겨 끼움). * ========================================================================== */ +import { isCtrlKey } from "../../A00_Common/key_shortcut"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; @@ -209,7 +210,7 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane const root = el("aside", { className: "m02-dfp", children: [grip, open, strip] }); root.addEventListener("keydown", (ev) => { - if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "z" && !ev.shiftKey) { + if (isCtrlKey(ev, "z") && !ev.shiftKey) { if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return; ev.preventDefault(); paper.undo(); @@ -258,7 +259,7 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane }); full.addEventListener("keydown", (ev) => { if (ev.key === "Escape" && !ev.defaultPrevented) shrink(); - if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "z" && !ev.shiftKey) { + if (isCtrlKey(ev, "z") && !ev.shiftKey) { if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return; ev.preventDefault(); paper.undo(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_CardShell.ts b/M02_MasterTemplete/M02_MasterTemplete_CardShell.ts index e87486094..ea6c0e4b3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_CardShell.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_CardShell.ts @@ -8,6 +8,7 @@ * 접기는 부모의 attachCollapsible(제목 줄 클릭) — 오른쪽 단추를 눌러도 접히지 않음. * ========================================================================== */ +import { isCtrlKey } from "../A00_Common/key_shortcut"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; @@ -76,7 +77,7 @@ export function cardShell(opt: CardShellOptions): CardShell { const host = opt.stateHost ?? opt.body; let save: (() => void) | null = null; root.addEventListener("keydown", (event) => { - if (!save || !(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "s") return; + if (!save || !isCtrlKey(event, "s")) return; event.preventDefault(); save(); }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 942642abd..38b78fe5f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -10,6 +10,7 @@ * ========================================================================== */ import "./M02_MasterTemplete_TreePanel.css"; +import { isCtrlKey } from "../A00_Common/key_shortcut"; import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { t as L } from "@ui/ui_template_locale"; @@ -598,7 +599,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { body.addEventListener("keydown", (event) => { const row = (event.target as HTMLElement).closest("[data-node]"); - if (!row && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v") { + if (!row && isCtrlKey(event, "v")) { event.preventDefault(); // 빈 자리에 초점 = 맨 위에 붙여넣기 return void paste([]); } @@ -626,10 +627,10 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { const many = picked.size > 1 && picked.has(row.dataset.node ?? ""); if (many && api.many) void removeMany(pickedTargets()); else if (!(target.kind === "folder" && !target.path.length)) void remove(target); - } else if (target && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c") { + } else if (target && isCtrlKey(event, "c")) { event.preventDefault(); copy(picked.has(row.dataset.node ?? "") ? pickedTargets() : [target]); - } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v") { + } else if (isCtrlKey(event, "v")) { event.preventDefault(); // 붙일 자리 = 초점 줄이 폴더면 그 안 · 아니면 맨 위 void paste(target?.kind === "folder" ? target.path : []); } diff --git a/resources/tester/cad/ctrl-shortcut-hangul.test.ts b/resources/tester/cad/ctrl-shortcut-hangul.test.ts index a93f4f661..d7ed6b090 100644 --- a/resources/tester/cad/ctrl-shortcut-hangul.test.ts +++ b/resources/tester/cad/ctrl-shortcut-hangul.test.ts @@ -1,6 +1,6 @@ /** - * Ctrl 단축키 — 한글 자판에서도 되돌리기 · 다시 하기가 먹는다(PLAN 214-1). - * 한글 자판은 key 가 「ㅋ」·「ㅛ」 · Shift 는 대문자 — 자판 자리(code)로 판정한다. + * Ctrl 단축키 — 한글 자판에서도 먹는다(PLAN 214-1 CAD · 214-2 페이지 공용 `A00_Common/key_shortcut.ts`). + * 한글 자판은 key 가 「ㅋ」·「ㅛ」 · Shift 는 대문자 — 영문이 아니면 자판 자리(code)로 판정한다. * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad ctrl-shortcut-hangul */ import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -111,3 +111,39 @@ describe("키 입력 — 한글 자판", () => { expect(prevented).not.toHaveBeenCalled(); }); }); + +// ---- 공용 판정 · 페이지(214-2) — 같은 함수를 B05 · M02 · 시트 키가 씀 ---- +import { isCtrlKey, keyLetter } from "../../../A00_Common/key_shortcut"; + +describe("keyLetter · isCtrlKey — 페이지 공용", () => { + it("한글 자판 key 「ㅋ」 + KeyZ = z · 영문 · 대문자도 소문자 · 숫자", () => { + expect(keyLetter({ key: "ㅋ", code: "KeyZ" })).toBe("z"); + expect(keyLetter({ key: "ㅊ", code: "KeyC" })).toBe("c"); + expect(keyLetter({ key: "ㅍ", code: "KeyV" })).toBe("v"); + expect(keyLetter({ key: "ㄴ", code: "KeyS" })).toBe("s"); + expect(keyLetter({ key: "Z", code: "KeyZ" })).toBe("z"); + expect(keyLetter({ key: "1", code: "Digit1" })).toBe("1"); + expect(keyLetter({ key: "Process", code: "Digit1" })).toBe("1"); + }); + it("다른 자판 배열(key 가 영문)은 key 를 따름 · 글자 아닌 키는 빈 글", () => { + expect(keyLetter({ key: "z", code: "KeyY" })).toBe("z"); // 독일식 자리 바뀜 + expect(keyLetter({ key: "Enter", code: "Enter" })).toBe(""); + expect(keyLetter({ key: "Escape", code: "Escape" })).toBe(""); + }); + it("code 없는 합성 사건은 key 로", () => { + expect(keyLetter({ key: "Z", code: "" })).toBe("z"); + }); + it("isCtrlKey — Ctrl · ⌘ 만 · 글자 맞아야", () => { + const e = (extra: object) => ({ + key: "ㅋ", + code: "KeyZ", + ctrlKey: false, + metaKey: false, + ...extra, + }); + expect(isCtrlKey(e({ ctrlKey: true }), "z")).toBe(true); + expect(isCtrlKey(e({ metaKey: true }), "z")).toBe(true); + expect(isCtrlKey(e({}), "z")).toBe(false); + expect(isCtrlKey(e({ ctrlKey: true }), "y")).toBe(false); + }); +}); diff --git a/resources/tester/cad/ctrl-shortcut-sheet.test.ts b/resources/tester/cad/ctrl-shortcut-sheet.test.ts new file mode 100644 index 000000000..0ae376b44 --- /dev/null +++ b/resources/tester/cad/ctrl-shortcut-sheet.test.ts @@ -0,0 +1,60 @@ +/** + * 시트 키 — 한글 자판에서도 Ctrl 단축키(PLAN 214-2). + * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --config ../../resources/tester/cad/vitest.ui.config.ts + */ +import { describe, expect, it } from "vitest"; +import { attachKeys } from "../../../A00_Common/spreadsheet/spreadsheet_keys"; + +describe("시트 키 — 한글 자판 Ctrl 단축키", () => { + const setup = () => { + const log: string[] = []; + const handlers: ((e: KeyboardEvent) => void)[] = []; + const ed = { + ta: { + addEventListener: (_: string, fn: (e: KeyboardEvent) => void) => handlers.push(fn), + removeEventListener: () => {}, + }, + formula: { addEventListener: () => {}, removeEventListener: () => {} }, + editing: () => false, + }; + const ctx = { + undo: () => log.push("undo"), + redo: () => log.push("redo"), + readOnly: false, + sheet: () => ({}), + selection: {}, + }; + attachKeys(ctx as never, ed as never, { + find: (replace: boolean) => log.push(replace ? "replace" : "find"), + }); + const press = (extra: object) => + handlers[0]({ + key: "", + code: "", + keyCode: 0, + isComposing: false, + ctrlKey: true, + metaKey: false, + shiftKey: false, + altKey: false, + target: null, + preventDefault: () => {}, + stopPropagation: () => {}, + ...extra, + } as unknown as KeyboardEvent); + return { log, press }; + }; + it("「ㅋ」 = 되돌리기 · 「ㅛ」 = 다시 하기 · Shift+「ㅋ」 = 다시 하기", () => { + const { log, press } = setup(); + press({ key: "ㅋ", code: "KeyZ" }); + press({ key: "ㅛ", code: "KeyY" }); + press({ key: "ㅋ", code: "KeyZ", shiftKey: true }); + expect(log).toEqual(["undo", "redo", "redo"]); + }); + it("「ㄹ」(KeyF) = 찾기 · 「ㅗ」(KeyH) = 바꾸기 · 조합 중에도", () => { + const { log, press } = setup(); + press({ key: "ㄹ", code: "KeyF" }); + press({ key: "Process", code: "KeyH", keyCode: 229, isComposing: true }); + expect(log).toEqual(["find", "replace"]); + }); +}); diff --git a/resources/tester/cad/vitest.ui.config.ts b/resources/tester/cad/vitest.ui.config.ts new file mode 100644 index 000000000..11ce23b73 --- /dev/null +++ b/resources/tester/cad/vitest.ui.config.ts @@ -0,0 +1,8 @@ +/** 페이지 코드(`@ui` 별칭을 쓰는 것)를 시험하는 vitest 설정 — 시험은 `ctrl-shortcut-sheet.test.ts`. */ +const root = new URL("../../..", import.meta.url).pathname.replace(/^\/(\w:)/, "$1"); + +export default { + root, + resolve: { alias: { "@ui": `${root}ui_template` } }, + test: { include: ["resources/tester/cad/ctrl-shortcut-sheet.test.ts"] }, +};