- 식 있는 열 = 계산 · 바인딩만 = 설계값(펼침이면 값마다 열) · 손 = 손 입력 - 식 줄은 [열id] 대신 머리 글 이름 · 같은 종류는 짧게 · 풍선은 긴 이름 + id 식 · 변수도 이름 - 열 폭 최소 = 머리 글 · 단위 폭(캔버스로 잼) · 들어갈 것 · 식 줄은 세 줄로 자르고 풍선에 전문 · 머리 칸도 풍선 - 쪽 나눔을 pagePlan 으로 떼어 시험 test_sheet_pages.py(줄 120 → 쪽 3 · 합계 마지막 쪽만) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1
327 lines
13 KiB
TypeScript
327 lines
13 KiB
TypeScript
/* =============================================================================
|
|
* ui_template_sheet.ts
|
|
* 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange})` → `{getDoc, setDoc, recalc, destroy}`.
|
|
*
|
|
* master = 시스템 관리자 양식 고치기 — 머리 · 단위 · 식 · 들어갈 것 · 일위대가 · 줄 · 열 더하기·지우기 · 손 열.
|
|
* project = 프로젝트 표 — 바인딩 · 계산 열 잠금 · 손 열만 입력 · 「전구간」 줄 맨 위 · 쪽줄마다 쪽.
|
|
* 칸을 고치면 문서를 그 자리에서 바꾸고 같은 풀이(`_recalc`)로 즉시 다시 그림 — 서버 왕복 없음.
|
|
* 저장은 부른 쪽 몫(`onChange` 로 문서를 받음 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂).
|
|
* 칸 고치기는 M01 `startEdit`(Enter · 칸 밖 = 확정 · Esc = 취소) 를 그대로 씀.
|
|
* ========================================================================== */
|
|
|
|
import { createButton, el } from "@ui/ui_template_elements";
|
|
import { startEdit } from "../../M01_MasterData/M01_MasterData_UI_Cells";
|
|
import {
|
|
addColumn,
|
|
addRow,
|
|
canDeleteRow,
|
|
deleteColumn,
|
|
deleteRow,
|
|
type SheetMode,
|
|
setCell,
|
|
setColumnWidth,
|
|
} from "./ui_template_sheet_ops";
|
|
import { recalcSheet } from "./ui_template_sheet_recalc";
|
|
import {
|
|
KEY_UNIT,
|
|
keyEditable,
|
|
navRows,
|
|
type RenderState,
|
|
renderSheet,
|
|
} from "./ui_template_sheet_render";
|
|
import { st } from "./ui_template_sheet_text";
|
|
import type { SheetColumn, SheetDoc, SheetResult } from "./ui_template_sheet_types";
|
|
import "./ui_template_sheet.css";
|
|
|
|
export type { SheetMode } from "./ui_template_sheet_ops";
|
|
export type { SheetDoc, SheetResult } from "./ui_template_sheet_types";
|
|
|
|
export interface SheetOptions {
|
|
mode: SheetMode;
|
|
onChange?: (doc: SheetDoc) => void;
|
|
}
|
|
|
|
export interface SheetHandle {
|
|
getDoc(): SheetDoc;
|
|
setDoc(doc: SheetDoc): void;
|
|
recalc(): SheetResult;
|
|
destroy(): void;
|
|
}
|
|
|
|
const MIN_WIDTH = 32;
|
|
|
|
export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptions): SheetHandle {
|
|
const state: RenderState = {
|
|
doc: structuredClone(input),
|
|
mode: opts.mode,
|
|
result: { 계산: {}, 합계: {}, 오류: [] },
|
|
sel: null,
|
|
};
|
|
const scroll = el("div", { className: "ui-sheet__scroll" });
|
|
const toolbar = el("div", { className: "ui-sheet__toolbar" });
|
|
const root = el("div", {
|
|
className: `ui-sheet ui-sheet--${opts.mode}`,
|
|
attrs: { tabindex: "0" },
|
|
children: [toolbar, el("p", { className: "ui-sheet__hint", text: st("Hint") }), scroll],
|
|
});
|
|
host.replaceChildren(root);
|
|
|
|
const colOf = (id: string): SheetColumn | undefined => state.doc.열.find((c) => c.id === id);
|
|
const rowIdOf = (key: string | undefined): string | null =>
|
|
key?.startsWith("d:") ? key.slice(2) : null;
|
|
|
|
// ── 단추 ──────────────────────────────────────────────────────────
|
|
const button = (label: string, run: () => void): HTMLButtonElement =>
|
|
createButton({ label, variant: "ghost", onClick: run });
|
|
const rowAdd = button(st("Row_Add"), () => {
|
|
const id = addRow(state.doc, state.mode, rowIdOf(state.sel?.r));
|
|
state.sel = { r: `d:${id}`, c: state.sel?.c ?? state.doc.열[0]?.id ?? "" };
|
|
changed();
|
|
});
|
|
const rowDelete = button(st("Row_Delete"), () => {
|
|
const id = rowIdOf(state.sel?.r);
|
|
if (!id) return;
|
|
const keys = navRows(state.doc, state.mode);
|
|
deleteRow(state.doc, id);
|
|
const next = keys[keys.indexOf(`d:${id}`) + 1] ?? keys[keys.indexOf(`d:${id}`) - 1];
|
|
state.sel = next && state.sel ? { r: next, c: state.sel.c } : null;
|
|
changed();
|
|
});
|
|
const colAdd = button(st("Col_Add"), () => {
|
|
const id = addColumn(state.doc, state.sel?.c ?? null, st("Col_New"));
|
|
state.sel = { r: state.sel?.r ?? KEY_UNIT, c: id };
|
|
changed();
|
|
});
|
|
const colDelete = button(st("Col_Delete"), () => {
|
|
const id = state.sel?.c;
|
|
if (!id || state.doc.열.length <= 1) return;
|
|
const at = state.doc.열.findIndex((c) => c.id === id);
|
|
deleteColumn(state.doc, id);
|
|
const next = state.doc.열[Math.min(at, state.doc.열.length - 1)];
|
|
state.sel = state.sel && next ? { r: state.sel.r, c: next.id } : null;
|
|
changed();
|
|
});
|
|
const colHand = button(st("Col_Hand"), () => {
|
|
const col = state.sel && colOf(state.sel.c);
|
|
if (!col) return;
|
|
if (col.손) delete col.손;
|
|
else col.손 = true;
|
|
changed();
|
|
});
|
|
toolbar.append(rowAdd, rowDelete);
|
|
if (opts.mode === "master") toolbar.append(colAdd, colDelete, colHand);
|
|
|
|
const syncToolbar = (): void => {
|
|
const rowId = rowIdOf(state.sel?.r);
|
|
const row = state.doc.줄.find((r) => r.id === rowId);
|
|
rowDelete.disabled = !row || !canDeleteRow(state.mode, row);
|
|
const col = state.sel ? colOf(state.sel.c) : undefined;
|
|
colDelete.disabled = !col || state.doc.열.length <= 1;
|
|
colHand.disabled = !col;
|
|
colHand.classList.toggle("is-on", !!col?.손);
|
|
};
|
|
|
|
// ── 그리기 · 고름 ─────────────────────────────────────────────────
|
|
const render = (): void => {
|
|
state.result = recalcSheet(state.doc);
|
|
const { scrollLeft, scrollTop } = scroll;
|
|
scroll.replaceChildren(renderSheet(state));
|
|
scroll.scrollLeft = scrollLeft;
|
|
scroll.scrollTop = scrollTop;
|
|
syncToolbar();
|
|
};
|
|
const changed = (): void => {
|
|
render();
|
|
opts.onChange?.(structuredClone(state.doc));
|
|
};
|
|
|
|
const cellsAt = (r: string, c: string): HTMLElement[] => [
|
|
...scroll.querySelectorAll<HTMLElement>(
|
|
`td[data-r="${CSS.escape(r)}"][data-c="${CSS.escape(c)}"]`,
|
|
),
|
|
];
|
|
const select = (r: string, c: string, reveal = false): void => {
|
|
for (const cell of scroll.querySelectorAll(".is-selected"))
|
|
cell.classList.remove("is-selected");
|
|
state.sel = { r, c };
|
|
const cells = cellsAt(r, c);
|
|
for (const cell of cells) cell.classList.add("is-selected");
|
|
if (reveal) cells[0]?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
syncToolbar();
|
|
};
|
|
const move = (dr: number, dc: number): void => {
|
|
const keys = navRows(state.doc, state.mode);
|
|
const cols = state.doc.열;
|
|
if (!state.sel) {
|
|
if (keys.length && cols.length) select(keys[0], cols[0].id, true);
|
|
return;
|
|
}
|
|
const r = Math.max(0, Math.min(keys.length - 1, keys.indexOf(state.sel.r) + dr));
|
|
const at = cols.findIndex((c) => c.id === state.sel!.c);
|
|
const c = Math.max(0, Math.min(cols.length - 1, at + dc));
|
|
select(keys[r], cols[c].id, true);
|
|
};
|
|
|
|
// ── 칸 고치기 ─────────────────────────────────────────────────────
|
|
const rawText = (key: string, col: SheetColumn): string => {
|
|
if (key === KEY_UNIT) return col.단위 ?? "";
|
|
if (key === "s:formula") return col.식 ?? "";
|
|
if (key === "s:desc") return col.설명 ?? "";
|
|
if (key === "s:price") return col.일위대가 ?? "";
|
|
const row = state.doc.줄.find((r) => `d:${r.id}` === key);
|
|
const formula = row?.식?.[col.id] ?? col.식;
|
|
if (formula) return `=${formula}`;
|
|
const raw = row?.값[col.id];
|
|
return raw === null || raw === undefined ? "" : String(raw);
|
|
};
|
|
const write = (key: string, col: SheetColumn, text: string): void => {
|
|
const value = text.trim();
|
|
if (key === KEY_UNIT) col.단위 = value || null;
|
|
else if (key === "s:formula") {
|
|
if (value) col.식 = value;
|
|
else delete col.식;
|
|
} else if (key === "s:desc") col.설명 = value;
|
|
else if (key === "s:price") col.일위대가 = value || null;
|
|
else setCell(state.doc, key.slice(2), col, text);
|
|
changed();
|
|
};
|
|
const edit = (initial?: string): void => {
|
|
const sel = state.sel;
|
|
const col = sel && colOf(sel.c);
|
|
if (!sel || !col || !keyEditable(state, sel.r, col)) return;
|
|
const td = cellsAt(sel.r, sel.c)[0];
|
|
if (!td) return;
|
|
startEdit(td, rawText(sel.r, col), (value) => write(sel.r, col, value));
|
|
const box = td.querySelector("input");
|
|
if (box && initial !== undefined) box.value = initial;
|
|
};
|
|
const editHead = (th: HTMLElement): void => {
|
|
const start = state.doc.열.findIndex((c) => c.id === th.dataset.col);
|
|
const level = Number(th.dataset.level);
|
|
if (start < 0 || Number.isNaN(level)) return;
|
|
const span = state.doc.열.slice(start, start + (th as HTMLTableCellElement).colSpan);
|
|
startEdit(th, th.textContent ?? "", (value) => {
|
|
for (const col of span) {
|
|
while (col.머리.length <= level) col.머리.push(null);
|
|
col.머리[level] = value.trim();
|
|
}
|
|
changed();
|
|
});
|
|
};
|
|
|
|
// ── 사건 ──────────────────────────────────────────────────────────
|
|
const onClick = (event: MouseEvent): void => {
|
|
const target = event.target as HTMLElement;
|
|
if (target.closest("input")) return;
|
|
const td = target.closest<HTMLElement>("td[data-r]");
|
|
if (td) select(td.dataset.r!, td.dataset.c!);
|
|
const th = target.closest<HTMLElement>("th[data-col]");
|
|
if (th) select(state.sel?.r ?? KEY_UNIT, th.dataset.col!);
|
|
root.focus({ preventScroll: true });
|
|
};
|
|
const onDblClick = (event: MouseEvent): void => {
|
|
const target = event.target as HTMLElement;
|
|
if (target.closest("input")) return;
|
|
const th = target.closest<HTMLElement>("th[data-col]");
|
|
if (th && state.mode === "master") editHead(th);
|
|
else if (target.closest("td[data-r]")) edit();
|
|
};
|
|
const onKey = (event: KeyboardEvent): void => {
|
|
const typing = (event.target as HTMLElement).tagName === "INPUT";
|
|
if (typing) {
|
|
if (event.key === "Enter") {
|
|
move(1, 0);
|
|
root.focus({ preventScroll: true });
|
|
} else if (event.key === "Tab") {
|
|
event.preventDefault();
|
|
(event.target as HTMLInputElement).blur();
|
|
move(0, event.shiftKey ? -1 : 1);
|
|
root.focus({ preventScroll: true });
|
|
} else if (event.key === "Escape") {
|
|
render();
|
|
root.focus({ preventScroll: true });
|
|
}
|
|
return;
|
|
}
|
|
const arrows: Record<string, [number, number]> = {
|
|
ArrowUp: [-1, 0],
|
|
ArrowDown: [1, 0],
|
|
ArrowLeft: [0, -1],
|
|
ArrowRight: [0, 1],
|
|
};
|
|
if (event.key in arrows) {
|
|
event.preventDefault();
|
|
move(...arrows[event.key]);
|
|
} else if (event.key === "Tab") {
|
|
event.preventDefault();
|
|
move(0, event.shiftKey ? -1 : 1);
|
|
} else if (event.key === "Enter" || event.key === "F2") {
|
|
event.preventDefault();
|
|
edit();
|
|
} else if (event.key === "Delete" || event.key === "Backspace") {
|
|
const col = state.sel && colOf(state.sel.c);
|
|
if (state.sel && col && keyEditable(state, state.sel.r, col)) {
|
|
event.preventDefault();
|
|
write(state.sel.r, col, "");
|
|
}
|
|
} else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
event.preventDefault();
|
|
edit(event.key);
|
|
}
|
|
};
|
|
|
|
// 열 너비 끌기 — 단위 줄 칸 오른쪽 손잡이
|
|
let drag: { id: string; x: number; width: number; total: number } | null = null;
|
|
const onPointerDown = (event: PointerEvent): void => {
|
|
const handle = (event.target as HTMLElement).closest<HTMLElement>("[data-resize]");
|
|
const col = handle && colOf(handle.dataset.resize!);
|
|
if (!handle || !col) return;
|
|
event.preventDefault();
|
|
handle.setPointerCapture(event.pointerId);
|
|
const total = parseFloat(scroll.querySelector<HTMLElement>("table")?.style.width ?? "0");
|
|
const now = scroll.querySelector<HTMLElement>(`col[data-col="${CSS.escape(col.id)}"]`);
|
|
drag = { id: col.id, x: event.clientX, width: parseFloat(now?.style.width ?? "72"), total };
|
|
};
|
|
const onPointerMove = (event: PointerEvent): void => {
|
|
if (!drag) return;
|
|
const width = Math.max(MIN_WIDTH, drag.width + event.clientX - drag.x);
|
|
for (const c of scroll.querySelectorAll<HTMLElement>(`col[data-col="${CSS.escape(drag.id)}"]`))
|
|
c.style.width = `${width}px`;
|
|
for (const table of scroll.querySelectorAll<HTMLElement>("table"))
|
|
table.style.width = `${drag.total - drag.width + width}px`;
|
|
};
|
|
const onPointerUp = (event: PointerEvent): void => {
|
|
if (!drag) return;
|
|
const width = Math.max(MIN_WIDTH, drag.width + event.clientX - drag.x);
|
|
const id = drag.id;
|
|
drag = null;
|
|
setColumnWidth(state.doc, id, width);
|
|
changed();
|
|
};
|
|
|
|
root.addEventListener("click", onClick);
|
|
root.addEventListener("dblclick", onDblClick);
|
|
root.addEventListener("keydown", onKey);
|
|
root.addEventListener("pointerdown", onPointerDown);
|
|
root.addEventListener("pointermove", onPointerMove);
|
|
root.addEventListener("pointerup", onPointerUp);
|
|
render();
|
|
|
|
return {
|
|
getDoc: () => structuredClone(state.doc),
|
|
setDoc: (doc) => {
|
|
state.doc = structuredClone(doc);
|
|
state.sel = null;
|
|
render();
|
|
},
|
|
recalc: () => {
|
|
render();
|
|
return structuredClone(state.result);
|
|
},
|
|
destroy: () => {
|
|
root.remove();
|
|
drag = null;
|
|
},
|
|
};
|
|
}
|