- 고른 줄 방향키 → 들여 · ← 내어 · ↑↓ 옮김(200-2 메뉴 항목 그대로 · Alt+←→ 안 받음) - 줄 끌기 = 위아래 자리 · 좌우 레벨 · 미리 보기 선 · 못 놓는 자리 빨간 점선(공용 표 안 고침) - 자식이 생긴 항목 = 저절로 묶음(Calc_Bill 짝 · 금액 = 자손 합 · 제 수량 · 연결은 덮개에 남음) - 자식이 다 빠진 새 묶음 = 항목(맨 위 공종 빼고) · 자재 · 조달수수료 줄 = 부모 못 됨 - 시험 test_204_1 새로 · 184_3 · 166_2 · 200_2 메뉴 · 키 기대값 고침 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
128 lines
5.4 KiB
TypeScript
128 lines
5.4 KiB
TypeScript
/* =============================================================================
|
|
* B09_Estimation_UI_Tabs_Bill_Drag.ts
|
|
* B09 설계내역서 줄 끌기(PLAN 204-1) — 줄을 잡아 위아래 = 자리 · 좌우 = 레벨(한 칸 = 1em) · 공용 표 위 덧그림.
|
|
*
|
|
* 놓을 자리 = 줄 사이 틈(포인터가 줄 가운데 위 = 그 줄 앞) · 끄는 줄 자손 = 함께 감 · 자기 자손 안 = 못 놓음
|
|
* 레벨 = 위 줄 + 1(위 줄 아래로 · 항목이면 저절로 묶음) ~ 아래 줄 레벨 · 맨 위(1) = 맨 위 줄만
|
|
* 놓기 = 표 drop(`…_Bill_Edit` dropMove — 메뉴와 같은 지킬 것) · 미리 보기 선(못 놓음 = is-bad · 커서 not-allowed)
|
|
* 잡기 = 글 칸 · 단추 밖 아무 칸 · 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).fontSize) || 14;
|
|
const line = document.createElement("div");
|
|
line.className = "b09-estimation__drop";
|
|
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);
|
|
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 box = root.getBoundingClientRect();
|
|
const left = box.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`,
|
|
});
|
|
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();
|
|
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);
|
|
});
|
|
}
|