Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts
T
eomsangdonandClaude Opus 5.5 25cf716a0d feat(M02): 컨테이너 안 「불러오는 중」 — 0.3초 넘을 때만 · 실패 = 까닭 + 다시 불러오기 (PLAN 30-6)
- 공용 부품 ui_template_loading(showLoading · loadingError · delayedShow) — 칸 안 작은 회전 표시 + 「불러오는 중...」 · 화면 전체 잠금 아님 · 다른 페이지도 씀
- 붙인 자리 — 좌측 나무(구조물 · 일위대가 · 사람 나무) · 상세 칸(구조물 표 · 도면 · 면적 · 재료) · 조합 목록 · 조합 상세 · 항목 카드
- 공용 글 Common_Load_Failed · Common_Reload
- 시험 test_ui_loading(0.3초 규칙 가짜 시계 · 실패 글 · 붙은 자리)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:02:36 +09:00

690 lines
27 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_TreePanel.ts
* 관리 화면(마스터 · 회사 · 개인 공용) 「구조물 라이브러리」 한 층의 나무 — 폴더 → … → 항목(깊이 자유) · 찾기.
* 층 + `owner`(남의 층 · PLAN 27-1c) 한 벌을 그림 — 층 단추는 `_Roots` · 찾기 칸은 부르는 쪽이 상세 위쪽에 · 자료 길은 `api`.
* 항목을 누르면 표에서 그 열이 골라짐(카드 넷은 표가 엶) · 표에서 열을 고르면 `select` 로 나무도 그 항목.
* 오른쪽 클릭 · 키보드(F2 · Delete)로 폴더 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기 —
* `POST /api/m02/layers/{층}/tree`(PLAN 26-1). 읽기만 층은 [복사]만. 나무 자료는 `_Model`.
* 여러 개(PLAN 30-2) — Ctrl · Shift 클릭으로 고름(`_Pick`) · 오른쪽 클릭 메뉴 · Ctrl+C · Ctrl+V · Delete 가
* 고른 것 전부에 · 복사는 읽을 수 있는 층 어디서나 → 쓸 수 있는 층에 붙여넣기(`api.many` · `api.paste`).
* ========================================================================== */
import "./M02_MasterTemplete_TreePanel.css";
import { createInputField, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { loadingError, showLoading } from "@ui/ui_template_loading";
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,
editTreeMany,
fetchTree,
pasteTree,
type Layer,
type TreeClip,
type TreeEdit,
type TreeItem,
type TreeNote,
type TreeRef,
type TreeView,
} from "./M02_MasterTemplete_Api_Fetch";
import { askFolder, askText } from "./M02_MasterTemplete_TreePanel_Dialogs";
import {
clipOf,
keepClip,
pickClick,
refOf,
type Target,
} from "./M02_MasterTemplete_TreePanel_Pick";
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>;
/** 여러 개 옮기기 · 지우기 한 번에 — 없으면 여러 개 메뉴 없음 */
many?: (
layer: Layer,
body: { op: "move" | "delete"; 대상: TreeRef[]; 새경로?: string[] },
판: string,
owner: string | null,
) => Promise<TreeView>;
/** 붙여넣기(복사해 둔 것 → 이 층 `dest` 아래) — 없으면 복사 · 붙여넣기 메뉴 없음 */
paste?: (
layer: Layer,
owner: string | null,
clip: TreeClip,
dest: string[],
) => Promise<TreeView>;
};
/** 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임(기본 「구조물」) */
clipKind?: string;
/** 「〇〇로 복사」 — 고른 것을 그 층 맨 위에 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로) */
copyTo?: { layer: Layer; owner: string | null; label: string }[];
/** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */
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;
/** 층 단추 줄 오른쪽 도구 — 「N개 고름」(30-2) · 「+」(맨 위 폴더 · 항목 더하기 · 읽기만 층 = 숨김 · 29-6) */
add: HTMLElement;
refresh: () => Promise<void>;
/** 표에서 고른 열을 나무에 표시(null = 지움) */
select: (colId: string | null) => void;
/** 「새 항목」 — 고른 폴더 안(없으면 맨 위)에 항목 추가 창 */
newItem: () => void;
}
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, many: editTreeMany, paste: pasteTree };
const clipKind = opt.clipKind ?? "구조물";
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>();
// 여러 개 고름(PLAN 30-2) — 줄 id(`f|경로` · `i|열`) · Shift 범위의 기준
let picked = new Set<string>();
let anchor: string | null = null;
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, topMenu()));
// 「N개 고름」 · 「+」 — 층 단추 줄 오른쪽 끝(30-2 · 부르는 쪽이 붙임)
const status = el("span", { className: "m02-tree__picked", attrs: { hidden: "" } });
const tools = el("span", { className: "m02-tree__tools", children: [status, add] });
/* --- 그리기 --- */
const canCopy = !!api.paste;
const pickedTargets = (): Target[] =>
[...picked].map((id) => targets.get(id)).filter((t): t is Target => !!t);
/** 붙여넣기 줄 — 같은 종류를 복사해 뒀고 이 층에 쓸 수 있을 때만 */
const pasteItem = (dest: string[]): TreeMenuItem[] => {
const got = opt.canEdit && api.paste ? clipOf(clipKind) : null;
return got
? [
{
label: L("M02_TreePaste").replace("{n}", String(got.refs.length)),
run: () => void paste(dest),
},
]
: [];
};
const copyItem = (list: Target[]): TreeMenuItem[] => {
if (!canCopy) return [];
const n = String(list.length);
return [
{ label: L("M02_TreeCopy").replace("{n}", n), run: () => copy(list) },
...(opt.copyTo ?? []).map((to) => ({
label: to.label.replace("{n}", n),
run: () => void copyTo(list, to),
})),
];
};
/** 맨 위(빈 자리 오른쪽 클릭 · 「+」) — 폴더 · 항목 더하기 · 붙여넣기 */
const topMenu = (): TreeMenuItem[] => [
...(opt.canEdit ? menuOf({ kind: "folder", path: [] }) : []),
...pasteItem([]),
];
/** 오른쪽 클릭 — 고른 것 안의 줄이면 고른 것 전부(옮기기 · 지우기 · 복사) · 아니면 그 줄 하나 */
const menuFor = (target: Target, id: string): TreeMenuItem[] => {
if (!picked.has(id)) setPicked(new Set([id]), id);
const list = pickedTargets();
if (list.length > 1) {
const n = String(list.length);
return [
...copyItem(list),
...(opt.canEdit && api.many
? [
{ label: L("M02_TreeMoveMany").replace("{n}", n), run: () => void moveMany(list) },
{
label: L("M02_TreeDeleteMany").replace("{n}", n),
run: () => void removeMany(list),
},
]
: []),
];
}
return [
...(opt.canEdit ? menuOf(target) : []),
...copyItem([target]),
...(target.kind === "folder" ? pasteItem(target.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 || canCopy ? { menu: () => menuFor(target, id) } : {}) };
}
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}`);
button.classList.toggle("is-picked", picked.size > 1 && picked.has(one.id));
// Ctrl · Shift 클릭 = 고르기만(열기 · 접기 안 함) — 줄 본디 click 보다 먼저(capture)
button.addEventListener(
"click",
(event) => {
const got = pickClick(
picked,
anchor,
one.id,
{ ctrl: event.ctrlKey || event.metaKey, shift: event.shiftKey },
visibleIds(),
);
if (got.handled) event.stopImmediatePropagation();
setPicked(got.picked, got.anchor);
},
{ capture: true },
);
// 끌어다 옮기기(30-2) — 쓸 수 있는 층만 · 고른 것 안의 줄을 끌면 고른 것 전부
if (opt.canEdit) {
button.draggable = true;
button.addEventListener("dragstart", (event) => {
const own = targets.get(one.id);
dragging = picked.size > 1 && picked.has(one.id) ? pickedTargets() : own ? [own] : null;
event.dataTransfer?.setData("text/plain", one.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
});
button.addEventListener("dragend", () => {
dragging = null;
dropMark(null);
});
}
return button;
};
/* --- 끌어다 놓기: 폴더 줄 위 = 그 안 · 빈 자리 = 맨 위 · 제 안으로는 안 됨 --- */
let dragging: Target[] | null = null;
const dropDest = (event: DragEvent): string[] | null => {
const row = (event.target as HTMLElement).closest<HTMLElement>("[data-node]");
const target = row ? targets.get(row.dataset.node ?? "") : undefined;
const dest = !row ? [] : target?.kind === "folder" ? target.path : null;
const blocked = dragging?.some((t) => t.kind === "folder" && dest && isUnder(dest, t.path));
return dest && !blocked ? dest : null;
};
const dropMark = (row: HTMLElement | null): void => {
body.querySelectorAll(".is-drop").forEach((b) => b.classList.remove("is-drop"));
row?.classList.add("is-drop");
};
body.addEventListener("dragover", (event) => {
if (!dragging || !dropDest(event)) return;
event.preventDefault();
dropMark((event.target as HTMLElement).closest<HTMLElement>("[data-node]") ?? body);
});
body.addEventListener("drop", (event) => {
const dest = dragging && dropDest(event);
const list = dragging;
dragging = null;
dropMark(null);
if (!dest || !list) return;
event.preventDefault();
void dropMove(list, dest);
});
/** 놓은 자리로 옮김 — 이미 그 자리인 것은 뺌 · 하나면 한 개 옮기기 · 여럿이면 한 번에 */
async function dropMove(list: Target[], dest: string[]): Promise<void> {
const moving = list.filter(
(t) => !samePath(t.kind === "item" ? folderOf(t.item) : t.path.slice(0, -1), dest),
);
if (!moving.length || !(await ready())) return;
if (moving.length === 1) {
const t = moving[0];
return edit(
t.kind === "item"
? { op: "move", 경로: folderOf(t.item), 키: t.item.key, 새경로: dest }
: { op: "move", 경로: t.path, 새경로: dest },
);
}
const run = api.many;
if (run)
await apply((판) =>
run(opt.layer, { op: "move", 대상: moving.map(refOf), 새경로: dest }, 판, owner),
);
}
const visibleIds = (): string[] =>
[...body.querySelectorAll<HTMLElement>("[data-node]")]
.filter((b) => !b.closest("[hidden]"))
.map((b) => b.dataset.node ?? "");
/** 고름을 바꾸고 줄 배경 · 「N개 고름」을 맞춤 */
function setPicked(next: Set<string>, nextAnchor: string | null): void {
picked = new Set([...next].filter((id) => targets.has(id)));
anchor = nextAnchor;
body.querySelectorAll<HTMLElement>("[data-node]").forEach((b) => {
b.classList.toggle("is-picked", picked.size > 1 && picked.has(b.dataset.node ?? ""));
});
status.hidden = picked.size < 2;
status.textContent = L("M02_TreePicked").replace("{n}", String(picked.size));
}
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 });
setPicked(picked, anchor); // 없어진 줄은 고름에서 뺌
}
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;
const stop = showLoading(body); // 0.3초 넘으면 나무 칸 안에 「불러오는 중」(PLAN 30-6)
try {
const got = await api.fetch(opt.layer, owner);
if (mine !== seq) return;
view = got;
} catch (error) {
if (mine !== seq) return;
view = null;
stop();
body.replaceChildren(loadingError(why(error), () => void refresh()));
return;
} finally {
stop(); // 늦게 온 옛 요청의 표시도 걷음
}
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;
}
const edit = (body_: TreeEdit, keepCol: string | null = activeCol): Promise<void> =>
apply((판) => api.edit(opt.layer, body_, 판, owner), keepCol);
/** 나무 고침 한 번(하나 · 여러 개 · 붙여넣기) — 판 다름 · 막힘 까닭 · 뒤 처리를 한 곳에서 */
async function apply(
run: (판: string) => Promise<TreeView>,
keepCol: string | null = activeCol,
): Promise<void> {
if (!view || busy) return;
busy = true;
try {
const got = await run(view.판);
view = got;
const note: TreeNote | undefined = got.알림;
// 새 항목은 그 열을 고름 · 지운 항목이면 고름 풀림 · 나머지는 지금 고른 열 그대로
const gone = note?.지운항목 ?? [];
const made = note?.열;
const col =
typeof made === "string" ? made : 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 (["add-folder", "rename", "move", "paste"].includes(note?.op ?? "")) {
if (note?.경로?.length) open.add(folderIdOf(note.경로));
}
picked = new Set();
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 },
);
}
/* --- 여러 개 · 복사 붙여넣기(PLAN 30-2) --- */
/** 복사 — 읽기만 층에서도 · 층을 바꿔도 남음(붙여넣기는 쓸 수 있는 층에서) */
function copy(list: Target[]): void {
if (!canCopy || !list.length) return;
keepClip({ kind: clipKind, from: { layer: opt.layer, owner }, refs: list.map(refOf) });
showToast(L("M02_TreeCopied").replace("{n}", String(list.length)), "success");
}
/** 「〇〇로 복사」 — 그 층 맨 위에 바로(새 키 · 같은 이름 「(2)」 · 이 나무는 그대로) */
async function copyTo(
list: Target[],
to: { layer: Layer; owner: string | null; label: string },
): Promise<void> {
if (!api.paste || !list.length) return;
const clip = { kind: clipKind, from: { layer: opt.layer, owner }, refs: list.map(refOf) };
try {
await api.paste(to.layer, to.owner, clip, []);
const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사$/, "");
showToast(
L("M02_TreeCopiedTo").replace("{n}", String(list.length)).replace("{layer}", where),
"success",
);
} catch (error) {
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
}
}
async function paste(dest: string[]): Promise<void> {
const got = clipOf(clipKind);
if (!got || !opt.canEdit || !api.paste || !(await ready())) return;
const run = api.paste;
await apply(() => run(opt.layer, owner, got, dest));
}
async function moveMany(list: Target[]): Promise<void> {
if (!api.many || !(await ready()) || !view) return;
const folders = list.flatMap((t) => (t.kind === "folder" ? [t.path] : []));
const to = await askFolder(
L("M02_TreeMove"),
listFolders(buildFolders(view)),
(path) => folders.some((f) => isUnder(path, f)),
[],
);
if (!to) return;
const run = api.many;
await apply((판) =>
run(opt.layer, { op: "move", 대상: list.map(refOf), 새경로: to }, 판, owner),
);
}
async function removeMany(list: Target[]): Promise<void> {
if (!api.many || !(await ready())) return;
const ask = L("M02_TreeDeleteManyAsk").replace("{n}", String(list.length));
if (!(await showConfirmDialog(ask, L("M02_Delete")))) return;
const run = api.many;
await apply((판) => run(opt.layer, { op: "delete", 대상: list.map(refOf) }, 판, owner));
}
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 ((event.target as HTMLElement).closest("[data-node]")) return;
const items = topMenu();
if (!items.length) return;
event.preventDefault();
openTreeMenu(event, items);
});
body.addEventListener("keydown", (event) => {
const row = (event.target as HTMLElement).closest<HTMLElement>("[data-node]");
if (!row && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v") {
event.preventDefault(); // 빈 자리에 초점 = 맨 위에 붙여넣기
return void paste([]);
}
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();
const many = picked.size > 1 && picked.has(row.dataset.node ?? "");
if (many && api.many) void removeMany(pickedTargets());
else if (!(target.kind === "folder" && !target.path.length)) void remove(target);
} else if (target && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c") {
event.preventDefault();
copy(picked.has(row.dataset.node ?? "") ? pickedTargets() : [target]);
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v") {
event.preventDefault(); // 붙일 자리 = 초점 줄이 폴더면 그 안 · 아니면 맨 위
void paste(target?.kind === "folder" ? target.path : []);
}
});
/** 상세 제목 줄 「새 항목」(30-5) — 나무 「항목 추가」와 같은 창 · 고른 폴더(하나)가 있으면 그 안 · 아니면 맨 위 */
const newItem = (): void => {
const at = anchor && picked.has(anchor) ? targets.get(anchor) : undefined;
void addItem(at?.kind === "folder" ? at.path : []);
};
return { root, search: search.root, add: tools, refresh, select, newItem };
}