feat(editor): streamline cell editing and navigation with improved keyboard support (#12770)

https://github.com/user-attachments/assets/6bce5fa3-fb25-4906-bef1-50d4da4a13f6

This PR addresses #12769 and improves table editing UX by making Enter
commit changes and move focus down, and Tab/Shift+Tab move focus
horizontally—matching spreadsheet-like behavior.

Typing now immediately enters edit mode for selected cells without
double-clicking.
These updates apply to both the standard and virtual table views.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- You can now start editing a table cell by simply typing any character
while the cell is selected.
- **Improvements**
- Pressing Enter while editing a cell will exit editing and move focus
down.
- Pressing Tab or Shift-Tab while editing a cell will exit editing and
move focus right or left, respectively.
- **Tests**
- Added unit tests for table cell hotkey behaviors to ensure reliable
editing and navigation.
- **Chores**
- Introduced Vitest configuration for streamlined testing and coverage
reporting.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: L-Sun <zover.v@gmail.com>
This commit is contained in:
Richard Lora
2025-06-23 08:24:22 -04:00
committed by GitHub
parent 74106ba7c6
commit a8c18cd631
7 changed files with 536 additions and 185 deletions
@@ -0,0 +1,120 @@
import { describe, expect, it, vi } from 'vitest';
import { TableHotkeysController } from '../view-presets/table/pc/controller/hotkeys.js';
import { TableHotkeysController as VirtualHotkeysController } from '../view-presets/table/pc-virtual/controller/hotkeys.js';
import {
TableViewAreaSelection,
TableViewRowSelection,
} from '../view-presets/table/selection';
function createLogic() {
const view = {
rowsDelete: vi.fn(),
rows$: { value: [] },
groupTrait: { groupsDataList$: { value: [] } },
};
const ui = { disposables: { add: vi.fn() }, requestUpdate: vi.fn() };
const selectionController = {
selection: undefined as any,
getCellContainer: vi.fn(),
insertRowAfter: vi.fn(),
focusToCell: vi.fn(),
rowSelectionChange: vi.fn(),
areaToRows: vi.fn().mockReturnValue([]),
rowsToArea: vi.fn(),
navigateRowSelection: vi.fn(),
selectionAreaUp: vi.fn(),
selectionAreaDown: vi.fn(),
selectionAreaLeft: vi.fn(),
selectionAreaRight: vi.fn(),
isRowSelection: vi.fn().mockReturnValue(false),
};
const logic: any = {
view,
ui$: { value: ui },
selectionController,
bindHotkey: vi.fn((hotkeys: any) => {
logic.hotkeys = hotkeys;
return { dispose: vi.fn() };
}),
handleEvent: vi.fn((name: string, handler: any) => {
if (name === 'keyDown') logic.keyDown = handler;
return { dispose: vi.fn() };
}),
};
return { logic, view, ui, selectionController };
}
describe('TableHotkeysController', () => {
it('deletes rows on Backspace', () => {
const { logic, view, ui, selectionController } = createLogic();
const ctrl = new TableHotkeysController(logic as any);
ctrl.hostConnected();
selectionController.selection = TableViewRowSelection.create({
rows: [{ id: 'r1' }],
});
logic.hotkeys.Backspace();
expect(selectionController.selection).toBeUndefined();
expect(view.rowsDelete).toHaveBeenCalledWith(['r1']);
expect(ui.requestUpdate).toHaveBeenCalled();
});
it('starts editing on character key', () => {
const { logic, selectionController } = createLogic();
const ctrl = new TableHotkeysController(logic as any);
ctrl.hostConnected();
const cell = {
rowId: 'r1',
dataset: { rowId: 'r1', columnId: 'c1' },
column: { valueSetFromString: vi.fn() },
};
selectionController.getCellContainer.mockReturnValue(cell);
selectionController.selection = TableViewAreaSelection.create({
focus: { rowIndex: 0, columnIndex: 0 },
isEditing: false,
});
const evt = {
key: 'A',
metaKey: false,
ctrlKey: false,
altKey: false,
preventDefault: vi.fn(),
};
logic.keyDown({ get: () => ({ raw: evt }) });
expect(cell.column.valueSetFromString).toHaveBeenCalledWith('r1', 'A');
expect(selectionController.selection.isEditing).toBe(true);
expect(evt.preventDefault).toHaveBeenCalled();
});
});
describe('Virtual TableHotkeysController', () => {
it('writes character to cell', () => {
const { logic, selectionController } = createLogic();
const ctrl = new VirtualHotkeysController(logic as any);
ctrl.hostConnected();
const cell = {
rowId: 'r1',
dataset: { rowId: 'r1', columnId: 'c1' },
column$: { value: { valueSetFromString: vi.fn() } },
};
selectionController.getCellContainer.mockReturnValue(cell);
selectionController.selection = TableViewAreaSelection.create({
focus: { rowIndex: 1, columnIndex: 0 },
isEditing: false,
});
const evt = {
key: 'b',
metaKey: false,
ctrlKey: false,
altKey: false,
preventDefault: vi.fn(),
};
logic.keyDown({ get: () => ({ raw: evt }) });
expect(cell.column$.value.valueSetFromString).toHaveBeenCalledWith(
'r1',
'b'
);
expect(selectionController.selection.isEditing).toBe(true);
expect(evt.preventDefault).toHaveBeenCalled();
});
});
@@ -3,6 +3,8 @@ import { DisposableGroup } from '@blocksuite/global/disposable';
import type { ReactiveController } from 'lit';
import { TableViewAreaSelection, TableViewRowSelection } from '../../selection';
import { handleCharStartEdit } from '../../utils.js';
import type { DatabaseCellContainer } from '../row/cell.js';
import { popRowMenu } from '../row/menu';
import type { VirtualTableViewUILogic } from '../table-view-ui-logic';
@@ -138,7 +140,11 @@ export class TableHotkeysController implements ReactiveController {
});
}
} else if (selection.isEditing) {
return false;
this.selectionController.selection = {
...selection,
isEditing: false,
};
this.selectionController.focusToCell('down');
} else {
this.selectionController.selection = {
...selection,
@@ -172,27 +178,31 @@ export class TableHotkeysController implements ReactiveController {
},
Tab: ctx => {
const selection = this.selectionController.selection;
if (
!selection ||
TableViewRowSelection.is(selection) ||
selection.isEditing
) {
if (!selection || TableViewRowSelection.is(selection)) {
return false;
}
ctx.get('keyboardState').raw.preventDefault();
if (selection.isEditing) {
this.selectionController.selection = {
...selection,
isEditing: false,
};
}
this.selectionController.focusToCell('right');
return true;
},
'Shift-Tab': ctx => {
const selection = this.selectionController.selection;
if (
!selection ||
TableViewRowSelection.is(selection) ||
selection.isEditing
) {
if (!selection || TableViewRowSelection.is(selection)) {
return false;
}
ctx.get('keyboardState').raw.preventDefault();
if (selection.isEditing) {
this.selectionController.selection = {
...selection,
isEditing: false,
};
}
this.selectionController.focusToCell('left');
return true;
},
@@ -390,5 +400,19 @@ export class TableHotkeysController implements ReactiveController {
},
})
);
this.disposables.add(
this.logic.handleEvent('keyDown', ctx => {
const event = ctx.get('keyboardState').raw;
return handleCharStartEdit<DatabaseCellContainer>({
event,
selection: this.selectionController.selection,
getCellContainer: this.selectionController.getCellContainer.bind(
this.selectionController
),
updateSelection: sel => (this.selectionController.selection = sel),
getColumn: cell => cell.column$.value,
});
})
);
}
}
@@ -2,6 +2,8 @@ import { popupTargetFromElement } from '@blocksuite/affine-components/context-me
import type { ReactiveController } from 'lit';
import { TableViewAreaSelection, TableViewRowSelection } from '../../selection';
import { handleCharStartEdit } from '../../utils.js';
import type { TableViewCellContainer } from '../cell.js';
import { popRowMenu } from '../menu.js';
import type { TableViewUILogic } from '../table-view-ui-logic';
@@ -136,7 +138,11 @@ export class TableHotkeysController implements ReactiveController {
});
}
} else if (selection.isEditing) {
return false;
this.selectionController.selection = {
...selection,
isEditing: false,
};
this.selectionController.focusToCell('down');
} else {
this.selectionController.selection = {
...selection,
@@ -170,27 +176,31 @@ export class TableHotkeysController implements ReactiveController {
},
Tab: ctx => {
const selection = this.selectionController.selection;
if (
!selection ||
TableViewRowSelection.is(selection) ||
selection.isEditing
) {
if (!selection || TableViewRowSelection.is(selection)) {
return false;
}
ctx.get('keyboardState').raw.preventDefault();
if (selection.isEditing) {
this.selectionController.selection = {
...selection,
isEditing: false,
};
}
this.selectionController.focusToCell('right');
return true;
},
'Shift-Tab': ctx => {
const selection = this.selectionController.selection;
if (
!selection ||
TableViewRowSelection.is(selection) ||
selection.isEditing
) {
if (!selection || TableViewRowSelection.is(selection)) {
return false;
}
ctx.get('keyboardState').raw.preventDefault();
if (selection.isEditing) {
this.selectionController.selection = {
...selection,
isEditing: false,
};
}
this.selectionController.focusToCell('left');
return true;
},
@@ -388,5 +398,19 @@ export class TableHotkeysController implements ReactiveController {
},
})
);
this.host?.disposables.add(
this.logic.handleEvent('keyDown', ctx => {
const event = ctx.get('keyboardState').raw;
return handleCharStartEdit<TableViewCellContainer>({
event,
selection: this.selectionController.selection,
getCellContainer: this.selectionController.getCellContainer.bind(
this.selectionController
),
updateSelection: sel => (this.selectionController.selection = sel),
getColumn: cell => cell.column,
});
})
);
}
}
@@ -0,0 +1,58 @@
import type { TableViewSelectionWithType } from './selection';
import { TableViewRowSelection } from './selection';
export interface TableCell {
rowId: string;
}
export type ColumnAccessor<T extends TableCell> = (
cell: T
) => { valueSetFromString(rowId: string, value: string): void } | undefined;
export interface StartEditOptions<T extends TableCell> {
event: KeyboardEvent;
selection: TableViewSelectionWithType | undefined;
getCellContainer: (
groupKey: string | undefined,
rowIndex: number,
columnIndex: number
) => T | undefined;
updateSelection: (sel: TableViewSelectionWithType) => void;
getColumn: ColumnAccessor<T>;
}
export function handleCharStartEdit<T extends TableCell>(
options: StartEditOptions<T>
): boolean {
const { event, selection, getCellContainer, updateSelection, getColumn } =
options;
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
return false;
}
if (
selection &&
!TableViewRowSelection.is(selection) &&
!selection.isEditing &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
event.key.length === 1
) {
const cell = getCellContainer(
selection.groupKey,
selection.focus.rowIndex,
selection.focus.columnIndex
);
if (cell) {
const column = getColumn(cell);
column?.valueSetFromString(cell.rowId, event.key);
updateSelection({ ...selection, isEditing: true });
event.preventDefault();
return true;
}
}
return false;
}