diff --git a/A00_Common/spreadsheet/spreadsheet_commands.ts b/A00_Common/spreadsheet/spreadsheet_commands.ts index e89a56a8..f4ecdf36 100644 --- a/A00_Common/spreadsheet/spreadsheet_commands.ts +++ b/A00_Common/spreadsheet/spreadsheet_commands.ts @@ -292,16 +292,24 @@ function insertDelete( setComments(s, notes); } - const merges: CellRange[] = []; - for (const m of mergesOf(s)) { + const shiftRange = (m: CellRange) => { 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); + return sp && (rows ? { ...m, r0: sp[0], r1: sp[1] } : { ...m, c0: sp[0], c1: sp[1] }); + }; + const merges: CellRange[] = []; + for (const m of mergesOf(s)) { + const next = shiftRange(m); + if (next && (next.r0 !== next.r1 || next.c0 !== next.c1)) merges.push(next); } setMerges(s, merges); + // 이름 범위도 따라감 · 범위가 통째로 지워진 이름은 뺌(엑셀은 `#REF!` 이름으로 남김 — 여기선 식이 `#NAME?`) + if (s.이름정의) + s.이름정의 = s.이름정의.flatMap((d) => { + const 범위 = shiftRange(d.범위); + return 범위 ? [{ ...d, 범위 }] : []; + }); const axis = rows ? "행" : "열"; const table = s[axis]; diff --git a/A00_Common/spreadsheet/spreadsheet_data_tools.ts b/A00_Common/spreadsheet/spreadsheet_data_tools.ts new file mode 100644 index 00000000..5d935a5a --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_data_tools.ts @@ -0,0 +1,422 @@ +/* ============================================================================= + * spreadsheet_data_tools.ts (2단계 · 주인 sub_laptop_1) + * 데이터 도구 셋 — ① 텍스트 나누기(구분 기호 · 고정 폭 · 미리보기) ② 중복된 항목 제거 + * (열 고르기 · 지운 수 알림) ③ 채우기 연속 데이터(단계 값 · 끝 값 · 행/열). + * 문서를 바꾸는 길은 「칸」 명령 하나뿐 — 세 도구 모두 이것만 씀. + * + * 잇는 법(D · sub7) — 메뉴 [데이터] 아래 세 항목을 만들어 `openTextToColumnsDialog(ctx)` · + * `openRemoveDuplicatesDialog(ctx)` · `openFillSeriesDialog(ctx)` 를 각각 부름 + * (`spreadsheet_menu.ts` 우클릭 메뉴에도 [중복된 항목 제거]는 달아 둘 만함). + * + * 고정 폭 나누기는 실제 엑셀처럼 눈금자를 끌어 자르지 않고 자를 자리(문자 수)를 + * 쉼표로 적는 입력 한 줄로 단순화함(미리보기로 바로 확인 가능 — 자리 표시만 다름). + * ========================================================================== */ + +import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; +import { fracToString } from "@ui/sheet/ui_template_sheet_frac"; +import { colName, toA1 } from "./spreadsheet_address"; +import type { Cell, CellAddress, CellInput, CellRange, Scalar } from "./spreadsheet_types"; +import type { SpreadsheetContext } from "./spreadsheet_view_types"; + +const TEXT = { + textToColumns: "텍스트 나누기", + byDelimiter: "구분 기호로", + byFixedWidth: "고정 폭으로", + delimiters: "구분 기호", + tab: "탭", + semicolon: "세미콜론", + comma: "쉼표", + space: "공백", + custom: "사용자 지정", + breaks: "자를 자리(문자 수, 쉼표로 구분)", + preview: "미리보기", + run: "실행", + cancel: "취소", + removeDuplicates: "중복된 항목 제거", + hasHeader: "머리글 포함", + keyColumns: "비교할 열", + removed: (n: number) => `중복 ${n}개 지움`, + fillSeries: "연속 데이터 채우기", + direction: "방향", + down: "아래로(열)", + right: "오른쪽으로(행)", + step: "단계 값", + stop: "끝 값(선택)", +}; + +// ═════════════════════════════════════════════════════════════════════════════ +// 공용 — 값 ↔ 글, 작은 대화상자 뼈대 +// ═════════════════════════════════════════════════════════════════════════════ + +function scalarText(v: Scalar): string { + if (v == null) return ""; + if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; + if (typeof v === "string") return v; + if (typeof v === "object" && "error" in v) return v.error; + return fracToString(v); +} + +function cellInputFrom(text: string): CellInput { + const n = Number(text); + return text !== "" && !Number.isNaN(n) ? n : text; +} + +function row(label: string, ...controls: HTMLElement[]): HTMLElement { + const r = el("div", { + children: [el("span", { text: label }), ...controls], + }); + r.style.cssText = "display:flex;align-items:center;gap:8px;margin-bottom:6px;"; + return r; +} + +/** `.ui-confirm` 배경 위에 제목 · 본문 · [실행]/[취소] 를 올린 작은 대화상자. */ +function modal(title: string, body: HTMLElement[], onRun: () => void, runLabel = TEXT.run): void { + const overlay = el("div", { className: "ui-confirm" }); + const panel = el("div", { className: "ui-confirm__panel" }); + panel.style.minWidth = "360px"; + const close = (): void => overlay.remove(); + overlay.addEventListener("click", (ev) => { + if (ev.target === overlay) close(); + }); + panel.append(el("h3", { text: title }), ...body); + panel.append( + el("div", { + className: "ui-confirm__actions", + children: [ + createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }), + createButton({ + label: runLabel, + variant: "filled", + onClick: () => { + onRun(); + close(); + }, + }), + ], + }), + ); + overlay.append(panel); + document.body.append(overlay); +} + +function readRange(ctx: SpreadsheetContext): CellRange | null { + return ctx.selection.범위[0] ?? null; +} + +// ═════════════════════════════════════════════════════════════════════════════ +// 1. 텍스트 나누기 +// ═════════════════════════════════════════════════════════════════════════════ + +export type DelimiterKind = "탭" | "세미콜론" | "쉼표" | "공백" | "사용자"; + +const DELIM_CHARS: Record, string> = { + 탭: "\t", + 세미콜론: ";", + 쉼표: ",", + 공백: " ", +}; + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** 한 줄을 고른 구분 기호로 나눔 — 기호가 하나도 없으면 원문 그대로 한 칸. */ +export function splitDelimited(line: string, kinds: DelimiterKind[], custom: string): string[] { + const chars = kinds + .map((k) => (k === "사용자" ? custom : DELIM_CHARS[k])) + .filter((c): c is string => !!c); + if (chars.length === 0) return [line]; + return line.split(new RegExp(chars.map(escapeRegExp).join("|"))); +} + +/** 한 줄을 자를 자리(문자 수, 오름차순)로 나눔. */ +export function splitFixedWidth(line: string, breaks: number[]): string[] { + const points = [0, ...breaks.filter((b) => b > 0 && b < line.length), line.length]; + const sorted = [...new Set(points)].sort((a, b) => a - b); + const out: string[] = []; + for (let i = 0; i < sorted.length - 1; i++) out.push(line.slice(sorted[i], sorted[i + 1])); + return out; +} + +/** 나뉜 줄들을 `anchor` 를 왼위로 삼아 칸에 씀(빈 글은 칸 지움). */ +export function applyTextToColumns( + ctx: SpreadsheetContext, + anchor: CellAddress, + rows: string[][], +): void { + const cells: Record = {}; + rows.forEach((cols, ri) => { + cols.forEach((text, ci) => { + cells[toA1(anchor.r + ri, anchor.c + ci)] = text === "" ? null : { 값: cellInputFrom(text) }; + }); + }); + ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells }); +} + +export function openTextToColumnsDialog(ctx: SpreadsheetContext): void { + if (ctx.readOnly) return; + const range = readRange(ctx); + if (!range) return; + const lines: string[] = []; + for (let r = range.r0; r <= range.r1; r++) { + lines.push(scalarText(ctx.engine.value(ctx.selection.시트, r, range.c0))); + } + + let mode: "구분" | "고정" = "구분"; + let kinds: DelimiterKind[] = ["쉼표"]; + let custom = ""; + let breaksText = ""; + + const modeSelect = el("select", { + children: [ + el("option", { attrs: { value: "구분" }, text: TEXT.byDelimiter }), + el("option", { attrs: { value: "고정" }, text: TEXT.byFixedWidth }), + ], + }) as HTMLSelectElement; + + const delimBox = el("div"); + const kindChecks = (["탭", "세미콜론", "쉼표", "공백", "사용자"] as DelimiterKind[]).map((k) => { + const check = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; + check.checked = kinds.includes(k); + check.addEventListener("change", () => { + kinds = check.checked ? [...kinds, k] : kinds.filter((x) => x !== k); + refreshPreview(); + }); + return row( + k === "탭" + ? TEXT.tab + : k === "세미콜론" + ? TEXT.semicolon + : k === "쉼표" + ? TEXT.comma + : k === "공백" + ? TEXT.space + : TEXT.custom, + check, + ); + }); + const customInput = createInputField({ + label: TEXT.custom, + onInput: (v) => { + custom = v; + refreshPreview(); + }, + }); + delimBox.append(...kindChecks, customInput.root); + + const breaksField = createInputField({ + label: TEXT.breaks, + placeholder: "5,10,20", + onInput: (v) => { + breaksText = v; + refreshPreview(); + }, + }); + breaksField.root.hidden = true; + + const previewBox = el("pre"); + previewBox.style.cssText = + "max-height:160px;overflow:auto;background:var(--color-canvas);padding:6px;border-radius:4px;font-size:0.8rem;"; + + function splitAll(): string[][] { + if (mode === "고정") { + const breaks = breaksText + .split(",") + .map((s) => Number(s.trim())) + .filter((n) => Number.isFinite(n)); + return lines.map((l) => splitFixedWidth(l, breaks)); + } + return lines.map((l) => splitDelimited(l, kinds, custom)); + } + + function refreshPreview(): void { + previewBox.textContent = splitAll() + .slice(0, 8) + .map((cols) => cols.join(" | ")) + .join("\n"); + } + + modeSelect.addEventListener("change", () => { + mode = modeSelect.value as "구분" | "고정"; + delimBox.hidden = mode !== "구분"; + breaksField.root.hidden = mode !== "고정"; + refreshPreview(); + }); + + refreshPreview(); + + modal( + TEXT.textToColumns, + [ + row(TEXT.byDelimiter + "/" + TEXT.byFixedWidth, modeSelect), + delimBox, + breaksField.root, + el("div", { text: TEXT.preview }), + previewBox, + ], + () => applyTextToColumns(ctx, { r: range.r0, c: range.c0 }, splitAll()), + ); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// 2. 중복된 항목 제거 +// ═════════════════════════════════════════════════════════════════════════════ + +/** `keyCols`(범위 안 0 부터 상대 열)로 값이 같은 뒤쪽 행들의 자리(0 부터 상대 행)를 돌려줌. */ +export function findDuplicateRowIndexes(rows: string[][], keyCols: number[]): number[] { + const seen = new Set(); + const dup: number[] = []; + rows.forEach((r, i) => { + const key = keyCols.map((c) => r[c] ?? "").join("\u0001"); + if (seen.has(key)) dup.push(i); + else seen.add(key); + }); + return dup; +} + +/** 범위 안 값을 읽어 중복 행을 지우고 위로 당김(범위 밖 칸은 안 건드림) — 지운 행 수를 돌려줌. */ +export function removeDuplicateRows( + ctx: SpreadsheetContext, + range: CellRange, + keyCols: number[], + skipHeader: boolean, +): number { + const width = range.c1 - range.c0 + 1; + const bodyR0 = range.r0 + (skipHeader ? 1 : 0); + const rows: string[][] = []; + for (let r = bodyR0; r <= range.r1; r++) { + const line: string[] = []; + for (let c = 0; c < width; c++) + line.push(scalarText(ctx.engine.value(ctx.selection.시트, r, range.c0 + c))); + rows.push(line); + } + const dupIdx = new Set(findDuplicateRowIndexes(rows, keyCols)); + const kept = rows.filter((_, i) => !dupIdx.has(i)); + + const cells: Record = {}; + for (let i = 0; i < rows.length; i++) { + const r = bodyR0 + i; + for (let c = 0; c < width; c++) { + const text = i < kept.length ? kept[i][c] : ""; + cells[toA1(r, range.c0 + c)] = text === "" ? null : { 값: cellInputFrom(text) }; + } + } + ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells }); + return dupIdx.size; +} + +export function openRemoveDuplicatesDialog(ctx: SpreadsheetContext): void { + if (ctx.readOnly) return; + const range = readRange(ctx); + if (!range) return; + const width = range.c1 - range.c0 + 1; + + let hasHeader = width > 1 || range.r1 > range.r0; + const headerCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; + headerCheck.checked = hasHeader; + + const keyCols = new Set(Array.from({ length: width }, (_, i) => i)); + const colChecks = Array.from({ length: width }, (_, i) => { + const c = range.c0 + i; + const label = hasHeader + ? scalarText(ctx.engine.value(ctx.selection.시트, range.r0, c)) || colName(c) + : colName(c); + const check = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; + check.checked = true; + check.addEventListener("change", () => { + if (check.checked) keyCols.add(i); + else keyCols.delete(i); + }); + return row(label, check); + }); + + headerCheck.addEventListener("change", () => { + hasHeader = headerCheck.checked; + }); + + modal( + TEXT.removeDuplicates, + [row(TEXT.hasHeader, headerCheck), el("div", { text: TEXT.keyColumns }), ...colChecks], + () => { + const removed = removeDuplicateRows(ctx, range, [...keyCols], hasHeader); + showToast(TEXT.removed(removed), "success"); + }, + ); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// 3. 채우기 연속 데이터 +// ═════════════════════════════════════════════════════════════════════════════ + +export type SeriesDirection = "행" | "열"; + +/** 시작값 · 단계 · 끝값(넘으면 멈춤, 없으면 count 까지) · 개수로 등차수열을 지음. */ +export function buildLinearSeries( + start: number, + step: number, + stop: number | null, + count: number, +): number[] { + const out: number[] = []; + for (let i = 0; i < count; i++) { + const v = start + step * i; + if (stop != null && ((step >= 0 && v > stop) || (step < 0 && v < stop))) break; + out.push(v); + } + return out; +} + +export function applyFillSeries( + ctx: SpreadsheetContext, + anchor: CellAddress, + direction: SeriesDirection, + values: number[], +): void { + const cells: Record = {}; + values.forEach((v, i) => { + const r = direction === "열" ? anchor.r + i : anchor.r; + const c = direction === "행" ? anchor.c + i : anchor.c; + cells[toA1(r, c)] = { 값: v }; + }); + ctx.dispatch({ 종류: "칸", 시트: ctx.selection.시트, 칸: cells }); +} + +export function openFillSeriesDialog(ctx: SpreadsheetContext): void { + if (ctx.readOnly) return; + const range = readRange(ctx); + if (!range) return; + const at = ctx.selection.활성; + const startText = scalarText(ctx.engine.value(ctx.selection.시트, at.r, at.c)); + const start = Number(startText) || 0; + + let direction: SeriesDirection = range.r1 > range.r0 ? "열" : "행"; + let step = 1; + let stop: number | null = null; + + const dirSelect = el("select", { + children: [ + el("option", { attrs: { value: "열" }, text: TEXT.down }), + el("option", { attrs: { value: "행" }, text: TEXT.right }), + ], + }) as HTMLSelectElement; + dirSelect.value = direction; + dirSelect.addEventListener("change", () => (direction = dirSelect.value as SeriesDirection)); + + const stepField = createInputField({ + label: TEXT.step, + type: "number", + value: "1", + onInput: (v) => (step = Number(v) || 0), + }); + const stopField = createInputField({ + label: TEXT.stop, + type: "number", + onInput: (v) => (stop = v === "" ? null : Number(v)), + }); + + modal(TEXT.fillSeries, [row(TEXT.direction, dirSelect), stepField.root, stopField.root], () => { + const extent = direction === "열" ? range.r1 - range.r0 + 1 : range.c1 - range.c0 + 1; + const values = buildLinearSeries(start, step, stop, extent); + applyFillSeries(ctx, at, direction, values); + }); +} diff --git a/A00_Common/spreadsheet/spreadsheet_eval_steps.ts b/A00_Common/spreadsheet/spreadsheet_eval_steps.ts index 15750ba8..e19ee044 100644 --- a/A00_Common/spreadsheet/spreadsheet_eval_steps.ts +++ b/A00_Common/spreadsheet/spreadsheet_eval_steps.ts @@ -15,7 +15,7 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address"; import { isError, isRange, numberText } from "./spreadsheet_eval"; import type { PlacedRange } from "./spreadsheet_eval"; -import { evalSafe } from "./spreadsheet_graph"; +import { bindNames, evalSafe } from "./spreadsheet_graph"; import { formulaToText, parseFormula } from "./spreadsheet_parser"; import type { CalcEngine, @@ -157,7 +157,7 @@ export function evalSteps( for (const n of nodes) { if (n === p.node) vals.set(n, engine.value(sheet, r, c)); else { - const v = evalSafe(n, ctx); + const v = evalSafe(bindNames(n, book, sheet), ctx); if (isRange(v)) continue; // 범위 값(INDEX 등)은 글 그대로 vals.set(n, v); } diff --git a/A00_Common/spreadsheet/spreadsheet_graph.ts b/A00_Common/spreadsheet/spreadsheet_graph.ts index 7e9046da..02f4d14e 100644 --- a/A00_Common/spreadsheet/spreadsheet_graph.ts +++ b/A00_Common/spreadsheet/spreadsheet_graph.ts @@ -21,6 +21,7 @@ import type { FormulaNode, ParseResult, Scalar, + Sheet, SheetCellAddress, Workbook, } from "./spreadsheet_types"; @@ -82,7 +83,36 @@ function collectRefs( } } -/** 나무 하나 풂 — 던진 오류는 오류 값으로(0 나눔 → `#DIV/0!` · 나머지 `#VALUE!`) */ +/** 이름(`값들`) → 정의한 범위 참조(자기 시트 이름 먼저 · 없으면 다른 시트) · 없는 이름은 그대로(→ `#NAME?`) */ +export function bindNames(node: FormulaNode, book: Workbook, host: string): FormulaNode { + const go = (n: FormulaNode): FormulaNode => { + switch (n.type) { + case "name": { + const low = n.name.toLowerCase(); + const def = (s?: Sheet) => s?.이름정의?.find((d) => d.이름.toLowerCase() === low); + const s = [book.시트.find((x) => x.id === host), ...book.시트].find(def); + if (!s) return n; + const g = def(s)!.범위; + const sheet = s.id === host ? null : s.이름; + const from = { r: g.r0, c: g.c0, absR: true, absC: true }; + const to = { r: g.r1, c: g.c1, absR: true, absC: true }; + return g.r0 === g.r1 && g.c0 === g.c1 + ? { type: "cell", sheet, ref: from } + : { type: "range", sheet, from, to }; + } + case "unary": + return { ...n, arg: go(n.arg) }; + case "binary": + return { ...n, left: go(n.left), right: go(n.right) }; + case "call": + return { ...n, args: n.args.map(go) }; + } + return n; + }; + return book.시트.some((s) => s.이름정의?.length) ? go(node) : node; +} + +/** 나무 하나 풂 —던진 오류는 오류 값으로(0 나눔 → `#DIV/0!` · 나머지 `#VALUE!`) */ export function evalSafe(node: FormulaNode, ctx: EvalContext): EvalResult { try { return evaluate(node, ctx); @@ -111,9 +141,15 @@ export function createCalcEngine(book: Workbook): CalcEngine { const sheetId = (name: string) => names.get(name.toLowerCase()) ?? null; - const parse = (text: string) => { - let p = parsed.get(text); - if (!p) parsed.set(text, (p = parseFormula(text))); + /** 식 나무(이름은 범위로 바꿈) — 시트마다 캐시 · rebuild 때 비움 */ + const parse = (text: string, host: string) => { + const k = `${host}!${text}`; + let p = parsed.get(k); + if (!p) { + p = parseFormula(text); + if (p.ok) p.node = bindNames(p.node, book, host); + parsed.set(k, p); + } return p; }; @@ -145,7 +181,7 @@ export function createCalcEngine(book: Workbook): CalcEngine { vals.set(key, inputValue(cell.값!)); return; } - const p = parse(cell.식); + const p = parse(cell.식, sheet.id); const deps: Dep[] = []; if (p.ok) collectRefs(p.node, sheet.id, sheetId, deps); precs.set(key, deps); @@ -167,6 +203,7 @@ export function createCalcEngine(book: Workbook): CalcEngine { function rebuild(next: Workbook) { book = next; + parsed.clear(); sheets = new Map(); names = new Map(); precs = new Map(); @@ -294,7 +331,7 @@ export function createCalcEngine(book: Workbook): CalcEngine { } function run(text: string, ctx: EvalContext): Scalar { - const p = parse(text); + const p = parse(text, ctx.sheet); if (!p.ok) return err("#NAME?", `식을 못 읽음: ${p.message} (${p.at + 1}번째 글자)`); const one = intersect(evalSafe(p.node, ctx), ctx); return one === null ? ZERO : one; 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/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 392e8db2..ef849a9f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -46,8 +46,10 @@ interface SheetEditorHandle extends EditorHandle { } type Mods = Record Promise>; -// 표 부품(sub3) · 도면 부품(sub2) — 파일 이름은 각 창이 정함 → 폴더를 훑어 내보낸 이름으로 찾음 -const SHEET_MODS: Mods = import.meta.glob("../ui_template/sheet/*.ts"); +// 표 부품(sub3) · 도면 부품(sub2) — 내보낸 이름으로 찾음 +// 표는 진입 파일만 — 폴더 통째(`*.ts`)면 분수 모듈이 따로 늦게 받는 입구가 되어 번들러 도우미를 +// 달고, 그 도우미가 든 큰 조각(compass)까지 스프레드시트가 끌어옴 +const SHEET_MODS: Mods = import.meta.glob("../ui_template/sheet/ui_template_sheet.ts"); const DRAWING_MODS: Mods = import.meta.glob("./M02_MasterTemplete_Drawing*.ts"); async function findExport(mods: Mods, name: string): Promise { diff --git a/resources/tester/spreadsheet/helper_spreadsheet_analysis.cjs b/resources/tester/spreadsheet/helper_spreadsheet_analysis.cjs index 0699003b..996e25fc 100644 --- a/resources/tester/spreadsheet/helper_spreadsheet_analysis.cjs +++ b/resources/tester/spreadsheet/helper_spreadsheet_analysis.cjs @@ -164,4 +164,69 @@ section("errcheck", () => { ]); }); +// ── 4. 이름 정의 풀이 ──────────────────────────────────────────────────────── +section("names", () => { + const { C, E, F } = require("./helper_spreadsheet_engine.cjs"); + const N = load("spreadsheet_names"); + const show = (v) => (v === null ? null : E.isError(v) ? v.error : E.isFrac(v) ? F.fracToString(v) : v); + const b = book( + { A1: 1, A2: 2, A3: 3, B1: 10, C1: "=SUM(값들)", C2: "=단가*2", C3: "=없는이름+1", C4: "=SUM(값들)*단가" }, + { D1: "=SUM(값들)", D2: "=SUM(둘값)", E1: 100 }, + ); + b.시트[0].이름정의 = [ + { 이름: "값들", 범위: A.parseRange("A1:A3") }, + { 이름: "단가", 범위: A.parseRange("B1") }, + ]; + b.시트[1].이름정의 = [{ 이름: "둘값", 범위: A.parseRange("E1") }]; + const e = G.createCalcEngine(b); + const v = (a1, s = "s1") => { + const p = A.parseA1(a1); + return show(e.value(s, p.r, p.c)); + }; + eq("풀이", ["C1", "C2", "C3", "C4"].map((a) => v(a)), ["6", "20", "#NAME?", "60"]); + eq("다른 시트에서 · 자기 시트 이름", [v("D1", "s2"), v("D2", "s2")], ["6", "100"]); + // 이름 범위 칸을 고치면 따라 풂 + C.applyCommand(b, { 종류: "칸", 시트: "s1", 칸: { A1: { 값: 5 } } }); + e.update([{ 시트: "s1", r: 0, c: 0 }]); + eq("딸린 칸 다시 풂", [v("C1"), v("C4"), v("D1", "s2")], ["10", "100", "10"]); + // 식 계산 걸음도 이름을 품 + const p = A.parseA1("C4"); + eq( + "계산 걸음", + ST.evalSteps(b, e, "s1", p.r, p.c).map((s) => s.글), + ["SUM(값들)*단가", "10*단가", "10*10", "100"], + ); + + // 이름 바꾸기 · 지우기(sub4 명령 → 시트통째 → rebuild) + const ctx = { + get book() { + return b; + }, + sheet: () => b.시트[0], + dispatch: (cmd) => { + C.applyCommand(b, cmd); + e.rebuild(b); + }, + }; + eq("이름 바꿈", N.renameName(ctx, "값들", "수량"), null); + eq("바꾼 뒤", [b.시트[0].칸.C1.식, v("C1"), v("D1", "s2")], ["SUM(수량)", "10", "10"]); + N.deleteName(ctx, "단가"); + eq("지운 뒤 #NAME?", [v("C2"), v("C4")], ["#NAME?", "#NAME?"]); + + // 행 넣기 · 지우기 때 이름 범위가 따라감 · 되돌리기 + const fx = C.applyCommand(b, { 종류: "행넣기", 시트: "s1", at: 0, 수: 1 }); + e.rebuild(b); + eq("넣은 뒤 범위", A.rangeToA1(b.시트[0].이름정의[0].범위), "A2:A4"); + eq("넣은 뒤 값", [v("C2"), v("D1", "s2")], ["10", "10"]); + C.applyCommand(b, fx.undo); + e.rebuild(b); + eq("되돌린 범위", A.rangeToA1(b.시트[0].이름정의[0].범위), "A1:A3"); + C.applyCommand(b, { 종류: "행지우기", 시트: "s1", at: 1, 수: 1 }); + e.rebuild(b); + eq("가운데 지움", [A.rangeToA1(b.시트[0].이름정의[0].범위), v("C1")], ["A1:A2", "8"]); + C.applyCommand(b, { 종류: "열지우기", 시트: "s1", at: 0, 수: 1 }); + e.rebuild(b); + eq("통째 지움 → 이름 빠짐", [b.시트[0].이름정의.length, v("B1")], [0, "#NAME?"]); +}); + process.stdout.write(JSON.stringify(results)); diff --git a/resources/tester/spreadsheet/test_spreadsheet_analysis.py b/resources/tester/spreadsheet/test_spreadsheet_analysis.py index 2a736f48..ba28b190 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_analysis.py +++ b/resources/tester/spreadsheet/test_spreadsheet_analysis.py @@ -3,7 +3,8 @@ 선행 · 종속 추적(누를 때마다 한 단계 · 범위 화살 · 오류 칸 빨강 · 다른 시트 · 더 없으면 false) · 수식 계산 걸음(왼쪽 먼저 · 빈 칸 0 · 범위는 함수가 · IF 첫 인자만 · 괄호 안 밑줄 · 오류 전파) · -오류 검사(순환 · 식 결과 오류 · 숫자처럼 보이는 글). +오류 검사(순환 · 식 결과 오류 · 숫자처럼 보이는 글) · +이름 정의 풀이(범위로 · 자기 시트 먼저 · 바꾸기 · 지우기 · 행열 넣기 · 지우기 때 범위 따라감 · 없는 이름 #NAME?). 화면(화살 겹판 · 계산 창 · ⚠ 풍선)은 harness_analysis.html 을 ORCA 로. """ @@ -14,7 +15,7 @@ import subprocess import pytest HERE = os.path.dirname(os.path.abspath(__file__)) -GROUPS = ["trace", "eval_steps", "errcheck"] +GROUPS = ["trace", "eval_steps", "errcheck", "names"] @pytest.fixture(scope="module") diff --git a/resources/tester/spreadsheet/test_stage2_data_tools.ts b/resources/tester/spreadsheet/test_stage2_data_tools.ts new file mode 100644 index 00000000..515c5f0e --- /dev/null +++ b/resources/tester/spreadsheet/test_stage2_data_tools.ts @@ -0,0 +1,113 @@ +/* ============================================================================= + * test_stage2_data_tools.ts — spreadsheet_data_tools 시험(텍스트 나누기 · 중복 제거 · 연속 채우기). + * 돌리기: npx tsx <이 파일> (`@ui/*` 별칭 때문에 plain node 안 됨) + * ========================================================================== */ + +import { + applyFillSeries, + applyTextToColumns, + buildLinearSeries, + findDuplicateRowIndexes, + removeDuplicateRows, + splitDelimited, + splitFixedWidth, +} from "../../../A00_Common/spreadsheet/spreadsheet_data_tools.ts"; +import { a1, assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts"; + +// ── 텍스트 나누기 ────────────────────────────────────────────── +assertEqual(splitDelimited("가,나,다", ["쉼표"], ""), ["가", "나", "다"], "쉼표 나누기"); +assertEqual(splitDelimited("가\t나", ["탭"], ""), ["가", "나"], "탭 나누기"); +assertEqual( + splitDelimited("가;나,다", ["세미콜론", "쉼표"], ""), + ["가", "나", "다"], + "복수 구분 기호", +); +assertEqual(splitDelimited("가|나", ["사용자"], "|"), ["가", "나"], "사용자 지정 구분 기호"); +assertEqual(splitDelimited("가나다", [], ""), ["가나다"], "구분 기호 없으면 한 칸 그대로"); + +assertEqual(splitFixedWidth("2026-09-27", [4, 7]), ["2026", "-09", "-27"], "고정 폭 나누기"); +assertEqual(splitFixedWidth("ABC", [10]), ["ABC"], "자리가 글보다 길면 한 칸"); + +{ + const book = makeBook([makeSheet("s1", {})]); + const ctx = makeCtx(book, "s1"); + applyTextToColumns(ctx, { r: 0, c: 0 }, [ + ["가", "나"], + ["1", "2"], + ]); + assertEqual(book.시트[0].칸[a1(0, 0)], { 값: "가" }, "나누기 결과 A1"); + assertEqual(book.시트[0].칸[a1(0, 1)], { 값: "나" }, "나누기 결과 B1"); + assertEqual(book.시트[0].칸[a1(1, 0)], { 값: 1 }, "나누기 결과 숫자는 수로"); + assertEqual(book.시트[0].칸[a1(1, 1)], { 값: 2 }, "나누기 결과 B2"); +} + +// ── 중복된 항목 제거 ────────────────────────────────────────── +assertEqual( + findDuplicateRowIndexes( + [ + ["가", "1"], + ["나", "2"], + ["가", "1"], + ["다", "3"], + ["나", "2"], + ], + [0, 1], + ), + [2, 4], + "중복 자리 찾기(모든 열 비교)", +); +assertEqual( + findDuplicateRowIndexes( + [ + ["가", "1"], + ["가", "9"], + ], + [0], + ), + [1], + "일부 열만 비교", +); + +{ + const book = makeBook([ + makeSheet("s1", { + [a1(0, 0)]: v("가"), + [a1(1, 0)]: v("나"), + [a1(2, 0)]: v("가"), + [a1(3, 0)]: v("다"), + }), + ]); + const ctx = makeCtx(book, "s1", { + s1: { A1: "가", A2: "나", A3: "가", A4: "다" }, + }); + const removed = removeDuplicateRows(ctx, { r0: 0, c0: 0, r1: 3, c1: 0 }, [0], false); + assertEqual(removed, 1, "지운 중복 행 수"); + assertEqual(book.시트[0].칸[a1(0, 0)], { 값: "가" }, "중복 제거 뒤 A1 그대로"); + assertEqual(book.시트[0].칸[a1(1, 0)], { 값: "나" }, "중복 제거 뒤 A2 그대로"); + assertEqual(book.시트[0].칸[a1(2, 0)], { 값: "다" }, "중복 제거 뒤 위로 당김"); + assertEqual(book.시트[0].칸[a1(3, 0)], undefined, "중복 제거 뒤 마지막 칸 비움"); +} + +// ── 채우기 연속 데이터 ────────────────────────────────────────── +assertEqual(buildLinearSeries(1, 1, null, 5), [1, 2, 3, 4, 5], "단계 1 · 5개"); +assertEqual(buildLinearSeries(0, 2, 6, 100), [0, 2, 4, 6], "끝값에서 멈춤"); +assertEqual(buildLinearSeries(10, -2, 4, 100), [10, 8, 6, 4], "내림차순 끝값에서 멈춤"); + +{ + const book = makeBook([makeSheet("s1", {})]); + const ctx = makeCtx(book, "s1"); + applyFillSeries(ctx, { r: 0, c: 0 }, "열", [1, 2, 3]); + assertEqual(book.시트[0].칸[a1(0, 0)], { 값: 1 }, "세로 채우기 A1"); + assertEqual(book.시트[0].칸[a1(1, 0)], { 값: 2 }, "세로 채우기 A2"); + assertEqual(book.시트[0].칸[a1(2, 0)], { 값: 3 }, "세로 채우기 A3"); +} +{ + const book = makeBook([makeSheet("s1", {})]); + const ctx = makeCtx(book, "s1"); + applyFillSeries(ctx, { r: 0, c: 0 }, "행", [1, 2]); + assertEqual(book.시트[0].칸[a1(0, 0)], { 값: 1 }, "가로 채우기 A1"); + assertEqual(book.시트[0].칸[a1(0, 1)], { 값: 2 }, "가로 채우기 B1"); +} + +if (process.exitCode) process.exit(process.exitCode); +console.log("spreadsheet_data_tools 시험 끝"); 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 시험 끝");