Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts
T
eomsangdonandClaude Opus 5.5 c1ad11e869 feat(M02): 좌측 같은 이름 단추 · 나무 뿌리 줄 없앰 · 「+」 더하기 · 찾기 하나(AND) (PLAN 29-6)
- 「일위대가 조합」 · 「구조물 조합」 컨테이너 안 같은 이름 줄 단추 없앰 — 제목을 펼치면 그 화면을 엶
- 나무 맨 위 뿌리 줄 없앰 — 폴더 · 항목부터 · 맨 위 더하기는 빈 자리 오른쪽 클릭 · 층 단추 옆 「+」(읽기만 층 = 숨김) · 빈 나무 안내글 맞춤
- 찾기 칸 하나 — 띄어쓰기 낱말 모두(AND · 대소문자 무시) · 이름 · 키 · 경로(구분 · 상세구분) · 단위 · 공용 matchText 를 나무 · 일위대가 조합 목록이 함께 씀(목록의 따로 찾기 칸 없앰)
- 시험 — 찾기 AND · 새 배치 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:15:43 +09:00

408 lines
15 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_TreePanel.ts
* 관리 화면(마스터 · 회사 · 개인 공용) 「구조물 라이브러리」 한 층의 나무 — 폴더 → … → 항목(깊이 자유) · 찾기.
* 층 + `owner`(남의 층 · PLAN 27-1c) 한 벌을 그림 — 층 단추는 `_Roots` · 찾기 칸은 부르는 쪽이 상세 위쪽에 · 자료 길은 `api`.
* 항목을 누르면 표에서 그 열이 골라짐(카드 넷은 표가 엶) · 표에서 열을 고르면 `select` 로 나무도 그 항목.
* 오른쪽 클릭 · 키보드(F2 · Delete)로 폴더 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기 —
* `POST /api/m02/layers/{층}/tree`(PLAN 26-1). 읽기만 층은 메뉴 · 키 없음. 나무 자료는 `_Model`.
* ========================================================================== */
import "./M02_MasterTemplete_TreePanel.css";
import { createInputField, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import {
createTreeRow,
openTreeMenu,
renderTree,
type MakeRow,
type TreeMenuItem,
type TreeNode,
} from "@ui/ui_template_tree";
import {
StaleError,
editTree,
fetchTree,
type Layer,
type TreeEdit,
type TreeItem,
type TreeNote,
type TreeView,
} from "./M02_MasterTemplete_Api_Fetch";
import { askFolder, askText } from "./M02_MasterTemplete_TreePanel_Dialogs";
import {
ancestorsOf,
buildFolders,
countItems,
folderIdOf,
folderOf,
isUnder,
itemIdOf,
leafName,
listFolders,
samePath,
type FolderNode,
} from "./M02_MasterTemplete_TreePanel_Model";
export interface TreePanelOptions {
layer: Layer;
/** 남의 층 주인(회사 `{C}` · 개인 `{C}/{U}`) — 없으면 자기 층 */
owner?: string | null;
/** 나무 읽기 · 고치기 길 — 없으면 구조물 조합 나무(`/layers/{층}/tree`) */
api?: {
fetch: (layer: Layer, owner: string | null) => Promise<TreeView>;
edit: (layer: Layer, edit: TreeEdit, 판: string, owner: string | null) => Promise<TreeView>;
};
/** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */
canEdit: boolean;
/** 폴더 깊이 한도 — 닿은 폴더엔 「폴더 더하기」 없음(일위대가 라이브러리 = 2) */
maxDepth?: number;
/** 항목 부르는 말 · 「항목 더하기」 대신 부르는 쪽이 새 항목 화면을 엶(일위대가 조합) */
item?: { add: string; name: string; onAdd?: (path: string[]) => void };
/** 항목을 누름 — 표에서 그 열을 고름 */
onPick: (item: TreeItem) => void;
/** 고치기 전에 저장 안 한 표 고침을 처리 — 취소면 false · 저장했으면 "changed" */
settle: () => Promise<false | "clean" | "changed">;
/** 나무가 바뀜 — 표를 다시 읽고 그 열(`colId`)을 고름 */
onChanged: (colId: string | null) => Promise<void>;
}
export interface TreePanelHandle {
/** 나무(좌측 패널에 붙임) */
root: HTMLElement;
/** 찾기 칸 — 부르는 쪽이 상세 위쪽에 붙임(PLAN 29-2) */
search: HTMLElement;
/** 「+」 — 맨 위 폴더 · 항목 더하기 · 부르는 쪽이 층 단추 옆에 붙임(읽기만 층 = 숨김 · 29-6) */
add: HTMLElement;
refresh: () => Promise<void>;
/** 표에서 고른 열을 나무에 표시(null = 지움) */
select: (colId: string | null) => void;
}
type Target = { kind: "folder"; path: string[] } | { kind: "item"; item: TreeItem };
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
const hint = (text: string): HTMLElement => el("p", { className: "m02-tree__hint", text });
export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
const owner = opt.owner ?? null;
const api = opt.api ?? { fetch: fetchTree, edit: editTree };
let view: TreeView | null = null;
let text = "";
let activeCol: string | null = null;
let seq = 0;
let busy = false;
const open = new Set<string>([folderIdOf([])]);
const targets = new Map<string, Target>();
const search = createInputField({ placeholder: L("M02_TreeSearch"), compact: true });
const body = el("div", { className: "m02-tree__body", attrs: { tabindex: "-1" } });
const root = el("div", { className: "m02-tree", children: [body] });
search.root.classList.add("m02-tree__search");
// 층 단추 옆 「+」 — 맨 위에 폴더 · 항목 더하기(뿌리 줄 오른쪽 클릭을 갈음)
const add = el("button", {
className: "m02-tree__add",
text: "+",
attrs: { type: "button", title: L("M02_TreeAddTop"), "aria-label": L("M02_TreeAddTop") },
});
add.hidden = !opt.canEdit;
add.addEventListener("click", (event) =>
openTreeMenu(event, menuOf({ kind: "folder", path: [] })),
);
/* --- 그리기 --- */
const menuOf = (target: Target): TreeMenuItem[] => {
const isTop = target.kind === "folder" && !target.path.length;
return [
...(target.kind === "folder"
? [
...(opt.maxDepth !== undefined && target.path.length >= opt.maxDepth
? []
: [{ label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) }]),
{
label: opt.item?.add ?? L("M02_TreeAddItem"),
run: () => void addItem(target.path),
},
]
: []),
...(isTop
? []
: [
{ label: L("M02_TreeRename"), run: () => void rename(target) },
{ label: L("M02_TreeMove"), run: () => void move(target) },
{ label: L("M02_Delete"), run: () => void remove(target) },
]),
];
};
function node(target: Target, base: Omit<TreeNode, "id" | "menu">, id: string): TreeNode {
targets.set(id, target);
return { ...base, id, ...(opt.canEdit ? { menu: () => menuOf(target) } : {}) };
}
function folderNode(folder: FolderNode, searching: boolean): TreeNode {
const id = folderIdOf(folder.path);
const kids = [
...folder.folders.map((one) => folderNode(one, searching)),
...folder.items.map((item) =>
node(
{ kind: "item", item },
{
label: leafName(item),
count: null,
tip: `${item.이름} · ${item.key}`,
run: () => opt.onPick(item),
},
itemIdOf(item),
),
),
];
return node(
{ kind: "folder", path: folder.path },
{
label: folder.path[folder.path.length - 1] ?? "", // 맨 위(빈 경로)는 그리지 않음
count: countItems(folder),
folder: true,
children: kids,
open: searching || open.has(id),
run: () => void (open.has(id) ? open.delete(id) : open.add(id)),
},
id,
);
}
const make: MakeRow = (one, caret, depth, onClick) => {
const button = createTreeRow(one, caret, depth, onClick);
button.dataset.node = one.id;
button.classList.toggle("is-active", !!activeCol && one.id === `i|${activeCol}`);
return button;
};
function paint(): void {
const focused = (document.activeElement as HTMLElement | null)?.dataset?.node;
targets.clear();
if (!view) return body.replaceChildren(hint(L("M02_PickerLoading")));
const searching = text.trim() !== "";
const tree = buildFolders(view, text);
const none = countItems(tree) === 0 && (searching || !tree.folders.length);
body.replaceChildren(
// 맨 위 뿌리 줄 없음 — 폴더 · 항목부터(PLAN 29-6 · 뿌리 메뉴 = 빈 자리 오른쪽 클릭 · 「+」)
...renderTree(folderNode(tree, searching).children ?? [], make),
...(none
? [
hint(
L(searching ? "M02_TreeNoHit" : opt.canEdit ? "M02_TreeEmptyEdit" : "M02_TreeEmpty"),
),
]
: []),
);
if (focused) rowOf(focused)?.focus({ preventScroll: true });
}
const rowOf = (id: string): HTMLElement | null =>
[...body.querySelectorAll<HTMLElement>("[data-node]")].find((b) => b.dataset.node === id) ??
null;
async function refresh(): Promise<void> {
const mine = ++seq;
try {
const got = await api.fetch(opt.layer, owner);
if (mine !== seq) return;
view = got;
} catch (error) {
if (mine !== seq) return;
view = null;
body.replaceChildren(hint(L("M02_PickerLoadFailed").replace("{value}", why(error))));
return;
}
paint();
}
function select(colId: string | null): void {
activeCol = colId;
const need =
view && colId ? ancestorsOf(view, colId).filter((p) => !open.has(folderIdOf(p))) : [];
if (need.length) {
need.forEach((p) => open.add(folderIdOf(p)));
paint();
} else {
body
.querySelectorAll<HTMLElement>("[data-node]")
.forEach((b) =>
b.classList.toggle("is-active", !!colId && b.dataset.node === `i|${colId}`),
);
}
if (colId) rowOf(`i|${colId}`)?.scrollIntoView({ block: "nearest" });
}
/* --- 고치기 --- */
/** 저장 안 한 표 고침 처리 → (바뀌었으면 나무 다시 읽음) → 참이면 계속 */
async function ready(): Promise<boolean> {
const settled = await opt.settle();
if (!settled) return false;
if (settled === "changed") await refresh();
return true;
}
async function edit(body_: TreeEdit, keepCol: string | null = activeCol): Promise<void> {
if (!view || busy) return;
busy = true;
try {
const got = await api.edit(opt.layer, body_, view.판, owner);
view = got;
const note: TreeNote | undefined = got.알림;
// 새 항목은 그 열을 고름 · 지운 항목이면 고름 풀림 · 나머지는 지금 고른 열 그대로
const gone = note?.지운항목 ?? [];
const col = note?.열 ?? (keepCol && !gone.includes(keepCol) ? keepCol : null);
activeCol = col;
const parent = note?.경로 ? note.경로.slice(0, -1) : [];
parent.forEach((_, i) => open.add(folderIdOf(parent.slice(0, i + 1))));
if (note?.op === "add-folder" || note?.op === "rename" || note?.op === "move") {
if (note.경로) open.add(folderIdOf(note.경로));
}
paint();
showToast(L("M02_TreeDone"), "success");
await opt.onChanged(col);
} catch (error) {
// 409 = 판 다름(서버 detail 이 객체 → 「HTTP 409」) 또는 같은 이름(글 까닭 — 그대로 보임)
if (error instanceof StaleError && error.message === "HTTP 409") {
await refresh();
showToast(L("M02_TreeStale"), "error");
} else {
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
}
} finally {
busy = false;
}
}
const nameOf = (target: Target): string =>
target.kind === "item" ? leafName(target.item) : target.path[target.path.length - 1];
async function addFolder(parent: string[]): Promise<void> {
if (!(await ready())) return;
const got = await askText(L("M02_TreeAddFolder"), [
{ key: "name", label: L("M02_TreeFolderName") },
]);
if (got) await edit({ op: "add-folder", 경로: parent, 이름: got.name });
}
async function addItem(parent: string[]): Promise<void> {
if (!(await ready())) return;
if (opt.item?.onAdd) return opt.item.onAdd(parent);
const got = await askText(L("M02_TreeAddItem"), [
{ key: "name", label: L("M02_TreeItemName") },
{ key: "unit", label: L("M02_TreeUnit") },
]);
if (got) {
await edit({
op: "add-item",
경로: parent,
이름: got.name,
...(got.unit ? { 단위: got.unit } : {}),
});
}
}
async function rename(target: Target): Promise<void> {
if (!(await ready())) return;
const got = await askText(L("M02_TreeRename"), [
{
key: "name",
label:
target.kind === "item"
? (opt.item?.name ?? L("M02_TreeItemName"))
: L("M02_TreeFolderName"),
value: nameOf(target),
},
]);
if (!got || got.name === nameOf(target)) return;
await edit(
target.kind === "item"
? { op: "rename", 경로: folderOf(target.item), 키: target.item.key, 이름: got.name }
: { op: "rename", 경로: target.path, 이름: got.name },
);
}
async function move(target: Target): Promise<void> {
if (!(await ready()) || !view) return;
const from = target.kind === "item" ? folderOf(target.item) : target.path.slice(0, -1);
const to = await askFolder(
L("M02_TreeMove"),
listFolders(buildFolders(view)),
(path) => target.kind === "folder" && isUnder(path, target.path),
from,
);
if (!to || samePath(to, from)) return;
await edit(
target.kind === "item"
? { op: "move", 경로: from, 키: target.item.key, 새경로: to }
: { op: "move", 경로: target.path, 새경로: to },
);
}
async function remove(target: Target): Promise<void> {
if (!(await ready()) || !view) return;
const folder =
target.kind === "folder" ? findFolder(buildFolders(view), target.path) : undefined;
const message =
target.kind === "item"
? L("M02_TreeDeleteItem").replace("{value}", leafName(target.item))
: L("M02_TreeDeleteFolder")
.replace("{value}", nameOf(target))
.replace("{n}", String(folder ? countItems(folder) : 0));
if (!(await showConfirmDialog(message, L("M02_Delete")))) return;
await edit(
target.kind === "item"
? { op: "delete", 경로: folderOf(target.item), 키: target.item.key }
: { op: "delete", 경로: target.path },
);
}
const findFolder = (from: FolderNode, path: string[]): FolderNode | undefined =>
samePath(from.path, path)
? from
: from.folders.map((one) => findFolder(one, path)).find(Boolean);
/* --- 이음 --- */
search.input.addEventListener("input", () => {
text = search.input.value;
paint();
});
// 빈 자리 오른쪽 클릭 = 맨 위 메뉴(폴더 · 항목 더하기)
body.addEventListener("contextmenu", (event) => {
if (!opt.canEdit || (event.target as HTMLElement).closest("[data-node]")) return;
event.preventDefault();
openTreeMenu(event, menuOf({ kind: "folder", path: [] }).slice(0, 2));
});
body.addEventListener("keydown", (event) => {
const row = (event.target as HTMLElement).closest<HTMLElement>("[data-node]");
if (!row) return;
const rows = [...body.querySelectorAll<HTMLElement>("[data-node]")].filter(
(b) => !b.closest("[hidden]"),
);
const at = rows.indexOf(row);
const caret = row.querySelector(".ui-tree__caret")?.textContent;
const target = targets.get(row.dataset.node ?? "");
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
rows[at + (event.key === "ArrowDown" ? 1 : -1)]?.focus();
} else if (
(event.key === "ArrowRight" && caret === "▸") ||
(event.key === "ArrowLeft" && caret === "▾")
) {
event.preventDefault();
row.click();
} else if (opt.canEdit && target && event.key === "F2") {
event.preventDefault();
if (!(target.kind === "folder" && !target.path.length)) void rename(target);
} else if (opt.canEdit && target && event.key === "Delete") {
event.preventDefault();
if (!(target.kind === "folder" && !target.path.length)) void remove(target);
}
});
return { root, search: search.root, add, refresh, select };
}