Files
Aislo/A00_Common/spreadsheet/spreadsheet.ts
T
eomsangdonandClaude Opus 5.5 b79793ea31 feat(spreadsheet): D 시트 탭 없앰 선택 — createSpreadsheet({tabs: false})
- 탭 줄을 안 그림 · Ctrl+PageDown/PageUp · Shift+F11 을 받지 않음(브라우저 몫) · [데이터 ▾] 의 시트 숨기기 · 탭 색 빠짐
- 산출근거(시트 하나)용 · 크기 화면 28,199 B(예산 28,672)

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

259 lines
9.2 KiB
TypeScript

/* =============================================================================
* spreadsheet.ts (주인 D)
* 산출근거 스프레드시트 진입 — `createSpreadsheet(칸, 통합문서, {readOnly, onChange, onSelect})` →
* `{getDoc, setDoc, recalc, destroy}`(표 부품 `createSheet` 와 같은 모양 · M02 는 부르는 줄만 바꿈).
* 문맥(`SpreadsheetContext`)을 만들어 C 격자 · E 도구 · 탭 · 메뉴 · 클립보드를 붙이고 D 입력(고름 · 편집기 ·
* 키 · 마우스)을 잇음. 한글 조합 = 숨은 textarea 에 늘 초점 · compositionstart 로 편집 시작(keydown 글자 방식 금지).
* ⚠ M02 만 `import()` 로 불러옴 — 정적 import 금지(다른 페이지 번들에 안 섞이게).
* 저장은 부른 쪽 몫(`onChange` 로 문서 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂).
* 화면 차례: 도구 모음(E) · 주소 상자 + 수식 입력줄(D) · 격자(C) · 시트 탭(E) · 상태 표시줄(sub4).
* 2단계 부품(찾기 · 메모 · 서식 붓 · 골라 붙여넣기)은 `spreadsheet_extras.ts` 로 이음.
* ========================================================================== */
import "./spreadsheet.css";
import { el } from "@ui/ui_template_elements";
import { parseA1, rangeToA1, toA1 } from "./spreadsheet_address";
import { attachClipboard } from "./spreadsheet_clipboard";
import { applyCommand, CommandError } from "./spreadsheet_commands";
import { createEditor } from "./spreadsheet_editor";
import { attachExtras } from "./spreadsheet_extras";
import { createCalcEngine } from "./spreadsheet_graph";
import { createGrid } from "./spreadsheet_grid";
import { createHistory } from "./spreadsheet_history";
import { attachKeys } from "./spreadsheet_keys";
import { attachMenu } from "./spreadsheet_menu";
import { attachMouse } from "./spreadsheet_mouse";
import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection";
import { createStatusBar } from "./spreadsheet_statusbar";
import { mountTabs } from "./spreadsheet_tabs";
import { mountToolbar } from "./spreadsheet_toolbar";
import type {
CalcValues,
Command,
CommandEffect,
SheetCellAddress,
Workbook,
} from "./spreadsheet_types";
import type {
EditorHandle,
GridHandle,
PartHandle,
Selection,
SpreadsheetContext,
} from "./spreadsheet_view_types";
export type { Workbook, CalcValues } from "./spreadsheet_types";
export interface SpreadsheetOptions {
/** 읽기만 — 도구 모음 · 메뉴 없음 · 편집 · 붙여넣기 · 끌기 막음(고르기 · 복사는 됨) */
readOnly?: boolean;
/** 명령마다 문서 사본 — 페이지가 캐시(sessionStorage)에 쌓고 [저장] 단추를 켬 */
onChange?: (book: Workbook) => void;
/** 고름이 바뀜 — 시트 id · 활성 칸 A1 · 범위 A1 */
onSelect?: (selection: { 시트: string; 칸: string; 범위: string }) => void;
/** 아래 시트 탭 줄 — false 면 안 그림(산출근거 = 시트 하나) · 시트 오가기 · 새 시트 단축키 · 시트 메뉴도 끔 */
tabs?: boolean;
}
export interface SpreadsheetHandle {
/** 문서 사본 */
getDoc(): Workbook;
/** 문서 바꿔 끼움 — 되돌리기 이력 비움 · 고름 A1 */
setDoc(book: Workbook): void;
/** 전부 다시 풀고 다시 그림 · 결과(저장 모양) */
recalc(): CalcValues;
destroy(): void;
}
/** 움직이는 끝(닻에서 먼 쪽) — 행열 전체면 활성 칸 쪽 */
function farCorner(sel: Selection): { r: number; c: number } {
const g = sel.범위[sel.범위.length - 1];
const a = sel.기준;
const whole = (lo: number, hi: number, last: number): boolean => lo === 0 && hi === last;
return {
r: whole(g.r0, g.r1, LAST_R)
? sel.활성.r
: Math.abs(g.r1 - a.r) >= Math.abs(a.r - g.r0)
? g.r1
: g.r0,
c: whole(g.c0, g.c1, LAST_C)
? sel.활성.c
: Math.abs(g.c1 - a.c) >= Math.abs(a.c - g.c0)
? g.c1
: g.c0,
};
}
export function createSpreadsheet(
host: HTMLElement,
book: Workbook,
opts: SpreadsheetOptions = {},
): SpreadsheetHandle {
const readOnly = !!opts.readOnly;
const noTabs = opts.tabs === false;
const root = el("div", {
className: `ss${readOnly ? " ss--readonly" : ""}${noTabs ? " ss--notabs" : ""}`,
});
host.append(root);
const firstSheet = (b: Workbook) => b.시트.find((s) => s.id === b.활성) ?? b.시트[0];
/** 시트마다 마지막 고름(탭을 오가도 그대로) */
const memory = new Map<string, Selection>();
const ctx: SpreadsheetContext = {
root,
book,
readOnly,
engine: createCalcEngine(book),
history: createHistory(),
selection: selectCell(firstSheet(book), 0, 0),
sheet: () => ctx.book.시트.find((s) => s.id === ctx.selection.시트) ?? ctx.book.시트[0],
dispatch,
undo: () => replay(ctx.history.undo()),
redo: () => replay(ctx.history.redo()),
select,
showSheet,
grid: null as unknown as GridHandle,
editor: null as unknown as EditorHandle,
};
const grid = createGrid(ctx);
ctx.grid = grid;
const ed = createEditor(ctx);
ctx.editor = ed;
const toolbar: PartHandle | null = readOnly ? null : mountToolbar(ctx);
const tabs: PartHandle | null = noTabs ? null : mountTabs(ctx);
const menu: PartHandle | null = readOnly ? null : attachMenu(ctx);
const clip = attachClipboard(ctx);
const status = createStatusBar(ctx);
const others = [toolbar, tabs, menu, clip, status].filter((p): p is PartHandle => !!p);
grid.root.classList.add("ss-grid-host");
for (const node of [toolbar?.root, ed.bar, grid.root, tabs?.root, status.root])
if (node) root.append(node);
const extras = attachExtras(ctx, ed, toolbar?.root ?? null);
others.push(...extras.parts);
const detachKeys = attachKeys(ctx, ed, extras.keys);
const detachMouse = attachMouse(ctx, ed, extras.mouse);
const toast = el("div", { className: "ss-toast" });
let toastTimer = 0;
grid.render();
select(ctx.selection);
function notify(message: string): void {
toast.textContent = message;
root.append(toast);
clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => toast.remove(), 3000);
}
function apply(command: Command): CommandEffect | null {
try {
return applyCommand(ctx.book, command);
} catch (e) {
if (e instanceof CommandError) return (notify(e.message), null);
throw e;
}
}
function dispatch(command: Command): void {
if (readOnly) return;
const eff = apply(command);
if (!eff) return;
ctx.history.push(command, eff.undo);
after(command, eff);
}
function replay(command: Command | null): void {
if (readOnly || !command || ed.editing()) return;
const eff = apply(command);
if (eff) after(command, eff);
}
function after(command: Command, eff: CommandEffect): void {
if (eff.rebuild) {
ctx.engine.rebuild(ctx.book);
if (!ctx.book.시트.some((s) => s.id === ctx.selection.시트)) {
const s = firstSheet(ctx.book);
ctx.selection = memory.get(s.id) ?? selectCell(s, 0, 0);
}
grid.render();
} else {
const again = ctx.engine.update(eff.cells);
if (command.종류 === "칸") grid.invalidate([...again, ...touched(command.시트, command.칸)]);
else grid.render();
}
select(ctx.selection);
opts.onChange?.(structuredClone(ctx.book));
}
/** 칸 명령이 건드린 칸(서식만 바뀐 칸도 다시 그리게) */
function touched(sheet: string, cells: Record<string, unknown>): SheetCellAddress[] {
const out: SheetCellAddress[] = [];
for (const key of Object.keys(cells)) {
const a = parseA1(key);
if (a) out.push({ 시트: sheet, ...a });
}
return out;
}
function select(selection: Selection, reveal = false): void {
ctx.selection = selection;
memory.set(selection.시트, selection);
grid.renderSelection();
if (reveal) {
const far = farCorner(selection);
grid.reveal(far.r, far.c);
}
ed.sync();
for (const p of others) p.refresh();
const g = selection.범위[0];
opts.onSelect?.({
시트: selection.시트,
칸: toA1(selection.활성.r, selection.활성.c),
범위: g ? rangeToA1(g) : toA1(selection.활성.r, selection.활성.c),
});
}
function showSheet(sheetId: string): void {
const sheet = ctx.book.시트.find((s) => s.id === sheetId);
if (!sheet || sheetId === ctx.selection.시트) return;
ed.commit();
if (ed.editing()) return; // 유효성에 막힘
ctx.book.활성 = sheetId;
ctx.selection = memory.get(sheetId) ?? selectCell(sheet, 0, 0);
grid.render();
select(ctx.selection, true);
ed.focus();
}
return {
getDoc: () => structuredClone(ctx.book),
setDoc(next: Workbook) {
ed.cancel();
ctx.book = next;
ctx.engine.rebuild(next);
ctx.history.clear();
memory.clear();
ctx.selection = selectCell(firstSheet(next), 0, 0);
grid.render();
select(ctx.selection);
},
recalc() {
ctx.engine.rebuild(ctx.book);
grid.render();
for (const p of others) p.refresh();
return ctx.engine.snapshot();
},
destroy() {
clearTimeout(toastTimer);
detachKeys();
detachMouse();
for (const p of others) p.destroy();
ed.destroy();
grid.destroy();
root.remove();
},
};
}