Optimise virtual rendering for both columns and rows to handle infinitely large datasets

This commit is contained in:
Andrew Kingston 2023-02-24 13:53:23 +00:00
parent 3ab0e95032
commit 4d3f669ae7
6 changed files with 251 additions and 151 deletions

View File

@ -10,8 +10,6 @@
import SpreadsheetHeader from "./SpreadsheetHeader.svelte" import SpreadsheetHeader from "./SpreadsheetHeader.svelte"
import SpreadsheetBody from "./SpreadsheetBody.svelte" import SpreadsheetBody from "./SpreadsheetBody.svelte"
import SpreadsheetCell from "./SpreadsheetCell.svelte" import SpreadsheetCell from "./SpreadsheetCell.svelte"
import SpacerCell from "./SpacerCell.svelte"
import VerticalSpacer from "./VerticalSpacer.svelte"
import SpreadsheetRow from "./SpreadsheetRow.svelte" import SpreadsheetRow from "./SpreadsheetRow.svelte"
export let table export let table
@ -37,7 +35,7 @@
const tableId = writable(table?.tableId) const tableId = writable(table?.tableId)
const changeCache = writable({}) const changeCache = writable({})
const newRows = writable([]) const newRows = writable([])
const visibleRows = writable([0, 0]) const visibleCells = writable({ y: [0, 0], x: [0, 0] })
// Build up spreadsheet context and additional stores // Build up spreadsheet context and additional stores
const context = { const context = {
@ -51,7 +49,7 @@
changeCache, changeCache,
newRows, newRows,
cellHeight, cellHeight,
visibleRows, visibleCells,
} }
const { reorder, reorderPlaceholder } = createReorderStores(context) const { reorder, reorderPlaceholder } = createReorderStores(context)
const resize = createResizeStore(context) const resize = createResizeStore(context)
@ -68,7 +66,8 @@
$: generateColumns($fetch) $: generateColumns($fetch)
$: rowCount = $rows.length $: rowCount = $rows.length
$: selectedRowCount = Object.values($selectedRows).filter(x => !!x).length $: selectedRowCount = Object.values($selectedRows).filter(x => !!x).length
$: updateSortedRows($fetch.rows, $newRows) $: updateSortedRows($fetch, $newRows)
$: visibleRows = $rows.slice($visibleCells.y[0], $visibleCells.y[1])
const createFetch = datasource => { const createFetch = datasource => {
return fetchData({ return fetchData({
@ -92,13 +91,16 @@
if (primaryDisplay) { if (primaryDisplay) {
fields = [primaryDisplay, ...fields.filter(x => x !== primaryDisplay)] fields = [primaryDisplay, ...fields.filter(x => x !== primaryDisplay)]
} }
$columns = fields.map((field, idx) => ({ $columns = fields.map((field, idx) => {
name: field, return {
width: defaultWidth, idx,
left: 40 + idx * defaultWidth, name: field,
schema: schema[field], width: defaultWidth,
primaryDisplay: field === primaryDisplay, left: 40 + idx * defaultWidth,
})) schema: schema[field],
primaryDisplay: field === primaryDisplay,
}
})
} }
} }
@ -157,13 +159,17 @@
} }
const updateSortedRows = (unsortedRows, newRows) => { const updateSortedRows = (unsortedRows, newRows) => {
let sortedRows = unsortedRows.slice() let foo = unsortedRows.rows
sortedRows.sort((a, b) => { for (let i = 0; i < 10; i++) {
const aIndex = newRows.indexOf(a._id) foo = foo.concat(foo.map(x => ({ ...x, _id: x._id + "x" })))
const bIndex = newRows.indexOf(b._id) }
return aIndex < bIndex ? -1 : 1 // let sortedRows = foo.slice()
}) // sortedRows.sort((a, b) => {
$rows = sortedRows // const aIndex = newRows.indexOf(a._id)
// const bIndex = newRows.indexOf(b._id)
// return aIndex < bIndex ? -1 : 1
// })
$rows = foo.slice()
} }
// API for children to consume // API for children to consume
@ -180,25 +186,18 @@
resize, resize,
spreadsheetAPI, spreadsheetAPI,
}) })
let sheetStyles = ""
let left = 40
for (let i = 0; i < 20; i++) {
sheetStyles += `--col-${i}-width:${160}px; --col-${i}-left:${left}px;`
left += 160
}
</script> </script>
<div use:styleable={$component.styles}> <div use:styleable={$component.styles}>
<div <div
class="wrapper" class="wrapper"
class:resize={$resize.columnIdx != null} class:resize={$resize.columnIdx != null}
style="--cell-height:{cellHeight}px;{sheetStyles}" style="--cell-height:{cellHeight}px;"
id="sheet-{rand}" id="sheet-{rand}"
> >
<SpreadsheetHeader /> <SpreadsheetHeader />
<SpreadsheetBody> <SpreadsheetBody>
<div class="row" style="top: 0;"> <div class="row">
<!-- Field headers --> <!-- Field headers -->
<SpreadsheetCell header label on:click={selectAll} width="40" left="0"> <SpreadsheetCell header label on:click={selectAll} width="40" left="0">
<input <input
@ -213,10 +212,8 @@
reorderSource={$reorder.columnIdx === fieldIdx} reorderSource={$reorder.columnIdx === fieldIdx}
reorderTarget={$reorder.swapColumnIdx === fieldIdx} reorderTarget={$reorder.swapColumnIdx === fieldIdx}
on:mousedown={e => reorder.actions.startReordering(fieldIdx, e)} on:mousedown={e => reorder.actions.startReordering(fieldIdx, e)}
id={`sheet-${rand}-header-${fieldIdx}`}
width={field.width} width={field.width}
left={field.left} left={field.left}
column={fieldIdx}
> >
<Icon <Icon
size="S" size="S"
@ -232,12 +229,12 @@
</div> </div>
<!-- All real rows --> <!-- All real rows -->
{#each $rows as row, rowIdx (row._id)} {#each visibleRows as row, rowIdx (row._id)}
<SpreadsheetRow {row} {rowIdx} /> <SpreadsheetRow {row} rowIdx={rowIdx + $visibleCells.y[0]} />
{/each} {/each}
<!-- New row placeholder --> <!-- New row placeholder -->
<div class="row" style="top:{($rows.length + 1) * cellHeight}px;"> <div class="row new" style="--top:{($rows.length + 1) * cellHeight}px;">
<SpreadsheetCell <SpreadsheetCell
label label
on:click={addRow} on:click={addRow}
@ -295,6 +292,12 @@
.row { .row {
display: flex; display: flex;
position: sticky; position: sticky;
width: 100%; top: 0;
width: inherit;
z-index: 4;
}
.row.new {
position: absolute;
transform: translateY(var(--top));
} }
</style> </style>

View File

@ -1,57 +1,85 @@
<script> <script>
import { getContext, onMount } from "svelte" import { getContext, onMount } from "svelte"
import { Utils } from "@budibase/frontend-core"
const { columns, selectedCellId, rand, visibleRows, cellHeight, rows } = const { columns, selectedCellId, rand, visibleCells, cellHeight, rows } =
getContext("spreadsheet") getContext("spreadsheet")
const padding = 180
let ref let ref
let height = 600 let width
let horizontallyScrolled = false let height
let scrollLeft = 0
let scrollTop = 0 let scrollTop = 0
$: gridStyles = getGridStyles($columns) $: computeVisibleCells($columns, scrollLeft, scrollTop, width, height)
$: computeVisibleRows(scrollTop, height) $: contentHeight = ($rows.length + 2) * cellHeight + padding
$: contentHeight = ($rows.length + 2) * cellHeight + 180 $: contentWidth = computeContentWidth($columns)
$: contentWidth = computeWidth($columns) $: horizontallyScrolled = scrollLeft > 0
$: console.log("new height")
const computeWidth = columns => { const computeContentWidth = columns => {
console.log("width") let total = 40 + padding
let width = 220
columns.forEach(col => { columns.forEach(col => {
width += col.width total += col.width
}) })
return width return total
}
const getGridStyles = columns => {
console.log("grid")
const widths = columns?.map(x => x.width)
if (!widths?.length) {
return "--grid: 1fr;"
}
return `--grid: 40px ${widths.map(x => `${x}px`).join(" ")} 180px;`
} }
// Store the current scroll position // Store the current scroll position
const handleScroll = e => { const handleScroll = e => {
// Update horizontally scrolled flag // Only update scroll offsets when a sizable change happens
horizontallyScrolled = e.target.scrollLeft > 0 if (Math.abs(scrollTop - e.target.scrollTop) > 10) {
scrollTop = e.target.scrollTop
// Only update scroll top offset when a sizable change happens }
scrollTop = e.target.scrollTop if (Math.abs(scrollLeft - e.target.scrollLeft) > 10) {
scrollLeft = e.target.scrollLeft
}
if (e.target.scrollLeft === 0) {
scrollLeft = 0
}
} }
const computeVisibleRows = (scrollTop, height) => { const computeVisibleCells = (
columns,
scrollLeft,
scrollTop,
width,
height
) => {
if (!columns.length) {
return
}
// Compute row visibility
const rows = Math.ceil(height / cellHeight) + 8 const rows = Math.ceil(height / cellHeight) + 8
const firstRow = Math.max(0, Math.floor(scrollTop / cellHeight) - 4) const firstRow = Math.max(0, Math.floor(scrollTop / cellHeight) - 4)
visibleRows.set([firstRow, firstRow + rows]) const visibleRows = [firstRow, firstRow + rows]
// Compute column visibility
let startColIdx = 1
let rightEdge = columns[1].width
while (rightEdge < scrollLeft) {
startColIdx++
rightEdge += columns[startColIdx].width
}
let endColIdx = startColIdx + 1
let leftEdge = columns[0].width + 40 + rightEdge
while (leftEdge < width + scrollLeft) {
leftEdge += columns[endColIdx]?.width
endColIdx++
}
const visibleColumns = [Math.max(1, startColIdx - 2), endColIdx + 2]
visibleCells.set({
x: visibleColumns,
y: visibleRows,
})
} }
// Observe and record the height of the body
onMount(() => { onMount(() => {
// Observe and record the height of the body
const observer = new ResizeObserver(entries => { const observer = new ResizeObserver(entries => {
width = entries[0].contentRect.width
height = entries[0].contentRect.height height = entries[0].contentRect.height
}) })
observer.observe(ref) observer.observe(ref)
@ -59,6 +87,16 @@
observer.disconnect() observer.disconnect()
} }
}) })
let sheetStyles = ""
let left = 0
for (let i = 0; i < 20; i++) {
if (i === 1) {
left += 40
}
sheetStyles += `--col-${i}-width:${160}px; --col-${i}-left:${left}px;`
left += 160
}
</script> </script>
<div <div
@ -68,6 +106,7 @@
on:scroll={handleScroll} on:scroll={handleScroll}
on:click|self={() => ($selectedCellId = null)} on:click|self={() => ($selectedCellId = null)}
id={`sheet-${rand}-body`} id={`sheet-${rand}-body`}
style={sheetStyles}
> >
<div <div
class="content" class="content"
@ -86,8 +125,20 @@
cursor: default; cursor: default;
} }
.content { .content {
background: rgba(255, 0, 0, 0.1); min-width: 100%;
min-height: 100%;
} }
/* Add shadow to sticky cells when horizontally scrolled */
.horizontally-scrolled :global(.cell.sticky) {
border-right-width: 1px;
}
.horizontally-scrolled :global(.cell.sticky:after) {
content: " ";
position: absolute;
width: 10px;
left: 100%;
height: 100%;
background: linear-gradient(to right, rgba(0, 0, 0, 0.08), transparent);
}
</style> </style>

View File

@ -8,8 +8,8 @@
export let selected = false export let selected = false
export let reorderSource = false export let reorderSource = false
export let reorderTarget = false export let reorderTarget = false
export let id = null export let left
export let column export let width
</script> </script>
<div <div
@ -27,8 +27,7 @@
on:mouseenter on:mouseenter
on:click on:click
on:mousedown on:mousedown
{id} style="--left: {left}px; --width:{width}px;"
style={`width:var(--col-${column}-width); left:var(--col-${column}-left);`}
> >
<slot /> <slot />
</div> </div>
@ -52,6 +51,8 @@
background: var(--cell-background); background: var(--cell-background);
position: absolute; position: absolute;
transition: border-color 130ms ease-out; transition: border-color 130ms ease-out;
width: var(--width);
transform: translateX(var(--left));
} }
.cell.row-hovered { .cell.row-hovered {
background: var(--cell-background-hover); background: var(--cell-background-hover);
@ -88,9 +89,10 @@
/* Sticky styles */ /* Sticky styles */
.cell.sticky { .cell.sticky {
position: sticky; position: sticky;
left: 40px;
z-index: 2; z-index: 2;
border-left-color: transparent; border-left-color: transparent;
transform: none;
left: 40px;
} }
.cell.sticky.selected { .cell.sticky.selected {
z-index: 3; z-index: 3;
@ -112,6 +114,7 @@
/* Label cells */ /* Label cells */
.cell.label { .cell.label {
width: 40px;
padding: 0 12px; padding: 0 12px;
border-left-width: 0; border-left-width: 0;
position: sticky; position: sticky;

View File

@ -20,14 +20,18 @@
selectedRows, selectedRows,
changeCache, changeCache,
spreadsheetAPI, spreadsheetAPI,
visibleRows, visibleCells,
cellHeight, cellHeight,
} = getContext("spreadsheet") } = getContext("spreadsheet")
$: rowSelected = !!$selectedRows[row._id] $: rowSelected = !!$selectedRows[row._id]
$: rowHovered = $hoveredRowId === row._id $: rowHovered = $hoveredRowId === row._id
$: data = { ...row, ...$changeCache[row._id] } $: data = { ...row, ...$changeCache[row._id] }
$: visible = rowIdx >= $visibleRows[0] && rowIdx <= $visibleRows[1] $: visibleColumns = [
$columns[0],
...$columns.slice($visibleCells.x[0], $visibleCells.x[1]),
]
$: containsSelectedCell = $selectedCellId?.split("-")[0] === row._id
const getCellForField = field => { const getCellForField = field => {
const type = field.schema.type const type = field.schema.type
@ -53,64 +57,66 @@
} }
</script> </script>
{#if visible} <div
<div class="row" style="--top:{(rowIdx + 1) * cellHeight}px;"> class="row"
style="--top:{(rowIdx + 1) * cellHeight}px;"
class:contains-selected-cell={containsSelectedCell}
>
<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 visibleColumns as column (column.name)}
{@const cellIdx = `${row._id}-${column.name}`}
<SpreadsheetCell <SpreadsheetCell
label
{rowSelected} {rowSelected}
{rowHovered} {rowHovered}
sticky={column.idx === 0}
selected={$selectedCellId === cellIdx}
reorderSource={$reorder.columnIdx === column.idx}
reorderTarget={$reorder.swapColumnIdx === column.idx}
on:mouseenter={() => ($hoveredRowId = row._id)} on:mouseenter={() => ($hoveredRowId = row._id)}
on:click={() => selectRow(row._id)} on:click={() => ($selectedCellId = cellIdx)}
width="40" width={column.width}
left="0" left={column.left}
column={column.idx}
> >
{#if rowSelected || rowHovered} <svelte:component
<input type="checkbox" checked={rowSelected} /> this={getCellForField(column)}
{:else} value={data[column.name]}
<span> schema={column.schema}
{rowIdx + 1}
</span>
{/if}
</SpreadsheetCell>
{#each $columns as field, fieldIdx}
{@const cellIdx = `${row._id}-${field.name}`}
<SpreadsheetCell
{rowSelected}
{rowHovered}
sticky={fieldIdx === 0}
selected={$selectedCellId === cellIdx} selected={$selectedCellId === cellIdx}
reorderSource={$reorder.columnIdx === fieldIdx} onChange={val => spreadsheetAPI.updateValue(row._id, column, val)}
reorderTarget={$reorder.swapColumnIdx === fieldIdx} readonly={column.schema.autocolumn}
on:mouseenter={() => ($hoveredRowId = row._id)} />
on:click={() => ($selectedCellId = cellIdx)} </SpreadsheetCell>
width={field.width} {/each}
left={field.left} </div>
column={fieldIdx}
>
<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>
{/each}
</div>
{/if}
<style> <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;
}
.row { .row {
display: flex; display: flex;
position: absolute; position: absolute;
top: var(--top); top: 0;
width: 100%; transform: translateY(var(--top));
width: inherit;
}
.row.contains-selected-cell {
z-index: 1;
}
.row :global(>:last-child) {
border-right-width: 1px;
} }
</style> </style>

View File

@ -27,17 +27,16 @@ export const createReorderStores = context => {
let breakpoints = [] let breakpoints = []
const cols = get(columns) const cols = get(columns)
cols.forEach((col, idx) => { cols.forEach((col, idx) => {
const header = document.getElementById(`sheet-${rand}-header-${idx}`) breakpoints.push(col.left)
const bounds = header.getBoundingClientRect()
breakpoints.push(bounds.x)
if (idx === cols.length - 1) { if (idx === cols.length - 1) {
breakpoints.push(bounds.x + bounds.width) breakpoints.push(col.left + col.width)
} }
}) })
console.log(breakpoints, e.clientX)
// Get bounds of the selected header and sheet body // Get bounds of the selected header and sheet body
const self = document.getElementById(`sheet-${rand}-header-${columnIdx}`)
const selfBounds = self.getBoundingClientRect() const self = cols[columnIdx]
const body = document.getElementById(`sheet-${rand}-body`) const body = document.getElementById(`sheet-${rand}-body`)
const bodyBounds = body.getBoundingClientRect() const bodyBounds = body.getBoundingClientRect()
@ -49,9 +48,9 @@ export const createReorderStores = context => {
initialMouseX: e.clientX, initialMouseX: e.clientX,
}) })
placeholder.set({ placeholder.set({
initialX: selfBounds.x - bodyBounds.x, initialX: self.left - bodyBounds.x,
x: selfBounds.x - bodyBounds.x, x: self.left - bodyBounds.x,
width: selfBounds.width, width: self.width,
height: (get(rows).length + 2) * 32, height: (get(rows).length + 2) * 32,
}) })
@ -103,13 +102,15 @@ export const createReorderStores = context => {
const stopReordering = () => { const stopReordering = () => {
// Swap position of columns // Swap position of columns
let { columnIdx, swapColumnIdx } = get(reorder) let { columnIdx, swapColumnIdx } = get(reorder)
const newColumns = get(columns).slice() columns.update(state => {
const removed = newColumns.splice(columnIdx, 1) const removed = state.splice(columnIdx, 1)
if (--swapColumnIdx < columnIdx) { if (--swapColumnIdx < columnIdx) {
swapColumnIdx++ swapColumnIdx++
} }
newColumns.splice(swapColumnIdx, 0, removed[0]) state.splice(swapColumnIdx, 0, removed[0])
columns.set(newColumns) state = state.map((col, idx) => ({ ...col, idx }))
return state
})
// Reset state // Reset state
reorder.set(reorderInitialState) reorder.set(reorderInitialState)

View File

@ -23,17 +23,16 @@ export const createResizeStore = context => {
const resize = writable(initialState) const resize = writable(initialState)
const startResizing = (idx, e) => { const startResizing = (idx, e) => {
const $columns = get(columns)
// Prevent propagation to stop reordering triggering // Prevent propagation to stop reordering triggering
e.stopPropagation() e.stopPropagation()
sheet = document.getElementById(`sheet-${rand}`) width = $columns[idx].width
styles = getComputedStyle(sheet) left = $columns[idx].left
width = parseInt(styles.getPropertyValue(`--col-${idx}-width`))
left = parseInt(styles.getPropertyValue(`--col-${idx}-left`))
initialWidth = width initialWidth = width
initialMouseX = e.clientX initialMouseX = e.clientX
columnIdx = idx columnIdx = idx
columnCount = get(columns).length columnCount = $columns.length
// Add mouse event listeners to handle resizing // Add mouse event listeners to handle resizing
document.addEventListener("mousemove", onResizeMouseMove) document.addEventListener("mousemove", onResizeMouseMove)
@ -48,17 +47,54 @@ export const createResizeStore = context => {
return return
} }
columns.update(state => {
state[columnIdx].width = newWidth
let offset = state[columnIdx].left + newWidth
for (let i = columnIdx + 1; i < state.length; i++) {
state[i].left = offset
offset += state[i].width
}
return state
})
// let newStyle = `--col-${columnIdx}-width:${newWidth}px;`
//
// let offset = left + newWidth
// for (let i = columnIdx + 1; i < columnCount; i++) {
// const colWidth = parseInt(styles.getPropertyValue(`--col-${i}-width`))
// newStyle += `--col-${i}-left:${offset}px;`
// offset += colWidth
// }
//
// sheet.style.cssText += newStyle
// let cells = sheet.querySelectorAll(`[data-col="${columnIdx}"]`)
// let left
// cells.forEach(cell => {
// cell.style.width = `${newWidth}px`
// cell.dataset.width = newWidth
// if (!left) {
// left = parseInt(cell.dataset.left)
// }
// })
//
// let offset = left + newWidth
// for (let i = columnIdx + 1; i < columnCount; i++) {
// cells = sheet.querySelectorAll(`[data-col="${i}"]`)
// let colWidth
// cells.forEach(cell => {
// cell.style.transform = `translateX(${offset}px)`
// cell.dataset.left = offset
// if (!colWidth) {
// colWidth = parseInt(cell.dataset.width)
// }
// })
// offset += colWidth
// }
let newStyle = `--col-${columnIdx}-width:${newWidth}px;`
let offset = left + newWidth
for (let i = columnIdx + 1; i < columnCount; i++) {
const colWidth = 160//parseInt(styles.getPropertyValue(`--col-${i}-width`))
newStyle += `--col-${i}-left:${offset}px;`
offset += colWidth
}
sheet.style.cssText += newStyle
width = newWidth width = newWidth
// Update width of column // Update width of column