Files
Aislo/A00_Common/spreadsheet/spreadsheet_commands.ts
T
eomsangdonandClaude Opus 5.5 279ef5fb4f feat(spreadsheet): A 엔진 뺀 셋 채움 · 메모 옮김 — 계약 더함(Sheet.기본 · Sheet.comments)
- 계약 더함: Sheet.기본 { 서식 · 열폭 · 행높이 } — 모두 고르기 서식 · 폭 · 높이 · 칸 서식 물려받기 = 칸 → 행 → 열 → 시트 기본 → 0
- 계약 더함: Sheet.comments — A1 → 메모 글 · 행열 넣기 · 지우기 · 잘라 붙이기 때 칸과 같이 옮김(지운 칸 · 덮은 칸 메모는 지움)
- 행 · 열 넣기 = 위(왼) 줄 칸 서식 · 높이 · 폭 따라감(숨김은 안 따라감 · 첫 줄 앞 넣기는 안 따라감)
- 범위를 칸 하나 자리에 넣으면 엑셀 암묵 교차(한 열 범위 = 식 든 행 · 한 행 범위 = 식 든 열)
- 풀이 차례를 반복 위상 순서로 — 2만 칸 사슬도 스택 안 넘침
- 시험 resources/tester/spreadsheet/test_spreadsheet_engine.py 규칙 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 20:23:32 +09:00

695 lines
27 KiB
TypeScript

/* =============================================================================
* spreadsheet_commands.ts (주인 A)
* 명령 적용 — 문서를 그 자리에서 바꾸고 되돌림 짝 · 바뀐 칸 · 통째 다시 여부를 돌려줌.
* 병합 규칙(엑셀): 병합 일부를 가르는 칸 쓰기 · 옮기기는 막음(오류 던짐 → 화면이 알림) ·
* 병합 안에 행열을 넣으면 병합이 늘고 지우면 줆 · 병합하면 왼위 칸 값만 남김.
* 서식 표는 같은 서식을 한 번만 둠(칸은 번호) · 안 쓰는 서식 정리는 저장 전에(`compactStyles`).
* 행열 · 옮기기 · 시트 지우기는 바뀐 시트를 통째로 되돌림(`시트통째`) — 다른 시트 식 글까지 한 번에.
* ========================================================================== */
import {
colName,
inRange,
normRange,
parseA1,
parseRange,
rangeInside,
rangesOverlap,
rangeToA1,
toA1,
} from "./spreadsheet_address";
import { shiftForCommand, shiftSpan } from "./spreadsheet_refshift";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type {
Cell,
CellRange,
CellStyle,
Command,
CommandEffect,
Sheet,
SheetCellAddress,
Workbook,
} from "./spreadsheet_types";
/** 막힌 명령(병합 가름 · 마지막 시트 지우기 · 같은 시트 이름 …) — 화면이 `message` 를 알림 */
export class CommandError extends Error {}
type Of<K extends Command["종류"]> = Extract<Command, { 종류: K }>;
const clone = <T>(x: T): T => structuredClone(x);
function sheetOf(book: Workbook, id: string): Sheet {
const s = book.시트.find((x) => x.id === id);
if (!s) throw new CommandError(`없는 시트: ${id}`);
return s;
}
const mergesOf = (s: Sheet): CellRange[] =>
(s.병합 ?? []).map((t) => parseRange(t)).filter((r): r is CellRange => r !== null);
function setMerges(s: Sheet, list: CellRange[]) {
if (list.length) s.병합 = list.map(rangeToA1);
else delete s.병합;
}
/** 칸 열쇠 중 범위 안 것 */
function cellsIn(s: Sheet, ranges: CellRange[]): [string, number, number][] {
const out: [string, number, number][] = [];
for (const a1 of Object.keys(s.칸)) {
const at = parseA1(a1);
if (at && ranges.some((rg) => inRange(rg, at.r, at.c))) out.push([a1, at.r, at.c]);
}
return out;
}
const isFull = (rg: CellRange, rows: boolean) =>
rows ? rg.c0 === 0 && rg.c1 === MAX_COLS - 1 : rg.r0 === 0 && rg.r1 === MAX_ROWS - 1;
function sortKeys(v: unknown): unknown {
if (!v || typeof v !== "object" || Array.isArray(v)) return v;
const o = v as Record<string, unknown>;
return Object.fromEntries(
Object.keys(o)
.sort()
.map((k) => [k, sortKeys(o[k])]),
);
}
const styleKey = (s: CellStyle) => JSON.stringify(sortKeys(s));
function internStyle(book: Workbook, style: CellStyle): number {
const key = styleKey(style);
const i = book.서식.findIndex((s) => styleKey(s) === key);
if (i >= 0) return i;
book.서식.push(style);
return book.서식.length - 1;
}
function setComments(s: Sheet, notes: Record<string, string>) {
if (Object.keys(notes).length) s.comments = notes;
else delete s.comments;
}
/** 시트 통째 서식 */
const sheetStyle = (s: Sheet) => s.기본?.서식 ?? 0;
/** 칸 자리에서 물려받는 서식(칸 서식 없을 때) — 행 · 열 · 시트 · 0 */
const inherited = (s: Sheet, r: number, c: number) =>
s.행?.[r + 1]?.서식 ?? s.열?.[colName(c)]?.서식 ?? sheetStyle(s);
/** 시트 `기본` 한 칸 — undefined 면 지움 · 빈 `기본` 도 지움 */
function setSheetDefault(s: Sheet, key: "서식" | "열폭" | "행높이", value: number | undefined) {
const d = (s.기본 ??= {});
if (value === undefined) delete d[key];
else d[key] = value;
if (!Object.keys(d).length) delete s.기본;
}
const addr = (시트: string, r: number, c: number): SheetCellAddress => ({ 시트, r, c });
/** 바뀐 시트만 통째 되돌림 짝 */
function restoreChanged(book: Workbook, old: Sheet[]): Command[] {
const out: Command[] = [];
for (const o of old) {
const now = book.시트.find((x) => x.id === o.id);
if (now && JSON.stringify(now) !== JSON.stringify(o)) out.push({ 종류: "시트통째", 시트: o });
}
return out;
}
/** 모든 식 글을 명령에 맞춰 고침 — 시트 이름을 바꾸기 전에 부름 */
function rewriteAll(book: Workbook, command: Command) {
for (const s of book.시트)
for (const cell of Object.values(s.칸))
if (cell.식 !== undefined) cell.식 = shiftForCommand(cell.식, s.id, command, book);
}
function checkSheetName(book: Workbook, name: string, self?: string) {
if (!name.trim() || name.length > 31) throw new CommandError("시트 이름은 1~31 글자");
if (/[[\]:*?/\\]/.test(name) || name.startsWith("'") || name.endsWith("'"))
throw new CommandError("시트 이름에 못 쓰는 글자: [ ] : * ? / \\ 와 앞뒤 '");
if (book.시트.some((s) => s.id !== self && s.이름.toLowerCase() === name.toLowerCase()))
throw new CommandError(`같은 이름 시트가 있음: ${name}`);
}
// ── 칸 ────────────────────────────────────────────────────────────────────
function setCells(book: Workbook, cmd: Of<"칸">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const merges = mergesOf(s);
const plan: [string, number, number, Cell | null][] = [];
for (const [key, cell] of Object.entries(cmd.칸)) {
const at = parseA1(key);
if (!at) throw new CommandError(`칸 주소 틀림: ${key}`);
if (cell && (cell.값 !== undefined || cell.식 !== undefined))
if (merges.some((m) => inRange(m, at.r, at.c) && (m.r0 !== at.r || m.c0 !== at.c)))
throw new CommandError("병합한 칸은 왼위 칸에만 값을 넣음");
plan.push([toA1(at.r, at.c), at.r, at.c, cell]);
}
const prev: Record<string, Cell | null> = {};
for (const [a1, , , cell] of plan) {
prev[a1] = s.칸[a1] ? clone(s.칸[a1]) : null;
if (cell) s.칸[a1] = clone(cell);
else delete s.칸[a1];
}
return {
undo: { 종류: "칸", 시트: s.id, 칸: prev },
cells: plan.map(([, r, c]) => addr(s.id, r, c)),
rebuild: false,
};
}
function clearContents(book: Workbook, cmd: Of<"내용지움">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const prev: Record<string, Cell | null> = {};
const cells: SheetCellAddress[] = [];
for (const [a1, r, c] of cellsIn(s, cmd.범위)) {
const cell = s.칸[a1];
if (cell.값 === undefined && cell.식 === undefined) continue;
prev[a1] = clone(cell);
if (cell.서식 === undefined) delete s.칸[a1];
else s.칸[a1] = { 서식: cell.서식 };
cells.push(addr(s.id, r, c));
}
return { undo: { 종류: "칸", 시트: s.id, 칸: prev }, cells, rebuild: false };
}
// ── 서식 ────────────────────────────────────────────────────────────────────
/** 범위 서식 바꿈 — `change(지금 번호) → 새 번호`. 행 · 열 전체는 행 · 열 서식 + 든 칸. */
function restyle(
book: Workbook,
sheet: string,
ranges: CellRange[],
change: (i: number) => number,
): CommandEffect {
const s = sheetOf(book, sheet);
const before = clone(s);
const memo = new Map<number, number>();
const f = (i: number) => {
if (!memo.has(i)) memo.set(i, change(i));
return memo.get(i)!;
};
const prev: Record<string, Cell | null> = {};
const cells: SheetCellAddress[] = [];
let whole = false;
const setInfo = (axis: "행" | "열", key: string) => {
const table = (s[axis] ??= {});
const info = (table[key] ??= {});
const idx = f(info.서식 ?? sheetStyle(s));
if (idx !== sheetStyle(s)) info.서식 = idx;
else delete info.서식;
if (!Object.keys(info).length) delete table[key];
};
const touch = (r: number, c: number) => {
const a1 = toA1(r, c);
if (a1 in prev) return;
const cell = s.칸[a1];
prev[a1] = cell ? clone(cell) : null;
const next: Cell = { ...cell };
const idx = f(cell?.서식 ?? inherited(s, r, c));
if (idx === inherited(s, r, c)) delete next.서식;
else next.서식 = idx;
if (Object.keys(next).length) s.칸[a1] = next;
else delete s.칸[a1];
cells.push(addr(s.id, r, c));
};
for (const rg of ranges) {
const fullRows = isFull(rg, true);
const fullCols = isFull(rg, false);
if (fullRows && fullCols) {
whole = true;
const idx = f(sheetStyle(s));
setSheetDefault(s, "서식", idx || undefined);
for (const k of Object.keys(s.행 ?? {})) setInfo("행", k);
for (const k of Object.keys(s.열 ?? {})) setInfo("열", k);
} else if (fullRows || fullCols) {
whole = true;
for (let i = fullRows ? rg.r0 : rg.c0; i <= (fullRows ? rg.r1 : rg.c1); i++)
setInfo(fullRows ? "행" : "열", fullRows ? String(i + 1) : colName(i));
} else {
if ((rg.r1 - rg.r0 + 1) * (rg.c1 - rg.c0 + 1) > 1_000_000)
throw new CommandError("범위가 너무 큼");
for (let r = rg.r0; r <= rg.r1; r++) for (let c = rg.c0; c <= rg.c1; c++) touch(r, c);
continue;
}
for (const [, r, c] of cellsIn(s, [rg])) touch(r, c);
}
if (s.행 && !Object.keys(s.행).length) delete s.행;
if (s.열 && !Object.keys(s.열).length) delete s.열;
return whole
? { undo: { 종류: "시트통째", 시트: before }, cells, rebuild: true }
: { undo: { 종류: "칸", 시트: s.id, 칸: prev }, cells, rebuild: false };
}
function patchStyle(book: Workbook, cmd: Of<"서식">): CommandEffect {
return restyle(book, cmd.시트, cmd.범위, (i) => {
const next: Record<string, unknown> = { ...book.서식[i] };
for (const [k, v] of Object.entries(cmd.바꿀)) {
if (v === null) delete next[k];
else if (v !== undefined) next[k] = clone(v);
}
return internStyle(book, next as CellStyle);
});
}
// ── 행열 넣기 · 지우기 ──────────────────────────────────────────────────────
function insertDelete(
book: Workbook,
cmd: Of<"행넣기" | "행지우기" | "열넣기" | "열지우기">,
): CommandEffect {
const s = sheetOf(book, cmd.시트);
const rows = cmd.종류.startsWith("행");
const insert = cmd.종류.endsWith("넣기");
const max = rows ? MAX_ROWS : MAX_COLS;
const { at, 수: n } = cmd;
if (n < 1 || at < 0 || at >= max || (!insert && at + n > max))
throw new CommandError("넣기 · 지우기 자리가 틀림");
const pos = (a1: string) => {
const p = parseA1(a1)!;
return rows ? p.r : p.c;
};
if (insert && Object.keys(s.칸).some((a1) => pos(a1) + n >= max))
throw new CommandError("격자 끝 칸이 밀려 나감 — 끝 쪽 칸을 먼저 지움");
const old = book.시트.map(clone);
rewriteAll(book, cmd);
const span = (i: number) => shiftSpan(i, i, insert, at, n, max)?.[0];
const cells: Record<string, Cell> = {};
for (const [a1, cell] of Object.entries(s.칸)) {
const p = parseA1(a1)!;
const i = span(rows ? p.r : p.c);
if (i !== undefined) cells[rows ? toA1(i, p.c) : toA1(p.r, i)] = cell;
}
s.칸 = cells;
if (s.comments) {
const notes: Record<string, string> = {};
for (const [a1, text] of Object.entries(s.comments)) {
const p = parseA1(a1);
const i = p && span(rows ? p.r : p.c);
if (p && i !== undefined && i !== null) notes[rows ? toA1(i, p.c) : toA1(p.r, i)] = text;
}
setComments(s, notes);
}
const merges: CellRange[] = [];
for (const m of mergesOf(s)) {
const sp = rows
? shiftSpan(m.r0, m.r1, insert, at, n, max)
: shiftSpan(m.c0, m.c1, insert, at, n, max);
if (!sp) continue;
const next = rows ? { ...m, r0: sp[0], r1: sp[1] } : { ...m, c0: sp[0], c1: sp[1] };
if (next.r0 !== next.r1 || next.c0 !== next.c1) merges.push(next);
}
setMerges(s, merges);
const axis = rows ? "행" : "열";
const table = s[axis];
if (table) {
const next: Record<string, (typeof table)[string]> = {};
for (const [k, v] of Object.entries(table)) {
const i = span(rows ? Number(k) - 1 : parseA1(`${k}1`)!.c);
if (i !== undefined) next[rows ? String(i + 1) : colName(i)] = v;
}
if (Object.keys(next).length) s[axis] = next;
else delete s[axis];
}
// 넣은 줄은 위(왼) 줄 서식 · 폭 · 높이를 따라감(엑셀 「위와 같은 서식」) — 첫 줄 앞 넣기는 안 따라감
if (insert && at > 0) {
for (const [a1, cell] of Object.entries(s.칸)) {
const p = parseA1(a1)!;
if ((rows ? p.r : p.c) !== at - 1 || cell.서식 === undefined) continue;
for (let k = 0; k < n; k++)
s.칸[rows ? toA1(at + k, p.c) : toA1(p.r, at + k)] = { 서식: cell.서식 };
}
const info = s[axis]?.[rows ? String(at) : colName(at - 1)];
if (info) {
const { 숨김: _hidden, ...look } = info;
if (Object.keys(look).length)
for (let k = 0; k < n; k++)
s[axis]![rows ? String(at + k + 1) : colName(at + k)] = { ...look };
}
}
const key = rows ? "행" : "열";
const frozen = s.틀고정?.[key];
if (frozen && at < frozen)
s.틀고정![key] = insert ? frozen + n : frozen - (Math.min(frozen, at + n) - at);
return { undo: { 종류: "묶음", 명령: restoreChanged(book, old) }, cells: [], rebuild: true };
}
// ── 옮기기 (잘라 붙이기) ─────────────────────────────────────────────────────
function move(book: Workbook, cmd: Of<"옮기기">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const t = sheetOf(book, cmd.대상시트);
const src = normRange(cmd.범위.r0, cmd.범위.c0, cmd.범위.r1, cmd.범위.c1);
const dr = cmd.r - src.r0;
const dc = cmd.c - src.c0;
const dst: CellRange = { r0: cmd.r, c0: cmd.c, r1: src.r1 + dr, c1: src.c1 + dc };
if (dst.r0 < 0 || dst.c0 < 0 || dst.r1 >= MAX_ROWS || dst.c1 >= MAX_COLS)
throw new CommandError("옮길 자리가 격자 밖");
const cuts = (list: CellRange[], rg: CellRange) =>
list.some((m) => rangesOverlap(m, rg) && !rangeInside(m, rg));
if (cuts(mergesOf(s), src) || cuts(mergesOf(t), dst))
throw new CommandError("병합 일부를 가르는 옮기기는 못 함");
if (s === t && dr === 0 && dc === 0)
return { undo: { 종류: "묶음", 명령: [] }, cells: [], rebuild: false };
const old = book.시트.map(clone);
const moved: [number, number, Cell][] = [];
for (const [a1, r, c] of cellsIn(s, [src])) {
const cell = s.칸[a1];
if (cell.식 !== undefined) cell.식 = shiftForCommand(cell.식, s.id, cmd, book, t.id);
moved.push([r + dr, c + dc, cell]);
delete s.칸[a1];
}
rewriteAll(book, cmd);
for (const [a1] of cellsIn(t, [dst])) delete t.칸[a1];
for (const [r, c, cell] of moved) t.칸[toA1(r, c)] = cell;
// 메모도 칸과 같이 — 덮은 자리 메모는 지움
const carriedNotes: [string, string][] = [];
const srcNotes = { ...s.comments };
for (const [a1, text] of Object.entries(srcNotes)) {
const p = parseA1(a1);
if (p && inRange(src, p.r, p.c)) {
carriedNotes.push([toA1(p.r + dr, p.c + dc), text]);
delete srcNotes[a1];
}
}
setComments(s, srcNotes);
const dstNotes = { ...t.comments };
for (const a1 of Object.keys(dstNotes)) {
const p = parseA1(a1);
if (p && inRange(dst, p.r, p.c)) delete dstNotes[a1];
}
setComments(t, { ...dstNotes, ...Object.fromEntries(carriedNotes) });
const sm = mergesOf(s);
const carried = sm.filter((m) => rangeInside(m, src));
setMerges(
s,
sm.filter((m) => !rangeInside(m, src)),
);
setMerges(t, [
...mergesOf(t).filter((m) => !rangeInside(m, dst)),
...carried.map((m) => ({ r0: m.r0 + dr, c0: m.c0 + dc, r1: m.r1 + dr, c1: m.c1 + dc })),
]);
return { undo: { 종류: "묶음", 명령: restoreChanged(book, old) }, cells: [], rebuild: true };
}
// ── 병합 ────────────────────────────────────────────────────────────────────
function merge(book: Workbook, cmd: Of<"병합">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const rg = normRange(cmd.범위.r0, cmd.범위.c0, cmd.범위.r1, cmd.범위.c1);
if (rg.r0 === rg.r1 && rg.c0 === rg.c1)
return { undo: { 종류: "묶음", 명령: [] }, cells: [], rebuild: false };
const list = mergesOf(s);
if (list.some((m) => rangesOverlap(m, rg) && !rangeInside(m, rg)))
throw new CommandError("다른 병합과 걸침");
const absorbed = list.filter((m) => rangeInside(m, rg));
const prev: Record<string, Cell | null> = {};
const cells: SheetCellAddress[] = [];
for (const [a1, r, c] of cellsIn(s, [rg])) {
const cell = s.칸[a1];
if ((r === rg.r0 && c === rg.c0) || (cell.값 === undefined && cell.식 === undefined)) continue;
prev[a1] = clone(cell);
if (cell.서식 === undefined) delete s.칸[a1];
else s.칸[a1] = { 서식: cell.서식 };
cells.push(addr(s.id, r, c));
}
setMerges(s, [...list.filter((m) => !rangeInside(m, rg)), rg]);
const undo: Command[] = [
{ 종류: "병합풀기", 시트: s.id, 범위: rg },
...absorbed.map((m): Command => ({ 종류: "병합", 시트: s.id, 범위: m })),
{ 종류: "칸", 시트: s.id, 칸: prev },
];
return { undo: { 종류: "묶음", 명령: undo }, cells, rebuild: false };
}
function unmerge(book: Workbook, cmd: Of<"병합풀기">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const list = mergesOf(s);
const gone = list.filter((m) => rangesOverlap(m, cmd.범위));
setMerges(
s,
list.filter((m) => !rangesOverlap(m, cmd.범위)),
);
const undo = gone.map((m): Command => ({ 종류: "병합", 시트: s.id, 범위: m }));
return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: false };
}
// ── 행 · 열 모양 ─────────────────────────────────────────────────────────────
/** 행 · 열 정보 한 속성 바꿈 → 원래 값끼리 묶은 되돌림 짝 */
function setAxis<V>(
s: Sheet,
axis: "행" | "열",
idx: number[],
prop: "폭" | "높이" | "숨김",
value: V | null,
undoOf: (idx: number[], prev: V | null) => Command,
): Command {
const table = ((s as unknown as Record<string, Record<string, Record<string, unknown>>>)[axis] ??=
{});
const groups = new Map<string, number[]>();
for (const i of idx) {
const k = axis === "열" ? colName(i) : String(i + 1);
const prev = table[k]?.[prop];
const g = JSON.stringify(prev ?? null);
groups.set(g, [...(groups.get(g) ?? []), i]);
const info = (table[k] ??= {});
if (value === null || value === false) delete info[prop];
else info[prop] = value;
if (!Object.keys(info).length) delete table[k];
}
if (!Object.keys(table).length) delete s[axis];
return { 종류: "묶음", 명령: [...groups].map(([g, list]) => undoOf(list, JSON.parse(g))) };
}
/** 모두 고르기 폭 · 높이 — 시트 `기본` 에 두고 줄마다 값은 지움(엑셀 기본 폭 · 높이) */
function sheetSize(s: Sheet, axis: "행" | "열", value: number | null): CommandEffect {
const before = clone(s);
const prop = axis === "열" ? "폭" : "높이";
setSheetDefault(s, axis === "열" ? "열폭" : "행높이", value ?? undefined);
const table = s[axis] as Record<string, Record<string, unknown>> | undefined;
for (const [k, info] of Object.entries(table ?? {})) {
delete info[prop];
if (!Object.keys(info).length) delete table![k];
}
if (table && !Object.keys(table).length) delete s[axis];
return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: false };
}
// ── 시트 ────────────────────────────────────────────────────────────────────
function addSheet(book: Workbook, cmd: Of<"시트더하기">): CommandEffect {
if (book.시트.some((s) => s.id === cmd.시트.id))
throw new CommandError(`같은 id 시트가 있음: ${cmd.시트.id}`);
checkSheetName(book, cmd.시트.이름);
book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, clone(cmd.시트));
return { undo: { 종류: "시트지우기", 시트: cmd.시트.id }, cells: [], rebuild: true };
}
function removeSheet(book: Workbook, cmd: Of<"시트지우기">): CommandEffect {
const s = sheetOf(book, cmd.시트);
if (book.시트.length === 1) throw new CommandError("마지막 시트는 못 지움");
const at = book.시트.indexOf(s);
const old = book.시트.map(clone);
rewriteAll(book, cmd);
book.시트.splice(at, 1);
if (book.활성 === s.id) book.활성 = book.시트[Math.min(at, book.시트.length - 1)].id;
const undo: Command[] = [
{ 종류: "시트더하기", 시트: old[at], 자리: at },
...restoreChanged(book, old),
];
return { undo: { 종류: "묶음", 명령: undo }, cells: [], rebuild: true };
}
function renameSheet(book: Workbook, cmd: Of<"시트이름">): CommandEffect {
const s = sheetOf(book, cmd.시트);
checkSheetName(book, cmd.이름, s.id);
const prev = s.이름;
rewriteAll(book, cmd);
s.이름 = cmd.이름;
return { undo: { 종류: "시트이름", 시트: s.id, 이름: prev }, cells: [], rebuild: true };
}
function moveSheet(book: Workbook, cmd: Of<"시트옮기기">): CommandEffect {
const s = sheetOf(book, cmd.시트);
const from = book.시트.indexOf(s);
book.시트.splice(from, 1);
book.시트.splice(Math.max(0, Math.min(cmd.자리, book.시트.length)), 0, s);
return { undo: { 종류: "시트옮기기", 시트: s.id, 자리: from }, cells: [], rebuild: false };
}
function replaceSheet(book: Workbook, cmd: Of<"시트통째">): CommandEffect {
const at = book.시트.findIndex((x) => x.id === cmd.시트.id);
if (at < 0) throw new CommandError(`없는 시트: ${cmd.시트.id}`);
const prev = book.시트[at];
book.시트[at] = clone(cmd.시트);
const undo: Of<"시트통째"> = { 종류: "시트통째", 시트: prev };
if (cmd.서식) {
undo.서식 = book.서식;
book.서식 = clone(cmd.서식);
}
return { undo, cells: [], rebuild: true };
}
function batch(book: Workbook, cmd: Of<"묶음">): CommandEffect {
const done: CommandEffect[] = [];
try {
for (const c of cmd.명령) done.push(applyCommand(book, c));
} catch (e) {
for (const d of done.reverse()) applyCommand(book, d.undo);
throw e;
}
return {
undo: { 종류: "묶음", 명령: done.map((d) => d.undo).reverse() },
cells: done.flatMap((d) => d.cells),
rebuild: done.some((d) => d.rebuild),
};
}
// ── 들머리 ──────────────────────────────────────────────────────────────────
export function applyCommand(book: Workbook, command: Command): CommandEffect {
const plain = (undo: Command): CommandEffect => ({ undo, cells: [], rebuild: false });
switch (command.종류) {
case "칸":
return setCells(book, command);
case "내용지움":
return clearContents(book, command);
case "서식":
return patchStyle(book, command);
case "서식지움":
return restyle(book, command.시트, command.범위, () => 0);
case "행넣기":
case "행지우기":
case "열넣기":
case "열지우기":
return insertDelete(book, command);
case "옮기기":
return move(book, command);
case "병합":
return merge(book, command);
case "병합풀기":
return unmerge(book, command);
case "열폭": {
const s = sheetOf(book, command.시트);
if (new Set(command.열).size >= MAX_COLS) return sheetSize(s, "열", command.폭);
return plain(
setAxis(s, "열", command.열, "폭", command.폭, (열, 폭) => ({
종류: "열폭",
시트: s.id,
열,
폭,
})),
);
}
case "행높이": {
const s = sheetOf(book, command.시트);
if (new Set(command.행).size >= MAX_ROWS) return sheetSize(s, "행", command.높이);
return plain(
setAxis(s, "행", command.행, "높이", command.높이, (행, 높이) => ({
종류: "행높이",
시트: s.id,
행,
높이,
})),
);
}
case "숨김": {
const s = sheetOf(book, command.시트);
const { 축 } = command;
return plain(
setAxis(s, 축, command.번호, "숨김", command.숨김, (번호, prev) => ({
종류: "숨김",
시트: s.id,
축,
번호,
숨김: prev ?? false,
})),
);
}
case "틀고정": {
const s = sheetOf(book, command.시트);
const prev = { 행: s.틀고정?.행 ?? 0, 열: s.틀고정?.열 ?? 0 };
const next: Sheet["틀고정"] = {};
if (command.행 > 0) next.행 = command.행;
if (command.열 > 0) next.열 = command.열;
if (Object.keys(next).length) s.틀고정 = next;
else delete s.틀고정;
return plain({ 종류: "틀고정", 시트: s.id, ...prev });
}
case "눈금선": {
const s = sheetOf(book, command.시트);
const prev = s.보기?.눈금선 ?? true;
if (command.보임) {
delete s.보기?.눈금선;
if (s.보기 && !Object.keys(s.보기).length) delete s.보기;
} else (s.보기 ??= {}).눈금선 = false;
return plain({ 종류: "눈금선", 시트: s.id, 보임: prev });
}
case "시트더하기":
return addSheet(book, command);
case "시트지우기":
return removeSheet(book, command);
case "시트이름":
return renameSheet(book, command);
case "시트옮기기":
return moveSheet(book, command);
case "시트통째":
return replaceSheet(book, command);
case "묶음":
return batch(book, command);
}
}
/** 빈 통합문서 — 시트 하나(`s1` · 이름은 부른 쪽) · 서식 [{}] */
export function emptyWorkbook(column: string, sheetName: string): Workbook {
return {
종류: "통합문서",
판: 1,
열: column,
서식: [{}],
시트: [emptySheet("s1", sheetName)],
활성: "s1",
};
}
export function emptySheet(id: string, name: string): Sheet {
return { id, 이름: name, 칸: {} };
}
/** 안 쓰는 서식을 빼고 번호를 다시 매김 — 저장 직전(되돌리기 짝은 안 만듦) */
export function compactStyles(book: Workbook): void {
const next: CellStyle[] = [];
const byKey = new Map<string, number>();
const remap = new Map<number, number>();
const map = (i: number) => {
let j = remap.get(i);
if (j === undefined) {
const style = book.서식[i] ?? {};
const k = styleKey(style);
j = byKey.get(k);
if (j === undefined) {
j = next.push(style) - 1;
byKey.set(k, j);
}
remap.set(i, j);
}
return j;
};
map(0);
for (const s of book.시트) {
for (const cell of Object.values(s.칸)) if (cell.서식 !== undefined) cell.서식 = map(cell.서식);
for (const info of [...Object.values(s.행 ?? {}), ...Object.values(s.열 ?? {}), s.기본 ?? {}])
if (info.서식 !== undefined) info.서식 = map(info.서식);
}
book.서식 = next;
}