- 손잡이 = 차례 칸 앞 · 단추 칸 = [▲ · ▼ 세로][옵션|수정][삭제] · 클래스 이름 그대로 - 줄 통째 끌기 = 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
211 lines
6.7 KiB
JavaScript
211 lines
6.7 KiB
JavaScript
/* 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));
|