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:
@@ -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> = { "&": "&", "<": "<", ">": ">", '"': """ };
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user