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_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_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..28c1da72 100644 --- a/A00_Common/spreadsheet/spreadsheet_types.ts +++ b/A00_Common/spreadsheet/spreadsheet_types.ts @@ -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 는