diff --git a/playwright/specs/sheet_excel_chrome.spec.ts b/playwright/specs/sheet_excel_chrome.spec.ts
index 171ebf8..d828cb8 100644
--- a/playwright/specs/sheet_excel_chrome.spec.ts
+++ b/playwright/specs/sheet_excel_chrome.spec.ts
@@ -159,11 +159,36 @@ test.describe('Sheet Excel chrome', () => {
await ctx.close();
});
+ test('typing over a selected cell replaces it, F2 and double-click edit it', async ({ page }) => {
+ const padId = `xl-overwrite-${Date.now()}`;
+ await openSheet(page, padId);
+ await commitCell(page, 0, 0, 'old'); // A1
+
+ // Selected, not editing: the first keystroke wipes the old content.
+ await cell(page, 0, 0).click();
+ await page.keyboard.type('new', { delay: 30 });
+ await page.keyboard.press('Enter');
+ await expect(cell(page, 0, 0)).toHaveText('new');
+
+ // F2 keeps the content and appends at the end.
+ await cell(page, 0, 0).click();
+ await page.keyboard.press('F2');
+ await page.keyboard.type('er', { delay: 30 });
+ await page.keyboard.press('Enter');
+ await expect(cell(page, 0, 0)).toHaveText('newer');
+
+ // Double-click also edits in place instead of overwriting.
+ await cell(page, 0, 0).dblclick();
+ await page.keyboard.type('!', { delay: 30 });
+ await page.keyboard.press('Enter');
+ await expect(cell(page, 0, 0)).toHaveText(/newer/);
+ await expect(cell(page, 0, 0)).toHaveText(/!/);
+ });
+
test('undo and redo revert and reapply an edit', async ({ page }) => {
const padId = `xl-undo-${Date.now()}`;
await openSheet(page, padId);
- // Typing into a cell appends to its content, so each value goes into a
- // fresh cell — this test is about the history, not about cell editing.
+ // Two separate cells, so each edit is its own history entry.
await commitCell(page, 0, 0, 'first'); // A1
await commitCell(page, 1, 0, 'second'); // A2
@@ -201,6 +226,43 @@ test.describe('Sheet Excel chrome', () => {
await expect(cell(page, 1, 0)).toHaveText('b');
});
+ test('find selects the matching cell, replace all rewrites every match', async ({ page }) => {
+ const padId = `xl-find-${Date.now()}`;
+ await openSheet(page, padId);
+ await commitCell(page, 0, 0, 'alpha'); // A1
+ await commitCell(page, 1, 0, 'beta'); // A2
+ await commitCell(page, 2, 0, 'alphabet'); // A3
+
+ await cell(page, 0, 1).click(); // park the selection outside the matches
+ await page.keyboard.press('Control+f');
+ const dialog = page.locator('.sheet-find');
+ await expect(dialog).toBeVisible();
+
+ await dialog.locator('input[type=text]').first().fill('alpha');
+ await dialog.locator('button', { hasText: 'Find Next' }).click();
+ // A1 is the first match in row-major order and becomes the focused cell.
+ await expect(cell(page, 0, 0)).toHaveClass(/sheet-sel-focus/);
+ await expect(dialog.locator('.sheet-find-status')).toContainText('2 cells found');
+
+ await page.keyboard.press('Escape');
+ await expect(dialog).toBeHidden();
+
+ await cell(page, 0, 1).click(); // focus back on the grid before the shortcut
+ await page.keyboard.press('Control+h');
+ await dialog.locator('input[type=text]').first().fill('alpha');
+ await dialog.locator('input[type=text]').nth(1).fill('gamma');
+ await dialog.locator('button', { hasText: 'Replace All' }).click();
+ await expect(cell(page, 0, 0)).toHaveText('gamma');
+ await expect(cell(page, 2, 0)).toHaveText('gammabet');
+ await expect(cell(page, 1, 0)).toHaveText('beta'); // untouched
+
+ // The sweep is one undo step.
+ await page.keyboard.press('Escape');
+ await page.locator('.sheet-toolbar button[title="Undo (Ctrl+Z)"]').click();
+ await expect(cell(page, 0, 0)).toHaveText('alpha');
+ await expect(cell(page, 2, 0)).toHaveText('alphabet');
+ });
+
test('selected cell highlights its row and column headers', async ({ page }) => {
const padId = `xl-headhl-${Date.now()}`;
await openSheet(page, padId);
diff --git a/ui/src/js/sheet/findReplace.test.ts b/ui/src/js/sheet/findReplace.test.ts
new file mode 100644
index 0000000..1520d8b
--- /dev/null
+++ b/ui/src/js/sheet/findReplace.test.ts
@@ -0,0 +1,91 @@
+import { describe, it, expect } from 'vitest';
+import { findAll, findNext, matches, replaceAll, replaceInRaw, type RawCell } from './findReplace';
+
+const CELLS: RawCell[] = [
+ { row: 0, col: 0, raw: 'Apple' },
+ { row: 0, col: 1, raw: 'pineapple' },
+ { row: 1, col: 0, raw: 'Banana' },
+ { row: 2, col: 3, raw: '=SUM(A1:A2)' },
+ { row: 3, col: 0, raw: 'apple pie apple' },
+];
+
+describe('matches', () => {
+ it('is case-insensitive by default and substring-based', () => {
+ expect(matches('Apple', 'apple')).toBe(true);
+ expect(matches('pineapple', 'APPLE')).toBe(true);
+ expect(matches('Apple', 'apple', { matchCase: true })).toBe(false);
+ });
+
+ it('honours whole-cell matching', () => {
+ expect(matches('Apple', 'apple', { wholeCell: true })).toBe(true);
+ expect(matches('pineapple', 'apple', { wholeCell: true })).toBe(false);
+ });
+
+ it('never matches an empty query', () => {
+ expect(matches('anything', '')).toBe(false);
+ });
+});
+
+describe('findAll / findNext', () => {
+ it('returns matches in row-major order', () => {
+ expect(findAll(CELLS, 'apple')).toEqual([
+ { row: 0, col: 0 },
+ { row: 0, col: 1 },
+ { row: 3, col: 0 },
+ ]);
+ });
+
+ it('searches formula text, like Excel looking in formulas', () => {
+ expect(findAll(CELLS, 'sum')).toEqual([{ row: 2, col: 3 }]);
+ });
+
+ it('steps forward from the current cell and wraps around', () => {
+ expect(findNext(CELLS, 'apple', { row: 0, col: 0 })).toEqual({ row: 0, col: 1 });
+ expect(findNext(CELLS, 'apple', { row: 0, col: 1 })).toEqual({ row: 3, col: 0 });
+ expect(findNext(CELLS, 'apple', { row: 3, col: 0 })).toEqual({ row: 0, col: 0 }); // wrap
+ expect(findNext(CELLS, 'nothing', { row: 0, col: 0 })).toBeNull();
+ });
+
+ it('finds the only match even when standing on it', () => {
+ expect(findNext(CELLS, 'banana', { row: 1, col: 0 })).toEqual({ row: 1, col: 0 });
+ });
+});
+
+describe('replaceInRaw', () => {
+ it('replaces every occurrence and keeps the surrounding casing', () => {
+ expect(replaceInRaw('apple pie apple', 'apple', 'pear')).toBe('pear pie pear');
+ expect(replaceInRaw('Apple and APPLE', 'apple', 'pear')).toBe('pear and pear');
+ expect(replaceInRaw('Apple and APPLE', 'apple', 'pear', { matchCase: true })).toBe('Apple and APPLE');
+ });
+
+ it('swaps the whole content in whole-cell mode', () => {
+ expect(replaceInRaw('Apple', 'apple', 'Pear', { wholeCell: true })).toBe('Pear');
+ expect(replaceInRaw('pineapple', 'apple', 'Pear', { wholeCell: true })).toBe('pineapple');
+ });
+
+ it('leaves non-matching content untouched', () => {
+ expect(replaceInRaw('Banana', 'apple', 'pear')).toBe('Banana');
+ });
+
+ it('does not loop forever when the replacement contains the query', () => {
+ expect(replaceInRaw('a a', 'a', 'aa')).toBe('aa aa');
+ });
+});
+
+describe('replaceAll', () => {
+ it('returns only the cells that actually change', () => {
+ expect(replaceAll(CELLS, 'apple', 'pear')).toEqual([
+ { row: 0, col: 0, raw: 'pear' },
+ { row: 0, col: 1, raw: 'pinepear' },
+ { row: 3, col: 0, raw: 'pear pie pear' },
+ ]);
+ });
+
+ it('rewrites formulas too', () => {
+ expect(replaceAll(CELLS, 'A1:A2', 'B1:B2')).toEqual([{ row: 2, col: 3, raw: '=SUM(B1:B2)' }]);
+ });
+
+ it('returns nothing when the query matches nothing', () => {
+ expect(replaceAll(CELLS, 'kiwi', 'x')).toEqual([]);
+ });
+});
diff --git a/ui/src/js/sheet/findReplace.ts b/ui/src/js/sheet/findReplace.ts
new file mode 100644
index 0000000..739218c
--- /dev/null
+++ b/ui/src/js/sheet/findReplace.ts
@@ -0,0 +1,75 @@
+// Pure find/replace over cell contents. Like Excel's default "Look in:
+// Formulas", the search runs against the raw cell content, so a formula is
+// found by its text and a replacement rewrites the formula itself.
+
+export interface FindOptions {
+ matchCase?: boolean;
+ // Excel's "Match entire cell contents": the query must be the whole content
+ // instead of appearing somewhere inside it.
+ wholeCell?: boolean;
+}
+
+export interface CellRef {
+ row: number;
+ col: number;
+}
+
+export interface RawCell extends CellRef {
+ raw: string;
+}
+
+const fold = (s: string, matchCase: boolean | undefined): string => (matchCase ? s : s.toLowerCase());
+
+export function matches(raw: string, query: string, opts: FindOptions = {}): boolean {
+ if (query === '') return false;
+ const hay = fold(raw, opts.matchCase);
+ const needle = fold(query, opts.matchCase);
+ return opts.wholeCell ? hay === needle : hay.includes(needle);
+}
+
+// findAll returns every matching cell in row-major order — the order Excel
+// searches in, and the order Find Next steps through.
+export function findAll(cells: RawCell[], query: string, opts: FindOptions = {}): CellRef[] {
+ return cells
+ .filter((c) => matches(c.raw, query, opts))
+ .sort((a, b) => a.row - b.row || a.col - b.col)
+ .map(({ row, col }) => ({ row, col }));
+}
+
+// findNext returns the first match strictly after `from` in row-major order,
+// wrapping around to the beginning. null when nothing matches at all.
+export function findNext(cells: RawCell[], query: string, from: CellRef, opts: FindOptions = {}): CellRef | null {
+ const hits = findAll(cells, query, opts);
+ if (hits.length === 0) return null;
+ return hits.find((h) => h.row > from.row || (h.row === from.row && h.col > from.col)) ?? hits[0];
+}
+
+// replaceInRaw rewrites every occurrence in one cell. In whole-cell mode the
+// content is swapped outright, matching Excel.
+export function replaceInRaw(raw: string, query: string, replacement: string, opts: FindOptions = {}): string {
+ if (!matches(raw, query, opts)) return raw;
+ if (opts.wholeCell) return replacement;
+ if (opts.matchCase) return raw.split(query).join(replacement);
+ // Case-insensitive: walk the folded string so the untouched parts keep their
+ // original casing (a plain regex would need the query escaped anyway).
+ const hay = raw.toLowerCase();
+ const needle = query.toLowerCase();
+ let out = '';
+ let i = 0;
+ for (let at = hay.indexOf(needle); at !== -1; at = hay.indexOf(needle, i)) {
+ out += raw.slice(i, at) + replacement;
+ i = at + needle.length;
+ }
+ return out + raw.slice(i);
+}
+
+// replaceAll produces the cells whose content actually changes, so the caller
+// can emit one op per changed cell and nothing for the rest.
+export function replaceAll(cells: RawCell[], query: string, replacement: string, opts: FindOptions = {}): RawCell[] {
+ const out: RawCell[] = [];
+ for (const c of cells) {
+ const next = replaceInRaw(c.raw, query, replacement, opts);
+ if (next !== c.raw) out.push({ row: c.row, col: c.col, raw: next });
+ }
+ return out.sort((a, b) => a.row - b.row || a.col - b.col);
+}
diff --git a/ui/src/js/sheet/sheetEditor.ts b/ui/src/js/sheet/sheetEditor.ts
index 6a88f5a..e8e8128 100644
--- a/ui/src/js/sheet/sheetEditor.ts
+++ b/ui/src/js/sheet/sheetEditor.ts
@@ -5,11 +5,13 @@ import { FormulaEngine } from './formulaEngine';
import { DomSheetView } from './sheetView';
import { SheetPresence, effectiveCells, type PresenceFrame } from './sheetPresence';
import { rangeToTSV, rangeToCSV, parseTSV, parseCSV, pasteOps, fillOps } from './sheetClipboard';
-import { normalize, selCells, selIsSingle, type Selection } from './sheetSelection';
+import { normalize, selCells, selFromSingle, selIsSingle, type Selection } from './sheetSelection';
import { createToolbar, type ToolbarCallbacks, type ToolbarElement } from './sheetToolbar';
import { createSheetTabs } from './sheetTabs';
import { sortRangeOps, distinctValues, hiddenRowsForFilter } from './sheetSortFilter';
import { createFormulaBar, type FormulaBarHandle } from './sheetFormulaBar';
+import { createFindDialog } from './sheetFindDialog';
+import { findAll, findNext, matches, replaceAll, replaceInRaw } from './findReplace';
import { rangeRefA1 } from './a1';
import { mergeProps } from './styleCss';
import { formatValue } from './format';
@@ -394,6 +396,7 @@ export function startSheetEditor(root: HTMLElement): void {
undo: () => doHistory('undo'),
redo: () => doHistory('redo'),
history: () => ({ canUndo: collab?.canUndo() ?? false, canRedo: collab?.canRedo() ?? false }),
+ openFind: (mode: 'find' | 'replace') => findDialog.open(readOnly ? 'find' : mode),
clear: (what: 'all' | 'formats' | 'contents') => {
if (readOnly || !collab) return;
blurActiveCell();
@@ -504,6 +507,7 @@ export function startSheetEditor(root: HTMLElement): void {
root.appendChild(toolbar);
root.appendChild(formulaBar.el);
root.appendChild(gridHost);
+ root.appendChild(findDialog.el);
const setActiveSheet = (id: string): void => {
if (id === activeSheetId) return;
@@ -650,6 +654,44 @@ export function startSheetEditor(root: HTMLElement): void {
for (const op of pasteOps(grid, { row: r0, col: c0 }, activeSheetId, collab.rev)) collab.applyLocal(op);
});
};
+ // --- Find & Replace ---------------------------------------------------
+ // Searches the raw cell content (Excel's default "Look in: Formulas"), so a
+ // formula is found by its text and a replacement rewrites the formula.
+ const findDialog = createFindDialog({
+ readOnly: false, // re-checked per action: `readOnly` is only known after the handshake
+ findNext: (query, opts) => {
+ const cells = cellsOfActive();
+ const hit = findNext(cells, query, selection.focus, opts);
+ if (hit) view?.setSelection(selFromSingle(hit.row, hit.col));
+ return { found: hit !== null, total: findAll(cells, query, opts).length };
+ },
+ replace: (query, replacement, opts) => {
+ const here = selection.focus;
+ const raw = rawValue(here.row, here.col);
+ let replaced = false;
+ if (!readOnly && collab && matches(raw, query, opts)) {
+ collab.applyLocal({
+ type: 'setCell', sheet: activeSheetId, baseRev: collab.rev,
+ row: here.row, col: here.col, raw: replaceInRaw(raw, query, replacement, opts),
+ });
+ replaced = true;
+ }
+ const hit = findNext(cellsOfActive(), query, here, opts);
+ if (hit) view?.setSelection(selFromSingle(hit.row, hit.col));
+ return { replaced, total: findAll(cellsOfActive(), query, opts).length };
+ },
+ replaceAll: (query, replacement, opts) => {
+ if (readOnly || !collab) return 0;
+ blurActiveCell();
+ const changed = replaceAll(cellsOfActive(), query, replacement, opts);
+ // One tick, so the whole sweep is a single undo step.
+ for (const c of changed) {
+ collab.applyLocal({ type: 'setCell', sheet: activeSheetId, baseRev: collab.rev, row: c.row, col: c.col, raw: c.raw });
+ }
+ return changed.length;
+ },
+ });
+
// Undo/redo this client's own edits. Blur first so a half-typed cell does not
// get committed over the restored value by the blur handler.
const doHistory = (which: 'undo' | 'redo'): void => {
@@ -686,6 +728,12 @@ export function startSheetEditor(root: HTMLElement): void {
doPaste();
return;
}
+ // Ctrl+F / Ctrl+H open the dialog (Ctrl+H only when it can replace).
+ if (mod && !editingNow() && (e.key === 'f' || e.key === 'F' || e.key === 'h' || e.key === 'H')) {
+ e.preventDefault();
+ findDialog.open(e.key.toLowerCase() === 'h' && !readOnly ? 'replace' : 'find');
+ return;
+ }
// Ctrl+Z / Ctrl+Y (and Ctrl+Shift+Z) — only outside cell editing, where the
// browser's own text undo still owns the keystroke.
if (mod && !editingNow() && !readOnly) {
diff --git a/ui/src/js/sheet/sheetFindDialog.ts b/ui/src/js/sheet/sheetFindDialog.ts
new file mode 100644
index 0000000..a4c94e9
--- /dev/null
+++ b/ui/src/js/sheet/sheetFindDialog.ts
@@ -0,0 +1,180 @@
+// Excel's Find & Replace dialog. Owns only its DOM and option state; the
+// editor supplies the cells and performs the selection/op side effects.
+import type { FindOptions } from './findReplace';
+
+export interface FindDialogCallbacks {
+ // Selects the next match after the current cell and reports what happened.
+ findNext: (query: string, opts: FindOptions) => { found: boolean; total: number };
+ // Replaces in the current cell if it matches, then advances.
+ replace: (query: string, replacement: string, opts: FindOptions) => { replaced: boolean; total: number };
+ replaceAll: (query: string, replacement: string, opts: FindOptions) => number;
+ readOnly: boolean;
+}
+
+export interface FindDialogHandle {
+ el: HTMLElement;
+ open: (mode: 'find' | 'replace') => void;
+ close: () => void;
+ isOpen: () => boolean;
+}
+
+const CSS = `
+.sheet-find { position: fixed; top: 90px; right: 24px; z-index: 50; width: 320px; background: #fff;
+ border: 1px solid #d4d8dd; box-shadow: 0 6px 18px rgba(0,0,0,0.18); border-radius: 4px;
+ font: 13px system-ui, sans-serif; color: #333; }
+.sheet-find[hidden] { display: none; }
+.sheet-find-head { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px;
+ background: #107c41; color: #fff; border-radius: 3px 3px 0 0; font-weight: 600; }
+.sheet-find-head button { background: none; border: none; color: #fff; font-size: 15px; cursor: pointer; line-height: 1; }
+.sheet-find-body { padding: 10px; display: grid; grid-template-columns: auto 1fr; gap: 6px 8px; align-items: center; }
+.sheet-find-body input[type=text] { width: 100%; box-sizing: border-box; height: 26px; padding: 0 6px;
+ border: 1px solid #d4d8dd; border-radius: 2px; font: 13px system-ui, sans-serif; }
+.sheet-find-opts { grid-column: 1 / -1; display: flex; gap: 14px; padding-top: 2px; }
+.sheet-find-opts label { display: flex; align-items: center; gap: 4px; font-size: 12px; cursor: pointer; }
+.sheet-find-actions { grid-column: 1 / -1; display: flex; gap: 6px; justify-content: flex-end; padding-top: 4px; }
+.sheet-find-actions button { height: 26px; padding: 0 10px; border: 1px solid #d4d8dd; border-radius: 3px;
+ background: #f5f6f7; font: 13px system-ui, sans-serif; cursor: pointer; }
+.sheet-find-actions button:hover:enabled { background: #e6f2ec; border-color: #bcd8c9; }
+.sheet-find-actions button:disabled { opacity: 0.5; cursor: default; }
+.sheet-find-status { grid-column: 1 / -1; min-height: 16px; font-size: 12px; color: #5f6b7a; }
+.sheet-find-status.miss { color: #c0392b; }
+`;
+
+export function createFindDialog(cb: FindDialogCallbacks): FindDialogHandle {
+ if (!document.getElementById('sheet-find-style')) {
+ const s = document.createElement('style');
+ s.id = 'sheet-find-style';
+ s.textContent = CSS;
+ document.head.appendChild(s);
+ }
+
+ const el = document.createElement('div');
+ el.className = 'sheet-find';
+ el.hidden = true;
+
+ const head = document.createElement('div');
+ head.className = 'sheet-find-head';
+ const title = document.createElement('span');
+ title.textContent = 'Find';
+ const closeBtn = document.createElement('button');
+ closeBtn.type = 'button';
+ closeBtn.textContent = '✕';
+ closeBtn.title = 'Close';
+ head.append(title, closeBtn);
+
+ const body = document.createElement('div');
+ body.className = 'sheet-find-body';
+
+ const field = (label: string): HTMLInputElement => {
+ const l = document.createElement('label');
+ l.textContent = label;
+ const input = document.createElement('input');
+ input.type = 'text';
+ body.append(l, input);
+ return input;
+ };
+ const findInput = field('Find what');
+ const replaceInput = field('Replace with');
+ const replaceLabel = replaceInput.previousElementSibling as HTMLElement;
+
+ const opts = document.createElement('div');
+ opts.className = 'sheet-find-opts';
+ const option = (label: string): HTMLInputElement => {
+ const wrap = document.createElement('label');
+ const box = document.createElement('input');
+ box.type = 'checkbox';
+ wrap.append(box, label);
+ opts.appendChild(wrap);
+ return box;
+ };
+ const matchCase = option('Match case');
+ const wholeCell = option('Entire cell');
+ body.appendChild(opts);
+
+ const status = document.createElement('div');
+ status.className = 'sheet-find-status';
+ body.appendChild(status);
+
+ const actions = document.createElement('div');
+ actions.className = 'sheet-find-actions';
+ const action = (label: string, onClick: () => void): HTMLButtonElement => {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.textContent = label;
+ b.addEventListener('click', onClick);
+ actions.appendChild(b);
+ return b;
+ };
+
+ const options = (): FindOptions => ({ matchCase: matchCase.checked, wholeCell: wholeCell.checked });
+ const say = (text: string, miss = false): void => {
+ status.textContent = text;
+ status.classList.toggle('miss', miss);
+ };
+ const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`;
+
+ const doFind = (): void => {
+ const query = findInput.value;
+ if (query === '') return say('');
+ const { found, total } = cb.findNext(query, options());
+ say(found ? `${plural(total, 'cell', 'cells')} found` : 'No match', !found);
+ };
+ const doReplace = (): void => {
+ const query = findInput.value;
+ if (query === '') return say('');
+ const { replaced, total } = cb.replace(query, replaceInput.value, options());
+ say(replaced ? `Replaced, ${plural(total, 'cell', 'cells')} left` : 'No match', !replaced);
+ };
+ const doReplaceAll = (): void => {
+ const query = findInput.value;
+ if (query === '') return say('');
+ const n = cb.replaceAll(query, replaceInput.value, options());
+ say(n === 0 ? 'No match' : `Replaced ${plural(n, 'cell', 'cells')}`, n === 0);
+ };
+
+ const replaceAllBtn = action('Replace All', doReplaceAll);
+ const replaceBtn = action('Replace', doReplace);
+ const findBtn = action('Find Next', doFind);
+ findBtn.style.fontWeight = '600';
+ if (cb.readOnly) {
+ replaceBtn.disabled = true;
+ replaceAllBtn.disabled = true;
+ replaceInput.disabled = true;
+ }
+ body.appendChild(actions);
+ el.append(head, body);
+
+ const close = (): void => {
+ el.hidden = true;
+ };
+ closeBtn.addEventListener('click', close);
+ // Enter = Find Next, Escape closes — the two keys the dialog owns while focused.
+ el.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ doFind();
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ close();
+ }
+ e.stopPropagation(); // the grid's global shortcuts must not see dialog typing
+ });
+
+ return {
+ el,
+ open: (mode) => {
+ const replacing = mode === 'replace' && !cb.readOnly;
+ title.textContent = replacing ? 'Find and Replace' : 'Find';
+ replaceLabel.hidden = !replacing;
+ replaceInput.hidden = !replacing;
+ replaceBtn.hidden = !replacing;
+ replaceAllBtn.hidden = !replacing;
+ el.hidden = false;
+ say('');
+ findInput.focus();
+ findInput.select();
+ },
+ close,
+ isOpen: () => !el.hidden,
+ };
+}
diff --git a/ui/src/js/sheet/sheetToolbar.ts b/ui/src/js/sheet/sheetToolbar.ts
index 4039da6..5502afe 100644
--- a/ui/src/js/sheet/sheetToolbar.ts
+++ b/ui/src/js/sheet/sheetToolbar.ts
@@ -38,6 +38,8 @@ export interface ToolbarCallbacks {
undo?: () => void;
redo?: () => void;
history?: () => { canUndo: boolean; canRedo: boolean };
+ // Ribbon: Excel's "Find & Select" entry in the Editing group.
+ openFind?: (mode: 'find' | 'replace') => void;
// Merge/unmerge the current selection (the editor decides which).
mergeToggle?: () => void;
}
@@ -105,6 +107,7 @@ const IC = {
fillDown: '',
fillRight: '',
clear: '',
+ find: '',
undo: '',
redo: '',
};
@@ -450,8 +453,14 @@ export function createToolbar(cb: ToolbarCallbacks): ToolbarElement {
}
// --- Home: Editing ---
- if (cb.autoSum || cb.fill || cb.clear) {
+ if (cb.autoSum || cb.fill || cb.clear || cb.openFind) {
const editing = group('Home', 'Editing');
+ if (cb.openFind) {
+ menuBtn(editing, { icon: IC.find }, 'Find & Select', [
+ ['Find… (Ctrl+F)', () => cb.openFind?.('find')],
+ ['Replace… (Ctrl+H)', () => cb.openFind?.('replace')],
+ ]);
+ }
if (cb.autoSum) {
const sum = col(editing);
btn(row(sum), { text: 'Σ' }, 'AutoSum', () => cb.autoSum?.());
diff --git a/ui/src/js/sheet/sheetView.ts b/ui/src/js/sheet/sheetView.ts
index fc37f55..78655a1 100644
--- a/ui/src/js/sheet/sheetView.ts
+++ b/ui/src/js/sheet/sheetView.ts
@@ -243,6 +243,24 @@ export class DomSheetView {
document.head.appendChild(style);
}
+ // caretToEnd collapses the caret behind the cell's text.
+ private caretToEnd(td: HTMLTableCellElement): void {
+ const sel = window.getSelection();
+ if (!sel) return;
+ const range = document.createRange();
+ range.selectNodeContents(td);
+ range.collapse(false);
+ sel.removeAllRanges();
+ sel.addRange(range);
+ }
+
+ // beginOverwrite empties the cell and puts the caret in it, so the keystroke
+ // that triggered it replaces the old content instead of appending to it.
+ private beginOverwrite(td: HTMLTableCellElement): void {
+ td.textContent = '';
+ this.caretToEnd(td);
+ }
+
private attach(td: HTMLTableCellElement, r: number, c: number): void {
td.addEventListener('mousedown', (e: MouseEvent) => {
// Right-click inside an existing selection keeps it (Excel behaviour), so
@@ -285,6 +303,16 @@ export class DomSheetView {
this.activeEdit = true;
this.opts.onLiveEdit?.(r, c, td.textContent ?? '');
});
+ // Double-click is Excel's "edit in place": keep the content and the caret
+ // where the user clicked, so the next keystroke does not wipe the cell.
+ td.addEventListener('dblclick', () => {
+ this.activeEdit = true;
+ });
+ // IME composition is the other way text starts arriving without a printable
+ // keydown we can see.
+ td.addEventListener('compositionstart', () => {
+ if (!this.activeEdit) this.beginOverwrite(td);
+ });
td.addEventListener('keydown', (e: KeyboardEvent) => {
const move = (dr: number, dc: number, extend: boolean) => {
e.preventDefault();
@@ -323,6 +351,20 @@ export class DomSheetView {
this.opts.onSelectionChange?.(this.selection);
return this.render();
}
+ // F2 enters edit mode on the existing content with the caret at the end,
+ // like Excel — the escape hatch from overwrite-on-typing below.
+ if (e.key === 'F2') {
+ e.preventDefault();
+ this.activeEdit = true;
+ this.caretToEnd(td);
+ return;
+ }
+ // Typing on a merely selected cell replaces its content (Excel): clear it
+ // and let the keystroke land in the empty cell.
+ if (!this.activeEdit && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
+ this.beginOverwrite(td);
+ return;
+ }
if (e.key === 'Enter') {
e.preventDefault();
td.blur();
@@ -364,6 +406,16 @@ export class DomSheetView {
return this.selection;
}
+ // setSelection moves the selection programmatically (Find Next, Go To) and
+ // scrolls the focused cell into view. Notifies like a click would, so the
+ // editor's selection mirror and the formula bar follow along.
+ setSelection(sel: Selection): void {
+ this.selection = sel;
+ this.opts.onSelectionChange?.(sel);
+ this.render();
+ this.cells[sel.focus.row]?.[sel.focus.col]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
+ }
+
// isEditing reports whether the user is actively typing into a cell (as
// opposed to merely having a cell selected/focused). Clipboard and
// range-delete shortcuts must NOT fire while actively editing.