- 칸을 고른 채 알약을 누르면 연결(흐림 + 주소 · 칸 왼아래 세모) · 흐린 알약은 연결 칸으로 · 우클릭 연결 풀기 - 자재 모드 [자재 더하기] → 자재찾기 창 → 새 알약 - 행열 넣기 · 지우기에 연결 칸 따라감(지운 칸이면 빠짐) · 되돌리기 짝 · getLinkedValues() - 늦게 불러옴(links 선택 또는 연결 있는 문서만) · 화면 28,518 B(예산 28,672) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
291 lines
11 KiB
TypeScript
291 lines
11 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, slotOf } from "./spreadsheet_editor";
|
|
import { toCalcValue } from "./spreadsheet_eval";
|
|
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 type { LinkOptions, Links } from "./spreadsheet_links";
|
|
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 {
|
|
CalcValue,
|
|
CalcValues,
|
|
Command,
|
|
CommandEffect,
|
|
SheetCellAddress,
|
|
Workbook,
|
|
} from "./spreadsheet_types";
|
|
import type {
|
|
EditorHandle,
|
|
GridHandle,
|
|
PartHandle,
|
|
Selection,
|
|
SpreadsheetContext,
|
|
} from "./spreadsheet_view_types";
|
|
|
|
export type { Workbook, CalcValues, CalcValue, LinkMaterial } from "./spreadsheet_types";
|
|
export type { LinkItem, LinkOptions } from "./spreadsheet_links";
|
|
|
|
export interface SpreadsheetOptions {
|
|
/** 읽기만 — 도구 모음 · 메뉴 없음 · 편집 · 붙여넣기 · 끌기 막음(고르기 · 복사는 됨) */
|
|
readOnly?: boolean;
|
|
/** 명령마다 문서 사본 — 페이지가 캐시(sessionStorage)에 쌓고 [저장] 단추를 켬 */
|
|
onChange?: (book: Workbook) => void;
|
|
/** 고름이 바뀜 — 시트 id · 활성 칸 A1 · 범위 A1 */
|
|
onSelect?: (selection: { 시트: string; 칸: string; 범위: string }) => void;
|
|
/** 아래 시트 탭 줄 — false 면 안 그림(산출근거 = 시트 하나) · 시트 오가기 · 새 시트 단축키 · 시트 메뉴도 끔 */
|
|
tabs?: boolean;
|
|
/** 위 알약 막대 — 입력값 · 자재를 칸에 연결(`spreadsheet_links.ts` · 이때만 불러옴) */
|
|
links?: LinkOptions;
|
|
}
|
|
|
|
export interface SpreadsheetHandle {
|
|
/** 문서 사본 */
|
|
getDoc(): Workbook;
|
|
/** 문서 바꿔 끼움 — 되돌리기 이력 비움 · 고름 A1 */
|
|
setDoc(book: Workbook): void;
|
|
/** 전부 다시 풀고 다시 그림 · 결과(저장 모양) */
|
|
recalc(): CalcValues;
|
|
/** 알약 키 → 연결 칸 값(빈 칸 0) */
|
|
getLinkedValues(): Record<string, CalcValue>;
|
|
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;
|
|
/** 알약 막대 · 연결 따라가기 — 선택이 있거나 문서에 연결이 있을 때만 불러옴(크기 예산) */
|
|
let links: Links | null = null;
|
|
let linking = false;
|
|
function loadLinks(): void {
|
|
if (linking || !(opts.links || ctx.book.시트.some((s) => s.연결?.length))) return;
|
|
linking = true;
|
|
void import("./spreadsheet_links").then((m) => {
|
|
if (!root.isConnected) return; // 받기 전에 닫힘
|
|
links = m.mountLinks(ctx, opts.links, () => ed.focus(), slotOf);
|
|
others.push(links);
|
|
links.refresh();
|
|
});
|
|
}
|
|
|
|
grid.render();
|
|
select(ctx.selection);
|
|
loadLinks();
|
|
|
|
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 (links?.apply ?? 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);
|
|
loadLinks();
|
|
},
|
|
recalc() {
|
|
ctx.engine.rebuild(ctx.book);
|
|
grid.render();
|
|
for (const p of others) p.refresh();
|
|
return ctx.engine.snapshot();
|
|
},
|
|
getLinkedValues() {
|
|
const out: Record<string, CalcValue> = {};
|
|
for (const s of ctx.book.시트)
|
|
for (const l of s.연결 ?? []) {
|
|
const p = parseA1(l.칸);
|
|
if (p) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c));
|
|
}
|
|
return out;
|
|
},
|
|
destroy() {
|
|
clearTimeout(toastTimer);
|
|
detachKeys();
|
|
detachMouse();
|
|
for (const p of others) p.destroy();
|
|
ed.destroy();
|
|
grid.destroy();
|
|
root.remove();
|
|
},
|
|
};
|
|
}
|