Skip to content

Commit 1b1198c

Browse files
SamTV12345claude
andcommitted
feat(sheet): find and replace
Ctrl+F / Ctrl+H open an Excel-style dialog with Find Next, Replace and Replace All, plus Match case and Entire cell; the ribbon gets a Find & Select menu in the Editing group. The search runs against the raw cell content, which is Excel default "Look in: Formulas": a formula is found by its text and a replacement rewrites the formula. Find Next walks row-major from the current cell and wraps around. Replace All emits one setCell op per changed cell within a single tick, so the whole sweep is one undo step. Case-insensitive replacement is done by walking the folded string instead of a regex, so untouched parts keep their original casing and no query needs escaping. The view gains setSelection() so a hit can be selected and scrolled into view the same way a click would, notifications included. Not covered: wildcards in the query, searching across all sheets, and Find All as a result list - the dialog reports the match count instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c1d9d6d commit 1b1198c

7 files changed

Lines changed: 467 additions & 2 deletions

File tree

playwright/specs/sheet_excel_chrome.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,48 @@ test.describe('Sheet Excel chrome', () => {
201201
await expect(cell(page, 1, 0)).toHaveText('b');
202202
});
203203

204+
test('find selects the matching cell, replace all rewrites every match', async ({ page }) => {
205+
const padId = `xl-find-${Date.now()}`;
206+
await openSheet(page, padId);
207+
await commitCell(page, 0, 0, 'alpha'); // A1
208+
await commitCell(page, 1, 0, 'beta'); // A2
209+
await commitCell(page, 2, 0, 'alphabet'); // A3
210+
211+
await cell(page, 0, 0).click(); // start on the first match
212+
await page.keyboard.press('Control+f');
213+
const dialog = page.locator('.sheet-find');
214+
await expect(dialog).toBeVisible();
215+
216+
await dialog.locator('input[type=text]').first().fill('alpha');
217+
// Find Next searches forward from the current cell, so A1 is skipped and
218+
// 'alphabet' in A3 comes next.
219+
await dialog.locator('button', { hasText: 'Find Next' }).click();
220+
await expect(cell(page, 2, 0)).toHaveClass(/sheet-sel-focus/);
221+
await expect(dialog.locator('.sheet-find-status')).toContainText('2 cells found');
222+
223+
// Past the last match it wraps around to A1.
224+
await dialog.locator('button', { hasText: 'Find Next' }).click();
225+
await expect(cell(page, 0, 0)).toHaveClass(/sheet-sel-focus/);
226+
227+
await page.keyboard.press('Escape');
228+
await expect(dialog).toBeHidden();
229+
230+
await cell(page, 0, 1).click(); // focus back on the grid before the shortcut
231+
await page.keyboard.press('Control+h');
232+
await dialog.locator('input[type=text]').first().fill('alpha');
233+
await dialog.locator('input[type=text]').nth(1).fill('gamma');
234+
await dialog.locator('button', { hasText: 'Replace All' }).click();
235+
await expect(cell(page, 0, 0)).toHaveText('gamma');
236+
await expect(cell(page, 2, 0)).toHaveText('gammabet');
237+
await expect(cell(page, 1, 0)).toHaveText('beta'); // untouched
238+
239+
// The sweep is one undo step.
240+
await page.keyboard.press('Escape');
241+
await page.locator('.sheet-toolbar button[title="Undo (Ctrl+Z)"]').click();
242+
await expect(cell(page, 0, 0)).toHaveText('alpha');
243+
await expect(cell(page, 2, 0)).toHaveText('alphabet');
244+
});
245+
204246
test('selected cell highlights its row and column headers', async ({ page }) => {
205247
const padId = `xl-headhl-${Date.now()}`;
206248
await openSheet(page, padId);
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { findAll, findNext, matches, replaceAll, replaceInRaw, type RawCell } from './findReplace';
3+
4+
const CELLS: RawCell[] = [
5+
{ row: 0, col: 0, raw: 'Apple' },
6+
{ row: 0, col: 1, raw: 'pineapple' },
7+
{ row: 1, col: 0, raw: 'Banana' },
8+
{ row: 2, col: 3, raw: '=SUM(A1:A2)' },
9+
{ row: 3, col: 0, raw: 'apple pie apple' },
10+
];
11+
12+
describe('matches', () => {
13+
it('is case-insensitive by default and substring-based', () => {
14+
expect(matches('Apple', 'apple')).toBe(true);
15+
expect(matches('pineapple', 'APPLE')).toBe(true);
16+
expect(matches('Apple', 'apple', { matchCase: true })).toBe(false);
17+
});
18+
19+
it('honours whole-cell matching', () => {
20+
expect(matches('Apple', 'apple', { wholeCell: true })).toBe(true);
21+
expect(matches('pineapple', 'apple', { wholeCell: true })).toBe(false);
22+
});
23+
24+
it('never matches an empty query', () => {
25+
expect(matches('anything', '')).toBe(false);
26+
});
27+
});
28+
29+
describe('findAll / findNext', () => {
30+
it('returns matches in row-major order', () => {
31+
expect(findAll(CELLS, 'apple')).toEqual([
32+
{ row: 0, col: 0 },
33+
{ row: 0, col: 1 },
34+
{ row: 3, col: 0 },
35+
]);
36+
});
37+
38+
it('searches formula text, like Excel looking in formulas', () => {
39+
expect(findAll(CELLS, 'sum')).toEqual([{ row: 2, col: 3 }]);
40+
});
41+
42+
it('steps forward from the current cell and wraps around', () => {
43+
expect(findNext(CELLS, 'apple', { row: 0, col: 0 })).toEqual({ row: 0, col: 1 });
44+
expect(findNext(CELLS, 'apple', { row: 0, col: 1 })).toEqual({ row: 3, col: 0 });
45+
expect(findNext(CELLS, 'apple', { row: 3, col: 0 })).toEqual({ row: 0, col: 0 }); // wrap
46+
expect(findNext(CELLS, 'nothing', { row: 0, col: 0 })).toBeNull();
47+
});
48+
49+
it('finds the only match even when standing on it', () => {
50+
expect(findNext(CELLS, 'banana', { row: 1, col: 0 })).toEqual({ row: 1, col: 0 });
51+
});
52+
});
53+
54+
describe('replaceInRaw', () => {
55+
it('replaces every occurrence and keeps the surrounding casing', () => {
56+
expect(replaceInRaw('apple pie apple', 'apple', 'pear')).toBe('pear pie pear');
57+
expect(replaceInRaw('Apple and APPLE', 'apple', 'pear')).toBe('pear and pear');
58+
expect(replaceInRaw('Apple and APPLE', 'apple', 'pear', { matchCase: true })).toBe('Apple and APPLE');
59+
});
60+
61+
it('swaps the whole content in whole-cell mode', () => {
62+
expect(replaceInRaw('Apple', 'apple', 'Pear', { wholeCell: true })).toBe('Pear');
63+
expect(replaceInRaw('pineapple', 'apple', 'Pear', { wholeCell: true })).toBe('pineapple');
64+
});
65+
66+
it('leaves non-matching content untouched', () => {
67+
expect(replaceInRaw('Banana', 'apple', 'pear')).toBe('Banana');
68+
});
69+
70+
it('does not loop forever when the replacement contains the query', () => {
71+
expect(replaceInRaw('a a', 'a', 'aa')).toBe('aa aa');
72+
});
73+
});
74+
75+
describe('replaceAll', () => {
76+
it('returns only the cells that actually change', () => {
77+
expect(replaceAll(CELLS, 'apple', 'pear')).toEqual([
78+
{ row: 0, col: 0, raw: 'pear' },
79+
{ row: 0, col: 1, raw: 'pinepear' },
80+
{ row: 3, col: 0, raw: 'pear pie pear' },
81+
]);
82+
});
83+
84+
it('rewrites formulas too', () => {
85+
expect(replaceAll(CELLS, 'A1:A2', 'B1:B2')).toEqual([{ row: 2, col: 3, raw: '=SUM(B1:B2)' }]);
86+
});
87+
88+
it('returns nothing when the query matches nothing', () => {
89+
expect(replaceAll(CELLS, 'kiwi', 'x')).toEqual([]);
90+
});
91+
});

ui/src/js/sheet/findReplace.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Pure find/replace over cell contents. Like Excel's default "Look in:
2+
// Formulas", the search runs against the raw cell content, so a formula is
3+
// found by its text and a replacement rewrites the formula itself.
4+
5+
export interface FindOptions {
6+
matchCase?: boolean;
7+
// Excel's "Match entire cell contents": the query must be the whole content
8+
// instead of appearing somewhere inside it.
9+
wholeCell?: boolean;
10+
}
11+
12+
export interface CellRef {
13+
row: number;
14+
col: number;
15+
}
16+
17+
export interface RawCell extends CellRef {
18+
raw: string;
19+
}
20+
21+
const fold = (s: string, matchCase: boolean | undefined): string => (matchCase ? s : s.toLowerCase());
22+
23+
export function matches(raw: string, query: string, opts: FindOptions = {}): boolean {
24+
if (query === '') return false;
25+
const hay = fold(raw, opts.matchCase);
26+
const needle = fold(query, opts.matchCase);
27+
return opts.wholeCell ? hay === needle : hay.includes(needle);
28+
}
29+
30+
// findAll returns every matching cell in row-major order — the order Excel
31+
// searches in, and the order Find Next steps through.
32+
export function findAll(cells: RawCell[], query: string, opts: FindOptions = {}): CellRef[] {
33+
return cells
34+
.filter((c) => matches(c.raw, query, opts))
35+
.sort((a, b) => a.row - b.row || a.col - b.col)
36+
.map(({ row, col }) => ({ row, col }));
37+
}
38+
39+
// findNext returns the first match strictly after `from` in row-major order,
40+
// wrapping around to the beginning. null when nothing matches at all.
41+
export function findNext(cells: RawCell[], query: string, from: CellRef, opts: FindOptions = {}): CellRef | null {
42+
const hits = findAll(cells, query, opts);
43+
if (hits.length === 0) return null;
44+
return hits.find((h) => h.row > from.row || (h.row === from.row && h.col > from.col)) ?? hits[0];
45+
}
46+
47+
// replaceInRaw rewrites every occurrence in one cell. In whole-cell mode the
48+
// content is swapped outright, matching Excel.
49+
export function replaceInRaw(raw: string, query: string, replacement: string, opts: FindOptions = {}): string {
50+
if (!matches(raw, query, opts)) return raw;
51+
if (opts.wholeCell) return replacement;
52+
if (opts.matchCase) return raw.split(query).join(replacement);
53+
// Case-insensitive: walk the folded string so the untouched parts keep their
54+
// original casing (a plain regex would need the query escaped anyway).
55+
const hay = raw.toLowerCase();
56+
const needle = query.toLowerCase();
57+
let out = '';
58+
let i = 0;
59+
for (let at = hay.indexOf(needle); at !== -1; at = hay.indexOf(needle, i)) {
60+
out += raw.slice(i, at) + replacement;
61+
i = at + needle.length;
62+
}
63+
return out + raw.slice(i);
64+
}
65+
66+
// replaceAll produces the cells whose content actually changes, so the caller
67+
// can emit one op per changed cell and nothing for the rest.
68+
export function replaceAll(cells: RawCell[], query: string, replacement: string, opts: FindOptions = {}): RawCell[] {
69+
const out: RawCell[] = [];
70+
for (const c of cells) {
71+
const next = replaceInRaw(c.raw, query, replacement, opts);
72+
if (next !== c.raw) out.push({ row: c.row, col: c.col, raw: next });
73+
}
74+
return out.sort((a, b) => a.row - b.row || a.col - b.col);
75+
}

ui/src/js/sheet/sheetEditor.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import { FormulaEngine } from './formulaEngine';
55
import { DomSheetView } from './sheetView';
66
import { SheetPresence, effectiveCells, type PresenceFrame } from './sheetPresence';
77
import { rangeToTSV, rangeToCSV, parseTSV, parseCSV, pasteOps, fillOps } from './sheetClipboard';
8-
import { normalize, selCells, selIsSingle, type Selection } from './sheetSelection';
8+
import { normalize, selCells, selFromSingle, selIsSingle, type Selection } from './sheetSelection';
99
import { createToolbar, type ToolbarCallbacks, type ToolbarElement } from './sheetToolbar';
1010
import { createSheetTabs } from './sheetTabs';
1111
import { sortRangeOps, distinctValues, hiddenRowsForFilter } from './sheetSortFilter';
1212
import { createFormulaBar, type FormulaBarHandle } from './sheetFormulaBar';
13+
import { createFindDialog } from './sheetFindDialog';
14+
import { findAll, findNext, matches, replaceAll, replaceInRaw } from './findReplace';
1315
import { rangeRefA1 } from './a1';
1416
import { mergeProps } from './styleCss';
1517
import { formatValue } from './format';
@@ -394,6 +396,7 @@ export function startSheetEditor(root: HTMLElement): void {
394396
undo: () => doHistory('undo'),
395397
redo: () => doHistory('redo'),
396398
history: () => ({ canUndo: collab?.canUndo() ?? false, canRedo: collab?.canRedo() ?? false }),
399+
openFind: (mode: 'find' | 'replace') => findDialog.open(readOnly ? 'find' : mode),
397400
clear: (what: 'all' | 'formats' | 'contents') => {
398401
if (readOnly || !collab) return;
399402
blurActiveCell();
@@ -504,6 +507,7 @@ export function startSheetEditor(root: HTMLElement): void {
504507
root.appendChild(toolbar);
505508
root.appendChild(formulaBar.el);
506509
root.appendChild(gridHost);
510+
root.appendChild(findDialog.el);
507511

508512
const setActiveSheet = (id: string): void => {
509513
if (id === activeSheetId) return;
@@ -650,6 +654,44 @@ export function startSheetEditor(root: HTMLElement): void {
650654
for (const op of pasteOps(grid, { row: r0, col: c0 }, activeSheetId, collab.rev)) collab.applyLocal(op);
651655
});
652656
};
657+
// --- Find & Replace ---------------------------------------------------
658+
// Searches the raw cell content (Excel's default "Look in: Formulas"), so a
659+
// formula is found by its text and a replacement rewrites the formula.
660+
const findDialog = createFindDialog({
661+
readOnly: false, // re-checked per action: `readOnly` is only known after the handshake
662+
findNext: (query, opts) => {
663+
const cells = cellsOfActive();
664+
const hit = findNext(cells, query, selection.focus, opts);
665+
if (hit) view?.setSelection(selFromSingle(hit.row, hit.col));
666+
return { found: hit !== null, total: findAll(cells, query, opts).length };
667+
},
668+
replace: (query, replacement, opts) => {
669+
const here = selection.focus;
670+
const raw = rawValue(here.row, here.col);
671+
let replaced = false;
672+
if (!readOnly && collab && matches(raw, query, opts)) {
673+
collab.applyLocal({
674+
type: 'setCell', sheet: activeSheetId, baseRev: collab.rev,
675+
row: here.row, col: here.col, raw: replaceInRaw(raw, query, replacement, opts),
676+
});
677+
replaced = true;
678+
}
679+
const hit = findNext(cellsOfActive(), query, here, opts);
680+
if (hit) view?.setSelection(selFromSingle(hit.row, hit.col));
681+
return { replaced, total: findAll(cellsOfActive(), query, opts).length };
682+
},
683+
replaceAll: (query, replacement, opts) => {
684+
if (readOnly || !collab) return 0;
685+
blurActiveCell();
686+
const changed = replaceAll(cellsOfActive(), query, replacement, opts);
687+
// One tick, so the whole sweep is a single undo step.
688+
for (const c of changed) {
689+
collab.applyLocal({ type: 'setCell', sheet: activeSheetId, baseRev: collab.rev, row: c.row, col: c.col, raw: c.raw });
690+
}
691+
return changed.length;
692+
},
693+
});
694+
653695
// Undo/redo this client's own edits. Blur first so a half-typed cell does not
654696
// get committed over the restored value by the blur handler.
655697
const doHistory = (which: 'undo' | 'redo'): void => {
@@ -686,6 +728,12 @@ export function startSheetEditor(root: HTMLElement): void {
686728
doPaste();
687729
return;
688730
}
731+
// Ctrl+F / Ctrl+H open the dialog (Ctrl+H only when it can replace).
732+
if (mod && !editingNow() && (e.key === 'f' || e.key === 'F' || e.key === 'h' || e.key === 'H')) {
733+
e.preventDefault();
734+
findDialog.open(e.key.toLowerCase() === 'h' && !readOnly ? 'replace' : 'find');
735+
return;
736+
}
689737
// Ctrl+Z / Ctrl+Y (and Ctrl+Shift+Z) — only outside cell editing, where the
690738
// browser's own text undo still owns the keystroke.
691739
if (mod && !editingNow() && !readOnly) {

0 commit comments

Comments
 (0)