diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 1701a2517..af6d31de2 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -52,7 +52,7 @@ import { } from "./B09_Estimation_UI_Tabs_Base"; import { buildBill } from "./B09_Estimation_UI_Tabs_Bill"; import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; -import { levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level"; +import { jumpRange, jumpTo, levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level"; import { buildSumExpense, buildSumLabor, @@ -262,19 +262,17 @@ export function renderB09Estimation(root: HTMLElement): void { ); } // 200-2 설계내역서 = 고른 줄 상자(235-1) · 229-1 레벨 단추 = 보조 탭 줄 오른쪽 · 202-1 안내 줄 = 표 아래 · Enter · F2 = 입력 - // 239-1 이동해 온 줄 = 빨강 외곽선(목록표 → 탭 길 is-pick · 고른 줄 상자와 따로) + // 239-1 · 242-1 이동해 온 줄 = 그 줄 + 자손 빨강 외곽선(is-pick) · 고른 줄 상자와 한때 하나(오면 고름 풂) const bill = (spec as BillTable).menu ? (spec as BillTable) : null; const at = focus === undefined ? -1 : rowIndex(spec.rows, focus); - const jump: [number, number] | null = at >= 0 ? [at, at] : null; + if (bill) jumpTo(bill.rows, at); const table = makeTable( - bill - ? (i) => [pickedClass(bill.rows[i]), pickClass(jump, i)].filter(Boolean).join(" ") || null - : undefined, + bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i) : undefined, ); const level = bill ? levelBar(table.root, bill, (i) => table.edit(i)) : null; if (level) bars.setTools(level.tools); panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : [])); - if (at >= 0) requestAnimationFrame(() => table.focusRow(at)); + if (at >= 0) requestAnimationFrame(() => table.focusRow(at, !bill)); // 설계내역서 = 스크롤만(표시 = 빨강) }; const apply = (next: Row, fromServer: boolean): void => { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts index 742314f4e..aff2f53c6 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts @@ -10,6 +10,8 @@ * 키 = Tab 들여쓰기 · Shift+Tab 내어쓰기 · Alt+↑↓ 옮기기 · Esc 편집 끝(편집 아니면 고름 풂) * 방향키(204) = → 들여쓰기 · ← 내어쓰기 · ↑↓ 옮기기(같은 메뉴 항목) · 끌기 = `…_Bill_Drag`(놓은 줄 = 고른 줄) * 글 칸 편집 중 · 초점이 표 · 도구 줄 밖이면 안 먹음 — 줄 누름이 다른 탭으로 가는 줄은 글 칸을 눌러 고름 + * 242-1 이동해 온 범위(총괄 등 → 그 줄 + 자손 · 빨강 is-pick) = 고름과 한때 하나 — 오면 고름 풂 · + * 줄 누름 · 방향키 · Esc · 끌어 놓음 = 빨강 풂 * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; @@ -29,6 +31,8 @@ const ACTS: [string, string][] = [ const GUIDE = "줄 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 자리 · 줄 끌기 · 더블클릭 · Enter · F2 입력"; let picked: string | null = null; +/** 242-1 이동해 온 범위 [첫 줄, 끝 줄] — 고름과 한때 하나 */ +let jump: [number, number] | null = null; /** 지금 그린 도구 줄 — 키가 부름 */ let live: { root: HTMLElement; @@ -44,6 +48,23 @@ let keysOn = false; export const pickedClass = (row: DataRow | undefined): string | null => row && picked !== null && String(row.key) === picked ? "is-focus" : null; +/** 그 줄 + 자손(뒤 줄 중 레벨이 더 깊은 연속 줄) 끝 줄 번호 */ +export function subtreeEnd(rows: DataRow[], at: number): number { + const lv = Number(rows[at]?.level ?? 1); + let end = at; + while (end + 1 < rows.length && Number(rows[end + 1]?.level ?? 1) > lv) end++; + return end; +} + +/** 242-1 이동해 온 줄(-1 = 없음) — 범위 = 그 줄 + 자손 · 오면 고름 풂(Shell 이 표 그리기 전에) */ +export function jumpTo(rows: DataRow[], at: number): void { + jump = at >= 0 ? [at, subtreeEnd(rows, at)] : null; + if (jump) picked = null; +} + +/** 지금 빨강 범위 — Shell 줄 class 곁값(pickClass) */ +export const jumpRange = (): [number, number] | null => jump; + /** 그 줄 메뉴 항목(글 → 동작) — 메뉴에 없는 동작 = 못 함 */ export function menuActs(table: BillTable, index: number): Map void> { const items = index >= 0 && table.menu ? table.menu(index) : []; @@ -72,7 +93,7 @@ function listenKeys(): void { if (keysOn) return; keysOn = true; document.addEventListener("keydown", (event) => { - if (!live?.root.isConnected || picked === null) return; + if (!live?.root.isConnected || (picked === null && !jump)) return; const at = document.activeElement; if ( at && @@ -81,6 +102,12 @@ function listenKeys(): void { !live.tools.includes(at as HTMLElement) ) return; + // 242-1 빨강 중 = 방향키 · Esc 가 빨강만 풂(고른 줄 없음) + if (picked === null) { + if (event.key !== "Escape" && !keyAct(event, editing(at))) return; + jump = null; + return live.sync(); + } // Esc = 편집 끝(표에 초점 · 키 받음) · 편집 아닐 때 = 고름 풂 if (event.key === "Escape") { if (!editing(at)) { @@ -130,6 +157,9 @@ export function levelBar( const acts = menuActs(table, index()); ACTS.forEach(([, label], k) => (buttons[k].disabled = !acts.has(label))); for (const tr of root.querySelectorAll("tr.is-focus")) tr.classList.remove("is-focus"); + if (!jump) + for (const tr of root.querySelectorAll("tr.is-pick")) + tr.classList.remove("is-pick", "is-pick-top", "is-pick-end"); root.querySelector(`tr[data-i="${index()}"]`)?.classList.add("is-focus"); }; root.addEventListener("click", (event) => { @@ -137,9 +167,13 @@ export function levelBar( const row = tr ? table.rows[Number(tr.dataset.i)] : undefined; if (!row) return; picked = String(row.key); + jump = null; sync(); }); - attachDrag(root, table, (key) => (picked = key)); + attachDrag(root, table, (key) => { + picked = key; + jump = null; + }); sync(); live = { root, tools: buttons, run, sync, edit, index }; listenKeys(); diff --git a/resources/tester/test_200_2_b09_bill_level_bar.py b/resources/tester/test_200_2_b09_bill_level_bar.py index 3c41ac20f..8da7177e0 100644 --- a/resources/tester/test_200_2_b09_bill_level_bar.py +++ b/resources/tester/test_200_2_b09_bill_level_bar.py @@ -197,8 +197,8 @@ def test_왼쪽_맞춤_레벨_들여쓰기_글꼴_공용_표(): def test_셸_배선(): src = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") assert ( - "? (i) => [pickedClass(bill.rows[i]), pickClass(jump, i)]" in src - ) # 239-1 이동 줄 빨강 곁 + "bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i)" in src + ) # 239-1 · 242-1 이동 줄 빨강 곁(고름과 한때 하나) assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄 assert "panel.replaceChildren(table.root, ...(level ? [level.guide] : [])" in src lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8") diff --git a/resources/tester/test_239_1_b09_total_bill_summary.py b/resources/tester/test_239_1_b09_total_bill_summary.py index 51fd8e161..4e9958014 100644 --- a/resources/tester/test_239_1_b09_total_bill_summary.py +++ b/resources/tester/test_239_1_b09_total_bill_summary.py @@ -107,8 +107,8 @@ def test_누름_설계내역서_그_줄_빨강(): assert r["link"] is None # 원가계산 줄 = 이동 없음(그대로) assert out["pick"] == [None, "is-pick is-pick-top is-pick-end", None] shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") - assert "const jump: [number, number] | null = at >= 0 ? [at, at] : null;" in shell - assert "[pickedClass(bill.rows[i]), pickClass(jump, i)]" in shell + assert "if (bill) jumpTo(bill.rows, at);" in shell # 242-1 범위 = 그 줄 + 자손 + assert "pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i)" in shell def test_설계내역서_없으면_원가계산_줄_그대로(): diff --git a/resources/tester/test_242_1_b09_bill_jump_range.py b/resources/tester/test_242_1_b09_bill_jump_range.py new file mode 100644 index 000000000..cf65f27a4 --- /dev/null +++ b/resources/tester/test_242_1_b09_bill_jump_range.py @@ -0,0 +1,130 @@ +"""242-1 B09 총괄 → 설계내역서 빨강 = 그 줄 + 하위 전부 · 고름 표시와 한때 하나. + +- 이동해 온 줄(Shell show focus) = jumpTo — 범위 = 그 줄 + 뒤 줄 중 레벨이 더 깊은 연속 줄 · + 줄 class = is-pick(첫 줄 is-pick-top · 끝 줄 is-pick-end) · 바깥 테두리 한 벌(181-2 CSS) +- 오면 고른 줄(is-focus) 풂 · 공용 표 focusRow(at, false) = 스크롤만(공용 강조 안 붙임) +- 설계내역서에서 줄 누름(범위 안 · 밖) · 방향키 · Esc · 끌어 놓음 = 빨강 풂 · 누른 줄만 고름 상자 +- 표 · 문서 = 가짜 DOM(200-2 시험 틀) · 줄 class = Shell 과 같은 곁값(pickedClass ?? pickClass) +""" + +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_184_3_b09_bill_menu import ROWS, _node, _ts # noqa: E402 +from test_200_2_b09_bill_level_bar import BAR_JS, SRC # noqa: E402 + +ROOT = Path(__file__).resolve().parents[2] +B09 = ROOT / "B09_Estimation" +GROUPS = (B09 / "B09_Estimation_UI_Tabs_Groups.ts").read_text("utf-8") +PICK = re.search(r"export function pickClass\(.*?\n}\n", GROUPS, re.DOTALL).group(0) + +SETUP = ( + "let focusAt = null;\n" + + BAR_JS.split("const out = {};")[0] + # 가짜 DOM 보강 — class 여럿 지움 · tr. 고르기 + .replace( + "remove: (c) => s.delete(c) }", + "remove: (...c) => c.forEach((x) => s.delete(x)) }", + ) + .replace( + 'querySelectorAll() { return this.all().filter((n) => n.tagName === "TR" && n.cls.has("is-focus")); }', + "querySelectorAll(sel) { const c = sel.match(/tr\\.([\\w-]+)/)[1];" + ' return this.all().filter((n) => n.tagName === "TR" && n.cls.has(c)); }', + ) + # Shell 차례 — jumpTo → 줄 그림(줄 class 곁값) → levelBar + .replace( + ' root = new FakeEl("div");', + " jumpTo(table.rows, focusAt ? table.rows.findIndex((r) => r.key === focusAt) : -1);\n" + ' root = new FakeEl("div");', + ) + .replace( + "td.append(input); tr.append(td); root.append(tr);", + "td.append(input); tr.append(td); root.append(tr);" + " const c = pickedClass(r) ?? pickClass(jumpRange(), i); if (c) tr.className = c;", + ) +) + +for patch in ["remove: (...c)", "querySelectorAll(sel)", "jumpTo(table.rows", "jumpRange(), i)"]: + assert patch in SETUP, patch # 200-2 틀이 바뀌면 여기서 멈춤 + +RUN_JS = """ +const marks = () => Object.fromEntries(root.kids.flatMap((tr, i) => { + const c = [...tr.cls].filter((x) => x.startsWith("is-")).sort().join(" "); + return c ? [[table.rows[i].key, c]] : []; +})); +const out = {}; +const go = (k) => { focusAt = k; user = {}; draw(); focusAt = null; }; +go("sec:1.2"); +out.range = [jumpRange(), marks()]; +out.ends = ["sec:1", "earth:4", "hand:g", "fee"].map((k) => subtreeEnd(table.rows, at(k))); +pick("earth:5"); +out.inside = [jumpRange(), marks()]; +go(null); pick("earth:4"); +out.before = marks(); +go("sec:1"); +out.came = [jumpRange(), marks(), table.rows.map((r) => pickedClass(r)).filter(Boolean)]; +pick("mat:M1"); +out.outside = [jumpRange(), marks()]; +go(null); +out.redraw = marks(); +go("sec:1.2"); root.focus(); +out.arrow = [key("ArrowDown"), jumpRange(), marks(), order()]; +go("sec:4"); root.focus(); +out.esc = [key("Escape"), jumpRange(), marks()]; +go("sec:4"); root.focus(); +out.other = [key("a"), jumpRange()]; +console.log(JSON.stringify(out)); +""" + +PICKED = {"sec:1.2": "is-pick is-pick-top", "earth:4": "is-pick", "earth:5": "is-pick", + "hand:g": "is-pick", "hand:a": "is-pick", "hand:b": "is-pick is-pick-end"} # fmt: skip + + +def _run(): + body = (SETUP + RUN_JS).replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False)) + return json.loads(_node(_ts(SRC) + PICK + body)) + + +def test_범위_그_줄_자손_전부_바깥_테두리_한_벌(): + out = _run() + assert out["range"] == [[1, 6], PICKED] + # sec:1 = 레벨 1 아래 전부 · 항목 = 자기만 · 묶음 = 손 줄까지 · 끝 줄 = 자기 + assert out["ends"] == [6, 2, 5, 11] + + +def test_표시_하나_총괄에서_오면_고름_풂(): + out = _run() + assert out["before"] == {"earth:4": "is-focus"} + rng, marks, picked = out["came"] + assert rng == [0, 6] and picked == [] + assert all("is-focus" not in c for c in marks.values()) + assert marks["sec:1"] == "is-pick is-pick-top" and marks["hand:b"] == "is-pick is-pick-end" + + +def test_다른_줄_누름_빨강_풂_그_줄만_고름(): + out = _run() + assert out["inside"] == [None, {"earth:5": "is-focus"}] # 범위 안 줄 + assert out["outside"] == [None, {"mat:M1": "is-focus"}] # 범위 밖 줄 + assert out["redraw"] == {"mat:M1": "is-focus"} # 다시 그려도 고름만 + + +def test_방향키_Esc_빨강만_풂_다른_키_그대로(): + out = _run() + assert out["arrow"] == [False, None, {}, []] # 줄 안 옮김(고른 줄 없음) + assert out["esc"] == [False, None, {}] + assert out["other"] == [False, [7, 8]] + + +def test_Shell_배선_공용_표_스크롤만(): + shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "if (bill) jumpTo(bill.rows, at);" in shell + assert "pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i)" in shell + assert "table.focusRow(at, !bill)" in shell + common = (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8") + assert "focusRow: (index: number, mark?: boolean) => void;" in common + assert "state.focus = mark ? index : null;" in common + level = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8") + assert 'tr.classList.remove("is-pick", "is-pick-top", "is-pick-end")' in level diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index 8cd212f94..99c6fde91 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -93,8 +93,8 @@ export interface DataTableOptions { export interface DataTable { root: HTMLElement; setRows: (rows: DataRow[], total?: DataRow | null) => void; - /** 그 줄로 스크롤하고 강조 */ - focusRow: (index: number) => void; + /** 그 줄로 스크롤하고 강조(mark = false → 스크롤만 · 강조 풂) */ + focusRow: (index: number, mark?: boolean) => void; /** 그 줄 칸 입력 시작(editOnDouble) — key 없거나 못 고치면 마지막 누른 열 · 첫 고칠 칸 · 못 하면 false */ edit: (index: number, key?: string) => boolean; } @@ -534,8 +534,8 @@ export function createDataTable(opts: DataTableOptions): DataTable { if (total !== undefined) state.total = total; draw(true); }, - focusRow(index) { - state.focus = index; + focusRow(index, mark = true) { + state.focus = mark ? index : null; root.scrollTop = Math.max(0, index * win.rowH - root.clientHeight / 3); draw(true); },