/* ============================================================================= * 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; edit: (layer: Layer, edit: TreeEdit, 판: string, owner: string | null) => Promise; /** 여러 개 옮기기 · 지우기 한 번에 — 없으면 여러 개 메뉴 없음 */ many?: ( layer: Layer, body: { op: "move" | "delete"; 대상: TreeRef[]; 새경로?: string[] }, 판: string, owner: string | null, ) => Promise; /** 붙여넣기(복사해 둔 것 → 이 층 `dest` 아래) — 없으면 복사 · 붙여넣기 메뉴 없음 */ paste?: ( layer: Layer, owner: string | null, clip: TreeClip, dest: string[], ) => Promise; }; /** 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임(기본 「구조물」) */ 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; /** 나무가 바뀜 — 표를 다시 읽고 그 열(`colId`)을 고름 */ onChanged: (colId: string | null) => Promise; } export interface TreePanelHandle { /** 나무(좌측 패널에 붙임) */ root: HTMLElement; /** 찾기 칸 — 부르는 쪽이 상세 위쪽에 붙임(PLAN 29-2) */ search: HTMLElement; /** 층 단추 줄 오른쪽 도구 — 「N개 고름」(30-2) · 「+」(맨 위 폴더 · 항목 더하기 · 읽기만 층 = 숨김 · 29-6) */ add: HTMLElement; refresh: () => Promise; /** 표에서 고른 열을 나무에 표시(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([folderIdOf([])]); const targets = new Map(); // 여러 개 고름(PLAN 30-2) — 줄 id(`f|경로` · `i|열`) · Shift 범위의 기준 let picked = new Set(); 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, 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("[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("[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 { 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("[data-node]")] .filter((b) => !b.closest("[hidden]")) .map((b) => b.dataset.node ?? ""); /** 고름을 바꾸고 줄 배경 · 「N개 고름」을 맞춤 */ function setPicked(next: Set, nextAnchor: string | null): void { picked = new Set([...next].filter((id) => targets.has(id))); anchor = nextAnchor; body.querySelectorAll("[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("[data-node]")].find((b) => b.dataset.node === id) ?? null; async function refresh(): Promise { 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("[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 { 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 => apply((판) => api.edit(opt.layer, body_, 판, owner), keepCol); /** 나무 고침 한 번(하나 · 여러 개 · 붙여넣기) — 판 다름 · 막힘 까닭 · 뒤 처리를 한 곳에서 */ async function apply( run: (판: string) => Promise, keepCol: string | null = activeCol, ): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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("[data-node]"); if (!row && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v") { event.preventDefault(); // 빈 자리에 초점 = 맨 위에 붙여넣기 return void paste([]); } if (!row) return; const rows = [...body.querySelectorAll("[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 }; }