feat(M02): 「〇〇로 복사」 · 끌어다 옮기기 · 켜진 층 단추 = 목록으로 (PLAN 30-2 더함 · 30-4)
- 오른쪽 클릭 「마스터 · 회사 · 개인으로 복사」 — 쓸 수 있는 자기 층(지금 층 뺌) 맨 위에 바로 · 새 키 · 같은 이름 「(2)」 - 끌어다 옮기기 — 같은 층 · 폴더 줄 위 = 그 안 · 빈 자리 = 맨 위 · 고른 것 전부 · 제 안으로 막음 · 읽기 층은 끌기 없음 - 켜진 층 단추를 다시 누르면 그 층 목록(구조물 = 집계표 · 일위대가 = 조합 목록 · 저장 안 한 고침은 물음) - 시험 맞춤 · 30-4 시험 한 건 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -81,6 +81,8 @@ export interface TreePanelOptions {
|
||||
};
|
||||
/** 복사해 둔 것의 나무 종류 — 같은 종류 나무끼리만 붙임(기본 「구조물」) */
|
||||
clipKind?: string;
|
||||
/** 「〇〇로 복사」 — 고른 것을 그 층 맨 위에 바로(층을 안 바꿈 · `label` 의 {n} = 개수 · `api.paste` 로) */
|
||||
copyTo?: { layer: Layer; owner: string | null; label: string }[];
|
||||
/** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */
|
||||
canEdit: boolean;
|
||||
/** 폴더 깊이 한도 — 닿은 폴더엔 「폴더 더하기」 없음(일위대가 라이브러리 = 2) */
|
||||
@@ -157,10 +159,17 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
]
|
||||
: [];
|
||||
};
|
||||
const copyItem = (list: Target[]): TreeMenuItem[] =>
|
||||
canCopy
|
||||
? [{ label: L("M02_TreeCopy").replace("{n}", String(list.length)), run: () => copy(list) }]
|
||||
: [];
|
||||
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: [] }) : []),
|
||||
@@ -272,9 +281,72 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
},
|
||||
{ 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<HTMLElement>("[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<HTMLElement>("[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<void> {
|
||||
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<HTMLElement>("[data-node]")]
|
||||
.filter((b) => !b.closest("[hidden]"))
|
||||
@@ -491,6 +563,25 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const got = clipOf(clipKind);
|
||||
if (!got || !opt.canEdit || !api.paste || !(await ready())) return;
|
||||
|
||||
Reference in New Issue
Block a user