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/A00_Common/spreadsheet/spreadsheet_condition.css b/A00_Common/spreadsheet/spreadsheet_condition.css index d98c625e3..3ac1140fa 100644 --- a/A00_Common/spreadsheet/spreadsheet_condition.css +++ b/A00_Common/spreadsheet/spreadsheet_condition.css @@ -1,13 +1,3 @@ -.ss-cond-menu { - position: fixed; - z-index: 10000; - padding: var(--spacing-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface); - box-shadow: var(--shadow-lg); -} - .ss-cond { min-width: min(46rem, 96vw); } diff --git a/A00_Common/spreadsheet/spreadsheet_condition.ts b/A00_Common/spreadsheet/spreadsheet_condition.ts index 628fd3d4f..f53abb816 100644 --- a/A00_Common/spreadsheet/spreadsheet_condition.ts +++ b/A00_Common/spreadsheet/spreadsheet_condition.ts @@ -7,6 +7,7 @@ * 글 [한, 영] 짝은 아래 TEXT * ========================================================================== */ +import { createMapContextMenu } from "@ui/ui_template_context_menu"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; @@ -316,52 +317,46 @@ function openJob( }); } -/** 칸 우클릭에 「조건 만들기」 한 줄 — conditions 를 준 편집기에만 붙음 */ +/** 「조건 만들기」 한 줄 — conditions 를 준 편집기에만 붙음(계획서 39-9b). + * 칸 메뉴(`spreadsheet_menu.ts`)가 있으면 그 끝 한 줄 · 없으면(미리보기 편집기) 같은 공용 메뉴에 이 줄만 */ export function attachCondition(ctx: SpreadsheetContext, opts: ConditionOptions): PartHandle { - let menu: HTMLElement | null = null; - const close = (): void => { - menu?.remove(); - menu = null; - document.removeEventListener("pointerdown", away, true); - }; - const away = (e: Event): void => { - if (menu && !menu.contains(e.target as Node)) close(); - }; - const onContext = (e: MouseEvent): void => { - const hit = ctx.grid.hitTest(e.clientX, e.clientY); - if (!hit || hit.kind !== "cell" || ctx.readOnly) return; - e.preventDefault(); - e.stopPropagation(); - close(); - const a1 = toA1(hit.r, hit.c); - const sheet = ctx.sheet(); - const cell = sheet.칸[a1]; - menu = el("div", { className: "ss-cond-menu" }); - menu.style.left = `${e.clientX}px`; - menu.style.top = `${e.clientY}px`; - menu.append( - createButton({ - label: tc("Menu"), - variant: "ghost", - size: "sm", - onClick: () => { - close(); - const existing = cell?.식 ?? null; + const items = (r: number, c: number): Array<[string, () => void]> => { + if (ctx.readOnly) return []; + const a1 = toA1(r, c); + return [ + [ + tc("Menu"), + () => { + const existing = ctx.sheet().칸[a1]?.식 ?? null; const draft = existing !== null ? formulaToDraft(existing) : null; openJob(ctx, opts, a1, draft, existing !== null && !draft ? existing : null); }, - }), - ); - document.body.append(menu); - document.addEventListener("pointerdown", away, true); + ], + ]; }; - ctx.root.addEventListener("contextmenu", onContext, true); + if (ctx.cellMenuExtra) { + ctx.cellMenuExtra = items; + return { root: null, refresh() {}, destroy: () => (ctx.cellMenuExtra = () => []) }; + } + const menu = createMapContextMenu("ss"); + ctx.root.append(menu.element); + const onContext = (e: MouseEvent): void => { + const hit = ctx.grid.hitTest(e.clientX, e.clientY); + if (!hit || hit.kind !== "cell") return; + const list = items(hit.r, hit.c); + if (!list.length) return; + e.preventDefault(); + const box = ctx.root.getBoundingClientRect(); + menu.open(e.clientX - box.left, e.clientY - box.top, list); + }; + ctx.root.addEventListener("contextmenu", onContext); return { - root: null, + root: menu.element, refresh() {}, destroy() { - ctx.root.removeEventListener("contextmenu", onContext, true); - close(); + ctx.root.removeEventListener("contextmenu", onContext); + menu.close(); + menu.element.remove(); }, }; } diff --git a/A00_Common/spreadsheet/spreadsheet_menu.ts b/A00_Common/spreadsheet/spreadsheet_menu.ts index 39293393d..4527ae2d9 100644 --- a/A00_Common/spreadsheet/spreadsheet_menu.ts +++ b/A00_Common/spreadsheet/spreadsheet_menu.ts @@ -32,6 +32,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { const menu = createMapContextMenu("ss"); ctx.root.append(menu.element); + ctx.cellMenuExtra ??= () => []; // 칸 메뉴 있음 — 조건 만들기가 이 끝에 붙음 function isMerged(range: CellRange): boolean { return (ctx.sheet().병합 ?? []).includes(rangeToA1(range)); @@ -106,6 +107,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { ...(note ? ([[st("MenuCommentDelete"), () => deleteComment(ctx, r, c)]] as MapContextMenuItem[]) : []), + ...(ctx.cellMenuExtra?.(r, c) ?? []), ]; } diff --git a/A00_Common/spreadsheet/spreadsheet_stage3.css b/A00_Common/spreadsheet/spreadsheet_stage3.css index d7420bd72..e50db4efe 100644 --- a/A00_Common/spreadsheet/spreadsheet_stage3.css +++ b/A00_Common/spreadsheet/spreadsheet_stage3.css @@ -93,6 +93,7 @@ } .ss-statusbar { + position: relative; /* 통계 고르기 메뉴(공용 `.ui-ctx-menu` · absolute)의 기준 */ display: flex; align-items: center; gap: var(--spacing-8); diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.css b/A00_Common/spreadsheet/spreadsheet_toolbar.css index f9776ec1f..84bd546d5 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.css +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.css @@ -241,39 +241,7 @@ cursor: pointer; } -/* ── 우클릭 메뉴(그리드 · 탭 공용, `ui_template_context_menu.ts`) ───────────── */ - -.ss__context-menu { - position: absolute; - z-index: var(--z-dropdown); - display: flex; - flex-direction: column; - min-width: 160px; - padding: var(--spacing-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); - box-shadow: var(--shadow-lg); -} - -.ss__context-menu[hidden] { - display: none; -} - -.ss__context-menu-item { - padding: var(--spacing-8) var(--spacing-12); - border: 0; - border-radius: var(--radius-sm); - background: transparent; - color: var(--color-text-body); - font-size: 13px; - text-align: left; - cursor: pointer; -} - -.ss__context-menu-item:hover { - background: var(--color-surface-sunken, var(--color-mist-violet)); -} +/* ── 우클릭 메뉴(그리드 · 탭 공용, `ui_template_context_menu.ts`) — 모양 · 자리는 공용 `.ui-ctx-menu` ── */ .ss__context-menu-item.is-danger { color: var(--color-danger); diff --git a/A00_Common/spreadsheet/spreadsheet_view_types.ts b/A00_Common/spreadsheet/spreadsheet_view_types.ts index 3695a4b4c..1f5ccb96a 100644 --- a/A00_Common/spreadsheet/spreadsheet_view_types.ts +++ b/A00_Common/spreadsheet/spreadsheet_view_types.ts @@ -109,6 +109,9 @@ export interface SpreadsheetContext { showSheet(sheetId: string): void; grid: GridHandle; editor: EditorHandle; + /** 칸 메뉴 끝에 붙일 줄 — 칸 메뉴(`spreadsheet_menu.ts`)가 빈 것으로 세움(= 칸 메뉴 있음) · + * 조건 만들기(`spreadsheet_condition.ts`)가 채움 · 없으면 칸 메뉴 없는 편집기 */ + cellMenuExtra?: (r: number, c: number) => Array<[label: string, action: () => void]>; } /** E 부품 — D 가 `mountToolbar(ctx)` 등으로 붙이고 고름 · 문서가 바뀌면 `refresh()`. */ 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/B04_PreProcess/B04_PreProcess_UI_Style_Map.css b/B04_PreProcess/B04_PreProcess_UI_Style_Map.css index b2f0bc28f..72c8079fd 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Style_Map.css +++ b/B04_PreProcess/B04_PreProcess_UI_Style_Map.css @@ -87,37 +87,10 @@ box-shadow: inset 0 0 0 1px var(--b04-layer-color); } -/* 관 매설 우클릭 메뉴 — 지도 뷰포트 기준 절대 위치. */ +/* 관 매설 우클릭 메뉴 — 지도 뷰포트 기준 절대 위치 · 모양은 공용 `.ui-ctx-menu`. */ .b04-map__context-menu { position: absolute; z-index: 6; - display: flex; - flex-direction: column; - min-width: 140px; - padding: var(--spacing-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); - box-shadow: 0 4px 16px rgb(0 0 0 / 25%); -} - -.b04-map__context-menu[hidden] { - display: none; -} - -.b04-map__context-menu-item { - padding: var(--spacing-8) var(--spacing-12); - border: 0; - border-radius: var(--radius-cards); - background: transparent; - color: var(--color-text-body); - font-size: 13px; - text-align: left; - cursor: pointer; -} - -.b04-map__context-menu-item:hover { - background: var(--color-surface-sunken); } .b04-map__viewport { 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_Style_Drainage.css b/B05_Profile/B05_Profile_UI_Style_Drainage.css index 0581d21e6..17e1ea15f 100644 --- a/B05_Profile/B05_Profile_UI_Style_Drainage.css +++ b/B05_Profile/B05_Profile_UI_Style_Drainage.css @@ -111,37 +111,10 @@ opacity: 1; } -/* 배관 우클릭 메뉴 — 뷰포트 기준 절대 위치(B04 지도와 같은 조작). */ +/* 배관 우클릭 메뉴 — 뷰포트 기준 절대 위치(B04 지도와 같은 조작) · 모양은 공용 `.ui-ctx-menu`. */ .b05-drainage__context-menu { position: absolute; z-index: 6; - display: flex; - flex-direction: column; - min-width: 130px; - padding: var(--spacing-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); - box-shadow: 0 4px 16px rgb(0 0 0 / 25%); -} - -.b05-drainage__context-menu[hidden] { - display: none; -} - -.b05-drainage__context-menu-item { - padding: var(--spacing-8) var(--spacing-12); - border: 0; - border-radius: var(--radius-cards); - background: transparent; - color: var(--color-text-body); - font-size: 13px; - text-align: left; - cursor: pointer; -} - -.b05-drainage__context-menu-item:hover { - background: var(--color-surface-sunken); } .b05-drainage__viewport { diff --git a/B05_Profile/B05_Profile_UI_Style_Table.css b/B05_Profile/B05_Profile_UI_Style_Table.css index 13ffd17ed..fb7222a9f 100644 --- a/B05_Profile/B05_Profile_UI_Style_Table.css +++ b/B05_Profile/B05_Profile_UI_Style_Table.css @@ -194,62 +194,17 @@ /* ─── 선택된 비정규 측점 값 열 오버레이 (규칙 열과 같은 12행) ────────────── 세로 점선·구조물 태그 없이, 선택 시에만 값 열을 테이블 위에 겹쳐 보여준다. */ -/* ── 구조물 우클릭 메뉴 (그래프 위 — 배관 추가 · 구조물 삭제) ─────────────── */ +/* ── 구조물 우클릭 메뉴 (그래프 위 — 배관 추가 · 구조물 삭제) · 모양은 공용 `.ui-ctx-menu` ── */ .b05-profile-chart__context-menu { position: absolute; z-index: 7; - display: flex; - flex-direction: column; - min-width: 120px; - padding: var(--spacing-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface-raised); - box-shadow: 0 4px 16px rgb(0 0 0 / 25%); } -.b05-profile-chart__context-menu[hidden] { - display: none; -} - -.b05-profile-chart__context-menu-item { - padding: var(--spacing-4) var(--spacing-8); - border: 0; - border-radius: var(--radius-cards); - background: transparent; - color: var(--color-text-body); - font-size: 12px; - text-align: left; - cursor: pointer; -} - -.b05-profile-chart__context-menu-item:hover { - background: var(--color-surface-sunken); -} - -/* 구조물군 하위 메뉴(아코디언, 2026-08-18 메뉴 일원화) — 머리글 굵게, 종류는 들여쓰기. - 종류가 많은 군이 펼쳐지면 메뉴가 화면을 넘을 수 있어 최대 높이에서 안쪽 스크롤. */ +/* 구조물군 하위 메뉴(아코디언) — 종류가 많은 군이 펼쳐지면 최대 높이에서 안쪽 스크롤. */ .b05-profile-chart__context-menu, .b05-drainage__context-menu { max-height: 320px; - overflow-y: auto; -} - -.b05-profile-chart__context-menu-item.is-group, -.b05-drainage__context-menu-item.is-group { - font-weight: var(--font-weight-medium); -} - -.b05-profile-chart__context-menu-sub, -.b05-drainage__context-menu-sub { - display: flex; - flex-direction: column; -} - -.b05-profile-chart__context-menu-item.is-sub, -.b05-drainage__context-menu-item.is-sub { - padding-left: var(--spacing-24); } .b05-profile-table__irregular-col { 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/master_template/table/구조물집계표.json b/resources/master_template/table/구조물집계표.json index 934753089..18996f1df 100644 --- a/resources/master_template/table/구조물집계표.json +++ b/resources/master_template/table/구조물집계표.json @@ -53,7 +53,7 @@ "id": "item1", "단위": "m", "꼴": "수", - "일위대가": "UA000006", + "일위대가": "UA000008", "자재조합": null, "경로": [ "기슭막이", @@ -99,7 +99,7 @@ "id": "item3", "단위": null, "꼴": "수", - "일위대가": null, + "일위대가": "UA000001", "자재조합": null, "경로": [ "기슭막이", diff --git a/resources/master_template/구조물집계표조합.json b/resources/master_template/구조물집계표조합.json index 57057ef56..119f6b4be 100644 --- a/resources/master_template/구조물집계표조합.json +++ b/resources/master_template/구조물집계표조합.json @@ -74,7 +74,7 @@ "분류2": "1.2m", "분류3": "aaa", "단위": "m", - "일위대가": "UA000006", + "일위대가": "UA000008", "자재조합": null, "도번": "SD-000001", "면적번": "SA-000001", @@ -120,7 +120,7 @@ "분류2": null, "분류3": null, "단위": null, - "일위대가": null, + "일위대가": "UA000001", "자재조합": null, "도번": "SD-000003", "면적번": "SA-000003", 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_context_menu.css b/ui_template/ui_template_context_menu.css new file mode 100644 index 000000000..2c0bd6eef --- /dev/null +++ b/ui_template/ui_template_context_menu.css @@ -0,0 +1,66 @@ +/* ============================================================================= + * 오른쪽 클릭 메뉴 공용 모양 — 지도 · 표 메뉴(`ui_template_context_menu.ts`) · 나무 메뉴(`ui_template_tree.ts`) 한 벌. + * 자리 · z · 최대 높이는 `:where()`(0 점) — 페이지 · 나무 클래스가 덮어씀(지도는 뷰포트 기준 absolute · 나무는 fixed). + * ========================================================================== */ + +:where(.ui-ctx-menu) { + position: absolute; + z-index: var(--z-dropdown); + max-height: calc(100vh - 8px); +} + +.ui-ctx-menu { + display: flex; + flex-direction: column; + min-width: 160px; + padding: var(--spacing-4); + overflow-y: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + box-shadow: var(--shadow-lg); +} + +.ui-ctx-menu[hidden] { + display: none; +} + +.ui-ctx-menu__item { + padding: var(--spacing-8) var(--spacing-12); + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-body); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.ui-ctx-menu__item:hover:not(:disabled), +.ui-ctx-menu__item:focus-visible { + background: var(--color-surface-sunken, var(--color-mist-violet)); +} + +.ui-ctx-menu__item:disabled { + color: var(--color-text-secondary); + cursor: not-allowed; +} + +/* 하위 메뉴(아코디언) — 머리글 굵게 · 종류는 들여쓰기 */ +.ui-ctx-menu__item.is-group { + font-weight: var(--font-weight-medium); +} + +.ui-ctx-menu__sub { + display: flex; + flex-direction: column; +} + +.ui-ctx-menu__sub[hidden] { + display: none; +} + +.ui-ctx-menu__item.is-sub { + padding-left: var(--spacing-24); +} diff --git a/ui_template/ui_template_context_menu.ts b/ui_template/ui_template_context_menu.ts index 60ebf277f..9b7ece1b4 100644 --- a/ui_template/ui_template_context_menu.ts +++ b/ui_template/ui_template_context_menu.ts @@ -8,8 +8,11 @@ * 새어 들어간다. 걸러 내지 않으면 메뉴를 닫는 처리가 항목의 click보다 먼저 돌아 아무 일도 * 일어나지 않는다(2026-08-01 B04에서 실제로 발생). 그래서 `contains()`를 함께 제공한다 — * 지도 쪽 pointerdown/pointerup/contextmenu는 이 판정으로 먼저 걸러야 한다. + * 모양은 공용 CSS 한 벌(`ui_template_context_menu.css` · `.ui-ctx-menu`) — 나무 메뉴와 같음(계획서 39-9b). * ========================================================================== */ +import "./ui_template_context_menu.css"; + /** 평면 항목 = [문구, 동작]. 하위 메뉴 항목 = {label, children} — 누르면 바로 아래로 * 펼쳐지는 아코디언(구조물군 → 종류, 2026-08-18 B05 우클릭 메뉴 일원화). */ export type MapContextMenuItem = @@ -30,9 +33,24 @@ export interface MapContextMenu { * 종단그래프·배수유역도·3D 어디서 열리든 우클릭 메뉴는 **한 번에 하나**다 — 새로 * 열리면 다른 영역의 메뉴는 닫힌다. 좌클릭(0)·중간버튼(1)은 어디서 눌러도 열린 * 메뉴를 닫는다. 캡처 단계로 들어 지도 캔버스가 이벤트를 삼켜도 닫힌다. */ -const openMenus = new Set>(); +type MenuEntry = Pick; +const openMenus = new Set(); let globalCloseBound = false; +/** 다른 메뉴를 닫고 장부에 올림 — 나무 메뉴(`ui_template_tree.ts`)도 같은 장부(한 번에 하나) */ +export function enterMenu(entry: MenuEntry): void { + bindGlobalClose(); + openMenus.forEach((menu) => { + if (menu !== entry) menu.close(); + }); + openMenus.add(entry); +} + +/** 닫힌 메뉴를 장부에서 뺌 */ +export function leaveMenu(entry: MenuEntry): void { + openMenus.delete(entry); +} + function bindGlobalClose(): void { if (globalCloseBound) return; globalCloseBound = true; @@ -62,7 +80,7 @@ function bindGlobalClose(): void { export function createMapContextMenu(blockClass: string): MapContextMenu { bindGlobalClose(); const element = document.createElement("div"); - element.className = `${blockClass}__context-menu`; + element.className = `${blockClass}__context-menu ui-ctx-menu`; element.hidden = true; const registryEntry = { @@ -74,13 +92,13 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { element.hidden = true; // 항목까지 비운다 — 남겨 두면 다음에 열었을 때 옛 동작이 함께 걸린다. element.replaceChildren(); - openMenus.delete(registryEntry); + leaveMenu(registryEntry); } function buildLeaf(label: string, action: () => void, sub = false): HTMLButtonElement { const entry = document.createElement("button"); entry.type = "button"; - entry.className = `${blockClass}__context-menu-item`; + entry.className = `${blockClass}__context-menu-item ui-ctx-menu__item`; if (sub) entry.classList.add("is-sub"); entry.textContent = label; entry.addEventListener("click", () => { @@ -94,10 +112,7 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { element, open(x, y, items) { // 다른 영역에 열려 있던 메뉴는 닫는다 — 전역에 하나만(2026-08-19 사용자 지시). - openMenus.forEach((menu) => { - if (menu !== registryEntry) menu.close(); - }); - openMenus.add(registryEntry); + enterMenu(registryEntry); element.replaceChildren(); items.forEach((item) => { if (Array.isArray(item)) { @@ -108,10 +123,10 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { // 한 번에 한 군만 연다(다른 군을 열면 앞의 군은 접힘). const head = document.createElement("button"); head.type = "button"; - head.className = `${blockClass}__context-menu-item is-group`; + head.className = `${blockClass}__context-menu-item ui-ctx-menu__item is-group`; head.textContent = `${item.label} ▸`; const body = document.createElement("div"); - body.className = `${blockClass}__context-menu-sub`; + body.className = `${blockClass}__context-menu-sub ui-ctx-menu__sub`; body.hidden = true; item.children.forEach(([label, action]) => body.append(buildLeaf(label, action, true))); head.addEventListener("click", () => { 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, + ); +} diff --git a/ui_template/ui_template_tree.css b/ui_template/ui_template_tree.css index 23f1bec7e..6a1c130a6 100644 --- a/ui_template/ui_template_tree.css +++ b/ui_template/ui_template_tree.css @@ -56,37 +56,8 @@ font-size: var(--text-caption); } -/* 오른쪽 클릭 메뉴 — 모달(z-index 1000) 위 */ +/* 오른쪽 클릭 메뉴 — 모달(z-index 1000) 위 · 모양은 공용 `.ui-ctx-menu`(ui_template_context_menu.css) */ .ui-tree__menu { position: fixed; z-index: 1100; - display: flex; - flex-direction: column; - min-width: 10rem; - padding: var(--spacing-4); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-surface-raised); - box-shadow: var(--shadow-lg); -} - -.ui-tree__menu-item { - padding: var(--spacing-8) var(--spacing-12); - border: 0; - border-radius: var(--radius-md); - background: none; - color: var(--color-text); - font: inherit; - text-align: left; - cursor: pointer; -} - -.ui-tree__menu-item:hover:not(:disabled), -.ui-tree__menu-item:focus-visible { - background: var(--color-surface-sunken); -} - -.ui-tree__menu-item:disabled { - color: var(--color-text-secondary); - cursor: not-allowed; } diff --git a/ui_template/ui_template_tree.ts b/ui_template/ui_template_tree.ts index d19f6d532..0da561078 100644 --- a/ui_template/ui_template_tree.ts +++ b/ui_template/ui_template_tree.ts @@ -7,6 +7,7 @@ * ========================================================================== */ import "./ui_template_tree.css"; +import { enterMenu, leaveMenu } from "./ui_template_context_menu"; import { el } from "./ui_template_elements"; export interface TreeNode { @@ -43,10 +44,15 @@ export interface TreeMenuItem { /** 오른쪽 클릭 자리에 작은 메뉴 — 바깥 누르기 · Esc · 스크롤 = 닫힘 */ export function openTreeMenu(event: MouseEvent, items: TreeMenuItem[]): void { - document.querySelector(".ui-tree__menu")?.remove(); - const menu = el("div", { className: "ui-tree__menu", attrs: { role: "menu" } }); + const menu = el("div", { className: "ui-tree__menu ui-ctx-menu", attrs: { role: "menu" } }); + // 장부 = 지도 · 표 메뉴와 한 벌(한 번에 하나 · 계획서 39-9b) — 앞 나무 메뉴도 여기서 닫힘 + const entry = { + close: () => close(), + contains: (t: EventTarget | null) => t instanceof Node && menu.contains(t), + }; const close = (): void => { menu.remove(); + leaveMenu(entry); document.removeEventListener("pointerdown", onOutside, true); document.removeEventListener("keydown", onKey, true); document.removeEventListener("scroll", close, true); @@ -62,7 +68,7 @@ export function openTreeMenu(event: MouseEvent, items: TreeMenuItem[]): void { }; for (const item of items) { const button = el("button", { - className: "ui-tree__menu-item", + className: "ui-tree__menu-item ui-ctx-menu__item", text: item.label, attrs: { type: "button", role: "menuitem" }, }); @@ -77,6 +83,7 @@ export function openTreeMenu(event: MouseEvent, items: TreeMenuItem[]): void { } menu.append(button); } + enterMenu(entry); document.body.append(menu); const room = menu.getBoundingClientRect(); menu.style.left = `${Math.max(4, Math.min(event.clientX, innerWidth - room.width - 4))}px`;