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:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user