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
This commit is contained in:
@@ -90,10 +90,10 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 순서(85-3) — 손잡이 + ▲▼ 작은 단추 · 끌어 놓을 줄 위 선 */
|
||||
/* 순서(85-3 · 102-1) — ▲ · ▼ 작은 단추 위아래 · 끌어 놓을 줄 위(위로) · 아래(아래로) 선 */
|
||||
.m02-labrow__order {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
@@ -116,14 +116,37 @@
|
||||
box-shadow: inset 0 2px 0 var(--color-primary);
|
||||
}
|
||||
|
||||
/* 순서 칸 2행(51-14) — 위 손잡이 ▲▼ / 아래 [옵션]·[수정] [삭제] · 줄 사이도 단추 묶음 간격 */
|
||||
/* 줄 통째 끌기(102-2) — 끄는 줄 무리 흐리게 · 아래로 놓을 자리 = 그 무리 맨 아래 선 */
|
||||
.m02-labrow tr.is-dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.m02-labrow tr.is-dragunder td {
|
||||
box-shadow: inset 0 -2px 0 var(--color-primary);
|
||||
}
|
||||
|
||||
/* 손잡이(102-1) — 차례 칸 앞 */
|
||||
.m02-labrow__no {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 단추 칸(51-14 · 102-1) — [▲ · ▼ 위아래][옵션|수정][삭제] · 옵션 · 삭제는 두 단추 높이(2행 병합 꼴) */
|
||||
.m02-labrow__tools {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-items: stretch;
|
||||
gap: var(--btn-group-gap);
|
||||
}
|
||||
|
||||
.m02-labrow__tools > .ui-btn-group {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* 구분 칸 품셈 책(102-3) — 단가산출 줄만 · 아래 작은 글 */
|
||||
.m02-labrow__book {
|
||||
display: block;
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 옵션 값 줄(44-6) — 이름 ~ 합계에 걸친 한 줄 말줄임(max-width 0 = 표 폭을 안 늘림) · 위 줄과 한 덩이 */
|
||||
.m02-labrow .m02-labrow__optrow td {
|
||||
padding-top: 0;
|
||||
@@ -396,6 +419,20 @@
|
||||
grid-column: 1 / -1;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* 끌기 선(102-2) — 칸마다 말고 줄 카드 하나에 */
|
||||
.m02-labrow tr.is-dragover td,
|
||||
.m02-labrow tr.is-dragunder td {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.m02-labrow tr.is-dragover {
|
||||
box-shadow: inset 0 2px 0 var(--color-primary);
|
||||
}
|
||||
|
||||
.m02-labrow tr.is-dragunder {
|
||||
box-shadow: inset 0 -2px 0 var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* 수량산출 칸(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 · 누르면 손 입력 · 엑셀에서 고르기 */
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
import { openLaborInput, type GoArea } from "./M02_MasterTemplete_Labor_Input";
|
||||
import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal";
|
||||
import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit";
|
||||
import { afterRowDrag } from "./M02_MasterTemplete_Labor_RowDrag";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
@@ -180,22 +181,25 @@ export function mountLaborDetail(
|
||||
};
|
||||
dgBox.addEventListener("toggle", drawDg);
|
||||
|
||||
// 줄 끄는 동안(102-2)엔 다시 그리지 않음 — 끝난 뒤 한 번
|
||||
const drawLogic = (): void =>
|
||||
logicHost.replaceChildren(
|
||||
logicTable(
|
||||
{ rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked },
|
||||
// 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠)
|
||||
edit && !promote
|
||||
? {
|
||||
open: pick,
|
||||
option: openRow,
|
||||
ref: openRef,
|
||||
qty: openQty,
|
||||
move: reorder,
|
||||
remove: removeRow,
|
||||
alias: setAlias,
|
||||
}
|
||||
: { open: pick, option: openRow, ref: openRef },
|
||||
afterRowDrag(logicHost, () =>
|
||||
logicHost.replaceChildren(
|
||||
logicTable(
|
||||
{ rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked },
|
||||
// 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠)
|
||||
edit && !promote
|
||||
? {
|
||||
open: pick,
|
||||
option: openRow,
|
||||
ref: openRef,
|
||||
qty: openQty,
|
||||
move: reorder,
|
||||
remove: removeRow,
|
||||
alias: setAlias,
|
||||
}
|
||||
: { open: pick, option: openRow, ref: openRef },
|
||||
),
|
||||
),
|
||||
);
|
||||
const draw = (): void => {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/* =============================================================================
|
||||
* 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));
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
LaborRow,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { attachRowDrag } from "./M02_MasterTemplete_Labor_RowDrag";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import "@ui/ui_template_general_layout.css";
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
@@ -47,6 +48,9 @@ export const kindOf = (row: Pick<LaborRow, "로직" | "참조">): string => {
|
||||
);
|
||||
};
|
||||
|
||||
/** 품셈 책(102-3) — 출처 첫 낱말 */
|
||||
const BOOK = /^(건설|산림)품셈/;
|
||||
|
||||
const num = (v: number): string =>
|
||||
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
|
||||
@@ -148,9 +152,6 @@ function moneyCell(
|
||||
}
|
||||
|
||||
/** 이름 칸 글마다 title = 전체 글(한 줄 말줄임 · 45-7) */
|
||||
/** 끌고 있는 줄 차례(85-3) — 손잡이 dragstart ~ drop */
|
||||
let dragFrom: number | null = null;
|
||||
|
||||
const titled = (td: HTMLElement): HTMLElement => {
|
||||
for (const c of td.children) c.setAttribute("title", c.textContent ?? "");
|
||||
return td;
|
||||
@@ -182,7 +183,7 @@ export interface RowHandlers {
|
||||
ref?: (i: number) => void;
|
||||
/** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */
|
||||
qty?: (i: number) => void;
|
||||
/** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 손잡이 끌기 = 놓은 줄 자리) */
|
||||
/** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 줄 끌기 102-2 = 놓은 줄 자리) */
|
||||
move?: (from: number, to: number) => void;
|
||||
remove?: (i: number) => void;
|
||||
/** 별칭 칸 고침(44-6 · 글자마다 · 빈 글 = null) — 없으면 글만 보임 */
|
||||
@@ -315,6 +316,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
row.켜쌓기율 != null ? tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)) : "";
|
||||
const note = [values.text, layer].filter(Boolean).join(" · ");
|
||||
const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null);
|
||||
const book = row.참조 ? undefined : BOOK.exec(logic?.출처 ?? "")?.[0];
|
||||
const small = (
|
||||
label: string,
|
||||
run: () => void,
|
||||
@@ -328,7 +330,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
run();
|
||||
},
|
||||
});
|
||||
// 순서(85-3) — ▲▼ 작은 단추(글은 title · aria) + 손잡이 끌기(줄 전체는 안 끎 · 별칭 칸 글 고르기)
|
||||
// 순서(85-3) — ▲▼ 작은 단추(글은 title · aria) · 손잡이 = 차례 칸 앞(102-1) · 끌기는 줄 통째(102-2 · `Labor_RowDrag`)
|
||||
const arrow = (mark: string, label: string, to: number): HTMLElement => {
|
||||
const b = small(mark, () => move?.(i, to));
|
||||
b.classList.add("m02-labrow__arrow");
|
||||
@@ -341,17 +343,9 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
const g = el("span", {
|
||||
className: "m02-labrow__grip",
|
||||
text: "⠿",
|
||||
attrs: { draggable: "true", title: tc("Labor_Drag"), "data-labor-grip": String(i) },
|
||||
});
|
||||
g.addEventListener("click", (e) => e.stopPropagation());
|
||||
g.addEventListener("dragstart", (ev) => {
|
||||
dragFrom = i;
|
||||
ev.dataTransfer?.setData("text/plain", String(i));
|
||||
if (ev.dataTransfer) ev.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
g.addEventListener("dragend", () => {
|
||||
dragFrom = null;
|
||||
attrs: { title: tc("Labor_Drag"), "data-labor-grip": String(i) },
|
||||
});
|
||||
g.addEventListener("click", (e) => e.stopPropagation()); // 손잡이 누름은 열고 닫지 않음
|
||||
return g;
|
||||
};
|
||||
const sel = data.selected === id ? " is-selected" : "";
|
||||
@@ -359,8 +353,18 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
className: (on ? "m02-labrow__row is-open" : "m02-labrow__row") + sel,
|
||||
attrs: { "data-labor-logic": id },
|
||||
children: [
|
||||
el("td", { className: "m02-labrow__no", text: String(i + 1) }),
|
||||
el("td", { className: "m02-labrow__kind", text: kindOf(row) }),
|
||||
el("td", {
|
||||
className: "m02-labrow__no",
|
||||
children: move ? [grip(), String(i + 1)] : [String(i + 1)],
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__kind",
|
||||
children: [
|
||||
kindOf(row),
|
||||
// 102-3 — 단가산출 줄만 품셈 책(출처 첫 낱말)
|
||||
...(book ? [el("small", { className: "m02-labrow__book", text: book })] : []),
|
||||
],
|
||||
}),
|
||||
titled(
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
@@ -406,7 +410,6 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
el("div", {
|
||||
className: "m02-labrow__order",
|
||||
children: [
|
||||
grip(),
|
||||
arrow("▲", tc("Up"), i - 1),
|
||||
arrow("▼", tc("Down"), i + 1),
|
||||
],
|
||||
@@ -437,21 +440,6 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
],
|
||||
});
|
||||
if (on) tr.addEventListener("click", () => on.open(i));
|
||||
if (move) {
|
||||
tr.addEventListener("dragover", (ev) => {
|
||||
if (dragFrom === null || dragFrom === i) return;
|
||||
ev.preventDefault();
|
||||
tr.classList.add("is-dragover");
|
||||
});
|
||||
tr.addEventListener("dragleave", () => tr.classList.remove("is-dragover"));
|
||||
tr.addEventListener("drop", (ev) => {
|
||||
ev.preventDefault();
|
||||
tr.classList.remove("is-dragover");
|
||||
const from = dragFrom;
|
||||
dragFrom = null;
|
||||
if (from !== null && from !== i) move(from, i);
|
||||
});
|
||||
}
|
||||
const subs = sel ? subRows(line, wait, tools) : [];
|
||||
if (!note) return [tr, ...subs];
|
||||
// 옵션 값 줄(44-6) — 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글은 title) · 행 높이 안 커짐
|
||||
@@ -509,9 +497,10 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
...COSTS.map((c) => tc(HEAD[c])),
|
||||
...(tools ? [tc("Col_Move")] : []),
|
||||
];
|
||||
return data.rows.length
|
||||
? table(heads, trs, foot)
|
||||
: el("p", { className: "m02-labrow__muted", text: tc("NoLogic") });
|
||||
if (!data.rows.length) return el("p", { className: "m02-labrow__muted", text: tc("NoLogic") });
|
||||
const out = table(heads, trs, foot);
|
||||
if (move) attachRowDrag(out, move);
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface MatTableData {
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/* M02 일위대가 줄 통째 끌기(`M02_MasterTemplete_Labor_RowDrag.ts` · 102-2) 단위검증 헬퍼 —
|
||||
* 트랜스파일해 Node 로 돌림 · 작은 가짜 DOM(줄 높이 20px · 셀렉터는 그 파일이 쓰는 꼴만) · JSON 으로 냄. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"M02_MasterTemplete",
|
||||
"M02_MasterTemplete_Labor_RowDrag.ts",
|
||||
);
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
|
||||
/* ---- 가짜 DOM ---- */
|
||||
const one = (node, sel) => {
|
||||
const m = /^(\w+)?(?:\[([\w-]+)\])?$/.exec(sel.trim());
|
||||
if (!m) throw new Error(`셀렉터 모름: ${sel}`);
|
||||
return (!m[1] || node.tag === m[1]) && (!m[2] || m[2] in node.attrs);
|
||||
};
|
||||
const matches = (node, sel) =>
|
||||
sel.split(",").some((part) => {
|
||||
const [up, me] = part.includes(">") ? part.split(">") : [null, part];
|
||||
return one(node, me) && (!up || (node.parent && one(node.parent, up)));
|
||||
});
|
||||
class Node {
|
||||
constructor(tag, attrs = {}, parent = null) {
|
||||
this.tag = tag;
|
||||
this.attrs = attrs;
|
||||
this.parent = parent;
|
||||
this.kids = [];
|
||||
this.on = {};
|
||||
this.style = {};
|
||||
const set = new Set();
|
||||
this.classList = {
|
||||
add: (...c) => c.forEach((x) => set.add(x)),
|
||||
remove: (...c) => c.forEach((x) => set.delete(x)),
|
||||
contains: (c) => set.has(c),
|
||||
list: () => [...set],
|
||||
};
|
||||
if (parent) parent.kids.push(this);
|
||||
}
|
||||
matches(sel) {
|
||||
return matches(this, sel);
|
||||
}
|
||||
closest(sel) {
|
||||
for (let n = this; n; n = n.parent) if (n.matches(sel)) return n;
|
||||
return null;
|
||||
}
|
||||
get previousElementSibling() {
|
||||
const k = this.parent.kids;
|
||||
return k[k.indexOf(this) - 1] ?? null;
|
||||
}
|
||||
get nextElementSibling() {
|
||||
const k = this.parent.kids;
|
||||
return k[k.indexOf(this) + 1] ?? null;
|
||||
}
|
||||
querySelectorAll(sel) {
|
||||
const out = [];
|
||||
const walk = (n) => n.kids.forEach((c) => (c.matches(sel) && out.push(c), walk(c)));
|
||||
walk(this);
|
||||
return out;
|
||||
}
|
||||
addEventListener(type, fn) {
|
||||
(this.on[type] ??= []).push(fn);
|
||||
}
|
||||
}
|
||||
|
||||
/* 표 — 줄 0 · 1(옵션 값 줄 · 하위 줄 둘 = 열린 줄) · 2 · 3 */
|
||||
const root = new Node("div");
|
||||
const tbody = new Node("tbody", {}, new Node("table", {}, root));
|
||||
const tr = (attrs) => new Node("tr", attrs, tbody);
|
||||
const rows = [
|
||||
tr({ "data-labor-logic": "A" }),
|
||||
tr({ "data-labor-logic": "B" }),
|
||||
tr({ "data-labor-opt": "B" }),
|
||||
tr({ "data-labor-sub": "" }),
|
||||
tr({ "data-labor-sub": "" }),
|
||||
tr({ "data-labor-logic": "C" }),
|
||||
tr({ "data-labor-logic": "D" }),
|
||||
];
|
||||
const cell = (r, tag = "td", attrs = {}) => new Node(tag, attrs, rows[r]);
|
||||
const td = rows.map((_, r) => cell(r));
|
||||
const btn = new Node("button", {}, cell(1));
|
||||
const input = new Node("input", { "data-labor-alias": "" }, cell(1));
|
||||
const qty = new Node("span", {}, new Node("button", { "data-labor-qty-open": "" }, cell(1)));
|
||||
const grip = new Node("span", { "data-labor-grip": "0" }, td[0]);
|
||||
const Y = (r) => r * 20 + 10;
|
||||
|
||||
let capture = null;
|
||||
root.hasPointerCapture = (id) => capture === id;
|
||||
root.setPointerCapture = (id) => (capture = id);
|
||||
root.releasePointerCapture = () => (capture = null);
|
||||
|
||||
const winClick = [];
|
||||
let frames = [];
|
||||
global.window = {
|
||||
addEventListener: (t, fn) => winClick.push(fn),
|
||||
removeEventListener: (t, fn) => {
|
||||
const i = winClick.indexOf(fn);
|
||||
if (i >= 0) winClick.splice(i, 1);
|
||||
},
|
||||
getSelection: () => ({ removeAllRanges() {} }),
|
||||
};
|
||||
global.requestAnimationFrame = (fn) => frames.push(fn);
|
||||
global.document = { elementFromPoint: (x, y) => td[Math.floor(y / 20)] ?? null };
|
||||
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
const m = box.exports;
|
||||
|
||||
/* ---- 부르기 ---- */
|
||||
const moves = [];
|
||||
m.attachRowDrag(root, (from, to) => moves.push([from, to]));
|
||||
const fire = (type, e) => root.on[type]?.forEach((fn) => fn({ preventDefault() {}, ...e }));
|
||||
const down = (target, y) =>
|
||||
fire("pointerdown", { button: 0, pointerId: 1, clientX: 50, clientY: y, target });
|
||||
const mv = (y, dx = 0) =>
|
||||
fire("pointermove", { pointerId: 1, buttons: 1, clientX: 50 + dx, clientY: y });
|
||||
/** 누름(click) — 창 캡처 삼킴을 먼저 거침 · 남으면 줄 열고 닫기 */
|
||||
const click = () => {
|
||||
let stopped = false;
|
||||
[...winClick].forEach((fn) =>
|
||||
fn({ stopPropagation: () => (stopped = true), preventDefault() {} }),
|
||||
);
|
||||
winClick.length = 0; // once
|
||||
return !stopped;
|
||||
};
|
||||
const frame = () => {
|
||||
const f = frames;
|
||||
frames = [];
|
||||
f.forEach((fn) => fn());
|
||||
};
|
||||
const classes = () => rows.map((r) => r.classList.list().sort().join(" "));
|
||||
|
||||
const out = {};
|
||||
out.threshold = [
|
||||
[5, 0],
|
||||
[6, 0],
|
||||
[0, -6],
|
||||
[5, 5],
|
||||
[-7, 2],
|
||||
].map(([dx, dy]) => m.pastThreshold(dx, dy));
|
||||
|
||||
// 1) 5px 만 움직임 = 누름 — 옮김 없음 · 누름 지나감(열고 닫기)
|
||||
down(td[0], Y(0));
|
||||
mv(Y(0) + 5);
|
||||
fire("pointerup", {});
|
||||
out.small = { moves: moves.length, clickPasses: click(), capture };
|
||||
|
||||
// 2) 줄 A(0) → 줄 C(차례 2) 로 끌기 — 끄는 동안 표시 · 다시 그림 미룸 · 놓으면 move(0,2) · 누름 삼킴
|
||||
let drawn = 0;
|
||||
const host = {};
|
||||
down(td[0], Y(0));
|
||||
mv(Y(0) + 7);
|
||||
m.afterRowDrag(host, () => drawn++);
|
||||
m.afterRowDrag(host, () => drawn++); // 마지막 것만
|
||||
mv(Y(5));
|
||||
out.during = { drawn, capture, classes: classes(), select: root.style.userSelect };
|
||||
fire("pointerup", {});
|
||||
out.drop = { moves: moves.splice(0), drawn, clickPasses: click(), classes: classes() };
|
||||
frame();
|
||||
out.afterFrame = { listeners: winClick.length };
|
||||
|
||||
// 3) 열린 줄 B 의 하위 줄에서 시작 → 위 줄 A 로 = move(1,0) · 표시 = A 위 선 · B 무리 흐림
|
||||
down(td[4], Y(4));
|
||||
mv(Y(0));
|
||||
out.fromSub = { classes: classes() };
|
||||
fire("pointerup", {});
|
||||
out.fromSub.moves = moves.splice(0);
|
||||
click();
|
||||
frame();
|
||||
|
||||
// 4) 줄 A → 열린 줄 B(아래로) — 표시 = B 무리 맨 아래(하위 줄 둘째) 선
|
||||
down(td[0], Y(0));
|
||||
mv(Y(2));
|
||||
out.downMark = classes();
|
||||
fire("pointerup", {});
|
||||
out.downMark.push(JSON.stringify(moves.splice(0)));
|
||||
click();
|
||||
frame();
|
||||
|
||||
// 5) 단추 · 입력칸 · 수량 여는 자리에서는 시작 안 함
|
||||
for (const [name, t] of [
|
||||
["button", btn],
|
||||
["input", input],
|
||||
["qty", qty],
|
||||
]) {
|
||||
down(t, Y(1));
|
||||
mv(Y(6));
|
||||
fire("pointerup", {});
|
||||
out[`block_${name}`] = { moves: moves.splice(0).length, capture, clickPasses: click() };
|
||||
}
|
||||
|
||||
// 6) 손잡이도 같은 길 · 제자리에 놓음 = 옮김 없음 · 누름은 삼킴(열림 그대로)
|
||||
down(grip, Y(0));
|
||||
mv(Y(0) + 8);
|
||||
fire("pointerup", {});
|
||||
out.gripSelf = { moves: moves.splice(0).length, clickPasses: click() };
|
||||
frame();
|
||||
|
||||
// 7) 끄는 중이 아니면 바로 그림
|
||||
let now = 0;
|
||||
m.afterRowDrag(host, () => now++);
|
||||
out.idleDraw = now;
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,85 @@
|
||||
"""102 — M02 일위대가 상세 줄 배치 · 줄 통째 끌기 · 구분 열 품셈 글.
|
||||
|
||||
끌기(102-2) = Node 헬퍼(`helper_m02_labor_rowdrag.cjs`)가 `M02_MasterTemplete_Labor_RowDrag.ts` 를 가짜 DOM 에서 돌림 ·
|
||||
6px 문턱 · 끈 뒤 누름 삼킴 · 열린 줄 하위 줄에서 시작 · 단추 · 입력칸 · 수량 칸 막음 · 끄는 동안 다시 그림 미룸.
|
||||
배치(102-1) · 품셈 글(102-3) = 소스 꼴.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
M02 = Path(HERE).parents[1] / "M02_MasterTemplete"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_m02_labor_rowdrag.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_문턱_6px(r):
|
||||
assert r["threshold"] == [False, True, True, False, True]
|
||||
|
||||
|
||||
def test_작게_움직이면_누름(r):
|
||||
assert r["small"] == {"moves": 0, "clickPasses": True, "capture": None}
|
||||
|
||||
|
||||
def test_끌어_놓으면_순서만_누름_삼킴(r):
|
||||
assert r["during"]["drawn"] == 0 # 끄는 동안 다시 그리지 않음
|
||||
assert r["during"]["capture"] == 1 and r["during"]["select"] == "none"
|
||||
assert r["during"]["classes"][0] == "is-dragging"
|
||||
assert r["during"]["classes"][5] == "is-dragunder"
|
||||
assert r["drop"]["moves"] == [[0, 2]]
|
||||
assert r["drop"]["drawn"] == 1 # 끝난 뒤 마지막 것 한 번
|
||||
assert r["drop"]["clickPasses"] is False # 열림 · 닫힘 그대로
|
||||
assert set(r["drop"]["classes"]) == {""}
|
||||
assert r["afterFrame"]["listeners"] == 0
|
||||
assert r["idleDraw"] == 1
|
||||
|
||||
|
||||
def test_열린_줄_하위_줄째(r):
|
||||
assert r["fromSub"]["moves"] == [[1, 0]]
|
||||
assert r["fromSub"]["classes"][:5] == ["is-dragover"] + ["is-dragging"] * 4
|
||||
assert r["downMark"][4] == "is-dragunder" and r["downMark"][-1] == "[[0,1]]"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["button", "input", "qty"])
|
||||
def test_단추_입력칸에선_시작_안_함(r, name):
|
||||
assert r[f"block_{name}"] == {"moves": 0, "capture": None, "clickPasses": True}
|
||||
|
||||
|
||||
def test_손잡이_제자리(r):
|
||||
assert r["gripSelf"] == {"moves": 0, "clickPasses": False}
|
||||
|
||||
|
||||
def test_배치_손잡이_차례칸_앞_단추칸():
|
||||
rows = (M02 / "M02_MasterTemplete_Labor_Rows.ts").read_text(encoding="utf-8")
|
||||
assert "children: move ? [grip(), String(i + 1)] : [String(i + 1)]," in rows
|
||||
assert 'draggable: "true"' not in rows and "dragFrom" not in rows
|
||||
assert "if (move) attachRowDrag(out, move);" in rows
|
||||
css = (M02 / "M02_MasterTemplete_Labor.css").read_text(encoding="utf-8")
|
||||
order = css[css.index(".m02-labrow__order {") :]
|
||||
assert "flex-direction: column;" in order[: order.index("}")]
|
||||
assert ".m02-labrow__tools > .ui-btn-group {\n align-items: stretch;" in css
|
||||
detail = (M02 / "M02_MasterTemplete_Labor_Detail.ts").read_text(encoding="utf-8")
|
||||
assert "afterRowDrag(logicHost, () =>" in detail
|
||||
|
||||
|
||||
def test_구분열_품셈_글():
|
||||
rows = (M02 / "M02_MasterTemplete_Labor_Rows.ts").read_text(encoding="utf-8")
|
||||
assert "const BOOK = /^(건설|산림)품셈/;" in rows
|
||||
assert 'const book = row.참조 ? undefined : BOOK.exec(logic?.출처 ?? "")?.[0];' in rows
|
||||
assert 'className: "m02-labrow__book"' in rows
|
||||
Reference in New Issue
Block a user