From ca515681f58ab405e69613b8cffdb865aebc6bf6 Mon Sep 17 00:00:00 2001 From: Jason Smith Date: Wed, 23 Mar 2022 21:58:33 -0700 Subject: [PATCH] Input/selection overhaul - Grid selection is totally redefined - Range/cell/rows/columns selections are all merged into the GridSelection object - Multi-select for range/row/column can all be enabled disabled - Blended selections of ranges/columns/rows can now be enabled - Delete callback is now universal and allows partial handling - Highlight ranges - Many keybinding improvements --- package-lock.json | 2 +- package.json | 2 +- packages/cells/package.json | 4 +- packages/core/API.md | 112 ++- packages/core/package.json | 2 +- packages/core/src/common/is-hotkey.ts | 56 ++ .../data-editor-beautiful.stories.tsx | 157 +++- .../src/data-editor/data-editor.stories.tsx | 109 +-- .../core/src/data-editor/data-editor.test.tsx | 877 +++++++++++++++--- packages/core/src/data-editor/data-editor.tsx | 864 +++++++++++------ .../src/data-editor/use-cell-sizer.test.tsx | 5 +- .../core/src/data-editor/use-cell-sizer.ts | 4 +- .../data-editor/use-cells-for-selection.ts | 63 ++ .../core/src/data-grid-dnd/data-grid-dnd.tsx | 8 +- .../data-grid-overlay-editor.tsx | 4 +- .../src/data-grid-search/data-grid-search.tsx | 45 +- packages/core/src/data-grid/data-grid-lib.ts | 136 ++- .../core/src/data-grid/data-grid-render.tsx | 262 +++++- .../core/src/data-grid/data-grid-types.ts | 23 +- .../core/src/data-grid/data-grid.stories.tsx | 31 +- .../core/src/data-grid/data-grid.test.tsx | 7 +- packages/core/src/data-grid/data-grid.tsx | 162 +--- .../src/data-grid/use-selection-behavior.ts | 116 +++ .../scrolling-data-grid.stories.tsx | 7 +- packages/source/package.json | 4 +- packages/source/src/use-collapsing-groups.ts | 30 +- .../source/src/use-data-source.stories.tsx | 11 +- 27 files changed, 2333 insertions(+), 770 deletions(-) create mode 100644 packages/core/src/common/is-hotkey.ts create mode 100644 packages/core/src/data-editor/use-cells-for-selection.ts create mode 100644 packages/core/src/data-grid/use-selection-behavior.ts diff --git a/package-lock.json b/package-lock.json index a0997508c..4cdd914a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "root", - "version": "3.5.0-alpha2", + "version": "4.0.0-alpha1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index d796e1b8c..a426f6a03 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "root", - "version": "3.5.0-alpha2", + "version": "4.0.0-alpha1", "scripts": { "bootstrap": "./bootstrap.sh", "build": "./build-all.sh", diff --git a/packages/cells/package.json b/packages/cells/package.json index 7d64945ae..4ce9724f2 100644 --- a/packages/cells/package.json +++ b/packages/cells/package.json @@ -1,6 +1,6 @@ { "name": "@glideapps/glide-data-grid-cells", - "version": "3.5.0-alpha2", + "version": "4.0.0-alpha1", "description": "Extra cells for glide-data-grid", "sideEffects": false, "type": "module", @@ -41,7 +41,7 @@ "canvas" ], "dependencies": { - "@glideapps/glide-data-grid": "3.5.0-alpha2", + "@glideapps/glide-data-grid": "4.0.0-alpha1", "@toast-ui/editor": "^3.1.3", "@toast-ui/react-editor": "^3.1.3", "react-select": "^5.2.2" diff --git a/packages/core/API.md b/packages/core/API.md index 4a995bb9a..336025e3d 100644 --- a/packages/core/API.md +++ b/packages/core/API.md @@ -113,18 +113,21 @@ Most data grids will want to set the majority of these props one way or another. | [gridSelection](#gridselection) | The current selection active in the data grid. Includes both the selection cell and the selected range. | | [spanRangeBehavior](#spanrangebehavior) | Determines if the `gridSelection` should allow partial spans or not. | | [onGridSelectionChange](#gridselection) | Emitted whenever the `gridSelection` should change. | -| [onSelectedColumnsChange](#selectedcolumns) | Emitted whenever the `selectedColumns` should change. | -| [onSelectedRowsChange](#selectedrows) | Emitted whenever the `selectedRows` should change. | | [onSelectionCleared](#onselectioncleared) | Emitted when the selection is explicitly cleared. | -| [selectedColumns](#selectedcolumns) | The currently selected columns. | -| [selectedRows](#selectedrows) | The currently selected rows. | +| [rangeMultiSelect](#rangemultiselect) | Controls if multiple ranges can be selected at once. | +| [columnMultiSelect](#rangemultiselect) | Controls if multiple columns can be selected at once. | +| [rowMultiSelect](#rangemultiselect) | Controls if multiple rows can be selected at aonce. | +| [rangeSelectionBlending](#rangeselectionblending) | Controls how range selections may be mixed with other selection types. | +| [columnSelectionBlending](#rangeselectionblending) | Controls how column selections may be mixed with other selection types. | +| [rowSelectionBlending](#rangeselectionblending) | Controls how row selections may be mixed with other selection types. | +| [highlightRegions](#highlightregions) | Adds additional highlights to the data grid for showing contextually important cells. | ## Editing | Name | Description | |------------|-----------------------| | [imageEditorOverride](#imageeditoroverride) | Used to provide an override to the default image editor for the data grid. `provideEditor` may be a better choice for most people. | | [onCellEdited](#oncelledited) | Emitted whenever a cell edit is completed. | -| [onDeleteRows](#ondeleterows) | Emitted whenever the user has requested the deletion of rows. | +| [onDelete](#ondelete) | Emitted whenever the user has requested the deletion of the selection. | | [onFinishedEditing](#onfinishedediting) | Emitted when editing has finished, regardless of data changing or not. | | [onGroupHeaderRenamed](#ongroupheaderrenamed) | Emitted whe the user wishes to rename a group. | | [onPaste](#onpaste) | Emitted any time data is pasted to the grid. Allows controlling paste behavior. | @@ -134,8 +137,6 @@ Most data grids will want to set the majority of these props one way or another. ## Input Interaction | Name | Description | |------------|-----------------------| -| [enableDownfill](#enabledownfill) | Enables the downfill keyboard shortcut, Ctrl/Cmd+D. Fills the current selection with the contents of the first row of the range. | -| [enableRightfill](#enabledownfill) | Enables the downfill keyboard shortcut, Ctrl/Cmd+R. Fills the current selection with the contents of the first column of the range. | | [maxColumnWidth](#maxcolumnwidth) | Sets the maximum width the user can resize a column to. | | [onCellClicked](#oncellclicked) | Emitted when a cell is clicked. | | [onCellContextMenu](#oncellcontextmenu) | Emitted when a cell should show a context menu. Usually right click. | @@ -165,6 +166,29 @@ Most data grids will want to set the majority of these props one way or another. |------------|-----------------------| | drawCustomCell | Use `drawCell` | +# Keybindings + +| Key Combo | Default | Flag | Description | +|---|----|---|---| +| Arrow | ✔️ | N/A | Moves the currently selected cell and clears other selections | +| Shift + Arrow | ✔️ | N/A | Extends the current selection range in the direction pressed. | +| Alt + Arrow | ✔️ | N/A | Moves the currently selected cell and retains the current selection | +| Ctrl/Cmd + Arrow \| Home/End | ✔️ | N/A | Move the selection as far as possible in the direction pressed. | +| Ctrl/Cmd + Shift + Arrow | ✔️ | N/A | Extends the selection as far as possible in the direction pressed. | +| Shift + Home/End | ✔️ | N/A | Extends the selection as far as possible in the direction pressed. | +| Ctrl/Cmd + A | ✔️ | `selectAll` | Selects all cells. | +| Shift + Space | ✔️ | `selectRow` | Selecs the current row. | +| Ctrl/Cmd + Space | ✔️ | `selectCol` | Selects the current col. | +| PageUp/PageDown | ❌ | `pageUp`/`pageDown` | Moves the current selection up/down by one page. | +| Escape | ✔️ | `clear` | Clear the current selection. | +| Ctrl/Cmd + D | ❌ | `downFill` | Data from the first row of the range will be down filled into the rows below it | +| Ctrl/Cmd + R | ❌ | `rightFill` | Data from the first column of the range will be right filled into the columns next to it | +| Ctrl/Cmd + C | ✔️ | `copy` | Copies the current selection. | +| Ctrl/Cmd + V | ✔️ | `paste` | Pastes the current buffer into the grid. | +| Ctrl/Cmd + F | ❌ | `search` | Opens the search interface. | +| Ctrl/Cmd + Home/End | ✔️ | `first`/`last` | Move the selection to the first/last cell in the data grid. | +| Ctrl/Cmd + Shift + Home/End | ✔️ | `first`/`last` | Extend the selection to the first/last cell in the data grid. | + # Full API Docs ## GridColumn @@ -232,12 +256,19 @@ export type GridColumn = SizedGridColumn | AutoGridColumn; --- ## GridSelection -`GridSelection` is the most basic representation of the selected cells in the data grid. It accounts for the selected cell and the range of cells selected as well. It is the selection which is modified by keyboard and mouse interaction when clicking on the cells themselves. +`GridSelection` is the most basic representation of the selected cells, rows, and columns in the data grid. The `current` property accounts for the selected cell and the range of cells selected as well. It is the selection which is modified by keyboard and mouse interaction when clicking on the cells themselves. + +The `rows` and `columns` properties both account for the columns or rows which have been explicitly selected by the user. Selecting a range which encompases the entire set of cells within a column/row does not implicitly set it into this part of the collection. This allows for distinguishing between cases when the user wishes to delete all contents of a row/column and delete the row/column itself. ```ts interface GridSelection { - readonly cell: readonly [number, number]; - readonly range: Readonly; + readonly current?: { + readonly cell: readonly [number, number]; + readonly range: Readonly; + readonly rangeStack: readonly Readonly[]; + }; + readonly columns: CompactSelection; + readonly rows: CompactSelection; } ``` @@ -357,11 +388,13 @@ Set to a positive number to freeze columns on the left side of the grid during h ## getCellsForSelection ```ts -getCellsForSelection?: (selection: GridSelection) => readonly (readonly GridCell[])[]; +getCellsForSelection?: true | (selection: Rectangle) => readonly (readonly GridCell[])[]; ``` `getCellsForSelection` is called when the user copies a selection to the clipboard or the data editor needs to inspect data which may be outside the curently visible range. It must return a two-dimensional array (an array of rows, where each row is an array of cells) of the cells in the selection's rectangle. Note that the rectangle can include cells that are not currently visible. +If `true` is passed instead of a callback, the data grid will internally use the `getCellContent` callback to provide a basic implementation of `getCellsForSelection`. This can make it easier to light up more data grid functionality, but may have negative side effects if your data source is not able to handle being queried for data outside the normal window. + --- ## markdownDivCreateNode @@ -606,33 +639,58 @@ If set to `default` the `gridSelection` will always be expanded to fully include If `allowPartial` is set no inflation behavior will be enforced. --- -## selectedColumns +## onSelectionCleared ```ts -readonly selectedColumns?: CompactSelection; -readonly onSelectedColumnsChange?: (newColumns: CompactSelection, trigger: HeaderSelectionTrigger) => void; +onSelectionCleared?: () => void; ``` -Controls header selection. If not provided default header selection behavior will be applied. +Emitted when the current selection is cleared, usually when the user presses "Escape". `rowSelection`, `columnSelection`, and `gridSelection` should all be empty when this event is emitted. This event only emits when the user explicitly attempts to clear the selection. --- -## selectedRows +## rangeMultiSelect ```ts -readonly selectedRows?: CompactSelection; -readonly onSelectedRowsChange?: (newRows: CompactSelection) => void; +rangeMultiSelect?: boolean; // default false +columnMultiSelect?: boolean; // default true +rowMultiSelect?: boolean; // default true ``` -Controls row selection. If not provided default row selection behavior will be applied. +Controls if multi-selection is allowed. If disabled, shift/ctrl/command clicking will work as if no modifiers are pressed. --- -## onSelectionCleared +## rangeSelectionBlending ```ts -onSelectionCleared?: () => void; +rangeSelectionBlending?: "exclusive" | "mixed"; // default exclusive +columnSelectionBlending?: "exclusive" | "mixed"; // default exclusive +rowSelectionBlending?: "exclusive" | "mixed"; // default exclusive ``` -Emitted when the current selection is cleared, usually when the user presses "Escape". `rowSelection`, `columnSelection`, and `gridSelection` should all be empty when this event is emitted. This event only emits when the user explicitly attempts to clear the selection. +Controls which types of selections can exist at the same time in the grid. If selection blending is set to exclusive, the grid will clear other types of selections when the exclusive selection is made. By default row, column, and range selections are exclusive. + +--- +## highlightRegions + +```ts +interface Highlight { + readonly color: string; + readonly range: Rectangle; +} + +highlightRegions?: readonly Highlight[]; +``` + +Highlight regions are regions on the grid which get drawn with a background color and a dashed line around the region. The color string must be css parseable and the opacity will be removed for the drawing of the dashed line. Opacity should be used to allow overlapping selections to properly blend in background colors. + +--- +## onDelete + +```ts +onDelete?: (selection: GridSelection) => GridSelection | boolean; +``` + +`onDelete` is called when the user deletes one or more rows. `gridSelection` is current selection. If the callback returns false, deletion will not happen. If it returns true, all cells inside all selected rows, columns and ranges will be deleted. If the callback returns a GridSelection, the newly returned selection will be deleted instead. --- ## imageEditorOverride @@ -704,16 +762,6 @@ onRowAppended?: () => void; `onRowAppended` controls adding new rows at the bottom of the Grid. If `onRowAppended` is defined, an empty row will display at the bottom. When the user clicks on one of its cells, `onRowAppended` is called, which is responsible for appending the new row. The appearance of the blank row can be configured using `trailingRowOptions`. ---- -## enableDownfill - -```ts -enableDownfill?: boolean; -enableRightfill?: boolean; -``` - -Enables the downfill and rightfill commands. When a `range` is selected and the downfill command is invoked (Ctrl/Cmd+d), the data from the first row of the range will be downfilled into the rows below it, ignoring cells which are not editable. When the rightfill command (Ctrl/Cmd+R) is invoked the contents of the first column are copied right to the rest of the range. - --- ## maxColumnWidth diff --git a/packages/core/package.json b/packages/core/package.json index 21ef54aac..ec2d7a810 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@glideapps/glide-data-grid", - "version": "3.5.0-alpha2", + "version": "4.0.0-alpha1", "description": "Super fast, pure canvas Data Grid Editor", "sideEffects": false, "type": "module", diff --git a/packages/core/src/common/is-hotkey.ts b/packages/core/src/common/is-hotkey.ts new file mode 100644 index 000000000..98d398cba --- /dev/null +++ b/packages/core/src/common/is-hotkey.ts @@ -0,0 +1,56 @@ +import { GridKeyEventArgs } from "../data-grid/data-grid-types"; +import { browserIsOSX } from "./browser-detect"; + +// brain dead syntax, find your deps, and make buggy replacements with 5 times the effort +// all lower case +// ctrl+shift+alt+d or ctrl+x or shift+c or shift+Backspace or alt+_53 +// you get it, last one is always event.key, nothing fancy +// special: primary === ctrl on windows, meta on mac +// no to lower, its a waste, we're the only consumer, don't use caps + +// and before you ask, yes space is " ", e.g. "ctrl+alt+ ", whatacountry.gif +// load bearing whitespace, it's basically python +// if the char starts with a _ it is the event.keycode instead +export function isHotkey(hotkey: string, args: GridKeyEventArgs): boolean { + if (hotkey.length === 0) return false; + let wantCtrl = false; + let wantShift = false; + let wantAlt = false; + let wantMeta = false; + const split = hotkey.split("+"); + const key = split.pop(); + if (key === undefined) return false; + if (key.length > 1 && key.startsWith("_")) { + const keycode = Number.parseInt(key.substring(1)); + if (keycode !== args.keyCode) return false; + } else { + if (key !== args.key) return false; + } + for (const accel of split) { + switch (accel) { + case "ctrl": + wantCtrl = true; + break; + case "shift": + wantShift = true; + break; + case "alt": + wantAlt = true; + break; + case "meta": + wantMeta = true; + break; + case "primary": + if (browserIsOSX.value) { + wantMeta = true; + } else { + wantCtrl = true; + } + break; + } + } + + return ( + args.altKey === wantAlt && args.ctrlKey === wantCtrl && args.shiftKey === wantShift && args.metaKey === wantMeta + ); +} diff --git a/packages/core/src/data-editor/data-editor-beautiful.stories.tsx b/packages/core/src/data-editor/data-editor-beautiful.stories.tsx index f33f77a67..d65eb2edf 100644 --- a/packages/core/src/data-editor/data-editor-beautiful.stories.tsx +++ b/packages/core/src/data-editor/data-editor-beautiful.stories.tsx @@ -9,6 +9,8 @@ import { GridColumn, GridColumnIcon, GridMouseEventArgs, + GridSelection, + GroupHeaderClickedEventArgs, isEditableGridCell, isTextEditableGridCell, Rectangle, @@ -1092,6 +1094,82 @@ export const SmoothScrollingGrid: React.FC = p => { }, }; +interface InputBlendingGridProps { + rangeBlending: "mixed" | "exclusive"; + columnBlending: "mixed" | "exclusive"; + rowBlending: "mixed" | "exclusive"; + rangeMultiSelect: boolean; + columnMultiSelect: boolean; + rowMultiSelect: boolean; +} + +export const InputBlending: React.FC = p => { + const { cols, getCellContent } = useMockDataGenerator(30); + + return ( + + Input blending can be enabled or disable between row, column, and range selections. Multi-selections + can also be enabled or disabled with the same level of granularity. + + }> + + + ); +}; +(InputBlending as any).args = { + rangeBlending: "mixed", + columnBlending: "mixed", + rowBlending: "mixed", + rangeMultiSelect: false, + columnMultiSelect: true, + rowMultiSelect: true, +}; +(InputBlending as any).argTypes = { + rangeBlending: { + control: { type: "select", options: ["mixed", "exclusive"] }, + }, + columnBlending: { + control: { type: "select", options: ["mixed", "exclusive"] }, + }, + rowBlending: { + control: { type: "select", options: ["mixed", "exclusive"] }, + }, +}; +(InputBlending as any).parameters = { + options: { + showPanel: true, + }, +}; + interface AddColumnsProps { columnsCount: number; } @@ -1140,8 +1218,6 @@ export const AddColumns: React.FC = p => { export const AutomaticRowMarkers: React.VFC = () => { const { cols, getCellContent } = useMockDataGenerator(6); - const [selectedRows, setSelectedRows] = React.useState(CompactSelection.empty()); - return ( { }> = p => { const { cols, getCellContent, getCellsForSelection } = useMockDataGenerator(6); - const [selectedRows, setSelectedRows] = React.useState(); - return ( = p => { {...defaultProps} rowHeight={p.rowHeight} headerHeight={p.headerHeight} - selectedRows={selectedRows} - onSelectedRowsChange={setSelectedRows} getCellsForSelection={getCellsForSelection} rowMarkers={"number"} getCellContent={getCellContent} @@ -1376,8 +1446,6 @@ const KeyName = styled.kbd` export const MultiSelectColumns: React.VFC = () => { const { cols, getCellContent, getCellsForSelection } = useMockDataGenerator(100); - const [sel, setSel] = React.useState(CompactSelection.empty()); - return ( { rowMarkers="both" columns={cols} rows={100_000} - selectedColumns={sel} - onSelectedColumnsChange={setSel} /> ); @@ -2569,9 +2635,10 @@ function useCollapsableColumnGroups(cols: readonly GridColumn[]) { const [collapsed, setCollapsed] = React.useState([]); const onGroupHeaderClicked = React.useCallback( - (colIndex: number) => { + (colIndex: number, args: GroupHeaderClickedEventArgs) => { const group = cols[colIndex].group ?? ""; setCollapsed(cv => (cv.includes(group) ? cv.filter(g => g !== group) : [...cv, group])); + args.preventDefault(); }, [cols] ); @@ -2922,3 +2989,67 @@ export const Padding: React.VFC = p => { showPanel: true, }, }; + +export const HighlightCells: React.VFC = () => { + const { cols, getCellContent, getCellsForSelection } = useMockDataGenerator(100); + + const [gridSelection, setGridSelection] = React.useState({ + columns: CompactSelection.empty(), + rows: CompactSelection.empty(), + }); + + const highlights = React.useMemo(() => { + if (gridSelection.current === undefined) return undefined; + const [col, row] = gridSelection.current.cell; + return [ + { + color: "#44BB0022", + range: { + x: col + 2, + y: row, + width: 10, + height: 10, + }, + }, + { + color: "#b000b021", + range: { + x: col, + y: row + 2, + width: 1, + height: 1, + }, + }, + ]; + }, [gridSelection]); + + return ( + + The highlightRegions prop can be set to provide additional hinting or context + for the current selection. + + }> + c > 0} + rows={1_000} + /> + + ); +}; +(HighlightCells as any).parameters = { + options: { + showPanel: false, + }, +}; diff --git a/packages/core/src/data-editor/data-editor.stories.tsx b/packages/core/src/data-editor/data-editor.stories.tsx index 9169880e5..e3b18aa41 100644 --- a/packages/core/src/data-editor/data-editor.stories.tsx +++ b/packages/core/src/data-editor/data-editor.stories.tsx @@ -312,8 +312,8 @@ export function Smooth() { export function ManualControl() { const [gridSelection, setGridSelection] = useState(undefined); - const cb = (newVal: GridSelection | undefined) => { - if ((newVal?.cell[0] ?? 0) % 2 === 0) { + const cb = (newVal: GridSelection) => { + if ((newVal.current?.cell[0] ?? 0) % 2 === 0) { setGridSelection(newVal); } }; @@ -401,51 +401,51 @@ DynamicAddRemoveColumns.args = { columnCount: 2, }; -export function RowSelectionStateLivesOutside() { - const [selected_rows, setSelectedRows] = useState(undefined); - const cb = (newRows: CompactSelection | undefined) => { - if (newRows !== undefined) { - setSelectedRows(newRows); - } - }; - - return ( - { - args.setData("text", "testing"); - }} - getCellContent={getData} - columns={columns} - rows={1000} - /> - ); -} - -export function ColSelectionStateLivesOutside() { - const [selected_cols, setSelectedCols] = useState(CompactSelection.empty()); - const cb = (newRows: CompactSelection | undefined) => { - if (newRows !== undefined) { - setSelectedCols(newRows); - } - }; - - return ( - { - args.setData("text", "testing"); - }} - getCellContent={getData} - columns={columns} - rows={1000} - /> - ); -} +// export function RowSelectionStateLivesOutside() { +// const [selected_rows, setSelectedRows] = useState(undefined); +// const cb = (newRows: CompactSelection | undefined) => { +// if (newRows !== undefined) { +// setSelectedRows(newRows); +// } +// }; + +// return ( +// { +// args.setData("text", "testing"); +// }} +// getCellContent={getData} +// columns={columns} +// rows={1000} +// /> +// ); +// } + +// export function ColSelectionStateLivesOutside() { +// const [selected_cols, setSelectedCols] = useState(CompactSelection.empty()); +// const cb = (newRows: CompactSelection | undefined) => { +// if (newRows !== undefined) { +// setSelectedCols(newRows); +// } +// }; + +// return ( +// { +// args.setData("text", "testing"); +// }} +// getCellContent={getData} +// columns={columns} +// rows={1000} +// /> +// ); +// } export function GridSelectionOutOfRangeNoColumns() { const dummyCols = useMemo( @@ -454,8 +454,9 @@ export function GridSelectionOutOfRangeNoColumns() { ); const [selected, setSelected] = useState({ - cell: [2, 8], - range: { width: 1, height: 1, x: 2, y: 8 }, + current: { cell: [2, 8], range: { width: 1, height: 1, x: 2, y: 8 }, rangeStack: [] }, + columns: CompactSelection.empty(), + rows: CompactSelection.empty(), }); const [cols, setCols] = useState(dummyCols); @@ -542,8 +543,9 @@ export function GridSelectionOutOfRangeLessColumnsThanSelection() { ); const [selected, setSelected] = useState({ - cell: [2, 8], - range: { width: 1, height: 1, x: 2, y: 8 }, + current: { cell: [2, 8], range: { width: 1, height: 1, x: 2, y: 8 }, rangeStack: [] }, + columns: CompactSelection.empty(), + rows: CompactSelection.empty(), }); const [cols, setCols] = useState(dummyCols); @@ -669,8 +671,9 @@ export function MarkdownEdits() { }, []); const [selected, setSelected] = useState({ - cell: [2, 8], - range: { width: 1, height: 1, x: 2, y: 8 }, + current: { cell: [2, 8], range: { width: 1, height: 1, x: 2, y: 8 }, rangeStack: [] }, + columns: CompactSelection.empty(), + rows: CompactSelection.empty(), }); const onSelected = useCallback((newSel?: GridSelection) => { diff --git a/packages/core/src/data-editor/data-editor.test.tsx b/packages/core/src/data-editor/data-editor.test.tsx index 2466b8295..5491e0a19 100644 --- a/packages/core/src/data-editor/data-editor.test.tsx +++ b/packages/core/src/data-editor/data-editor.test.tsx @@ -8,7 +8,6 @@ import { GridCell, GridCellKind, GridSelection, - HeaderSelectionTrigger, isSizedGridColumn, } from ".."; import { DataEditorRef } from "./data-editor"; @@ -224,13 +223,11 @@ const Context: React.FC = p => { // eslint-disable-next-line react/display-name const EventedDataEditor = React.forwardRef((p, ref) => { - const [sel, setSel] = React.useState(); + const [sel, setSel] = React.useState(p.gridSelection); const [extraRows, setExtraRows] = React.useState(0); - const [selectedRows, setSelectedRows] = React.useState(p.selectedRows ?? CompactSelection.empty()); - const [selectedCols, setSelectedCols] = React.useState(p.selectedColumns ?? CompactSelection.empty()); const onGridSelectionChange = React.useCallback( - (s: GridSelection | undefined) => { + (s: GridSelection) => { setSel(s); p.onGridSelectionChange?.(s); }, @@ -242,32 +239,12 @@ const EventedDataEditor = React.forwardRef((p, r p.onRowAppended?.(); }, [p]); - const onSelectedRowsChange = React.useCallback( - (newVal: CompactSelection) => { - setSelectedRows(newVal); - p.onSelectedRowsChange?.(newVal); - }, - [p] - ); - - const onSelectedColumnsChange = React.useCallback( - (newVal: CompactSelection, trigger: HeaderSelectionTrigger) => { - setSelectedCols(newVal); - p.onSelectedColumnsChange?.(newVal, trigger); - }, - [p] - ); - return ( @@ -286,7 +263,11 @@ describe("data-editor", () => { const a11ycell = screen.getByTestId("glide-cell-0-5"); fireEvent.focus(a11ycell); - expect(spy).toBeCalledWith(expect.objectContaining({ cell: [0, 5] })); + expect(spy).toBeCalledWith( + expect.objectContaining({ + current: expect.objectContaining({ cell: [0, 5] }), + }) + ); }); test("Click a11y cell", async () => { @@ -456,7 +437,7 @@ describe("data-editor", () => { icon: "headerCode", })} columns={basicProps.columns.map(c => ({ ...c, group: "A" }))} - onSelectedColumnsChange={spy} + onGridSelectionChange={spy} />, { wrapper: Context, @@ -475,8 +456,11 @@ describe("data-editor", () => { clientY: 16, // GroupHeader }); - expect(spy).toHaveBeenCalled(); - expect(spy).toHaveBeenCalledWith(CompactSelection.fromSingleSelection([0, 10]), expect.anything()); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith({ + columns: CompactSelection.fromSingleSelection([0, 10]), + rows: CompactSelection.empty(), + }); spy.mockClear(); @@ -493,7 +477,10 @@ describe("data-editor", () => { }); expect(spy).toHaveBeenCalled(); - expect(spy).toHaveBeenCalledWith(CompactSelection.empty(), expect.anything()); + expect(spy).toHaveBeenCalledWith({ + columns: CompactSelection.empty(), + rows: CompactSelection.empty(), + }); spy.mockClear(); @@ -510,7 +497,10 @@ describe("data-editor", () => { }); expect(spy).toHaveBeenCalled(); - expect(spy).toHaveBeenCalledWith(CompactSelection.fromSingleSelection([0, 10]), expect.anything()); + expect(spy).toHaveBeenCalledWith({ + rows: CompactSelection.empty(), + columns: CompactSelection.fromSingleSelection([0, 10]), + }); }); test("Rename group header shows", async () => { @@ -647,8 +637,12 @@ describe("data-editor", () => { render( , { @@ -665,6 +659,47 @@ describe("data-editor", () => { expect(spy).toHaveBeenCalled(); }); + test("Delete range", async () => { + const spy = jest.fn(); + + jest.useFakeTimers(); + render( + , + { + wrapper: Context, + } + ); + prep(); + + const canvas = screen.getByTestId("data-grid-canvas"); + fireEvent.keyDown(canvas, { + key: "Delete", + }); + + expect(spy).toHaveBeenCalledWith({ + columns: CompactSelection.empty(), + rows: CompactSelection.empty(), + current: { + cell: [2, 2], + range: { x: 2, y: 2, width: 4, height: 10 }, + rangeStack: [], + }, + }); + }); + test("Open and close overlay", async () => { jest.useFakeTimers(); render(, { @@ -839,7 +874,7 @@ describe("data-editor", () => { key: "ArrowLeft", }); - expect(spy).toBeCalledWith(expect.objectContaining({ cell: [0, 1] })); + expect(spy).toBeCalledWith(expect.objectContaining({ current: expect.objectContaining({ cell: [0, 1] }) })); }); test("Arrow shift left", async () => { @@ -867,7 +902,11 @@ describe("data-editor", () => { key: "ArrowLeft", }); - expect(spy).toBeCalledWith({ cell: [1, 1], range: { x: 0, y: 1, width: 2, height: 1 } }); + expect(spy).toBeCalledWith( + expect.objectContaining({ + current: expect.objectContaining({ cell: [1, 1], range: { x: 0, y: 1, width: 2, height: 1 } }), + }) + ); }); test("Arrow right", async () => { @@ -894,7 +933,7 @@ describe("data-editor", () => { key: "ArrowRight", }); - expect(spy).toBeCalledWith(expect.objectContaining({ cell: [2, 1] })); + expect(spy).toBeCalledWith(expect.objectContaining({ current: expect.objectContaining({ cell: [2, 1] }) })); }); test("Arrow shift right", async () => { @@ -922,7 +961,11 @@ describe("data-editor", () => { key: "ArrowRight", }); - expect(spy).toBeCalledWith({ cell: [1, 1], range: { x: 1, y: 1, width: 2, height: 1 } }); + expect(spy).toBeCalledWith( + expect.objectContaining({ + current: expect.objectContaining({ cell: [1, 1], range: { x: 1, y: 1, width: 2, height: 1 } }), + }) + ); }); test("Tab navigation", async () => { @@ -949,7 +992,7 @@ describe("data-editor", () => { key: "Tab", }); - expect(spy).toBeCalledWith(expect.objectContaining({ cell: [2, 1] })); + expect(spy).toBeCalledWith(expect.objectContaining({ current: expect.objectContaining({ cell: [2, 1] }) })); spy.mockClear(); fireEvent.keyDown(canvas, { @@ -957,7 +1000,7 @@ describe("data-editor", () => { shiftKey: true, }); - expect(spy).toBeCalledWith(expect.objectContaining({ cell: [1, 1] })); + expect(spy).toBeCalledWith(expect.objectContaining({ current: expect.objectContaining({ cell: [1, 1] }) })); }); test("Arrow down", async () => { @@ -984,7 +1027,7 @@ describe("data-editor", () => { key: "ArrowDown", }); - expect(spy).toBeCalledWith(expect.objectContaining({ cell: [1, 2] })); + expect(spy).toBeCalledWith(expect.objectContaining({ current: expect.objectContaining({ cell: [1, 2] }) })); }); test("Arrow up", async () => { @@ -1011,7 +1054,7 @@ describe("data-editor", () => { key: "ArrowUp", }); - expect(spy).toBeCalledWith(expect.objectContaining({ cell: [1, 1] })); + expect(spy).toBeCalledWith(expect.objectContaining({ current: expect.objectContaining({ cell: [1, 1] }) })); }); test("Search close", async () => { @@ -1099,7 +1142,9 @@ describe("data-editor", () => { }); expect(spy).toBeCalledWith( - expect.objectContaining({ cell: [1, 2], range: { x: 1, y: 2, width: 2, height: 1 } }) + expect.objectContaining({ + current: expect.objectContaining({ cell: [1, 2], range: { x: 1, y: 2, width: 2, height: 1 } }), + }) ); fireEvent.copy(window); @@ -1110,7 +1155,7 @@ describe("data-editor", () => { key: "ArrowDown", }); - expect(spy).toBeCalledWith(expect.objectContaining({ cell: [1, 3] })); + expect(spy).toBeCalledWith(expect.objectContaining({ current: expect.objectContaining({ cell: [1, 3] }) })); fireEvent.paste(window); await new Promise(resolve => setTimeout(resolve, 10)); @@ -1132,11 +1177,92 @@ describe("data-editor", () => { ); }); - test("Copy rows", async () => { + test("Copy/paste with simple getCellsForSelection", async () => { + const spy = jest.fn(); + const pasteSpy = jest.fn((_target: any, _values: any) => true); jest.useFakeTimers(); - render(, { - wrapper: Context, + render( + pasteSpy(...args)} + />, + { + wrapper: Context, + } + ); + prep(); + + const canvas = screen.getByTestId("data-grid-canvas"); + jest.spyOn(document, "activeElement", "get").mockImplementation(() => canvas); + fireEvent.mouseDown(canvas, { + clientX: 300, // Col B + clientY: 36 + 32 * 2 + 16, // Row 2 (0 indexed) + }); + + fireEvent.mouseUp(canvas, { + clientX: 300, // Col B + clientY: 36 + 32 * 2 + 16, // Row 2 (0 indexed) + }); + + spy.mockClear(); + fireEvent.keyDown(canvas, { + key: "ArrowRight", + shiftKey: true, }); + + expect(spy).toBeCalledWith( + expect.objectContaining({ + current: expect.objectContaining({ cell: [1, 2], range: { x: 1, y: 2, width: 2, height: 1 } }), + }) + ); + + fireEvent.copy(window); + expect(navigator.clipboard.writeText).toBeCalledWith("1, 2\t2, 2"); + + spy.mockClear(); + fireEvent.keyDown(canvas, { + key: "ArrowDown", + }); + + expect(spy).toBeCalledWith(expect.objectContaining({ current: expect.objectContaining({ cell: [1, 3] }) })); + + fireEvent.paste(window); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(pasteSpy).toBeCalledWith( + [1, 3], + [ + ["Sunday", "Dogs", "https://google.com"], + ["Monday", "Cats", "https://google.com"], + ["Tuesday", "Turtles", "https://google.com"], + ["Wednesday", "Bears", "https://google.com"], + ["Thursday", "L ions", "https://google.com"], + ["Friday", "Pigs", "https://google.com"], + [ + "Saturday", + 'Turkeys and some "quotes" and\na new line char "more quotes" plus a tab .', + "https://google.com", + ], + ] + ); + }); + + test("Copy rows", async () => { + jest.useFakeTimers(); + render( + , + { + wrapper: Context, + } + ); prep(); const canvas = screen.getByTestId("data-grid-canvas"); @@ -1148,9 +1274,19 @@ describe("data-editor", () => { test("Copy cols", async () => { jest.useFakeTimers(); - render(, { - wrapper: Context, - }); + render( + , + { + wrapper: Context, + } + ); prep(); const canvas = screen.getByTestId("data-grid-canvas"); @@ -1225,9 +1361,25 @@ describe("data-editor", () => { test("Blit does not crash horizontal scroll", async () => { jest.useFakeTimers(); - render(, { - wrapper: Context, - }); + render( + , + { + wrapper: Context, + } + ); const scroller = prep(); const canvas = screen.getByTestId("data-grid-canvas"); @@ -1307,7 +1459,7 @@ describe("data-editor", () => { test("Click row marker", async () => { const spy = jest.fn(); jest.useFakeTimers(); - render(, { + render(, { wrapper: Context, }); prep(); @@ -1324,13 +1476,16 @@ describe("data-editor", () => { clientY: 36 + 32 * 2 + 16, // Row 2 (0 indexed) }); - expect(spy).toHaveBeenCalledWith(CompactSelection.fromSingleSelection(2)); + expect(spy).toHaveBeenCalledWith({ + columns: CompactSelection.empty(), + rows: CompactSelection.fromSingleSelection(2), + }); }); test("Shift click row marker", async () => { const spy = jest.fn(); jest.useFakeTimers(); - render(, { + render(, { wrapper: Context, }); prep(); @@ -1360,13 +1515,58 @@ describe("data-editor", () => { clientY: 36 + 32 * 5 + 16, // Row 2 (0 indexed) }); - expect(spy).toHaveBeenCalledWith(CompactSelection.fromSingleSelection([2, 6])); + expect(spy).toHaveBeenCalledWith({ + columns: CompactSelection.empty(), + rows: CompactSelection.fromSingleSelection([2, 6]), + }); + }); + + test("Shift click row marker - no multi-select", async () => { + const spy = jest.fn(); + jest.useFakeTimers(); + render( + , + { + wrapper: Context, + } + ); + prep(); + const canvas = screen.getByTestId("data-grid-canvas"); + + fireEvent.mouseDown(canvas, { + clientX: 10, // Row marker + clientY: 36 + 32 * 2 + 16, // Row 2 (0 indexed) + }); + + fireEvent.mouseUp(canvas, { + clientX: 10, // Row marker + clientY: 36 + 32 * 2 + 16, // Row 2 (0 indexed) + }); + + spy.mockClear(); + + fireEvent.mouseDown(canvas, { + shiftKey: true, + clientX: 10, // Row marker + clientY: 36 + 32 * 5 + 16, // Row 2 (0 indexed) + }); + + fireEvent.mouseUp(canvas, { + shiftKey: true, + clientX: 10, // Row marker + clientY: 36 + 32 * 5 + 16, // Row 2 (0 indexed) + }); + + expect(spy).toHaveBeenCalledWith({ + columns: CompactSelection.empty(), + rows: CompactSelection.fromSingleSelection(5), + }); }); test("Ctrl click row marker", async () => { const spy = jest.fn(); jest.useFakeTimers(); - render(, { + render(, { wrapper: Context, }); prep(); @@ -1396,7 +1596,10 @@ describe("data-editor", () => { clientY: 36 + 32 * 5 + 16, // Row 2 (0 indexed) }); - expect(spy).toHaveBeenCalledWith(CompactSelection.fromSingleSelection(2).add(5)); + expect(spy).toHaveBeenCalledWith({ + columns: CompactSelection.empty(), + rows: CompactSelection.fromSingleSelection(2).add(5), + }); spy.mockClear(); @@ -1412,57 +1615,77 @@ describe("data-editor", () => { clientY: 36 + 32 * 5 + 16, // Row 2 (0 indexed) }); - expect(spy).toHaveBeenCalledWith(CompactSelection.fromSingleSelection(2)); + expect(spy).toHaveBeenCalledWith({ + columns: CompactSelection.empty(), + rows: CompactSelection.fromSingleSelection(2), + }); }); - test("Shift click grid selection", async () => { + test("Ctrl click row marker - no multi", async () => { const spy = jest.fn(); jest.useFakeTimers(); - render(, { - wrapper: Context, - }); + render( + , + { + wrapper: Context, + } + ); prep(); const canvas = screen.getByTestId("data-grid-canvas"); fireEvent.mouseDown(canvas, { - clientX: 300, // Col B + clientX: 10, // Row marker clientY: 36 + 32 * 2 + 16, // Row 2 (0 indexed) }); fireEvent.mouseUp(canvas, { - clientX: 300, // Col B + clientX: 10, // Row marker clientY: 36 + 32 * 2 + 16, // Row 2 (0 indexed) }); spy.mockClear(); fireEvent.mouseDown(canvas, { - shiftKey: true, - clientX: 400, // Col C - clientY: 36 + 32 * 6 + 16, // Row 6 (0 indexed) + ctrlKey: true, + clientX: 10, // Row marker + clientY: 36 + 32 * 5 + 16, // Row 2 (0 indexed) }); fireEvent.mouseUp(canvas, { - shiftKey: true, - clientX: 400, // Col C - clientY: 36 + 32 * 6 + 16, // Row 6 (0 indexed) + ctrlKey: true, + clientX: 10, // Row marker + clientY: 36 + 32 * 5 + 16, // Row 2 (0 indexed) }); expect(spy).toHaveBeenCalledWith({ - cell: [1, 2], - range: { - x: 1, - y: 2, - width: 2, - height: 5, - }, + columns: CompactSelection.empty(), + rows: CompactSelection.fromSingleSelection(5), + }); + + spy.mockClear(); + + fireEvent.mouseDown(canvas, { + ctrlKey: true, + clientX: 10, // Row marker + clientY: 36 + 32 * 5 + 16, // Row 2 (0 indexed) + }); + + fireEvent.mouseUp(canvas, { + ctrlKey: true, + clientX: 10, // Row marker + clientY: 36 + 32 * 5 + 16, // Row 2 (0 indexed) + }); + + expect(spy).toHaveBeenCalledWith({ + columns: CompactSelection.empty(), + rows: CompactSelection.empty(), }); }); - test("Fill down", async () => { + test("Shift click grid selection", async () => { const spy = jest.fn(); jest.useFakeTimers(); - render(, { + render(, { wrapper: Context, }); prep(); @@ -1478,6 +1701,8 @@ describe("data-editor", () => { clientY: 36 + 32 * 2 + 16, // Row 2 (0 indexed) }); + spy.mockClear(); + fireEvent.mouseDown(canvas, { shiftKey: true, clientX: 400, // Col C @@ -1490,24 +1715,80 @@ describe("data-editor", () => { clientY: 36 + 32 * 6 + 16, // Row 6 (0 indexed) }); - fireEvent.keyDown(canvas, { - keyCode: 68, - ctrlKey: true, - }); - - expect(spy).toHaveBeenCalledTimes(8); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + current: { + cell: [1, 2], + range: { + x: 1, + y: 2, + width: 2, + height: 5, + }, + rangeStack: [], + }, + }) + ); }); - test("Fill right", async () => { + test("Fill down", async () => { const spy = jest.fn(); jest.useFakeTimers(); - render(, { - wrapper: Context, - }); - prep(); - const canvas = screen.getByTestId("data-grid-canvas"); - - fireEvent.mouseDown(canvas, { + render( + , + { + wrapper: Context, + } + ); + prep(); + const canvas = screen.getByTestId("data-grid-canvas"); + + fireEvent.mouseDown(canvas, { + clientX: 300, // Col B + clientY: 36 + 32 * 2 + 16, // Row 2 (0 indexed) + }); + + fireEvent.mouseUp(canvas, { + clientX: 300, // Col B + clientY: 36 + 32 * 2 + 16, // Row 2 (0 indexed) + }); + + fireEvent.mouseDown(canvas, { + shiftKey: true, + clientX: 400, // Col C + clientY: 36 + 32 * 6 + 16, // Row 6 (0 indexed) + }); + + fireEvent.mouseUp(canvas, { + shiftKey: true, + clientX: 400, // Col C + clientY: 36 + 32 * 6 + 16, // Row 6 (0 indexed) + }); + + fireEvent.keyDown(canvas, { + keyCode: 68, + ctrlKey: true, + }); + + expect(spy).toHaveBeenCalledTimes(8); + }); + + test("Fill right", async () => { + const spy = jest.fn(); + jest.useFakeTimers(); + render(, { + wrapper: Context, + }); + prep(); + const canvas = screen.getByTestId("data-grid-canvas"); + + fireEvent.mouseDown(canvas, { clientX: 300, // Col B clientY: 36 + 32 * 2 + 16, // Row 2 (0 indexed) }); @@ -1576,7 +1857,10 @@ describe("data-editor", () => { key: "Escape", }); - expect(spy).toBeCalledWith(undefined); + expect(spy).toBeCalledWith({ + columns: CompactSelection.empty(), + rows: CompactSelection.empty(), + }); }); test("Delete range", async () => { @@ -1667,7 +1951,10 @@ describe("data-editor", () => { clientY: 36 + 32 * 6 + 16, // Row 6 (0 indexed) }); - expect(spy).toBeCalledWith(undefined); + expect(spy).toBeCalledWith({ + columns: CompactSelection.empty(), + rows: CompactSelection.empty(), + }); }); test("Delete Column", async () => { @@ -1771,8 +2058,11 @@ describe("data-editor", () => { render( , { @@ -1882,7 +2172,11 @@ describe("data-editor", () => { clientY: 36 + 32 * 12 + 16, // Row 2 }); - expect(spy).toBeCalledWith({ cell: [1, 2], range: { height: 11, width: 3, x: 1, y: 2 } }); + expect(spy).toBeCalledWith( + expect.objectContaining({ + current: { cell: [1, 2], range: { height: 11, width: 3, x: 1, y: 2 }, rangeStack: [] }, + }) + ); fireEvent.mouseUp(canvas, { clientX: 600, // Col B @@ -1892,19 +2186,10 @@ describe("data-editor", () => { test("Select all", async () => { const spy = jest.fn(); - const rowsSpy = jest.fn(); jest.useFakeTimers(); - render( - , - { - wrapper: Context, - } - ); + render(, { + wrapper: Context, + }); prep(); const canvas = screen.getByTestId("data-grid-canvas"); @@ -1918,8 +2203,10 @@ describe("data-editor", () => { clientY: 10, }); - expect(spy).toBeCalledWith(undefined); - expect(rowsSpy).toBeCalledWith(CompactSelection.fromSingleSelection([0, 1000])); + expect(spy).toBeCalledWith({ + columns: CompactSelection.empty(), + rows: CompactSelection.fromSingleSelection([0, 1000]), + }); fireEvent.mouseDown(canvas, { clientX: 10, @@ -1931,8 +2218,10 @@ describe("data-editor", () => { clientY: 10, }); - spy.mockClear(); - expect(rowsSpy).toBeCalledWith(CompactSelection.empty()); + expect(spy).toBeCalledWith({ + columns: CompactSelection.empty(), + rows: CompactSelection.empty(), + }); }); test("Draggable", async () => { @@ -2003,20 +2292,10 @@ describe("data-editor", () => { test("Click cell does not double-emit selectedrows/columns", async () => { const gridSelectionSpy = jest.fn(); - const selectedRowsSpy = jest.fn(); - const selectedColsSpy = jest.fn(); jest.useFakeTimers(); - render( - , - { - wrapper: Context, - } - ); + render(, { + wrapper: Context, + }); prep(); const canvas = screen.getByTestId("data-grid-canvas"); @@ -2030,18 +2309,21 @@ describe("data-editor", () => { clientY: 36 + 32 * 2 + 16, // Row 2 (0 indexed) }); - expect(gridSelectionSpy).toBeCalledWith({ cell: [1, 2], range: { height: 1, width: 1, x: 1, y: 2 } }); - expect(selectedRowsSpy).not.toHaveBeenCalled(); - expect(selectedColsSpy).not.toHaveBeenCalled(); + expect(gridSelectionSpy).toBeCalledWith( + expect.objectContaining({ + current: expect.objectContaining({ cell: [1, 2], range: { height: 1, width: 1, x: 1, y: 2 } }), + }) + ); gridSelectionSpy.mockClear(); fireEvent.keyDown(canvas, { key: "Escape", }); - expect(gridSelectionSpy).toBeCalledWith(undefined); - expect(selectedRowsSpy).not.toHaveBeenCalled(); - expect(selectedColsSpy).not.toHaveBeenCalled(); + expect(gridSelectionSpy).toBeCalledWith({ + rows: CompactSelection.empty(), + columns: CompactSelection.empty(), + }); }); test("Span expansion", async () => { @@ -2104,14 +2386,22 @@ describe("data-editor", () => { key: "ArrowDown", }); - expect(spy).toBeCalledWith({ cell: [2, 2], range: { x: 2, y: 2, width: 2, height: 2 } }); + expect(spy).toBeCalledWith( + expect.objectContaining({ + current: expect.objectContaining({ cell: [2, 2], range: { x: 2, y: 2, width: 2, height: 2 } }), + }) + ); spy.mockClear(); fireEvent.keyDown(canvas, { key: "ArrowDown", }); - expect(spy).toBeCalledWith({ cell: [2, 3], range: { x: 2, y: 3, width: 2, height: 1 } }); + expect(spy).toBeCalledWith( + expect.objectContaining({ + current: expect.objectContaining({ cell: [2, 3], range: { x: 2, y: 3, width: 2, height: 1 } }), + }) + ); }); test("Imperative Handle works", async () => { @@ -2162,7 +2452,7 @@ describe("data-editor", () => { const cols = basicProps.columns.length; - expect(spy).toBeCalledWith(expect.objectContaining({ cell: [1, 999] })); + expect(spy).toBeCalledWith(expect.objectContaining({ current: expect.objectContaining({ cell: [1, 999] }) })); spy.mockClear(); fireEvent.keyDown(canvas, { @@ -2170,7 +2460,9 @@ describe("data-editor", () => { ctrlKey: true, }); - expect(spy).toBeCalledWith(expect.objectContaining({ cell: [cols - 1, 999] })); + expect(spy).toBeCalledWith( + expect.objectContaining({ current: expect.objectContaining({ cell: [cols - 1, 999] }) }) + ); spy.mockClear(); fireEvent.keyDown(canvas, { @@ -2178,7 +2470,9 @@ describe("data-editor", () => { ctrlKey: true, }); - expect(spy).toBeCalledWith(expect.objectContaining({ cell: [cols - 1, 0] })); + expect(spy).toBeCalledWith( + expect.objectContaining({ current: expect.objectContaining({ cell: [cols - 1, 0] }) }) + ); spy.mockClear(); fireEvent.keyDown(canvas, { @@ -2186,7 +2480,7 @@ describe("data-editor", () => { ctrlKey: true, }); - expect(spy).toBeCalledWith(expect.objectContaining({ cell: [0, 0] })); + expect(spy).toBeCalledWith(expect.objectContaining({ current: expect.objectContaining({ cell: [0, 0] }) })); spy.mockClear(); fireEvent.keyDown(canvas, { @@ -2196,7 +2490,9 @@ describe("data-editor", () => { }); expect(spy).toBeCalledWith( - expect.objectContaining({ cell: [0, 0], range: { x: 0, y: 0, width: 1, height: 1000 } }) + expect.objectContaining({ + current: expect.objectContaining({ cell: [0, 0], range: { x: 0, y: 0, width: 1, height: 1000 } }), + }) ); spy.mockClear(); @@ -2207,7 +2503,9 @@ describe("data-editor", () => { }); expect(spy).toBeCalledWith( - expect.objectContaining({ cell: [0, 0], range: { x: 0, y: 0, width: cols, height: 1000 } }) + expect.objectContaining({ + current: expect.objectContaining({ cell: [0, 0], range: { x: 0, y: 0, width: cols, height: 1000 } }), + }) ); // spy.mockClear(); @@ -2254,11 +2552,304 @@ describe("data-editor", () => { clientY: 36 + 32 * 12 + 16, // Row 2 }); - expect(spy).toBeCalledWith({ cell: [1, 2], range: { height: 11, width: 1, x: 1, y: 2 } }); + expect(spy).toBeCalledWith( + expect.objectContaining({ + current: expect.objectContaining({ cell: [1, 2], range: { height: 11, width: 1, x: 1, y: 2 } }), + }) + ); + + fireEvent.mouseUp(canvas, { + clientX: 600, // Col B + clientY: 36 + 32 * 12 + 16, // Row 2 + }); + }); + + test("Select all", async () => { + const spy = jest.fn(); + jest.useFakeTimers(); + render(, { + wrapper: Context, + }); + prep(); + const canvas = screen.getByTestId("data-grid-canvas"); + + fireEvent.keyDown(canvas, { + key: "a", + ctrlKey: true, + }); + + expect(spy).toHaveBeenCalledWith({ + columns: CompactSelection.empty(), + rows: CompactSelection.empty(), + current: { + cell: [0, 0], + range: { + x: 0, + y: 0, + width: 10, + height: 1000, + }, + rangeStack: [], + }, + }); + }); + + test("Select column with blending", async () => { + const spy = jest.fn(); + jest.useFakeTimers(); + render( + , + { + wrapper: Context, + } + ); + prep(); + + const canvas = screen.getByTestId("data-grid-canvas"); + fireEvent.mouseDown(canvas, { + clientX: 300, // Col B + clientY: 36 + 32 + 16, // Row 1 (0 indexed) + }); + + fireEvent.mouseUp(canvas, { + clientX: 300, // Col B + clientY: 36 + 32 + 16, // Row 1 (0 indexed) + }); + + spy.mockClear(); + fireEvent.keyDown(canvas, { + key: " ", + ctrlKey: true, + }); + + expect(spy).toHaveBeenCalledWith({ + columns: CompactSelection.fromSingleSelection(1), + rows: CompactSelection.empty(), + current: { + cell: [1, 1], + range: { + x: 1, + y: 1, + width: 1, + height: 1, + }, + rangeStack: [], + }, + }); + }); + + test("Select column", async () => { + const spy = jest.fn(); + jest.useFakeTimers(); + render(, { + wrapper: Context, + }); + prep(); + + const canvas = screen.getByTestId("data-grid-canvas"); + fireEvent.mouseDown(canvas, { + clientX: 300, // Col B + clientY: 36 + 32 + 16, // Row 1 (0 indexed) + }); + + fireEvent.mouseUp(canvas, { + clientX: 300, // Col B + clientY: 36 + 32 + 16, // Row 1 (0 indexed) + }); + + spy.mockClear(); + fireEvent.keyDown(canvas, { + key: " ", + ctrlKey: true, + }); + + expect(spy).toHaveBeenCalledWith({ + columns: CompactSelection.fromSingleSelection(1), + rows: CompactSelection.empty(), + current: undefined, + }); + }); + + test("Select row with blending", async () => { + const spy = jest.fn(); + jest.useFakeTimers(); + render( + , + { + wrapper: Context, + } + ); + prep(); + + const canvas = screen.getByTestId("data-grid-canvas"); + fireEvent.mouseDown(canvas, { + clientX: 300, // Col B + clientY: 36 + 32 + 16, // Row 1 (0 indexed) + }); + + fireEvent.mouseUp(canvas, { + clientX: 300, // Col B + clientY: 36 + 32 + 16, // Row 1 (0 indexed) + }); + + spy.mockClear(); + fireEvent.keyDown(canvas, { + key: " ", + shiftKey: true, + }); + + expect(spy).toHaveBeenCalledWith({ + columns: CompactSelection.empty(), + rows: CompactSelection.fromSingleSelection(1), + current: { + cell: [1, 1], + range: { + x: 1, + y: 1, + width: 1, + height: 1, + }, + rangeStack: [], + }, + }); + }); + + test("Select row", async () => { + const spy = jest.fn(); + jest.useFakeTimers(); + render(, { + wrapper: Context, + }); + prep(); + + const canvas = screen.getByTestId("data-grid-canvas"); + fireEvent.mouseDown(canvas, { + clientX: 300, // Col B + clientY: 36 + 32 + 16, // Row 1 (0 indexed) + }); + + fireEvent.mouseUp(canvas, { + clientX: 300, // Col B + clientY: 36 + 32 + 16, // Row 1 (0 indexed) + }); + + spy.mockClear(); + fireEvent.keyDown(canvas, { + key: " ", + shiftKey: true, + }); + + expect(spy).toHaveBeenCalledWith({ + columns: CompactSelection.empty(), + rows: CompactSelection.fromSingleSelection(1), + current: undefined, + }); + }); + + test("Select range with mouse then permissive move", async () => { + const spy = jest.fn(); + jest.useFakeTimers(); + render(, { + wrapper: Context, + }); + prep(); + const canvas = screen.getByTestId("data-grid-canvas"); + + fireEvent.mouseDown(canvas, { + clientX: 300, // Col B + clientY: 36 + 32 * 2 + 16, // Row 2 + }); + + fireEvent.mouseMove(canvas, { + clientX: 600, // Col B + clientY: 36 + 32 * 12 + 16, // Row 2 + }); fireEvent.mouseUp(canvas, { clientX: 600, // Col B clientY: 36 + 32 * 12 + 16, // Row 2 }); + + spy.mockClear(); + + fireEvent.keyDown(canvas, { + key: "ArrowLeft", + altKey: true, + }); + + expect(spy).toBeCalledWith( + expect.objectContaining({ + current: { + cell: [0, 2], + range: { height: 1, width: 1, x: 0, y: 2 }, + rangeStack: [{ height: 11, width: 3, x: 1, y: 2 }], + }, + }) + ); + }); + + test("Close overlay with enter key", async () => { + const spy = jest.fn(); + jest.useFakeTimers(); + render( + , + { + wrapper: Context, + } + ); + prep(); + + const canvas = screen.getByTestId("data-grid-canvas"); + fireEvent.mouseDown(canvas, { + clientX: 300, // Col B + clientY: 36 + 32 + 16, // Row 1 (0 indexed) + }); + + fireEvent.mouseUp(canvas, { + clientX: 300, // Col B + clientY: 36 + 32 + 16, // Row 1 (0 indexed) + }); + + fireEvent.keyDown(canvas, { + key: "Enter", + }); + + spy.mockClear(); + fireEvent.keyDown(canvas, { + key: "Enter", + }); + + expect(spy).toHaveBeenCalledWith({ + columns: CompactSelection.empty(), + rows: CompactSelection.empty(), + current: { + cell: [1, 2], + range: { + x: 1, + y: 2, + width: 1, + height: 1, + }, + rangeStack: [], + }, + }); }); }); diff --git a/packages/core/src/data-editor/data-editor.tsx b/packages/core/src/data-editor/data-editor.tsx index 44fc4379d..370b725cf 100644 --- a/packages/core/src/data-editor/data-editor.tsx +++ b/packages/core/src/data-editor/data-editor.tsx @@ -24,10 +24,11 @@ import { ProvideEditorCallback, DrawCustomCellCallback, GridMouseCellEventArgs, - GridMouseHeaderEventArgs, - GridMouseGroupHeaderEventArgs, GridColumn, isObjectEditorCallbackResult, + GroupHeaderClickedEventArgs, + HeaderClickedEventArgs, + CellClickedEventArgs, } from "../data-grid/data-grid-types"; import DataGridSearch, { DataGridSearchProps } from "../data-grid-search/data-grid-search"; import { browserIsOSX } from "../common/browser-detect"; @@ -40,6 +41,9 @@ import { CellRenderers } from "../data-grid/cells"; import { isGroupEqual } from "../data-grid/data-grid-lib"; import { GroupRename } from "./group-rename"; import { useCellSizer } from "./use-cell-sizer"; +import { isHotkey } from "../common/is-hotkey"; +import { SelectionBlending, useSelectionBehavior } from "../data-grid/use-selection-behavior"; +import { useCellsForSelection } from "./use-cells-for-selection"; interface MouseState { readonly previousSelection?: GridSelection; @@ -64,6 +68,7 @@ type Props = Omit< | "lastRowSticky" | "lockColumns" | "firstColAccessible" + | "getCellsForSelection" | "onCellFocused" | "onKeyDown" | "onKeyUp" @@ -77,7 +82,7 @@ type Props = Omit< | "verticalBorder" | "scrollRef" | "searchColOffset" - | "selectedCell" + | "selection" | "selectedColumns" | "translateX" | "translateY" @@ -87,19 +92,8 @@ type ImageEditorType = React.ComponentType; type ReplaceReturnType any, TNewReturn> = (...a: Parameters) => TNewReturn; -export type HeaderSelectionTrigger = "selection" | "drag" | "header" | "group"; - type EmitEvents = "copy" | "paste" | "delete" | "fill-right" | "fill-down"; -interface PreventableEvent { - preventDefault: () => void; -} -interface CellClickedEventArgs extends GridMouseCellEventArgs, PreventableEvent {} - -interface HeaderClickedEventArgs extends GridMouseHeaderEventArgs, PreventableEvent {} - -interface GroupHeaderClickedEventArgs extends GridMouseGroupHeaderEventArgs, PreventableEvent {} - function getSpanStops(cells: readonly (readonly GridCell[])[]): number[] { const disallowed = uniq( flatten( @@ -111,8 +105,64 @@ function getSpanStops(cells: readonly (readonly GridCell[])[]): number[] { return disallowed; } +function shiftSelection(input: GridSelection, offset: number): GridSelection { + if (input === undefined || offset === 0 || (input.columns.length === 0 && input.current === undefined)) + return input; + + return { + current: + input.current === undefined + ? undefined + : { + cell: [input.current.cell[0] + offset, input.current.cell[1]], + range: { + ...input.current.range, + x: input.current.range.x + offset, + }, + rangeStack: input.current.rangeStack.map(r => ({ + ...r, + x: r.x + offset, + })), + }, + rows: input.rows, + columns: input.columns.offset(offset), + }; +} + +interface Keybinds { + readonly selectAll: boolean; + readonly selectRow: boolean; + readonly selectColumn: boolean; + readonly downFill: boolean; + readonly rightFill: boolean; + readonly pageUp: boolean; + readonly pageDown: boolean; + readonly clear: boolean; + readonly copy: boolean; + readonly paste: boolean; + readonly search: boolean; + readonly first: boolean; + readonly last: boolean; +} + +const keybindingDefaults: Keybinds = { + selectAll: true, + selectRow: true, + selectColumn: true, + downFill: false, + rightFill: false, + pageUp: false, + pageDown: false, + clear: true, + copy: true, + paste: true, + search: false, + first: true, + last: true, +}; + export interface DataEditorProps extends Props { - readonly onDeleteRows?: (rows: readonly number[]) => void; + readonly onDelete?: (selection: GridSelection) => boolean | GridSelection; readonly onCellEdited?: (cell: readonly [number, number], newValue: EditableGridCell) => void; readonly onRowAppended?: () => Promise<"top" | "bottom" | number | undefined> | void; readonly onHeaderClicked?: (colIndex: number, event: HeaderClickedEventArgs) => void; @@ -141,6 +191,13 @@ export interface DataEditorProps extends Props { readonly spanRangeBehavior?: "default" | "allowPartial"; + readonly rangeSelectionBlending?: SelectionBlending; + readonly columnSelectionBlending?: SelectionBlending; + readonly rowSelectionBlending?: SelectionBlending; + readonly rangeMultiSelect?: boolean; + readonly columnMultiSelect?: boolean; + readonly rowMultiSelect?: boolean; + readonly rowHeight?: DataGridSearchProps["rowHeight"]; readonly onMouseMove?: DataGridSearchProps["onMouseMove"]; @@ -149,11 +206,6 @@ export interface DataEditorProps extends Props { readonly provideEditor?: ProvideEditorCallback; - readonly onSelectedRowsChange?: (newRows: CompactSelection) => void; - - readonly selectedColumns?: DataGridSearchProps["selectedColumns"]; - readonly onSelectedColumnsChange?: (newColumns: CompactSelection, trigger: HeaderSelectionTrigger) => void; - readonly onSelectionCleared?: () => void; /** @@ -170,7 +222,7 @@ export interface DataEditorProps extends Props { readonly drawCell?: DrawCustomCellCallback; readonly gridSelection?: GridSelection; - readonly onGridSelectionChange?: (newSelection: GridSelection | undefined) => void; + readonly onGridSelectionChange?: (newSelection: GridSelection) => void; readonly onVisibleRegionChanged?: ( range: Rectangle, tx?: number, @@ -184,8 +236,9 @@ export interface DataEditorProps extends Props { readonly getCellContent: ReplaceReturnType; readonly rowSelectionMode?: "auto" | "multi"; - readonly enableDownfill?: boolean; - readonly enableRightfill?: boolean; + readonly keybindings?: Partial; + + readonly getCellsForSelection?: DataGridSearchProps["getCellsForSelection"] | true; readonly freezeColumns?: DataGridSearchProps["freezeColumns"]; @@ -214,10 +267,14 @@ const loadingCell: GridCell = { allowOverlay: false, }; +const emptyGridSelection: GridSelection = { + columns: CompactSelection.empty(), + rows: CompactSelection.empty(), + current: undefined, +}; + const DataEditorImpl: React.ForwardRefRenderFunction = (p, forwardedRef) => { - const [gridSelectionInner, setGridSelectionInner] = React.useState(); - const [selectedColumnsInner, setSelectedColumnsInner] = React.useState(CompactSelection.empty()); - const [selectedRowsInner, setSelectedRowsInner] = React.useState(CompactSelection.empty()); + const [gridSelectionInner, setGridSelectionInner] = React.useState(emptyGridSelection); const [overlay, setOverlay] = React.useState<{ target: Rectangle; content: GridCell; @@ -234,7 +291,6 @@ const DataEditorImpl: React.ForwardRefRenderFunction { + return keybindingsIn === undefined + ? keybindingDefaults + : { + ...keybindingDefaults, + ...keybindingsIn, + }; + }, [keybindingsIn]); + const rowMarkerWidth = rowMarkerWidthRaw ?? (rows > 10000 ? 48 : rows > 1000 ? 44 : rows > 100 ? 36 : 32); const hasRowMarkers = rowMarkers !== "none"; const rowMarkerOffset = hasRowMarkers ? 1 : 0; const showTrailingBlankRow = onRowAppended !== undefined; const lastRowSticky = trailingRowOptions?.sticky === true; - const gridSelectionOuterMangled: GridSelection | undefined = React.useMemo(() => { - return gridSelectionOuter === undefined - ? undefined - : { - cell: [gridSelectionOuter.cell[0] + rowMarkerOffset, gridSelectionOuter.cell[1]], - range: { - ...gridSelectionOuter.range, - x: gridSelectionOuter.range.x + rowMarkerOffset, - }, - }; + const [showSearchInner, setShowSearchInner] = React.useState(false); + const showSearch = showSearchIn ?? showSearchInner; + + const onSearchClose = React.useCallback(() => { + if (onSearchCloseIn !== undefined) { + onSearchCloseIn(); + } else { + setShowSearchInner(false); + } + }, [onSearchCloseIn]); + + const gridSelectionOuterMangled: GridSelection | undefined = React.useMemo((): GridSelection | undefined => { + return gridSelectionOuter === undefined ? undefined : shiftSelection(gridSelectionOuter, rowMarkerOffset); }, [gridSelectionOuter, rowMarkerOffset]); const gridSelection = gridSelectionOuterMangled ?? gridSelectionInner; + const [getCellsForSelection, getCellsForSeletionDirect] = useCellsForSelection( + getCellsForSelectionIn, + getCellContent, + rowMarkerOffset + ); + const expandSelection = React.useCallback( - (newVal: GridSelection | undefined): GridSelection | undefined => { - if (spanRangeBehavior === "allowPartial") return newVal; - if (newVal !== undefined && getCellsForSelection !== undefined) { + (newVal: GridSelection): GridSelection => { + if (spanRangeBehavior === "allowPartial" || newVal.current === undefined) return newVal; + if (getCellsForSelection !== undefined) { let isFilled = false; do { - if (newVal === undefined) break; - const r: Rectangle = newVal.range; + if (newVal?.current === undefined) break; + const r: Rectangle = newVal.current?.range; const cells: (readonly GridCell[])[] = []; if (r.width > 2) { const leftCells = getCellsForSelection({ - x: r.x - rowMarkerOffset, + x: r.x, y: r.y, width: 1, height: r.height, @@ -326,7 +405,7 @@ const DataEditorImpl: React.ForwardRefRenderFunction { + (newVal: GridSelection, expand: boolean): void => { if (expand) { newVal = expandSelection(newVal); } if (onGridSelectionChange !== undefined) { - if (newVal === undefined) { - onGridSelectionChange(undefined); - } else { - onGridSelectionChange({ - cell: [newVal.cell[0] - rowMarkerOffset, newVal.cell[1]], - range: { - ...newVal.range, - x: newVal.range.x - rowMarkerOffset, - }, - }); - } + onGridSelectionChange(shiftSelection(newVal, -rowMarkerOffset)); } else { setGridSelectionInner(newVal); } }, [onGridSelectionChange, rowMarkerOffset, expandSelection] ); - const selectedRows = selectedRowsOuter ?? selectedRowsInner; - const setSelectedRowsCb = setSelectedRowsOuter ?? setSelectedRowsInner; - - const selectedRowsRef = React.useRef(selectedRows); - selectedRowsRef.current = selectedRows; - const setSelectedRows = React.useCallback( - (newRows: CompactSelection) => { - if (selectedRowsRef.current.equals(newRows)) return; - setSelectedRowsCb(newRows); - }, - [setSelectedRowsCb] - ); - - const mangledOuterCols = selectedColumnsOuter?.offset(rowMarkerOffset); - const selectedColumns = mangledOuterCols ?? selectedColumnsInner; - const selectedColumnsRef = React.useRef(selectedColumns); - selectedColumnsRef.current = selectedColumns; - const setSelectedColumns = React.useCallback( - (newColumns: CompactSelection, trigger: HeaderSelectionTrigger) => { - if (selectedColumnsRef.current.equals(newColumns)) return; - if (setSelectedColumnsOuter !== undefined) { - setSelectedColumnsOuter?.(newColumns.offset(-rowMarkerOffset), trigger); - } else { - setSelectedColumnsInner(newColumns); + const onDelete = React.useCallback>( + sel => { + if (onDeleteIn !== undefined) { + return onDeleteIn(shiftSelection(sel, -rowMarkerOffset)); } + return true; }, - [rowMarkerOffset, setSelectedColumnsOuter] + [onDeleteIn, rowMarkerOffset] + ); + + const [setCurrent, setSelectedRows, setSelectedColumns] = useSelectionBehavior( + gridSelection, + setGridSelection, + rangeSelectionBlending, + columnSelectionBlending, + rowSelectionBlending, + rangeMultiSelect ); const theme = useTheme(); @@ -431,7 +495,19 @@ const DataEditorImpl: React.ForwardRefRenderFunction { + if (highlightRegionsIn === undefined) return undefined; + if (rowMarkerOffset === 0) return highlightRegionsIn; + return highlightRegionsIn.map(r => ({ + color: r.color, + range: { + ...r.range, + x: r.range.x + rowMarkerOffset, + }, + })); + }, [highlightRegionsIn, rowMarkerOffset]); const enableGroups = React.useMemo(() => { return columns.some(c => c.group !== undefined); @@ -505,7 +581,7 @@ const DataEditorImpl: React.ForwardRefRenderFunction { - if (gridSelection === undefined) return; + if (gridSelection.current === undefined) return; - const [col, row] = gridSelection.cell; + const [col, row] = gridSelection.current.cell; const c = getMangedCellContent([col, row]); if (c.kind !== GridCellKind.Boolean && c.allowOverlay) { let content = c; @@ -609,11 +685,11 @@ const DataEditorImpl: React.ForwardRefRenderFunction { const isMultiKey = browserIsOSX.value ? args.metaKey : args.ctrlKey; + const isMultiRow = isMultiKey && rowMultiSelect; + const isMultiCol = isMultiKey && columnMultiSelect; const [col, row] = args.location; + const selectedColumns = gridSelection.columns; + const selectedRows = gridSelection.rows; + const [cellCol, cellRow] = gridSelection.current?.cell ?? []; if (args.kind === "cell") { lastSelectedColRef.current = undefined; @@ -829,51 +913,52 @@ const DataEditorImpl: React.ForwardRefRenderFunction= rowMarkerOffset && showTrailingBlankRow && row === rows) { const customTargetColumn = getCustomNewRowTargetColumn(col); void appendRow(customTargetColumn ?? col); } else { - if (gridSelection?.cell[0] !== col || gridSelection.cell[1] !== row) { + if (cellCol !== col || cellRow !== row) { const isLastStickyRow = lastRowSticky && row === rows; const startedFromLastSticky = - lastRowSticky && gridSelection !== undefined && gridSelection.cell[1] === rows; + lastRowSticky && gridSelection !== undefined && gridSelection.current?.cell[1] === rows; if ( (args.shiftKey || args.isLongTouch === true) && - gridSelection !== undefined && + cellCol !== undefined && + cellRow !== undefined && + gridSelection.current !== undefined && !startedFromLastSticky ) { if (isLastStickyRow) { @@ -882,14 +967,13 @@ const DataEditorImpl: React.ForwardRefRenderFunction= rowMarkerOffset; i--) { - if (!isGroupEqual(needle.group, mangledCols[i].group)) break; - start--; - } - - for (let i = col + 1; i < mangledCols.length; i++) { - if (!isGroupEqual(needle.group, mangledCols[i].group)) break; - end++; - } - - setSelectedRows(CompactSelection.empty()); - setGridSelection(undefined, false); - focus(); - - if (isMultiKey) { - if (selectedColumns.hasAll([start, end + 1])) { - let newVal = selectedColumns; - for (let index = start; index <= end; index++) { - newVal = newVal.remove(index); - } - setSelectedColumns(newVal, "group"); - } else { - setSelectedColumns(selectedColumns.add([start, end + 1]), "group"); - } - } else { - setSelectedColumns(CompactSelection.fromSingleSelection([start, end + 1]), "group"); - } } }, [ appendRow, + columnMultiSelect, focus, getCustomNewRowTargetColumn, gridSelection, hasRowMarkers, lastRowSticky, - mangledCols, onSelectionCleared, rowMarkerOffset, rowMarkers, + rowMultiSelect, rowSelectionMode, rows, - selectedColumns, - selectedRows, + setCurrent, setGridSelection, setSelectedColumns, setSelectedRows, @@ -1050,6 +1105,49 @@ const DataEditorImpl: React.ForwardRefRenderFunction(); + const handleGroupHeaderSelection = React.useCallback( + (args: GridMouseEventArgs) => { + if (args.kind !== "group-header" || !columnMultiSelect) { + return; + } + const isMultiKey = browserIsOSX.value ? args.metaKey : args.ctrlKey; + const [col] = args.location; + const selectedColumns = gridSelection.columns; + + if (col < rowMarkerOffset) return; + + const needle = mangledCols[col]; + let start = col; + let end = col; + for (let i = col - 1; i >= rowMarkerOffset; i--) { + if (!isGroupEqual(needle.group, mangledCols[i].group)) break; + start--; + } + + for (let i = col + 1; i < mangledCols.length; i++) { + if (!isGroupEqual(needle.group, mangledCols[i].group)) break; + end++; + } + + focus(); + + if (isMultiKey) { + if (selectedColumns.hasAll([start, end + 1])) { + let newVal = selectedColumns; + for (let index = start; index <= end; index++) { + newVal = newVal.remove(index); + } + setSelectedColumns(newVal, undefined, isMultiKey); + } else { + setSelectedColumns(undefined, [start, end + 1], isMultiKey); + } + } else { + setSelectedColumns(CompactSelection.fromSingleSelection([start, end + 1]), undefined, isMultiKey); + } + }, + [columnMultiSelect, focus, gridSelection.columns, mangledCols, rowMarkerOffset, setSelectedColumns] + ); + const onMouseUp = React.useCallback( (args: GridMouseEventArgs, isOutside: boolean) => { const mouse = mouseState.current; @@ -1075,9 +1173,13 @@ const DataEditorImpl: React.ForwardRefRenderFunction { - let selected = gridSelection?.cell; + let selected = currentCell; if (selected !== undefined) { selected = [selected[0] - rowMarkerOffset, selected[1]]; } @@ -1213,13 +1323,13 @@ const DataEditorImpl: React.ForwardRefRenderFunction { onColumnMoved?.(startIndex - rowMarkerOffset, endIndex - rowMarkerOffset); - setSelectedColumns(CompactSelection.fromSingleSelection(endIndex), "drag"); + setSelectedColumns(CompactSelection.fromSingleSelection(endIndex), undefined, true); }, [onColumnMoved, rowMarkerOffset, setSelectedColumns] ); @@ -1247,8 +1357,8 @@ const DataEditorImpl: React.ForwardRefRenderFunction { - if (mouseState.current !== undefined && gridSelection !== undefined && !isDraggable) { - const [selectedCol, selectedRow] = gridSelection.cell; + if (mouseState.current !== undefined && gridSelection.current !== undefined && !isDraggable) { + const [selectedCol, selectedRow] = gridSelection.current.cell; // eslint-disable-next-line prefer-const let [col, row] = args.location; @@ -1270,12 +1380,14 @@ const DataEditorImpl: React.ForwardRefRenderFunction { - if (gridSelection === undefined) return; + if (gridSelection.current === undefined) return; const [x, y] = direction; - const [col, row] = gridSelection.cell; - const old = gridSelection.range; + const [col, row] = gridSelection.current.cell; + const old = gridSelection.current.range; let left = old.x; let right = old.x + old.width; let top = old.y; @@ -1388,10 +1500,12 @@ const DataEditorImpl: React.ForwardRefRenderFunction { + (col: number, row: number, fromEditingTrailingRow: boolean, freeMove: boolean): boolean => { const rowMax = mangledRows - (fromEditingTrailingRow ? 0 : 1); col = clamp(col, rowMarkerOffset, columns.length - 1 + rowMarkerOffset); row = clamp(row, 0, rowMax); - if (col === gridSelection?.cell[0] && row === gridSelection?.cell[1]) return false; - setGridSelection({ cell: [col, row], range: { x: col, y: row, width: 1, height: 1 } }, true); + if (col === currentCell?.[0] && row === currentCell?.[1]) return false; + if (freeMove && gridSelection.current !== undefined) { + const newStack = [...gridSelection.current.rangeStack]; + if (gridSelection.current.range.width > 1 || gridSelection.current.range.height > 1) { + newStack.push(gridSelection.current.range); + } + setGridSelection( + { + ...gridSelection, + current: { + cell: [col, row], + range: { x: col, y: row, width: 1, height: 1 }, + rangeStack: newStack, + }, + }, + true + ); + } else { + setCurrent( + { + cell: [col, row], + range: { x: col, y: row, width: 1, height: 1 }, + }, + true, + false, + "keyboard-nav" + ); + } if (lastSent.current !== undefined && lastSent.current[0] === col && lastSent.current[1] === row) { lastSent.current = undefined; @@ -1517,7 +1661,16 @@ const DataEditorImpl: React.ForwardRefRenderFunction { if (selCol === cell[0] && selRow === cell[1]) return; - setGridSelection( + setCurrent( { cell, range: { x: cell[0], y: cell[1], width: 1, height: 1 }, }, - true + true, + false, + "keyboard-nav" ); - setSelectedRows(CompactSelection.empty()); }, - [selCol, selRow, setGridSelection, setSelectedRows] + [selCol, selRow, setCurrent] ); const onKeyDown = React.useCallback( @@ -1571,25 +1731,40 @@ const DataEditorImpl: React.ForwardRefRenderFunction ({ cell: x }))); } - if (isDeleteKey && selectedColumns.length > 0 && gridSelection === undefined) { + if (isDeleteKey) { + const callbackResult = onDelete?.(gridSelection) ?? true; event.cancel(); - for (const col of selectedColumns) { - deleteRange({ - x: col, - y: 0, - width: 1, - height: rows, - }); + if (callbackResult !== false) { + const toDelete = callbackResult === true ? gridSelection : callbackResult; + + // delete order: + // 1) primary range + // 2) secondary ranges + // 3) columns + // 4) rows + + if (toDelete.current !== undefined) { + deleteRange(toDelete.current.range); + for (const r of toDelete.current.rangeStack) { + deleteRange(r); + } + } + + for (const r of toDelete.rows) { + deleteRange({ + x: rowMarkerOffset, + y: r, + width: mangledCols.length - rowMarkerOffset, + height: 1, + }); + } + + for (const col of toDelete.columns) { + deleteRange({ + x: col, + y: 0, + width: 1, + height: rows, + }); + } } return; } - if (gridSelection === undefined) return; - let [col, row] = gridSelection.cell; + if (gridSelection.current === undefined) return; + let [col, row] = gridSelection.current.cell; + let freeMove = false; - if (event.key === "Enter" && event.bounds !== undefined) { + if (keybindings.selectColumn && isHotkey("primary+ ", event)) { + if (selectedColumns.hasIndex(col)) { + setSelectedColumns(selectedColumns.remove(col), undefined, true); + } else { + setSelectedColumns(undefined, col, true); + } + } else if (keybindings.selectRow && isHotkey("shift+ ", event)) { + if (selectedRows.hasIndex(row)) { + setSelectedRows(selectedRows.remove(row), undefined, true); + } else { + setSelectedRows(undefined, row, true); + } + } else if ( + (isHotkey("Enter", event) || isHotkey(" ", event) || isHotkey("shift+Enter", event)) && + event.bounds !== undefined + ) { if (overlayOpen) { setOverlay(undefined); - row++; + if (isHotkey("Enter", event)) { + row++; + } else if (isHotkey("shift+Enter", event)) { + row--; + } } else if (row === rows && showTrailingBlankRow) { window.setTimeout(() => { const customTargetColumn = getCustomNewRowTargetColumn(col); @@ -1649,10 +1871,14 @@ const DataEditorImpl: React.ForwardRefRenderFunction 1 && enableDownfill) { + } else if ( + keybindings.downFill && + isHotkey("primary+_68", event) && + gridSelection.current.range.height > 1 + ) { // ctrl/cmd + d const damage: (readonly [number, number])[] = []; - const r = gridSelection.range; + const r = gridSelection.current.range; for (let x = 0; x < r.width; x++) { const fillCol = x + r.x; const fillVal = getMangedCellContent([fillCol, r.y]); @@ -1673,10 +1899,14 @@ const DataEditorImpl: React.ForwardRefRenderFunction 1 && enableRightfill) { + } else if ( + keybindings.rightFill && + isHotkey("primary+_82", event) && + gridSelection.current.range.width > 1 + ) { // ctrl/cmd + r const damage: (readonly [number, number])[] = []; - const r = gridSelection.range; + const r = gridSelection.current.range; for (let y = 0; y < r.height; y++) { const fillRow = y + r.y; const fillVal = getMangedCellContent([r.x, fillRow]); @@ -1697,33 +1927,64 @@ const DataEditorImpl: React.ForwardRefRenderFunction { + if (!keybindings.paste) return; function pasteToCell(inner: InnerGridCell, target: readonly [number, number], toPaste: string): boolean { if (!isInnerOnlyCell(inner) && isReadWriteCell(inner) && inner.readonly !== true) { switch (inner.kind) { @@ -1900,11 +2170,13 @@ const DataEditorImpl: React.ForwardRefRenderFunction { + if (!keybindings.copy) return; const focused = scrollRef.current?.contains(document.activeElement) === true || canvasRef.current?.contains(document.activeElement) === true; + const selectedColumns = gridSelection.columns; + const selectedRows = gridSelection.rows; + if (focused && getCellsForSelection !== undefined) { - if (gridSelection !== undefined) { + if (gridSelection.current !== undefined) { copyToClipboard( - getCellsForSelection({ - ...gridSelection.range, - x: gridSelection.range.x - rowMarkerOffset, - }), + getCellsForSelection(gridSelection.current.range), range( - gridSelection.range.x - rowMarkerOffset, - gridSelection.range.x + gridSelection.range.width - rowMarkerOffset + gridSelection.current.range.x - rowMarkerOffset, + gridSelection.current.range.x + gridSelection.current.range.width - rowMarkerOffset ) ); } else if (selectedRows !== undefined && selectedRows.length > 0) { @@ -1983,20 +2247,20 @@ const DataEditorImpl: React.ForwardRefRenderFunction getCellsForSelection({ - x: 0, + x: rowMarkerOffset, y: rowIndex, - width: columns.length, + width: columnsIn.length - rowMarkerOffset, height: 1, })[0] ); - copyToClipboard(cells, range(columns.length)); + copyToClipboard(cells, range(columnsIn.length)); } else if (selectedColumns.length >= 1) { const results: (readonly (readonly GridCell[])[])[] = []; const cols: number[] = []; for (const col of selectedColumns) { results.push( getCellsForSelection({ - x: col - rowMarkerOffset, + x: col, y: 0, width: 1, height: rows, @@ -2014,14 +2278,13 @@ const DataEditorImpl: React.ForwardRefRenderFunction { - if (gridSelection === undefined) return; - const [col, row] = gridSelection.cell; + if (gridSelection.current === undefined) return; + const [col, row] = gridSelection.current.cell; // Check that the grid selection is in range before updating the selected cell const selectionColInRange = mangledCols[col]; if (selectionColInRange === undefined) return; - updateSelectedCell(col, row); + updateSelectedCell(col, row, false, false); }, [mangledCols, rows, gridSelection, updateSelectedCell]); const disabledRows = React.useMemo(() => { @@ -2196,6 +2459,10 @@ const DataEditorImpl: React.ForwardRefRenderFunction string; -function buildCellsForSelectionGetter(dataBuilder: DataBuilder): DataEditorProps["getCellsForSelection"] { +function buildCellsForSelectionGetter(dataBuilder: DataBuilder): DataGridSearchProps["getCellsForSelection"] { const getCellsForSelection = (selection: Rectangle): readonly (readonly GridCell[])[] => { const result: GridCell[][] = []; diff --git a/packages/core/src/data-editor/use-cell-sizer.ts b/packages/core/src/data-editor/use-cell-sizer.ts index e96823767..6e1b0f628 100644 --- a/packages/core/src/data-editor/use-cell-sizer.ts +++ b/packages/core/src/data-editor/use-cell-sizer.ts @@ -1,8 +1,8 @@ import * as React from "react"; import { Theme } from "../common/styles"; +import type { DataGridSearchProps } from "../data-grid-search/data-grid-search"; import { CellRenderers } from "../data-grid/cells"; import { GridCell, GridCellKind, GridColumn, isSizedGridColumn, SizedGridColumn } from "../data-grid/data-grid-types"; -import type { DataEditorProps } from "./data-editor"; const defaultSize = 150; @@ -16,7 +16,7 @@ function measureCell(ctx: CanvasRenderingContext2D, cell: GridCell): number { export function useCellSizer( columns: readonly GridColumn[], rows: number, - getCellsForSelection: DataEditorProps["getCellsForSelection"], + getCellsForSelection: DataGridSearchProps["getCellsForSelection"], theme: Theme ): readonly SizedGridColumn[] { const rowsRef = React.useRef(rows); diff --git a/packages/core/src/data-editor/use-cells-for-selection.ts b/packages/core/src/data-editor/use-cells-for-selection.ts new file mode 100644 index 000000000..cc55a4522 --- /dev/null +++ b/packages/core/src/data-editor/use-cells-for-selection.ts @@ -0,0 +1,63 @@ +import * as React from "react"; +import type { DataGridSearchProps } from "../data-grid-search/data-grid-search"; +import { GridCell, GridCellKind } from "../data-grid/data-grid-types"; +import type { DataEditorProps } from "./data-editor"; + +type CellsForSelectionCallback = NonNullable; +export function useCellsForSelection( + getCellsForSelectionIn: CellsForSelectionCallback | true | undefined, + getCellContent: DataEditorProps["getCellContent"], + rowMarkerOffset: number +) { + const getCellsForSelectionDirectWhenValid = React.useCallback( + rect => { + if (getCellsForSelectionIn === true) { + const result: GridCell[][] = []; + + for (let y = rect.y; y < rect.y + rect.height; y++) { + const row: GridCell[] = []; + for (let x = rect.x; x < rect.x + rect.width; x++) { + if (x < 0) { + row.push({ + kind: GridCellKind.Loading, + allowOverlay: false, + }); + } else { + row.push(getCellContent([x, y])); + } + } + result.push(row); + } + + return result; + } + return getCellsForSelectionIn?.(rect) ?? []; + }, + [getCellContent, getCellsForSelectionIn] + ); + const getCellsForSelectionDirect = + getCellsForSelectionIn !== undefined ? getCellsForSelectionDirectWhenValid : undefined; + const getCellsForSelectionMangled = React.useCallback( + rect => { + if (getCellsForSelectionDirect === undefined) return []; + const newRect = { + ...rect, + x: rect.x - rowMarkerOffset, + }; + if (newRect.x < 0) { + newRect.x = 0; + newRect.width--; + return getCellsForSelectionDirect(newRect).map(row => [ + { kind: GridCellKind.Loading, allowOverlay: false }, + ...row, + ]); + } + return getCellsForSelectionDirect(newRect); + }, + [getCellsForSelectionDirect, rowMarkerOffset] + ); + + const getCellsForSelection = getCellsForSelectionIn !== undefined ? getCellsForSelectionMangled : undefined; + + return [getCellsForSelection, getCellsForSelectionDirect] as const; +} diff --git a/packages/core/src/data-grid-dnd/data-grid-dnd.tsx b/packages/core/src/data-grid-dnd/data-grid-dnd.tsx index c16a2b56f..257dc75e1 100644 --- a/packages/core/src/data-grid-dnd/data-grid-dnd.tsx +++ b/packages/core/src/data-grid-dnd/data-grid-dnd.tsx @@ -39,7 +39,8 @@ const DataGridDnd: React.FunctionComponent = p => { getCellContent, } = p; - const { onMouseDown, onMouseUp, onItemHovered, isDraggable = false, columns, selectedColumns } = p; + const { onMouseDown, onMouseUp, onItemHovered, isDraggable = false, columns, selection } = p; + const selectedColumns = selection.columns; const onItemHoveredImpl = React.useCallback( (args: GridMouseEventArgs) => { @@ -238,6 +239,7 @@ const DataGridDnd: React.FunctionComponent = p => { lastRowSticky={p.lastRowSticky} rowHeight={p.rowHeight} rows={p.rows} + highlightRegions={p.highlightRegions} verticalBorder={p.verticalBorder} width={p.width} canvasRef={p.canvasRef} @@ -257,9 +259,7 @@ const DataGridDnd: React.FunctionComponent = p => { onKeyDown={p.onKeyDown} onKeyUp={p.onKeyUp} prelightCells={p.prelightCells} - selectedCell={p.selectedCell} - selectedColumns={p.selectedColumns} - selectedRows={p.selectedRows} + selection={p.selection} translateX={p.translateX} translateY={p.translateY} // handled or mutated props diff --git a/packages/core/src/data-grid-overlay-editor/data-grid-overlay-editor.tsx b/packages/core/src/data-grid-overlay-editor/data-grid-overlay-editor.tsx index 0a7793400..6a2f816a4 100644 --- a/packages/core/src/data-grid-overlay-editor/data-grid-overlay-editor.tsx +++ b/packages/core/src/data-grid-overlay-editor/data-grid-overlay-editor.tsx @@ -95,8 +95,8 @@ const DataGridOverlayEditor: React.FunctionComponent onFinishEditing(undefined, [0, 0]); event.stopPropagation(); event.preventDefault(); - } else if (event.key === "Enter" && !event.shiftKey) { - onFinishEditing(tempValue, [0, 1]); + } else if (event.key === "Enter" && !event.ctrlKey) { + onFinishEditing(tempValue, [0, event.shiftKey ? -1 : 1]); event.stopPropagation(); event.preventDefault(); } else if (event.key === "Tab") { diff --git a/packages/core/src/data-grid-search/data-grid-search.tsx b/packages/core/src/data-grid-search/data-grid-search.tsx index 1a5d494e2..59c16e2d6 100644 --- a/packages/core/src/data-grid-search/data-grid-search.tsx +++ b/packages/core/src/data-grid-search/data-grid-search.tsx @@ -1,6 +1,6 @@ // import AppIcon from "common/app-icon"; import * as React from "react"; -import { GridCell, GridCellKind, GridSelection, Rectangle, InnerGridCell } from "../data-grid/data-grid-types"; +import { GridCell, GridCellKind, Rectangle } from "../data-grid/data-grid-types"; import ScrollingDataGrid, { ScrollingDataGridProps } from "../scrolling-data-grid/scrolling-data-grid"; import { SearchWrapper } from "./data-grid-search-style"; import { assert } from "../common/support"; @@ -47,7 +47,6 @@ const closeX = ( export interface DataGridSearchProps extends Omit { readonly getCellsForSelection?: (selection: Rectangle) => readonly (readonly GridCell[])[]; readonly onSearchResultsChanged?: (results: readonly (readonly [number, number])[], navIndex: number) => void; - readonly searchColOffset: number; readonly showSearch?: boolean; readonly onSearchClose?: () => void; } @@ -58,14 +57,12 @@ const DataGridSearch: React.FunctionComponent = p => { const { getCellsForSelection, onSearchResultsChanged, - searchColOffset, showSearch = false, onSearchClose, canvasRef, cellYOffset, rows, columns, - getCellContent, } = p; const [searchID] = React.useState(() => "search-box-" + Math.round(Math.random() * 1000)); @@ -91,27 +88,6 @@ const DataGridSearch: React.FunctionComponent = p => { } }, []); - const getCellsForSelectionMangled = React.useCallback( - (selection: GridSelection): readonly (readonly InnerGridCell[])[] => { - if (getCellsForSelection !== undefined) return getCellsForSelection(selection.range); - - const range = selection.range; - - const result: InnerGridCell[][] = []; - for (let row = range.y; row < range.y + range.height; row++) { - const inner: InnerGridCell[] = []; - for (let col = range.x; col < range.x + range.width; col++) { - inner.push(getCellContent([col + searchColOffset, row])); - } - - result.push(inner); - } - - return result; - }, - [getCellContent, getCellsForSelection, searchColOffset] - ); - const cellYOffsetRef = React.useRef(cellYOffset); cellYOffsetRef.current = cellYOffset; const beginSearch = React.useCallback( @@ -135,15 +111,13 @@ const DataGridSearch: React.FunctionComponent = p => { const tick = () => { const tStart = performance.now(); const rowsLeft = rows - rowsSearched; - const data = getCellsForSelectionMangled({ - cell: [0, 0], - range: { + const data = + getCellsForSelection?.({ x: 0, y: startY, - width: columns.length - searchColOffset, + width: columns.length, height: Math.min(searchStride, rowsLeft, rows - startY), - }, - }); + }) ?? []; let added = false; data.forEach((d, row) => @@ -171,7 +145,7 @@ const DataGridSearch: React.FunctionComponent = p => { } if (testString !== undefined && regex.test(testString)) { - runningResult.push([col + searchColOffset, row + startY]); + runningResult.push([col, row + startY]); added = true; } }) @@ -213,7 +187,7 @@ const DataGridSearch: React.FunctionComponent = p => { cancelSearch(); searchHandle.current = window.requestAnimationFrame(tick); }, - [cancelSearch, columns.length, getCellsForSelectionMangled, onSearchResultsChanged, rows, searchColOffset] + [cancelSearch, columns.length, getCellsForSelection, onSearchResultsChanged, rows] ); const onClose = React.useCallback(() => { @@ -409,6 +383,7 @@ const DataGridSearch: React.FunctionComponent = p => { rowHeight={p.rowHeight} onMouseMove={p.onMouseMove} rows={p.rows} + highlightRegions={p.highlightRegions} verticalBorder={p.verticalBorder} canvasRef={p.canvasRef} className={p.className} @@ -439,9 +414,7 @@ const DataGridSearch: React.FunctionComponent = p => { rightElementSticky={p.rightElementSticky} scrollRef={p.scrollRef} scrollToEnd={p.scrollToEnd} - selectedCell={p.selectedCell} - selectedColumns={p.selectedColumns} - selectedRows={p.selectedRows} + selection={p.selection} showMinimap={p.showMinimap} smoothScrollX={p.smoothScrollX} smoothScrollY={p.smoothScrollY} diff --git a/packages/core/src/data-grid/data-grid-lib.ts b/packages/core/src/data-grid/data-grid-lib.ts index d08da6eca..aad227b63 100644 --- a/packages/core/src/data-grid/data-grid-lib.ts +++ b/packages/core/src/data-grid/data-grid-lib.ts @@ -1,5 +1,5 @@ import { Theme } from "../common/styles"; -import { DrilldownCellData, Item, GridSelection, InnerGridCell, SizedGridColumn } from "./data-grid-types"; +import { DrilldownCellData, Item, GridSelection, InnerGridCell, SizedGridColumn, Rectangle } from "./data-grid-types"; import { degreesToRadians, direction } from "../common/utils"; import React from "react"; import { BaseDrawArgs, PrepResult } from "./cells/cell-types"; @@ -28,10 +28,10 @@ export function isGroupEqual(left: string | undefined, right: string | undefined return (left ?? "") === (right ?? ""); } -export function cellIsSelected(location: Item, cell: InnerGridCell, selection: GridSelection | undefined): boolean { - if (selection === undefined) return false; +export function cellIsSelected(location: Item, cell: InnerGridCell, selection: GridSelection): boolean { + if (selection?.current === undefined) return false; - const [col, row] = selection.cell; + const [col, row] = selection.current.cell; const [cellCol, cellRow] = location; if (cellRow !== row) return false; @@ -42,13 +42,11 @@ export function cellIsSelected(location: Item, cell: InnerGridCell, selection: G return col >= cell.span[0] && col <= cell.span[1]; } -export function cellIsInRange(location: Item, cell: InnerGridCell, selection: GridSelection | undefined): boolean { - if (selection === undefined) return false; - - const startX = selection.range.x; - const endX = selection.range.x + selection.range.width - 1; - const startY = selection.range.y; - const endY = selection.range.y + selection.range.height - 1; +function cellIsInRect(location: Item, cell: InnerGridCell, rect: Rectangle): boolean { + const startX = rect.x; + const endX = rect.x + rect.width - 1; + const startY = rect.y; + const endY = rect.y + rect.height - 1; const [cellCol, cellRow] = location; if (cellRow < startY || cellRow > endY) return false; @@ -65,6 +63,19 @@ export function cellIsInRange(location: Item, cell: InnerGridCell, selection: Gr ); } +export function cellIsInRange(location: Item, cell: InnerGridCell, selection: GridSelection): number { + let result = 0; + if (selection.current === undefined) return result; + + if (cellIsInRect(location, cell, selection.current.range)) result++; + for (const r of selection.current.rangeStack) { + if (cellIsInRect(location, cell, r)) { + result++; + } + } + return result; +} + function remapForDnDState( columns: readonly MappedGridColumn[], dndState?: { @@ -855,3 +866,106 @@ export function roundedPoly(ctx: CanvasRenderingContext2D, points: Point[], radi } ctx.closePath(); } + +export function computeBounds( + col: number, + row: number, + width: number, + height: number, + groupHeaderHeight: number, + totalHeaderHeight: number, + cellXOffset: number, + cellYOffset: number, + translateX: number, + translateY: number, + rows: number, + freezeColumns: number, + lastRowSticky: boolean, + mappedColumns: readonly MappedGridColumn[], + rowHeight: number | ((index: number) => number) +): Rectangle { + const result: Rectangle = { + x: 0, + y: totalHeaderHeight + translateY, + width: 0, + height: 0, + }; + + const headerHeight = totalHeaderHeight - groupHeaderHeight; + + if (col >= freezeColumns) { + const dir = cellXOffset > col ? -1 : 1; + const freezeWidth = getStickyWidth(mappedColumns); + result.x += freezeWidth + translateX; + for (let i = cellXOffset; i !== col; i += dir) { + result.x += mappedColumns[dir === 1 ? i : i - 1].width * dir; + } + } else { + for (let i = 0; i < col; i++) { + result.x += mappedColumns[i].width; + } + } + result.width = mappedColumns[col].width + 1; + + if (row === -1) { + result.y = groupHeaderHeight; + result.height = headerHeight; + } else if (row === -2) { + result.y = 0; + result.height = groupHeaderHeight; + + let start = col; + const group = mappedColumns[col].group; + const sticky = mappedColumns[col].sticky; + while ( + start > 0 && + isGroupEqual(mappedColumns[start - 1].group, group) && + mappedColumns[start - 1].sticky === sticky + ) { + const c = mappedColumns[start - 1]; + result.x -= c.width; + result.width += c.width; + start--; + } + + let end = col; + while ( + end + 1 < mappedColumns.length && + isGroupEqual(mappedColumns[end + 1].group, group) && + mappedColumns[end + 1].sticky === sticky + ) { + const c = mappedColumns[end + 1]; + result.width += c.width; + end++; + } + if (!sticky) { + const freezeWidth = getStickyWidth(mappedColumns); + const clip = result.x - freezeWidth; + if (clip < 0) { + result.x -= clip; + result.width += clip; + } + + if (result.x + result.width > width) { + result.width = width - result.x; + } + } + } else if (lastRowSticky && row === rows - 1) { + const stickyHeight = typeof rowHeight === "number" ? rowHeight : rowHeight(row); + result.y = height - stickyHeight; + result.height = stickyHeight; + } else { + const dir = cellYOffset > row ? -1 : 1; + if (typeof rowHeight === "number") { + const delta = row - cellYOffset; + result.y += delta * rowHeight; + } else { + for (let r = cellYOffset; r !== row; r += dir) { + result.y += rowHeight(r) * dir; + } + } + result.height = (typeof rowHeight === "number" ? rowHeight : rowHeight(row)) + 1; + } + + return result; +} diff --git a/packages/core/src/data-grid/data-grid-render.tsx b/packages/core/src/data-grid/data-grid-render.tsx index 46140a8d5..24f5a7820 100644 --- a/packages/core/src/data-grid/data-grid-render.tsx +++ b/packages/core/src/data-grid/data-grid-render.tsx @@ -23,6 +23,7 @@ import { isGroupEqual, cellIsSelected, cellIsInRange, + computeBounds, } from "./data-grid-lib"; import { SpriteManager, SpriteVariant } from "./data-grid-sprites"; import { Theme } from "../common/styles"; @@ -43,6 +44,11 @@ import { PrepResult } from "./cells/cell-types"; type HoverInfo = readonly [Item, readonly [number, number]]; +export interface Highlight { + readonly color: string; + readonly range: Rectangle; +} + interface GroupDetails { readonly name: string; readonly icon?: string; @@ -753,7 +759,7 @@ function drawGridHeaders( ? 0 : hoverValues.find(s => s.item[0] === c.sourceIndex && s.item[1] === -1)?.hoverAmount ?? 0; - const hasSelectedCell = selectedCell !== undefined && selectedCell.cell[0] === c.sourceIndex; + const hasSelectedCell = selectedCell?.current !== undefined && selectedCell.current.cell[0] === c.sourceIndex; const bgFillStyle = selected ? theme.accentColor : hasSelectedCell ? theme.bgHeaderHasFocus : theme.bgHeader; @@ -984,9 +990,10 @@ function drawCells( lastRowSticky: boolean, drawRegions: readonly Rectangle[], damage: CellList | undefined, - selectedCell: GridSelection | undefined, + selection: GridSelection, selectedColumns: CompactSelection, prelightCells: CellList | undefined, + highlightRegions: readonly Highlight[] | undefined, drawCustomCell: DrawCustomCellCallback | undefined, imageLoader: ImageWindowLoader, spriteManager: SpriteManager, @@ -1034,6 +1041,8 @@ function drawCells( }; reclip(); + const colSelected = selectedColumns.hasIndex(c.sourceIndex); + const groupTheme = getGroupDetails(c.group ?? "").overrideTheme; const colTheme = c.themeOverride === undefined && groupTheme === undefined @@ -1147,29 +1156,35 @@ function drawCells( ctx.beginPath(); - const isFocused = - cellIsSelected([c.sourceIndex, row], cell, selectedCell) || - cellIsInRange([c.sourceIndex, row], cell, selectedCell); + const cellIndex = [c.sourceIndex, row] as const; + const isSelected = cellIsSelected(cellIndex, cell, selection); + let accentCount = cellIsInRange(cellIndex, cell, selection); const spanIsHighlighted = cell.span !== undefined && selectedColumns.some( index => cell.span !== undefined && index >= cell.span[0] && index <= cell.span[1] ); - const highlighted = - spanIsHighlighted || - isFocused || - (!isSticky && (rowSelected || selectedColumns.hasIndex(c.sourceIndex))); + if (isSelected) { + accentCount = Math.max(accentCount, 1); + } + if (spanIsHighlighted) { + accentCount++; + } + if (!isSelected) { + if (rowSelected) accentCount++; + if (colSelected && !isSticky) accentCount++; + } let fill: string | undefined; if (isSticky || theme.bgCell !== outerTheme.bgCell) { fill = blend(theme.bgCell, fill); } - if (highlighted || rowDisabled) { + if (accentCount > 0 || rowDisabled) { if (rowDisabled) { fill = blend(theme.bgHeader, fill); } - if (highlighted) { + for (let i = 0; i < accentCount; i++) { fill = blend(theme.accentLight, fill); } } else { @@ -1177,6 +1192,21 @@ function drawCells( fill = blend(theme.bgSearchResult, fill); } } + + if (highlightRegions !== undefined) { + for (const region of highlightRegions) { + const r = region.range; + if ( + r.x <= c.sourceIndex && + c.sourceIndex < r.x + r.width && + r.y <= row && + row < r.y + r.height + ) { + fill = blend(region.color, fill); + } + } + } + if (fill !== undefined) { ctx.fillStyle = fill; if (prepResult !== undefined) { @@ -1206,7 +1236,7 @@ function drawCells( drawY, cellWidth, rh, - highlighted, + accentCount > 0, theme, drawCustomCell, imageLoader, @@ -1371,6 +1401,179 @@ function overdrawStickyBoundaries( ctx.stroke(); } +function drawHighlightRings( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + cellXOffset: number, + cellYOffset: number, + translateX: number, + translateY: number, + mappedColumns: readonly MappedGridColumn[], + freezeColumns: number, + headerHeight: number, + groupHeaderHeight: number, + rowHeight: number | ((index: number) => number), + lastRowSticky: boolean, + rows: number, + highlightRegions: readonly Highlight[] | undefined +): (() => void) | undefined { + if (highlightRegions === undefined || highlightRegions.length === 0) return undefined; + const drawRects = highlightRegions.map(h => { + const r = h.range; + const topLeftBounds = computeBounds( + r.x, + r.y, + width, + height, + groupHeaderHeight, + headerHeight + groupHeaderHeight, + cellXOffset, + cellYOffset, + translateX, + translateY, + rows, + freezeColumns, + lastRowSticky, + mappedColumns, + rowHeight + ); + if (r.width === 1 && r.height === 1) { + if (r.x < freezeColumns) { + return [{ color: h.color, rect: topLeftBounds }, undefined]; + } + return [undefined, { color: h.color, rect: topLeftBounds }]; + } + + const bottomRightBounds = computeBounds( + r.x + r.width - 1, + r.y + r.height - 1, + width, + height, + groupHeaderHeight, + headerHeight + groupHeaderHeight, + cellXOffset, + cellYOffset, + translateX, + translateY, + rows, + freezeColumns, + lastRowSticky, + mappedColumns, + rowHeight + ); + if (r.x < freezeColumns && r.x + r.width >= freezeColumns) { + const freezeSectionRightBounds = computeBounds( + freezeColumns - 1, + r.y + r.height - 1, + width, + height, + groupHeaderHeight, + headerHeight + groupHeaderHeight, + cellXOffset, + cellYOffset, + translateX, + translateY, + rows, + freezeColumns, + lastRowSticky, + mappedColumns, + rowHeight + ); + const unfreezeSectionleftBounds = computeBounds( + freezeColumns, + r.y + r.height - 1, + width, + height, + groupHeaderHeight, + headerHeight + groupHeaderHeight, + cellXOffset, + cellYOffset, + translateX, + translateY, + rows, + freezeColumns, + lastRowSticky, + mappedColumns, + rowHeight + ); + + return [ + { + color: h.color, + rect: { + x: topLeftBounds.x, + y: topLeftBounds.y, + width: freezeSectionRightBounds.x + freezeSectionRightBounds.width - topLeftBounds.x, + height: freezeSectionRightBounds.y + freezeSectionRightBounds.height - topLeftBounds.y, + }, + }, + { + color: h.color, + rect: { + x: unfreezeSectionleftBounds.x, + y: unfreezeSectionleftBounds.y, + width: bottomRightBounds.x + bottomRightBounds.width - unfreezeSectionleftBounds.x, + height: bottomRightBounds.y + bottomRightBounds.height - unfreezeSectionleftBounds.y, + }, + }, + ]; + } else { + return [ + undefined, + { + color: h.color, + rect: { + x: topLeftBounds.x, + y: topLeftBounds.y, + width: bottomRightBounds.x + bottomRightBounds.width - topLeftBounds.x, + height: bottomRightBounds.y + bottomRightBounds.height - topLeftBounds.y, + }, + }, + ]; + } + }); + + const stickyWidth = getStickyWidth(mappedColumns); + + const drawCb = () => { + ctx.beginPath(); + ctx.save(); + ctx.setLineDash([7, 5]); + ctx.lineWidth = 2; + for (const dr of drawRects) { + const [s] = dr; + if ( + s !== undefined && + intersectRect(0, 0, width, height, s.rect.x, s.rect.y, s.rect.width, s.rect.height) + ) { + ctx.strokeStyle = withAlpha(s.color, 1); + ctx.strokeRect(s.rect.x + 1, s.rect.y + 1, s.rect.width - 2, s.rect.height - 2); + } + } + let clipped = false; + for (const dr of drawRects) { + const [, s] = dr; + if ( + s !== undefined && + intersectRect(0, 0, width, height, s.rect.x, s.rect.y, s.rect.width, s.rect.height) + ) { + if (!clipped && s.rect.x < stickyWidth) { + ctx.rect(stickyWidth, 0, width, height); + ctx.clip(); + clipped = true; + } + ctx.strokeStyle = withAlpha(s.color, 1); + ctx.strokeRect(s.rect.x + 1, s.rect.y + 1, s.rect.width - 2, s.rect.height - 2); + } + } + ctx.restore(); + }; + + drawCb(); + return drawCb; +} + function drawFocusRing( ctx: CanvasRenderingContext2D, width: number, @@ -1382,16 +1585,19 @@ function drawFocusRing( allColumns: readonly MappedGridColumn[], theme: Theme, totalHeaderHeight: number, - selectedCell: GridSelection | undefined, + selectedCell: GridSelection, getRowHeight: (row: number) => number, getCellContent: (cell: readonly [number, number]) => InnerGridCell, lastRowSticky: boolean, rows: number ): (() => void) | undefined { - if (selectedCell === undefined || effectiveCols.find(c => (c.sourceIndex === selectedCell.cell[0]) === undefined)) + if ( + selectedCell.current === undefined || + effectiveCols.find(c => (c.sourceIndex === selectedCell.current?.cell[0]) === undefined) + ) return undefined; - const [targetCol, targetRow] = selectedCell.cell; - const cell = getCellContent(selectedCell.cell); + const [targetCol, targetRow] = selectedCell.current.cell; + const cell = getCellContent(selectedCell.current.cell); const targetColSpan = cell.span ?? [targetCol, targetCol]; const isStickyRow = lastRowSticky && targetRow === rows - 1; @@ -1488,7 +1694,7 @@ export function drawGrid( verticalBorder: (col: number) => boolean, selectedColumns: CompactSelection, isResizing: boolean, - selectedCell: GridSelection | undefined, + selectedCell: GridSelection, lastRowSticky: boolean, rows: number, getCellContent: (cell: readonly [number, number]) => InnerGridCell, @@ -1497,6 +1703,7 @@ export function drawGrid( drawCustomCell: DrawCustomCellCallback | undefined, drawHeaderCallback: DrawHeaderCallback | undefined, prelightCells: CellList | undefined, + highlightRegions: readonly Highlight[] | undefined, imageLoader: ImageWindowLoader, lastBlitData: React.MutableRefObject, canBlit: boolean, @@ -1668,6 +1875,7 @@ export function drawGrid( selectedCell, selectedColumns, prelightCells, + highlightRegions, drawCustomCell, imageLoader, spriteManager, @@ -1760,6 +1968,24 @@ export function drawGrid( rows ); + const highlightRedraw = drawHighlightRings( + targetCtx, + width, + height, + cellXOffset, + cellYOffset, + translateX, + translateY, + mappedColumns, + freezeColumns, + headerHeight, + groupHeaderHeight, + rowHeight, + lastRowSticky, + rows, + highlightRegions + ); + targetCtx.fillStyle = theme.bgCell; if (drawRegions.length > 0) { targetCtx.beginPath(); @@ -1795,6 +2021,7 @@ export function drawGrid( selectedCell, selectedColumns, prelightCells, + highlightRegions, drawCustomCell, imageLoader, spriteManager, @@ -1845,6 +2072,7 @@ export function drawGrid( ); focusRedraw?.(); + highlightRedraw?.(); imageLoader?.setWindow( { diff --git a/packages/core/src/data-grid/data-grid-types.ts b/packages/core/src/data-grid/data-grid-types.ts index 3f7903093..e83a1e78e 100644 --- a/packages/core/src/data-grid/data-grid-types.ts +++ b/packages/core/src/data-grid/data-grid-types.ts @@ -5,9 +5,19 @@ import React, { CSSProperties } from "react"; import ImageWindowLoader from "../common/image-window-loader"; import { SpriteManager } from "./data-grid-sprites"; +// Thoughts: +// rows/columns are called out as selected, but when selected they must also be added +// to the range. Handling delete events may have different desired outcomes depending on +// how the range came to be selected. The rows/columns properties retain this essential +// information. export interface GridSelection { - readonly cell: readonly [number, number]; - readonly range: Readonly; + readonly current?: { + readonly cell: readonly [number, number]; + readonly range: Readonly; + readonly rangeStack: readonly Readonly[]; // lowest to highest, does not include range + }; + readonly columns: CompactSelection; + readonly rows: CompactSelection; } export type GridMouseEventArgs = @@ -16,6 +26,15 @@ export type GridMouseEventArgs = | GridMouseOutOfBoundsEventArgs | GridMouseGroupHeaderEventArgs; +interface PreventableEvent { + preventDefault: () => void; +} +export interface CellClickedEventArgs extends GridMouseCellEventArgs, PreventableEvent {} + +export interface HeaderClickedEventArgs extends GridMouseHeaderEventArgs, PreventableEvent {} + +export interface GroupHeaderClickedEventArgs extends GridMouseGroupHeaderEventArgs, PreventableEvent {} + interface PositionableMouseEventArgs { readonly localEventX: number; readonly localEventY: number; diff --git a/packages/core/src/data-grid/data-grid.stories.tsx b/packages/core/src/data-grid/data-grid.stories.tsx index a5d88b8a8..009692c3a 100644 --- a/packages/core/src/data-grid/data-grid.stories.tsx +++ b/packages/core/src/data-grid/data-grid.stories.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { StoryFn, StoryContext } from "@storybook/addons"; import { BuilderThemeWrapper } from "../stories/story-utils"; import DataGrid from "./data-grid"; -import { CompactSelection, GridCellKind } from "./data-grid-types"; +import { CompactSelection, GridCellKind, GridSelection } from "./data-grid-types"; export default { title: "Subcomponents/DataGrid", @@ -19,6 +19,12 @@ export default { ], }; +const emptyGridSelection: GridSelection = { + columns: CompactSelection.empty(), + rows: CompactSelection.empty(), + current: undefined, +}; + export function Simplenotest() { let x = 0; @@ -46,6 +52,7 @@ export function Simplenotest() { groupHeaderHeight={0} accessibilityHeight={50} enableGroups={false} + selection={emptyGridSelection} rows={100000} headerHeight={44} rowHeight={34} @@ -98,7 +105,15 @@ export function SelectedCellnotest() { allowOverlay: false, owned: true, })} - selectedCell={{ cell: [2, 2], range: { x: 2, y: 2, width: 1, height: 1 } }} + selection={{ + current: { + cell: [2, 2], + range: { x: 2, y: 2, width: 1, height: 1 }, + rangeStack: [], + }, + columns: CompactSelection.empty(), + rows: CompactSelection.empty(), + }} freezeColumns={0} firstColAccessible={true} verticalBorder={() => true} @@ -135,7 +150,11 @@ export function SelectedRownotest() { allowOverlay: false, owned: true, })} - selectedRows={CompactSelection.fromSingleSelection([2, 4])} + selection={{ + current: undefined, + rows: CompactSelection.fromSingleSelection([2, 4]), + columns: CompactSelection.empty(), + }} freezeColumns={0} firstColAccessible={true} verticalBorder={() => true} @@ -172,7 +191,11 @@ export const SelectedColumnnotest = () => { allowOverlay: false, owned: true, })} - selectedColumns={CompactSelection.fromSingleSelection([2, 4])} + selection={{ + current: undefined, + rows: CompactSelection.empty(), + columns: CompactSelection.fromSingleSelection([2, 4]), + }} freezeColumns={0} firstColAccessible={true} verticalBorder={() => true} diff --git a/packages/core/src/data-grid/data-grid.test.tsx b/packages/core/src/data-grid/data-grid.test.tsx index 179605ec7..77c6e873f 100644 --- a/packages/core/src/data-grid/data-grid.test.tsx +++ b/packages/core/src/data-grid/data-grid.test.tsx @@ -2,7 +2,7 @@ import { describe, test, expect } from "jest-without-globals"; import * as React from "react"; import { render, fireEvent, screen } from "@testing-library/react"; import DataGrid, { DataGridProps, DataGridRef } from "./data-grid"; -import { GridCellKind } from "./data-grid-types"; +import { CompactSelection, GridCellKind } from "./data-grid-types"; import { ThemeProvider } from "styled-components"; import { getDataEditorTheme } from "../common/styles"; @@ -33,6 +33,11 @@ const basicProps: DataGridProps = { ], enableGroups: false, freezeColumns: 0, + selection: { + current: undefined, + rows: CompactSelection.empty(), + columns: CompactSelection.empty(), + }, firstColAccessible: true, onMouseMove: () => undefined, getCellContent: cell => ({ diff --git a/packages/core/src/data-grid/data-grid.tsx b/packages/core/src/data-grid/data-grid.tsx index d853a8b53..6dec88bbe 100644 --- a/packages/core/src/data-grid/data-grid.tsx +++ b/packages/core/src/data-grid/data-grid.tsx @@ -3,11 +3,11 @@ import { Theme } from "../common/styles"; import { useTheme } from "styled-components"; import ImageWindowLoader from "../common/image-window-loader"; import { + computeBounds, getColumnIndexForX, getEffectiveColumns, getRowIndexForY, getStickyWidth, - isGroupEqual, useMappedColumns, } from "./data-grid-lib"; import { @@ -36,6 +36,7 @@ import { getHeaderMenuBounds, GetRowThemeCallback, GroupDetailsCallback, + Highlight, pointInRect, } from "./data-grid-render"; import { AnimationManager, StepCallback } from "./animation-manager"; @@ -81,10 +82,9 @@ export interface DataGridProps { readonly getRowThemeOverride?: GetRowThemeCallback; readonly onHeaderMenuClick?: (col: number, screenPosition: Rectangle) => void; - readonly selectedRows?: CompactSelection; - readonly selectedColumns?: CompactSelection; - readonly selectedCell?: GridSelection; + readonly selection: GridSelection; readonly prelightCells?: readonly (readonly [number, number])[]; + readonly highlightRegions?: readonly Highlight[]; readonly disabledRows?: CompactSelection; @@ -158,10 +158,8 @@ const DataGrid: React.ForwardRefRenderFunction = (p, getCellContent, getRowThemeOverride, onHeaderMenuClick, - selectedRows, enableGroups, - selectedCell, - selectedColumns, + selection, freezeColumns, lastRowSticky, onMouseDown, @@ -173,6 +171,7 @@ const DataGrid: React.ForwardRefRenderFunction = (p, firstColAccessible, onKeyDown, onKeyUp, + highlightRegions, canvasRef, onDragStart, eventTargetRef, @@ -244,103 +243,43 @@ const DataGrid: React.ForwardRefRenderFunction = (p, (canvas: HTMLCanvasElement, col: number, row: number): Rectangle => { const rect = canvas.getBoundingClientRect(); - const result: Rectangle = { - x: rect.x, - y: rect.y + totalHeaderHeight + translateY, - width: 0, - height: 0, - }; - - if (col >= freezeColumns) { - const dir = cellXOffset > col ? -1 : 1; - const freezeWidth = getStickyWidth(mappedColumns); - result.x += freezeWidth + translateX; - for (let i = cellXOffset; i !== col; i += dir) { - result.x += mappedColumns[i].width * dir; - } - } else { - for (let i = 0; i < col; i++) { - result.x += mappedColumns[i].width; - } - } - result.width = mappedColumns[col].width + 1; - - if (row === -1) { - result.y = rect.y + groupHeaderHeight; - result.height = headerHeight; - } else if (row === -2) { - result.y = rect.y; - result.height = groupHeaderHeight; - - let start = col; - const group = mappedColumns[col].group; - const sticky = mappedColumns[col].sticky; - while ( - start > 0 && - isGroupEqual(mappedColumns[start - 1].group, group) && - mappedColumns[start - 1].sticky === sticky - ) { - const c = mappedColumns[start - 1]; - result.x -= c.width; - result.width += c.width; - start--; - } + const result = computeBounds( + col, + row, + width, + height, + groupHeaderHeight, + totalHeaderHeight, + cellXOffset, + cellYOffset, + translateX, + translateY, + rows, + freezeColumns, + lastRowSticky, + mappedColumns, + rowHeight + ); - let end = col; - while ( - end + 1 < mappedColumns.length && - isGroupEqual(mappedColumns[end + 1].group, group) && - mappedColumns[end + 1].sticky === sticky - ) { - const c = mappedColumns[end + 1]; - result.width += c.width; - end++; - } - if (!sticky) { - const freezeWidth = getStickyWidth(mappedColumns); - const clip = result.x - (rect.x + freezeWidth); - if (clip < 0) { - result.x -= clip; - result.width += clip; - } - - if (result.x + result.width > rect.right) { - result.width = rect.right - result.x; - } - } - } else if (lastRowSticky && row === rows - 1) { - const stickyHeight = typeof rowHeight === "number" ? rowHeight : rowHeight(row); - result.y = rect.y + (height - stickyHeight); - result.height = stickyHeight; - } else { - const dir = cellYOffset > row ? -1 : 1; - if (typeof rowHeight === "number") { - const delta = row - cellYOffset; - result.y += delta * rowHeight; - } else { - for (let r = cellYOffset; r !== row; r += dir) { - result.y += rowHeight(r) * dir; - } - } - result.height = (typeof rowHeight === "number" ? rowHeight : rowHeight(row)) + 1; - } + result.x += rect.x; + result.y += rect.y; return result; }, [ + width, + height, + groupHeaderHeight, totalHeaderHeight, + cellXOffset, + cellYOffset, + translateX, translateY, + rows, freezeColumns, - mappedColumns, lastRowSticky, - rows, - cellXOffset, - translateX, - groupHeaderHeight, - headerHeight, + mappedColumns, rowHeight, - height, - cellYOffset, ] ); @@ -518,13 +457,13 @@ const DataGrid: React.ForwardRefRenderFunction = (p, theme, headerHeight, groupHeaderHeight, - selectedRows ?? CompactSelection.empty(), + selection.rows, disabledRows ?? CompactSelection.empty(), rowHeight, verticalBorder, - selectedColumns ?? CompactSelection.empty(), + selection.columns, isResizing, - selectedCell, + selection, lastRowSticky, rows, getCellContent, @@ -533,6 +472,7 @@ const DataGrid: React.ForwardRefRenderFunction = (p, drawCustomCell, drawHeader, prelightCells, + highlightRegions, imageLoader, lastBlitData, canBlit.current ?? false, @@ -559,13 +499,11 @@ const DataGrid: React.ForwardRefRenderFunction = (p, theme, headerHeight, groupHeaderHeight, - selectedRows, + selection, disabledRows, rowHeight, verticalBorder, - selectedColumns, isResizing, - selectedCell, lastRowSticky, rows, getCellContent, @@ -574,6 +512,7 @@ const DataGrid: React.ForwardRefRenderFunction = (p, drawCustomCell, drawHeader, prelightCells, + highlightRegions, imageLoader, spriteManager, scrolling, @@ -594,10 +533,9 @@ const DataGrid: React.ForwardRefRenderFunction = (p, isResizing, verticalBorder, getCellContent, - selectedRows, + highlightRegions, lastWasTouch, - selectedColumns, - selectedCell, + selection, dragAndDropState, prelightCells, scrolling, @@ -918,8 +856,8 @@ const DataGrid: React.ForwardRefRenderFunction = (p, if (canvas === null) return; let bounds: Rectangle | undefined; - if (selectedCell !== undefined) { - bounds = getBoundsForItem(canvas, selectedCell.cell[0], selectedCell.cell[1]); + if (selection.current !== undefined) { + bounds = getBoundsForItem(canvas, selection.current.cell[0], selection.current.cell[1]); } onKeyDown?.({ @@ -936,7 +874,7 @@ const DataGrid: React.ForwardRefRenderFunction = (p, keyCode: event.keyCode, }); }, - [onKeyDown, selectedCell, getBoundsForItem] + [onKeyDown, selection, getBoundsForItem] ); const onKeyUpImpl = React.useCallback( @@ -945,8 +883,8 @@ const DataGrid: React.ForwardRefRenderFunction = (p, if (canvas === null) return; let bounds: Rectangle | undefined; - if (selectedCell !== undefined) { - bounds = getBoundsForItem(canvas, selectedCell.cell[0], selectedCell.cell[1]); + if (selection.current !== undefined) { + bounds = getBoundsForItem(canvas, selection.current.cell[0], selection.current.cell[1]); } onKeyUp?.({ @@ -963,7 +901,7 @@ const DataGrid: React.ForwardRefRenderFunction = (p, keyCode: event.keyCode, }); }, - [onKeyUp, selectedCell, getBoundsForItem] + [onKeyUp, selection, getBoundsForItem] ); const refImpl = React.useCallback( @@ -1144,8 +1082,8 @@ const DataGrid: React.ForwardRefRenderFunction = (p, return CellRenderers[cell.kind].getAccessibilityString(cell); } }; - const [fCol, fRow] = selectedCell?.cell ?? []; - const range = selectedCell?.range; + const [fCol, fRow] = selection.current?.cell ?? []; + const range = selection.current?.range; return ( = (p, rows, cellYOffset, accessibilityHeight, - selectedCell, + selection, focusElement, getCellContent, canvasRef, diff --git a/packages/core/src/data-grid/use-selection-behavior.ts b/packages/core/src/data-grid/use-selection-behavior.ts new file mode 100644 index 000000000..927712c43 --- /dev/null +++ b/packages/core/src/data-grid/use-selection-behavior.ts @@ -0,0 +1,116 @@ +import React from "react"; +import { CompactSelection, GridSelection, Slice } from "./data-grid-types"; + +type SetCallback = (newVal: GridSelection, expand: boolean) => void; + +export type SelectionBlending = "exclusive" | "mixed"; + +type SelectionTrigger = "click" | "drag" | "keyboard-nav" | "keyboard-select" | "edit"; + +export function useSelectionBehavior( + gridSelection: GridSelection, + setGridSelection: SetCallback, + rangeBehavior: SelectionBlending, + columnBehavior: SelectionBlending, + rowBehavior: SelectionBlending, + rangeMultiselection: boolean +) { + // if append is true, the current range will be added to the rangeStack + const setCurrent = React.useCallback( + ( + current: Pick, "cell" | "range"> | undefined, + expand: boolean, + append: boolean, + trigger: SelectionTrigger + ) => { + append = append && rangeMultiselection; + const allowColumnCoSelect = + columnBehavior === "mixed" && rangeBehavior === "mixed" && (append || trigger === "drag"); + const allowRowCoSelect = + rowBehavior === "mixed" && rangeBehavior === "mixed" && (append || trigger === "drag"); + let newVal: GridSelection = { + current: + current === undefined + ? undefined + : { + ...current, + rangeStack: trigger === "drag" ? gridSelection.current?.rangeStack ?? [] : [], + }, + columns: allowColumnCoSelect ? gridSelection.columns : CompactSelection.empty(), + rows: allowRowCoSelect ? gridSelection.rows : CompactSelection.empty(), + }; + + if (append && newVal.current !== undefined && gridSelection.current !== undefined) { + newVal = { + ...newVal, + current: { + ...newVal.current, + rangeStack: [...gridSelection.current.rangeStack, gridSelection.current.range], + }, + }; + } + setGridSelection(newVal, expand); + }, + [columnBehavior, gridSelection, rangeBehavior, rangeMultiselection, rowBehavior, setGridSelection] + ); + + const setSelectedRows = React.useCallback( + (newRows: CompactSelection | undefined, append: Slice | number | undefined, allowMixed: boolean): void => { + newRows = newRows ?? gridSelection.rows; + allowMixed = allowMixed || gridSelection.current === undefined; + if (append !== undefined) { + newRows = newRows.add(append); + } + let newVal: GridSelection; + if (rowBehavior === "exclusive" && newRows.length !== 0) { + newVal = { + current: undefined, + columns: CompactSelection.empty(), + rows: newRows, + }; + } else { + const rangeMixed = allowMixed && rangeBehavior === "mixed"; + const columnMixed = allowMixed && columnBehavior === "mixed"; + const current = !rangeMixed ? undefined : gridSelection.current; + newVal = { + current, + columns: columnMixed ? gridSelection.columns : CompactSelection.empty(), + rows: newRows, + }; + } + setGridSelection(newVal, false); + }, + [columnBehavior, gridSelection, rangeBehavior, rowBehavior, setGridSelection] + ); + + const setSelectedColumns = React.useCallback( + (newCols: CompactSelection | undefined, append: number | Slice | undefined, allowMixed: boolean): void => { + newCols = newCols ?? gridSelection.columns; + allowMixed = allowMixed || gridSelection.current === undefined; + if (append !== undefined) { + newCols = newCols.add(append); + } + let newVal: GridSelection; + if (columnBehavior === "exclusive" && newCols.length !== 0) { + newVal = { + current: undefined, + rows: CompactSelection.empty(), + columns: newCols, + }; + } else { + const rangeMixed = allowMixed && rangeBehavior === "mixed"; + const rowMixed = allowMixed && rowBehavior === "mixed"; + const current = !rangeMixed ? undefined : gridSelection.current; + newVal = { + current, + rows: rowMixed ? gridSelection.rows : CompactSelection.empty(), + columns: newCols, + }; + } + setGridSelection(newVal, false); + }, + [columnBehavior, gridSelection, rangeBehavior, rowBehavior, setGridSelection] + ); + + return [setCurrent, setSelectedRows, setSelectedColumns] as const; +} diff --git a/packages/core/src/scrolling-data-grid/scrolling-data-grid.stories.tsx b/packages/core/src/scrolling-data-grid/scrolling-data-grid.stories.tsx index 14ee03171..c6a04696d 100644 --- a/packages/core/src/scrolling-data-grid/scrolling-data-grid.stories.tsx +++ b/packages/core/src/scrolling-data-grid/scrolling-data-grid.stories.tsx @@ -4,7 +4,7 @@ import { StoryFn, StoryContext } from "@storybook/addons"; import { BuilderThemeWrapper } from "../stories/story-utils"; import GridScroller from "./scrolling-data-grid"; import { styled } from "../common/styles"; -import { GridCell, GridCellKind, Rectangle } from "../data-grid/data-grid-types"; +import { CompactSelection, GridCell, GridCellKind, Rectangle } from "../data-grid/data-grid-types"; const InnerContainer = styled.div` width: 100%; @@ -73,6 +73,11 @@ export function Simplenotest() { translateX={translateX} translateY={translateY} lockColumns={0} + selection={{ + current: undefined, + rows: CompactSelection.empty(), + columns: CompactSelection.empty(), + }} firstColAccessible={true} groupHeaderHeight={34} headerHeight={44} diff --git a/packages/source/package.json b/packages/source/package.json index ec6416c73..c66d6bb4d 100644 --- a/packages/source/package.json +++ b/packages/source/package.json @@ -1,6 +1,6 @@ { "name": "@glideapps/glide-data-grid-source", - "version": "3.5.0-alpha2", + "version": "4.0.0-alpha1", "description": "Useful data source hooks for Glide Data Grid", "sideEffects": false, "type": "module", @@ -42,7 +42,7 @@ "canvas" ], "dependencies": { - "@glideapps/glide-data-grid": "3.5.0-alpha2" + "@glideapps/glide-data-grid": "4.0.0-alpha1" }, "peerDependencies": { "lodash": "^4.17.19" diff --git a/packages/source/src/use-collapsing-groups.ts b/packages/source/src/use-collapsing-groups.ts index 45b9eebcd..cffe29ecb 100644 --- a/packages/source/src/use-collapsing-groups.ts +++ b/packages/source/src/use-collapsing-groups.ts @@ -3,23 +3,12 @@ import React from "react"; type Props = Pick< DataEditorProps, - | "columns" - | "onGroupHeaderClicked" - | "onSelectedColumnsChange" - | "onGridSelectionChange" - | "getGroupDetails" - | "gridSelection" - | "freezeColumns" + "columns" | "onGroupHeaderClicked" | "onGridSelectionChange" | "getGroupDetails" | "gridSelection" | "freezeColumns" > & { theme: Theme }; type Result = Pick< DataEditorProps, - | "columns" - | "onGroupHeaderClicked" - | "onSelectedColumnsChange" - | "onGridSelectionChange" - | "getGroupDetails" - | "gridSelection" + "columns" | "onGroupHeaderClicked" | "onGridSelectionChange" | "getGroupDetails" | "gridSelection" >; export function useCollapsingGroups(props: Props): Result { @@ -29,7 +18,6 @@ export function useCollapsingGroups(props: Props): Result { const { columns: columnsIn, onGroupHeaderClicked: onGroupHeaderClickedIn, - onSelectedColumnsChange: onSelectedColumnsChangeIn, onGridSelectionChange: onGridSelectionChangeIn, getGroupDetails: getGroupDetailsIn, gridSelection: gridSelectionIn, @@ -96,23 +84,16 @@ export function useCollapsingGroups(props: Props): Result { const group = columns[index]?.group ?? ""; if (group === "") return; + a.preventDefault(); setCollapsed(cv => (cv.includes(group) ? cv.filter(x => x !== group) : [...cv, group])); }, [columns, onGroupHeaderClickedIn] ); - const onSelectedColumnsChange = React.useCallback>( - (cs, reason) => { - if (reason === "group") return; - onSelectedColumnsChangeIn?.(cs, reason); - }, - [onSelectedColumnsChangeIn] - ); - const onGridSelectionChange = React.useCallback>( s => { - if (s !== undefined) { - const col = s.cell[0]; + if (s.current !== undefined) { + const col = s.current.cell[0]; const column = columns[col]; setCollapsed(cv => { if (cv.includes(column?.group ?? "")) { @@ -150,7 +131,6 @@ export function useCollapsingGroups(props: Props): Result { return { columns, onGroupHeaderClicked, - onSelectedColumnsChange, onGridSelectionChange, getGroupDetails, gridSelection, diff --git a/packages/source/src/use-data-source.stories.tsx b/packages/source/src/use-data-source.stories.tsx index 11b79dc83..24ff7a006 100644 --- a/packages/source/src/use-data-source.stories.tsx +++ b/packages/source/src/use-data-source.stories.tsx @@ -171,22 +171,27 @@ const cols: GridColumn[] = [ { title: "A", width: 200, + group: "Group 1", }, { title: "B", width: 200, + group: "Group 1", }, { title: "C", width: 200, + group: "Group 2", }, { title: "D", width: 200, + group: "Group 2", }, { title: "E", width: 200, + group: "Group 2", }, ]; @@ -197,7 +202,7 @@ export const UseDataSource: React.VFC = () => { const moveArgs = useMoveableColumns({ columns: cols, - getCellContent: ([col, row]) => { + getCellContent: React.useCallback(([col, row]) => { if (col === 0) { return { kind: GridCellKind.Text, @@ -219,7 +224,7 @@ export const UseDataSource: React.VFC = () => { data: d, displayData: d, }; - }, + }, []), }); const [sort, setSort] = React.useState(); @@ -248,7 +253,7 @@ export const UseDataSource: React.VFC = () => { }, []); return ( - Some of our extension cells.}> + Fixme.}>