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.