From 734991057253c64354e38c2e7d6a19b8c904b741 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 21 Jun 2024 11:08:24 +0100 Subject: [PATCH] Add support for selecting multiple cells --- packages/bbui/src/bbui.css | 22 +-- .../src/components/grid/cells/DataCell.svelte | 42 +++++- .../src/components/grid/cells/GridCell.svelte | 2 + .../src/components/grid/layout/GridRow.svelte | 10 +- .../grid/layout/StickyColumn.svelte | 11 +- .../grid/overlays/MenuOverlay.svelte | 20 ++- .../src/components/grid/stores/columns.js | 12 +- .../src/components/grid/stores/index.js | 2 + .../src/components/grid/stores/menu.js | 31 ++++- .../src/components/grid/stores/selection.js | 130 ++++++++++++++++++ .../src/components/grid/stores/ui.js | 20 ++- .../frontend-core/src/themes/midnight.css | 34 ++--- packages/frontend-core/src/themes/nord.css | 2 +- 13 files changed, 289 insertions(+), 49 deletions(-) create mode 100644 packages/frontend-core/src/components/grid/stores/selection.js diff --git a/packages/bbui/src/bbui.css b/packages/bbui/src/bbui.css index 9b5d89f61c..d60a3e18ea 100644 --- a/packages/bbui/src/bbui.css +++ b/packages/bbui/src/bbui.css @@ -3,13 +3,13 @@ --ink: #000000; /* Brand colours */ - --bb-coral: #FF4E4E; - --bb-coral-light: #F97777; - --bb-indigo: #6E56FF; - --bb-indigo-light: #9F8FFF; - --bb-lime: #ECFFB5; + --bb-coral: #ff4e4e; + --bb-coral-light: #f97777; + --bb-indigo: #6e56ff; + --bb-indigo-light: #9f8fff; + --bb-lime: #ecffb5; --bb-forest-green: #053835; - --bb-beige: #F6EFEA; + --bb-beige: #f6efea; --grey-1: #fafafa; --grey-2: #f5f5f5; @@ -49,10 +49,10 @@ --rounded-medium: 8px; --rounded-large: 16px; - --font-sans: "Source Sans Pro", -apple-system, BlinkMacSystemFont, Segoe UI, "Inter", - "Helvetica Neue", Arial, "Noto Sans", sans-serif; - --font-accent: "Source Sans Pro", -apple-system, BlinkMacSystemFont, Segoe UI, "Inter", - "Helvetica Neue", Arial, "Noto Sans", sans-serif; + --font-sans: "Source Sans Pro", -apple-system, BlinkMacSystemFont, Segoe UI, + "Inter", "Helvetica Neue", Arial, "Noto Sans", sans-serif; + --font-accent: "Source Sans Pro", -apple-system, BlinkMacSystemFont, Segoe UI, + "Inter", "Helvetica Neue", Arial, "Noto Sans", sans-serif; --font-serif: "Georgia", Cambria, Times New Roman, Times, serif; --font-mono: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; @@ -111,7 +111,7 @@ a { /* Custom theme additions */ .spectrum--darkest { --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 { --drop-shadow: rgba(0, 0, 0, 0.3); diff --git a/packages/frontend-core/src/components/grid/cells/DataCell.svelte b/packages/frontend-core/src/components/grid/cells/DataCell.svelte index 30d7e39921..d0ad26e939 100644 --- a/packages/frontend-core/src/components/grid/cells/DataCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/DataCell.svelte @@ -4,12 +4,19 @@ import { getCellRenderer } from "../lib/renderers" import { derived, writable } from "svelte/store" - const { rows, focusedCellId, focusedCellAPI, menu, config, validation } = - getContext("grid") + const { + rows, + focusedCellId, + focusedCellAPI, + menu, + config, + validation, + cellSelection, + } = getContext("grid") export let highlighted - export let selected export let rowFocused + export let rowSelected export let rowIdx export let topRow = false export let focused @@ -20,6 +27,8 @@ export let updateValue = rows.actions.updateValue export let contentLines = 1 export let hidden = false + export let isSelectingCells = false + export let selectedCells = {} 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) => { if (!selected) { 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() + } focusedCellId.set(cellId)} on:contextmenu={e => menu.actions.open(cellId, e)} + on:mousedown={startSelection} + on:mouseenter={updateSelectionCallback} + on:mouseup={stopSelectionCallback} width={column.width} > {#if error} @@ -155,6 +156,7 @@ .cell.focused.readonly { --cell-background: var(--cell-background-hover); } + .cell.selected.focused, .cell.selected:not(.focused) { --cell-background: var(--spectrum-global-color-blue-100); } diff --git a/packages/frontend-core/src/components/grid/layout/GridRow.svelte b/packages/frontend-core/src/components/grid/layout/GridRow.svelte index c3d6f6eb86..6b05565f0e 100644 --- a/packages/frontend-core/src/components/grid/layout/GridRow.svelte +++ b/packages/frontend-core/src/components/grid/layout/GridRow.svelte @@ -19,10 +19,14 @@ dispatch, rows, columnRenderMap, + isSelectingCells, + selectedCells, + selectedCellCount, } = getContext("grid") $: rowSelected = !!$selectedRows[row._id] - $: rowHovered = $hoveredRowId === row._id + $: rowHovered = + $hoveredRowId === row._id && (!$selectedCellCount || !$isSelectingCells) $: rowFocused = $focusedRow?._id === row._id $: reorderSource = $reorder.sourceColumn @@ -43,8 +47,8 @@ {column} {row} {rowFocused} + {rowSelected} highlighted={rowHovered || rowFocused || reorderSource === column.name} - selected={rowSelected} rowIdx={row.__idx} topRow={top} focused={$focusedCellId === cellId} @@ -52,6 +56,8 @@ width={column.width} contentLines={$contentLines} hidden={!$columnRenderMap[column.name]} + isSelectingCells={$isSelectingCells} + selectedCells={$selectedCells} /> {/each} diff --git a/packages/frontend-core/src/components/grid/layout/StickyColumn.svelte b/packages/frontend-core/src/components/grid/layout/StickyColumn.svelte index b57c89ee4f..d98c1d3d25 100644 --- a/packages/frontend-core/src/components/grid/layout/StickyColumn.svelte +++ b/packages/frontend-core/src/components/grid/layout/StickyColumn.svelte @@ -24,6 +24,9 @@ dispatch, contentLines, isDragging, + isSelectingCells, + selectedCells, + selectedCellCount, } = getContext("grid") $: rowCount = $rows.length @@ -70,7 +73,9 @@ {#each $renderedRows as row, idx} {@const rowSelected = !!$selectedRows[row._id]} - {@const rowHovered = $hoveredRowId === row._id} + {@const rowHovered = + $hoveredRowId === row._id && + (!$selectedCellCount || !$isSelectingCells)} {@const rowFocused = $focusedRow?._id === row._id} {@const cellId = getCellID(row._id, $stickyColumn?.name)}
{/if}
diff --git a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte index 5535538cf0..700670940d 100644 --- a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte +++ b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte @@ -21,7 +21,6 @@ notifications, hasBudibaseIdentifiers, selectedRowCount, - selectedRows, } = getContext("grid") let anchor @@ -83,6 +82,25 @@ > Delete {$selectedRowCount} rows + {:else if $menu.multiCellMode} + + Copy + + + Paste + + {}}> + Delete + {:else} { } export const deriveStores = context => { - const { columns, stickyColumn } = context + const { columns, stickyColumn, visibleColumns } = context // Quick access to all columns const allColumns = derived( @@ -67,9 +67,19 @@ export const deriveStores = context => { 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 { allColumns, hasNonAutoColumn, + columnLookupMap, } } diff --git a/packages/frontend-core/src/components/grid/stores/index.js b/packages/frontend-core/src/components/grid/stores/index.js index 6dfff6531b..a261023b2c 100644 --- a/packages/frontend-core/src/components/grid/stores/index.js +++ b/packages/frontend-core/src/components/grid/stores/index.js @@ -20,6 +20,7 @@ import * as Table from "./datasources/table" import * as ViewV2 from "./datasources/viewV2" import * as NonPlus from "./datasources/nonPlus" import * as Cache from "./cache" +import * as Selection from "./selection" const DependencyOrderedStores = [ Sort, @@ -44,6 +45,7 @@ const DependencyOrderedStores = [ Config, Notifications, Cache, + Selection, ] export const attachStores = context => { diff --git a/packages/frontend-core/src/components/grid/stores/menu.js b/packages/frontend-core/src/components/grid/stores/menu.js index 9120ad4a9f..295604b275 100644 --- a/packages/frontend-core/src/components/grid/stores/menu.js +++ b/packages/frontend-core/src/components/grid/stores/menu.js @@ -3,10 +3,11 @@ import { parseCellID } from "../lib/utils" export const createStores = () => { const menu = writable({ - x: 0, - y: 0, + left: 0, + top: 0, visible: false, - selectedRow: null, + multiRowMode: false, + multiCellMode: false, }) return { menu, @@ -14,8 +15,15 @@ export const createStores = () => { } export const createActions = context => { - const { menu, focusedCellId, gridID, selectedRows, selectedRowCount } = - context + const { + menu, + focusedCellId, + gridID, + selectedRows, + selectedRowCount, + selectedCells, + selectedCellCount, + } = context const open = (cellId, e) => { e.preventDefault() @@ -32,7 +40,7 @@ export const createActions = context => { const targetBounds = e.target.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 if (get(selectedRowCount) > 1) { 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 - if (!multiRowMode) { + if (!multiRowMode && !multiCellMode) { focusedCellId.set(cellId) } @@ -51,6 +67,7 @@ export const createActions = context => { top: targetBounds.top - dataBounds.top + e.offsetY, visible: true, multiRowMode, + multiCellMode, }) } diff --git a/packages/frontend-core/src/components/grid/stores/selection.js b/packages/frontend-core/src/components/grid/stores/selection.js new file mode 100644 index 0000000000..568c10eb19 --- /dev/null +++ b/packages/frontend-core/src/components/grid/stores/selection.js @@ -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({}) + } + }) +} diff --git a/packages/frontend-core/src/components/grid/stores/ui.js b/packages/frontend-core/src/components/grid/stores/ui.js index 6280b05eca..3aeb278c89 100644 --- a/packages/frontend-core/src/components/grid/stores/ui.js +++ b/packages/frontend-core/src/components/grid/stores/ui.js @@ -188,6 +188,9 @@ export const initialise = context => { rowHeight, fixedRowHeight, selectedRowCount, + menu, + cellSelection, + selectedCellCount, } = context // Ensure we clear invalid rows from state if they disappear @@ -248,6 +251,14 @@ export const initialise = context => { if (id && get(selectedRowCount)) { 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 @@ -268,8 +279,13 @@ export const initialise = context => { // Clear focused cell when selecting rows selectedRowCount.subscribe(count => { - if (get(focusedCellId) && count) { - focusedCellId.set(null) + if (count) { + if (get(focusedCellId)) { + focusedCellId.set(null) + } + if (get(selectedCellCount)) { + cellSelection.actions.clear() + } } }) } diff --git a/packages/frontend-core/src/themes/midnight.css b/packages/frontend-core/src/themes/midnight.css index 501bc164bc..30132e11e2 100644 --- a/packages/frontend-core/src/themes/midnight.css +++ b/packages/frontend-core/src/themes/midnight.css @@ -1,20 +1,20 @@ .spectrum--midnight { - --hue: 220; - --sat: 10%; - --spectrum-global-color-gray-50: hsl(var(--hue), var(--sat), 12%); - --spectrum-global-color-gray-75: hsl(var(--hue), var(--sat), 15%); - --spectrum-global-color-gray-100: hsl(var(--hue), var(--sat), 17%); - --spectrum-global-color-gray-200: hsl(var(--hue), var(--sat), 20%); - --spectrum-global-color-gray-300: hsl(var(--hue), var(--sat), 24%); - --spectrum-global-color-gray-400: hsl(var(--hue), var(--sat), 32%); - --spectrum-global-color-gray-500: hsl(var(--hue), var(--sat), 40%); - --spectrum-global-color-gray-600: hsl(var(--hue), var(--sat), 60%); - --spectrum-global-color-gray-700: hsl(var(--hue), var(--sat), 70%); - --spectrum-global-color-gray-800: hsl(var(--hue), var(--sat), 85%); - --spectrum-global-color-gray-900: hsl(var(--hue), var(--sat), 95%); + --hue: 220; + --sat: 10%; + --spectrum-global-color-gray-50: hsl(var(--hue), var(--sat), 12%); + --spectrum-global-color-gray-75: hsl(var(--hue), var(--sat), 15%); + --spectrum-global-color-gray-100: hsl(var(--hue), var(--sat), 17%); + --spectrum-global-color-gray-200: hsl(var(--hue), var(--sat), 20%); + --spectrum-global-color-gray-300: hsl(var(--hue), var(--sat), 24%); + --spectrum-global-color-gray-400: hsl(var(--hue), var(--sat), 32%); + --spectrum-global-color-gray-500: hsl(var(--hue), var(--sat), 40%); + --spectrum-global-color-gray-600: hsl(var(--hue), var(--sat), 60%); + --spectrum-global-color-gray-700: hsl(var(--hue), var(--sat), 70%); + --spectrum-global-color-gray-800: hsl(var(--hue), var(--sat), 85%); + --spectrum-global-color-gray-900: hsl(var(--hue), var(--sat), 95%); - /* Custom additions */ - --modal-background: var(--spectrum-global-color-gray-50); - --drop-shadow: rgba(0, 0, 0, 0.25) !important; - --spectrum-global-color-blue-100: rgba(35, 40, 50) !important; + /* Custom additions */ + --modal-background: var(--spectrum-global-color-gray-50); + --drop-shadow: rgba(0, 0, 0, 0.25) !important; + --spectrum-global-color-blue-100: rgba(36, 44, 64) !important; } diff --git a/packages/frontend-core/src/themes/nord.css b/packages/frontend-core/src/themes/nord.css index d47dbe8aa8..a99812b146 100644 --- a/packages/frontend-core/src/themes/nord.css +++ b/packages/frontend-core/src/themes/nord.css @@ -49,5 +49,5 @@ /* Custom additions */ --modal-background: var(--spectrum-global-color-gray-50); --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; }