- 계약 더함: 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
695 lines
27 KiB
TypeScript
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;
|
|
}
|