Files
Aislo/ui_template/ui_template_context_menu.ts
T
eomsangdonandClaude Opus 5.5 8d50aa0582 feat(B09): 설계내역서 입력 칸 테두리 되돌림 · 우클릭 「항목 추가」 하나 · 연결 이름 셋 · 「줄 삭제」 · 하위 메뉴 옆 판 (246-1 · 246-2)
- 공용 표 입력 칸 = 241 앞 모양(accent 5% 채움 · td is-can-edit 표시 지움 · 더블클릭 입력 그대로)
- 우클릭 더함 넷 → 「항목 추가」 — 항목 = 바로 아래 같은 부모 · 폴더 = 첫 하위 · 1단 = ← 내어쓰기(직접비 · 관급자재대 앞 · 자재 줄 막음)
- 연결 이름 = 구조물 조합 · 일위대가 사전조합 · 단가산출 · 들여오기 알림 글 쉬운 말 · 「줄 지우기」 → 「줄 삭제」 · 안내 글
- 공용 우클릭 메뉴 children = 「글 ▸」 한 줄 + 오른쪽 옆 판(올림 · 누름 · → 열기 · ← 닫기 · 화면 끝 = 왼쪽)
- 시험 기대값 고침(241_1 · 243_1 · 166_2 · 182 · 184_3 · 204_2) · 새 시험 246_1 · 246_2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-10 21:28:09 +09:00

226 lines
9.5 KiB
TypeScript

/* =============================================================================
* 지도 우클릭 메뉴 (B04 2D 지도 · B05 배수유역도 공용)
*
* 지도 위 객체를 우클릭했을 때 뜨는 작은 메뉴. 뷰포트의 자식으로 넣고 뷰포트 기준 절대
* 위치로 띄운다.
*
* **주의**: 메뉴는 뷰포트 안에 있으므로 메뉴 위에서 난 pointer 이벤트가 지도 조작으로
* 새어 들어간다. 걸러 내지 않으면 메뉴를 닫는 처리가 항목의 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} — 「글 ▸」 한 줄 · 올리거나 누르면
* 오른쪽 옆 새 판(하위 메뉴 · 화면 오른쪽 끝이면 왼쪽) · → 열기 · ← 닫기(PLAN 246-2).
* 구분선 = `"-"` — 맨 앞 · 맨 뒤 · 겹친 것은 안 그림. */
export type MapContextMenuItem =
| [label: string, action: () => void]
| { label: string; children: Array<[label: string, action: () => void]> }
| "-";
export interface MapContextMenu {
/** 뷰포트에 append할 요소. */
element: HTMLElement;
/** 위치는 뷰포트 기준 px. */
open: (x: number, y: number, items: MapContextMenuItem[]) => void;
close: () => void;
/** 이벤트가 메뉴 안에서 났는지. 지도 조작에서 걸러 내는 데 쓴다. */
contains: (target: EventTarget | null) => boolean;
}
/* ── 전역 단일 메뉴 규칙 (2026-08-19 사용자 지시) ────────────────────────────
* 종단그래프·배수유역도·3D 어디서 열리든 우클릭 메뉴는 **한 번에 하나**다 — 새로
* 열리면 다른 영역의 메뉴는 닫힌다. 좌클릭(0)·중간버튼(1)은 어디서 눌러도 열린
* 메뉴를 닫는다. 캡처 단계로 들어 지도 캔버스가 이벤트를 삼켜도 닫힌다. */
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;
document.addEventListener(
"pointerdown",
(event) => {
openMenus.forEach((menu) => {
if (!menu.contains(event.target)) menu.close();
});
},
true,
);
// Esc 도 열린 메뉴를 닫음 — 메뉴가 열렸을 때만 키를 먹음(편집 취소 등 아래로 안 샘)
document.addEventListener(
"keydown",
(event) => {
if (event.key !== "Escape" || !openMenus.size) return;
openMenus.forEach((menu) => menu.close());
event.stopPropagation();
event.preventDefault();
},
true,
);
}
/** `blockClass`는 페이지 CSS 블록명(예: `b04-map`, `b05-drainage`). */
export function createMapContextMenu(blockClass: string): MapContextMenu {
bindGlobalClose();
const element = document.createElement("div");
element.className = `${blockClass}__context-menu ui-ctx-menu`;
element.hidden = true;
// 메뉴 안 click 은 메뉴에서 끝냄 — 그릇(종단 그래프 등)의 「빈 곳 클릭 = 선택 해제」로 새면
// 폼이 리셋되고 다시 그려져 메뉴가 사라짐(54-49).
element.addEventListener("click", (event) => event.stopPropagation());
const registryEntry = {
close,
contains: (target: EventTarget | null) => target instanceof Node && element.contains(target),
};
function close(): void {
element.hidden = true;
// 항목까지 비운다 — 남겨 두면 다음에 열었을 때 옛 동작이 함께 걸린다.
element.replaceChildren();
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 ui-ctx-menu__item`;
if (!sub) entry.addEventListener("pointerenter", closeSide); // 다른 줄로 옮기면 옆 판 닫힘
entry.textContent = label;
entry.addEventListener("click", () => {
close();
action();
});
return entry;
}
/** 옆 판(하위 메뉴) 닫음 — 머리 줄 표시도 풂 */
function closeSide(): void {
element.querySelector(".ui-ctx-menu__side")?.remove();
element
.querySelectorAll<HTMLElement>(".ui-ctx-menu__item.is-open")
.forEach((head) => head.classList.remove("is-open"));
}
/** 옆 판 자리 — 머리 줄 오른쪽(넘치면 메뉴 왼쪽) · 아래 끝은 올림 · fixed(메뉴 overflow 에 안 잘림) ·
* 변환된 그릇 안이면 실제 자리와 어긋난 만큼 바로잡음 */
function placeSide(side: HTMLElement, head: HTMLElement): void {
const view = element.ownerDocument.defaultView ?? window;
const menu = element.getBoundingClientRect();
let x = menu.right;
let y = head.getBoundingClientRect().top;
side.style.left = `${x}px`;
side.style.top = `${y}px`;
const box = side.getBoundingClientRect();
const [ox, oy] = [box.left - x, box.top - y];
y -= (side.firstElementChild?.getBoundingClientRect().top ?? box.top) - box.top; // 첫 줄 = 머리 줄 높이
if (x + box.width > view.innerWidth - 4) x = Math.max(4, menu.left - box.width);
if (y + box.height > view.innerHeight - 4) y = Math.max(4, view.innerHeight - 4 - box.height);
side.style.left = `${x - ox}px`;
side.style.top = `${y - oy}px`;
}
/** 하위 메뉴 머리 줄 — 올림 · 누름 · → = 옆 판 · 옆 판 ← = 닫고 머리로 */
function buildGroup(label: string, children: Array<[string, () => void]>): HTMLButtonElement {
const head = document.createElement("button");
head.type = "button";
head.className = `${blockClass}__context-menu-item ui-ctx-menu__item is-group`;
head.setAttribute("aria-haspopup", "menu");
head.textContent = `${label} ▸`;
const show = (focus: boolean): void => {
if (!head.classList.contains("is-open")) {
closeSide();
const side = document.createElement("div");
side.className = `${blockClass}__context-menu-sub ui-ctx-menu ui-ctx-menu__side`;
side.style.position = "fixed";
side.setAttribute("role", "menu");
children.forEach(([text, action]) => side.append(buildLeaf(text, action, true)));
side.addEventListener("keydown", (event) => {
if (event.key !== "ArrowLeft") return;
event.preventDefault();
event.stopPropagation();
closeSide();
head.focus();
});
element.append(side);
head.classList.add("is-open");
placeSide(side, head);
}
if (focus) element.querySelector<HTMLElement>(".ui-ctx-menu__side button")?.focus();
};
head.addEventListener("pointerenter", () => show(false));
head.addEventListener("click", () => show(true));
head.addEventListener("keydown", (event) => {
if (event.key !== "ArrowRight") return;
event.preventDefault();
event.stopPropagation();
show(true);
});
return head;
}
/** 화면 밖으로 나가면 안쪽으로 당김(오른쪽 · 아래 끝) — 열 때 */
function fit(): void {
const x = parseFloat(element.style.left);
const y = parseFloat(element.style.top);
const box = element.getBoundingClientRect();
const view = element.ownerDocument.defaultView ?? window; // 새 창에 옮겨진 패널(54-35)
const dx = Math.max(0, box.right - view.innerWidth + 4);
const dy = Math.max(0, box.bottom - view.innerHeight + 4);
if (dx) element.style.left = `${Math.max(x - dx, x - box.left)}px`;
if (dy) element.style.top = `${Math.max(y - dy, y - box.top)}px`;
}
return {
element,
open(x, y, items) {
// 다른 영역에 열려 있던 메뉴는 닫는다 — 전역에 하나만(2026-08-19 사용자 지시).
enterMenu(registryEntry);
element.replaceChildren();
items.forEach((item) => {
if (item === "-") {
const last = element.lastElementChild;
if (last && !last.classList.contains("ui-ctx-menu__sep"))
element.append(
Object.assign(document.createElement("div"), { className: "ui-ctx-menu__sep" }),
);
return;
}
if (Array.isArray(item)) {
element.append(buildLeaf(item[0], item[1]));
return;
}
// 하위 메뉴 = 머리 줄 하나 · 옆 판(한 번에 하나)
element.append(buildGroup(item.label, item.children));
});
if (element.lastElementChild?.classList.contains("ui-ctx-menu__sep"))
element.lastElementChild.remove();
element.style.left = `${x}px`;
element.style.top = `${y}px`;
element.hidden = false;
fit();
},
close,
contains: (target) => target instanceof Node && element.contains(target),
};
}