mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 01:09:54 +08:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
+35
-11
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user