/* ============================================================================= * M02_MasterTemplete_UI_Side_Library.ts * 좌측 서고 칸(716-4) — 일위대가(`precombo`) · 단가산출(`sangeun`) 두 칸이 같이 쓰는 층 단추 + 그 층 나무. * 칸마다 다른 것(종류 · 목록 고름 · 새 문서 몸 · 글)은 `LibraryOptions` 로 받음(일위대가 = `UI_Side_Precombo`). * 나무 = 자유 폴더(158-1 · 깊이 자유 · 층마다 따로 · 자리 없는 문서 = 뿌리) — 폴더 자리만 서버 * (`{종류}-tree`)에 적음 · 문서 파일은 안 옮김. 폴더 오른쪽 클릭 [새 폴더] · [여기에 새 …] · [이름 바꾸기] · * [지우기](안에 든 문서째 + 확인 창 · 쓰는 곳 있으면 서버 409 까닭 그대로) · 빈 자리 = 뿌리 메뉴 · 끌어 옮기기. * 여럿(구조물 조합 나무 꼴 · `TreePanel_Pick`) — Ctrl · Shift 클릭 고름 · 오른쪽 클릭 · Ctrl+C · Ctrl+V · * Delete 가 고른 것 전부에 · 복사는 읽을 수 있는 층 어디서나 → 쓸 수 있는 층에 붙여넣기(새 키). * 문서 누름 = 편집 칸 · 켜진 층 단추 다시 누름 = 목록표(페이지 몫). * ========================================================================== */ import { isCtrlKey } from "../A00_Common/key_shortcut"; import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { createEmpty } from "@ui/ui_template_empty"; import { t as L } from "@ui/ui_template_locale"; import { createTreeRow, openTreeMenu, renderTree, type TreeMenuItem, type TreeNode, } from "@ui/ui_template_tree"; import { StaleError, editPrecomboTree, fetchPrecomboTree, listTemplates, newLibrary, pasteLibraryTree, type Layer, type LibraryKind, type PrecomboTree, } from "./M02_MasterTemplete_Api_Fetch"; import { nameDialog } from "./M02_MasterTemplete_Library_List"; import type { Root } from "./M02_MasterTemplete_Layer"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag"; import { folderIdOf, samePath } from "./M02_MasterTemplete_TreePanel_Model"; import { clipOf, keepClip, pickClick, refOf, type Target, } from "./M02_MasterTemplete_TreePanel_Pick"; import type { Selection } from "./M02_MasterTemplete_UI_Main"; /** 서고 칸 층(134-3) — 페이지 층과 따로 · `owner` null = 자기 층 */ export interface LibraryScope { root: Root; layer: Layer; owner: string | null; canWrite: boolean; } export interface LibraryOptions { kind: LibraryKind; scope: () => LibraryScope; /** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) */ bar: () => HTMLElement; confirmLeave: () => Promise; onOpen: (sel: Selection | null) => void; getOpen: () => Selection | null; /** 그 층 목록표 고름 — 문서 열기의 바탕(종류 · 층 · 주인 · 권한) */ list: (scope: LibraryScope) => Selection; /** [여기에 새 …] 문서 몸 */ blank: (name: string) => Record; /** 빈 나무 글 · 새 문서 메뉴 글 */ text: { none: string; newHere: string }; /** 문서 줄 덧메뉴(706-1 회사에 올리기 요청) — 없거나 빈 목록이면 메뉴 없음 */ more?: (key: string, name: string) => TreeMenuItem[]; } export interface LibraryHandle { body: HTMLElement; /** 고름 표시만 */ paint: () => void; /** 층 단추 · 나무를 그 층 목록으로 다시 그림 */ refresh: () => Promise; } export interface LibraryRow { 이름: string; 종류: string; 제목?: string | null; 구분?: string | null; 상세구분?: string | null; } export interface LibraryFolder { path: string[]; folders: LibraryFolder[]; docs: LibraryRow[]; } /** 폴더 나무(158-1) — 적어 둔 폴더(그 차례) + 문서 자리(없으면 뿌리 · 모르는 폴더면 그 폴더를 만듦) */ export function buildLibraryFolders( rows: LibraryRow[], view: Pick, ): LibraryFolder { const root: LibraryFolder = { path: [], folders: [], docs: [] }; const at = (path: string[]): LibraryFolder => path.reduce((node, name, i) => { let next = node.folders.find((f) => f.path[i] === name); if (!next) { next = { path: path.slice(0, i + 1), folders: [], docs: [] }; node.folders.push(next); } return next; }, root); view.폴더.forEach(at); for (const row of rows) at(view.자리[row.이름] ?? []).docs.push(row); return root; } /** 폴더 안 문서 수(아래 폴더까지) */ export const docCount = (node: LibraryFolder): number => node.docs.length + node.folders.reduce((n, f) => n + docCount(f), 0); type TreeBody = Parameters[1]; const why = (error: unknown): string => (error instanceof Error ? error.message : ""); export function buildLibrarySection(opt: LibraryOptions): LibraryHandle { const kind = opt.kind; const clipKind = `library:${kind}`; const barHost = el("div"); const treeHost = el("div", { className: "ui-tree m02-tree__body", attrs: { "data-library-tree": kind, tabindex: "0" }, }); const leaves = new Map(); const toggled = new Map(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로 const targets = new Map(); const counts = new Map(); let picked = new Set(); let anchor: string | null = null; let rows: LibraryRow[] = []; let view: PrecomboTree | null = null; let seq = 0; let busy = false; const open = async (row: LibraryRow): Promise => { const at = opt.scope(); if (!(await opt.confirmLeave())) return; opt.onOpen({ ...opt.list(at), name: row.이름, label: row.제목 ? `${row.이름} ${row.제목}` : row.이름, }); }; /** 나무 고침 한 번 — 판 다름 = 다시 읽음 · 막힘 까닭(같은 이름 · 쓰는 곳) 그대로 */ const apply = async (run: (판: string) => Promise): Promise => { if (!view || busy) return; busy = true; try { view = await run(view.판); const gone = view.알림?.지운문서 ?? []; if (gone.length) { rows = rows.filter((r) => !gone.includes(r.이름)); const cur = opt.getOpen(); if (cur && cur.kind === kind && gone.includes(cur.name)) opt.onOpen(null); } if (view.알림?.만든문서?.length) await refresh(true); else draw(); showToast(L("M02_TreeDone"), "success"); } catch (error) { 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 edit = async (body: TreeBody): Promise => { const at = opt.scope(); for (const path of [body.경로, body.새경로]) path?.forEach((_, i) => toggled.set(folderIdOf(path.slice(0, i + 1)), true)); await apply((판) => editPrecomboTree(at.layer, body, 판, at.owner, kind)); }; /** [여기에 새 …] — 뿌리에 생성 → 폴더면 그 자리로 옮김 → 나무 다시 · 상세 엶 */ const make = (path: string[]): void => { const at = opt.scope(); nameDialog(opt.text.newHere, "", async (name) => { const got = await newLibrary(at.layer, kind, { 문서: opt.blank(name) }, at.owner); showToast(L("M02_Created"), "success"); await refresh(); if (path.length) await edit({ op: "move", 키: got.이름, 새경로: path }); await open({ 이름: got.이름, 종류: kind, 제목: name }); }); }; const addFolder = async (parent: string[]): Promise => { const got = await askText(L("M02_TreeAddFolder"), [ { key: "name", label: L("M02_TreeFolderName") }, ]); if (got) await edit({ op: "add-folder", 경로: parent, 이름: got.name }); }; const rename = async (path: string[]): Promise => { const was = path[path.length - 1]; const got = await askText(L("M02_TreeRename"), [ { key: "name", label: L("M02_TreeFolderName"), value: was }, ]); if (got && got.name !== was) await edit({ op: "rename", 경로: path, 이름: got.name }); }; /* --- 지우기 · 여럿 · 복사 붙여넣기(구조물 조합 나무 꼴) --- */ const pickedTargets = (): Target[] => [...picked].map((id) => targets.get(id)).filter((t): t is Target => !!t); /** 지우기 — 확인 창(폴더 = 안의 문서 수) 뒤 한 번에 · 쓰는 곳이 있으면 서버가 막음(그 이름) */ const remove = async (list: Target[]): Promise => { if (!list.length || !opt.scope().canWrite) return; const [one] = list; const ask = list.length > 1 ? L("M02_TreeDeleteManyAsk").replace("{n}", String(list.length)) : one.kind === "item" ? L("M02_TreeDeleteItem").replace("{value}", one.item.이름) : L("M02_TreeDeleteFolder") .replace("{value}", one.path[one.path.length - 1]) .replace("{n}", String(counts.get(folderIdOf(one.path)) ?? 0)); if (!(await showConfirmDialog(ask, L("M02_Delete")))) return; await edit({ op: "delete", 대상: list.map(refOf) }); }; const copy = (list: Target[]): void => { if (!list.length) return; const at = opt.scope(); keepClip({ kind: clipKind, from: { layer: at.layer, owner: at.owner }, refs: list.map(refOf) }); showToast(L("M02_TreeCopied").replace("{n}", String(list.length)), "success"); }; const paste = async (dest: string[]): Promise => { const got = clipOf(clipKind); const at = opt.scope(); if (!got || !at.canWrite) return; toggled.set(folderIdOf(dest), true); await apply(() => pasteLibraryTree(kind, at.layer, at.owner, got, dest)); }; const menuAt = (path: string[]): TreeMenuItem[] => { const off = opt.scope().canWrite ? undefined : tc("ReadOnly"); const item = (label: string, run: () => void): TreeMenuItem => ({ label, run, disabledReason: off, }); const got = clipOf(clipKind); const target: Target = { kind: "folder", path }; return [ item(L("M02_TreeAddFolder"), () => void addFolder(path)), item(opt.text.newHere, () => make(path)), ...(path.length ? [ item(L("M02_TreeRename"), () => void rename(path)), item(L("M02_Delete"), () => void remove([target])), { label: L("M02_TreeCopy").replace("{n}", "1"), run: () => copy([target]) }, ] : []), ...(got ? [item(L("M02_TreePaste").replace("{n}", String(got.refs.length)), () => void paste(path))] : []), ]; }; /** 오른쪽 클릭 — 고른 것 안의 줄이면 고른 것 전부(복사 · 지우기) · 아니면 그 줄 하나 */ const menuFor = (id: string, single: () => TreeMenuItem[]): TreeMenuItem[] => { if (!picked.has(id)) setPicked(new Set([id]), id); const list = pickedTargets(); if (list.length < 2) return single(); const n = String(list.length); const off = opt.scope().canWrite ? undefined : tc("ReadOnly"); return [ { label: L("M02_TreeCopy").replace("{n}", n), run: () => copy(list) }, { label: L("M02_TreeDeleteMany").replace("{n}", n), run: () => void remove(list), disabledReason: off, }, ]; }; const leaf = (row: LibraryRow, folder: string[]): TreeNode => { const id = `doc:${row.이름}`; const label = row.제목 || row.이름; const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label }; const target: Target = { kind: "item", item }; targets.set(id, target); const single = (): TreeMenuItem[] => [ { label: L("M02_TreeCopy").replace("{n}", "1"), run: () => copy([target]) }, { label: L("M02_Delete"), run: () => void remove([target]), disabledReason: opt.scope().canWrite ? undefined : tc("ReadOnly"), }, ...(opt.more?.(row.이름, label) ?? []), ]; const menu = () => menuFor(id, single); return { id, label, tip: row.이름, count: null, run: () => void open(row), menu }; }; const folderNode = (node: LibraryFolder): TreeNode => { const id = folderIdOf(node.path); const count = docCount(node); targets.set(id, { kind: "folder", path: node.path }); counts.set(id, count); return { id, label: node.path[node.path.length - 1], count, folder: true, open: toggled.get(id) ?? count > 0, children: kids(node), menu: () => menuFor(id, () => menuAt(node.path)), }; }; const kids = (node: LibraryFolder): TreeNode[] => [ ...node.folders.map(folderNode), ...node.docs.map((row) => leaf(row, node.path)), ]; /** 끌어 옮기기(구조물 조합 나무와 같은 부품) — 고른 것 전부 · 폴더 줄 위 = 그 안 · 빈 자리 = 뿌리 */ const drag = attachTreeDrag({ body: treeHost, targetOf: (id) => targets.get(id), listFor: (id) => picked.size > 1 && picked.has(id) ? pickedTargets() : targets.has(id) ? [targets.get(id)!] : null, move: (list, dest) => { const from = (t: Target): string[] => t.kind === "item" ? t.item.경로.slice(0, -1) : t.path.slice(0, -1); const moving = list.filter((t) => !samePath(from(t), dest)); if (moving.length) void edit({ op: "move", 대상: moving.map(refOf), 새경로: dest }); }, }); const visibleIds = (): string[] => [...treeHost.querySelectorAll("[data-node]")] .filter((b) => !b.closest("[hidden]")) .map((b) => b.dataset.node ?? ""); const setPicked = (next: Set, at: string | null): void => { picked = next; anchor = at; for (const row of treeHost.querySelectorAll("[data-node]")) row.classList.toggle("is-picked", picked.size > 1 && picked.has(row.dataset.node ?? "")); }; const draw = (): void => { leaves.clear(); targets.clear(); counts.clear(); const tree = buildLibraryFolders(rows, view ?? { 폴더: [], 자리: {} }); if (!tree.docs.length && !tree.folders.length) return void treeHost.replaceChildren(createEmpty({ text: opt.text.none, small: true })); const canWrite = opt.scope().canWrite; treeHost.replaceChildren( ...renderTree(kids(tree), (node, caret, depth, onClick) => { const row = createTreeRow(node, caret, depth, () => { onClick(); if (!node.children?.length) return; toggled.set(node.id, row.querySelector(".ui-tree__caret")?.textContent !== "▸"); }); row.dataset.node = node.id; // Ctrl · Shift 클릭 = 고르기만(열기 · 접기 안 함) — 줄 본디 click 보다 먼저(capture) row.addEventListener( "click", (event) => { const mod = { ctrl: event.ctrlKey || event.metaKey, shift: event.shiftKey }; const got = pickClick(picked, anchor, node.id, mod, visibleIds()); if (got.handled) event.stopImmediatePropagation(); setPicked(got.picked, got.anchor); }, { capture: true }, ); if (node.run) { row.dataset.libraryKey = node.id.slice(4); leaves.set(node.id.slice(4), row); } if (canWrite) drag.arm(row, node.id); return row; }), ); setPicked(new Set([...picked].filter((id) => targets.has(id))), anchor); paint(); }; // 빈 자리 오른쪽 클릭 = 뿌리 메뉴(새 폴더 · 새 문서 · 붙여넣기) treeHost.addEventListener("contextmenu", (event) => { if (!view || (event.target as HTMLElement).closest("[data-node]")) return; event.preventDefault(); openTreeMenu(event, menuAt([])); }); // 키 — Delete = 고른 것(없으면 초점 줄) 지움 · Ctrl+C 복사 · Ctrl+V = 초점 폴더 안(아니면 뿌리) treeHost.addEventListener("keydown", (event) => { const row = (event.target as HTMLElement).closest("[data-node]"); const id = row?.dataset.node ?? ""; const target = targets.get(id); const list = picked.has(id) ? pickedTargets() : target ? [target] : []; if (event.key === "Delete" && list.length) { event.preventDefault(); void remove(list); } else if (isCtrlKey(event, "c") && list.length) { event.preventDefault(); copy(list); } else if (isCtrlKey(event, "v")) { event.preventDefault(); void paste(target?.kind === "folder" ? target.path : []); } }); const paint = (): void => { const cur = opt.getOpen(); const at = opt.scope(); const mine = cur && cur.kind === kind && cur.layer === at.layer && (cur.owner ?? null) === at.owner; for (const [key, row] of leaves) row.classList.toggle("is-active", !!mine && cur.name === key); }; /** `keep` = 나무 자리 그대로 두고 목록만 다시(붙여넣기 뒤 새 문서) */ const refresh = async (keep = false): Promise => { const mine = ++seq; const at = opt.scope(); barHost.replaceChildren(opt.bar()); if (!keep) treeHost.replaceChildren(createEmpty({ text: tc("Loading"), small: true })); const [got, tree] = await Promise.all([ // 못 받으면 빈 나무(목록표에서 까닭) · 폴더를 못 받으면 문서는 모두 뿌리 listTemplates(at.layer, null, at.owner).then( (list): LibraryRow[] => list.filter((r) => r.종류 === kind), (): LibraryRow[] => [], ), keep && view ? Promise.resolve(view) : fetchPrecomboTree(at.layer, at.owner, kind).catch(() => null), ]); if (mine !== seq) return; rows = got; view = tree; draw(); }; const body = el("div", { className: "m02-side__items ui-sidebar-list", attrs: { "data-library": kind }, children: [barHost, treeHost], }); return { body, paint, refresh: () => refresh() }; }