Split into more modular components and try virtual rendering

This commit is contained in:
Andrew Kingston 2023-02-23 16:40:48 +00:00
parent 0060025cca
commit ddb70ba3f6
7 changed files with 379 additions and 250 deletions

View File

@ -0,0 +1,13 @@
<script>
import SpreadsheetCell from "./SpreadsheetCell.svelte"
import { getContext } from "svelte"
const { selectedCellId, hoveredRowId } = getContext("spreadsheet")
</script>
<SpreadsheetCell
{...$$props}
spacer
on:click={() => ($selectedCellId = null)}
on:mouseenter={() => ($hoveredRowId = null)}
/>

View File

@ -3,18 +3,16 @@
import { writable } from "svelte/store"
import { fetchData, LuceneUtils } from "@budibase/frontend-core"
import { Icon } from "@budibase/bbui"
import TextCell from "./cells/TextCell.svelte"
import OptionsCell from "./cells/OptionsCell.svelte"
import DateCell from "./cells/DateCell.svelte"
import MultiSelectCell from "./cells/MultiSelectCell.svelte"
import NumberCell from "./cells/NumberCell.svelte"
import RelationshipCell from "./cells/RelationshipCell.svelte"
import { createReorderStores } from "./stores/reorder"
import { createResizeStore } from "./stores/resize"
import ReorderPlaceholder from "./ReorderPlaceholder.svelte"
import ResizeSlider from "./ResizeSlider.svelte"
import SpreadsheetHeader from "./SpreadsheetHeader.svelte"
import SpreadsheetBody from "./SpreadsheetBody.svelte"
import SpreadsheetCell from "./SpreadsheetCell.svelte"
import SpacerCell from "./SpacerCell.svelte"
import VerticalSpacer from "./VerticalSpacer.svelte"
import SpreadsheetRow from "./SpreadsheetRow.svelte"
export let table
export let filter
@ -25,6 +23,7 @@
const component = getContext("component")
// Sheet constants
const cellHeight = 32
const limit = 100
const defaultWidth = 160
const rand = Math.random()
@ -36,6 +35,9 @@
const selectedCellId = writable(null)
const selectedRows = writable({})
const tableId = writable(table?.tableId)
const changeCache = writable({})
const newRows = writable([])
const visibleRows = writable([0, 0])
// Build up spreadsheet context and additional stores
const context = {
@ -46,27 +48,14 @@
selectedCellId,
selectedRows,
tableId,
changeCache,
newRows,
cellHeight,
visibleRows,
}
const { reorder, reorderPlaceholder } = createReorderStores(context)
const resize = createResizeStore(context)
// API for children to consume
const spreadsheetAPI = {
refreshData: () => fetch?.refresh(),
}
// Set context for children to consume
setContext("spreadsheet", {
...context,
reorder,
reorderPlaceholder,
resize,
spreadsheetAPI,
})
let changeCache = {}
let newRows = []
$: tableId.set(table?.tableId)
$: query = LuceneUtils.buildLuceneQuery(filter)
$: fetch = createFetch(table)
@ -79,7 +68,7 @@
$: generateColumns($fetch)
$: rowCount = $rows.length
$: selectedRowCount = Object.values($selectedRows).filter(x => !!x).length
$: updateSortedRows($fetch.rows, newRows)
$: updateSortedRows($fetch.rows, $newRows)
const createFetch = datasource => {
return fetchData({
@ -112,22 +101,6 @@
}
}
const getCellForField = field => {
const type = field.schema.type
if (type === "options") {
return OptionsCell
} else if (type === "datetime") {
return DateCell
} else if (type === "array") {
return MultiSelectCell
} else if (type === "number") {
return NumberCell
} else if (type === "link") {
return RelationshipCell
}
return TextCell
}
const getIconForField = field => {
const type = field.schema.type
if (type === "options") {
@ -138,13 +111,6 @@
return "Text"
}
const selectRow = id => {
selectedRows.update(state => {
state[id] = !state[id]
return state
})
}
const selectAll = () => {
const allSelected = selectedRowCount === rowCount
if (allSelected) {
@ -159,7 +125,7 @@
}
}
const handleChange = async (rowId, field, value) => {
const updateValue = async (rowId, field, value) => {
let row = $rows.find(x => x._id === rowId)
if (!row) {
return
@ -167,19 +133,25 @@
if (row[field.name] === value) {
return
}
changeCache[rowId] = { [field.name]: value }
changeCache.update(state => {
state[rowId] = { [field.name]: value }
return state
})
await API.saveRow({
...row,
...changeCache[rowId],
...$changeCache[rowId],
})
await fetch.refresh()
delete changeCache[rowId]
changeCache.update(state => {
delete state[rowId]
return state
})
}
const addRow = async field => {
const res = await API.saveRow({ tableId: table.tableId })
$selectedCellId = `${res._id}-${field.name}`
newRows.push(res._id)
newRows.update(state => [...state, res._id])
await fetch.refresh()
}
@ -192,25 +164,44 @@
})
$rows = sortedRows
}
// API for children to consume
const spreadsheetAPI = {
refreshData: () => fetch?.refresh(),
updateValue,
}
// Set context for children to consume
setContext("spreadsheet", {
...context,
reorder,
reorderPlaceholder,
resize,
spreadsheetAPI,
})
</script>
<div use:styleable={$component.styles}>
<div class="wrapper" class:resize={$resize.columnIdx != null}>
<div
class="wrapper"
class:resize={$resize.columnIdx != null}
style="--cell-height:{cellHeight}px;"
>
<SpreadsheetHeader />
<SpreadsheetBody>
<!-- Field headers -->
<div class="header cell label" on:click={selectAll}>
<SpreadsheetCell header label on:click={selectAll}>
<input
type="checkbox"
checked={rowCount && selectedRowCount === rowCount}
/>
</div>
</SpreadsheetCell>
{#each $columns as field, fieldIdx}
<div
class="header cell"
class:sticky={fieldIdx === 0}
class:reorder-source={$reorder.columnIdx === fieldIdx}
class:reorder-target={$reorder.swapColumnIdx === fieldIdx}
<SpreadsheetCell
header
sticky={fieldIdx === 0}
reorderSource={$reorder.columnIdx === fieldIdx}
reorderTarget={$reorder.swapColumnIdx === fieldIdx}
on:mousedown={e => reorder.actions.startReordering(fieldIdx, e)}
id={`sheet-${rand}-header-${fieldIdx}`}
>
@ -223,101 +214,44 @@
{field.name}
</span>
<ResizeSlider columnIdx={fieldIdx} />
</div>
</SpreadsheetCell>
{/each}
<!-- Horizontal spacer -->
<div
class="header cell spacer"
class:reorder-target={$reorder.swapColumnIdx === $columns.length}
<SpacerCell
header
reorderTarget={$reorder.swapColumnIdx === $columns.length}
/>
<!-- All real rows -->
{#each $rows as row, rowIdx (row._id)}
{@const rowSelected = !!$selectedRows[row._id]}
{@const rowHovered = $hoveredRowId === row._id}
{@const data = { ...row, ...changeCache[row._id] }}
<div
class="cell label"
class:row-selected={rowSelected}
class:hovered={rowHovered}
on:focus
on:mouseover={() => ($hoveredRowId = row._id)}
on:click={() => selectRow(row._id)}
>
{#if rowSelected || rowHovered}
<input type="checkbox" checked={rowSelected} />
{:else}
<span>
{rowIdx + 1}
</span>
{/if}
</div>
{#each $columns as field, fieldIdx}
{@const cellIdx = `${row._id}-${field.name}`}
{#key cellIdx}
<div
class="cell"
class:row-selected={rowSelected}
class:sticky={fieldIdx === 0}
class:hovered={rowHovered}
class:selected={$selectedCellId === cellIdx}
class:reorder-source={$reorder.columnIdx === fieldIdx}
class:reorder-target={$reorder.swapColumnIdx === fieldIdx}
on:focus
on:mouseover={() => ($hoveredRowId = row._id)}
on:click={() => ($selectedCellId = cellIdx)}
>
<svelte:component
this={getCellForField(field)}
value={data[field.name]}
schema={field.schema}
selected={$selectedCellId === cellIdx}
onChange={val => handleChange(row._id, field, val)}
readonly={field.schema.autocolumn}
/>
</div>
{/key}
{/each}
<!-- Horizontal spacer -->
<div
class="cell spacer"
class:reorder-target={$reorder.swapColumnIdx === $columns.length}
/>
<SpreadsheetRow {row} {rowIdx} />
{/each}
<!-- New row placeholder -->
<div
class="cell label new"
<SpreadsheetCell
label
on:click={addRow}
on:focus
on:mouseover={() => ($hoveredRowId = "new")}
class:hovered={$hoveredRowId === "new"}
on:mouseenter={() => ($hoveredRowId = "new")}
rowHovered={$hoveredRowId === "new"}
>
<Icon hoverable name="Add" size="S" />
</div>
</SpreadsheetCell>
{#each $columns as field, fieldIdx}
<div
class="cell new"
class:sticky={fieldIdx === 0}
class:hovered={$hoveredRowId === "new"}
class:reorder-source={$reorder.columnIdx === fieldIdx}
class:reorder-target={$reorder.swapColumnIdx === fieldIdx}
<SpreadsheetCell
sticky={fieldIdx === 0}
rowHovered={$hoveredRowId === "new"}
reorderSource={$reorder.columnIdx === fieldIdx}
reorderTarget={$reorder.swapColumnIdx === fieldIdx}
on:click={() => addRow(field)}
on:focus
on:mouseover={() => ($hoveredRowId = "new")}
on:mouseenter={() => ($hoveredRowId = "new")}
/>
{/each}
<!-- Horizontal spacer -->
<div
class="cell spacer"
class:reorder-target={$reorder.swapColumnIdx === $columns.length}
/>
<SpacerCell reorderTarget={$reorder.swapColumnIdx === $columns.length} />
<!-- Vertical spacer -->
<div class="vertical-spacer" />
<!-- Vertical spacer to pad bottom of sheet -->
<VerticalSpacer />
</SpreadsheetBody>
<!-- Reorder placeholder -->
<!-- Placeholder overlay for new column position -->
<ReorderPlaceholder />
</div>
</div>
@ -337,121 +271,12 @@
--cell-background-hover: var(--spectrum-global-color-gray-100);
--cell-padding: 8px;
--cell-spacing: 4px;
--cell-height: 32px;
--cell-font-size: 14px;
}
.wrapper.resize *:hover {
cursor: col-resize;
}
.wrapper ::-webkit-scrollbar-track {
.wrapper::-webkit-scrollbar-track {
background: var(--cell-background);
}
/* Cells */
.cell {
height: var(--cell-height);
border-style: solid;
border-color: var(--spectrum-global-color-gray-300);
border-width: 0;
border-bottom-width: 1px;
border-left-width: 1px;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
color: var(--spectrum-global-color-gray-900);
font-size: var(--cell-font-size);
gap: var(--cell-spacing);
background: var(--cell-background);
position: relative;
transition: border-color 130ms ease-out;
}
.cell.hovered {
background: var(--cell-background-hover);
}
.cell.selected {
box-shadow: inset 0 0 0 2px var(--spectrum-global-color-blue-400);
z-index: 1;
}
.cell:not(.selected) {
user-select: none;
}
.cell:hover {
cursor: default;
}
.cell.row-selected {
background-color: rgb(224, 242, 255);
}
.cell.new:hover {
cursor: pointer;
}
/* Header cells */
.header {
background: var(--spectrum-global-color-gray-200);
position: sticky;
top: 0;
padding: 0 var(--cell-padding);
z-index: 3;
border-color: var(--spectrum-global-color-gray-400);
}
.header span {
flex: 1 1 auto;
width: 0;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.header.sticky {
z-index: 4;
}
/* Sticky styles */
.sticky {
position: sticky;
left: 40px;
z-index: 2;
border-left-color: transparent;
}
.sticky.selected {
z-index: 3;
}
/* Spacer cells */
.spacer {
background: none;
border-bottom: none;
}
.vertical-spacer {
grid-column: 1/-1;
height: 180px;
}
/* Reorder styles */
.cell.reorder-source {
background: var(--spectrum-global-color-gray-200);
}
.cell.reorder-target {
border-left-color: var(--spectrum-global-color-blue-400);
}
.label {
padding: 0 12px;
border-right: none;
position: sticky;
left: 0;
z-index: 2;
}
.label.header {
z-index: 4;
}
.label span {
min-width: 14px;
text-align: center;
color: var(--spectrum-global-color-gray-500);
}
input[type="checkbox"] {
margin: 0;
}
</style>

View File

@ -1,11 +1,17 @@
<script>
import { getContext } from "svelte"
import { getContext, onMount } from "svelte"
import { Utils } from "@budibase/frontend-core"
const { columns, selectedCellId, rand } = getContext("spreadsheet")
const { columns, selectedCellId, rand, visibleRows, cellHeight } =
getContext("spreadsheet")
let ref
let height = 0
let horizontallyScrolled = false
let scrollTop = 0
$: gridStyles = getGridStyles($columns)
$: computeVisibleRows(scrollTop, height)
const getGridStyles = columns => {
const widths = columns?.map(x => x.width)
@ -15,12 +21,35 @@
return `--grid: 40px ${widths.map(x => `${x}px`).join(" ")} 180px;`
}
// Store the current scroll position
const handleScroll = e => {
// Update horizontally scrolled flag
horizontallyScrolled = e.target.scrollLeft > 0
// Only update scroll top offset when a sizable change happens
scrollTop = e.target.scrollTop
}
const computeVisibleRows = Utils.debounce((scrollTop, height) => {
const rows = Math.ceil(height / cellHeight) + 16
const firstRow = Math.max(0, Math.floor(scrollTop / cellHeight) - 8)
visibleRows.set([firstRow, firstRow + rows])
}, 50)
// Observe and record the height of the body
onMount(() => {
const observer = new ResizeObserver(entries => {
height = entries[0].contentRect.height
})
observer.observe(ref)
return () => {
observer.disconnect()
}
})
</script>
<div
bind:this={ref}
class="spreadsheet"
class:horizontally-scrolled={horizontallyScrolled}
on:scroll={handleScroll}

View File

@ -0,0 +1,138 @@
<script>
export let header = false
export let label = false
export let spacer = false
export let rowHovered = false
export let rowSelected = false
export let sticky = false
export let selected = false
export let reorderSource = false
export let reorderTarget = false
export let id = null
</script>
<div
class="cell"
class:header
class:label
class:spacer
class:row-selected={rowSelected}
class:row-hovered={rowHovered}
class:sticky
class:selected
class:reorder-source={reorderSource}
class:reorder-target={reorderTarget}
on:focus
on:mouseenter
on:click
on:mousedown
{id}
>
<slot />
</div>
<style>
/* Cells */
.cell {
height: var(--cell-height);
border-style: solid;
border-color: var(--spectrum-global-color-gray-300);
border-width: 0;
border-bottom-width: 1px;
border-left-width: 1px;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
color: var(--spectrum-global-color-gray-900);
font-size: var(--cell-font-size);
gap: var(--cell-spacing);
background: var(--cell-background);
position: relative;
transition: border-color 130ms ease-out;
}
.cell.row-hovered {
background: var(--cell-background-hover);
}
.cell.selected {
box-shadow: inset 0 0 0 2px var(--spectrum-global-color-blue-400);
z-index: 1;
}
.cell:not(.selected) {
user-select: none;
}
.cell:hover {
cursor: default;
}
.cell.row-selected {
background-color: rgb(224, 242, 255);
}
/* Header cells */
.cell.header {
background: var(--spectrum-global-color-gray-200);
position: sticky;
top: 0;
padding: 0 var(--cell-padding);
z-index: 3;
border-color: var(--spectrum-global-color-gray-400);
}
.cell.header :global(span) {
flex: 1 1 auto;
width: 0;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
/* Sticky styles */
.cell.sticky {
position: sticky;
left: 40px;
z-index: 2;
border-left-color: transparent;
}
.cell.sticky.selected {
z-index: 3;
}
.cell.header.sticky {
z-index: 4;
}
/* Reorder styles */
.cell.reorder-source {
background: var(--spectrum-global-color-gray-100);
}
.cell.header.reorder-source {
background: var(--spectrum-global-color-gray-200);
}
.cell.reorder-target {
border-left-color: var(--spectrum-global-color-blue-400);
}
/* Label cells */
.cell.label {
padding: 0 12px;
border-left-width: 0;
position: sticky;
left: 0;
z-index: 2;
}
.cell.label.header {
z-index: 4;
}
.cell.label :global(span) {
min-width: 14px;
text-align: center;
color: var(--spectrum-global-color-gray-500);
}
.cell.label :global(input[type="checkbox"]) {
margin: 0;
}
/* Spacer cells */
.cell.spacer {
background: none;
border-bottom: none;
}
</style>

View File

@ -0,0 +1,107 @@
<script>
import { getContext } from "svelte"
import SpreadsheetCell from "./SpreadsheetCell.svelte"
import SpacerCell from "./SpacerCell.svelte"
import OptionsCell from "./cells/OptionsCell.svelte"
import DateCell from "./cells/DateCell.svelte"
import MultiSelectCell from "./cells/MultiSelectCell.svelte"
import NumberCell from "./cells/NumberCell.svelte"
import RelationshipCell from "./cells/RelationshipCell.svelte"
import TextCell from "./cells/TextCell.svelte"
export let row
export let rowIdx
const {
selectedCellId,
reorder,
hoveredRowId,
columns,
selectedRows,
changeCache,
spreadsheetAPI,
visibleRows,
} = getContext("spreadsheet")
$: rowSelected = !!$selectedRows[row._id]
$: rowHovered = $hoveredRowId === row._id
$: data = { ...row, ...$changeCache[row._id] }
$: visible = rowIdx >= $visibleRows[0] && rowIdx <= $visibleRows[1]
const getCellForField = field => {
const type = field.schema.type
if (type === "options") {
return OptionsCell
} else if (type === "datetime") {
return DateCell
} else if (type === "array") {
return MultiSelectCell
} else if (type === "number") {
return NumberCell
} else if (type === "link") {
return RelationshipCell
}
return TextCell
}
const selectRow = id => {
selectedRows.update(state => {
state[id] = !state[id]
return state
})
}
</script>
{#if !visible}
<div class="row-placeholder" />
{:else}
<SpreadsheetCell
label
{rowSelected}
{rowHovered}
on:mouseenter={() => ($hoveredRowId = row._id)}
on:click={() => selectRow(row._id)}
>
{#if rowSelected || rowHovered}
<input type="checkbox" checked={rowSelected} />
{:else}
<span>
{rowIdx + 1}
</span>
{/if}
</SpreadsheetCell>
{#each $columns as field, fieldIdx}
{@const cellIdx = `${row._id}-${field.name}`}
{#key cellIdx}
<SpreadsheetCell
{rowSelected}
{rowHovered}
sticky={fieldIdx === 0}
selected={$selectedCellId === cellIdx}
reorderSource={$reorder.columnIdx === fieldIdx}
reorderTarget={$reorder.swapColumnIdx === fieldIdx}
on:mouseenter={() => ($hoveredRowId = row._id)}
on:click={() => ($selectedCellId = cellIdx)}
>
<svelte:component
this={getCellForField(field)}
value={data[field.name]}
schema={field.schema}
selected={$selectedCellId === cellIdx}
onChange={val => spreadsheetAPI.updateValue(row._id, field, val)}
readonly={field.schema.autocolumn}
/>
</SpreadsheetCell>
{/key}
{/each}
<SpacerCell reorderTarget={$reorder.swapColumnIdx === $columns.length} />
{/if}
<style>
.row-placeholder {
height: var(--cell-height);
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
background: var(--cell-background);
grid-column: 1/-1;
}
</style>

View File

@ -0,0 +1,17 @@
<script>
import { getContext } from "svelte"
const { selectedCellId, hoveredRowId } = getContext("spreadsheet")
</script>
<div
on:click={() => ($selectedCellId = null)}
on:mouseenter={() => ($hoveredRowId = null)}
/>
<style>
div {
grid-column: 1/-1;
height: 180px;
}
</style>

View File

@ -34,7 +34,7 @@ export const createResizeStore = context => {
const newWidth = Math.max(MinColumnWidth, $resize.initialWidth + dx)
// Skip small updates
if (Math.abs(width - newWidth) < 10) {
if (Math.abs(width - newWidth) < 20) {
return
}