From 0f8efaa4240ac3beba64cd0c7d3e147b5c98ec43 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 17:12:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(common):=20B=20=C2=B7=20M=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EB=A9=94=EB=89=B4=20=EB=A7=89=EA=B8=B0=20=E2=80=94?= =?UTF-8?q?=20=EA=B3=B5=EC=9A=A9=20=EC=A7=84=EC=9E=85=20=EC=B0=BD=20?= =?UTF-8?q?=EC=BA=A1=EC=B2=98=20=ED=95=9C=20=EA=B3=B3=20=C2=B7=20CAD=20ifr?= =?UTF-8?q?ame=20=EB=8F=84=20=C2=B7=20=EA=B8=80=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EC=B9=B8=EC=9D=80=20=EC=82=B4=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 ui_template_native_menu.ts — 주소 /^[bm]\d/ · 글 입력 칸(input · textarea · contenteditable · 읽기 전용 포함) 제외 · preventDefault 만(우리 메뉴 그대로) - main.ts 공용 진입 한 줄 · cad_host.ts iframe load 때 같은 함수 - 「브라우저 메뉴 그대로 둔다」 주석 셋 바로잡음(B04 지도 · B05 배수 · 3D) - 시험 test_common_native_menu.py (PLAN 39-9a) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- A00_Common/main.ts | 4 ++ B04_PreProcess/B04_PreProcess_UI_MapViewer.ts | 2 +- B05_Profile/B05_Profile_UI_Drainage_Parts.ts | 2 +- B05_Profile/B05_Profile_UI_Viewer_Menu.ts | 2 +- .../tester/helper_common_native_menu.cjs | 62 +++++++++++++++++++ resources/tester/test_common_native_menu.py | 54 ++++++++++++++++ ui_template/cad_host/cad_host.ts | 5 ++ ui_template/ui_template_native_menu.ts | 35 +++++++++++ 8 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 resources/tester/helper_common_native_menu.cjs create mode 100644 resources/tester/test_common_native_menu.py create mode 100644 ui_template/ui_template_native_menu.ts diff --git a/A00_Common/main.ts b/A00_Common/main.ts index 1b0a85ee1..c1dca79fd 100644 --- a/A00_Common/main.ts +++ b/A00_Common/main.ts @@ -11,6 +11,7 @@ import "./random_uuid_polyfill"; 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 { ROUTES, type RoutePath } from "@config/config_frontend"; import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell"; import { @@ -51,6 +52,9 @@ function bootstrap(): void { // 2. 테마/언어 복원 initThemeAndLang(); + // B · M 페이지는 브라우저 기본 오른쪽 클릭 메뉴를 막음 — 우리 메뉴는 그대로(계획서 39-9a) + blockNativeMenu(window, () => NATIVE_MENU_ROUTE.test(currentRoute())); + // 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼 let shown = ""; let shownHash = ""; diff --git a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts index 2409f70bb..388ff9eca 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts @@ -585,7 +585,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { event.clientX - rect.left, event.clientY - rect.top, ); - // 계획선이나 관 마커 위에서만 전용 메뉴를 띄운다 — 그 밖은 브라우저 메뉴를 그대로 둔다. + // 계획선이나 관 마커 위에서만 전용 메뉴를 띄운다 — 그 밖은 아무 메뉴도 안 뜸(브라우저 메뉴는 공용 진입이 막음). if (opened) event.preventDefault(); }); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index d246c7fd0..3a9d17fab 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -212,7 +212,7 @@ export function bindPipeContextMenu( ]); return; } - // 계획선에서 먼 자리는 브라우저 기본 메뉴를 그대로 둔다. + // 계획선에서 먼 자리는 아무 메뉴도 안 뜸(브라우저 메뉴는 공용 진입이 막음). if (!editor.canAddAt(view, x, y)) return; event.preventDefault(); // 빈 자리 — 종단그래프와 같은 구조물군 → 종류 2단 메뉴(2026-08-18 일원화). diff --git a/B05_Profile/B05_Profile_UI_Viewer_Menu.ts b/B05_Profile/B05_Profile_UI_Viewer_Menu.ts index 69a2c728c..ace56762c 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Menu.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Menu.ts @@ -75,7 +75,7 @@ export function mountViewerStructureMenu(deps: ViewerMenuDeps): void { const model = deps.modelPointAt(event.clientX, event.clientY); if (!model) return; const hit = projectToRoute(deps.routePoints(), model.x, model.y); - // 노선에서 먼 자리는 브라우저 기본 메뉴를 그대로 둔다 — 배치 대상이 아니다. + // 노선에서 먼 자리는 아무 메뉴도 안 뜸 — 배치 대상이 아님(브라우저 메뉴는 공용 진입이 막음). if (!hit || hit.distance > ROUTE_PICK_DISTANCE_M) return; event.preventDefault(); const chainage = Number(hit.chainage.toFixed(2)); diff --git a/resources/tester/helper_common_native_menu.cjs b/resources/tester/helper_common_native_menu.cjs new file mode 100644 index 000000000..eeba0b3b0 --- /dev/null +++ b/resources/tester/helper_common_native_menu.cjs @@ -0,0 +1,62 @@ +/* 브라우저 기본 메뉴 막기(`ui_template/ui_template_native_menu.ts`) 단위검증 헬퍼 — + * 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 냄. 가짜 창 · 가짜 요소로 판정. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join(__dirname, "..", "..", "ui_template", "ui_template_native_menu.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "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; + +const targets = { + div: { tagName: "DIV" }, + canvas: { tagName: "CANVAS" }, + input: { tagName: "INPUT", type: "text" }, + inputNoType: { tagName: "INPUT", type: "" }, + number: { tagName: "INPUT", type: "number" }, + readonly: { tagName: "INPUT", type: "text", readOnly: true }, + checkbox: { tagName: "INPUT", type: "checkbox" }, + range: { tagName: "INPUT", type: "range" }, + textarea: { tagName: "TEXTAREA" }, + editable: { tagName: "SPAN", isContentEditable: true }, + textNode: { nodeType: 3 }, + none: null, +}; + +const editable = {}; +for (const [k, t] of Object.entries(targets)) editable[k] = m.isTextEditable(t); + +// 가짜 창 — 캡처 듣기 하나를 받아 둠 +function fakeWindow() { + const win = { calls: [] }; + win.addEventListener = (type, fn, capture) => win.calls.push({ type, fn, capture }); + return win; +} +function fire(win, target) { + const ev = { target, defaultPrevented: false, stopped: false }; + ev.preventDefault = () => (ev.defaultPrevented = true); + ev.stopPropagation = () => (ev.stopped = true); + win.calls.forEach((c) => c.fn(ev)); + return ev; +} + +let on = true; +const win = fakeWindow(); +m.blockNativeMenu(win, () => on); +const blocked = {}; +for (const k of ["div", "canvas", "input", "readonly", "textarea", "editable", "checkbox"]) + blocked[k] = fire(win, targets[k]).defaultPrevented; +on = false; +const off = fire(win, targets.div).defaultPrevented; +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"]) + routes[r] = route(r); + +console.log(JSON.stringify({ editable, blocked, off, stopped, listener: win.calls.map((c) => [c.type, c.capture]), routes })); diff --git a/resources/tester/test_common_native_menu.py b/resources/tester/test_common_native_menu.py new file mode 100644 index 000000000..80a84c753 --- /dev/null +++ b/resources/tester/test_common_native_menu.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +"""브라우저 기본 오른쪽 클릭 메뉴 막기(PLAN 39-9a) 회귀검증 — 글 입력 칸 판정 · 막기 · 꺼짐 · 대상 주소. + +Node 헬퍼(`helper_common_native_menu.cjs`)가 `ui_template/ui_template_native_menu.ts` 를 트랜스파일해 +가짜 창 · 가짜 요소로 돌리고, 여기서 값을 판정함. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_common_native_menu.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + 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): + 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"]) + + +def test_꺼지면_안_막고_이벤트는_안_멈춤(results): + assert results["off"] is False + assert results["stopped"] is False # 우리 메뉴 듣기가 그대로 돌아야 함 + assert results["listener"] == [["contextmenu", True]] # 창 캡처 단계 + + +def test_대상_주소는_B_M_만(results): + r = results["routes"] + assert r["b01-account"] and r["b05-profile"] and r["b11-loading"] + assert r["m01-master-data"] and r["m02-master-template"] + assert not (r["a01-home"] or r["a06-login"] or r["bogus"]) diff --git a/ui_template/cad_host/cad_host.ts b/ui_template/cad_host/cad_host.ts index b004bab5d..88c27c69e 100644 --- a/ui_template/cad_host/cad_host.ts +++ b/ui_template/cad_host/cad_host.ts @@ -9,6 +9,7 @@ import "./cad_host.css"; import { showToast } from "@ui/ui_template_elements"; +import { blockNativeMenu } from "@ui/ui_template_native_menu"; /** CAD 앱 정적 경로 (main.py 마운트, dev는 vite proxy 위임) */ const CAD_APP_URL = "/b07-cad/index.html"; @@ -105,6 +106,10 @@ export function createCadHost { + if (frame.contentWindow) blockNativeMenu(frame.contentWindow, () => true); + }); const license = document.createElement("a"); license.className = "cad-host__license"; license.href = "/b07-cad/THIRD_PARTY_LICENSES.txt"; diff --git a/ui_template/ui_template_native_menu.ts b/ui_template/ui_template_native_menu.ts new file mode 100644 index 000000000..14e7c8f9b --- /dev/null +++ b/ui_template/ui_template_native_menu.ts @@ -0,0 +1,35 @@ +/* ============================================================================= + * ui_template_native_menu.ts + * 브라우저 기본 오른쪽 클릭 메뉴 막기 — 공용 진입(`A00_Common/main.ts`) · CAD iframe(`cad_host.ts`)이 부름. + * + * 창 캡처 단계에서 `preventDefault` 만 함 — 이벤트는 멈추지 않으므로 우리 메뉴 듣기는 그대로 돎. + * 글 입력 칸(input · textarea · contenteditable · 읽기 전용 포함)은 브라우저 메뉴를 살림(붙여넣기 · 맞춤법). + * ⚠ `instanceof` 안 씀 — iframe 문서의 요소는 다른 realm 이라 `instanceof HTMLElement` 가 거짓. + * ========================================================================== */ + +/** 막을 주소 — 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(); + }, + true, + ); +}