diff --git a/playwright/specs/sheet_excel_chrome.spec.ts b/playwright/specs/sheet_excel_chrome.spec.ts index 171ebf88..76f6b56b 100644 --- a/playwright/specs/sheet_excel_chrome.spec.ts +++ b/playwright/specs/sheet_excel_chrome.spec.ts @@ -201,6 +201,48 @@ 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, 0).click(); // start on the first match + 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'); + // Find Next searches forward from the current cell, so A1 is skipped and + // 'alphabet' in A3 comes next. + await dialog.locator('button', { hasText: 'Find Next' }).click(); + await expect(cell(page, 2, 0)).toHaveClass(/sheet-sel-focus/); + await expect(dialog.locator('.sheet-find-status')).toContainText('2 cells found'); + + // Past the last match it wraps around to A1. + await dialog.locator('button', { hasText: 'Find Next' }).click(); + await expect(cell(page, 0, 0)).toHaveClass(/sheet-sel-focus/); + + 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 00000000..1520d8b7 --- /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 00000000..739218c7 --- /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 6a88f5ab..e8e81286 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 00000000..fb6ec1be --- /dev/null +++ b/ui/src/js/sheet/sheetFindDialog.ts @@ -0,0 +1,186 @@ +// 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}`; + + // Selecting a hit focuses that cell, which would steal the keyboard from the + // dialog. Excel keeps typing in the box, so take the focus back. + const keepFocus = (): void => findInput.focus(); + + 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); + keepFocus(); + }; + 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); + keepFocus(); + }; + 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 4039da6b..5502afed 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 fc37f55c..9cccd51a 100644 --- a/ui/src/js/sheet/sheetView.ts +++ b/ui/src/js/sheet/sheetView.ts @@ -364,6 +364,20 @@ export class DomSheetView { return this.selection; } + // setSelection moves the selection programmatically (Find Next, Go To). + // It focuses the cell like a click would — DOM focus is what makes a cell the + // active one for typing, so selecting without focusing would leave the + // keyboard pointed at the previous cell. + setSelection(sel: Selection): void { + this.selection = sel; + this.opts.onSelectionChange?.(sel); + this.render(); + const td = this.cells[sel.focus.row]?.[sel.focus.col]; + if (!td) return; + td.focus(); + td.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.