Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -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 = "";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
}
|
||||
|
||||
.ss-statusbar {
|
||||
position: relative; /* 통계 고르기 메뉴(공용 `.ui-ctx-menu` · absolute)의 기준 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()`. */
|
||||
|
||||
@@ -585,7 +585,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
event.clientX - rect.left,
|
||||
event.clientY - rect.top,
|
||||
);
|
||||
// 계획선이나 관 마커 위에서만 전용 메뉴를 띄운다 — 그 밖은 브라우저 메뉴를 그대로 둔다.
|
||||
// 계획선이나 관 마커 위에서만 전용 메뉴를 띄운다 — 그 밖은 아무 메뉴도 안 뜸(브라우저 메뉴는 공용 진입이 막음).
|
||||
if (opened) event.preventDefault();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -212,7 +212,7 @@ export function bindPipeContextMenu(
|
||||
]);
|
||||
return;
|
||||
}
|
||||
// 계획선에서 먼 자리는 브라우저 기본 메뉴를 그대로 둔다.
|
||||
// 계획선에서 먼 자리는 아무 메뉴도 안 뜸(브라우저 메뉴는 공용 진입이 막음).
|
||||
if (!editor.canAddAt(view, x, y)) return;
|
||||
event.preventDefault();
|
||||
// 빈 자리 — 종단그래프와 같은 구조물군 → 종류 2단 메뉴(2026-08-18 일원화).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
"id": "item1",
|
||||
"단위": "m",
|
||||
"꼴": "수",
|
||||
"일위대가": "UA000006",
|
||||
"일위대가": "UA000008",
|
||||
"자재조합": null,
|
||||
"경로": [
|
||||
"기슭막이",
|
||||
@@ -99,7 +99,7 @@
|
||||
"id": "item3",
|
||||
"단위": null,
|
||||
"꼴": "수",
|
||||
"일위대가": null,
|
||||
"일위대가": "UA000001",
|
||||
"자재조합": null,
|
||||
"경로": [
|
||||
"기슭막이",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }));
|
||||
@@ -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"])
|
||||
@@ -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<D extends CadHostDrawing, M = unknown, Q = unknown
|
||||
frame.className = "cad-host__frame";
|
||||
frame.src = CAD_APP_URL;
|
||||
frame.title = options.title;
|
||||
// CAD 안(리본 · 패널)도 브라우저 기본 메뉴를 막음 — 부모 문서의 듣기는 iframe 에 안 닿음(계획서 39-9a)
|
||||
frame.addEventListener("load", () => {
|
||||
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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<Pick<MapContextMenu, "close" | "contains">>();
|
||||
type MenuEntry = Pick<MapContextMenu, "close" | "contains">;
|
||||
const openMenus = new Set<MenuEntry>();
|
||||
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", () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user