/* ============================================================================= * spreadsheet_find.ts (2단계 · 주인 sub_laptop_1) * 찾기 · 바꾸기 — 대소문자 구분 · 전체 칸 일치 · 「수식 안」(식 글 속 글자도 뒤짐) 옵션. * 값 칸은 `값`(수 · 참거짓은 글로 바꿔 견줌) · 식 칸은 기본으로 식 글(`=` 뺀 것)을 뒤짐(계산값은 * 이 계층에서 모름 — CalcEngine 은 C·D 쪽에만 있음). 바꾸기는 `dispatch({종류:"칸", …})` 하나로만. * * 잇는 법(D · sub7) — 1단계 격자 · 편집기가 서면: * 1) 도구 모음이나 Ctrl+F 단축키로 작은 패널을 띄우고 `createFindPanel(ctx)` 를 그 안에 붙임. * 2) 패널의 「다음 찾기」 는 `findNext` 결과로 `ctx.select` + `ctx.grid.reveal` 호출. * 3) 「모두 바꾸기」 버튼은 `replaceAll` 을 부르고 끝나면 바뀐 수를 토스트로. * ========================================================================== */ import type { CellInput, SheetCellAddress, Workbook } from "./spreadsheet_types"; import type { SpreadsheetContext } from "./spreadsheet_view_types"; export interface FindOptions { 대소문자?: boolean; 전체칸?: boolean; /** 식 칸은 식 글(수식) 속에서도 찾음 — 꺼지면 값 칸만 뒤짐(식 칸은 건너뜀) */ 수식안?: boolean; } const norm = (text: string, opts: FindOptions | undefined): string => opts?.대소문자 ? text : text.toLowerCase(); /** 칸 하나를 글로 — 식 칸은 `수식안` 이 있어야 식 글, 없으면 후보에서 빠짐(null). */ function cellText( book: Workbook, sheetId: string, r: number, c: number, opts?: FindOptions, ): string | null { const sheet = book.시트.find((s) => s.id === sheetId); const cell = sheet?.칸[a1(r, c)]; if (!cell) return null; if (cell.식 !== undefined) return opts?.수식안 ? cell.식 : null; const v = cell.값; if (v === undefined) return null; return typeof v === "boolean" ? (v ? "TRUE" : "FALSE") : String(v); } function a1(r: number, c: number): string { let col = c + 1; let letters = ""; while (col > 0) { const rem = (col - 1) % 26; letters = String.fromCharCode(65 + rem) + letters; col = Math.floor((col - 1) / 26); } return `${letters}${r + 1}`; } const matches = (haystack: string, query: string, opts: FindOptions | undefined): boolean => { const h = norm(haystack, opts); const q = norm(query, opts); return opts?.전체칸 ? h === q : h.includes(q); }; /** 시트 하나 안 찾는 순서 — 왼위부터 오른아래로(행 우선). */ function* cellsInOrder(book: Workbook, sheetId: string): Generator { const sheet = book.시트.find((s) => s.id === sheetId); if (!sheet) return; const addrs = Object.keys(sheet.칸) .map((key) => parseA1(key)) .sort((x, y) => x.r - y.r || x.c - y.c); for (const { r, c } of addrs) yield { 시트: sheetId, r, c }; } function parseA1(key: string): { r: number; c: number } { const m = /^([A-Z]+)(\d+)$/.exec(key); if (!m) return { r: 0, c: 0 }; let col = 0; for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64); return { r: Number(m[2]) - 1, c: col - 1 }; } /** 통합문서 전체(활성 시트 먼저 뒤지고 싶으면 `sheetOrder` 로 순서를 줌)에서 다 찾음. */ export function findAll( book: Workbook, query: string, opts?: FindOptions, sheetOrder?: string[], ): SheetCellAddress[] { if (!query) return []; const ids = sheetOrder ?? book.시트.map((s) => s.id); const out: SheetCellAddress[] = []; for (const id of ids) { for (const at of cellsInOrder(book, id)) { const text = cellText(book, at.시트, at.r, at.c, opts); if (text !== null && matches(text, query, opts)) out.push(at); } } return out; } /** `from` 다음 칸부터 한 바퀴 돌아 처음 걸리는 것(순환) · 없으면 null. */ export function findNext( book: Workbook, from: SheetCellAddress, query: string, opts?: FindOptions, ): SheetCellAddress | null { const all = findAll(book, query, opts, [ from.시트, ...book.시트.map((s) => s.id).filter((id) => id !== from.시트), ]); if (all.length === 0) return null; const idx = all.findIndex((m) => m.시트 === from.시트 && m.r === from.r && m.c === from.c); return all[(idx + 1) % all.length]; } /** 한 칸 바꾸기 — 식 칸(`수식안`)이면 식 글 속 글자를 바꿈 · 값 칸이면 값 전체(전체칸)나 부분(글만)을 바꿈. */ export function replaceOne( ctx: SpreadsheetContext, at: SheetCellAddress, query: string, replacement: string, opts?: FindOptions, ): void { const sheet = ctx.book.시트.find((s) => s.id === at.시트); const cell = sheet?.칸[a1(at.r, at.c)]; if (!sheet || !cell) return; const next: { 값?: CellInput; 식?: string; 서식?: number } = { 서식: cell.서식 }; if (cell.식 !== undefined && opts?.수식안) { next.식 = replaceText(cell.식, query, replacement, opts); } else if (cell.값 !== undefined) { const text = typeof cell.값 === "boolean" ? (cell.값 ? "TRUE" : "FALSE") : String(cell.값); next.값 = replaceText(text, query, replacement, opts); } else { return; } ctx.dispatch({ 종류: "칸", 시트: at.시트, 칸: { [a1(at.r, at.c)]: next } }); } function replaceText( text: string, query: string, replacement: string, opts: FindOptions | undefined, ): string { if (opts?.전체칸) return matches(text, query, opts) ? replacement : text; if (opts?.대소문자) return text.split(query).join(replacement); const re = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"); return text.replace(re, replacement); } /** 모두 바꾸기 — 한 번의 되돌리기로 묶어 적용 · 바뀐 칸 수를 돌려줌. */ export function replaceAll( ctx: SpreadsheetContext, query: string, replacement: string, opts?: FindOptions, ): number { const hits = findAll(ctx.book, query, opts); if (hits.length === 0) return 0; const bySheet = new Map(); for (const at of hits) bySheet.set(at.시트, [...(bySheet.get(at.시트) ?? []), at]); const commands = [...bySheet.entries()].map(([sheetId, ats]) => { const sheet = ctx.book.시트.find((s) => s.id === sheetId)!; const 칸: Record = {}; for (const at of ats) { const cell = sheet.칸[a1(at.r, at.c)]; if (!cell) continue; if (cell.식 !== undefined && opts?.수식안) { 칸[a1(at.r, at.c)] = { 식: replaceText(cell.식, query, replacement, opts), 서식: cell.서식, }; } else if (cell.값 !== undefined) { const text = typeof cell.값 === "boolean" ? (cell.값 ? "TRUE" : "FALSE") : String(cell.값); 칸[a1(at.r, at.c)] = { 값: replaceText(text, query, replacement, opts), 서식: cell.서식 }; } } return { 종류: "칸" as const, 시트: sheetId, 칸 }; }); ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands }); return hits.length; }