Skip to content

Commit 1253b19

Browse files
SamTV12345claude
andcommitted
fix(sheet): typing over a selected cell replaces its content
Selecting a cell and typing appended to what was already there, because the grid cell is a permanently contenteditable td and the keystroke just went to the caret. Excel overwrites instead, which is what everyone expects from a spreadsheet - and it is what made a two-value edit in the undo e2e test read "firstsecond". The first printable keystroke on a cell that is selected but not being edited now clears it and lets the character land in the empty cell. The escape hatches match Excel: F2 enters edit mode with the caret at the end, double-click edits in place at the click position. IME composition takes the same overwrite path, since it starts without a printable keydown. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5efc964 commit 1253b19

2 files changed

Lines changed: 69 additions & 2 deletions

File tree

playwright/specs/sheet_excel_chrome.spec.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,36 @@ test.describe('Sheet Excel chrome', () => {
159159
await ctx.close();
160160
});
161161

162+
test('typing over a selected cell replaces it, F2 and double-click edit it', async ({ page }) => {
163+
const padId = `xl-overwrite-${Date.now()}`;
164+
await openSheet(page, padId);
165+
await commitCell(page, 0, 0, 'old'); // A1
166+
167+
// Selected, not editing: the first keystroke wipes the old content.
168+
await cell(page, 0, 0).click();
169+
await page.keyboard.type('new', { delay: 30 });
170+
await page.keyboard.press('Enter');
171+
await expect(cell(page, 0, 0)).toHaveText('new');
172+
173+
// F2 keeps the content and appends at the end.
174+
await cell(page, 0, 0).click();
175+
await page.keyboard.press('F2');
176+
await page.keyboard.type('er', { delay: 30 });
177+
await page.keyboard.press('Enter');
178+
await expect(cell(page, 0, 0)).toHaveText('newer');
179+
180+
// Double-click also edits in place instead of overwriting.
181+
await cell(page, 0, 0).dblclick();
182+
await page.keyboard.type('!', { delay: 30 });
183+
await page.keyboard.press('Enter');
184+
await expect(cell(page, 0, 0)).toHaveText(/newer/);
185+
await expect(cell(page, 0, 0)).toHaveText(/!/);
186+
});
187+
162188
test('undo and redo revert and reapply an edit', async ({ page }) => {
163189
const padId = `xl-undo-${Date.now()}`;
164190
await openSheet(page, padId);
165-
// Typing into a cell appends to its content, so each value goes into a
166-
// fresh cell — this test is about the history, not about cell editing.
191+
// Two separate cells, so each edit is its own history entry.
167192
await commitCell(page, 0, 0, 'first'); // A1
168193
await commitCell(page, 1, 0, 'second'); // A2
169194

ui/src/js/sheet/sheetView.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,24 @@ export class DomSheetView {
243243
document.head.appendChild(style);
244244
}
245245

246+
// caretToEnd collapses the caret behind the cell's text.
247+
private caretToEnd(td: HTMLTableCellElement): void {
248+
const sel = window.getSelection();
249+
if (!sel) return;
250+
const range = document.createRange();
251+
range.selectNodeContents(td);
252+
range.collapse(false);
253+
sel.removeAllRanges();
254+
sel.addRange(range);
255+
}
256+
257+
// beginOverwrite empties the cell and puts the caret in it, so the keystroke
258+
// that triggered it replaces the old content instead of appending to it.
259+
private beginOverwrite(td: HTMLTableCellElement): void {
260+
td.textContent = '';
261+
this.caretToEnd(td);
262+
}
263+
246264
private attach(td: HTMLTableCellElement, r: number, c: number): void {
247265
td.addEventListener('mousedown', (e: MouseEvent) => {
248266
// Right-click inside an existing selection keeps it (Excel behaviour), so
@@ -285,6 +303,16 @@ export class DomSheetView {
285303
this.activeEdit = true;
286304
this.opts.onLiveEdit?.(r, c, td.textContent ?? '');
287305
});
306+
// Double-click is Excel's "edit in place": keep the content and the caret
307+
// where the user clicked, so the next keystroke does not wipe the cell.
308+
td.addEventListener('dblclick', () => {
309+
this.activeEdit = true;
310+
});
311+
// IME composition is the other way text starts arriving without a printable
312+
// keydown we can see.
313+
td.addEventListener('compositionstart', () => {
314+
if (!this.activeEdit) this.beginOverwrite(td);
315+
});
288316
td.addEventListener('keydown', (e: KeyboardEvent) => {
289317
const move = (dr: number, dc: number, extend: boolean) => {
290318
e.preventDefault();
@@ -323,6 +351,20 @@ export class DomSheetView {
323351
this.opts.onSelectionChange?.(this.selection);
324352
return this.render();
325353
}
354+
// F2 enters edit mode on the existing content with the caret at the end,
355+
// like Excel — the escape hatch from overwrite-on-typing below.
356+
if (e.key === 'F2') {
357+
e.preventDefault();
358+
this.activeEdit = true;
359+
this.caretToEnd(td);
360+
return;
361+
}
362+
// Typing on a merely selected cell replaces its content (Excel): clear it
363+
// and let the keystroke land in the empty cell.
364+
if (!this.activeEdit && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
365+
this.beginOverwrite(td);
366+
return;
367+
}
326368
if (e.key === 'Enter') {
327369
e.preventDefault();
328370
td.blur();

0 commit comments

Comments
 (0)