feat(M02): 공용 나무 여러 개 고르기 · 복사 · 붙여넣기 · 여러 개 옮기기 · 지우기 + 층 사이 붙여넣기 서버 · 「+」 오른쪽 맞춤 (PLAN 30-2)

- 나무 부품 — 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>
This commit is contained in:
2026-09-30 19:05:01 +09:00
co-authored by Claude Opus 5.5
parent 7ea8b75162
commit 0f510eb53b
11 changed files with 630 additions and 28 deletions
@@ -4,7 +4,9 @@
* 층 + `owner`(남의 층 · PLAN 27-1c) 한 벌을 그림 — 층 단추는 `_Roots` · 찾기 칸은 부르는 쪽이 상세 위쪽에 · 자료 길은 `api`.
* 항목을 누르면 표에서 그 열이 골라짐(카드 넷은 표가 엶) · 표에서 열을 고르면 `select` 로 나무도 그 항목.
* 오른쪽 클릭 · 키보드(F2 · Delete)로 폴더 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기 —
* `POST /api/m02/layers/{층}/tree`(PLAN 26-1). 읽기만 층은 메뉴 · 키 없음. 나무 자료는 `_Model`.
* `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";
@@ -21,14 +23,25 @@ import {
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,
@@ -51,7 +64,23 @@ export interface TreePanelOptions {
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) */
@@ -71,21 +100,20 @@ export interface TreePanelHandle {
root: HTMLElement;
/** 찾기 칸 — 부르는 쪽이 상세 위쪽에 붙임(PLAN 29-2) */
search: HTMLElement;
/** 「+」 — 맨 위 폴더 · 항목 더하기 · 부르는 쪽이 층 단추 옆에 붙임(읽기만 층 = 숨김 · 29-6) */
/** 층 단추 줄 오른쪽 도구 — 「N개 고름」(30-2) · 「+」(맨 위 폴더 · 항목 더하기 · 읽기만 층 = 숨김 · 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 };
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;
@@ -93,6 +121,9 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
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" } });
@@ -105,11 +136,61 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
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: [] })),
);
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 [
@@ -136,7 +217,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
function node(target: Target, base: Omit<TreeNode, "id" | "menu">, id: string): TreeNode {
targets.set(id, target);
return { ...base, id, ...(opt.canEdit ? { menu: () => menuOf(target) } : {}) };
return { ...base, id, ...(opt.canEdit || canCopy ? { menu: () => menuFor(target, id) } : {}) };
}
function folderNode(folder: FolderNode, searching: boolean): TreeNode {
@@ -174,9 +255,42 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
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();
@@ -196,6 +310,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
: []),
);
if (focused) rowOf(focused)?.focus({ preventScroll: true });
setPicked(picked, anchor); // 없어진 줄은 고름에서 뺌
}
const rowOf = (id: string): HTMLElement | null =>
@@ -243,22 +358,32 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
return true;
}
async function edit(body_: TreeEdit, keepCol: string | null = activeCol): Promise<void> {
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 api.edit(opt.layer, body_, view.판, owner);
const got = await run(view.판);
view = got;
const note: TreeNote | undefined = got.알림;
// 새 항목은 그 열을 고름 · 지운 항목이면 고름 풀림 · 나머지는 지금 고른 열 그대로
const gone = note?.지운항목 ?? [];
const col = note?.열 ?? (keepCol && !gone.includes(keepCol) ? keepCol : null);
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 (note?.op === "add-folder" || note?.op === "rename" || note?.op === "move") {
if (note.경로) open.add(folderIdOf(note.경로));
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);
@@ -358,6 +483,45 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
);
}
/* --- 여러 개 · 복사 붙여넣기(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
@@ -369,15 +533,21 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
paint();
});
// 빈 자리 오른쪽 클릭 = 맨 위 메뉴(폴더 · 항목 더하기)
// 빈 자리 오른쪽 클릭 = 맨 위 메뉴(폴더 · 항목 더하기 · 붙여넣기)
body.addEventListener("contextmenu", (event) => {
if (!opt.canEdit || (event.target as HTMLElement).closest("[data-node]")) return;
if ((event.target as HTMLElement).closest("[data-node]")) return;
const items = topMenu();
if (!items.length) return;
event.preventDefault();
openTreeMenu(event, menuOf({ kind: "folder", path: [] }).slice(0, 2));
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]"),
@@ -399,9 +569,17 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
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);
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, refresh, select };
return { root, search: search.root, add: tools, refresh, select };
}