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
180 lines
7.0 KiB
TypeScript
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;
|
|
}
|