- 레벨 고쳐 생긴 폴더 줄도 처음 폴더 줄과 같은 꼴(is-title · 크기 = 레벨) - 들여쓰기 한 단 22px · 끌기 좌우 한 단 = 들여쓰기 폭 - 끌기 미리 보기 = 레벨 시작 점 · 「○○ 안 · 레벨 N」 · 들어갈 폴더 줄 테두리 · 끄는 줄 흐림 - 데이터 점검 [업데이트] 위 틈 = 카드 왼쪽 여유 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
155 lines
7.0 KiB
TypeScript
155 lines
7.0 KiB
TypeScript
/* =============================================================================
|
|
* B09_Estimation_UI_Tabs_Bill_Drag.ts
|
|
* B09 설계내역서 줄 끌기(PLAN 204-1) — 줄을 잡아 위아래 = 자리 · 좌우 = 레벨(한 칸 = 1em) · 공용 표 위 덧그림.
|
|
*
|
|
* 놓을 자리 = 줄 사이 틈(포인터가 줄 가운데 위 = 그 줄 앞) · 끄는 줄 자손 = 함께 감 · 자기 자손 안 = 못 놓음
|
|
* 레벨 = 위 줄 + 1(위 줄 아래로 · 항목이면 저절로 묶음) ~ 아래 줄 레벨 · 맨 위(1) = 맨 위 줄만
|
|
* 놓기 = 표 drop(`…_Bill_Edit` dropMove — 메뉴와 같은 지킬 것) · 미리 보기 선(못 놓음 = is-bad · 커서 not-allowed)
|
|
* 미리 보기(121) = 선 머리 글 「○○ 안 · 레벨 N」 + 들어갈 폴더 줄 테두리 + 끄는 줄 흐리게 · 좌우 한 단 = 들여쓰기 폭(--dt-indent)
|
|
* 잡기 = 글 칸 · 단추 밖 아무 칸 · 6px 넘게 움직여야 끌기(그냥 누름 = 줄 고름)
|
|
* ========================================================================== */
|
|
|
|
import type { DataRow } from "@ui/ui_template_data_table";
|
|
import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
|
|
|
export interface DropAt {
|
|
parent: string | null;
|
|
before: string | null;
|
|
level: number;
|
|
}
|
|
|
|
const holds = (row: DataRow): boolean => row.key !== "fee" && !String(row.key).startsWith("mat:");
|
|
|
|
/** 틈 gap(0 ~ 줄 수 · 그 줄 앞) · 바라는 레벨 → 놓을 자리 · 같은 자리 · 못 놓음 = null */
|
|
export function dropAt(rows: DataRow[], from: number, gap: number, want: number): DropAt | null {
|
|
const row = rows[from];
|
|
if (!row) return null;
|
|
const lv = (r: DataRow | undefined): number => Number(r?.level ?? 1);
|
|
let end = from + 1;
|
|
while (end < rows.length && lv(rows[end]) > lv(row)) end += 1;
|
|
if (gap > from && gap < end) return null; // 자기 자손 안
|
|
const rest = [...rows.slice(0, from), ...rows.slice(end)];
|
|
const g = gap <= from ? gap : gap - (end - from);
|
|
const up = rest[g - 1];
|
|
const down = rest[g];
|
|
const max = up ? lv(up) + (holds(up) ? 1 : 0) : 1;
|
|
const min = Math.max(down ? lv(down) : 1, lv(row) === 1 ? 1 : 2);
|
|
if (min > max) return null;
|
|
const level = Math.min(max, Math.max(min, want));
|
|
if (level === lv(row) && (gap === from || gap === end)) return null; // 제자리
|
|
let parent: DataRow | undefined = up;
|
|
const byKey = new Map(rest.map((r) => [String(r.key), r]));
|
|
while (parent && lv(parent) >= level) parent = byKey.get(String(parent.parent ?? ""));
|
|
return {
|
|
parent: parent ? String(parent.key) : null,
|
|
before: down && lv(down) === level ? String(down.key) : null,
|
|
level,
|
|
};
|
|
}
|
|
|
|
/** 표 root 에 줄 끌기를 붙임 — 놓으면 done(끈 줄 키) 뒤 drop 실행 */
|
|
export function attachDrag(root: HTMLElement, table: BillTable, done: (key: string) => void): void {
|
|
let endAt = 0;
|
|
// 끌고 난 누름 = 줄 고름 아님
|
|
root.addEventListener(
|
|
"click",
|
|
(event) => {
|
|
if (Date.now() - endAt < 300) event.stopPropagation();
|
|
},
|
|
true,
|
|
);
|
|
root.addEventListener("pointerdown", (down) => {
|
|
const target = down.target as Element;
|
|
if (down.button !== 0 || !table.drop || target.closest("input,textarea,select,button,a"))
|
|
return;
|
|
const tr = target.closest<HTMLElement>("tr[data-i]");
|
|
if (!tr || !root.contains(tr)) return;
|
|
const from = Number(tr.dataset.i);
|
|
const step = parseFloat(getComputedStyle(tr).getPropertyValue("--dt-indent")) || 14;
|
|
const line = document.createElement("div");
|
|
line.className = "b09-estimation__drop";
|
|
const tag = document.createElement("span");
|
|
tag.className = "b09-estimation__drop-tag";
|
|
line.append(tag);
|
|
const rowEl = (i: number): HTMLElement | null => root.querySelector(`tr[data-i="${i}"]`);
|
|
let into: HTMLElement | null = null;
|
|
let lifted: HTMLElement[] = [];
|
|
let on = false;
|
|
let run: (() => void) | null = null;
|
|
let seen = "";
|
|
const move = (event: PointerEvent): void => {
|
|
const dx = event.clientX - down.clientX;
|
|
if (!on && Math.abs(dx) + Math.abs(event.clientY - down.clientY) < 6) return;
|
|
if (!on) {
|
|
on = true;
|
|
document.body.append(line);
|
|
const lv = Number(table.rows[from]?.level ?? 1);
|
|
for (let i = from; i === from || Number(table.rows[i]?.level ?? 1) > lv; i++) {
|
|
const el = rowEl(i);
|
|
if (!el) break;
|
|
el.classList.add("is-drag-src");
|
|
lifted.push(el);
|
|
}
|
|
document.body.style.userSelect = "none";
|
|
window.getSelection()?.removeAllRanges();
|
|
}
|
|
const trs = table.rows.map((_r, i) => root.querySelector<HTMLElement>(`tr[data-i="${i}"]`));
|
|
let gap = trs.length;
|
|
let y = trs.at(-1)?.getBoundingClientRect().bottom ?? 0;
|
|
for (const [i, el] of trs.entries()) {
|
|
const box = el?.getBoundingClientRect();
|
|
if (box && event.clientY < box.top + box.height / 2) {
|
|
[gap, y] = [i, box.top];
|
|
break;
|
|
}
|
|
}
|
|
const want = Number(table.rows[from]?.level ?? 1) + Math.round(dx / step);
|
|
const at = dropAt(table.rows, from, gap, want);
|
|
const key = `${gap}|${at?.level}`;
|
|
if (key !== seen) {
|
|
seen = key;
|
|
run = at ? table.drop!(from, at.parent, at.before) : null;
|
|
const p = at?.parent ? table.rows.findIndex((r) => String(r.key) === at.parent) : -1;
|
|
into?.classList.remove("is-drop-into");
|
|
into = p >= 0 ? rowEl(p) : null;
|
|
into?.classList.add("is-drop-into");
|
|
tag.textContent = !run
|
|
? "못 놓음"
|
|
: `${p >= 0 ? `「${String(table.rows[p].name ?? "").trim()}」 안` : "맨 위"} · 레벨 ${at!.level}`;
|
|
}
|
|
const cell = tr.querySelector("td.ui-data-table__lv") ?? tr;
|
|
const box = root.getBoundingClientRect();
|
|
const left = cell.getBoundingClientRect().left + ((at?.level ?? want) - 1) * step + 8;
|
|
Object.assign(line.style, {
|
|
top: `${y - 1}px`,
|
|
left: `${left}px`,
|
|
width: `${Math.max(box.right - left, 40)}px`,
|
|
});
|
|
// 머리 글 = 명칭 칸 바로 오른쪽 · 선 위에 걸침(줄 이름을 덮지 않게)
|
|
tag.style.left = `${Math.max(12, cell.getBoundingClientRect().right - left + 8)}px`;
|
|
line.classList.toggle("is-bad", !run);
|
|
document.body.style.cursor = run ? "grabbing" : "not-allowed";
|
|
};
|
|
const stop = (event: PointerEvent): void => {
|
|
window.removeEventListener("pointermove", move);
|
|
window.removeEventListener("pointerup", stop);
|
|
window.removeEventListener("pointercancel", stop);
|
|
line.remove();
|
|
into?.classList.remove("is-drop-into");
|
|
for (const el of lifted) el.classList.remove("is-drag-src");
|
|
lifted = [];
|
|
document.body.style.userSelect = "";
|
|
document.body.style.cursor = "";
|
|
if (!on) return;
|
|
endAt = Date.now();
|
|
if (event.type === "pointerup" && run) {
|
|
done(String(table.rows[from].key));
|
|
run();
|
|
}
|
|
};
|
|
window.addEventListener("pointermove", move);
|
|
window.addEventListener("pointerup", stop);
|
|
window.addEventListener("pointercancel", stop);
|
|
});
|
|
}
|