From 2d11682d2a8d1099383101a01e28f62b28a67459 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 12:41:04 +0900 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=EA=B8=80=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EC=B9=B8=20=EC=9A=B0=ED=81=B4=EB=A6=AD=20=EB=B8=8C=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=EC=A0=80=20=EB=A9=94=EB=89=B4=EB=8F=84=20=EB=A7=89?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20B09=20=EC=84=A4=EA=B3=84=EB=82=B4=EC=97=AD?= =?UTF-8?q?=EC=84=9C=20=EA=B8=80=20=EC=B9=B8=20=EC=A4=84=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4(184-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 ui_template_native_menu: 글 입력 칸 예외(isTextEditable) 삭제 - B09 attachRowMenu: 글 칸 위 return 삭제 - 시험: test_common_native_menu 기대값 · test_182 글 칸 줄 메뉴 시험 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G --- .../B09_Estimation_UI_Tabs_Bill_Edit.ts | 3 +- .../tester/helper_common_native_menu.cjs | 36 +++++++++++++++---- resources/tester/test_182_b09_bill_level.py | 28 +++++++++++++++ resources/tester/test_common_native_menu.py | 15 +++----- ui_template/ui_template_native_menu.ts | 18 ++-------- 5 files changed, 65 insertions(+), 35 deletions(-) diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index 5d9d186e1..51e586c27 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -363,7 +363,7 @@ export function openMenuAt(x: number, y: number, items: MapContextMenuItem[]): v if (items.length) sharedMenu().open(x, y, items); } -/** 표에 줄 우클릭 메뉴를 붙임(공용 표는 안 고침) — 글 칸 위는 브라우저 메뉴(붙여넣기) 그대로 */ +/** 표에 줄 우클릭 메뉴를 붙임(공용 표는 안 고침) — 글 칸 위에서도 줄 메뉴 */ export function attachRowMenu( root: HTMLElement, menu: (index: number) => MapContextMenuItem[], @@ -377,7 +377,6 @@ export function attachRowMenu( ); root.addEventListener("contextmenu", (event) => { const target = event.target as Element; - if (target.closest("input")) return; const row = target.closest("tr[data-i]"); if (!row || !root.contains(row)) return; event.preventDefault(); diff --git a/resources/tester/helper_common_native_menu.cjs b/resources/tester/helper_common_native_menu.cjs index eeba0b3b0..f7742ae6c 100644 --- a/resources/tester/helper_common_native_menu.cjs +++ b/resources/tester/helper_common_native_menu.cjs @@ -27,9 +27,6 @@ const targets = { none: null, }; -const editable = {}; -for (const [k, t] of Object.entries(targets)) editable[k] = m.isTextEditable(t); - // 가짜 창 — 캡처 듣기 하나를 받아 둠 function fakeWindow() { const win = { calls: [] }; @@ -48,7 +45,17 @@ let on = true; const win = fakeWindow(); m.blockNativeMenu(win, () => on); const blocked = {}; -for (const k of ["div", "canvas", "input", "readonly", "textarea", "editable", "checkbox"]) +for (const k of [ + "div", + "canvas", + "input", + "readonly", + "textarea", + "editable", + "checkbox", + "textNode", + "none", +]) blocked[k] = fire(win, targets[k]).defaultPrevented; on = false; const off = fire(win, targets.div).defaultPrevented; @@ -56,7 +63,24 @@ const stopped = fire(win, targets.div).stopped; const route = (r) => m.NATIVE_MENU_ROUTE.test(r); const routes = {}; -for (const r of ["a01-home", "a06-login", "b01-account", "b05-profile", "b11-loading", "m01-master-data", "m02-master-template", "bogus"]) +for (const r of [ + "a01-home", + "a06-login", + "b01-account", + "b05-profile", + "b11-loading", + "m01-master-data", + "m02-master-template", + "bogus", +]) routes[r] = route(r); -console.log(JSON.stringify({ editable, blocked, off, stopped, listener: win.calls.map((c) => [c.type, c.capture]), routes })); +console.log( + JSON.stringify({ + blocked, + off, + stopped, + listener: win.calls.map((c) => [c.type, c.capture]), + routes, + }), +); diff --git a/resources/tester/test_182_b09_bill_level.py b/resources/tester/test_182_b09_bill_level.py index 775a9e234..e68350ffa 100644 --- a/resources/tester/test_182_b09_bill_level.py +++ b/resources/tester/test_182_b09_bill_level.py @@ -300,3 +300,31 @@ def test_연결_단추_칸_메뉴_고르기_창_TS(): assert out["menus"] == [[10, 40, [*picks, "-", "연결 풀기"]], [10, 40, picks]] assert out["picked"] == ["일위대가 호표 고르기", True, ["대가3"]] # 166-2 고르기 창 재사용 assert out["ref"] == DG + + +ROW_MENU_JS = """ +const opened = []; +globalThis.createMapContextMenu = () => ({ + element: { isConnected: true, style: {} }, + open: (x, y, items) => opened.push({ x, y, items }), +}); +const listeners = {}; +const root = { addEventListener: (type, fn) => (listeners[type] = fn), contains: () => true }; +attachRowMenu(root, (i) => [["줄 " + i, () => {}]]); +const row = { dataset: { i: "7" } }; +// 글 칸(input) 안쪽 우클릭 — 줄 칸 · 글 칸 모두 같은 줄 메뉴 +const click = (inInput) => { + const ev = { target: { closest: (sel) => (sel === "tr[data-i]" ? row : inInput && sel === "input" ? {} : null) }, + clientX: 5, clientY: 6, prevented: false, preventDefault() { this.prevented = true; } }; + listeners.contextmenu(ev); + return ev.prevented; +}; +const out = { onInput: click(true), onCell: click(false), opened: opened.map((m) => [m.x, m.y, m.items.map((i) => i[0])]) }; +console.log(JSON.stringify(out)); +""" + + +def test_글_칸_위_우클릭도_줄_메뉴_TS(): + out = json.loads(_node(_ts(["UI_Tabs", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"]) + ROW_MENU_JS)) + assert out["onInput"] is True and out["onCell"] is True + assert out["opened"] == [[5, 6, ["줄 7"]], [5, 6, ["줄 7"]]] diff --git a/resources/tester/test_common_native_menu.py b/resources/tester/test_common_native_menu.py index 80a84c753..77820c678 100644 --- a/resources/tester/test_common_native_menu.py +++ b/resources/tester/test_common_native_menu.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""브라우저 기본 오른쪽 클릭 메뉴 막기(PLAN 39-9a) 회귀검증 — 글 입력 칸 판정 · 막기 · 꺼짐 · 대상 주소. +"""브라우저 기본 오른쪽 클릭 메뉴 막기(PLAN 39-9a) 회귀검증 — 글 입력 칸 포함 막기 · 꺼짐 · 대상 주소. Node 헬퍼(`helper_common_native_menu.cjs`)가 `ui_template/ui_template_native_menu.ts` 를 트랜스파일해 가짜 창 · 가짜 요소로 돌리고, 여기서 값을 판정함. @@ -27,18 +27,11 @@ def results(): return json.loads(proc.stdout) -def test_글_입력_칸만_살림(results): - e = results["editable"] - assert e["input"] and e["inputNoType"] and e["number"] and e["readonly"] - assert e["textarea"] and e["editable"] - assert not (e["div"] or e["canvas"] or e["checkbox"] or e["range"]) - assert not (e["textNode"] or e["none"]) - - -def test_입력_칸_밖은_막음(results): +def test_글_입력_칸도_막음(results): b = results["blocked"] assert b["div"] and b["canvas"] and b["checkbox"] - assert not (b["input"] or b["readonly"] or b["textarea"] or b["editable"]) + assert b["input"] and b["readonly"] and b["textarea"] and b["editable"] + assert b["textNode"] and b["none"] def test_꺼지면_안_막고_이벤트는_안_멈춤(results): diff --git a/ui_template/ui_template_native_menu.ts b/ui_template/ui_template_native_menu.ts index 14e7c8f9b..d9a9abb85 100644 --- a/ui_template/ui_template_native_menu.ts +++ b/ui_template/ui_template_native_menu.ts @@ -3,32 +3,18 @@ * 브라우저 기본 오른쪽 클릭 메뉴 막기 — 공용 진입(`A00_Common/main.ts`) · CAD iframe(`cad_host.ts`)이 부름. * * 창 캡처 단계에서 `preventDefault` 만 함 — 이벤트는 멈추지 않으므로 우리 메뉴 듣기는 그대로 돎. - * 글 입력 칸(input · textarea · contenteditable · 읽기 전용 포함)은 브라우저 메뉴를 살림(붙여넣기 · 맞춤법). - * ⚠ `instanceof` 안 씀 — iframe 문서의 요소는 다른 realm 이라 `instanceof HTMLElement` 가 거짓. + * 글 입력 칸도 막음 — 붙여넣기 · 복사는 Ctrl+V · Ctrl+C · 그 칸에 우리 메뉴가 붙어 있으면 그 메뉴가 뜸. * ========================================================================== */ /** 막을 주소 — B · M 영역(`b01-…` · `m02-…`) · A 영역은 그대로 */ export const NATIVE_MENU_ROUTE = /^[bm]\d/; -/** 글을 쓰는 input 종류 — checkbox · range · button 등은 빠짐 */ -const TEXT_INPUT_TYPES = new Set(["text", "search", "number", "email", "url", "tel", "password"]); - -/** 오른쪽 클릭 자리가 글 입력 칸인지 */ -export function isTextEditable(target: EventTarget | null): boolean { - const node = target as { tagName?: unknown; isContentEditable?: boolean; type?: string } | null; - if (!node || typeof node.tagName !== "string") return false; - if (node.isContentEditable) return true; - const tag = node.tagName.toUpperCase(); - if (tag === "TEXTAREA") return true; - return tag === "INPUT" && TEXT_INPUT_TYPES.has((node.type || "text").toLowerCase()); -} - /** `win` 의 기본 메뉴를 막음 — `isOn()` 이 거짓이면(예: A 영역 주소) 그대로 둠 */ export function blockNativeMenu(win: Window, isOn: () => boolean): void { win.addEventListener( "contextmenu", (event) => { - if (isOn() && !isTextEditable(event.target)) event.preventDefault(); + if (isOn()) event.preventDefault(); }, true, );