diff --git a/A00_Common/spreadsheet/spreadsheet_insert_dialog.ts b/A00_Common/spreadsheet/spreadsheet_insert_dialog.ts new file mode 100644 index 00000000..59b8009d --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_insert_dialog.ts @@ -0,0 +1,240 @@ +/* ============================================================================= + * spreadsheet_insert_dialog.ts (2단계 · 주인 sub_laptop_1) + * 칸 삽입(Ctrl+Shift++) · 칸 삭제(Ctrl+-) 대화상자 — 엑셀처럼 네 갈래: + * 삽입 = 셀을 오른쪽으로 밀기 · 셀을 아래로 밀기 · 행 전체 · 열 전체 + * 삭제 = 셀을 왼쪽으로 밀기 · 셀을 위로 밀기 · 행 전체 · 열 전체 + * 「행 전체」·「열 전체」는 A 의 행넣기 · 행지우기 · 열넣기 · 열지우기 명령을 그대로 씀 + * (참조 옮김까지 A 가 다 함). 「오른쪽 · 아래로 · 왼쪽 · 위로 밀기」는 0 계약에 전용 명령이 + * 없어(부분 범위 이동 명령 없음) 「칸」 명령으로 손수 값 · 식 글 · 서식 번호를 옮김 — + * ⚠ 식의 상대 참조는 안 고쳐짐 · 행 · 열 단위 기본서식(`행`·`열` 레코드)도 안 옮김 + * (부분 이동은 칸 레코드만). 온전한 참조 옮김이 필요하면 「행 전체」·「열 전체」를 쓰거나 + * A 의 `spreadsheet_refshift.ts` 가 부분 범위까지 다루게 된 뒤 이 파일의 shift* 함수만 고치면 됨. + * + * 잇는 법(D · sub7) — Ctrl+Shift+= → `openInsertCellsDialog(ctx)` · Ctrl+- → + * `openDeleteCellsDialog(ctx)` 를 `spreadsheet_keys.ts` navKey 에 더함. 우클릭 메뉴 + * [삽입]·[삭제] 도 같은 함수를 부르면 됨. + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { parseA1, toA1 } from "./spreadsheet_address"; +import type { Cell, CellRange, Sheet } from "./spreadsheet_types"; +import type { SpreadsheetContext } from "./spreadsheet_view_types"; + +const TEXT = { + insertTitle: "삽입", + deleteTitle: "삭제", + shiftRight: "셀을 오른쪽으로 밀기", + shiftDown: "셀을 아래로 밀기", + shiftLeft: "셀을 왼쪽으로 밀기", + shiftUp: "셀을 위로 밀기", + wholeRow: "행 전체", + wholeCol: "열 전체", + ok: "확인", + cancel: "취소", +}; + +function readRange(ctx: SpreadsheetContext): CellRange | null { + return ctx.selection.범위[0] ?? null; +} + +/** 시트 `칸` 레코드 안에서 실제 값이 있는 가장 큰 행 · 열(없으면 -1). */ +function sheetUsedBounds(sheet: Sheet): { maxR: number; maxC: number } { + let maxR = -1; + let maxC = -1; + for (const key of Object.keys(sheet.칸)) { + const at = parseA1(key); + if (!at) continue; + if (at.r > maxR) maxR = at.r; + if (at.c > maxC) maxC = at.c; + } + return { maxR, maxC }; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// 부분 범위 밀기 — 「칸」 명령으로 값 · 식 · 서식 번호만 옮김(위 머리 주석의 한계 참고) +// ═════════════════════════════════════════════════════════════════════════════ + +export function insertShiftRight(ctx: SpreadsheetContext, range: CellRange): void { + const sheet = ctx.sheet(); + const width = range.c1 - range.c0 + 1; + const lastCol = Math.max(sheetUsedBounds(sheet).maxC + width, range.c1); + const cells: Record = {}; + for (let r = range.r0; r <= range.r1; r++) { + for (let c = lastCol; c >= range.c0; c--) { + const src = c - width; + cells[toA1(r, c)] = src >= range.c0 ? (sheet.칸[toA1(r, src)] ?? null) : null; + } + } + ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells }); +} + +export function insertShiftDown(ctx: SpreadsheetContext, range: CellRange): void { + const sheet = ctx.sheet(); + const height = range.r1 - range.r0 + 1; + const lastRow = Math.max(sheetUsedBounds(sheet).maxR + height, range.r1); + const cells: Record = {}; + for (let c = range.c0; c <= range.c1; c++) { + for (let r = lastRow; r >= range.r0; r--) { + const src = r - height; + cells[toA1(r, c)] = src >= range.r0 ? (sheet.칸[toA1(src, c)] ?? null) : null; + } + } + ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells }); +} + +export function deleteShiftLeft(ctx: SpreadsheetContext, range: CellRange): void { + const sheet = ctx.sheet(); + const width = range.c1 - range.c0 + 1; + const lastCol = Math.max(sheetUsedBounds(sheet).maxC, range.c1); + const cells: Record = {}; + for (let r = range.r0; r <= range.r1; r++) { + for (let c = range.c0; c <= lastCol; c++) { + const src = c + width; + cells[toA1(r, c)] = src <= lastCol ? (sheet.칸[toA1(r, src)] ?? null) : null; + } + } + ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells }); +} + +export function deleteShiftUp(ctx: SpreadsheetContext, range: CellRange): void { + const sheet = ctx.sheet(); + const height = range.r1 - range.r0 + 1; + const lastRow = Math.max(sheetUsedBounds(sheet).maxR, range.r1); + const cells: Record = {}; + for (let c = range.c0; c <= range.c1; c++) { + for (let r = range.r0; r <= lastRow; r++) { + const src = r + height; + cells[toA1(r, c)] = src <= lastRow ? (sheet.칸[toA1(src, c)] ?? null) : null; + } + } + ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells }); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// 대화상자 +// ═════════════════════════════════════════════════════════════════════════════ + +function radioGroup( + name: string, + options: [string, string][], +): { root: HTMLElement; value(): string } { + const inputs: HTMLInputElement[] = []; + const root = el("div", { + children: options.map(([value, label], i) => { + const input = el("input", { + attrs: { type: "radio", name, value, ...(i === 0 ? { checked: "true" } : {}) }, + }) as HTMLInputElement; + inputs.push(input); + const wrap = el("label", { children: [input, el("span", { text: label })] }); + wrap.style.cssText = "display:flex;align-items:center;gap:6px;margin-bottom:4px;"; + return wrap; + }), + }); + return { root, value: () => inputs.find((i) => i.checked)?.value ?? options[0][0] }; +} + +function modal( + title: string, + body: HTMLElement, + onOk: (choice: string) => void, + choice: () => string, +): void { + const overlay = el("div", { className: "ui-confirm" }); + const panel = el("div", { className: "ui-confirm__panel" }); + const close = (): void => overlay.remove(); + overlay.addEventListener("click", (ev) => { + if (ev.target === overlay) close(); + }); + panel.append( + el("h3", { text: title }), + body, + el("div", { + className: "ui-confirm__actions", + children: [ + createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }), + createButton({ + label: TEXT.ok, + variant: "filled", + onClick: () => { + onOk(choice()); + close(); + }, + }), + ], + }), + ); + overlay.append(panel); + document.body.append(overlay); +} + +export function openInsertCellsDialog(ctx: SpreadsheetContext): void { + if (ctx.readOnly) return; + const range = readRange(ctx); + if (!range) return; + const group = radioGroup("insertMode", [ + ["right", TEXT.shiftRight], + ["down", TEXT.shiftDown], + ["row", TEXT.wholeRow], + ["col", TEXT.wholeCol], + ]); + modal( + TEXT.insertTitle, + group.root, + (choice) => { + if (choice === "right") insertShiftRight(ctx, range); + else if (choice === "down") insertShiftDown(ctx, range); + else if (choice === "row") { + ctx.dispatch({ + 종류: "행넣기", + 시트: ctx.selection.시트, + at: range.r0, + 수: range.r1 - range.r0 + 1, + }); + } else { + ctx.dispatch({ + 종류: "열넣기", + 시트: ctx.selection.시트, + at: range.c0, + 수: range.c1 - range.c0 + 1, + }); + } + }, + group.value, + ); +} + +export function openDeleteCellsDialog(ctx: SpreadsheetContext): void { + if (ctx.readOnly) return; + const range = readRange(ctx); + if (!range) return; + const group = radioGroup("deleteMode", [ + ["left", TEXT.shiftLeft], + ["up", TEXT.shiftUp], + ["row", TEXT.wholeRow], + ["col", TEXT.wholeCol], + ]); + modal( + TEXT.deleteTitle, + group.root, + (choice) => { + if (choice === "left") deleteShiftLeft(ctx, range); + else if (choice === "up") deleteShiftUp(ctx, range); + else if (choice === "row") { + ctx.dispatch({ + 종류: "행지우기", + 시트: ctx.selection.시트, + at: range.r0, + 수: range.r1 - range.r0 + 1, + }); + } else { + ctx.dispatch({ + 종류: "열지우기", + 시트: ctx.selection.시트, + at: range.c0, + 수: range.c1 - range.c0 + 1, + }); + } + }, + group.value, + ); +} diff --git a/A00_Common/spreadsheet/spreadsheet_view_menu.ts b/A00_Common/spreadsheet/spreadsheet_view_menu.ts new file mode 100644 index 00000000..c2436459 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_view_menu.ts @@ -0,0 +1,197 @@ +/* ============================================================================= + * spreadsheet_view_menu.ts (2단계 · 주인 sub_laptop_1) + * 이동 대화상자(F5 · 주소 · 정의된 이름) · 틀 고정 메뉴(첫 행 · 첫 열 · 현재 칸 기준 · 풀기). + * + * 정의된 이름 목록은 `Sheet.이름정의`(3단계 · sub_laptop_4 계약 · 안 이어짐) 가 있으면 + * 그대로 보여 줌 — 없으면 빈 목록(주소 입력만 됨). 이름으로 다른 시트를 못 가리키는 + * 지금 계약대로 활성 시트 안에서만 찾음. + * + * 잇는 법(D · sub7) — F5 → `openGoToDialog(ctx)` 를 `spreadsheet_keys.ts` navKey 에 더함. + * 틀 고정 단추(E 도구 모음)는 눌렀을 때 `openFreezePanesMenu(ctx, button)` 를 부르면 그 + * 단추 아래 작은 메뉴가 뜸. + * ========================================================================== */ + +import { createButton, createInputField, el } from "@ui/ui_template_elements"; +import { parseRange, rangeToA1 } from "./spreadsheet_address"; +import type { CellRange } from "./spreadsheet_types"; +import type { SpreadsheetContext } from "./spreadsheet_view_types"; + +const TEXT = { + goToTitle: "이동", + addressLabel: "참조", + namedRanges: "정의된 이름", + ok: "확인", + cancel: "취소", + notFound: "주소를 찾을 수 없음", + freezeFirstRow: "첫 행 고정", + freezeFirstColumn: "첫 열 고정", + freezeAtActive: "틀 고정(현재 칸 기준)", + unfreeze: "틀 고정 풀기", +}; + +// ═════════════════════════════════════════════════════════════════════════════ +// 이동(F5) +// ═════════════════════════════════════════════════════════════════════════════ + +/** 범위(또는 칸) 글을 읽어 그 범위로 고름을 옮기고 보이게 스크롤함. */ +export function goTo(ctx: SpreadsheetContext, target: CellRange): void { + const top = { r: target.r0, c: target.c0 }; + ctx.select({ 시트: ctx.selection.시트, 범위: [target], 활성: top, 기준: top }, true); +} + +export function openGoToDialog(ctx: SpreadsheetContext): void { + const overlay = el("div", { className: "ui-confirm" }); + const panel = el("div", { className: "ui-confirm__panel" }); + const close = (): void => overlay.remove(); + overlay.addEventListener("click", (ev) => { + if (ev.target === overlay) close(); + }); + + const errorMsg = el("p", { text: "" }); + errorMsg.style.cssText = "color:var(--color-danger, red);font-size:0.8rem;min-height:1em;"; + + const addressField = createInputField({ + label: TEXT.addressLabel, + value: rangeToA1( + ctx.selection.범위[0] ?? { + r0: ctx.selection.활성.r, + c0: ctx.selection.활성.c, + r1: ctx.selection.활성.r, + c1: ctx.selection.활성.c, + }, + ), + }); + + const goToAddress = (): void => { + const target = parseRange(addressField.input.value); + if (!target) { + errorMsg.textContent = TEXT.notFound; + return; + } + goTo(ctx, target); + close(); + }; + addressField.input.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") goToAddress(); + }); + + const names = ctx.sheet().이름정의 ?? []; + const namesList = el("div", { + children: names.map((n) => + createButton({ + label: `${n.이름} (${rangeToA1(n.범위)})`, + variant: "ghost", + onClick: () => { + goTo(ctx, n.범위); + close(); + }, + }), + ), + }); + namesList.style.cssText = + "display:flex;flex-direction:column;gap:2px;max-height:160px;overflow:auto;"; + + panel.append( + el("h3", { text: TEXT.goToTitle }), + addressField.root, + errorMsg, + ...(names.length > 0 ? [el("p", { text: TEXT.namedRanges }), namesList] : []), + el("div", { + className: "ui-confirm__actions", + children: [ + createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }), + createButton({ label: TEXT.ok, variant: "filled", onClick: goToAddress }), + ], + }), + ); + overlay.append(panel); + document.body.append(overlay); + addressField.input.focus(); + addressField.input.select(); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// 틀 고정 +// ═════════════════════════════════════════════════════════════════════════════ + +export function freezeFirstRow(ctx: SpreadsheetContext): void { + ctx.dispatch({ + 종류: "틀고정", + 시트: ctx.selection.시트, + 행: 1, + 열: ctx.sheet().틀고정?.열 ?? 0, + }); +} + +export function freezeFirstColumn(ctx: SpreadsheetContext): void { + ctx.dispatch({ + 종류: "틀고정", + 시트: ctx.selection.시트, + 행: ctx.sheet().틀고정?.행 ?? 0, + 열: 1, + }); +} + +/** 활성 칸의 왼위 자리를 기준으로 그 위 · 왼쪽을 고정(활성 칸이 A1 이면 사실상 풀기와 같음). */ +export function freezeAtActiveCell(ctx: SpreadsheetContext): void { + const at = ctx.selection.활성; + ctx.dispatch({ 종류: "틀고정", 시트: ctx.selection.시트, 행: at.r, 열: at.c }); +} + +export function unfreezePanes(ctx: SpreadsheetContext): void { + ctx.dispatch({ 종류: "틀고정", 시트: ctx.selection.시트, 행: 0, 열: 0 }); +} + +/** 도구 모음 단추 아래(또는 화면 가운데, `anchor` 없으면) 뜨는 작은 메뉴 — 누르면 바로 적용하고 닫힘. */ +export function openFreezePanesMenu(ctx: SpreadsheetContext, anchor?: HTMLElement): void { + const menu = el("div", { className: "ui-confirm__panel" }); + menu.style.cssText = + "position:fixed;display:flex;flex-direction:column;gap:2px;min-width:180px;z-index:1000;padding:6px;"; + + const items: [string, () => void][] = [ + [TEXT.freezeFirstRow, () => freezeFirstRow(ctx)], + [TEXT.freezeFirstColumn, () => freezeFirstColumn(ctx)], + [TEXT.freezeAtActive, () => freezeAtActiveCell(ctx)], + [TEXT.unfreeze, () => unfreezePanes(ctx)], + ]; + + const close = (): void => { + menu.remove(); + document.removeEventListener("mousedown", onOutside, true); + document.removeEventListener("keydown", onEscape); + }; + const onOutside = (ev: MouseEvent): void => { + if (!menu.contains(ev.target as Node)) close(); + }; + const onEscape = (ev: KeyboardEvent): void => { + if (ev.key === "Escape") close(); + }; + + menu.append( + ...items.map(([label, action]) => + createButton({ + label, + variant: "ghost", + onClick: () => { + action(); + close(); + }, + }), + ), + ); + + document.body.append(menu); + if (anchor) { + const box = anchor.getBoundingClientRect(); + menu.style.top = `${box.bottom + 4}px`; + menu.style.left = `${box.left}px`; + } else { + menu.style.top = "40%"; + menu.style.left = "50%"; + menu.style.transform = "translate(-50%, -50%)"; + } + setTimeout(() => { + document.addEventListener("mousedown", onOutside, true); + document.addEventListener("keydown", onEscape); + }, 0); +} diff --git a/resources/tester/spreadsheet/test_stage2_helpers.ts b/resources/tester/spreadsheet/test_stage2_helpers.ts index b088f82b..333530ef 100644 --- a/resources/tester/spreadsheet/test_stage2_helpers.ts +++ b/resources/tester/spreadsheet/test_stage2_helpers.ts @@ -14,7 +14,10 @@ import type { Sheet, Workbook, } from "../../../A00_Common/spreadsheet/spreadsheet_types"; -import type { SpreadsheetContext } from "../../../A00_Common/spreadsheet/spreadsheet_view_types"; +import type { + Selection, + SpreadsheetContext, +} from "../../../A00_Common/spreadsheet/spreadsheet_view_types"; export function assertEqual(actual: T, expected: T, label: string): void { const a = JSON.stringify(actual); @@ -52,8 +55,9 @@ export function makeCtx( book: Workbook, sheetId: string, values: FakeEngineValues = {}, -): SpreadsheetContext & { applied: Command[] } { +): SpreadsheetContext & { applied: Command[]; selects: Selection[] } { const applied: Command[] = []; + const selects: Selection[] = []; const apply = (cmd: Command): void => { applied.push(cmd); @@ -126,7 +130,7 @@ export function makeCtx( dispatch: (cmd: Command) => apply(cmd), undo: () => {}, redo: () => {}, - select: () => {}, + select: (sel: Selection) => selects.push(sel), showSheet: () => {}, grid: { root: {} as HTMLElement, @@ -149,6 +153,7 @@ export function makeCtx( sync: () => {}, }, applied, + selects, }; } diff --git a/resources/tester/spreadsheet/test_stage2_insert_dialog.ts b/resources/tester/spreadsheet/test_stage2_insert_dialog.ts new file mode 100644 index 00000000..af571c20 --- /dev/null +++ b/resources/tester/spreadsheet/test_stage2_insert_dialog.ts @@ -0,0 +1,65 @@ +/* ============================================================================= + * test_stage2_insert_dialog.ts — spreadsheet_insert_dialog 시험(부분 범위 밀기 4가지). + * 돌리기: npx tsx <이 파일> + * ========================================================================== */ + +import { + deleteShiftLeft, + deleteShiftUp, + insertShiftDown, + insertShiftRight, +} from "../../../A00_Common/spreadsheet/spreadsheet_insert_dialog.ts"; +import { a1, assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts"; + +// A1=1 B1=2 C1=3 한 줄 있는 시트에서 B1:B1 자리에 오른쪽으로 밀기 → B·C 가 밀리고 B 는 빔. +{ + const book = makeBook([ + makeSheet("s1", { [a1(0, 0)]: v(1), [a1(0, 1)]: v(2), [a1(0, 2)]: v(3) }), + ]); + const ctx = makeCtx(book, "s1"); + insertShiftRight(ctx, { r0: 0, c0: 1, r1: 0, c1: 1 }); + assertEqual(book.시트[0].칸[a1(0, 0)], { 값: 1 }, "오른쪽 밀기 · A1 안 건드림"); + assertEqual(book.시트[0].칸[a1(0, 1)], undefined, "오른쪽 밀기 · B1 빔(끼운 자리)"); + assertEqual(book.시트[0].칸[a1(0, 2)], { 값: 2 }, "오른쪽 밀기 · 옛 B1 이 C1 로"); + assertEqual(book.시트[0].칸[a1(0, 3)], { 값: 3 }, "오른쪽 밀기 · 옛 C1 이 D1 로"); +} + +// A1=1 A2=2 A3=3 세로줄에서 A2 자리에 아래로 밀기. +{ + const book = makeBook([ + makeSheet("s1", { [a1(0, 0)]: v(1), [a1(1, 0)]: v(2), [a1(2, 0)]: v(3) }), + ]); + const ctx = makeCtx(book, "s1"); + insertShiftDown(ctx, { r0: 1, c0: 0, r1: 1, c1: 0 }); + assertEqual(book.시트[0].칸[a1(0, 0)], { 값: 1 }, "아래로 밀기 · A1 안 건드림"); + assertEqual(book.시트[0].칸[a1(1, 0)], undefined, "아래로 밀기 · A2 빔(끼운 자리)"); + assertEqual(book.시트[0].칸[a1(2, 0)], { 값: 2 }, "아래로 밀기 · 옛 A2 가 A3 로"); + assertEqual(book.시트[0].칸[a1(3, 0)], { 값: 3 }, "아래로 밀기 · 옛 A3 가 A4 로"); +} + +// A1=1 B1=2 C1=3 에서 A1:A1 삭제(왼쪽으로 밀기) → B·C 가 왼쪽으로 당겨짐. +{ + const book = makeBook([ + makeSheet("s1", { [a1(0, 0)]: v(1), [a1(0, 1)]: v(2), [a1(0, 2)]: v(3) }), + ]); + const ctx = makeCtx(book, "s1"); + deleteShiftLeft(ctx, { r0: 0, c0: 0, r1: 0, c1: 0 }); + assertEqual(book.시트[0].칸[a1(0, 0)], { 값: 2 }, "왼쪽 당김 · A1 이 옛 B1 값"); + assertEqual(book.시트[0].칸[a1(0, 1)], { 값: 3 }, "왼쪽 당김 · B1 이 옛 C1 값"); + assertEqual(book.시트[0].칸[a1(0, 2)], undefined, "왼쪽 당김 · 마지막 자리 빔"); +} + +// A1=1 A2=2 A3=3 에서 A1:A1 삭제(위로 밀기) → A2·A3 가 위로 당겨짐. +{ + const book = makeBook([ + makeSheet("s1", { [a1(0, 0)]: v(1), [a1(1, 0)]: v(2), [a1(2, 0)]: v(3) }), + ]); + const ctx = makeCtx(book, "s1"); + deleteShiftUp(ctx, { r0: 0, c0: 0, r1: 0, c1: 0 }); + assertEqual(book.시트[0].칸[a1(0, 0)], { 값: 2 }, "위로 당김 · A1 이 옛 A2 값"); + assertEqual(book.시트[0].칸[a1(1, 0)], { 값: 3 }, "위로 당김 · A2 가 옛 A3 값"); + assertEqual(book.시트[0].칸[a1(2, 0)], undefined, "위로 당김 · 마지막 자리 빔"); +} + +if (process.exitCode) process.exit(process.exitCode); +console.log("spreadsheet_insert_dialog 시험 끝"); diff --git a/resources/tester/spreadsheet/test_stage2_view_menu.ts b/resources/tester/spreadsheet/test_stage2_view_menu.ts new file mode 100644 index 00000000..8400c829 --- /dev/null +++ b/resources/tester/spreadsheet/test_stage2_view_menu.ts @@ -0,0 +1,65 @@ +/* ============================================================================= + * test_stage2_view_menu.ts — spreadsheet_view_menu 시험(이동 · 틀 고정 명령 짓기). + * 돌리기: npx tsx <이 파일> + * ========================================================================== */ + +import { + freezeAtActiveCell, + freezeFirstColumn, + freezeFirstRow, + goTo, + unfreezePanes, +} from "../../../A00_Common/spreadsheet/spreadsheet_view_menu.ts"; +import { assertEqual, makeBook, makeCtx, makeSheet } from "./test_stage2_helpers.ts"; + +{ + const book = makeBook([makeSheet("s1", {})]); + const ctx = makeCtx(book, "s1"); + goTo(ctx, { r0: 2, c0: 3, r1: 4, c1: 5 }); + assertEqual(ctx.selects.length, 1, "이동 · select 한 번 불림"); + assertEqual(ctx.selects[0].범위, [{ r0: 2, c0: 3, r1: 4, c1: 5 }], "이동 · 범위 그대로"); + assertEqual(ctx.selects[0].활성, { r: 2, c: 3 }, "이동 · 활성 칸은 범위 왼위"); +} + +{ + const book = makeBook([makeSheet("s1", {})]); + const ctx = makeCtx(book, "s1"); + freezeFirstRow(ctx); + assertEqual(ctx.applied[0], { 종류: "틀고정", 시트: "s1", 행: 1, 열: 0 }, "첫 행 고정"); +} + +{ + const book = makeBook([makeSheet("s1", {})]); + const ctx = makeCtx(book, "s1"); + freezeFirstColumn(ctx); + assertEqual(ctx.applied[0], { 종류: "틀고정", 시트: "s1", 행: 0, 열: 1 }, "첫 열 고정"); +} + +{ + const book = makeBook([makeSheet("s1", {}, { 틀고정: { 행: 1, 열: 0 } })]); + const ctx = makeCtx(book, "s1"); + freezeFirstColumn(ctx); + assertEqual( + ctx.applied[0], + { 종류: "틀고정", 시트: "s1", 행: 1, 열: 1 }, + "첫 열 고정 · 이미 있던 행 고정은 유지", + ); +} + +{ + const book = makeBook([makeSheet("s1", {})]); + const ctx = makeCtx(book, "s1"); + ctx.selection.활성 = { r: 3, c: 2 }; + freezeAtActiveCell(ctx); + assertEqual(ctx.applied[0], { 종류: "틀고정", 시트: "s1", 행: 3, 열: 2 }, "현재 칸 기준 고정"); +} + +{ + const book = makeBook([makeSheet("s1", {})]); + const ctx = makeCtx(book, "s1"); + unfreezePanes(ctx); + assertEqual(ctx.applied[0], { 종류: "틀고정", 시트: "s1", 행: 0, 열: 0 }, "틀 고정 풀기"); +} + +if (process.exitCode) process.exit(process.exitCode); +console.log("spreadsheet_view_menu 시험 끝");