Add support for selecting multiple cells

This commit is contained in:
Andrew Kingston 2024-06-21 11:08:24 +01:00
parent 8a8d835a1a
commit 7349910572
No known key found for this signature in database
13 changed files with 289 additions and 49 deletions

View File

@ -3,13 +3,13 @@
--ink: #000000; --ink: #000000;
/* Brand colours */ /* Brand colours */
--bb-coral: #FF4E4E; --bb-coral: #ff4e4e;
--bb-coral-light: #F97777; --bb-coral-light: #f97777;
--bb-indigo: #6E56FF; --bb-indigo: #6e56ff;
--bb-indigo-light: #9F8FFF; --bb-indigo-light: #9f8fff;
--bb-lime: #ECFFB5; --bb-lime: #ecffb5;
--bb-forest-green: #053835; --bb-forest-green: #053835;
--bb-beige: #F6EFEA; --bb-beige: #f6efea;
--grey-1: #fafafa; --grey-1: #fafafa;
--grey-2: #f5f5f5; --grey-2: #f5f5f5;
@ -49,10 +49,10 @@
--rounded-medium: 8px; --rounded-medium: 8px;
--rounded-large: 16px; --rounded-large: 16px;
--font-sans: "Source Sans Pro", -apple-system, BlinkMacSystemFont, Segoe UI, "Inter", --font-sans: "Source Sans Pro", -apple-system, BlinkMacSystemFont, Segoe UI,
"Helvetica Neue", Arial, "Noto Sans", sans-serif; "Inter", "Helvetica Neue", Arial, "Noto Sans", sans-serif;
--font-accent: "Source Sans Pro", -apple-system, BlinkMacSystemFont, Segoe UI, "Inter", --font-accent: "Source Sans Pro", -apple-system, BlinkMacSystemFont, Segoe UI,
"Helvetica Neue", Arial, "Noto Sans", sans-serif; "Inter", "Helvetica Neue", Arial, "Noto Sans", sans-serif;
--font-serif: "Georgia", Cambria, Times New Roman, Times, serif; --font-serif: "Georgia", Cambria, Times New Roman, Times, serif;
--font-mono: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", --font-mono: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace; monospace;
@ -111,7 +111,7 @@ a {
/* Custom theme additions */ /* Custom theme additions */
.spectrum--darkest { .spectrum--darkest {
--drop-shadow: rgba(0, 0, 0, 0.6); --drop-shadow: rgba(0, 0, 0, 0.6);
--spectrum-global-color-blue-100: rgb(28, 33, 43); --spectrum-global-color-blue-100: rgb(30, 36, 50);
} }
.spectrum--dark { .spectrum--dark {
--drop-shadow: rgba(0, 0, 0, 0.3); --drop-shadow: rgba(0, 0, 0, 0.3);

View File

@ -4,12 +4,19 @@
import { getCellRenderer } from "../lib/renderers" import { getCellRenderer } from "../lib/renderers"
import { derived, writable } from "svelte/store" import { derived, writable } from "svelte/store"
const { rows, focusedCellId, focusedCellAPI, menu, config, validation } = const {
getContext("grid") rows,
focusedCellId,
focusedCellAPI,
menu,
config,
validation,
cellSelection,
} = getContext("grid")
export let highlighted export let highlighted
export let selected
export let rowFocused export let rowFocused
export let rowSelected
export let rowIdx export let rowIdx
export let topRow = false export let topRow = false
export let focused export let focused
@ -20,6 +27,8 @@
export let updateValue = rows.actions.updateValue export let updateValue = rows.actions.updateValue
export let contentLines = 1 export let contentLines = 1
export let hidden = false export let hidden = false
export let isSelectingCells = false
export let selectedCells = {}
const emptyError = writable(null) const emptyError = writable(null)
@ -43,6 +52,11 @@
} }
} }
// Callbacks for cell selection
$: cellSelected = selectedCells[cellId]
$: updateSelectionCallback = isSelectingCells ? updateSelection : null
$: stopSelectionCallback = isSelectingCells ? stopSelection : null
const getErrorStore = (selected, cellId) => { const getErrorStore = (selected, cellId) => {
if (!selected) { if (!selected) {
return emptyError return emptyError
@ -68,20 +82,38 @@
}) })
}, },
} }
const startSelection = e => {
if (e.button !== 0) {
return
}
focusedCellId.set(cellId)
cellSelection.actions.start(cellId)
}
const updateSelection = e => {
cellSelection.actions.update(cellId)
}
const stopSelection = e => {
cellSelection.actions.stop()
}
</script> </script>
<GridCell <GridCell
{highlighted} {highlighted}
{selected}
{rowIdx} {rowIdx}
{topRow} {topRow}
{focused} {focused}
{selectedUser} {selectedUser}
{readonly} {readonly}
{hidden} {hidden}
selected={rowSelected || cellSelected}
error={$error} error={$error}
on:click={() => focusedCellId.set(cellId)}
on:contextmenu={e => menu.actions.open(cellId, e)} on:contextmenu={e => menu.actions.open(cellId, e)}
on:mousedown={startSelection}
on:mouseenter={updateSelectionCallback}
on:mouseup={stopSelectionCallback}
width={column.width} width={column.width}
> >
<svelte:component <svelte:component

View File

@ -46,6 +46,7 @@
on:touchstart on:touchstart
on:touchend on:touchend
on:touchcancel on:touchcancel
on:mouseenter
{style} {style}
> >
{#if error} {#if error}
@ -155,6 +156,7 @@
.cell.focused.readonly { .cell.focused.readonly {
--cell-background: var(--cell-background-hover); --cell-background: var(--cell-background-hover);
} }
.cell.selected.focused,
.cell.selected:not(.focused) { .cell.selected:not(.focused) {
--cell-background: var(--spectrum-global-color-blue-100); --cell-background: var(--spectrum-global-color-blue-100);
} }

View File

@ -19,10 +19,14 @@
dispatch, dispatch,
rows, rows,
columnRenderMap, columnRenderMap,
isSelectingCells,
selectedCells,
selectedCellCount,
} = getContext("grid") } = getContext("grid")
$: rowSelected = !!$selectedRows[row._id] $: rowSelected = !!$selectedRows[row._id]
$: rowHovered = $hoveredRowId === row._id $: rowHovered =
$hoveredRowId === row._id && (!$selectedCellCount || !$isSelectingCells)
$: rowFocused = $focusedRow?._id === row._id $: rowFocused = $focusedRow?._id === row._id
$: reorderSource = $reorder.sourceColumn $: reorderSource = $reorder.sourceColumn
</script> </script>
@ -43,8 +47,8 @@
{column} {column}
{row} {row}
{rowFocused} {rowFocused}
{rowSelected}
highlighted={rowHovered || rowFocused || reorderSource === column.name} highlighted={rowHovered || rowFocused || reorderSource === column.name}
selected={rowSelected}
rowIdx={row.__idx} rowIdx={row.__idx}
topRow={top} topRow={top}
focused={$focusedCellId === cellId} focused={$focusedCellId === cellId}
@ -52,6 +56,8 @@
width={column.width} width={column.width}
contentLines={$contentLines} contentLines={$contentLines}
hidden={!$columnRenderMap[column.name]} hidden={!$columnRenderMap[column.name]}
isSelectingCells={$isSelectingCells}
selectedCells={$selectedCells}
/> />
{/each} {/each}
</div> </div>

View File

@ -24,6 +24,9 @@
dispatch, dispatch,
contentLines, contentLines,
isDragging, isDragging,
isSelectingCells,
selectedCells,
selectedCellCount,
} = getContext("grid") } = getContext("grid")
$: rowCount = $rows.length $: rowCount = $rows.length
@ -70,7 +73,9 @@
<GridScrollWrapper scrollVertically attachHandlers> <GridScrollWrapper scrollVertically attachHandlers>
{#each $renderedRows as row, idx} {#each $renderedRows as row, idx}
{@const rowSelected = !!$selectedRows[row._id]} {@const rowSelected = !!$selectedRows[row._id]}
{@const rowHovered = $hoveredRowId === row._id} {@const rowHovered =
$hoveredRowId === row._id &&
(!$selectedCellCount || !$isSelectingCells)}
{@const rowFocused = $focusedRow?._id === row._id} {@const rowFocused = $focusedRow?._id === row._id}
{@const cellId = getCellID(row._id, $stickyColumn?.name)} {@const cellId = getCellID(row._id, $stickyColumn?.name)}
<div <div
@ -85,7 +90,7 @@
{row} {row}
{cellId} {cellId}
{rowFocused} {rowFocused}
selected={rowSelected} {rowSelected}
highlighted={rowHovered || rowFocused} highlighted={rowHovered || rowFocused}
rowIdx={row.__idx} rowIdx={row.__idx}
topRow={idx === 0} topRow={idx === 0}
@ -94,6 +99,8 @@
width={$stickyColumn.width} width={$stickyColumn.width}
column={$stickyColumn} column={$stickyColumn}
contentLines={$contentLines} contentLines={$contentLines}
isSelectingCells={$isSelectingCells}
selectedCells={$selectedCells}
/> />
{/if} {/if}
</div> </div>

View File

@ -21,7 +21,6 @@
notifications, notifications,
hasBudibaseIdentifiers, hasBudibaseIdentifiers,
selectedRowCount, selectedRowCount,
selectedRows,
} = getContext("grid") } = getContext("grid")
let anchor let anchor
@ -83,6 +82,25 @@
> >
Delete {$selectedRowCount} rows Delete {$selectedRowCount} rows
</MenuItem> </MenuItem>
{:else if $menu.multiCellMode}
<MenuItem
icon="Copy"
on:click={clipboard.actions.copy}
on:click={menu.actions.close}
>
Copy
</MenuItem>
<MenuItem
icon="Paste"
disabled={$copiedCell == null || $focusedCellAPI?.isReadonly()}
on:click={clipboard.actions.paste}
on:click={menu.actions.close}
>
Paste
</MenuItem>
<MenuItem icon="Delete" disabled={isNewRow} on:click={() => {}}>
Delete
</MenuItem>
{:else} {:else}
<MenuItem <MenuItem
icon="Copy" icon="Copy"

View File

@ -45,7 +45,7 @@ export const createStores = () => {
} }
export const deriveStores = context => { export const deriveStores = context => {
const { columns, stickyColumn } = context const { columns, stickyColumn, visibleColumns } = context
// Quick access to all columns // Quick access to all columns
const allColumns = derived( const allColumns = derived(
@ -67,9 +67,19 @@ export const deriveStores = context => {
return normalCols.length > 0 return normalCols.length > 0
}) })
// Derive a lookup map for column indices by name
const columnLookupMap = derived(visibleColumns, $visibleColumns => {
let map = {}
$visibleColumns.forEach((column, idx) => {
map[column.name] = idx
})
return map
})
return { return {
allColumns, allColumns,
hasNonAutoColumn, hasNonAutoColumn,
columnLookupMap,
} }
} }

View File

@ -20,6 +20,7 @@ import * as Table from "./datasources/table"
import * as ViewV2 from "./datasources/viewV2" import * as ViewV2 from "./datasources/viewV2"
import * as NonPlus from "./datasources/nonPlus" import * as NonPlus from "./datasources/nonPlus"
import * as Cache from "./cache" import * as Cache from "./cache"
import * as Selection from "./selection"
const DependencyOrderedStores = [ const DependencyOrderedStores = [
Sort, Sort,
@ -44,6 +45,7 @@ const DependencyOrderedStores = [
Config, Config,
Notifications, Notifications,
Cache, Cache,
Selection,
] ]
export const attachStores = context => { export const attachStores = context => {

View File

@ -3,10 +3,11 @@ import { parseCellID } from "../lib/utils"
export const createStores = () => { export const createStores = () => {
const menu = writable({ const menu = writable({
x: 0, left: 0,
y: 0, top: 0,
visible: false, visible: false,
selectedRow: null, multiRowMode: false,
multiCellMode: false,
}) })
return { return {
menu, menu,
@ -14,8 +15,15 @@ export const createStores = () => {
} }
export const createActions = context => { export const createActions = context => {
const { menu, focusedCellId, gridID, selectedRows, selectedRowCount } = const {
context menu,
focusedCellId,
gridID,
selectedRows,
selectedRowCount,
selectedCells,
selectedCellCount,
} = context
const open = (cellId, e) => { const open = (cellId, e) => {
e.preventDefault() e.preventDefault()
@ -32,7 +40,7 @@ export const createActions = context => {
const targetBounds = e.target.getBoundingClientRect() const targetBounds = e.target.getBoundingClientRect()
const dataBounds = dataNode.getBoundingClientRect() const dataBounds = dataNode.getBoundingClientRect()
// Check if there are multiple rows selected, and this is one of them // Check if there are multiple rows selected, and if this is one of them
let multiRowMode = false let multiRowMode = false
if (get(selectedRowCount) > 1) { if (get(selectedRowCount) > 1) {
const rowId = parseCellID(cellId).id const rowId = parseCellID(cellId).id
@ -41,8 +49,16 @@ export const createActions = context => {
} }
} }
// Check if there are multiple cells selected, and if this is one of them
let multiCellMode = false
if (!multiRowMode && get(selectedCellCount) > 1) {
if (get(selectedCells)[cellId]) {
multiCellMode = true
}
}
// Only focus this cell if not in multi row mode // Only focus this cell if not in multi row mode
if (!multiRowMode) { if (!multiRowMode && !multiCellMode) {
focusedCellId.set(cellId) focusedCellId.set(cellId)
} }
@ -51,6 +67,7 @@ export const createActions = context => {
top: targetBounds.top - dataBounds.top + e.offsetY, top: targetBounds.top - dataBounds.top + e.offsetY,
visible: true, visible: true,
multiRowMode, multiRowMode,
multiCellMode,
}) })
} }

View File

@ -0,0 +1,130 @@
import { derived, writable, get } from "svelte/store"
import { getCellID, parseCellID } from "../lib/utils"
export const createStores = () => {
const cellSelection = writable({
active: false,
sourceCellId: null,
targetCellId: null,
})
return {
cellSelection,
}
}
export const deriveStores = context => {
const { cellSelection, rowLookupMap, columnLookupMap, rows, visibleColumns } =
context
const isSelectingCells = derived(cellSelection, $cellSelection => {
return $cellSelection.active
})
const selectedCells = derived(
[cellSelection, rowLookupMap, columnLookupMap],
([$cellSelection, $rowLookupMap, $columnLookupMap]) => {
const { sourceCellId, targetCellId } = $cellSelection
if (!sourceCellId || !targetCellId || sourceCellId === targetCellId) {
return {}
}
const $rows = get(rows)
const $visibleColumns = get(visibleColumns)
// Get source and target row and column indices
const sourceInfo = parseCellID(sourceCellId)
const targetInfo = parseCellID(targetCellId)
// Row indices
const sourceRowIndex = $rowLookupMap[sourceInfo.id]
const targetRowIndex = $rowLookupMap[targetInfo.id]
const lowerRowIndex = Math.min(sourceRowIndex, targetRowIndex)
const upperRowIndex = Math.max(sourceRowIndex, targetRowIndex)
// Column indices
const sourceColIndex = $columnLookupMap[sourceInfo.field]
const targetColIndex = $columnLookupMap[targetInfo.field]
const lowerColIndex = Math.min(sourceColIndex, targetColIndex)
const upperColIndex = Math.max(sourceColIndex, targetColIndex)
// Build map of all cells inside these bounds
let map = {}
let rowId, colName, cellId
for (let rowIdx = lowerRowIndex; rowIdx <= upperRowIndex; rowIdx++) {
for (let colIdx = lowerColIndex; colIdx <= upperColIndex; colIdx++) {
rowId = $rows[rowIdx]._id
colName = $visibleColumns[colIdx].name
cellId = getCellID(rowId, colName)
map[cellId] = true
}
}
return map
}
)
const selectedCellCount = derived(selectedCells, $selectedCells => {
return Object.keys($selectedCells).length
})
return {
isSelectingCells,
selectedCells,
selectedCellCount,
}
}
export const createActions = context => {
const { cellSelection } = context
const startCellSelection = sourceCellId => {
cellSelection.set({
active: true,
sourceCellId,
targetCellId: sourceCellId,
})
}
const updateCellSelection = targetCellId => {
cellSelection.update(state => ({
...state,
targetCellId,
}))
}
const stopCellSelection = () => {
cellSelection.update(state => ({
...state,
active: false,
}))
}
const clearCellSelection = () => {
cellSelection.set({
active: false,
sourceCellId: null,
targetCellId: null,
})
}
return {
cellSelection: {
...cellSelection,
actions: {
start: startCellSelection,
update: updateCellSelection,
stop: stopCellSelection,
clear: clearCellSelection,
},
},
}
}
export const initialise = context => {
const { selectedCellCount, selectedRowCount, selectedRows } = context
selectedCellCount.subscribe($selectedCellCount => {
if ($selectedCellCount && get(selectedRowCount)) {
selectedRows.set({})
}
})
}

View File

@ -188,6 +188,9 @@ export const initialise = context => {
rowHeight, rowHeight,
fixedRowHeight, fixedRowHeight,
selectedRowCount, selectedRowCount,
menu,
cellSelection,
selectedCellCount,
} = context } = context
// Ensure we clear invalid rows from state if they disappear // Ensure we clear invalid rows from state if they disappear
@ -248,6 +251,14 @@ export const initialise = context => {
if (id && get(selectedRowCount)) { if (id && get(selectedRowCount)) {
selectedRows.set({}) selectedRows.set({})
} }
// Clear cell selection when focusing a cell
if (id && get(selectedCellCount)) {
cellSelection.actions.clear()
}
// Close the menu if it was open
menu.actions.close()
}) })
// Pull row height from table as long as we don't have a fixed height // Pull row height from table as long as we don't have a fixed height
@ -268,8 +279,13 @@ export const initialise = context => {
// Clear focused cell when selecting rows // Clear focused cell when selecting rows
selectedRowCount.subscribe(count => { selectedRowCount.subscribe(count => {
if (get(focusedCellId) && count) { if (count) {
if (get(focusedCellId)) {
focusedCellId.set(null) focusedCellId.set(null)
} }
if (get(selectedCellCount)) {
cellSelection.actions.clear()
}
}
}) })
} }

View File

@ -16,5 +16,5 @@
/* Custom additions */ /* Custom additions */
--modal-background: var(--spectrum-global-color-gray-50); --modal-background: var(--spectrum-global-color-gray-50);
--drop-shadow: rgba(0, 0, 0, 0.25) !important; --drop-shadow: rgba(0, 0, 0, 0.25) !important;
--spectrum-global-color-blue-100: rgba(35, 40, 50) !important; --spectrum-global-color-blue-100: rgba(36, 44, 64) !important;
} }

View File

@ -49,5 +49,5 @@
/* Custom additions */ /* Custom additions */
--modal-background: var(--spectrum-global-color-gray-50); --modal-background: var(--spectrum-global-color-gray-50);
--drop-shadow: rgba(0, 0, 0, 0.15) !important; --drop-shadow: rgba(0, 0, 0, 0.15) !important;
--spectrum-global-color-blue-100: rgb(56, 65, 84) !important; --spectrum-global-color-blue-100: rgb(56, 65, 90) !important;
} }