fix(ui): 글 입력 칸 우클릭 브라우저 메뉴도 막음 · B09 설계내역서 글 칸 줄 메뉴(184-1)

- 공용 ui_template_native_menu: 글 입력 칸 예외(isTextEditable) 삭제
- B09 attachRowMenu: 글 칸 위 return 삭제
- 시험: test_common_native_menu 기대값 · test_182 글 칸 줄 메뉴 시험

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
This commit is contained in:
2026-10-10 12:41:04 +09:00
co-authored by Claude Sonnet 5.5
parent 6e1cb86349
commit 2d11682d2a
5 changed files with 65 additions and 35 deletions
@@ -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<HTMLElement>("tr[data-i]");
if (!row || !root.contains(row)) return;
event.preventDefault();
+30 -6
View File
@@ -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,
}),
);
@@ -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"]]]
+4 -11
View File
@@ -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):
+2 -16
View File
@@ -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,
);