- 새 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
162 lines
6.3 KiB
TypeScript
162 lines
6.3 KiB
TypeScript
/* =============================================================================
|
|
* ui_template_tree.ts
|
|
* 공용 나무 보기 — 부모(접기/펴기) · 자식(깊이 들임) · 잎 고름. M01 왼쪽 패널과 항목 고르기가 함께 씀.
|
|
* 줄 모양은 부르는 쪽이 `MakeRow` 로 정함(기본 줄 = `createTreeRow`).
|
|
* 오른쪽 클릭 메뉴 = 줄마다 `menu()` 가 **메뉴 항목을 밖에서 줌**(고르기 모달 · 관리 화면 나무가 같은 자리를 씀 ·
|
|
* 26-3) — 항목이 없으면 브라우저 기본 메뉴 그대로.
|
|
* ========================================================================== */
|
|
|
|
import "./ui_template_tree.css";
|
|
import { enterMenu, leaveMenu } from "./ui_template_context_menu";
|
|
import { el } from "./ui_template_elements";
|
|
|
|
export interface TreeNode {
|
|
id: string;
|
|
label: string;
|
|
/** 줄 수 — null 이면 안 보임 */
|
|
count: number | null;
|
|
/** 눌렀을 때 — 없으면 접기/펴기만 */
|
|
run?: () => void;
|
|
children?: TreeNode[];
|
|
/** 처음부터 펴짐 */
|
|
open?: boolean;
|
|
/** 마우스를 올렸을 때 글(기본 줄) */
|
|
tip?: string;
|
|
/** 오른쪽 클릭 메뉴 항목 — 부를 때마다 새로 받음 · 빈 목록 · 없음 = 메뉴 없음(기본 줄) */
|
|
menu?: () => TreeMenuItem[];
|
|
/** 자식이 없어도 폴더(빈 폴더) — 폴더 아이콘 · 굵은 글(기본 줄) */
|
|
folder?: boolean;
|
|
}
|
|
|
|
/** 기본 줄 아이콘 — 폴더 · 항목(글꼴색을 따름) */
|
|
const ICON = {
|
|
folder:
|
|
'<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M1.5 3.5h4l1.5 1.5h7.5v8h-13z" fill="none" stroke="currentColor" stroke-linejoin="round"/></svg>',
|
|
leaf: '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M3.5 1.5h6l3 3v10h-9z M9.5 1.5v3h3" fill="none" stroke="currentColor" stroke-linejoin="round"/></svg>',
|
|
};
|
|
|
|
export interface TreeMenuItem {
|
|
label: string;
|
|
run: () => void;
|
|
/** 못 누르는 까닭 — 있으면 흐리고 마우스를 올리면 까닭이 보임 */
|
|
disabledReason?: string;
|
|
}
|
|
|
|
/** 오른쪽 클릭 자리에 작은 메뉴 — 바깥 누르기 · Esc · 스크롤 = 닫힘 */
|
|
export function openTreeMenu(event: MouseEvent, items: TreeMenuItem[]): void {
|
|
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);
|
|
};
|
|
const onOutside = (down: Event): void => {
|
|
if (!menu.contains(down.target as Node)) close();
|
|
};
|
|
const onKey = (key: KeyboardEvent): void => {
|
|
if (key.key === "Escape") {
|
|
key.stopPropagation(); // 모달의 Esc(취소)까지 가지 않게
|
|
close();
|
|
}
|
|
};
|
|
for (const item of items) {
|
|
const button = el("button", {
|
|
className: "ui-tree__menu-item ui-ctx-menu__item",
|
|
text: item.label,
|
|
attrs: { type: "button", role: "menuitem" },
|
|
});
|
|
if (item.disabledReason) {
|
|
button.disabled = true;
|
|
button.title = item.disabledReason;
|
|
} else {
|
|
button.addEventListener("click", () => {
|
|
close();
|
|
item.run();
|
|
});
|
|
}
|
|
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`;
|
|
menu.style.top = `${Math.max(4, Math.min(event.clientY, innerHeight - room.height - 4))}px`;
|
|
document.addEventListener("pointerdown", onOutside, true);
|
|
document.addEventListener("keydown", onKey, true);
|
|
document.addEventListener("scroll", close, true);
|
|
}
|
|
|
|
/** 줄(버튼) 만들기 — 돌려받은 것 = 그 버튼 · click 은 부르는 쪽이 이음 */
|
|
export type MakeRow = (
|
|
node: TreeNode,
|
|
caret: string,
|
|
depth: number,
|
|
onClick: () => void,
|
|
) => HTMLElement;
|
|
|
|
export function renderTree(nodes: TreeNode[], make: MakeRow, depth = 0): HTMLElement[] {
|
|
return nodes.map((node) => {
|
|
if (!node.children?.length) return make(node, "", depth, () => node.run?.());
|
|
const kids = el("div", {
|
|
className: "ui-tree__kids",
|
|
children: renderTree(node.children, make, depth + 1),
|
|
});
|
|
kids.style.setProperty("--tree-depth", String(depth)); // 들여쓰기 안내선 자리(CSS)
|
|
kids.hidden = !node.open;
|
|
const row = make(node, kids.hidden ? "▸" : "▾", depth, () => {
|
|
kids.hidden = !kids.hidden;
|
|
const caret = row.querySelector(".ui-tree__caret");
|
|
if (caret) caret.textContent = kids.hidden ? "▸" : "▾";
|
|
node.run?.();
|
|
});
|
|
return el("div", { className: "ui-tree__branch", children: [row, kids] });
|
|
});
|
|
}
|
|
|
|
/** 기본 줄 — 화살표 · 폴더/항목 아이콘 · 글(폴더는 굵게) · 줄 수 · 깊이 들임.
|
|
* 잎 고름 표시(`is-active`)는 부르는 쪽이 켬. */
|
|
export function createTreeRow(
|
|
node: TreeNode,
|
|
caret: string,
|
|
depth: number,
|
|
onClick: () => void,
|
|
): HTMLButtonElement {
|
|
const branch = !!node.children?.length || !!node.folder;
|
|
const icon = el("span", { className: "ui-tree__icon" });
|
|
icon.innerHTML = branch ? ICON.folder : ICON.leaf; // 고정 SVG 글만
|
|
const button = el("button", {
|
|
className: `ui-tree__row ui-sidebar-row${branch ? " is-branch" : ""}`,
|
|
attrs: { type: "button" },
|
|
children: [
|
|
el("span", {
|
|
className: "ui-tree__label",
|
|
children: [
|
|
el("span", { className: "ui-tree__caret", text: caret }),
|
|
icon,
|
|
el("span", { text: node.label }),
|
|
],
|
|
}),
|
|
...(node.count === null
|
|
? []
|
|
: [el("span", { className: "ui-tree__count", text: node.count.toLocaleString("ko-KR") })]),
|
|
],
|
|
});
|
|
button.style.paddingLeft = `calc(var(--spacing-8) + ${depth} * var(--spacing-16))`;
|
|
if (node.tip) button.title = node.tip;
|
|
button.addEventListener("click", onClick);
|
|
button.addEventListener("contextmenu", (event) => {
|
|
const items = node.menu?.() ?? [];
|
|
if (!items.length) return;
|
|
event.preventDefault();
|
|
openTreeMenu(event, items);
|
|
});
|
|
return button;
|
|
}
|