Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1

# Conflicts:
#	B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts
This commit is contained in:
2026-10-10 15:37:00 +09:00
12 changed files with 182 additions and 35 deletions
+49
View File
@@ -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<KeyboardEvent, "code"> & Partial<Pick<KeyboardEvent, "key">>;
/** 누른 글자·숫자(소문자) — 글자·숫자 자리가 아니면 빈 글 */
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<KeyboardEvent, "ctrlKey" | "metaKey">,
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<KeyboardEvent, "ctrlKey" | "altKey" | "shiftKey"> &
Partial<Pick<KeyboardEvent, "metaKey">>,
): 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;
}
}
+4 -2
View File
@@ -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) {
@@ -9,6 +9,7 @@
* 되돌리기 범위가 B05 조작 전부인 이유는 `B05_Profile_UI_Profile_History` 머리말 참조.
* ========================================================================== */
import { isCtrlKey } from "../A00_Common/key_shortcut";
import type {
AlignmentBase,
AlignmentEdits,
@@ -310,7 +311,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
freeView.redraw();
return;
}
if ((event.ctrlKey || event.metaKey) && (key === "z" || key === "Z")) {
if (isCtrlKey(event, "z")) {
event.preventDefault();
flushHold();
if (event.shiftKey) history.redo();
@@ -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;
@@ -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<KeyboardEvent, 'ctrlKey' | 'altKey' | 'shiftKey' | 'code'>
): 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';
@@ -25,6 +25,6 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"include": ["src", "../../A00_Common/key_shortcut.ts"],
"exclude": ["node_modules"]
}
+3 -2
View File
@@ -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();
@@ -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();
});
@@ -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<HTMLElement>("[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 : []);
}
@@ -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);
});
});
@@ -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"]);
});
});
+8
View File
@@ -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"] },
};