Files
Aislo/ui_template/sheet/ui_template_sheet.ts
T
eomsangdonandClaude Opus 5.5 4425bd4fea fix(ui): 표 칸 F2 · Enter 는 커서를 글 끝에 — 이어 쓰기(엑셀과 같음)
- 원인: 칸 고치기가 늘 글 전부를 고른 채 열려 F2 뒤에 치면 이어 쓰지 않고 바뀜(3 → F2 → 4 → 4)
- 고침: 표 부품 `edit` 한 곳 — F2 · Enter 는 커서를 글 끝(고름 없음) · 바로 치기는 누른 글자로 바꿈 · 두 번 누르기는 그대로 전부 고름 · M01 `startEdit` 는 안 건드림
- 확인: ORCA 5180 구조물집계표 단위 칸 「m」 — F2 커서 1·1 → 4 → 「m4」 · Enter 같음 · 바로 치기 「7」 · 두 번 누르기 0·1 · 모두 Esc 로 「m」 되돌림 · 저장 안 함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zGnt85s95baQ72rhKBCJ4
2026-09-28 23:23:27 +09:00

410 lines
18 KiB
TypeScript

/* =============================================================================
* ui_template_sheet.ts
* 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange, drawingLabel, areaLabel, materialLabel, onSelect, onPick})`
* → `{getDoc, setDoc, recalc, destroy}`.
*
* master = 시스템 관리자 양식 고치기 — 머리 · 층 이름 · 단위 · 일위대가 · 줄 더하기·지우기 ·
* 층마다 열 더하기(「분류1 더하기」 … 층 이름 + 더하기) · 열 지우기 · 손 열.
* 도면 · 면적산출 · 재료산출 줄 = `drawingLabel` · `areaLabel` · `materialLabel` 글 ·
* 칸을 고르면 `onSelect` · `onPick` 을 주면 일위대가 · 도면 · 면적산출 · 재료산출 칸은
* 치지 않고 조회 후 선택.
* 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 {
addLevel,
addRow,
canDeleteRow,
deleteColumn,
deleteRow,
isBlankPick,
isBoundColumn,
isCalcColumn,
KEY_PRICE,
KEY_UNIT,
navRows,
PICK_ROWS,
REF_ROWS,
selectionOf,
type SheetMode,
setCell,
setColumnWidth,
setHeadLabel,
setLayerName,
} from "./ui_template_sheet_ops";
import { headDepth } from "./ui_template_sheet_header";
import { recalcSheet } from "./ui_template_sheet_recalc";
import { keyEditable, refText, type RenderState, renderSheet } from "./ui_template_sheet_render";
import { st } from "./ui_template_sheet_text";
import type { SheetColumn, SheetDoc, SheetResult, SheetSelection } from "./ui_template_sheet_types";
import "./ui_template_sheet.css";
export type { SheetMode } from "./ui_template_sheet_ops";
export type { SheetDoc, SheetResult, SheetSelection } from "./ui_template_sheet_types";
export interface SheetOptions {
mode: SheetMode;
onChange?: (doc: SheetDoc) => void;
/** 도면 줄 칸 글(마스터 첫 쪽 · 열 id) — 없거나 빈 글이면 「없음」 · 글이 바뀌면 `recalc()` 로 다시 그림. */
drawingLabel?: (colId: string) => string;
/** 면적산출 · 재료산출 줄 칸 글(번호) — `drawingLabel` 과 같음. */
areaLabel?: (colId: string) => string;
materialLabel?: (colId: string) => string;
/** 칸을 고름 — 누르기 · 방향키 · Tab. */
onSelect?: (sel: SheetSelection) => void;
/** 조회 후 선택 — master 일위대가 · 도면 · 면적산출 · 재료산출 칸을 두 번 누르기 · Enter · F2 로 부름.
* 글을 돌려주면 일위대가 칸에 넣음 · 번호 글 줄은 부른 쪽이 가져온 뒤 `recalc()`.
* 없으면 일위대가는 글로 고침 · 번호 글 줄은 읽기만. */
onPick?: (sel: SheetSelection) => Promise<string | null | undefined> | 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,
drawingLabel: opts.drawingLabel,
areaLabel: opts.areaLabel,
materialLabel: opts.materialLabel,
pick: !!opts.onPick,
};
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();
});
/** `level` 층 새 칸 열 — 고른 열 무리 끝에(층 이름이 있으면 「새 분류2」 · 없으면 「새 열」) */
const addAt = (level: number): void => {
const at = state.sel?.c;
if (!at) return;
const name = state.doc.층?.[level]?.trim();
const label = name ? `${st("New_Prefix")}${name}` : st("Col_New");
const id = addLevel(state.doc, at, level, headDepth(state.doc.열, state.doc.층), label);
if (!id) return;
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(colDelete, colHand);
// 층마다 열 더하기 단추 — 층이 둘 이상이면 둘째 층부터(「분류1 더하기」 …) · 하나면 「열 더하기」 ·
// 층 이름 · 층 수가 바뀌면 다시 만듦
let levelButtons: HTMLButtonElement[] = [];
let levelKey = "";
const syncLevels = (hasCol: boolean): void => {
const depth = headDepth(state.doc.열, state.doc.층);
const levels = depth < 2 ? [0] : Array.from({ length: depth - 1 }, (_, i) => i + 1);
const labels = levels.map((level) => {
const name = state.doc.층?.[level]?.trim();
return name ? `${name}${st("Add_Suffix")}` : st("Col_Add");
});
if (labels.join("|") !== levelKey) {
levelKey = labels.join("|");
for (const b of levelButtons) b.remove();
levelButtons = levels.map((level, i) => button(labels[i], () => addAt(level)));
colDelete.before(...levelButtons);
}
for (const b of levelButtons) b.disabled = !hasCol;
};
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;
if (opts.mode === "master") syncLevels(!!col);
colDelete.disabled = !col || state.doc.열.length <= 1;
// 바인딩 · 계산 열은 손 입력이 안 먹음(프로젝트에서 잠김) — 켜지 못하게 · 이미 켜진 것은 끌 수 있게
colHand.disabled = !col || (!col.손 && (isBoundColumn(col) || isCalcColumn(col)));
colHand.classList.toggle("is-on", !!col?.손);
};
// ── 그리기 · 고름 ─────────────────────────────────────────────────
const render = (): void => {
root.querySelector(".ui-sheet__headhint")?.remove();
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();
opts.onSelect?.(selectionOf(state.doc, r, c));
};
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 === KEY_PRICE) return col.일위대가 ?? "";
if (REF_ROWS.includes(key)) return refText(state, key, col.id);
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 => {
if (REF_ROWS.includes(key)) return; // 읽기만
const value = text.trim();
if (key === KEY_UNIT) col.단위 = value || null;
else if (key === KEY_PRICE) col.일위대가 = value || null;
else setCell(state.doc, key.slice(2), col, text);
changed();
};
/** 조회 후 선택 — 부른 쪽이 고른 글을 돌려주면 일위대가 칸에 넣음(그 사이 문서가 바뀌었으면 새 열에) */
const pick = async (key: string, colId: string): Promise<void> => {
const got = await opts.onPick?.(selectionOf(state.doc, key, colId));
const col = colOf(colId);
if (key === KEY_PRICE && typeof got === "string" && col) write(key, col, got);
root.focus({ preventScroll: true });
};
/** 칸 고치기 — 두 번 누르기 = 글 전부 고름 · 바로 치기(initial) = 그 글자로 바꿈 · Enter · F2(atEnd) = 커서를 글 끝에 */
const edit = (initial?: string, atEnd = false): void => {
const sel = state.sel;
const col = sel && colOf(sel.c);
if (!sel || !col) return;
if (isBlankPick(sel.r, col)) return; // 글 열은 빈칸 · 잠김
if (opts.onPick && state.mode === "master" && PICK_ROWS.includes(sel.r)) {
if (initial === undefined) void pick(sel.r, col.id); // 글자를 쳐서는 안 엶
return;
}
if (!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) return;
if (initial !== undefined) box.value = initial;
if (initial !== undefined || atEnd) box.setSelectionRange(box.value.length, box.value.length);
};
/** 층 이름(머리 층 줄 머리 칸) 고치기 — master */
const editLayer = (th: HTMLElement): void => {
const level = Number(th.dataset.layer);
if (Number.isNaN(level)) return;
startEdit(th, th.textContent ?? "", (value) => {
setLayerName(state.doc, level, value);
changed();
});
};
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);
const depth = headDepth(state.doc.열, state.doc.층);
const tip = el("div", { className: "ui-sheet__headhint", text: st("Head_Hint") });
const at = th.getBoundingClientRect();
tip.style.left = `${at.left}px`;
tip.style.top = `${at.bottom + 2}px`;
root.append(tip);
startEdit(th, th.textContent ?? "", (value) => {
for (const col of span) setHeadLabel(col.머리, level, value, depth);
changed();
});
th.querySelector("input")?.addEventListener("blur", () => tip.remove());
};
// ── 사건 ──────────────────────────────────────────────────────────
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]");
const layer = target.closest<HTMLElement>("th[data-layer]");
if (th && state.mode === "master") editHead(th);
else if (layer && state.mode === "master") editLayer(layer);
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(undefined, true);
} 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;
},
};
}