Files
Aislo/A00_Common/spreadsheet/spreadsheet_find.ts
T
eomsangdonandClaude Sonnet 5 d38ef573e0 feat(spreadsheet): 2단계 다섯 — 찾기 · 붙여넣기 골라서 · 서식 붓 · 폭 자동 맞춤 · 함수 도움말
PLAN 11-8 스프레드시트 2단계. 0 계약(spreadsheet_types.ts · spreadsheet_view_types.ts)
타입만 보고 새 파일 다섯 짬 — 잇기는 1단계 끝에 D(sub7)가 파일 머리 주석대로:

- spreadsheet_find.ts        찾기 · 바꾸기(대소문자 · 전체칸 · 수식 안)
- spreadsheet_paste_special.ts 붙여넣기 골라서(값만 · 서식만 · 수식만)
- spreadsheet_format_painter.ts 서식 붓(칸→행→열→기본 차례로 서식을 찾음)
- spreadsheet_autofit.ts      열 머리 두 번 눌러 폭 자동 맞춤(어림 재기 · 실제 재기 자리 열어 둠)
- spreadsheet_func_help.ts    함수 자동 완성 · 인자 도움말(1단계 함수 48 개)

여러 범위 · 확대 · 눈금선 끄기는 이미 계약에 있어 새 파일 없이 붙일 자리만 확인(autofit.ts 머리).
시험 = resources/tester/spreadsheet/test_stage2_*.ts(공용 견본 · 가짜 컨텍스트는 test_stage2_helpers.ts).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-27 20:20:16 +09:00

180 lines
7.0 KiB
TypeScript

/* =============================================================================
* 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<SheetCellAddress> {
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<string, SheetCellAddress[]>();
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<string, { 값?: CellInput; 식?: string; 서식?: number }> = {};
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;
}