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, );