- spreadsheet.ts — `inspect` 선택이면 문맥을 window.__aisloSheet 에 걺(M02 [스프레드시트 시험] 단추 전용 · destroy 때 뗌 · 1단계 검증 뒤 지움) - 시험 틀 — ssScenario()(페이지 안 한 번에 한글 · 가리키기 · 재계산 · 되돌리기 · Delete · Ctrl+Enter · 수식 입력줄 · F2) · 가짜 격자 data-addr · 진짜 분수 값 보임 · 오류 모음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
269 lines
9.3 KiB
TypeScript
269 lines
9.3 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).
|
|
* ========================================================================== */
|
|
|
|
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 { 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;
|
|
}
|
|
|
|
/** 이어 붙이는 남의 부품 — 시험 틀이 빈 몸 부품을 가짜로 바꿔 끼우는 자리(화면 코드는 그대로 씀) */
|
|
export const parts = {
|
|
createCalcEngine,
|
|
createHistory,
|
|
applyCommand,
|
|
createGrid,
|
|
mountToolbar,
|
|
mountTabs,
|
|
attachMenu,
|
|
attachClipboard,
|
|
};
|
|
|
|
/** 움직이는 끝(닻에서 먼 쪽) — 행열 전체면 활성 칸 쪽 */
|
|
function farCorner(sel: Selection): { r: number; c: number } {
|
|
const g = sel.범위[0];
|
|
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<string, Selection>();
|
|
|
|
const ctx: SpreadsheetContext = {
|
|
root,
|
|
book,
|
|
readOnly,
|
|
engine: parts.createCalcEngine(book),
|
|
history: parts.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 = parts.createGrid(ctx);
|
|
ctx.grid = grid;
|
|
const ed = createEditor(ctx);
|
|
ctx.editor = ed;
|
|
const toolbar: PartHandle | null = readOnly ? null : parts.mountToolbar(ctx);
|
|
const tabs = parts.mountTabs(ctx);
|
|
const menu: PartHandle | null = readOnly ? null : parts.attachMenu(ctx);
|
|
const clip = parts.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 detachKeys = attachKeys(ctx, ed);
|
|
const detachMouse = attachMouse(ctx, ed);
|
|
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 parts.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();
|
|
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();
|
|
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();
|
|
},
|
|
};
|
|
}
|