- 나무 부품 — Ctrl · Shift 클릭 고름(TreePanel_Pick) · 고른 줄 배경 · 층 단추 줄 「N개 고름」 · 오른쪽 클릭 메뉴가 고른 것 전부(복사 · 옮기기 · 지우기) · Ctrl+C · Ctrl+V · Delete · 복사는 읽기 층에서도 · 붙여넣기는 쓸 수 있는 층만 · 콜백 api.many · api.paste · clipKind(일위대가 쪽이 붙일 자리)
- 서버 — POST /layers/{층}/items/paste(여러 항목 · 폴더 · 새 SC/SP 키 · 본뜬 = 원본 · 같은 이름 「(2)」 · 세 파일 · 입력 연결 · 자재 조합 · 빈 폴더까지 · M02_Layer_Access 쓰기 층만) · 나무 고침 대상 여러 개(옮기기 · 지우기 한 번 저장 · 막히면 안 씀)
- 층 단추 줄 「N개 고름」 · 「+」 오른쪽 끝
- 시험 test_m02_tree_paste 새로 · 고르기 규칙 Node 시험
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
586 lines
23 KiB
TypeScript
586 lines
23 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 { 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;
|
|
/** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */
|
|
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;
|
|
}
|
|
|
|
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[] =>
|
|
canCopy
|
|
? [{ label: L("M02_TreeCopy").replace("{n}", String(list.length)), run: () => copy(list) }]
|
|
: [];
|
|
/** 맨 위(빈 자리 오른쪽 클릭 · 「+」) — 폴더 · 항목 더하기 · 붙여넣기 */
|
|
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 },
|
|
);
|
|
return button;
|
|
};
|
|
|
|
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;
|
|
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;
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
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 : []);
|
|
}
|
|
});
|
|
|
|
return { root, search: search.root, add: tools, refresh, select };
|
|
}
|