feat(B09): 설계내역서 접히지 않는 트리 — 폴더 줄 수식 · 파일 줄 입력 · 안내선 · 더블클릭 입력 · 고른 줄 상자(235-1)

- 공용 표 editOnDouble — 고칠 칸 = 글 · 더블클릭 · Enter · F2 = 그 칸만 글 상자 · Enter · 초점 떠남 = 입력 끝
- 공용 표 level.tree — ├ └ │ 안내선 + 폴더 · 파일 아이콘(공용 나무와 한 벌 TREE_ICON)
- 설계내역서 — 폴더 줄 금액 = 자손 합 수식 풍선 · 고른 줄 = accent 상자 테두리
- test_235_1 새 시험 · test_228_1 들여쓰기 식 개수 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
This commit is contained in:
2026-10-10 19:09:00 +09:00
co-authored by Claude Opus 5.5
parent 21c1428772
commit 3e70362b14
10 changed files with 580 additions and 27 deletions
+112 -13
View File
@@ -14,6 +14,9 @@
* 레벨(PLAN 201-1 · 보기 전용) = level { key, of(줄) } → 그 열 왼쪽 맞춤 + 레벨마다 1em 들여쓰기(글 상자도)
* 글꼴(PLAN 228-1) = 칸 class is-lv1~5 · 묶음(group · 안 주면 바로 아래 줄이 더 깊은 줄) = is-node 굵게
* 크기 Lv1 16 · Lv2 15 · Lv3 14 · Lv4 13 · Lv5 12(px) · 항목 줄은 14 보다 크지 않게
* 나무(PLAN 235-1 · tree) = 접히지 않는 나무 안내선(├ is-tee · └ is-end · │ is-bar) + 폴더 · 파일 아이콘(공용 나무 꼴)
* 더블클릭 입력(PLAN 235-1 · editOnDouble) = 고칠 칸도 글로 그림 · 더블클릭 · t.edit(줄) = 그 칸만 글 상자 ·
* Enter(조합 끝난 뒤) · 초점 떠남 = 입력 끝(onEdit) — 한 번 누름 = 줄 고름 · 끌기
* 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만
* 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c)
* ========================================================================== */
@@ -76,7 +79,13 @@ export interface DataTableOptions {
key: string;
of: (index: number) => number | null | undefined;
group?: (index: number) => boolean;
/** 접히지 않는 나무 안내선 · 폴더(묶음) · 파일 아이콘(235-1) */
tree?: boolean;
};
/** 참이면 고칠 칸 = 글 · 더블클릭 · edit() 때만 글 상자(235-1) */
editOnDouble?: boolean;
/** 지금 글 상자인 칸(editOnDouble · 표가 정함) */
editing?: { index: number; key: string } | null;
/** 같은 값 세로 병합할 열 키(표마다 지정) — 위아래로 같은 글이 이어지면 rowSpan 한 칸 */
mergeSame?: string[];
}
@@ -86,8 +95,17 @@ export interface DataTable {
setRows: (rows: DataRow[], total?: DataRow | null) => void;
/** 그 줄로 스크롤하고 강조 */
focusRow: (index: number) => void;
/** 그 줄 칸 입력 시작(editOnDouble) — key 없거나 못 고치면 마지막 누른 열 · 첫 고칠 칸 · 못 하면 false */
edit: (index: number, key?: string) => boolean;
}
/** 나무 아이콘 — 폴더 · 항목(글꼴색을 따름) · 공용 나무(`ui_template_tree` 기본 줄)와 같은 꼴 */
export const TREE_ICON = {
folder:
'<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M1.5 3.5h4l1.5 1.5h7.5v8h-13z" fill="none" stroke="currentColor" stroke-linejoin="round"/></svg>',
leaf: '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M3.5 1.5h6l3 3v10h-9z M9.5 1.5v3h3" fill="none" stroke="currentColor" stroke-linejoin="round"/></svg>',
};
const EMPTY = "-";
const ESC: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
const esc = (s: string): string => s.replace(/[&<>"]/g, (c) => ESC[c]);
@@ -169,14 +187,16 @@ function rowHtml(
if (!span) continue; // 위 칸에 합쳐짐
const lvc =
lv?.key === key
? ` ui-data-table__lv is-lv${Math.min(5, lv.n + 1)}${lv.node ? " is-node" : ""}`
? ` ui-data-table__lv is-lv${Math.min(5, lv.n + 1)}${lv.node ? " is-node" : ""}${lv.tree === undefined ? "" : " has-tree"}`
: "";
// 나무(235-1) = 안내선 + 폴더 · 파일 아이콘 — 글 상자 칸은 안내선만
const guide = lv?.key === key ? (lv.tree ?? "") : "";
const c = spans ? ` data-c="${j}"` : "";
if (edit !== null && editable?.(edit, key)) {
const v = r[key];
const raw = v === null || v === undefined ? "" : String(v);
const tip = esc(cols[j].title ?? cols[j].label);
tr += `<td class="is-edit${lvc}" data-num=""${c}><input class="ui-data-table__input" data-i="${edit}" data-k="${esc(key)}" value="${esc(raw)}" inputmode="decimal" aria-label="${tip}"></td>`;
tr += `<td class="is-edit${lvc}" data-num=""${c}>${guide}<input class="ui-data-table__input" data-i="${edit}" data-k="${esc(key)}" value="${esc(raw)}" inputmode="decimal" aria-label="${tip}"></td>`;
continue;
}
const [txt, empty] = formatCell(r[key], fmts[j], blank);
@@ -197,7 +217,11 @@ function rowHtml(
(fmts[j] ? ' data-num=""' : "") +
(span > 1 ? ` rowspan="${span}"` : "") +
c;
tr += `<td${attrs}>${esc(txt)}</td>`;
const icon =
lv?.key === key && lv.tree !== undefined
? `<span class="ui-data-table__icon">${lv.node ? TREE_ICON.folder : TREE_ICON.leaf}</span>`
: "";
tr += `<td${attrs}>${guide}${icon}${esc(txt)}</td>`;
}
const cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus", extra]
.filter(Boolean)
@@ -209,9 +233,10 @@ function rowHtml(
: `<tr>${tr}</tr>`;
}
/** 줄 곁값 — lv = 레벨 열 · 들여쓸 칸 수 · 묶음 줄인지 · spans = 열마다 rowSpan(0 = 위 칸에 합쳐짐) */
/** 줄 곁값 — lv = 레벨 열 · 들여쓸 칸 수 · 묶음 줄인지 · 나무 안내선 HTML(나무 아니면 없음) ·
* spans = 열마다 rowSpan(0 = 위 칸에 합쳐짐) */
interface RowMore {
lv?: { key: string; n: number; node: boolean };
lv?: { key: string; n: number; node: boolean; tree?: string };
spans?: number[];
}
@@ -248,6 +273,25 @@ export function mergeSpans(o: DataTableOptions, from: number, to: number): numbe
return out;
}
/** 나무 안내선(235-1) — 줄마다 칸(한 단 = 들여쓰기 한 칸 · 레벨 2 부터) span HTML.
* 자기 칸 = 아래에 같은 레벨 형제가 더 있으면 ├(is-tee) · 끝이면 └(is-end) · 윗대 칸 = 그 윗대 형제가 아래에 더 있으면 │(is-bar) */
export function treeGuides(levels: number[]): string[] {
const out: string[] = new Array(levels.length);
const more: boolean[] = []; // 레벨 d 형제가 아래에 더 있음(더 얕은 줄 전까지)
const span = (cls: string, slot: number) =>
`<span class="ui-data-table__guide ${cls}" style="--g:${slot}"></span>`;
for (let i = levels.length - 1; i >= 0; i--) {
const lv = Math.max(1, levels[i]);
let html = "";
for (let d = 2; d < lv; d++) if (more[d]) html += span("is-bar", d - 2);
if (lv >= 2) html += span(more[lv] ? "is-tee" : "is-end", lv - 2);
out[i] = html;
more[lv] = true;
more.length = lv + 1; // 더 깊은 레벨 = 이 줄에서 끊김
}
return out;
}
/** 그릴 줄 구간 — 밖의 줄은 빈 줄 높이(rowH px)로만 채움 */
export interface RowWindow {
from: number;
@@ -284,6 +328,12 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
const blank = o.empty ?? EMPTY;
const pick = o.onRowClick !== undefined || o.onCellClick !== undefined;
const spans = o.mergeSame?.length ? mergeSpans(o, from, to) : null;
const tree = o.level?.tree ? treeGuides(o.rows.map((_r, i) => o.level!.of(i) ?? 1)) : null;
// 더블클릭 입력(235-1) — 글 상자 = 지금 고치는 칸만
const inputAt = o.editOnDouble
? (i: number, key: string) =>
o.editing?.index === i && o.editing.key === key && !!o.editable?.(i, key)
: o.editable;
for (let i = from; i < to; i++) {
const lvN = o.level ? Math.max(0, (o.level.of(i) ?? 1) - 1) : 0;
const node = o.level ? isNode(o.level, i, n) : false;
@@ -296,12 +346,12 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
blank,
pick ? i : null,
i,
o.editable,
inputAt,
o.focus === i,
o.rowClass?.(i),
o.cellClass ? (key) => o.cellClass?.(i, key) : undefined,
{
...(o.level ? { lv: { key: o.level.key, n: lvN, node } } : {}),
...(o.level ? { lv: { key: o.level.key, n: lvN, node, tree: tree?.[i] } } : {}),
...(spans ? { spans: spans[i - from] } : {}),
},
);
@@ -419,14 +469,62 @@ export function createDataTable(opts: DataTableOptions): DataTable {
const key = input.dataset.k;
setTimeout(() => state.onEdit?.(index, key, input.value));
});
root.addEventListener("click", (event) => {
const row = (event.target as Element).closest<HTMLElement>("tr[data-i]");
if (!row || !root.contains(row)) return;
const cell = (event.target as Element).closest<HTMLTableCellElement>("td");
/** 누른 칸 [줄, 열 키] — 본문 줄 밖 = null */
const cellAt = (target: EventTarget | null): [number, string] | null => {
const row = (target as Element).closest<HTMLElement>("tr[data-i]");
if (!row || !root.contains(row)) return null;
const cell = (target as Element).closest<HTMLTableCellElement>("td");
const column = cell ? state.columns[cellColumn(cell)] : undefined;
if (column) state.onCellClick?.(Number(row.dataset.i), column.key);
state.onRowClick?.(Number(row.dataset.i));
return [Number(row.dataset.i), column?.key ?? ""];
};
let lastKey = "";
root.addEventListener("click", (event) => {
const at = cellAt(event.target);
if (!at) return;
lastKey = at[1];
if (at[1]) state.onCellClick?.(at[0], at[1]);
state.onRowClick?.(at[0]);
});
const edit = (index: number, key?: string): boolean => {
if (!state.editOnDouble || !state.editable) return false;
const can = state.columns.map((c) => c.key).filter((k) => state.editable!(index, k));
const k = [key, lastKey, can[0]].find((x) => x !== undefined && can.includes(x));
if (k === undefined) return false;
state.editing = { index, key: k };
draw(true);
const input = root.querySelector<HTMLInputElement>(
`input[data-i="${index}"][data-k="${CSS.escape(k)}"]`,
);
input?.focus();
input?.select();
return true;
};
if (opts.editOnDouble) {
root.addEventListener("dblclick", (event) => {
const at = cellAt(event.target);
if (at && state.editable?.(at[0], at[1])) edit(at[0], at[1]);
});
// Enter = 입력 끝(한글 조합 중 Enter 는 조합 끝) — 표에 초점 · 키가 문서까지 가지 않게(다시 입력 시작 방지)
root.addEventListener("keydown", (event) => {
if (event.key !== "Enter" || event.isComposing) return;
if (!(event.target instanceof HTMLInputElement)) return;
event.preventDefault();
event.stopPropagation();
root.focus();
});
// 초점 떠남 = 입력 끝 — 다시 그린 같은 칸으로 초점이 돌아왔으면 그대로
root.addEventListener("focusout", (event) => {
const input = event.target as HTMLInputElement;
if (!state.editing || !input.dataset?.k) return;
setTimeout(() => {
const now = document.activeElement as HTMLElement | null;
const back = now && root.contains(now) && now.dataset.k === input.dataset.k;
if (back && now.dataset.i === input.dataset.i) return;
state.editing = null;
draw(true);
});
});
}
draw(true);
if (opts.fillViewport) fillViewport(root, () => draw(false));
return {
@@ -441,5 +539,6 @@ export function createDataTable(opts: DataTableOptions): DataTable {
root.scrollTop = Math.max(0, index * win.rowH - root.clientHeight / 3);
draw(true);
},
edit,
};
}