feat(common): 오른쪽 클릭 메뉴 공통 양식 — 공용 CSS 한 벌 · 「한 번에 하나」 장부 합침 · 조건 만들기 공용 메뉴로

- 새 ui_template_context_menu.css(.ui-ctx-menu) — 지도 · 표 메뉴와 나무 메뉴가 같은 클래스 · 자리 · z 는 :where 로 페이지가 덮어씀
- 페이지 CSS 4 벌(스프레드시트 · B04 지도 · B05 배수 · 종단)과 나무 메뉴 CSS 의 모양 줄 지움 — 자리 · z 만 남김
- 상태줄 통계 메뉴 흠(CSS 없음) — 공용 클래스 + .ss-statusbar 기준 자리
- 나무 메뉴가 지도 · 표 메뉴 장부에 등록(enterMenu · leaveMenu)
- 조건 만들기 — 가로채기 지움 · 칸 메뉴가 있으면 그 끝 한 줄(ctx.cellMenuExtra) · 없으면(미리보기 편집기) 같은 공용 메뉴에 이 줄만 (PLAN 39-9b)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
This commit is contained in:
2026-10-04 17:17:01 +09:00
co-authored by Claude Opus 5.5
parent 0f8efaa424
commit 277311955b
13 changed files with 146 additions and 227 deletions
@@ -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);
}
+33 -38
View File
@@ -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);
+1 -33
View File
@@ -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()`. */
+1 -28
View File
@@ -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 {
+1 -28
View File
@@ -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 {
+2 -47
View File
@@ -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 {
+66
View File
@@ -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);
}
+25 -10
View File
@@ -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", () => {
+1 -30
View File
@@ -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;
}
+10 -3
View File
@@ -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`;