Files
Aislo/A00_Common/spreadsheet/spreadsheet.ts
T
eomsangdonandClaude Opus 5.5 500424d7ae feat(spreadsheet): D 잇기 — 정렬 · 자동 필터(sub3)
- spreadsheet_extras.ts — 수식 입력줄 앞 데이터 단추(↑ ↓ 정렬… 필터) · attachFilter 부품 붙임 · Alt+↓ 머리 칸 값 목록
- spreadsheet_keys.ts — Ctrl+Shift+L 필터 · Alt+↓ 자리
- spreadsheet_selection.ts — 걸러진 행도 방향키 · Enter 가 건너뜀
- spreadsheet.ts — recalc 뒤 부품 refresh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
2026-09-27 21:05:39 +09:00

262 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).
* 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.범위[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: 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<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();
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();
},
};
}