diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 4fa07239..9a27f130 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -36,6 +36,7 @@ import { type TreeView, } from "./M02_MasterTemplete_Api_Fetch"; import { askFolder, askNewItem, askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; +import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag"; import { clipOf, keepClip, @@ -286,49 +287,18 @@ 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); - }); - } + if (opt.canEdit) drag.arm(button, one.id); // 끌어다 옮기기(30-2) — 쓸 수 있는 층만 return button; }; - /* --- 끌어다 놓기: 폴더 줄 위 = 그 안 · 빈 자리 = 맨 위 · 제 안으로는 안 됨 --- */ - let dragging: Target[] | null = null; - const dropDest = (event: DragEvent): string[] | null => { - const row = (event.target as HTMLElement).closest("[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("[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); + const drag = attachTreeDrag({ + body, + targetOf: (id) => targets.get(id), + listFor: (id) => { + const own = targets.get(id); + return picked.size > 1 && picked.has(id) ? pickedTargets() : own ? [own] : null; + }, + move: (list, dest) => void dropMove(list, dest), }); /** 놓은 자리로 옮김 — 이미 그 자리인 것은 뺌 · 하나면 한 개 옮기기 · 여럿이면 한 번에 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Drag.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Drag.ts new file mode 100644 index 00000000..67e137d2 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Drag.ts @@ -0,0 +1,74 @@ +/* ============================================================================= + * M02_MasterTemplete_TreePanel_Drag.ts + * 공용 나무 끌어다 옮기기(PLAN 30-2 · 31-5) — 같은 층 안 · 폴더 줄 위 = 그 안 · 빈 자리 = 맨 위 · + * 제 안으로는 안 됨 · 고른 것 안의 줄을 끌면 고른 것 전부. 옮기는 일은 부르는 쪽(`move`). + * 놓을 자리 강조는 한 곳만 · 놓음 · 끝 · 칸 밖으로 나감에서 모두 지움(나무 칸 자신 포함). + * ========================================================================== */ + +import type { Target } from "./M02_MasterTemplete_TreePanel_Pick"; +import { isUnder } from "./M02_MasterTemplete_TreePanel_Model"; + +export interface TreeDragOptions { + /** 나무 칸(줄들의 부모 · 빈 자리 = 맨 위) */ + body: HTMLElement; + targetOf: (id: string) => Target | undefined; + /** 이 줄을 끌 때 옮길 것 — 고른 것 안이면 고른 것 전부 */ + listFor: (id: string) => Target[] | null; + move: (list: Target[], dest: string[]) => void; +} + +/** 칸에 놓기를 달고 줄마다 끌기를 다는 손잡이(`arm`)를 돌려줌 */ +export function attachTreeDrag(opt: TreeDragOptions): { + arm: (row: HTMLElement, id: string) => void; +} { + const { body } = opt; + let dragging: Target[] | null = null; + + const dropDest = (event: DragEvent): string[] | null => { + const row = (event.target as HTMLElement).closest("[data-node]"); + const target = row ? opt.targetOf(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; + }; + /** 놓을 자리 강조 — 한 곳만 · null = 모두 지움(나무 칸 자신 = 맨 위 자리 포함 · PLAN 31-5 흠) */ + const dropMark = (row: HTMLElement | null): void => { + body.classList.remove("is-drop"); + body.querySelectorAll(".is-drop").forEach((b) => b.classList.remove("is-drop")); + row?.classList.add("is-drop"); + }; + // 나무 칸 밖으로 나가면 강조를 지움 — 칸 안 자식 위로 옮겨 갈 때는 그대로(깜빡임 막음) + body.addEventListener("dragleave", (event) => { + const to = event.relatedTarget as Node | null; + if (!to || !body.contains(to)) dropMark(null); + }); + body.addEventListener("dragover", (event) => { + if (!dragging || !dropDest(event)) return; + event.preventDefault(); + dropMark((event.target as HTMLElement).closest("[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(); + opt.move(list, dest); + }); + + return { + arm: (row, id) => { + row.draggable = true; + row.addEventListener("dragstart", (event) => { + dragging = opt.listFor(id); + event.dataTransfer?.setData("text/plain", id); + if (event.dataTransfer) event.dataTransfer.effectAllowed = "move"; + }); + row.addEventListener("dragend", () => { + dragging = null; + dropMark(null); + }); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index d60cb45a..9c64b6b1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -188,6 +188,8 @@ export function buildSide(opt: SideOptions): SideHandle { ], }); attachCollapsible(listHost); + // 들어오면 「일위대가 조합」 · 「구조물 조합」 둘 다 펼친 채(PLAN 31-5 · 구조물 조합은 위에서 펼침) + labor?.body.closest("section")?.classList.remove("is-collapsed"); const root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost] }); diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 233719bf..c7bb3f17 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -231,8 +231,9 @@ def test_켜진_층_단추는_목록으로_복사_끌기(): labor = _text(M02 / "M02_MasterTemplete_Labor.ts") assert 'if (editor.isDirty() && !(await showConfirmDialog(tc("BackAsk")))) return;' in labor panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") - assert "if (opt.canEdit) {\n button.draggable = true;" in panel - assert 'dragging?.some((t) => t.kind === "folder" && dest && isUnder(dest, t.path))' in panel + assert "if (opt.canEdit) drag.arm(button, one.id);" in panel + drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts") + assert 'dragging?.some((t) => t.kind === "folder" && dest && isUnder(dest, t.path))' in drag def test_구조물_조합_제목_줄_새_항목(): @@ -255,3 +256,15 @@ def test_구조물_조합_제목_줄_새_항목(): assert ".m02-main__new {\n margin-left: auto;" in _text( M02 / "M02_MasterTemplete_UI_Style.css" ) + + +def test_들어오면_두_컨테이너_펼침_끌기_뒤_강조_지움(): + """31-5 — 좌측 「일위대가 조합」 · 「구조물 조합」 둘 다 펼친 채 · 끌어 놓은 뒤 강조(나무 칸 + 자신 = 맨 위 자리 포함)를 모두 지움 · 칸 밖으로 나가면 지움(칸 안 자식 위로는 그대로).""" + side = _text(M02 / "M02_MasterTemplete_UI_Side.ts") + assert 'labor?.body.closest("section")?.classList.remove("is-collapsed");' in side + assert 'summary.classList.remove("is-collapsed");' in side + drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts") + mark = drag.split("const dropMark = ")[1].split("};")[0] + assert 'body.classList.remove("is-drop");' in mark + assert "if (!to || !body.contains(to)) dropMark(null);" in drag