fix(CAD): 되돌리기 · 다시 하기 한글 자판 대응 · M02 화면 ↶ ↷ 단추(214-1)
- Ctrl+Z · Y · Shift+Z · S · A 를 자판 자리(code)로 판정 — 한글 자판 「ㅋ」·「ㅛ」 · Shift 대문자 대응 - 한글 조합 중에도 Ctrl 단축키는 먹음 - 제목줄 없는 M02 화면(양식 편집 · 구조물 카드)은 우하단 탐색 막대에 되돌리기 · 다시 하기 단추 - 포인터가 CAD 위에 오면 부모 입력 칸이 아닐 때 CAD 로 초점 - 새 시험 resources/tester/cad/ctrl-shortcut-hangul.test.ts Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
@@ -1,13 +1,30 @@
|
||||
/** 화면 우하단 탐색 막대 (줌 표시·전체 보기) */
|
||||
/** 화면 우하단 탐색 막대 (줌 표시·전체 보기) — 제목줄이 없는 화면(M02)은 되돌리기 · 다시 하기도 */
|
||||
import type { FC } from 'react';
|
||||
import { runCommandInput } from '../commands/run-command';
|
||||
import { getScreenCanvasDrawController } from '../state';
|
||||
import {
|
||||
getScreenCanvasDrawController,
|
||||
isDrawingReadOnly,
|
||||
isHostSave,
|
||||
isViewOnly,
|
||||
} from '../state';
|
||||
|
||||
export const ViewControls: FC = () => {
|
||||
const zoom = getScreenCanvasDrawController()?.getScreenScale() ?? 1;
|
||||
// 제목줄(빠른 막대)이 없는 M02 양식 · 구조물 카드 — 고칠 수 있을 때만
|
||||
const showUndo = isHostSave() && !isViewOnly() && !isDrawingReadOnly();
|
||||
|
||||
return (
|
||||
<div className="cad-view-controls controls">
|
||||
{showUndo && (
|
||||
<>
|
||||
<button type="button" onClick={() => runCommandInput('UNDO')} title="실행 취소 (Ctrl+Z)">
|
||||
↶
|
||||
</button>
|
||||
<button type="button" onClick={() => runCommandInput('REDO')} title="다시 실행 (Ctrl+Y)">
|
||||
↷
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="cad-view-controls__fit"
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
isPlainSpace,
|
||||
} from './ime-sink.ts';
|
||||
import { drawInputField } from './input-field-draw.ts';
|
||||
import { ctrlShortcut } from './input-shortcut.ts';
|
||||
import { wheelZoom } from './input-wheel.ts';
|
||||
import {
|
||||
ABSOLUTE_POINT_REGEXP,
|
||||
@@ -339,7 +340,9 @@ export class InputController {
|
||||
if (target !== this.ime.element && target?.closest('input, textarea, select')) {
|
||||
return;
|
||||
}
|
||||
if (isImeKey(evt)) return; // 한글 조합 중 — 단축키가 먹지 않게 IME 에 넘김
|
||||
const shortcut = ctrlShortcut(evt);
|
||||
// 한글 조합 중 — 단축키가 먹지 않게 IME 에 넘김(Ctrl 단축키는 조합 중에도 먹음)
|
||||
if (isImeKey(evt) && !shortcut) return;
|
||||
console.log(`key pressed: ${evt.key}`);
|
||||
if (evt.key === 'F12') {
|
||||
// F12 => open developer tools
|
||||
@@ -386,19 +389,16 @@ export class InputController {
|
||||
}
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
if (evt.ctrlKey && !evt.shiftKey && evt.key === 'z') {
|
||||
if (shortcut === 'undo') {
|
||||
// User wants to undo the last action
|
||||
this.handleUndo(evt);
|
||||
} else if (evt.ctrlKey && evt.shiftKey && evt.key === 'z') {
|
||||
} else if (shortcut === 'redo') {
|
||||
// User wants to redo the last action
|
||||
this.handleRedo(evt);
|
||||
} else if (evt.ctrlKey && evt.key === 'y') {
|
||||
// User wants to redo the last action
|
||||
this.handleRedo(evt);
|
||||
} else if (evt.ctrlKey && evt.key === 's') {
|
||||
} else if (shortcut === 'save') {
|
||||
// 부모 페이지가 저장을 맡은 화면(M02 양식)만 — B07 은 예전처럼 아무 일 없음
|
||||
if (isHostSave()) requestHostSave();
|
||||
} else if (evt.ctrlKey && evt.key === 'a') {
|
||||
} else if (shortcut === 'selectAll') {
|
||||
// User wants to select everything
|
||||
setSelectedEntityIds(getPickableEntities().map((entity) => entity.id));
|
||||
} else if (evt.key === 'Backspace') {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Ctrl 단축키 이름 */
|
||||
export type CtrlShortcut = 'undo' | 'redo' | 'save' | 'selectAll';
|
||||
|
||||
/**
|
||||
* Ctrl 단축키 판정 — 글자(`key`)가 아니라 자판 자리(`code`).
|
||||
* 한글 자판은 `key` 가 「ㅋ」·「ㅛ」로 오고, Shift 를 누르면 `key` 가 대문자(「Z」)라 글자로는 안 맞는다.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Ctrl 단축키 — 한글 자판에서도 되돌리기 · 다시 하기가 먹는다(PLAN 214-1).
|
||||
* 한글 자판은 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";
|
||||
|
||||
vi.mock("../../../B07_DesignDetail/openwebcad/src/state.ts", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
}));
|
||||
|
||||
import { ctrlShortcut } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-shortcut";
|
||||
import { InputController } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-controller";
|
||||
import { redo, undo } from "../../../B07_DesignDetail/openwebcad/src/state";
|
||||
|
||||
const press = (controller: InputController, extra: Partial<KeyboardEvent>) => {
|
||||
const preventDefault = vi.fn();
|
||||
controller.handleKeyStroke({
|
||||
key: "",
|
||||
code: "",
|
||||
keyCode: 0,
|
||||
isComposing: false,
|
||||
shiftKey: false,
|
||||
ctrlKey: true,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
repeat: false,
|
||||
target: null,
|
||||
preventDefault,
|
||||
stopPropagation: vi.fn(),
|
||||
...extra,
|
||||
} as unknown as KeyboardEvent);
|
||||
return preventDefault;
|
||||
};
|
||||
|
||||
describe("ctrlShortcut — 자판 자리(code)로 판정", () => {
|
||||
const k = (code: string, extra = {}) =>
|
||||
ctrlShortcut({ ctrlKey: true, altKey: false, shiftKey: false, code, ...extra });
|
||||
it("KeyZ = 되돌리기 · Shift+KeyZ · KeyY = 다시 하기", () => {
|
||||
expect(k("KeyZ")).toBe("undo");
|
||||
expect(k("KeyZ", { shiftKey: true })).toBe("redo");
|
||||
expect(k("KeyY")).toBe("redo");
|
||||
});
|
||||
it("KeyS = 저장 · KeyA = 전부 고르기 · Shift 면 아님", () => {
|
||||
expect(k("KeyS")).toBe("save");
|
||||
expect(k("KeyA")).toBe("selectAll");
|
||||
expect(k("KeyS", { shiftKey: true })).toBeNull();
|
||||
expect(k("KeyA", { shiftKey: true })).toBeNull();
|
||||
});
|
||||
it("Ctrl 없음 · Alt(AltGr) 함께 · 다른 자리는 아님", () => {
|
||||
expect(k("KeyZ", { ctrlKey: false })).toBeNull();
|
||||
expect(k("KeyZ", { altKey: true })).toBeNull();
|
||||
expect(k("KeyX")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("키 입력 — 한글 자판", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(undo).mockClear();
|
||||
vi.mocked(redo).mockClear();
|
||||
});
|
||||
|
||||
it("Ctrl+「ㅋ」(KeyZ) = 되돌리기", () => {
|
||||
const controller = new InputController();
|
||||
const prevented = press(controller, { key: "ㅋ", code: "KeyZ" });
|
||||
expect(undo).toHaveBeenCalledTimes(1);
|
||||
expect(redo).not.toHaveBeenCalled();
|
||||
expect(prevented).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Ctrl+「ㅛ」(KeyY) = 다시 하기", () => {
|
||||
const controller = new InputController();
|
||||
press(controller, { key: "ㅛ", code: "KeyY" });
|
||||
expect(redo).toHaveBeenCalledTimes(1);
|
||||
expect(undo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Ctrl+Shift+「ㅋ」(KeyZ) = 다시 하기", () => {
|
||||
const controller = new InputController();
|
||||
press(controller, { key: "ㅋ", code: "KeyZ", shiftKey: true });
|
||||
expect(redo).toHaveBeenCalledTimes(1);
|
||||
expect(undo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("영문 자판 Ctrl+Shift+Z(key 「Z」) = 다시 하기 · Ctrl+Z = 되돌리기", () => {
|
||||
const controller = new InputController();
|
||||
press(controller, { key: "Z", code: "KeyZ", shiftKey: true });
|
||||
expect(redo).toHaveBeenCalledTimes(1);
|
||||
press(controller, { key: "z", code: "KeyZ" });
|
||||
expect(undo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("한글 조합 중(keyCode 229 · key Process)에도 Ctrl+Z 는 먹음", () => {
|
||||
const controller = new InputController();
|
||||
press(controller, { key: "Process", code: "KeyZ", keyCode: 229, isComposing: true });
|
||||
expect(undo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("조합 중 Ctrl 없는 키는 여전히 IME 에 넘김", () => {
|
||||
const controller = new InputController();
|
||||
const prevented = press(controller, {
|
||||
key: "Process",
|
||||
code: "KeyZ",
|
||||
keyCode: 229,
|
||||
isComposing: true,
|
||||
ctrlKey: false,
|
||||
});
|
||||
expect(undo).not.toHaveBeenCalled();
|
||||
expect(prevented).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -148,6 +148,12 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
|
||||
frame.addEventListener("load", () => {
|
||||
if (frame.contentWindow) blockNativeMenu(frame.contentWindow, () => true);
|
||||
});
|
||||
// 포인터가 CAD 위에 오면 키(Ctrl+Z · Ctrl+Y …)가 CAD 로 가게 초점을 넘김 — 부모 입력 칸에서 치는 중이면 안 뺏음
|
||||
frame.addEventListener("mouseenter", () => {
|
||||
const active = document.activeElement;
|
||||
if (active === frame || active?.closest("input, textarea, select, [contenteditable]")) return;
|
||||
frame.contentWindow?.focus();
|
||||
});
|
||||
const license = document.createElement("a");
|
||||
license.className = "cad-host__license";
|
||||
license.href = "/b07-cad/THIRD_PARTY_LICENSES.txt";
|
||||
|
||||
Reference in New Issue
Block a user