Files
Aislo/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts
T
eomsangdonandClaude Opus 5.5 fe34f561a0 fix(B09): 설계내역서 폴더 줄 글꼴 한 벌 · 들여쓰기 넓힘 · 끌기 놓을 자리 표시 · 데이터 점검 틈 (240 · 121 · 122)
- 레벨 고쳐 생긴 폴더 줄도 처음 폴더 줄과 같은 꼴(is-title · 크기 = 레벨)
- 들여쓰기 한 단 22px · 끌기 좌우 한 단 = 들여쓰기 폭
- 끌기 미리 보기 = 레벨 시작 점 · 「○○ 안 · 레벨 N」 · 들어갈 폴더 줄 테두리 · 끄는 줄 흐림
- 데이터 점검 [업데이트] 위 틈 = 카드 왼쪽 여유

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 20:13:30 +09:00

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);
});
}