diff --git a/A00_Common/spreadsheet/spreadsheet.css b/A00_Common/spreadsheet/spreadsheet.css index 1266725d..9f068cd6 100644 --- a/A00_Common/spreadsheet/spreadsheet.css +++ b/A00_Common/spreadsheet/spreadsheet.css @@ -109,3 +109,151 @@ background: #24292f; color: #fff; } + +/* 2단계 잇기 — 서식 붓 · 찾기 패널 · 메모 · 골라 붙여넣기 */ +.ss-brush { + flex: none; + padding: 0 8px; + border: none; + border-right: 1px solid #d0d7de; + background: none; + font: inherit; + cursor: pointer; +} + +.ss-brush.is-on { + background: #dafbe1; + box-shadow: inset 0 0 0 2px #217346; +} + +.ss--painting .ss-grid-host { + cursor: copy; +} + +.ss-find { + position: absolute; + top: 4px; + right: 16px; + z-index: 20; + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px; + border: 1px solid #d0d7de; + border-radius: 6px; + background: #fff; + box-shadow: 0 4px 12px rgb(0 0 0 / 15%); +} + +.ss-find[hidden], +.ss-find__row[hidden], +.ss-memo[hidden], +.ss-pick[hidden], +.spreadsheet-comment-balloon[hidden] { + display: none; +} + +.ss-find__row { + display: flex; + align-items: center; + gap: 4px; +} + +.ss-find__input { + width: 180px; + padding: 2px 6px; + border: 1px solid #d0d7de; + font: inherit; +} + +.ss-find__btn { + padding: 2px 8px; + border: 1px solid #d0d7de; + border-radius: 4px; + background: #f6f8fa; + font: inherit; + cursor: pointer; +} + +.ss-find__opt { + display: flex; + align-items: center; + gap: 2px; + font-size: 12px; +} + +.ss-find__status { + font-size: 12px; + color: #57606a; +} + +.ss-memo, +.spreadsheet-comment-balloon { + position: absolute; + z-index: 15; + min-width: 140px; + max-width: 260px; + padding: 4px 6px; + border: 1px solid #bf8700; + background: #fff8c5; + font: inherit; + white-space: pre-wrap; + box-shadow: 0 2px 6px rgb(0 0 0 / 15%); +} + +.ss-memo { + min-height: 60px; + resize: both; + outline: none; +} + +.ss-pick { + position: absolute; + z-index: 20; + display: flex; + gap: 2px; + padding: 4px; + border: 1px solid #d0d7de; + background: #fff; + box-shadow: 0 4px 12px rgb(0 0 0 / 15%); +} + +.ss-pick > button { + padding: 2px 8px; + border: 1px solid #d0d7de; + background: #f6f8fa; + font: inherit; + cursor: pointer; +} + +/* 함수 자동 완성 · 인자 도움말 */ +.ss-hint { + position: absolute; + z-index: 6; + min-width: 200px; + max-width: 360px; + border: 1px solid #d0d7de; + background: #fff; + font-size: 12px; + box-shadow: 0 4px 12px rgb(0 0 0 / 15%); +} + +.ss-hint[hidden] { + display: none; +} + +.ss-hint__item, +.ss-hint__help { + padding: 2px 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ss-hint__item { + cursor: pointer; +} + +.ss-hint__item.is-on { + background: #ddf4ff; +} diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 25f43b0e..1597924a 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -7,6 +7,7 @@ * ⚠ M02 만 `import()` 로 불러옴 — 정적 import 금지(다른 페이지 번들에 안 섞이게). * 저장은 부른 쪽 몫(`onChange` 로 문서 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂). * 화면 차례: 도구 모음(E) · 주소 상자 + 수식 입력줄(D) · 격자(C) · 시트 탭(E). + * 2단계 부품(찾기 · 메모 · 서식 붓 · 골라 붙여넣기)은 `spreadsheet_extras.ts` 로 이음. * ========================================================================== */ import "./spreadsheet.css"; @@ -15,6 +16,7 @@ import { parseA1, rangeToA1, toA1 } from "./spreadsheet_address"; import { attachClipboard } from "./spreadsheet_clipboard"; import { applyCommand, CommandError } from "./spreadsheet_commands"; import { createEditor } from "./spreadsheet_editor"; +import { attachExtras } from "./spreadsheet_extras"; import { createCalcEngine } from "./spreadsheet_graph"; import { createGrid } from "./spreadsheet_grid"; import { createHistory } from "./spreadsheet_history"; @@ -70,18 +72,6 @@ export interface SpreadsheetHandle { destroy(): void; } -/** 이어 붙이는 남의 부품 — 시험 틀이 빈 몸 부품을 가짜로 바꿔 끼우는 자리(화면 코드는 그대로 씀) */ -export const parts = { - createCalcEngine, - createHistory, - applyCommand, - createGrid, - mountToolbar, - mountTabs, - attachMenu, - attachClipboard, -}; - /** 움직이는 끝(닻에서 먼 쪽) — 행열 전체면 활성 칸 쪽 */ function farCorner(sel: Selection): { r: number; c: number } { const g = sel.범위[0]; @@ -117,8 +107,8 @@ export function createSpreadsheet( root, book, readOnly, - engine: parts.createCalcEngine(book), - history: parts.createHistory(), + engine: createCalcEngine(book), + history: createHistory(), selection: selectCell(firstSheet(book), 0, 0), sheet: () => ctx.book.시트.find((s) => s.id === ctx.selection.시트) ?? ctx.book.시트[0], dispatch, @@ -130,20 +120,22 @@ export function createSpreadsheet( editor: null as unknown as EditorHandle, }; - const grid = parts.createGrid(ctx); + const grid = createGrid(ctx); ctx.grid = grid; const ed = createEditor(ctx); ctx.editor = ed; - const toolbar: PartHandle | null = readOnly ? null : parts.mountToolbar(ctx); - const tabs = parts.mountTabs(ctx); - const menu: PartHandle | null = readOnly ? null : parts.attachMenu(ctx); - const clip = parts.attachClipboard(ctx); + const toolbar: PartHandle | null = readOnly ? null : mountToolbar(ctx); + const tabs = mountTabs(ctx); + const menu: PartHandle | null = readOnly ? null : attachMenu(ctx); + const clip = attachClipboard(ctx); const others = [toolbar, tabs, menu, clip].filter((p): p is PartHandle => !!p); grid.root.classList.add("ss-grid-host"); for (const node of [toolbar?.root, ed.bar, grid.root, tabs.root]) if (node) root.append(node); - const detachKeys = attachKeys(ctx, ed); - const detachMouse = attachMouse(ctx, ed); + const extras = attachExtras(ctx, ed, toolbar?.root ?? null); + others.push(...extras.parts); + const detachKeys = attachKeys(ctx, ed, extras.keys); + const detachMouse = attachMouse(ctx, ed, extras.mouse); const toast = el("div", { className: "ss-toast" }); let toastTimer = 0; @@ -160,7 +152,7 @@ export function createSpreadsheet( function apply(command: Command): CommandEffect | null { try { - return parts.applyCommand(ctx.book, command); + return applyCommand(ctx.book, command); } catch (e) { if (e instanceof CommandError) return (notify(e.message), null); throw e; diff --git a/A00_Common/spreadsheet/spreadsheet_commands.ts b/A00_Common/spreadsheet/spreadsheet_commands.ts index 5eee4ddb..e89a56a8 100644 --- a/A00_Common/spreadsheet/spreadsheet_commands.ts +++ b/A00_Common/spreadsheet/spreadsheet_commands.ts @@ -18,7 +18,15 @@ import { rangeToA1, toA1, } from "./spreadsheet_address"; +import { + addSheet, + moveSheet, + removeSheet, + renameSheet, + replaceSheet, +} from "./spreadsheet_commands_sheet"; import { shiftForCommand, shiftSpan } from "./spreadsheet_refshift"; +import { applyFilter, applySort, shiftFilter } from "./spreadsheet_sortfilter"; import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; import type { Cell, @@ -34,11 +42,11 @@ import type { /** 막힌 명령(병합 가름 · 마지막 시트 지우기 · 같은 시트 이름 …) — 화면이 `message` 를 알림 */ export class CommandError extends Error {} -type Of = Extract; +export type Of = Extract; -const clone = (x: T): T => structuredClone(x); +export const clone = (x: T): T => structuredClone(x); -function sheetOf(book: Workbook, id: string): Sheet { +export function sheetOf(book: Workbook, id: string): Sheet { const s = book.시트.find((x) => x.id === id); if (!s) throw new CommandError(`없는 시트: ${id}`); return s; @@ -107,7 +115,7 @@ function setSheetDefault(s: Sheet, key: "서식" | "열폭" | "행높이", value const addr = (시트: string, r: number, c: number): SheetCellAddress => ({ 시트, r, c }); /** 바뀐 시트만 통째 되돌림 짝 */ -function restoreChanged(book: Workbook, old: Sheet[]): Command[] { +export function restoreChanged(book: Workbook, old: Sheet[]): Command[] { const out: Command[] = []; for (const o of old) { const now = book.시트.find((x) => x.id === o.id); @@ -117,20 +125,12 @@ function restoreChanged(book: Workbook, old: Sheet[]): Command[] { } /** 모든 식 글을 명령에 맞춰 고침 — 시트 이름을 바꾸기 전에 부름 */ -function rewriteAll(book: Workbook, command: Command) { +export 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 { @@ -332,6 +332,7 @@ function insertDelete( } } + shiftFilter(s, rows, insert, at, n); const key = rows ? "행" : "열"; const frozen = s.틀고정?.[key]; if (frozen && at < frozen) @@ -484,61 +485,6 @@ function sheetSize(s: Sheet, axis: "행" | "열", value: number | null): Command 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 { @@ -644,6 +590,10 @@ export function applyCommand(book: Workbook, command: Command): CommandEffect { return moveSheet(book, command); case "시트통째": return replaceSheet(book, command); + case "정렬": + return applySort(book, command); + case "필터": + return applyFilter(book, command); case "묶음": return batch(book, command); } diff --git a/A00_Common/spreadsheet/spreadsheet_commands_sheet.ts b/A00_Common/spreadsheet/spreadsheet_commands_sheet.ts new file mode 100644 index 00000000..e4ac3e5d --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_commands_sheet.ts @@ -0,0 +1,69 @@ +/* ============================================================================= + * spreadsheet_commands_sheet.ts (주인 A) + * 시트 명령 — 더하기 · 지우기 · 이름 · 옮기기 · 통째 바꿔 넣기. 들머리는 `applyCommand`(commands). + * ========================================================================== */ + +import { clone, CommandError, restoreChanged, rewriteAll, sheetOf } from "./spreadsheet_commands"; +import type { Of } from "./spreadsheet_commands"; +import type { Command, CommandEffect, Workbook } from "./spreadsheet_types"; + +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}`); +} + +export 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 }; +} + +export 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 }; +} + +export 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 }; +} + +export 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 }; +} + +export 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 }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_editor.ts b/A00_Common/spreadsheet/spreadsheet_editor.ts index 08411ba7..ed3b9e90 100644 --- a/A00_Common/spreadsheet/spreadsheet_editor.ts +++ b/A00_Common/spreadsheet/spreadsheet_editor.ts @@ -11,6 +11,7 @@ import { el } from "@ui/ui_template_elements"; import { moveFormula } from "./spreadsheet_refshift"; import { colName, parseA1, parseRange, rangeToA1, toA1 } from "./spreadsheet_address"; +import { funcHelp, suggestFunctions, type FuncHelp } from "./spreadsheet_func_help"; import { tokenize } from "./spreadsheet_parser"; import { cellsOf, move, selectCell } from "./spreadsheet_selection"; import type { Cell, CellAddress, CellStyle, Sheet } from "./spreadsheet_types"; @@ -36,6 +37,10 @@ export interface Editor extends EditorHandle { point(sel: Selection): void; /** 방향키 가리키기 — 지금 가리킨 곳(없으면 편집 칸)에서 */ pointMove(dr: number, dc: number, extend: boolean, jump: boolean): void; + /** 함수 자동 완성 목록이 떠 있으면 고른 이름을 넣음(Tab) */ + accept(): boolean; + /** 자동 완성 목록 안 위아래(목록이 없으면 false) */ + suggestMove(d: number): boolean; /** 고름 칸 전부에 같은 입력(Ctrl+Enter) — 식은 활성 칸 기준 상대 이동 */ commitAll(): void; /** 초점 되찾기(스크롤 없이) */ @@ -140,7 +145,45 @@ function styleAt(ctx: SpreadsheetContext, sheet: Sheet, r: number, c: number): C return ctx.book.서식[idx] ?? {}; } -/** editorSlot 칸 자리 → 층 안 자리 */ +/** 식 글 caret 앞 → 함수 이름 치는 중(prefix) · 함수 인자 안(func · 몇 번째 인자 0 부터) · 아님 null */ +export function formulaHint( + pre: string, +): { prefix: string } | { func: string; arg: number } | null { + if (pre[0] !== "=") return null; + const m = /(?:^=|[(,+\-*/^&<>:;= ])([A-Za-z][A-Za-z0-9.]*)$/.exec(pre); + if (m && !/^\$?[A-Za-z]{1,3}\$?\d+$/.test(m[1])) return { prefix: m[1] }; + let depth = 0; + let arg = 0; + let inStr = false; + for (let i = pre.length - 1; i > 0; i--) { + const ch = pre[i]; + if (ch === '"') inStr = !inStr; + if (inStr) continue; + if (ch === ")") depth++; + else if (ch === "," && depth === 0) arg++; + else if (ch === "(") { + if (depth-- > 0) continue; + const n = /([A-Za-z][A-Za-z0-9.]*)$/.exec(pre.slice(0, i)); + return n ? { func: n[1].toUpperCase(), arg } : null; + } + } + return null; +} + +/** 편집기 · 미리보기 자리 — C `editorSlot` 의 box 는 격자 뿌리 기준이라 층(layer)이 뿌리 안에서 + * 비켜 놓인 만큼 빼서 층 안 좌표로 맞춤. */ +export function slotOf( + ctx: SpreadsheetContext, + r: number, + c: number, +): { layer: HTMLElement; box: CellBox } { + const { layer, box } = ctx.grid.editorSlot(r, c); + const a = layer.getBoundingClientRect(); + const g = ctx.grid.root.getBoundingClientRect(); + return { layer, box: { ...box, x: box.x - (a.left - g.left), y: box.y - (a.top - g.top) } }; +} + +/** 칸 자리 → 층 안 자리 */ export function boxStyle(node: HTMLElement, box: CellBox): void { node.style.left = `${box.x}px`; node.style.top = `${box.y}px`; @@ -178,7 +221,7 @@ export function createEditor(ctx: SpreadsheetContext): Editor { function place(): void { const at = st?.at ?? ctx.selection.활성; - const { layer, box } = ctx.grid.editorSlot(at.r, at.c); + const { layer, box } = slotOf(ctx, at.r, at.c); if (ta.parentElement !== layer) { const had = document.activeElement === ta; layer.append(ta); @@ -206,6 +249,56 @@ export function createEditor(ctx: SpreadsheetContext): Editor { function highlight(): void { const t = st ? text() : ""; ctx.grid.setRefHighlights(st ? refHighlights(t, st.sheet, sheetId) : []); + showHint(); + } + + // ── 함수 자동 완성 · 인자 도움말 (spreadsheet_func_help · sub1) ──────────────── + const hint = el("div", { className: "ss-hint", attrs: { hidden: "" } }); + let sugg: FuncHelp[] = []; + let pick = 0; + let typed = ""; + + function showHint(): void { + const node = src(); + const h = st ? formulaHint(node.value.slice(0, node.selectionEnd)) : null; + sugg = h && "prefix" in h ? suggestFunctions(h.prefix).slice(0, 8) : []; + typed = h && "prefix" in h ? h.prefix : ""; + const help = h && "func" in h ? funcHelp(h.func) : null; + if (!sugg.length && !help) return void hint.setAttribute("hidden", ""); + pick = Math.min(pick, Math.max(0, sugg.length - 1)); + hint.replaceChildren( + ...(sugg.length + ? sugg.map((f, i) => { + const row = el("div", { + className: i === pick ? "ss-hint__item is-on" : "ss-hint__item", + children: [el("b", { text: f.이름 }), ` ${f.뜻}`], + }); + row.addEventListener("mousedown", (e) => { + e.preventDefault(); + pick = i; + accept(); + }); + return row; + }) + : [el("div", { className: "ss-hint__help", text: `${help!.꼴} — ${help!.뜻}` })]), + ); + if (hint.parentElement !== ta.parentElement) ta.parentElement?.append(hint); + hint.style.left = ta.style.left; + hint.style.top = `${parseFloat(ta.style.top || "0") + ta.offsetHeight}px`; + hint.removeAttribute("hidden"); + } + + function accept(): boolean { + if (!st || !sugg.length) return false; + const node = src(); + const pos = node.selectionEnd; + const name = `${sugg[pick].이름}(`; + node.setRangeText(name, pos - typed.length, pos, "end"); + (node === ta ? formula : ta).value = node.value; + pick = 0; + place(); + highlight(); + return true; } /** 편집 시작 — `value` 없으면 textarea 에 이미 든 글(조합 · 바로 친 글자) 그대로 */ @@ -231,6 +324,7 @@ export function createEditor(ctx: SpreadsheetContext): Editor { ta.removeAttribute("style"); ctx.root.classList.remove("ss--editing"); ctx.grid.setRefHighlights([]); + hint.setAttribute("hidden", ""); sync(); focus(); } @@ -389,11 +483,19 @@ export function createEditor(ctx: SpreadsheetContext): Editor { pointable, point, pointMove, + accept, + suggestMove(d: number) { + if (!st || !sugg.length) return false; + pick = (pick + d + sugg.length) % sugg.length; + showHint(); + return true; + }, focus, place() { if (st) place(); }, destroy() { + hint.remove(); ta.remove(); bar.remove(); }, diff --git a/A00_Common/spreadsheet/spreadsheet_extras.ts b/A00_Common/spreadsheet/spreadsheet_extras.ts new file mode 100644 index 00000000..bcb24ea3 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_extras.ts @@ -0,0 +1,208 @@ +/* ============================================================================= + * spreadsheet_extras.ts (주인 D) + * 2단계 부품 잇기(sub1 파일들의 「잇는 법」) — 찾기 패널 · 메모 풍선(마우스 올림) · 메모 편집(Shift+F2) · + * 서식 붓(수식 입력줄 앞 단추 · 두 번 누르면 계속 · Esc 끔) · 골라 붙여넣기(Ctrl+Shift+V → 값만 · 서식만 · 수식만) · + * 도구 모음을 누른 뒤 초점을 격자로 되돌림. + * 빨간 세모는 C 격자 · 우클릭 [메모 …] 는 E 메뉴 몫(브레인 배정). + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { parseClipboard } from "./spreadsheet_clipboard"; +import { attachComments, getComment, setComment } from "./spreadsheet_comments"; +import type { Editor } from "./spreadsheet_editor"; +import { mountFindPanel } from "./spreadsheet_find_panel"; +import { createFormatPainter } from "./spreadsheet_format_painter"; +import type { KeyHooks } from "./spreadsheet_keys"; +import type { MouseHooks } from "./spreadsheet_mouse"; +import { pasteSpecial, type PasteSpecialMode } from "./spreadsheet_paste_special"; +import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; + +const TEXT = { + painter: "서식 붓 — 한 번 누르면 한 번 · 두 번 누르면 Esc 까지 계속", + pasteTitle: "골라 붙여넣기", + modes: { 값: "값만", 서식: "서식만", 수식: "수식만" } as Record, + memo: "메모 — Ctrl+Enter 또는 밖을 누르면 적용 · Esc 취소", +}; + +export interface Extras { + keys: KeyHooks; + mouse: MouseHooks; + parts: PartHandle[]; +} + +export function attachExtras( + ctx: SpreadsheetContext, + ed: Editor, + toolbarRoot: HTMLElement | null, +): Extras { + const back = (): void => ed.focus(); + /** 칸 오른쪽 자리(부품 뿌리 기준 px) — C editorSlot box 는 격자 뿌리 기준 */ + const beside = (r: number, c: number): { x: number; y: number } => { + const { box } = ctx.grid.editorSlot(r, c); + const g = ctx.grid.root.getBoundingClientRect(); + const o = ctx.root.getBoundingClientRect(); + return { x: box.x + box.w + g.left - o.left + 4, y: box.y + g.top - o.top }; + }; + const put = (node: HTMLElement, r: number, c: number): void => { + const p = beside(r, c); + node.style.left = `${p.x}px`; + node.style.top = `${p.y}px`; + }; + + // ── 찾기 ─────────────────────────────────────────────────────────────── + const find = mountFindPanel(ctx, back); + + // ── 메모 — 풍선 · 편집 ───────────────────────────────────────────────── + const comments = attachComments(ctx); + let hovered = ""; + const memo = el("textarea", { className: "ss-memo", attrs: { hidden: "", title: TEXT.memo } }); + ctx.root.append(memo); + let memoAt: { r: number; c: number } | null = null; + const closeMemo = (save: boolean): void => { + if (!memoAt) return; + const at = memoAt; + memoAt = null; + memo.setAttribute("hidden", ""); + if (save && memo.value !== (getComment(ctx.sheet(), at.r, at.c) ?? "")) + setComment(ctx, at.r, at.c, memo.value.trim()); + back(); + }; + memo.addEventListener("keydown", (e) => { + if (e.key === "Escape" || (e.key === "Enter" && e.ctrlKey)) { + e.preventDefault(); + e.stopPropagation(); + closeMemo(e.key === "Enter"); + } + }); + memo.addEventListener("blur", () => closeMemo(true)); + + function editMemo(): void { + if (ctx.readOnly) return; + const { r, c } = ctx.selection.활성; + memoAt = { r, c }; + comments.hideBalloon(); + memo.value = getComment(ctx.sheet(), r, c) ?? ""; + memo.removeAttribute("hidden"); + put(memo, r, c); + memo.focus(); + } + + // ── 서식 붓 ───────────────────────────────────────────────────────────── + const painter = createFormatPainter(ctx); + let sticky = false; + const brush = el("button", { + className: "ss-brush", + text: "붓", + attrs: { type: "button", title: TEXT.painter }, + }); + const showBrush = (): void => { + brush.classList.toggle("is-on", painter.active()); + ctx.root.classList.toggle("ss--painting", painter.active()); + }; + brush.addEventListener("click", (e) => { + if (e.detail === 2) sticky = true; + else if (painter.active()) (painter.clear(), (sticky = false)); + else (painter.pick(), (sticky = false)); + showBrush(); + back(); + }); + if (!ctx.readOnly) ed.bar.prepend(brush); + + // ── 골라 붙여넣기 ───────────────────────────────────────────────────────── + let armed = false; + const picker = el("div", { className: "ss-pick", attrs: { hidden: "", title: TEXT.pasteTitle } }); + ctx.root.append(picker); + const onPaste = (e: ClipboardEvent): void => { + if (!armed || ed.editing()) return; + armed = false; + e.preventDefault(); + e.stopPropagation(); // E 클립보드의 보통 붙여넣기를 막음 + const at = { ...ctx.selection.활성 }; + const data = e.clipboardData; + const block = parseClipboard( + data?.getData("text/html") ?? "", + data?.getData("text/plain") ?? "", + at, + ); + if (!block) return; + // ponytail: 우리 복사면 원본을 같은 시트로 봄(ClipBlock 에 시트가 없음) — 값만 때 식 칸 계산값용 + const source = + block.출처 === "aislo" && block.원점 + ? { 시트: ctx.selection.시트, ...block.원점 } + : undefined; + picker.replaceChildren( + ...(Object.keys(TEXT.modes) as PasteSpecialMode[]).map((mode) => { + const b = el("button", { + text: TEXT.modes[mode], + attrs: { type: "button", "data-mode": mode }, + }); + b.addEventListener("click", () => { + picker.setAttribute("hidden", ""); + pasteSpecial(ctx, block, at, mode, source); + back(); + }); + return b; + }), + ); + picker.removeAttribute("hidden"); + put(picker, at.r, at.c); + (picker.firstElementChild as HTMLElement).focus(); + }; + picker.addEventListener("keydown", (e) => { + if (e.key !== "Escape") return; + e.preventDefault(); + picker.setAttribute("hidden", ""); + back(); + }); + ctx.root.addEventListener("paste", onPaste, true); + + // ── 도구 모음 뒤 초점 ─────────────────────────────────────────────────── + const onToolbarClick = (e: Event): void => { + if ((e.target as HTMLElement).closest("button")) back(); + }; + toolbarRoot?.addEventListener("click", onToolbarClick); + toolbarRoot?.addEventListener("change", back); + + const self: PartHandle = { + root: null, + refresh: () => showBrush(), + destroy() { + ctx.root.removeEventListener("paste", onPaste, true); + toolbarRoot?.removeEventListener("click", onToolbarClick); + toolbarRoot?.removeEventListener("change", back); + memo.remove(); + picker.remove(); + brush.remove(); + }, + }; + + return { + keys: { + find: (replace) => find.open(replace), + pasteSpecial: () => (armed = true), + comment: editMemo, + escape() { + painter.clear(); + sticky = false; + showBrush(); + }, + }, + mouse: { + selected() { + if (!painter.active()) return; + painter.paint(ctx.selection.범위[0], sticky); + showBrush(); + }, + hover(hit) { + const key = hit?.kind === "cell" ? `${hit.r},${hit.c}` : ""; + if (key === hovered) return; + hovered = key; + if (!hit || !key || memoAt || !getComment(ctx.sheet(), hit.r, hit.c)) + return comments.hideBalloon(); + comments.showBalloon(hit.r, hit.c); + put(comments.root!, hit.r, hit.c); // showBalloon 의 cellBox 자리 → 화면 자리로 고침 + }, + }, + parts: [find, comments, self], + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_filter.ts b/A00_Common/spreadsheet/spreadsheet_filter.ts new file mode 100644 index 00000000..328267d2 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_filter.ts @@ -0,0 +1,232 @@ +/* ============================================================================= + * spreadsheet_filter.ts (주인 A · 화면) + * 자동 필터 — 머리 행 칸마다 ▼ 단추 · 값 목록 창(모두 선택 · 값마다 체크 · 이 열 정렬 · 이 열 필터 지움). + * 걸러진 행은 엔진이 `행.걸러짐` 으로 적음(사용자 숨김과 따로) · 명령은 `필터`(spreadsheet_sortfilter.ts). + * + * 잇는 법(D · sub7 · C · sub6): + * · 부품 붙이기: `const filter = attachFilter(ctx)` — 단추 층을 격자 overlay(editorSlot 층)에 얹음. + * grid.render() 뒤마다 `filter.refresh()`(단추 자리 · 켜짐 표시) · 스크롤은 스스로 따라감. + * · 도구 단추 「필터」 · Ctrl+Shift+L → `toggleFilter(ctx)`(켜기 = 고른 범위 · 칸 하나면 현재 영역 / 끄기). + * · Alt+↓(활성 칸이 머리 행이면) → `filter.open(활성 열)`. + * · 격자(C): 행 `걸러짐` 을 `숨김` 처럼 높이 0 으로 — 머리의 숨김 표시(두 줄)는 사용자 `숨김` 만. + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { colName, parseRange, rangeToA1 } from "./spreadsheet_address"; +import { CommandError } from "./spreadsheet_commands"; +import { currentRegion, filterCommand, filterItems, sortCommand } from "./spreadsheet_sortfilter"; +import type { AutoFilter, Command } from "./spreadsheet_types"; +import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; +import "./spreadsheet_sortfilter.css"; + +const TEXT = { + Button: ["필터", "Filter"], + All: ["(모두 선택)", "(Select all)"], + Blank: ["(빈 칸)", "(Blanks)"], + SortAsc: ["오름차순 정렬", "Sort ascending"], + SortDesc: ["내림차순 정렬", "Sort descending"], + Clear: ["이 열 필터 지우기", "Clear filter from column"], + Ok: ["확인", "OK"], + Cancel: ["취소", "Cancel"], +}; +const t = (key: keyof typeof TEXT) => TEXT[key][currentLanguageIndex] ?? TEXT[key][0]; + +function send(ctx: SpreadsheetContext, make: () => Command): void { + try { + ctx.dispatch(make()); + } catch (e) { + if (!(e instanceof CommandError)) throw e; + showToast(e.message, "error"); + } +} + +/** 필터 켜기 · 끄기 */ +export function toggleFilter(ctx: SpreadsheetContext): void { + if (ctx.readOnly) return; + const s = ctx.sheet(); + if (s.필터) return send(ctx, () => filterCommand(ctx.book, ctx.engine, s.id, null)); + const g = ctx.selection.범위[0]; + const rg = g.r0 === g.r1 && g.c0 === g.c1 ? currentRegion(ctx.book, s.id, g.r0, g.c0) : g; + send(ctx, () => ({ 종류: "필터", 시트: s.id, 필터: { 범위: rangeToA1(rg) }, 걸러짐: [] })); +} + +export interface FilterHandle extends PartHandle { + /** 열 `c` 값 목록 창 열기(필터 범위 안일 때) */ + open(c: number): void; +} + +export function attachFilter(ctx: SpreadsheetContext): FilterHandle { + const layer = el("div", { className: "ss-filter__layer" }); + let panel: HTMLElement | null = null; + + const filterOf = () => { + const f = ctx.sheet().필터; + const rg = f && parseRange(f.범위); + return f && rg ? { f, rg } : null; + }; + + /** 칸 자리 → 단추 층 안 자리(editorSlot 상자는 격자 뿌리 기준 — 층 원점과 다를 수 있어 실제 자리로 맞춤) */ + function boxIn(r: number, c: number) { + const { layer: host, box } = ctx.grid.editorSlot(r, c); + const a = host.getBoundingClientRect(); + const g = ctx.grid.root.getBoundingClientRect(); + return { ...box, x: box.x - (a.left - g.left), y: box.y - (a.top - g.top) }; + } + + /** 머리 칸이 지금 보이나(틀고정 판이면 늘 보임) */ + function shown(r: number, c: number): boolean { + const v = ctx.grid.visibleRange(); + const fz = ctx.sheet().틀고정 ?? {}; + const rowOk = r < (fz.행 ?? 0) || (r >= v.r0 && r <= v.r1); + const colOk = c < (fz.열 ?? 0) || (c >= v.c0 && c <= v.c1); + return rowOk && colOk; + } + + function place(): void { + const { layer: host } = ctx.grid.editorSlot(0, 0); + if (layer.parentElement !== host) host.append(layer); + const cur = filterOf(); + const btns: HTMLElement[] = []; + if (cur) + for (let c = cur.rg.c0; c <= cur.rg.c1; c++) { + const box = boxIn(cur.rg.r0, c); + if (!box.w || !box.h || !shown(cur.rg.r0, c)) continue; + const on = !!cur.f.조건?.[colName(c)]; + const b = el("button", { + className: on ? "ss-filter__btn is-on" : "ss-filter__btn", + text: "▼", + attrs: { + type: "button", + "aria-label": `${t("Button")} ${colName(c)}`, + "data-col": String(c), + }, + }); + b.style.left = `${box.x + box.w - 17}px`; + b.style.top = `${box.y + box.h - 17}px`; + b.addEventListener("mousedown", (ev) => ev.stopPropagation()); + b.addEventListener("click", () => open(c)); + btns.push(b); + } + layer.replaceChildren(...btns, ...(panel ? [panel] : [])); + } + + function close(): void { + panel?.remove(); + panel = null; + document.removeEventListener("mousedown", onOutside, true); + } + + const onOutside = (ev: MouseEvent) => { + if (panel && !panel.contains(ev.target as Node)) close(); + }; + + function apply(next: AutoFilter): void { + close(); + send(ctx, () => filterCommand(ctx.book, ctx.engine, ctx.sheet().id, next)); + } + + function open(c: number): void { + const cur = filterOf(); + if (!cur || c < cur.rg.c0 || c > cur.rg.c1 || ctx.readOnly) return; + close(); + const sheetId = ctx.sheet().id; + const items = filterItems(ctx.book, ctx.engine, sheetId, c); + const boxes = items.map((it) => { + const cb = el("input", { attrs: { type: "checkbox" } }); + cb.checked = it.켬; + return cb; + }); + const all = el("input", { attrs: { type: "checkbox" } }); + const ok = createButton({ + label: t("Ok"), + onClick: () => { + const conds = { ...cur.f.조건 }; + if (boxes.every((b) => b.checked)) delete conds[colName(c)]; + else conds[colName(c)] = items.filter((_, i) => boxes[i].checked).map((it) => it.글); + apply({ ...cur.f, 조건: conds }); + }, + }); + const sync = () => { + all.checked = boxes.every((b) => b.checked); + all.indeterminate = !all.checked && boxes.some((b) => b.checked); + ok.disabled = !boxes.some((b) => b.checked); + }; + all.addEventListener("change", () => { + for (const b of boxes) b.checked = all.checked; + sync(); + }); + for (const b of boxes) b.addEventListener("change", sync); + const link = (label: string, run: () => void) => { + const a = el("button", { + className: "ss-filter__link", + text: label, + attrs: { type: "button" }, + }); + a.addEventListener("click", run); + return a; + }; + const sortBy = (desc: boolean) => () => { + close(); + send(ctx, () => + sortCommand(ctx.book, ctx.engine, sheetId, cur.rg, { + 머리: true, + 기준: [{ 열: c, 내림: desc }], + }), + ); + }; + const rest = { ...cur.f.조건 }; + delete rest[colName(c)]; + + panel = el("div", { + className: "ss-filter__panel", + attrs: { role: "dialog", "aria-label": `${t("Button")} ${colName(c)}` }, + children: [ + link(t("SortAsc"), sortBy(false)), + link(t("SortDesc"), sortBy(true)), + link(t("Clear"), () => apply({ ...cur.f, 조건: rest })), + el("div", { + className: "ss-filter__list", + children: [ + el("label", { children: [all, t("All")] }), + ...items.map((it, i) => + el("label", { children: [boxes[i], it.글 === "" ? t("Blank") : it.글] }), + ), + ], + }), + el("div", { + className: "ss-filter__foot", + children: [createButton({ label: t("Cancel"), variant: "ghost", onClick: close }), ok], + }), + ], + }); + panel.addEventListener("keydown", (ev) => { + if (ev.key === "Escape") close(); + ev.stopPropagation(); + }); + const box = boxIn(cur.rg.r0, c); + panel.style.left = `${box.x}px`; + panel.style.top = `${box.y + box.h}px`; + sync(); + layer.append(panel); + document.addEventListener("mousedown", onOutside, true); + } + + const onScroll = () => place(); + ctx.grid.root.addEventListener("scroll", onScroll, true); + place(); + + return { + root: layer, + refresh() { + close(); + place(); + }, + open, + destroy() { + close(); + ctx.grid.root.removeEventListener("scroll", onScroll, true); + layer.remove(); + }, + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_find_panel.ts b/A00_Common/spreadsheet/spreadsheet_find_panel.ts new file mode 100644 index 00000000..985ffd1b --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_find_panel.ts @@ -0,0 +1,131 @@ +/* ============================================================================= + * spreadsheet_find_panel.ts (주인 D) + * 찾기(Ctrl+F) · 바꾸기(Ctrl+H) 작은 패널 — 부품 오른위에 뜸. 찾기 규칙은 `spreadsheet_find.ts`(sub1). + * Enter = 다음 · Esc = 닫고 격자로. 바꾸기 = 활성 칸이 맞으면 바꾸고 다음으로(엑셀). + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { findAll, findNext, replaceAll, replaceOne, type FindOptions } from "./spreadsheet_find"; +import { selectCell } from "./spreadsheet_selection"; +import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; + +const TEXT = { + find: "찾을 내용", + replace: "바꿀 내용", + next: "다음", + one: "바꾸기", + all: "모두 바꾸기", + close: "닫기", + case: "대소문자", + whole: "전체 칸", + formula: "수식 안", + none: "찾을 수 없음", + replaced: "{n} 개 바꿈", +}; + +export interface FindPanelHandle extends PartHandle { + open(replace: boolean): void; + close(): void; +} + +export function mountFindPanel(ctx: SpreadsheetContext, back: () => void): FindPanelHandle { + const query = el("input", { className: "ss-find__input", attrs: { placeholder: TEXT.find } }); + const repl = el("input", { className: "ss-find__input", attrs: { placeholder: TEXT.replace } }); + const status = el("span", { className: "ss-find__status" }); + const button = (text: string, onClick: () => void): HTMLButtonElement => { + const b = el("button", { className: "ss-find__btn", text, attrs: { type: "button" } }); + b.addEventListener("click", onClick); + return b; + }; + const check = (text: string): HTMLInputElement => { + const box = el("input", { attrs: { type: "checkbox" } }); + options.append(el("label", { className: "ss-find__opt", children: [box, text] })); + return box; + }; + const closeBtn = button("×", close); + closeBtn.title = TEXT.close; + const options = el("div", { className: "ss-find__row" }); + const caseBox = check(TEXT.case); + const wholeBox = check(TEXT.whole); + const formulaBox = check(TEXT.formula); + const replaceRow = el("div", { + className: "ss-find__row", + children: [repl, button(TEXT.one, one), button(TEXT.all, all)], + }); + const root = el("div", { + className: "ss-find", + attrs: { hidden: "" }, + children: [ + el("div", { + className: "ss-find__row", + children: [query, button(TEXT.next, next), closeBtn], + }), + replaceRow, + options, + status, + ], + }); + ctx.root.append(root); + + const opts = (): FindOptions => ({ + 대소문자: caseBox.checked, + 전체칸: wholeBox.checked, + 수식안: formulaBox.checked, + }); + const here = () => ({ 시트: ctx.selection.시트, ...ctx.selection.활성 }); + + function next(): void { + status.textContent = ""; + if (!query.value) return; + const hit = findNext(ctx.book, here(), query.value, opts()); + if (!hit) return void (status.textContent = TEXT.none); + if (hit.시트 !== ctx.selection.시트) ctx.showSheet(hit.시트); + ctx.select(selectCell(ctx.sheet(), hit.r, hit.c), true); + } + + function one(): void { + if (ctx.readOnly || !query.value) return; + const at = here(); + const onIt = findAll(ctx.book, query.value, opts(), [at.시트]).some( + (h) => h.r === at.r && h.c === at.c, + ); + if (onIt) replaceOne(ctx, at, query.value, repl.value, opts()); + next(); + } + + function all(): void { + if (ctx.readOnly || !query.value) return; + const n = replaceAll(ctx, query.value, repl.value, opts()); + status.textContent = n ? TEXT.replaced.replace("{n}", String(n)) : TEXT.none; + } + + function open(replace: boolean): void { + replaceRow.hidden = !replace || ctx.readOnly; + root.removeAttribute("hidden"); + status.textContent = ""; + query.focus(); + query.select(); + } + + function close(): void { + root.setAttribute("hidden", ""); + back(); + } + + const onKey = (e: KeyboardEvent): void => { + if (e.key === "Escape") close(); + else if (e.key === "Enter") e.target === repl ? one() : next(); + else return; + e.preventDefault(); + e.stopPropagation(); + }; + root.addEventListener("keydown", onKey); + + return { + root, + open, + close, + refresh() {}, + destroy: () => root.remove(), + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_functions.ts b/A00_Common/spreadsheet/spreadsheet_functions.ts index d0cabc22..a92ef894 100644 --- a/A00_Common/spreadsheet/spreadsheet_functions.ts +++ b/A00_Common/spreadsheet/spreadsheet_functions.ts @@ -1,18 +1,21 @@ /* ============================================================================= * spreadsheet_functions.ts (주인 B) - * 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` 에 두고 여기서 모음. - * 1단계 약 45 — 수학 SUM PRODUCT SUMPRODUCT ROUND ROUNDUP ROUNDDOWN INT TRUNC CEILING FLOOR ABS MOD - * POWER SQRT PI SIN COS TAN ASIN ACOS ATAN RADIANS DEGREES MIN MAX AVERAGE · 논리 IF AND OR NOT IFERROR · - * 글 CONCATENATE FIXED TEXT LEN LEFT RIGHT MID VALUE · 찾기 VLOOKUP HLOOKUP INDEX MATCH CHOOSE · - * 세기 COUNT COUNTA COUNTIF SUMIF. 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만). + * 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` · `_lookup.ts` 에 + * 두고 여기서 모음. 1단계 약 45 + 2단계 첫 묶음(2026-09-27) — 아래 `FUNC_HELP_BATCH2`(sub1 의 + * `spreadsheet_func_help.ts` 가 읽어 이름 목록에 더할 것 — 그 파일은 sub1 몫이라 안 고침). * 없는 이름 = `#NAME?` · 인자 수가 min · max 밖이면 A 의 eval 이 `#VALUE!` + 까닭(엑셀은 입력을 막음). * ========================================================================== */ import type { SheetFunction } from "./spreadsheet_types"; import { MATH_FUNCTIONS } from "./spreadsheet_functions_math"; import { MISC_FUNCTIONS } from "./spreadsheet_functions_misc"; +import { LOOKUP_FUNCTIONS } from "./spreadsheet_functions_lookup"; -const TABLE: Record = { ...MATH_FUNCTIONS, ...MISC_FUNCTIONS }; +const TABLE: Record = { + ...MATH_FUNCTIONS, + ...MISC_FUNCTIONS, + ...LOOKUP_FUNCTIONS, +}; export function getFunction(name: string): SheetFunction | undefined { return TABLE[name.toUpperCase()]; @@ -22,3 +25,97 @@ export function getFunction(name: string): SheetFunction | undefined { export function functionNames(): string[] { return Object.keys(TABLE).sort(); } + +/** 2단계 첫 묶음(2026-09-27) — sub1 `spreadsheet_func_help.ts` 의 `TABLE` 에 이 모양대로 더할 것. + * `이름` · `꼴`(인자 모양 · 대괄호 = 생략 가능) · `뜻`(한 줄). */ +export interface FuncHelpEntry { + 이름: string; + 꼴: string; + 뜻: string; +} + +export const FUNC_HELP_BATCH2: FuncHelpEntry[] = [ + { 이름: "MROUND", 꼴: "MROUND(수, 배수)", 뜻: "배수의 가장 가까운 정수 배로 반올림" }, + { 이름: "ODD", 꼴: "ODD(수)", 뜻: "홀수로 올림(0 에서 먼 쪽)" }, + { 이름: "EVEN", 꼴: "EVEN(수)", 뜻: "짝수로 올림(0 에서 먼 쪽)" }, + { 이름: "LOG", 꼴: "LOG(수, [밑=10])", 뜻: "로그(밑 지정)" }, + { 이름: "LOG10", 꼴: "LOG10(수)", 뜻: "상용로그" }, + { 이름: "LN", 꼴: "LN(수)", 뜻: "자연로그" }, + { 이름: "EXP", 꼴: "EXP(수)", 뜻: "e 의 거듭제곱" }, + { + 이름: "SUMIFS", + 꼴: "SUMIFS(합할범위, 조건범위1, 조건1, [조건범위2, 조건2], …)", + 뜻: "여러 조건에 다 맞는 칸의 합", + }, + { + 이름: "COUNTIFS", + 꼴: "COUNTIFS(조건범위1, 조건1, [조건범위2, 조건2], …)", + 뜻: "여러 조건에 다 맞는 칸 개수", + }, + { + 이름: "AVERAGEIF", + 꼴: "AVERAGEIF(범위, 조건, [평균범위])", + 뜻: "조건에 맞는 칸(또는 짝 칸)의 평균", + }, + { + 이름: "AVERAGEIFS", + 꼴: "AVERAGEIFS(평균범위, 조건범위1, 조건1, …)", + 뜻: "여러 조건에 다 맞는 칸의 평균", + }, + { + 이름: "MAXIFS", + 꼴: "MAXIFS(최대범위, 조건범위1, 조건1, …)", + 뜻: "여러 조건에 다 맞는 칸의 최댓값", + }, + { + 이름: "MINIFS", + 꼴: "MINIFS(최소범위, 조건범위1, 조건1, …)", + 뜻: "여러 조건에 다 맞는 칸의 최솟값", + }, + { + 이름: "IFS", + 꼴: "IFS(조건1, 값1, [조건2, 값2], …)", + 뜻: "처음으로 참인 조건의 값", + }, + { + 이름: "SWITCH", + 꼴: "SWITCH(식, 값1, 결과1, [값2, 결과2], …, [기본값])", + 뜻: "식과 같은 값을 찾아 그 결과", + }, + { + 이름: "XLOOKUP", + 꼴: "XLOOKUP(찾을값, 찾을범위, 돌려줄범위, [못찾으면], [맞춤꼴], [찾는방향])", + 뜻: "찾을범위에서 찾아 돌려줄범위의 같은 자리 값", + }, + { + 이름: "TEXTJOIN", + 꼴: "TEXTJOIN(구분자, 빈칸건너뜀, 글1, [글2], …)", + 뜻: "구분자로 여러 글을 이어 붙임", + }, + { 이름: "TRIM", 꼴: "TRIM(글)", 뜻: "앞뒤 · 겹친 빈칸을 뺌" }, + { 이름: "UPPER", 꼴: "UPPER(글)", 뜻: "대문자로" }, + { 이름: "LOWER", 꼴: "LOWER(글)", 뜻: "소문자로" }, + { + 이름: "SUBSTITUTE", + 꼴: "SUBSTITUTE(글, 옛글, 새글, [몇번째])", + 뜻: "글 안 옛글을 새글로 바꿈", + }, + { 이름: "REPLACE", 꼴: "REPLACE(글, 시작, 글자수, 새글)", 뜻: "자리로 콕 집어 바꿈" }, + { 이름: "FIND", 꼴: "FIND(찾을글, 안글, [시작])", 뜻: "글자 그대로 찾음(대소문자 가림)" }, + { + 이름: "SEARCH", + 꼴: "SEARCH(찾을글, 안글, [시작])", + 뜻: "찾음(대소문자 안 가림 · * ? 자리표시)", + }, + { 이름: "REPT", 꼴: "REPT(글, 번)", 뜻: "글을 번 만큼 되풀이" }, + { 이름: "ISNUMBER", 꼴: "ISNUMBER(값)", 뜻: "수면 참" }, + { 이름: "ISTEXT", 꼴: "ISTEXT(값)", 뜻: "글이면 참" }, + { 이름: "ISBLANK", 꼴: "ISBLANK(값)", 뜻: "빈 칸이면 참" }, + { 이름: "ISERROR", 꼴: "ISERROR(값)", 뜻: "오류 값이면 참" }, + { 이름: "NA", 꼴: "NA()", 뜻: "#N/A 오류를 만듦" }, + { 이름: "TODAY", 꼴: "TODAY()", 뜻: "오늘 날짜(일련번호)" }, + { 이름: "DATE", 꼴: "DATE(년, 월, 일)", 뜻: "날짜 일련번호를 만듦" }, + { 이름: "YEAR", 꼴: "YEAR(일련번호)", 뜻: "연도" }, + { 이름: "MONTH", 꼴: "MONTH(일련번호)", 뜻: "월" }, + { 이름: "DAY", 꼴: "DAY(일련번호)", 뜻: "일" }, +]; diff --git a/A00_Common/spreadsheet/spreadsheet_functions_lookup.ts b/A00_Common/spreadsheet/spreadsheet_functions_lookup.ts new file mode 100644 index 00000000..214caa5f --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_functions_lookup.ts @@ -0,0 +1,536 @@ +/* ============================================================================= + * spreadsheet_functions_lookup.ts (주인 B · misc 700 줄 넘어 2026-09-27 쪼갬) + * 찾기 · 조건부 세기·합·평균·최대·최소 함수. `spreadsheet_functions_misc.ts` 에서 + * VLOOKUP · HLOOKUP · INDEX · MATCH · CHOOSE · COUNT · COUNTA · COUNTIF · SUMIF · + * SUMIFS · COUNTIFS · AVERAGEIF(S) · MAXIFS · MINIFS · IFS · SWITCH · XLOOKUP 를 옮겨 옴. + * 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀. + * ========================================================================== */ + +import { + ZERO, + add, + cmp, + div, + frac, + parseDecimal, + type Frac, +} from "@ui/sheet/ui_template_sheet_frac"; +import type { ErrorValue, LazyArg, RangeValue, Scalar, SheetFunction } from "./spreadsheet_types"; +import { + bool, + collectNumbers, + fracToDouble, + isErr, + isFrac, + isRange, + mkErr, + num, + toNumber, + toScalar, +} from "./spreadsheet_functions_math"; + +const ONE = frac(1n); + +// ── 값 견줌(찾기 · 세기 함수 공용) ─────────────────────────────────────────── + +function typeRank(v: Scalar): number { + if (v === null) return -1; + if (isFrac(v)) return 0; + if (typeof v === "string") return 1; + if (typeof v === "boolean") return 2; + return 3; +} + +function compareScalars(a: Scalar, b: Scalar): number { + if (isFrac(a) && isFrac(b)) return cmp(a, b); + if (typeof a === "string" && typeof b === "string") { + const la = a.toUpperCase(); + const lb = b.toUpperCase(); + return la < lb ? -1 : la > lb ? 1 : 0; + } + if (typeof a === "boolean" && typeof b === "boolean") return a === b ? 0 : a ? 1 : -1; + return 0; +} + +function equalScalars(a: Scalar, b: Scalar): boolean { + return typeRank(a) === typeRank(b) && compareScalars(a, b) === 0; +} + +function wrapScalar(v: Scalar): RangeValue { + return { kind: "range", rows: 1, cols: 1, at: () => v }; +} + +// ── 조건 글(`>10` · `<=5` · `사과*`) ───────────────────────────────────────── + +function wildcardToRegex(text: string): RegExp { + const escaped = text + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, "."); + return new RegExp(`^${escaped}$`, "i"); +} + +function parseCriteria(crit: Scalar): (cell: Scalar) => boolean { + if (crit === null) return (cell) => cell === null; + if (typeof crit === "boolean") return (cell) => typeof cell === "boolean" && cell === crit; + if (isFrac(crit)) return (cell) => isFrac(cell) && cmp(cell, crit) === 0; + if (isErr(crit)) return () => false; + const m = /^(<=|>=|<>|<|>|=)?(.*)$/.exec(crit); + const op = m?.[1] ?? ""; + const rest = m?.[2] ?? crit; + let numRest: Frac | null = null; + try { + numRest = rest.trim() === "" ? null : parseDecimal(rest); + } catch { + numRest = null; + } + if (numRest !== null) { + const nr = numRest; + return (cell) => { + if (!isFrac(cell)) return false; + const c = cmp(cell, nr); + if (op === "<=") return c <= 0; + if (op === ">=") return c >= 0; + if (op === "<>") return c !== 0; + if (op === "<") return c < 0; + if (op === ">") return c > 0; + return c === 0; + }; + } + const pat = wildcardToRegex(rest); + if (op === "<>") return (cell) => !(typeof cell === "string" && pat.test(cell)); + return (cell) => typeof cell === "string" && pat.test(cell); +} + +/** `args[from]` 부터 (범위,조건) 짝을 이어가며 모두 맞는 자리만 참 — *IFS 함수 공용. + * `shape` 범위와 크기가 다른 조건 범위가 있으면 `#VALUE!`. */ +function criteriaMask(args: LazyArg[], from: number, shape: RangeValue): boolean[] | ErrorValue { + const mask = new Array(shape.rows * shape.cols).fill(true); + for (let i = from; i + 1 < args.length; i += 2) { + const rangeArg = args[i](); + if (!isRange(rangeArg)) return mkErr("#VALUE!"); + if (rangeArg.rows !== shape.rows || rangeArg.cols !== shape.cols) { + return mkErr("#VALUE!", "조건 범위 크기가 다름"); + } + const crit = toScalar(args[i + 1]()); + if (isErr(crit)) return crit; + const pred = parseCriteria(crit); + let k = 0; + for (let r = 0; r < shape.rows; r++) { + for (let c = 0; c < shape.cols; c++) { + if (mask[k] && !pred(rangeArg.at(r, c))) mask[k] = false; + k++; + } + } + } + return mask; +} + +// ── 함수 표 ────────────────────────────────────────────────────────────── + +export const LOOKUP_FUNCTIONS: Record = { + VLOOKUP: { + min: 3, + max: 4, + call: (args) => { + const lookup = toScalar(args[0]()); + if (isErr(lookup)) return lookup; + const table = args[1](); + if (!isRange(table)) return mkErr("#VALUE!"); + const colN = num(args[2]()); + if (isErr(colN)) return colN; + const col = Math.trunc(fracToDouble(colN)) - 1; + if (col < 0 || col >= table.cols) return mkErr("#REF!"); + const approxRaw = args.length > 3 ? bool(args[3]()) : true; + if (isErr(approxRaw)) return approxRaw; + if (approxRaw) { + let best = -1; + for (let r = 0; r < table.rows; r++) { + const key = table.at(r, 0); + if (typeRank(key) !== typeRank(lookup)) { + if (best >= 0) break; + continue; + } + if (compareScalars(key, lookup) <= 0) best = r; + else break; + } + if (best < 0) return mkErr("#N/A"); + return table.at(best, col); + } + for (let r = 0; r < table.rows; r++) { + if (equalScalars(table.at(r, 0), lookup)) return table.at(r, col); + } + return mkErr("#N/A"); + }, + }, + HLOOKUP: { + min: 3, + max: 4, + call: (args) => { + const lookup = toScalar(args[0]()); + if (isErr(lookup)) return lookup; + const table = args[1](); + if (!isRange(table)) return mkErr("#VALUE!"); + const rowN = num(args[2]()); + if (isErr(rowN)) return rowN; + const row = Math.trunc(fracToDouble(rowN)) - 1; + if (row < 0 || row >= table.rows) return mkErr("#REF!"); + const approxRaw = args.length > 3 ? bool(args[3]()) : true; + if (isErr(approxRaw)) return approxRaw; + if (approxRaw) { + let best = -1; + for (let c = 0; c < table.cols; c++) { + const key = table.at(0, c); + if (typeRank(key) !== typeRank(lookup)) { + if (best >= 0) break; + continue; + } + if (compareScalars(key, lookup) <= 0) best = c; + else break; + } + if (best < 0) return mkErr("#N/A"); + return table.at(row, best); + } + for (let c = 0; c < table.cols; c++) { + if (equalScalars(table.at(0, c), lookup)) return table.at(row, c); + } + return mkErr("#N/A"); + }, + }, + INDEX: { + min: 2, + max: 3, + call: (args) => { + const rangeArg = args[0](); + if (isErr(rangeArg)) return rangeArg; + const range = isRange(rangeArg) ? rangeArg : wrapScalar(rangeArg); + const rowN = num(args[1]()); + if (isErr(rowN)) return rowN; + const row = Math.trunc(fracToDouble(rowN)); + const colN = args.length > 2 ? num(args[2]()) : frac(range.cols === 1 ? 1n : 0n); + if (isErr(colN)) return colN; + const col = Math.trunc(fracToDouble(colN)); + if (row === 0 && col === 0) return mkErr("#VALUE!"); + if (row === 0) { + if (col < 1 || col > range.cols) return mkErr("#REF!"); + const c = col - 1; + return { kind: "range", rows: range.rows, cols: 1, at: (r: number) => range.at(r, c) }; + } + if (col === 0) { + if (row < 1 || row > range.rows) return mkErr("#REF!"); + const r = row - 1; + return { + kind: "range", + rows: 1, + cols: range.cols, + at: (_r: number, c: number) => range.at(r, c), + }; + } + if (row < 1 || row > range.rows || col < 1 || col > range.cols) return mkErr("#REF!"); + return range.at(row - 1, col - 1); + }, + }, + MATCH: { + min: 2, + max: 3, + call: (args) => { + const lookup = toScalar(args[0]()); + if (isErr(lookup)) return lookup; + const rangeArg = args[1](); + if (!isRange(rangeArg)) return mkErr("#VALUE!"); + if (rangeArg.rows !== 1 && rangeArg.cols !== 1) { + return mkErr("#VALUE!", "한 줄 · 한 칸짜리 범위만"); + } + const n = rangeArg.rows === 1 ? rangeArg.cols : rangeArg.rows; + const at = (i: number): Scalar => + rangeArg.rows === 1 ? rangeArg.at(0, i) : rangeArg.at(i, 0); + const modeN = args.length > 2 ? num(args[2]()) : ONE; + if (isErr(modeN)) return modeN; + const mode = Math.trunc(fracToDouble(modeN)); + if (mode === 0) { + for (let i = 0; i < n; i++) if (equalScalars(at(i), lookup)) return frac(BigInt(i + 1)); + return mkErr("#N/A"); + } + let best = -1; + for (let i = 0; i < n; i++) { + const key = at(i); + if (typeRank(key) !== typeRank(lookup)) { + if (best >= 0) break; + continue; + } + const c = compareScalars(key, lookup); + if (mode > 0 ? c <= 0 : c >= 0) best = i; + else break; + } + if (best < 0) return mkErr("#N/A"); + return frac(BigInt(best + 1)); + }, + }, + CHOOSE: { + min: 2, + call: (args) => { + const idxN = num(args[0]()); + if (isErr(idxN)) return idxN; + const idx = Math.trunc(fracToDouble(idxN)); + if (idx < 1 || idx > args.length - 1) return mkErr("#VALUE!"); + return args[idx](); + }, + }, + COUNT: { + min: 0, + call: (args) => { + const values = collectNumbers(args, false, false); + return isErr(values) ? values : frac(BigInt(values.length)); + }, + }, + COUNTA: { + min: 0, + call: (args) => { + let count = 0; + for (const a of args) { + const v = a(); + if (isRange(v)) { + for (let r = 0; r < v.rows; r++) { + for (let c = 0; c < v.cols; c++) if (v.at(r, c) !== null) count++; + } + } else if (v !== null) { + count++; + } + } + return frac(BigInt(count)); + }, + }, + COUNTIF: { + min: 2, + max: 2, + call: (args) => { + const range = args[0](); + if (!isRange(range)) return mkErr("#VALUE!"); + const crit = toScalar(args[1]()); + if (isErr(crit)) return crit; + const pred = parseCriteria(crit); + let count = 0; + for (let r = 0; r < range.rows; r++) { + for (let c = 0; c < range.cols; c++) if (pred(range.at(r, c))) count++; + } + return frac(BigInt(count)); + }, + }, + SUMIF: { + min: 2, + max: 3, + call: (args) => { + const range = args[0](); + if (!isRange(range)) return mkErr("#VALUE!"); + const crit = toScalar(args[1]()); + if (isErr(crit)) return crit; + const pred = parseCriteria(crit); + const sumRange = args.length > 2 ? args[2]() : range; + if (!isRange(sumRange)) return mkErr("#VALUE!"); + if (sumRange.rows !== range.rows || sumRange.cols !== range.cols) { + return mkErr("#VALUE!", "합 범위 크기가 다름"); + } + let total = ZERO; + for (let r = 0; r < range.rows; r++) { + for (let c = 0; c < range.cols; c++) { + if (!pred(range.at(r, c))) continue; + const n = toNumber(sumRange.at(r, c)); + if (isErr(n)) return n; + total = add(total, n); + } + } + return total; + }, + }, + SUMIFS: { + min: 3, + call: (args) => { + const sumRange = args[0](); + if (!isRange(sumRange)) return mkErr("#VALUE!"); + const mask = criteriaMask(args, 1, sumRange); + if (isErr(mask)) return mask; + let total = ZERO; + let k = 0; + for (let r = 0; r < sumRange.rows; r++) { + for (let c = 0; c < sumRange.cols; c++) { + if (mask[k]) { + const n = toNumber(sumRange.at(r, c)); + if (isErr(n)) return n; + total = add(total, n); + } + k++; + } + } + return total; + }, + }, + COUNTIFS: { + min: 2, + call: (args) => { + const first = args[0](); + if (!isRange(first)) return mkErr("#VALUE!"); + const mask = criteriaMask(args, 0, first); + if (isErr(mask)) return mask; + return frac(BigInt(mask.filter(Boolean).length)); + }, + }, + AVERAGEIF: { + min: 2, + max: 3, + call: (args) => { + const range = args[0](); + if (!isRange(range)) return mkErr("#VALUE!"); + const crit = toScalar(args[1]()); + if (isErr(crit)) return crit; + const pred = parseCriteria(crit); + const avgRange = args.length > 2 ? args[2]() : range; + if (!isRange(avgRange)) return mkErr("#VALUE!"); + if (avgRange.rows !== range.rows || avgRange.cols !== range.cols) { + return mkErr("#VALUE!", "평균 범위 크기가 다름"); + } + let total = ZERO; + let count = 0; + for (let r = 0; r < range.rows; r++) { + for (let c = 0; c < range.cols; c++) { + if (!pred(range.at(r, c))) continue; + const n = toNumber(avgRange.at(r, c)); + if (isErr(n)) return n; + total = add(total, n); + count++; + } + } + return count === 0 ? mkErr("#DIV/0!") : div(total, frac(BigInt(count))); + }, + }, + AVERAGEIFS: { + min: 3, + call: (args) => { + const avgRange = args[0](); + if (!isRange(avgRange)) return mkErr("#VALUE!"); + const mask = criteriaMask(args, 1, avgRange); + if (isErr(mask)) return mask; + let total = ZERO; + let count = 0; + let k = 0; + for (let r = 0; r < avgRange.rows; r++) { + for (let c = 0; c < avgRange.cols; c++) { + if (mask[k]) { + const n = toNumber(avgRange.at(r, c)); + if (isErr(n)) return n; + total = add(total, n); + count++; + } + k++; + } + } + return count === 0 ? mkErr("#DIV/0!") : div(total, frac(BigInt(count))); + }, + }, + MAXIFS: { + min: 3, + call: (args) => { + const targetRange = args[0](); + if (!isRange(targetRange)) return mkErr("#VALUE!"); + const mask = criteriaMask(args, 1, targetRange); + if (isErr(mask)) return mask; + let best: Frac | null = null; + let k = 0; + for (let r = 0; r < targetRange.rows; r++) { + for (let c = 0; c < targetRange.cols; c++) { + if (mask[k]) { + const cell = targetRange.at(r, c); + if (isFrac(cell) && (best === null || cmp(cell, best) > 0)) best = cell; + } + k++; + } + } + return best ?? ZERO; + }, + }, + MINIFS: { + min: 3, + call: (args) => { + const targetRange = args[0](); + if (!isRange(targetRange)) return mkErr("#VALUE!"); + const mask = criteriaMask(args, 1, targetRange); + if (isErr(mask)) return mask; + let best: Frac | null = null; + let k = 0; + for (let r = 0; r < targetRange.rows; r++) { + for (let c = 0; c < targetRange.cols; c++) { + if (mask[k]) { + const cell = targetRange.at(r, c); + if (isFrac(cell) && (best === null || cmp(cell, best) < 0)) best = cell; + } + k++; + } + } + return best ?? ZERO; + }, + }, + IFS: { + min: 2, + call: (args) => { + for (let i = 0; i + 1 < args.length; i += 2) { + const c = bool(args[i]()); + if (isErr(c)) return c; + if (c) return args[i + 1](); + } + return mkErr("#N/A"); + }, + }, + SWITCH: { + min: 3, + call: (args) => { + const target = toScalar(args[0]()); + if (isErr(target)) return target; + let i = 1; + for (; i + 1 < args.length; i += 2) { + const cand = toScalar(args[i]()); + if (isErr(cand)) return cand; + if (equalScalars(target, cand)) return args[i + 1](); + } + return i < args.length ? args[i]() : mkErr("#N/A"); + }, + }, + XLOOKUP: { + min: 3, + max: 6, + call: (args) => { + const lookup = toScalar(args[0]()); + if (isErr(lookup)) return lookup; + const lookupRange = args[1](); + const returnRange = args[2](); + if (!isRange(lookupRange) || !isRange(returnRange)) return mkErr("#VALUE!"); + const n = lookupRange.rows * lookupRange.cols; + if (n !== returnRange.rows * returnRange.cols) { + return mkErr("#VALUE!", "찾을 범위 · 돌려줄 범위 크기가 다름"); + } + const at = (rv: RangeValue, i: number): Scalar => (rv.rows === 1 ? rv.at(0, i) : rv.at(i, 0)); + const modeN = args.length > 4 ? num(args[4]()) : ZERO; + if (isErr(modeN)) return modeN; + const mode = Math.trunc(fracToDouble(modeN)); + const searchN = args.length > 5 ? num(args[5]()) : ONE; + if (isErr(searchN)) return searchN; + const reverse = Math.trunc(fracToDouble(searchN)) < 0; + let best = -1; + for (let step = 0; step < n; step++) { + const i = reverse ? n - 1 - step : step; + const key = at(lookupRange, i); + if (typeRank(key) !== typeRank(lookup)) continue; + const c = compareScalars(key, lookup); + if (c === 0) { + best = i; + break; + } + if (mode === 1 && c > 0) { + if (best < 0 || compareScalars(key, at(lookupRange, best)) < 0) best = i; + } else if (mode === -1 && c < 0) { + if (best < 0 || compareScalars(key, at(lookupRange, best)) > 0) best = i; + } + } + if (best < 0) return args.length > 3 ? args[3]() : mkErr("#N/A"); + return at(returnRange, best); + }, + }, +}; diff --git a/A00_Common/spreadsheet/spreadsheet_functions_math.ts b/A00_Common/spreadsheet/spreadsheet_functions_math.ts index 99f21e12..6d65b53e 100644 --- a/A00_Common/spreadsheet/spreadsheet_functions_math.ts +++ b/A00_Common/spreadsheet/spreadsheet_functions_math.ts @@ -415,4 +415,76 @@ export const MATH_FUNCTIONS: Record = { return div(values.reduce(add, ZERO), frac(BigInt(values.length))); }, }, + MROUND: { + min: 2, + max: 2, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + const m = num(args[1]()); + if (isErr(m)) return m; + if (cmp(m, ZERO) === 0) return ZERO; + if (cmp(n, ZERO) !== 0 && cmp(n, ZERO) > 0 !== cmp(m, ZERO) > 0) { + return mkErr("#NUM!", "수 · 배수의 부호가 다름"); + } + return mul(frac(toInteger(div(n, m), "round")), m); + }, + }, + ODD: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + let v = toInteger(n, "away"); + if (v === 0n) v = 1n; + else if (v % 2n === 0n) v += v < 0n ? -1n : 1n; + return frac(v); + }, + }, + EVEN: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + let v = toInteger(n, "away"); + if (v % 2n !== 0n) v += v < 0n ? -1n : 1n; + return frac(v); + }, + }, + LOG: { + min: 1, + max: 2, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + if (cmp(n, ZERO) <= 0) return mkErr("#NUM!"); + const base = args.length > 1 ? num(args[1]()) : frac(10n); + if (isErr(base)) return base; + if (cmp(base, ZERO) <= 0 || cmp(base, ONE) === 0) return mkErr("#NUM!"); + return fromDouble(Math.log(fracToDouble(n)) / Math.log(fracToDouble(base))); + }, + }, + LOG10: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + if (cmp(n, ZERO) <= 0) return mkErr("#NUM!"); + return fromDouble(Math.log10(fracToDouble(n))); + }, + }, + LN: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + if (isErr(n)) return n; + if (cmp(n, ZERO) <= 0) return mkErr("#NUM!"); + return fromDouble(Math.log(fracToDouble(n))); + }, + }, + EXP: unaryDouble(Math.exp), }; diff --git a/A00_Common/spreadsheet/spreadsheet_functions_misc.ts b/A00_Common/spreadsheet/spreadsheet_functions_misc.ts index 10d4916a..dd33073f 100644 --- a/A00_Common/spreadsheet/spreadsheet_functions_misc.ts +++ b/A00_Common/spreadsheet/spreadsheet_functions_misc.ts @@ -1,12 +1,12 @@ /* ============================================================================= * spreadsheet_functions_misc.ts (주인 B) - * 논리 · 글 · 찾기 · 세기 함수. 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀. - * 뜻 · 경계값은 엑셀과 같게(Formula.js 는 시험 대조 답으로만). + * 논리 · 글 · 정보 · 날짜 함수. 찾기 · 조건부 세기/합/평균/최대/최소는 + * `spreadsheet_functions_lookup.ts` 로 옮김(700 줄 규칙 · 2026-09-27). + * 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀. * ========================================================================== */ import { ZERO, - add, cmp, frac, mul, @@ -15,12 +15,12 @@ import { toInteger, type Frac, } from "@ui/sheet/ui_template_sheet_frac"; -import type { ErrorValue, LazyArg, RangeValue, Scalar, SheetFunction } from "./spreadsheet_types"; +import type { ErrorValue, LazyArg, SheetFunction } from "./spreadsheet_types"; import { formatValue } from "./spreadsheet_numfmt"; import { bool, - collectNumbers, fracToDouble, + fromDouble, isErr, isFrac, isRange, @@ -28,82 +28,19 @@ import { num, str, toBool, - toNumber, toScalar, toText, } from "./spreadsheet_functions_math"; const ONE = frac(1n); -// ── 값 견줌(찾기 · 세기 함수 공용) ─────────────────────────────────────────── - -function typeRank(v: Scalar): number { - if (v === null) return -1; - if (isFrac(v)) return 0; - if (typeof v === "string") return 1; - if (typeof v === "boolean") return 2; - return 3; -} - -function compareScalars(a: Scalar, b: Scalar): number { - if (isFrac(a) && isFrac(b)) return cmp(a, b); - if (typeof a === "string" && typeof b === "string") { - const la = a.toUpperCase(); - const lb = b.toUpperCase(); - return la < lb ? -1 : la > lb ? 1 : 0; - } - if (typeof a === "boolean" && typeof b === "boolean") return a === b ? 0 : a ? 1 : -1; - return 0; -} - -function equalScalars(a: Scalar, b: Scalar): boolean { - return typeRank(a) === typeRank(b) && compareScalars(a, b) === 0; -} - -function wrapScalar(v: Scalar): RangeValue { - return { kind: "range", rows: 1, cols: 1, at: () => v }; -} - -// ── 조건 글(`>10` · `<=5` · `사과*`) ───────────────────────────────────────── - -function wildcardToRegex(text: string): RegExp { +/** SEARCH — 통짜 맞춤이 아니라 안 어디서든 찾음(자리 없이 그대로 씀). */ +function wildcardToSearchRegex(text: string): RegExp { const escaped = text .replace(/[.+^${}()|[\]\\]/g, "\\$&") .replace(/\*/g, ".*") .replace(/\?/g, "."); - return new RegExp(`^${escaped}$`, "i"); -} - -function parseCriteria(crit: Scalar): (cell: Scalar) => boolean { - if (crit === null) return (cell) => cell === null; - if (typeof crit === "boolean") return (cell) => typeof cell === "boolean" && cell === crit; - if (isFrac(crit)) return (cell) => isFrac(cell) && cmp(cell, crit) === 0; - if (isErr(crit)) return () => false; - const m = /^(<=|>=|<>|<|>|=)?(.*)$/.exec(crit); - const op = m?.[1] ?? ""; - const rest = m?.[2] ?? crit; - let numRest: Frac | null = null; - try { - numRest = rest.trim() === "" ? null : parseDecimal(rest); - } catch { - numRest = null; - } - if (numRest !== null) { - const nr = numRest; - return (cell) => { - if (!isFrac(cell)) return false; - const c = cmp(cell, nr); - if (op === "<=") return c <= 0; - if (op === ">=") return c >= 0; - if (op === "<>") return c !== 0; - if (op === "<") return c < 0; - if (op === ">") return c > 0; - return c === 0; - }; - } - const pat = wildcardToRegex(rest); - if (op === "<>") return (cell) => !(typeof cell === "string" && pat.test(cell)); - return (cell) => typeof cell === "string" && pat.test(cell); + return new RegExp(escaped, "i"); } // ── 숫자 → 고정 소수 글(FIXED) ─────────────────────────────────────────────── @@ -126,8 +63,6 @@ function formatFixed(n: Frac, decimals: number, commas: boolean): string { return negative ? `-${body}` : body; } -// ── 함수 표 ────────────────────────────────────────────────────────────── - function collectBools(args: LazyArg[]): boolean[] | ErrorValue { const out: boolean[] = []; for (const a of args) { @@ -152,6 +87,23 @@ function collectBools(args: LazyArg[]): boolean[] | ErrorValue { return out; } +// ── 날짜 일련번호(엑셀 1900 체계 · 1970-01-01 = 25569) ─────────────────────── +// 1900 을 윤년 취급하는 옛 버그는 그대로 두지 않음(1900 년대 자체를 실무가 안 씀) — +// 유닉스 자정 기준으로 바로 셈해 1900 년 2 월 안쪽만 어긋남(1단계 밖). + +const MS_PER_DAY = 86400000; +const UNIX_EPOCH_AS_EXCEL_SERIAL = 25569; + +function serialToDate(serial: number): Date { + return new Date(Math.round((serial - UNIX_EPOCH_AS_EXCEL_SERIAL) * MS_PER_DAY)); +} + +function dateToSerial(d: Date): number { + return d.getTime() / MS_PER_DAY + UNIX_EPOCH_AS_EXCEL_SERIAL; +} + +// ── 함수 표 ────────────────────────────────────────────────────────────── + export const MISC_FUNCTIONS: Record = { IF: { min: 2, @@ -311,217 +263,207 @@ export const MISC_FUNCTIONS: Record = { } }, }, - VLOOKUP: { + TEXTJOIN: { min: 3, - max: 4, call: (args) => { - const lookup = toScalar(args[0]()); - if (isErr(lookup)) return lookup; - const table = args[1](); - if (!isRange(table)) return mkErr("#VALUE!"); - const colN = num(args[2]()); - if (isErr(colN)) return colN; - const col = Math.trunc(fracToDouble(colN)) - 1; - if (col < 0 || col >= table.cols) return mkErr("#REF!"); - const approxRaw = args.length > 3 ? bool(args[3]()) : true; - if (isErr(approxRaw)) return approxRaw; - if (approxRaw) { - let best = -1; - for (let r = 0; r < table.rows; r++) { - const key = table.at(r, 0); - if (typeRank(key) !== typeRank(lookup)) { - if (best >= 0) break; - continue; - } - if (compareScalars(key, lookup) <= 0) best = r; - else break; - } - if (best < 0) return mkErr("#N/A"); - return table.at(best, col); - } - for (let r = 0; r < table.rows; r++) { - if (equalScalars(table.at(r, 0), lookup)) return table.at(r, col); - } - return mkErr("#N/A"); - }, - }, - HLOOKUP: { - min: 3, - max: 4, - call: (args) => { - const lookup = toScalar(args[0]()); - if (isErr(lookup)) return lookup; - const table = args[1](); - if (!isRange(table)) return mkErr("#VALUE!"); - const rowN = num(args[2]()); - if (isErr(rowN)) return rowN; - const row = Math.trunc(fracToDouble(rowN)) - 1; - if (row < 0 || row >= table.rows) return mkErr("#REF!"); - const approxRaw = args.length > 3 ? bool(args[3]()) : true; - if (isErr(approxRaw)) return approxRaw; - if (approxRaw) { - let best = -1; - for (let c = 0; c < table.cols; c++) { - const key = table.at(0, c); - if (typeRank(key) !== typeRank(lookup)) { - if (best >= 0) break; - continue; - } - if (compareScalars(key, lookup) <= 0) best = c; - else break; - } - if (best < 0) return mkErr("#N/A"); - return table.at(row, best); - } - for (let c = 0; c < table.cols; c++) { - if (equalScalars(table.at(0, c), lookup)) return table.at(row, c); - } - return mkErr("#N/A"); - }, - }, - INDEX: { - min: 2, - max: 3, - call: (args) => { - const rangeArg = args[0](); - if (isErr(rangeArg)) return rangeArg; - const range = isRange(rangeArg) ? rangeArg : wrapScalar(rangeArg); - const rowN = num(args[1]()); - if (isErr(rowN)) return rowN; - const row = Math.trunc(fracToDouble(rowN)); - const colN = args.length > 2 ? num(args[2]()) : frac(range.cols === 1 ? 1n : 0n); - if (isErr(colN)) return colN; - const col = Math.trunc(fracToDouble(colN)); - if (row === 0 && col === 0) return mkErr("#VALUE!"); - if (row === 0) { - if (col < 1 || col > range.cols) return mkErr("#REF!"); - const c = col - 1; - return { kind: "range", rows: range.rows, cols: 1, at: (r: number) => range.at(r, c) }; - } - if (col === 0) { - if (row < 1 || row > range.rows) return mkErr("#REF!"); - const r = row - 1; - return { - kind: "range", - rows: 1, - cols: range.cols, - at: (_r: number, c: number) => range.at(r, c), - }; - } - if (row < 1 || row > range.rows || col < 1 || col > range.cols) return mkErr("#REF!"); - return range.at(row - 1, col - 1); - }, - }, - MATCH: { - min: 2, - max: 3, - call: (args) => { - const lookup = toScalar(args[0]()); - if (isErr(lookup)) return lookup; - const rangeArg = args[1](); - if (!isRange(rangeArg)) return mkErr("#VALUE!"); - if (rangeArg.rows !== 1 && rangeArg.cols !== 1) { - return mkErr("#VALUE!", "한 줄 · 한 칸짜리 범위만"); - } - const n = rangeArg.rows === 1 ? rangeArg.cols : rangeArg.rows; - const at = (i: number): Scalar => - rangeArg.rows === 1 ? rangeArg.at(0, i) : rangeArg.at(i, 0); - const modeN = args.length > 2 ? num(args[2]()) : ONE; - if (isErr(modeN)) return modeN; - const mode = Math.trunc(fracToDouble(modeN)); - if (mode === 0) { - for (let i = 0; i < n; i++) if (equalScalars(at(i), lookup)) return frac(BigInt(i + 1)); - return mkErr("#N/A"); - } - let best = -1; - for (let i = 0; i < n; i++) { - const key = at(i); - if (typeRank(key) !== typeRank(lookup)) { - if (best >= 0) break; - continue; - } - const c = compareScalars(key, lookup); - if (mode > 0 ? c <= 0 : c >= 0) best = i; - else break; - } - if (best < 0) return mkErr("#N/A"); - return frac(BigInt(best + 1)); - }, - }, - CHOOSE: { - min: 2, - call: (args) => { - const idxN = num(args[0]()); - if (isErr(idxN)) return idxN; - const idx = Math.trunc(fracToDouble(idxN)); - if (idx < 1 || idx > args.length - 1) return mkErr("#VALUE!"); - return args[idx](); - }, - }, - COUNT: { - min: 0, - call: (args) => { - const values = collectNumbers(args, false, false); - return isErr(values) ? values : frac(BigInt(values.length)); - }, - }, - COUNTA: { - min: 0, - call: (args) => { - let count = 0; - for (const a of args) { - const v = a(); + const delim = str(args[0]()); + if (isErr(delim)) return delim; + const ignoreEmpty = bool(args[1]()); + if (isErr(ignoreEmpty)) return ignoreEmpty; + const parts: string[] = []; + for (let i = 2; i < args.length; i++) { + const v = args[i](); if (isRange(v)) { for (let r = 0; r < v.rows; r++) { - for (let c = 0; c < v.cols; c++) if (v.at(r, c) !== null) count++; + for (let c = 0; c < v.cols; c++) { + const t = toText(v.at(r, c)); + if (isErr(t)) return t; + if (!(ignoreEmpty && t === "")) parts.push(t); + } } - } else if (v !== null) { - count++; + } else { + const t = str(v); + if (isErr(t)) return t; + if (!(ignoreEmpty && t === "")) parts.push(t); } } - return frac(BigInt(count)); + return parts.join(delim); }, }, - COUNTIF: { + TRIM: { + min: 1, + max: 1, + call: (args) => { + const s = str(args[0]()); + return isErr(s) ? s : s.split(" ").filter(Boolean).join(" "); + }, + }, + UPPER: { + min: 1, + max: 1, + call: (args) => { + const s = str(args[0]()); + return isErr(s) ? s : s.toUpperCase(); + }, + }, + LOWER: { + min: 1, + max: 1, + call: (args) => { + const s = str(args[0]()); + return isErr(s) ? s : s.toLowerCase(); + }, + }, + SUBSTITUTE: { + min: 3, + max: 4, + call: (args) => { + const text = str(args[0]()); + if (isErr(text)) return text; + const oldT = str(args[1]()); + if (isErr(oldT)) return oldT; + const newT = str(args[2]()); + if (isErr(newT)) return newT; + if (oldT === "") return text; + if (args.length <= 3) return text.split(oldT).join(newT); + const nthArg = num(args[3]()); + if (isErr(nthArg)) return nthArg; + const nth = Math.trunc(fracToDouble(nthArg)); + let idx = -1; + let count = 0; + let pos = 0; + for (;;) { + const found = text.indexOf(oldT, pos); + if (found < 0) break; + count++; + if (count === nth) { + idx = found; + break; + } + pos = found + oldT.length; + } + return idx < 0 ? text : text.slice(0, idx) + newT + text.slice(idx + oldT.length); + }, + }, + REPLACE: { + min: 4, + max: 4, + call: (args) => { + const text = str(args[0]()); + if (isErr(text)) return text; + const startN = num(args[1]()); + if (isErr(startN)) return startN; + const lenN = num(args[2]()); + if (isErr(lenN)) return lenN; + const newText = str(args[3]()); + if (isErr(newText)) return newText; + const start = Math.trunc(fracToDouble(startN)); + const len = Math.max(0, Math.trunc(fracToDouble(lenN))); + if (start < 1) return mkErr("#VALUE!"); + return text.slice(0, start - 1) + newText + text.slice(start - 1 + len); + }, + }, + FIND: { + min: 2, + max: 3, + call: (args) => { + const findT = str(args[0]()); + if (isErr(findT)) return findT; + const within = str(args[1]()); + if (isErr(within)) return within; + const startArg = args.length > 2 ? num(args[2]()) : ONE; + if (isErr(startArg)) return startArg; + const start = Math.trunc(fracToDouble(startArg)); + if (start < 1) return mkErr("#VALUE!"); + const idx = within.indexOf(findT, start - 1); + return idx < 0 ? mkErr("#VALUE!") : frac(BigInt(idx + 1)); + }, + }, + SEARCH: { + min: 2, + max: 3, + call: (args) => { + const findT = str(args[0]()); + if (isErr(findT)) return findT; + const within = str(args[1]()); + if (isErr(within)) return within; + const startArg = args.length > 2 ? num(args[2]()) : ONE; + if (isErr(startArg)) return startArg; + const start = Math.trunc(fracToDouble(startArg)); + if (start < 1) return mkErr("#VALUE!"); + const pat = wildcardToSearchRegex(findT); + const m = pat.exec(within.slice(start - 1)); + return m ? frac(BigInt(m.index + start)) : mkErr("#VALUE!"); + }, + }, + REPT: { min: 2, max: 2, call: (args) => { - const range = args[0](); - if (!isRange(range)) return mkErr("#VALUE!"); - const crit = toScalar(args[1]()); - if (isErr(crit)) return crit; - const pred = parseCriteria(crit); - let count = 0; - for (let r = 0; r < range.rows; r++) { - for (let c = 0; c < range.cols; c++) if (pred(range.at(r, c))) count++; - } - return frac(BigInt(count)); + const s = str(args[0]()); + if (isErr(s)) return s; + const nArg = num(args[1]()); + if (isErr(nArg)) return nArg; + const n = Math.trunc(fracToDouble(nArg)); + return n < 0 ? mkErr("#VALUE!") : s.repeat(n); }, }, - SUMIF: { - min: 2, + ISNUMBER: { min: 1, max: 1, call: (args) => isFrac(toScalar(args[0]())) }, + ISTEXT: { min: 1, max: 1, call: (args) => typeof toScalar(args[0]()) === "string" }, + ISBLANK: { min: 1, max: 1, call: (args) => toScalar(args[0]()) === null }, + ISERROR: { min: 1, max: 1, call: (args) => isErr(toScalar(args[0]())) }, + NA: { min: 0, max: 0, call: () => mkErr("#N/A") }, + TODAY: { + min: 0, + max: 0, + call: () => { + const now = new Date(); + const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + return fromDouble(dateToSerial(new Date(midnight))); + }, + }, + DATE: { + min: 3, max: 3, call: (args) => { - const range = args[0](); - if (!isRange(range)) return mkErr("#VALUE!"); - const crit = toScalar(args[1]()); - if (isErr(crit)) return crit; - const pred = parseCriteria(crit); - const sumRange = args.length > 2 ? args[2]() : range; - if (!isRange(sumRange)) return mkErr("#VALUE!"); - if (sumRange.rows !== range.rows || sumRange.cols !== range.cols) { - return mkErr("#VALUE!", "합 범위 크기가 다름"); - } - let total = ZERO; - for (let r = 0; r < range.rows; r++) { - for (let c = 0; c < range.cols; c++) { - if (!pred(range.at(r, c))) continue; - const n = toNumber(sumRange.at(r, c)); - if (isErr(n)) return n; - total = add(total, n); - } - } - return total; + const y = num(args[0]()); + if (isErr(y)) return y; + const m = num(args[1]()); + if (isErr(m)) return m; + const d = num(args[2]()); + if (isErr(d)) return d; + const date = new Date( + Date.UTC( + Math.trunc(fracToDouble(y)), + Math.trunc(fracToDouble(m)) - 1, + Math.trunc(fracToDouble(d)), + ), + ); + return fromDouble(dateToSerial(date)); + }, + }, + YEAR: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCFullYear())); + }, + }, + MONTH: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCMonth() + 1)); + }, + }, + DAY: { + min: 1, + max: 1, + call: (args) => { + const n = num(args[0]()); + return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCDate())); }, }, }; diff --git a/A00_Common/spreadsheet/spreadsheet_keys.ts b/A00_Common/spreadsheet/spreadsheet_keys.ts index 2e5cdfcb..22315c3a 100644 --- a/A00_Common/spreadsheet/spreadsheet_keys.ts +++ b/A00_Common/spreadsheet/spreadsheet_keys.ts @@ -31,7 +31,19 @@ const ARROWS: Record = { ArrowRight: [0, 1], }; -export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void { +/** 2단계 잇기 자리 — 부른 쪽(spreadsheet.ts)이 채움 */ +export interface KeyHooks { + /** Ctrl+F · Shift+F5 찾기 · Ctrl+H 바꾸기 */ + find?(replace: boolean): void; + /** Ctrl+Shift+V — 곧 올 paste 사건을 골라 붙여넣기로 */ + pasteSpecial?(): void; + /** Shift+F2 메모 편집 */ + comment?(): void; + /** 고름 중 Esc(서식 붓 끄기 등) */ + escape?(): void; +} + +export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks = {}): () => void { const go = (sel: Selection): void => ctx.select(sel, true); /** 확정 뒤 한 칸 — 고름이 여러 칸이면 그 안에서 돎 */ @@ -56,6 +68,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void { return true; } case "Tab": + if (!e.shiftKey && ed.accept()) return true; // 함수 자동 완성 ed.commit(); next(0, e.shiftKey ? -1 : 1); return true; @@ -67,6 +80,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void { return true; } const arrow = ARROWS[e.key]; + if (arrow && !arrow[1] && !e.shiftKey && ed.suggestMove(arrow[0])) return true; if (!arrow || inBar || ed.mode() === "edit") return false; if (ed.pointable()) { ed.pointMove(arrow[0], arrow[1], e.shiftKey, ctrl); @@ -116,6 +130,14 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void { case "r": fill(k === "d"); return true; + case "f": + case "h": + if (!hooks.find) return false; + hooks.find(k === "h"); + return true; + case "v": + if (e.shiftKey) hooks.pasteSpecial?.(); + return false; // paste 사건은 그대로 — E 클립보드 · 골라 붙여넣기가 받음 } return false; } @@ -126,9 +148,17 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void { case "Tab": next(0, e.shiftKey ? -1 : 1); return true; - case "F2": - ed.begin(); + case "F5": // Shift+F5 = 찾기(엑셀 · Ctrl+F 를 브라우저가 가로챌 때) + if (!e.shiftKey || !hooks.find) return false; + hooks.find(false); return true; + case "F2": + if (e.shiftKey) hooks.comment?.(); + else ed.begin(); + return true; + case "Escape": + hooks.escape?.(); + return !!hooks.escape; case "Delete": if (!ctx.readOnly) ctx.dispatch({ 종류: "내용지움", 시트: sheet.id, 범위: sel.범위 }); return true; diff --git a/A00_Common/spreadsheet/spreadsheet_mouse.ts b/A00_Common/spreadsheet/spreadsheet_mouse.ts index 5d0e706b..9100497f 100644 --- a/A00_Common/spreadsheet/spreadsheet_mouse.ts +++ b/A00_Common/spreadsheet/spreadsheet_mouse.ts @@ -6,6 +6,7 @@ * 머리 끝(폭 · 높이 끌기)은 C 몫 — 여기서 건드리지 않음. * ========================================================================== */ +import { autofitColumn } from "./spreadsheet_autofit"; import { fillCells } from "./spreadsheet_fill"; import { hasValue, @@ -18,7 +19,7 @@ import { selectRows, selectSpan, } from "./spreadsheet_selection"; -import { boxStyle, type Editor } from "./spreadsheet_editor"; +import { boxStyle, slotOf, type Editor } from "./spreadsheet_editor"; import type { CellAddress, CellRange } from "./spreadsheet_types"; import type { HitResult, Selection, SpreadsheetContext } from "./spreadsheet_view_types"; @@ -45,7 +46,19 @@ export function fillTarget( return { target: { ...src, c0: p.c }, clear: null }; } -export function attachMouse(ctx: SpreadsheetContext, ed: Editor): () => void { +/** 2단계 잇기 자리 — 부른 쪽(spreadsheet.ts)이 채움 */ +export interface MouseHooks { + /** 칸 끌어 고르기가 끝남(서식 붓 바르기) */ + selected?(): void; + /** 누르지 않고 움직일 때 가리킨 것(메모 풍선) — 격자 밖이면 null */ + hover?(hit: HitResult | null): void; +} + +export function attachMouse( + ctx: SpreadsheetContext, + ed: Editor, + hooks: MouseHooks = {}, +): () => void { const root = ctx.grid.root; const preview = document.createElement("div"); preview.className = "ss-fill-preview"; @@ -80,8 +93,8 @@ export function attachMouse(ctx: SpreadsheetContext, ed: Editor): () => void { function showPreview(g: CellRange | null): void { if (!g) return preview.remove(); - const a = ctx.grid.editorSlot(g.r0, g.c0); - const b = ctx.grid.editorSlot(Math.min(g.r1, LAST_R), Math.min(g.c1, LAST_C)); + const a = slotOf(ctx, g.r0, g.c0); + const b = slotOf(ctx, Math.min(g.r1, LAST_R), Math.min(g.c1, LAST_C)); if (preview.parentElement !== a.layer) a.layer.append(preview); boxStyle(preview, { x: a.box.x, @@ -198,11 +211,14 @@ export function attachMouse(ctx: SpreadsheetContext, ed: Editor): () => void { ctx.select(first); const anchor = first.기준; let last = `${hit.r},${hit.c}`; - return drag((p) => { - if (`${p.r},${p.c}` === last) return; - last = `${p.r},${p.c}`; - ctx.select(selectSpan(sheet, anchor, p)); - }); + return drag( + (p) => { + if (`${p.r},${p.c}` === last) return; + last = `${p.r},${p.c}`; + ctx.select(selectSpan(sheet, anchor, p)); + }, + () => hooks.selected?.(), + ); } } } @@ -212,12 +228,28 @@ export function attachMouse(ctx: SpreadsheetContext, ed: Editor): () => void { const hit = ctx.grid.hitTest(e.clientX, e.clientY); if (!hit) return; if (hit.kind === "fillHandle") return fillDown(); + if (hit.kind === "colEdge") { + // 고른 열 전체의 경계면 고른 열 모두(엑셀) + const g = ctx.selection.범위[0]; + const whole = g.r0 === 0 && g.r1 === LAST_R && inRange(g, 0, hit.c); + for (let c = whole ? g.c0 : hit.c; c <= (whole ? g.c1 : hit.c); c++) autofitColumn(ctx, c); + return; + } if (hit.kind === "cell" && !ed.editing()) ed.begin(); } + const onHover = (e: MouseEvent): void => { + if (!stopDrag && !e.buttons) hooks.hover?.(ctx.grid.hitTest(e.clientX, e.clientY)); + }; + const onLeave = (): void => hooks.hover?.(null); + root.addEventListener("mousedown", onDown); root.addEventListener("dblclick", onDbl); + root.addEventListener("mousemove", onHover); + root.addEventListener("mouseleave", onLeave); return () => { + root.removeEventListener("mousemove", onHover); + root.removeEventListener("mouseleave", onLeave); stopDrag?.(); preview.remove(); root.removeEventListener("mousedown", onDown); diff --git a/A00_Common/spreadsheet/spreadsheet_numfmt.ts b/A00_Common/spreadsheet/spreadsheet_numfmt.ts index 1351fcab..b6533dff 100644 --- a/A00_Common/spreadsheet/spreadsheet_numfmt.ts +++ b/A00_Common/spreadsheet/spreadsheet_numfmt.ts @@ -14,6 +14,7 @@ import { fracToString, mul, roundAt, + sub, toInteger, type Frac, } from "@ui/sheet/ui_template_sheet_frac"; @@ -235,6 +236,130 @@ function renderNumericSection(toks: Tok[], magnitude: Frac): string { return out; } +// ── 분수 형식(`0/0` · `# ?/?` · `# ??/??` · 분모 고정 `# ?/8`) ─────────────── + +interface FracPlan { + prefix: Tok[]; + wholeDigits: number; // 0 = 정수부 자리 없음(가분수로 통째) + sepToks: Tok[]; // 정수부 · 분자 사이 리터럴(보통 칸 하나) + fixedDenom: bigint | null; // null = 분모 자리표시(자동 찾기) · 있으면 그 값 고정 + denomDigits: number; // 자동일 때 자리표시 개수(최대 분모 = 10^자리 - 1) + suffix: Tok[]; +} + +/** 슬래시 앞뒤 자리표시(0/#/?)·고정 숫자 자리를 찾음 — 없으면 분수 형식이 아님(null). */ +function detectFraction(toks: Tok[]): FracPlan | null { + if (toks.some((t) => t.k === "point")) return null; + const slashIdx = toks.findIndex((t) => t.k === "lit" && t.ch === "/"); + if (slashIdx < 0) return null; + + let i = slashIdx - 1; + while (i >= 0 && toks[i].k === "digit") i--; + const numeratorStart = i + 1; + if (numeratorStart === slashIdx) return null; // 분자 자리표시 없음 + + let j = slashIdx + 1; + let denomDigits = 0; + let fixedDigits = ""; + let mode: "auto" | "fixed" | null = null; + while (j < toks.length) { + const t = toks[j]; + if (t.k === "digit") { + if (mode === "fixed") break; + mode = "auto"; + denomDigits++; + j++; + continue; + } + if (t.k === "lit" && /[0-9]/.test(t.ch)) { + if (mode === "auto") break; + mode = "fixed"; + fixedDigits += t.ch; + j++; + continue; + } + break; + } + if (mode === null) return null; + const denomEnd = j; + + const sepToks: Tok[] = []; + let k = numeratorStart - 1; + while (k >= 0 && toks[k].k !== "digit") { + sepToks.unshift(toks[k]); + k--; + } + let wholeDigits = 0; + while (k >= 0 && toks[k].k === "digit") { + wholeDigits++; + k--; + } + const prefix = wholeDigits > 0 ? toks.slice(0, k + 1) : toks.slice(0, numeratorStart); + if (wholeDigits === 0) sepToks.length = 0; + + return { + prefix, + wholeDigits, + sepToks, + fixedDenom: mode === "fixed" ? BigInt(fixedDigits) : null, + denomDigits, + suffix: toks.slice(denomEnd), + }; +} + +/** 분모가 `maxDenom` 이하인 가장 가까운 분수(0 ≤ x) — 다 훑어봐도 999 번 안쪽(자리표시 최대 3 개). */ +function bestFraction(x: Frac, maxDenom: bigint): { num: bigint; den: bigint } { + let bestNum = 0n; + let bestDen = 1n; + let bestDiff: Frac | null = null; + for (let den = 1n; den <= maxDenom; den++) { + const num = toInteger(mul(x, frac(den)), "round"); + const gap = sub(x, frac(num, den)); + const diff = frac(gap.n < 0n ? -gap.n : gap.n, gap.d); + if (bestDiff === null || cmp(diff, bestDiff) < 0) { + bestDiff = diff; + bestNum = num; + bestDen = den; + } + } + return { num: bestNum, den: bestDen }; +} + +function renderFractionSection(plan: FracPlan, magnitude: Frac): string { + let whole = 0n; + let remainder = magnitude; + if (plan.wholeDigits > 0) { + whole = toInteger(magnitude, "trunc"); + remainder = sub(magnitude, frac(whole)); + } + let num: bigint; + let den: bigint; + if (plan.fixedDenom !== null) { + den = plan.fixedDenom; + num = toInteger(mul(remainder, frac(den)), "round"); + } else { + const maxDenom = 10n ** BigInt(plan.denomDigits) - 1n; + const best = bestFraction(remainder, maxDenom); + num = best.num; + den = best.den; + } + if (plan.wholeDigits > 0 && den > 0n && num >= den) { + whole += num / den; + num %= den; + } + const fracText = num !== 0n ? `${num}/${den}` : ""; + + let out = ""; + for (const t of plan.prefix) if (t.k === "lit") out += t.ch; + if (plan.wholeDigits > 0) { + if (whole !== 0n || fracText === "") out += whole.toString(); + if (fracText !== "") for (const t of plan.sepToks) if (t.k === "lit") out += t.ch; + } + out += fracText; + for (const t of plan.suffix) if (t.k === "lit") out += t.ch; + return out; +} + function generalFormat(value: Frac): string { return fracToString(value); } @@ -265,6 +390,11 @@ function formatNumber(value: Frac, code: string | undefined): FormattedText { if (color) extra.색 = color; if (fillChar) extra.채움글 = fillChar; const magnitude = cmp(value, ZERO) < 0 ? frac(-value.n, value.d) : value; + const fraction = detectFraction(toks); + if (fraction) { + const body = renderFractionSection(fraction, magnitude); + return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra }; + } if (!toks.some((t) => t.k === "digit" || t.k === "general")) { let out = ""; for (const t of toks) if (t.k === "lit") out += t.ch; diff --git a/A00_Common/spreadsheet/spreadsheet_sort_dialog.ts b/A00_Common/spreadsheet/spreadsheet_sort_dialog.ts new file mode 100644 index 00000000..2c660248 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_sort_dialog.ts @@ -0,0 +1,180 @@ +/* ============================================================================= + * spreadsheet_sort_dialog.ts (주인 A · 화면) + * 정렬 대화 상자 · 빠른 정렬(오름 · 내림). 엔진은 `spreadsheet_sortfilter.ts`(명령 `정렬`). + * 범위 = 고른 범위 · 칸 하나만 골랐으면 이어진 값 덩어리(엑셀 「현재 영역」) · 머리 행은 짐작해 켬. + * + * 잇는 법(D · sub7): + * · 도구 모음 · 메뉴 「정렬…」 → `openSortDialog(ctx)` — 창을 `ctx.root` 에 띄우고 닫는 함수를 돌려줌. + * · 「오름차순」 「내림차순」 단추 → `sortSelection(ctx, 내림)` — 활성 칸 열을 기준으로 바로 정렬. + * · 막히면(병합 걸침 등) 토스트로 까닭을 보임 · 되돌리기는 `ctx.dispatch` 가 쌓음. + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { colName, rangeToA1 } from "./spreadsheet_address"; +import { CommandError } from "./spreadsheet_commands"; +import { currentRegion, displayText, sortCommand } from "./spreadsheet_sortfilter"; +import type { SortKey, SortOptions } from "./spreadsheet_sortfilter"; +import type { CellRange } from "./spreadsheet_types"; +import type { SpreadsheetContext } from "./spreadsheet_view_types"; +import "./spreadsheet_sortfilter.css"; + +const TEXT = { + Title: ["정렬", "Sort"], + Range: ["범위", "Range"], + Header: ["머리 행 있음", "My data has headers"], + Case: ["대소문자 구분", "Case sensitive"], + Column: ["열", "Column"], + Asc: ["오름차순", "Ascending"], + Desc: ["내림차순", "Descending"], + AddKey: ["기준 더하기", "Add level"], + RemoveKey: ["빼기", "Remove"], + Cancel: ["취소", "Cancel"], + Ok: ["정렬", "Sort"], +}; +const t = (key: keyof typeof TEXT) => TEXT[key][currentLanguageIndex] ?? TEXT[key][0]; + +/** 정렬 범위 — 칸 하나면 현재 영역 */ +function targetRange(ctx: SpreadsheetContext): CellRange { + const g = ctx.selection.범위[0]; + if (g.r0 === g.r1 && g.c0 === g.c1) + return currentRegion(ctx.book, ctx.selection.시트, g.r0, g.c0); + return g; +} + +/** 머리 행 짐작 — 첫 행이 모두 글이고 둘째 행에 글 아닌 값이 있으면 머리 */ +function guessHeader(ctx: SpreadsheetContext, rg: CellRange): boolean { + if (rg.r0 === rg.r1) return false; + const v = (r: number, c: number) => ctx.engine.value(ctx.selection.시트, r, c); + let allText = true; + let secondOther = false; + for (let c = rg.c0; c <= rg.c1; c++) { + const a = v(rg.r0, c); + const b = v(rg.r0 + 1, c); + if (a !== null && typeof a !== "string") allText = false; + if (b !== null && typeof b !== "string") secondOther = true; + } + return allText && secondOther; +} + +function run(ctx: SpreadsheetContext, rg: CellRange, opts: SortOptions): boolean { + try { + ctx.dispatch(sortCommand(ctx.book, ctx.engine, ctx.selection.시트, rg, opts)); + return true; + } catch (e) { + if (!(e instanceof CommandError)) throw e; + showToast(e.message, "error"); + return false; + } +} + +/** 활성 칸 열 기준 바로 정렬 */ +export function sortSelection(ctx: SpreadsheetContext, desc: boolean): void { + if (ctx.readOnly) return; + const rg = targetRange(ctx); + const c = Math.min(Math.max(ctx.selection.활성.c, rg.c0), rg.c1); + run(ctx, rg, { 머리: guessHeader(ctx, rg), 기준: [{ 열: c, 내림: desc }] }); +} + +/** 정렬 대화 상자 — 닫는 함수를 돌려줌 */ +export function openSortDialog(ctx: SpreadsheetContext): () => void { + const rg = targetRange(ctx); + const sheet = ctx.selection.시트; + const keys: SortKey[] = [{ 열: Math.min(Math.max(ctx.selection.활성.c, rg.c0), rg.c1) }]; + + const header = el("input", { attrs: { type: "checkbox" } }); + header.checked = guessHeader(ctx, rg); + const caseBox = el("input", { attrs: { type: "checkbox" } }); + const list = el("div", { className: "ss-sort__keys" }); + const box = el("div", { + className: "ss-sort__box", + attrs: { role: "dialog", "aria-label": t("Title") }, + }); + const shade = el("div", { className: "ss-sort", children: [box] }); + + const columnLabel = (c: number) => { + const head = header.checked ? displayText(ctx.book, ctx.engine, sheet, rg.r0, c) : ""; + return head ? `${colName(c)}: ${head}` : `${t("Column")} ${colName(c)}`; + }; + + function drawKeys() { + list.replaceChildren( + ...keys.map((k, i) => { + const col = el("select", { attrs: { "aria-label": t("Column") } }); + for (let c = rg.c0; c <= rg.c1; c++) + col.append(el("option", { text: columnLabel(c), attrs: { value: String(c) } })); + col.value = String(k.열); + col.addEventListener("change", () => (k.열 = Number(col.value))); + const dir = el("select", { + children: [ + el("option", { text: t("Asc"), attrs: { value: "asc" } }), + el("option", { text: t("Desc"), attrs: { value: "desc" } }), + ], + }); + dir.value = k.내림 ? "desc" : "asc"; + dir.addEventListener("change", () => (k.내림 = dir.value === "desc")); + const remove = createButton({ + label: t("RemoveKey"), + variant: "ghost", + disabled: keys.length === 1, + onClick: () => { + keys.splice(i, 1); + drawKeys(); + }, + }); + return el("div", { className: "ss-sort__row", children: [col, dir, remove] }); + }), + ); + } + + const close = () => { + shade.remove(); + document.removeEventListener("keydown", onKey, true); + }; + const ok = () => { + if (run(ctx, rg, { 머리: header.checked, 기준: keys, 대소문자: caseBox.checked })) close(); + }; + const onKey = (ev: KeyboardEvent) => { + if (ev.key === "Escape") close(); + else if (ev.key === "Enter" && !(ev.target instanceof HTMLSelectElement)) ok(); + else return; + ev.preventDefault(); + ev.stopPropagation(); + }; + + header.addEventListener("change", drawKeys); + box.append( + el("div", { className: "ss-sort__title", text: t("Title") }), + el("div", { className: "ss-sort__meta", text: `${t("Range")} ${rangeToA1(rg)}` }), + el("label", { className: "ss-sort__meta", children: [header, t("Header")] }), + el("label", { className: "ss-sort__meta", children: [caseBox, t("Case")] }), + list, + el("div", { + className: "ss-sort__row", + children: [ + createButton({ + label: t("AddKey"), + variant: "ghost", + onClick: () => { + keys.push({ 열: rg.c0 }); + drawKeys(); + }, + }), + ], + }), + el("div", { + className: "ss-sort__foot", + children: [ + createButton({ label: t("Cancel"), variant: "ghost", onClick: close }), + createButton({ label: t("Ok"), onClick: ok }), + ], + }), + ); + shade.addEventListener("mousedown", (ev) => { + if (ev.target === shade) close(); + }); + document.addEventListener("keydown", onKey, true); + drawKeys(); + ctx.root.append(shade); + return close; +} diff --git a/A00_Common/spreadsheet/spreadsheet_sortfilter.css b/A00_Common/spreadsheet/spreadsheet_sortfilter.css new file mode 100644 index 00000000..57d40347 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_sortfilter.css @@ -0,0 +1,128 @@ +/* spreadsheet_sortfilter.css (주인 A) — 정렬 대화 상자 · 자동 필터 단추 · 값 목록 창 */ + +.ss-sort { + position: absolute; + inset: 0; + z-index: var(--z-dropdown); + display: flex; + align-items: center; + justify-content: center; + background: rgb(0 0 0 / 18%); +} + +.ss-sort__box, +.ss-filter__panel { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-12); + background: var(--color-surface-raised); + color: var(--color-text-body); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + box-shadow: var(--shadow-lg); + font-size: 13px; +} + +.ss-sort__box { + min-width: 360px; + max-width: 90%; +} + +.ss-sort__title { + font-weight: 600; +} + +.ss-sort__meta, +.ss-sort__row, +.ss-sort__foot, +.ss-filter__foot { + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +.ss-sort__meta { + color: var(--color-text-secondary); +} + +.ss-sort__row select { + flex: 1; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: inherit; +} + +.ss-sort__foot, +.ss-filter__foot { + justify-content: flex-end; +} + +/* 필터 단추 층 — 크기 0(격자 조작을 막지 않음) · 단추만 누름 */ +.ss-filter__layer { + position: absolute; + left: 0; + top: 0; + width: 0; + height: 0; + overflow: visible; +} + +.ss-filter__btn { + position: absolute; + width: 16px; + height: 16px; + padding: 0; + font-size: 9px; + line-height: 14px; + text-align: center; + cursor: pointer; + background: var(--color-surface); + color: var(--color-text-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); +} + +.ss-filter__btn.is-on { + color: var(--color-royal-amethyst); + border-color: var(--color-royal-amethyst); +} + +.ss-filter__panel { + position: absolute; + z-index: var(--z-dropdown); + width: 220px; +} + +.ss-filter__list { + display: flex; + flex-direction: column; + max-height: 220px; + overflow: auto; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); +} + +.ss-filter__list label { + display: flex; + gap: var(--spacing-4); + align-items: center; + white-space: nowrap; +} + +.ss-filter__link { + padding: 2px var(--spacing-4); + text-align: left; + background: none; + border: none; + color: inherit; + cursor: pointer; +} + +.ss-filter__link:hover { + background: var(--color-surface-sunken); +} diff --git a/A00_Common/spreadsheet/spreadsheet_sortfilter.ts b/A00_Common/spreadsheet/spreadsheet_sortfilter.ts new file mode 100644 index 00000000..e83f75f4 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_sortfilter.ts @@ -0,0 +1,289 @@ +/* ============================================================================= + * spreadsheet_sortfilter.ts (주인 A) + * 정렬 · 자동 필터 엔진 — DOM 모름. 값 비교 · 보이는 글은 엔진(`CalcEngine`) · 숫자 형식(B)으로 셈. + * 명령은 결과(정렬 차례 · 걸러진 행)를 박아 둠 → 다시 하기가 같은 결과 · 적용은 `applyCommand` 가 여기로 넘김. + * + * 엑셀 규칙: + * 정렬 차례 — 오름: 수 < 글 < 참거짓(FALSE !!s.행?.[r + 1]?.걸러짐; + +const rank = (v: Scalar) => + isFrac(v) ? 0 : typeof v === "string" ? 1 : typeof v === "boolean" ? 2 : 3; + +const blank = (v: Scalar) => v === null || v === ""; + +/** 엑셀 정렬 비교 — 빈 칸은 오름 · 내림 모두 맨 뒤 */ +export function sortCompare(a: Scalar, b: Scalar, desc = false, caseSensitive = false): number { + if (blank(a) || blank(b)) return blank(a) === blank(b) ? 0 : blank(a) ? 1 : -1; + let d = rank(a) - rank(b); + if (d === 0 && !isError(a)) { + if (typeof a === "string" && caseSensitive) d = COLLATE_CASE.compare(a, b as string); + else if (typeof a === "string") d = COLLATE.compare(a, b as string); + else d = compareValues(a, b); + } + return desc ? -d : d; +} + +const COLLATE = new Intl.Collator("ko", { sensitivity: "accent" }); +const COLLATE_CASE = new Intl.Collator("ko", { sensitivity: "variant", caseFirst: "lower" }); + +/** 칸 하나에서 이어진 값 덩어리(엑셀 「현재 영역」) — 빈 칸만 둘러싼 테두리까지 늘림 */ +export function currentRegion(book: Workbook, sheetId: string, r: number, c: number): CellRange { + const s = sheetOf(book, sheetId); + const filled = new Set(); + for (const [a1, cell] of Object.entries(s.칸)) + if (cell.값 !== undefined || cell.식 !== undefined) filled.add(a1); + const has = (rr: number, cc: number) => + rr >= 0 && cc >= 0 && rr < MAX_ROWS && cc < MAX_COLS && filled.has(toA1(rr, cc)); + const g = { r0: r, c0: c, r1: r, c1: c }; + for (let grew = true; grew;) { + grew = false; + const rowHas = (rr: number) => { + for (let cc = g.c0 - 1; cc <= g.c1 + 1; cc++) if (has(rr, cc)) return true; + return false; + }; + const colHas = (cc: number) => { + for (let rr = g.r0 - 1; rr <= g.r1 + 1; rr++) if (has(rr, cc)) return true; + return false; + }; + if (g.r0 > 0 && rowHas(g.r0 - 1)) (g.r0--, (grew = true)); + if (g.r1 < MAX_ROWS - 1 && rowHas(g.r1 + 1)) (g.r1++, (grew = true)); + if (g.c0 > 0 && colHas(g.c0 - 1)) (g.c0--, (grew = true)); + if (g.c1 < MAX_COLS - 1 && colHas(g.c1 + 1)) (g.c1++, (grew = true)); + } + return g; +} + +function checkMerges(s: Sheet, range: CellRange) { + const hit = (s.병합 ?? []).some((m) => { + const rg = parseRange(m); + return rg && rangesOverlap(rg, range); + }); + if (hit) throw new CommandError("병합한 칸이 든 범위는 정렬하지 못함 — 병합을 먼저 풂"); +} + +// ── 정렬 ─────────────────────────────────────────────────────────────────── + +/** 정렬 명령 만들기 — 값은 엔진 계산값으로 견줌 · 걸러진 행은 제자리 */ +export function sortCommand( + book: Workbook, + engine: CalcEngine, + sheetId: string, + range: CellRange, + opts: SortOptions, +): Command { + const s = sheetOf(book, sheetId); + const data: CellRange = { ...range, r0: range.r0 + (opts.머리 ? 1 : 0) }; + if (data.r0 > data.r1) throw new CommandError("정렬할 행이 없음"); + if (!opts.기준.length) throw new CommandError("정렬 기준이 없음"); + for (const k of opts.기준) + if (k.열 < range.c0 || k.열 > range.c1) throw new CommandError("정렬 기준 열이 범위 밖"); + checkMerges(s, data); + const rows: number[] = []; + for (let r = data.r0; r <= data.r1; r++) if (!filtered(s, r)) rows.push(r); + const keys = new Map(rows.map((r) => [r, opts.기준.map((k) => engine.value(sheetId, r, k.열))])); + const sorted = [...rows].sort((a, b) => { + const va = keys.get(a)!; + const vb = keys.get(b)!; + for (let i = 0; i < opts.기준.length; i++) { + const d = sortCompare(va[i], vb[i], opts.기준[i].내림, opts.대소문자); + if (d) return d; + } + return a - b; + }); + const order: number[] = []; + let next = 0; + for (let r = data.r0; r <= data.r1; r++) order.push(filtered(s, r) ? r : sorted[next++]); + return { 종류: "정렬", 시트: sheetId, 범위: data, 차례: order }; +} + +export function applySort(book: Workbook, cmd: Of<"정렬">): CommandEffect { + const s = sheetOf(book, cmd.시트); + const { r0, r1, c0, c1 } = cmd.범위; + const n = r1 - r0 + 1; + if ( + cmd.차례.length !== n || + new Set(cmd.차례).size !== n || + cmd.차례.some((r) => r < r0 || r > r1) + ) + throw new CommandError("정렬 차례가 범위와 안 맞음"); + checkMerges(s, cmd.범위); + const before = clone(s); + const take = (table: Record | undefined) => { + const out = new Map(); + for (let r = r0; r <= r1; r++) + for (let c = c0; c <= c1; c++) { + const a1 = toA1(r, c); + if (table && a1 in table) { + out.set(a1, table[a1]); + delete table[a1]; + } + } + return out; + }; + const cells = take(s.칸); + const notes = take(s.comments); + cmd.차례.forEach((src, i) => { + const to = r0 + i; + for (let c = c0; c <= c1; c++) { + const cell = cells.get(toA1(src, c)); + if (cell) { + const moved = { ...cell }; + if (moved.식 !== undefined) moved.식 = moveFormula(moved.식, to - src, 0); + s.칸[toA1(to, c)] = moved; + } + const note = notes.get(toA1(src, c)); + if (note !== undefined) (s.comments ??= {})[toA1(to, c)] = note; + } + }); + if (s.comments && !Object.keys(s.comments).length) delete s.comments; + return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: true }; +} + +// ── 자동 필터 ────────────────────────────────────────────────────────────── + +/** 칸의 보이는 글(숫자 형식 적용) — 빈 칸 "" */ +export function displayText( + book: Workbook, + engine: CalcEngine, + sheetId: string, + r: number, + c: number, +) { + const s = sheetOf(book, sheetId); + const cell = s.칸[toA1(r, c)]; + if (!cell || (cell.값 === undefined && cell.식 === undefined)) return ""; + const idx = cell.서식 ?? s.행?.[r + 1]?.서식 ?? s.열?.[colName(c)]?.서식 ?? s.기본?.서식 ?? 0; + return formatValue(engine.value(sheetId, r, c), book.서식[idx]?.형식).글; +} + +/** 필터 단추 목록 — 열의 서로 다른 보이는 글(정렬 차례 · 빈 칸 맨 뒤) · 켬 = 지금 보이는 값 */ +export function filterItems( + book: Workbook, + engine: CalcEngine, + sheetId: string, + c: number, +): { 글: string; 켬: boolean }[] { + const s = sheetOf(book, sheetId); + const f = s.필터 && parseRange(s.필터.범위); + if (!f) return []; + const allowed = s.필터!.조건?.[colName(c)]; + const seen = new Map(); + for (let r = f.r0 + 1; r <= f.r1; r++) { + const text = displayText(book, engine, sheetId, r, c); + if (!seen.has(text)) seen.set(text, text === "" ? null : engine.value(sheetId, r, c)); + } + return [...seen] + .sort((a, b) => sortCompare(a[1], b[1]) || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) + .map(([글]) => ({ 글, 켬: !allowed || allowed.includes(글) })); +} + +/** 필터 명령 — 범위 · 조건으로 걸러질 행을 셈(null = 필터 끔) */ +export function filterCommand( + book: Workbook, + engine: CalcEngine, + sheetId: string, + filter: AutoFilter | null, +): Command { + if (!filter) return { 종류: "필터", 시트: sheetId, 필터: null, 걸러짐: [] }; + const f = parseRange(filter.범위); + if (!f) throw new CommandError(`필터 범위 틀림: ${filter.범위}`); + const hidden: number[] = []; + const conds = Object.entries(filter.조건 ?? {}).map( + ([col, list]) => [colIndex(col), new Set(list)] as const, + ); + for (let r = f.r0 + 1; r <= f.r1; r++) + if (conds.some(([c, ok]) => !ok.has(displayText(book, engine, sheetId, r, c)))) hidden.push(r); + return { 종류: "필터", 시트: sheetId, 필터: filter, 걸러짐: hidden }; +} + +export function applyFilter(book: Workbook, cmd: Of<"필터">): CommandEffect { + const s = sheetOf(book, cmd.시트); + const before = clone(s); + if (cmd.필터) s.필터 = clone(cmd.필터); + else delete s.필터; + const table = (s.행 ??= {}); + for (const [k, info] of Object.entries(table)) { + delete info.걸러짐; + if (!Object.keys(info).length) delete table[k]; + } + for (const r of cmd.걸러짐) (table[String(r + 1)] ??= {}).걸러짐 = true; + if (!Object.keys(table).length) delete s.행; + return { undo: { 종류: "시트통째", 시트: before }, cells: [], rebuild: false }; +} + +/** 행열 넣기 · 지우기 때 필터 범위 · 조건 열 따라감(머리 행 · 끝 열까지 지우면 필터 없어짐) */ +export function shiftFilter(s: Sheet, rows: boolean, insert: boolean, at: number, n: number) { + const f = s.필터 && parseRange(s.필터.범위); + if (!s.필터 || !f) return; + const sp = rows + ? shiftSpan(f.r0, f.r1, insert, at, n, MAX_ROWS) + : shiftSpan(f.c0, f.c1, insert, at, n, MAX_COLS); + const headGone = rows && !insert && f.r0 >= at && f.r0 < at + n; + if (!sp || headGone) { + delete s.필터; + return; + } + const next = rows ? { ...f, r0: sp[0], r1: sp[1] } : { ...f, c0: sp[0], c1: sp[1] }; + s.필터.범위 = rangeToA1(next); + if (rows || !s.필터.조건) return; + const conds: Record = {}; + for (const [col, list] of Object.entries(s.필터.조건)) { + const c = shiftSpan(colIndex(col), colIndex(col), insert, at, n, MAX_COLS); + if (c) conds[colName(c[0])] = list; + } + s.필터.조건 = conds; +} diff --git a/A00_Common/spreadsheet/spreadsheet_types.ts b/A00_Common/spreadsheet/spreadsheet_types.ts index 0612f6b1..4767bcc6 100644 --- a/A00_Common/spreadsheet/spreadsheet_types.ts +++ b/A00_Common/spreadsheet/spreadsheet_types.ts @@ -14,7 +14,7 @@ * · xlsx 가져오기 · 내보내기 · 인쇄 · PDF 는 만들지 않음(단추도 없음). xlsx 는 시험(F)만 읽음. * * ── 크기 예산 (vite build · 압축(gzip) 뒤 · M02 산출근거를 열 때만 받는 조각) ────── - * 엔진 A+B 22 KB · 화면 C+D+E 28 KB · css 4 KB · 합 54 KB 안쪽. 재는 곳 = F(빌드 조각 크기 시험). + * 엔진 A+B 32 KB · 화면 C+D+E 28 KB · css 4 KB · 합 64 KB 안쪽. 재는 곳 = F(빌드 조각 크기 시험). * * ── 파일 · 주인 · 예산 줄 수 ───────────────────────────────────────────────── * 0 spreadsheet_types.ts 이 계약(타입) ~450 @@ -27,6 +27,11 @@ * A spreadsheet_refshift.ts 넣기 · 지우기 · 옮기기 · 복사 때 참조 옮김 ~300 * A spreadsheet_commands.ts 명령 적용 → 되돌림 짝 · 빈 통합문서 ~550 * A spreadsheet_history.ts 되돌리기 · 다시 ~100 + * A spreadsheet_commands_sheet.ts 시트 명령(더하기 · 지우기 · 이름 · 옮기기 · 통째) ~100 + * A spreadsheet_sortfilter.ts 정렬 · 자동 필터 엔진(차례 · 걸러짐 셈 · 적용) ~300 + * A spreadsheet_sort_dialog.ts 정렬 대화 상자 · 빠른 정렬(화면) ~250 + * A spreadsheet_filter.ts 자동 필터 단추 · 값 목록 창(화면) ~300 + * A spreadsheet_sortfilter.css ~150 * A spreadsheet_recalc.ts 통합문서 한 벌 풀이(서버 Node · 시험 진입) ~80 * B spreadsheet_functions.ts 함수 표(이름 → 뜻) · 인자 수 검사 ~120 * B spreadsheet_functions_math.ts 수학 · 삼각 · 끝수 ~400 @@ -125,6 +130,14 @@ export interface RowInfo { 높이?: number; 숨김?: boolean; 서식?: number; + /** 자동 필터가 숨긴 행 — 사용자 `숨김` 과 따로(필터 풀면 보임) · 격자는 숨김처럼 그림. */ + 걸러짐?: boolean; +} + +/** 자동 필터 — `범위` 첫 행 = 머리(단추) · `조건` = 열 글자 → 보일 값(보이는 글 · 빈 칸은 ""). 조건 없는 열은 다 보임. */ +export interface AutoFilter { + 범위: string; + 조건?: Record; } export interface Sheet { @@ -147,6 +160,8 @@ export interface Sheet { 기본?: { 서식?: number; 열폭?: number; 행높이?: number }; /** 칸 메모 — A1 열쇠 → 메모 글. 행열 넣기 · 지우기 · 잘라 붙이기 때 칸과 같이 옮김(지운 칸 메모는 지움). */ comments?: Record; + /** 자동 필터(시트에 하나) */ + 필터?: AutoFilter; } /** 새 시트 기본값 — 값은 사용자 협의로 확정(9_엑셀검토 8장 · 임의 확정 금지). 없으면 A 의 임시값. */ @@ -353,6 +368,11 @@ export type Command = | { 종류: "시트옮기기"; 시트: string; 자리: number } /** 되돌림 짝 전용 — 시트 통째 바꿔 넣기(행열 지우기 되돌림 등) */ | { 종류: "시트통째"; 시트: Sheet; 서식?: CellStyle[] } + /** 정렬 — `차례[i]` = 범위 r0+i 자리에 올 원래 행(범위 열만 옮김 · 식은 복사처럼 상대 참조만 옮김). + * 차례는 `sortCommand`(값 비교는 엔진)가 만듦 — 다시 하기가 같은 결과가 되게 명령에 박음. */ + | { 종류: "정렬"; 시트: string; 범위: CellRange; 차례: number[] } + /** 자동 필터 켜기 · 조건 바꾸기 · 끄기(null) — `걸러짐` = 숨길 행(`filterCommand` 가 값으로 셈) */ + | { 종류: "필터"; 시트: string; 필터: AutoFilter | null; 걸러짐: number[] } /** 여러 명령 = 되돌리기 한 번 */ | { 종류: "묶음"; 명령: Command[] }; diff --git a/resources/tester/spreadsheet/harness_sortfilter.html b/resources/tester/spreadsheet/harness_sortfilter.html new file mode 100644 index 00000000..7eaf596d --- /dev/null +++ b/resources/tester/spreadsheet/harness_sortfilter.html @@ -0,0 +1,28 @@ + + + + + 정렬 · 필터 시험 틀 + + + + +
+ + + + + +
+
+ + + + diff --git a/resources/tester/spreadsheet/harness_sortfilter.ts b/resources/tester/spreadsheet/harness_sortfilter.ts new file mode 100644 index 00000000..6703ba73 --- /dev/null +++ b/resources/tester/spreadsheet/harness_sortfilter.ts @@ -0,0 +1,85 @@ +/* 정렬 · 자동 필터 화면 시험 틀 입구 — 진짜 부품(createSpreadsheet)에 정렬 대화 상자 · 필터 단추를 얹음. + * sub7 잇기 전이라 `parts.mountTabs` 를 감싸 문맥(ctx)을 꺼내고, grid.render 뒤 filter.refresh 를 부름. + * 번들: node harness_sortfilter_bundle.cjs → tmp/spreadsheet_sortfilter/harness.js (git 밖) */ +import { createSpreadsheet, parts } from "../../../A00_Common/spreadsheet/spreadsheet"; +import { + openSortDialog, + sortSelection, +} from "../../../A00_Common/spreadsheet/spreadsheet_sort_dialog"; +import { attachFilter, toggleFilter } from "../../../A00_Common/spreadsheet/spreadsheet_filter"; +import type { SpreadsheetContext } from "../../../A00_Common/spreadsheet/spreadsheet_view_types"; +import type { Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types"; + +const w = window as unknown as Record; +let ctx: SpreadsheetContext | null = null; +const origTabs = parts.mountTabs; +parts.mountTabs = (c: SpreadsheetContext) => { + ctx = c; + return origTabs(c); +}; + +const rows: [string, string, number][] = [ + ["터파기", "토사", 12.5], + ["되메우기", "토사", 3], + ["잡석", "", 7.25], + ["터파기", "풍화암", 4], + ["콘크리트", "18-21-8", 9.9], + ["되메우기", "토사", 0], + ["거푸집", "합판 3회", 21], + ["터파기", "토사", 1.5], +]; +const 칸: Workbook["시트"][number]["칸"] = { + A1: { 값: "공종" }, + B1: { 값: "규격" }, + C1: { 값: "수량" }, + D1: { 값: "두 배" }, + C12: { 식: "SUM(C2:C9)" }, + B12: { 값: "합계" }, +}; +rows.forEach(([a, b, c], i) => { + const r = i + 2; + 칸[`A${r}`] = { 값: a }; + if (b) 칸[`B${r}`] = { 값: b }; + 칸[`C${r}`] = { 값: c, 서식: 1 }; + 칸[`D${r}`] = { 식: `C${r}*2`, 서식: 1 }; +}); +const book: Workbook = { + 종류: "통합문서", + 판: 1, + 열: "harness", + 서식: [{}, { 형식: "0.00" }], + 시트: [{ id: "s1", 이름: "정렬필터", 칸, 행: { 11: { 숨김: true } } }], + 활성: "s1", +}; + +const host = document.getElementById("host")!; +w.ss = createSpreadsheet(host, book, { + onChange: () => (w.ssChanges = Number(w.ssChanges ?? 0) + 1), +}); +const c = ctx as unknown as SpreadsheetContext; +const filter = attachFilter(c); +const render = c.grid.render.bind(c.grid); +c.grid.render = () => { + render(); + filter.refresh(); +}; +w.ssCtx = c; +w.ssFilter = filter; +w.ssSortDialog = () => openSortDialog(c); +w.ssSortQuick = (desc: boolean) => sortSelection(c, desc); +w.ssToggleFilter = () => toggleFilter(c); +/** 열 값(보이는 칸 글) — 판정용 */ +w.ssCol = (letter: string, r0: number, r1: number) => { + const out: string[] = []; + for (let r = r0; r <= r1; r++) + out.push(document.querySelector(`[data-a1="${letter}${r}"]`)?.textContent ?? ""); + return out; +}; +for (const [id, run] of [ + ["btn-sort", () => openSortDialog(c)], + ["btn-asc", () => sortSelection(c, false)], + ["btn-desc", () => sortSelection(c, true)], + ["btn-filter", () => toggleFilter(c)], + ["btn-undo", () => c.undo()], +] as const) + document.getElementById(id)!.addEventListener("click", run); diff --git a/resources/tester/spreadsheet/harness_sortfilter_bundle.cjs b/resources/tester/spreadsheet/harness_sortfilter_bundle.cjs new file mode 100644 index 00000000..632917a1 --- /dev/null +++ b/resources/tester/spreadsheet/harness_sortfilter_bundle.cjs @@ -0,0 +1,29 @@ +/* 정렬 · 필터 시험 틀 번들 — rolldown(이미 설치) 한 파일 · css 는