Files
Aislo/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts
T
eomsangdonandClaude Sonnet 5.5 361ce51659 refactor(B08·B09): 자체 변종 → 부품 상자 — 탭 줄 · 분할 고르기 · 배지 칩 · 도구줄 · 떠 있는 판 · 보조 글 (PLAN 553-3)
- 위 · 보조 탭 줄 = 부품 탭(fillTabs · 밑줄 · 알약) · B09 좌측 스위치 = 분할 고르기
- 연결 칩 · 검사 탭 이름 · 끌기 태그 = 배지 · 이정표 도구 · 머리 줄 · 저장 줄 = 도구줄 · 구간 고침 · 행 고름 창 = 떠 있는 판
- 안내 · 빈 줄 · 상태 글 = 보조 글 · 운반 칸 = 카드 · 인쇄 미리보기 좌측 줄 = B09 dock 한 벌 · 쪽 체크 = 라디오 · 체크 부품
- 시험 흉내 틀 · 문구 맞춤

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 22:33:35 +09:00

156 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 { createBadge } from "@ui/ui_template_badge";
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 = createBadge("", "neutral", { size: 18 });
tag.classList.add("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);
});
}