- 손잡이 = 차례 칸 앞 · 단추 칸 = [▲ · ▼ 세로][옵션|수정][삭제] · 클래스 이름 그대로 - 줄 통째 끌기 = 6px 문턱 · 끈 뒤 누름 한 번 삼킴 · 열린 줄 하위 줄째 · 단추 · 입력칸 · 수량 칸 제외 · 끄는 동안 다시 그림 미룸(Labor_RowDrag) - 구분 칸 아래 작은 글 건설품셈 · 산림품셈(단가산출 줄만 · 출처 첫 낱말) - 시험 test_m02_labor_rowdrag(Node 가짜 DOM 하네스) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
127 lines
5.1 KiB
TypeScript
127 lines
5.1 KiB
TypeScript
/* =============================================================================
|
|
* M02_MasterTemplete_Labor_RowDrag.ts
|
|
* 일위대가 담은 로직 줄 통째 끌기(102-2) — 줄 · 옵션 값 줄 · 하위 줄 어디서나(손잡이도 같은 길).
|
|
* 6px 움직여야 끌기(B05 `Profile_Wheel` 선례) · 그 아래는 누름(열고 닫기) · 끈 뒤 누름 한 번 삼킴(열림 · 닫힘 그대로) ·
|
|
* 단추 · 입력칸 · 수량 여는 자리에서는 시작 안 함(별칭 글 고르기 85-3) · 놓은 줄 자리로 `move` ·
|
|
* 끄는 동안 표를 다시 그리지 않음(`afterRowDrag` — 끝난 뒤 한 번).
|
|
* ========================================================================== */
|
|
|
|
/** 이만큼 움직여야 끌기 — 그 아래는 누름(B05 와 같음 · 4px 은 손떨림에 넘어감) */
|
|
export const DRAG_THRESHOLD_PX = 6;
|
|
const BLOCK = "button, input, select, textarea, a, [data-labor-qty-open]";
|
|
const HEAD = "tr[data-labor-logic]";
|
|
|
|
let active = false;
|
|
const queued = new Map<Element, () => void>();
|
|
|
|
/** 다시 그림 — 끄는 동안이면 끝난 뒤로 미룸(자리마다 마지막 것만) */
|
|
export function afterRowDrag(host: Element, draw: () => void): void {
|
|
if (active) return void queued.set(host, draw);
|
|
queued.delete(host);
|
|
draw();
|
|
}
|
|
|
|
/** 문턱 — 가로 · 세로 어느 쪽이든 6px 이면 끌기 */
|
|
export const pastThreshold = (dx: number, dy: number): boolean =>
|
|
Math.abs(dx) >= DRAG_THRESHOLD_PX || Math.abs(dy) >= DRAG_THRESHOLD_PX;
|
|
|
|
/** 그 줄이 속한 로직 줄(옵션 값 줄 · 하위 줄은 위로 올라가 찾음) */
|
|
const headOf = (tr: Element | null | undefined): Element | null => {
|
|
let t = tr ?? null;
|
|
while (t && !t.matches(HEAD)) t = t.previousElementSibling;
|
|
return t;
|
|
};
|
|
|
|
/** 로직 줄 무리 — 로직 줄 + 옵션 값 줄 + 하위 줄 */
|
|
const groupOf = (head: Element): Element[] => {
|
|
const out = [head];
|
|
for (let t = head.nextElementSibling; t && !t.matches(HEAD); t = t.nextElementSibling)
|
|
out.push(t);
|
|
return out;
|
|
};
|
|
|
|
/** `root` = 표 감싼 칸 · 줄 차례 = tbody 안 로직 줄 차례(같은 로직 두 줄이어도 자리로 셈) */
|
|
export function attachRowDrag(root: HTMLElement, move: (from: number, to: number) => void): void {
|
|
let id: number | null = null;
|
|
let x = 0;
|
|
let y = 0;
|
|
let moved = false;
|
|
let from = -1;
|
|
let to = -1;
|
|
let marks: Element[] = [];
|
|
const heads = (): Element[] => [...root.querySelectorAll(`tbody > ${HEAD}`)];
|
|
const unmark = (): void => {
|
|
for (const m of marks) m.classList.remove("is-dragging", "is-dragover", "is-dragunder");
|
|
marks = [];
|
|
};
|
|
const mark = (els: Element[], cls: string): void => {
|
|
for (const e of els) e.classList.add(cls);
|
|
marks.push(...els);
|
|
};
|
|
|
|
const end = (drop: boolean): void => {
|
|
const pid = id;
|
|
id = null;
|
|
if (pid === null || !moved) return;
|
|
moved = false;
|
|
if (root.hasPointerCapture(pid)) root.releasePointerCapture(pid);
|
|
unmark();
|
|
root.style.userSelect = "";
|
|
root.style.cursor = "";
|
|
active = false;
|
|
// 끈 뒤 누름 한 번 삼킴(캡처 · 다음 프레임에 걷음) — 열림 · 닫힘 그대로
|
|
const swallow = (e: MouseEvent): void => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
};
|
|
window.addEventListener("click", swallow, { capture: true, once: true });
|
|
requestAnimationFrame(() => window.removeEventListener("click", swallow, { capture: true }));
|
|
if (drop && to >= 0 && to !== from) move(from, to);
|
|
const later = [...queued.values()];
|
|
queued.clear();
|
|
for (const draw of later) draw();
|
|
};
|
|
|
|
root.addEventListener("pointerdown", (e) => {
|
|
if (e.button !== 0 || moved) return;
|
|
const t = e.target as Element;
|
|
if (t.closest(BLOCK)) return;
|
|
const head = headOf(t.closest("tbody > tr"));
|
|
from = head ? heads().indexOf(head) : -1;
|
|
if (from < 0) return;
|
|
id = e.pointerId;
|
|
x = e.clientX;
|
|
y = e.clientY;
|
|
to = -1;
|
|
});
|
|
|
|
root.addEventListener("pointermove", (e) => {
|
|
if (id !== e.pointerId) return;
|
|
if (!moved) {
|
|
if (!(e.buttons & 1)) return void (id = null); // 누름 없이 들어옴 — 끌기 아님
|
|
if (!pastThreshold(e.clientX - x, e.clientY - y)) return;
|
|
moved = true;
|
|
active = true;
|
|
// 이제부터 끌기 — 표 밖으로 나가도 이어지게 포인터를 잡음 · 글자 고름 막음
|
|
root.setPointerCapture(e.pointerId);
|
|
root.style.userSelect = "none";
|
|
root.style.cursor = "grabbing";
|
|
window.getSelection()?.removeAllRanges();
|
|
}
|
|
e.preventDefault();
|
|
unmark();
|
|
const list = heads();
|
|
if (list[from]) mark(groupOf(list[from]), "is-dragging");
|
|
const over = headOf(document.elementFromPoint(e.clientX, e.clientY)?.closest("tbody > tr"));
|
|
to = over ? list.indexOf(over) : -1;
|
|
if (!over || to < 0 || to === from) return;
|
|
// 위로 = 놓을 줄 위 선 · 아래로 = 놓을 줄 무리 아래 선(그 뒤로 감)
|
|
if (to < from) mark([over], "is-dragover");
|
|
else mark(groupOf(over).slice(-1), "is-dragunder");
|
|
});
|
|
|
|
root.addEventListener("pointerup", () => end(true));
|
|
root.addEventListener("pointercancel", () => end(false));
|
|
root.addEventListener("lostpointercapture", () => end(false));
|
|
}
|