/* ============================================================================= * 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). * 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 { 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; /** ⚠ 임시 — 브레인 브라우저 검증용: 문맥을 `window.__aisloSheet` 에 걺(book · 고름 · 명령 읽기). * M02 [스프레드시트 시험] 단추만 켬 · 산출근거 카드는 안 켬 · 1단계 검증 뒤 지움. */ inspect?: boolean; } declare global { interface Window { /** ⚠ 임시 — `inspect` 로 연 표의 문맥(브레인 검증용) */ __aisloSheet?: SpreadsheetContext; } } 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 root = el("div", { className: readOnly ? "ss ss--readonly" : "ss" }); host.append(root); const firstSheet = (b: Workbook) => b.시트.find((s) => s.id === b.활성) ?? b.시트[0]; /** 시트마다 마지막 고름(탭을 오가도 그대로) */ const memory = new Map(); 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 = mountTabs(ctx); const menu: PartHandle | null = readOnly ? null : attachMenu(ctx); const clip = attachClipboard(ctx); const others = [toolbar, tabs, menu, clip].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]) 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); if (opts.inspect) window.__aisloSheet = ctx; // ⚠ 임시 — 브레인 검증용 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): 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() { if (window.__aisloSheet === ctx) delete window.__aisloSheet; clearTimeout(toastTimer); detachKeys(); detachMouse(); for (const p of others) p.destroy(); ed.destroy(); grid.destroy(); root.remove(); }, }; }