Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Labor_RowDrag.ts
T
eomsangdonandClaude Opus 5.5 4e41e76d49 feat(m02): 일위대가 상세 손잡이 맨 왼쪽 · ▲▼ 위아래 · 옵션 · 삭제 2행 높이 · 줄 통째 끌기 · 구분 열 품셈 글(102-1~3)
- 손잡이 = 차례 칸 앞 · 단추 칸 = [▲ · ▼ 세로][옵션|수정][삭제] · 클래스 이름 그대로
- 줄 통째 끌기 = 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
2026-10-08 16:11:40 +09:00

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