/* ============================================================================= * 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 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)); }