diff --git a/A00_Common/design/design_profile_popout.ts b/A00_Common/design/design_profile_popout.ts index 304bdb07f..ccd46475f 100644 --- a/A00_Common/design/design_profile_popout.ts +++ b/A00_Common/design/design_profile_popout.ts @@ -9,6 +9,7 @@ * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; +import { releaseSelectAfterPick } from "@ui/ui_template_native_menu"; import type { DesignProfile } from "./design_profile"; /** 메인 창 문서 · 창에 걸린 듣기가 받아야 할 새 창 이벤트. */ @@ -101,6 +102,8 @@ function attachPopout(profile: DesignProfile, spec: PopoutSpec): void { true, ), ); + // 새 창 드롭다운도 고른 뒤 초점 뺌 — change 는 메인으로 안 넘김(요구 280-6). + releaseSelectAfterPick(next); // 새 창이 초점을 잃으면 길게 누르기를 끊는다(메인 창 blur 듣기). next.addEventListener("blur", () => window.dispatchEvent(new FocusEvent("blur"))); // 새 창 크기 기준으로 다시 맞춘다. diff --git a/A00_Common/main.ts b/A00_Common/main.ts index e7ea76d3e..647de8f24 100644 --- a/A00_Common/main.ts +++ b/A00_Common/main.ts @@ -13,7 +13,11 @@ import "./device_context"; // 맨 앞 — 그리기 전에 기기 표시( import "./touch_bridge"; // 손가락 → 마우스 번역(기기대응 3-1) import "@ui/ui_template_theme.css"; import { injectBaseStyles } from "@ui/ui_template_elements"; -import { blockNativeMenu, NATIVE_MENU_ROUTE } from "@ui/ui_template_native_menu"; +import { + blockNativeMenu, + NATIVE_MENU_ROUTE, + releaseSelectAfterPick, +} from "@ui/ui_template_native_menu"; import { initLoupe, LOUPE_ROUTE } from "@ui/ui_template_loupe"; import { ROUTES, type RoutePath } from "@config/config_frontend"; import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell"; @@ -58,6 +62,8 @@ function bootstrap(): void { // B · M 페이지는 브라우저 기본 오른쪽 클릭 메뉴를 막음 — 우리 메뉴는 그대로(계획서 39-9a) blockNativeMenu(window, () => NATIVE_MENU_ROUTE.test(currentRoute())); + // 드롭다운을 마우스로 고르면 초점 뺌 — 휠 · ↑↓ 가 값을 또 바꾸지 않게(요구 280-6) + releaseSelectAfterPick(window); // B01~B09 · M01 · M02 돋보기 — 오른쪽 길게 누름 · Z 누름 동안(계획서 218-1 · 220) initLoupe(window, () => LOUPE_ROUTE.test(currentRoute())); diff --git a/resources/tester/helper_224_1c_subpanel_popout.cjs b/resources/tester/helper_224_1c_subpanel_popout.cjs index c48685322..c3bf297be 100644 --- a/resources/tester/helper_224_1c_subpanel_popout.cjs +++ b/resources/tester/helper_224_1c_subpanel_popout.cjs @@ -117,7 +117,7 @@ const js = ts.transpileModule(read("A00_Common", "design", "design_profile_popou const mod = {}; new Function("exports", "require", "window", "document", js)( mod, - () => ({ showToast() {} }), + () => ({ showToast() {}, releaseSelectAfterPick() {} }), main, mainDoc, ); diff --git a/resources/tester/helper_280_6_select_release.cjs b/resources/tester/helper_280_6_select_release.cjs new file mode 100644 index 000000000..5b84ac0ab --- /dev/null +++ b/resources/tester/helper_280_6_select_release.cjs @@ -0,0 +1,76 @@ +/* 280-6 드롭다운 초점 빼기(`ui_template/ui_template_native_menu.ts` `releaseSelectAfterPick`) 단위검증 헬퍼 — + * 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 냄. 가짜 창 · 가짜 select · 가짜 타이머로 판정. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const js = ts.transpileModule( + fs.readFileSync(path.join(ROOT, "ui_template", "ui_template_native_menu.ts"), "utf8"), + { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }, +).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const m = box.exports; + +// 가짜 창 — 캡처 듣기 · 타이머(손으로 돌림) · 문서 하나 +function setup() { + const listeners = []; + const timers = []; + const doc = { activeElement: null }; + const win = { + addEventListener: (type, fn, capture) => listeners.push({ type, fn, capture }), + setTimeout: (fn) => timers.push(fn), + }; + m.releaseSelectAfterPick(win); + const el = (tagName) => { + const node = { tagName, ownerDocument: doc, blurs: 0 }; + node.blur = () => { + node.blurs += 1; + doc.activeElement = null; + }; + return node; + }; + const fire = (type, target) => + listeners.filter((l) => l.type === type).forEach((l) => l.fn({ type, target })); + const tick = () => timers.splice(0).forEach((fn) => fn()); + return { listeners, doc, el, fire, tick }; +} + +// 차례 하나 — 초점을 select 에 두고 입력들을 쏜 뒤 change · (처리) · 한 틱 +function run(inputs, tag = "SELECT", moveFocus = false) { + const t = setup(); + const target = t.el(tag); + t.doc.activeElement = target; + inputs.forEach((type) => t.fire(type, target)); + t.fire("change", target); + const before = target.blurs; // 한 틱 전엔 안 뺌 + if (moveFocus) t.doc.activeElement = t.el("INPUT"); + t.tick(); + return { before, blurs: target.blurs }; +} + +const src = (rel) => fs.readFileSync(path.join(ROOT, rel), "utf8"); +const setupOnce = setup(); + +console.log( + JSON.stringify({ + mouse: run(["pointerdown"]), + keyboard: run(["keydown"]), + none: run([]), + input: run(["pointerdown"], "INPUT"), + moved: run(["pointerdown"], "SELECT", true), + mouseThenKey: run(["pointerdown", "keydown"]), + keyThenMouse: run(["keydown", "pointerdown"]), + listeners: setupOnce.listeners.map((l) => [l.type, l.capture]), + wiring: { + main: /releaseSelectAfterPick\(window\)/.test(src("A00_Common/main.ts")), + cad: /releaseSelectAfterPick\(frame\.contentWindow\)/.test( + src("ui_template/cad_host/cad_host.ts"), + ), + popout: /releaseSelectAfterPick\(next\)/.test( + src("A00_Common/design/design_profile_popout.ts"), + ), + }, + }), +); diff --git a/resources/tester/helper_282_1_profile_toolbar.cjs b/resources/tester/helper_282_1_profile_toolbar.cjs index 932c18225..260f28e3d 100644 --- a/resources/tester/helper_282_1_profile_toolbar.cjs +++ b/resources/tester/helper_282_1_profile_toolbar.cjs @@ -134,7 +134,7 @@ const { withToolLead } = load(`${leadSrc}\nexports.withToolLead = withToolLead;` // ---- 진짜 attachProfilePopout ---- const { attachProfilePopout } = load(read("A00_Common", "design", "design_profile_popout.ts"), { - require: () => ({ showToast() {} }), + require: () => ({ showToast() {}, releaseSelectAfterPick() {} }), document, window, getComputedStyle: () => ({ backgroundColor: "" }), diff --git a/resources/tester/test_280_6_select_release.py b/resources/tester/test_280_6_select_release.py new file mode 100644 index 000000000..e7ee17a5e --- /dev/null +++ b/resources/tester/test_280_6_select_release.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +"""280-6 드롭다운 — 마우스로 고르면 한 박자 뒤 초점 뺌 · 키보드로 고르면 그대로 · 부르는 자리 셋. + +Node 헬퍼(`helper_280_6_select_release.cjs`)가 `ui_template/ui_template_native_menu.ts` 를 트랜스파일해 +가짜 창 · 가짜 select · 가짜 타이머로 돌리고, 여기서 값을 판정함. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_280_6_select_release.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_마우스로_고르면_한_박자_뒤_초점_뺌(r): + assert r["mouse"] == {"before": 0, "blurs": 1} + assert r["keyThenMouse"]["blurs"] == 1 + assert r["none"]["blurs"] == 1 # 입력 없이 온 change(스크립트 · 손가락)도 뺌 + + +def test_키보드로_고르면_초점_남음(r): + assert r["keyboard"]["blurs"] == 0 + assert r["mouseThenKey"]["blurs"] == 0 # 마우스로 펼친 뒤 ↑↓ + Enter + + +def test_select_밖_이미_옮긴_초점은_안_건드림(r): + assert r["input"]["blurs"] == 0 + assert r["moved"]["blurs"] == 0 + + +def test_창_캡처_듣기와_부르는_자리_셋(r): + assert sorted(r["listeners"]) == [["change", True], ["keydown", True], ["pointerdown", True]] + assert r["wiring"] == {"main": True, "cad": True, "popout": True} diff --git a/ui_template/cad_host/cad_host.ts b/ui_template/cad_host/cad_host.ts index 1947b26e7..04f615dfa 100644 --- a/ui_template/cad_host/cad_host.ts +++ b/ui_template/cad_host/cad_host.ts @@ -9,7 +9,7 @@ import "./cad_host.css"; import { showToast } from "@ui/ui_template_elements"; -import { blockNativeMenu } from "@ui/ui_template_native_menu"; +import { blockNativeMenu, releaseSelectAfterPick } from "@ui/ui_template_native_menu"; /** CAD 앱 정적 경로 (main.py 마운트, dev는 vite proxy 위임) */ const CAD_APP_URL = "/b07-cad/index.html"; @@ -148,7 +148,9 @@ export function createCadHost { - if (frame.contentWindow) blockNativeMenu(frame.contentWindow, () => true); + if (!frame.contentWindow) return; + blockNativeMenu(frame.contentWindow, () => true); + releaseSelectAfterPick(frame.contentWindow); // 리본 드롭다운도 고른 뒤 초점 뺌(요구 280-6) }); // 포인터가 CAD 위에 오면 키(Ctrl+Z · Ctrl+Y …)가 CAD 로 가게 초점을 넘김 — 부모 입력 칸에서 치는 중이면 안 뺏음 frame.addEventListener("mouseenter", () => { diff --git a/ui_template/ui_template_native_menu.ts b/ui_template/ui_template_native_menu.ts index d9a9abb85..d5da108d9 100644 --- a/ui_template/ui_template_native_menu.ts +++ b/ui_template/ui_template_native_menu.ts @@ -1,9 +1,10 @@ /* ============================================================================= * ui_template_native_menu.ts - * 브라우저 기본 오른쪽 클릭 메뉴 막기 — 공용 진입(`A00_Common/main.ts`) · CAD iframe(`cad_host.ts`)이 부름. + * 브라우저 기본 동작 손봄 — 공용 진입(`A00_Common/main.ts`) · CAD iframe(`cad_host.ts`) · 종단 새 창(`design_profile_popout.ts`)이 부름. * - * 창 캡처 단계에서 `preventDefault` 만 함 — 이벤트는 멈추지 않으므로 우리 메뉴 듣기는 그대로 돎. + * 오른쪽 클릭 메뉴 막기 — 창 캡처 단계에서 `preventDefault` 만 함 — 이벤트는 멈추지 않으므로 우리 메뉴 듣기는 그대로 돎. * 글 입력 칸도 막음 — 붙여넣기 · 복사는 Ctrl+V · Ctrl+C · 그 칸에 우리 메뉴가 붙어 있으면 그 메뉴가 뜸. + * 드롭다운 초점 빼기 — 마우스로 고른 뒤 초점이 남으면 휠 · ↑↓ 가 값을 또 바꿈(요구 280-6). * ========================================================================== */ /** 막을 주소 — B · M 영역(`b01-…` · `m02-…`) · A 영역은 그대로 */ @@ -19,3 +20,23 @@ export function blockNativeMenu(win: Window, isOn: () => boolean): void { true, ); } + +/** `win` 안 드롭다운을 마우스로 고르면 한 박자 뒤 초점을 뺌 — 키보드로 고른 때(↑↓ 훑기)는 그대로 둠. + * 한 박자 뒤 · 아직 그 select 에 초점일 때만 — 페이지 처리(다시 그림 · 다른 칸 초점)가 먼저 끝남. + * iframe 은 다른 realm — `instanceof` 대신 `tagName` 으로 가림. */ +export function releaseSelectAfterPick(win: Window): void { + let byKey = false; + win.addEventListener("pointerdown", () => (byKey = false), true); + win.addEventListener("keydown", () => (byKey = true), true); + win.addEventListener( + "change", + (event) => { + const select = event.target as HTMLSelectElement | null; + if (byKey || select?.tagName !== "SELECT") return; + win.setTimeout(() => { + if (select.ownerDocument.activeElement === select) select.blur(); + }, 0); + }, + true, + ); +}