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

View File

@ -1,57 +1,85 @@
<script>
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")
const padding = 180
let ref
let height = 600
let horizontallyScrolled = false
let width
let height
let scrollLeft = 0
let scrollTop = 0
$: gridStyles = getGridStyles($columns)
$: computeVisibleRows(scrollTop, height)
$: contentHeight = ($rows.length + 2) * cellHeight + 180
$: contentWidth = computeWidth($columns)
$: console.log("new height")
$: computeVisibleCells($columns, scrollLeft, scrollTop, width, height)
$: contentHeight = ($rows.length + 2) * cellHeight + padding
$: contentWidth = computeContentWidth($columns)
$: horizontallyScrolled = scrollLeft > 0
const computeWidth = columns => {
console.log("width")
let width = 220
const computeContentWidth = columns => {
let total = 40 + padding
columns.forEach(col => {
width += col.width
total += col.width
})
return width
}
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;`
return total
}
// 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
// Only update scroll offsets when a sizable change happens
if (Math.abs(scrollTop - e.target.scrollTop) > 10) {
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 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(() => {
// Observe and record the height of the body
const observer = new ResizeObserver(entries => {
width = entries[0].contentRect.width
height = entries[0].contentRect.height
})
observer.observe(ref)
@ -59,6 +87,16 @@
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>
<div
@ -68,6 +106,7 @@
on:scroll={handleScroll}
on:click|self={() => ($selectedCellId = null)}
id={`sheet-${rand}-body`}
style={sheetStyles}
>
<div
class="content"
@ -86,8 +125,20 @@
cursor: default;
}
.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>

View File

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

View File

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

View File

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

View File

@ -23,17 +23,16 @@ export const createResizeStore = context => {
const resize = writable(initialState)
const startResizing = (idx, e) => {
const $columns = get(columns)
// Prevent propagation to stop reordering triggering
e.stopPropagation()
sheet = document.getElementById(`sheet-${rand}`)
styles = getComputedStyle(sheet)
width = parseInt(styles.getPropertyValue(`--col-${idx}-width`))
left = parseInt(styles.getPropertyValue(`--col-${idx}-left`))
width = $columns[idx].width
left = $columns[idx].left
initialWidth = width
initialMouseX = e.clientX
columnIdx = idx
columnCount = get(columns).length
columnCount = $columns.length
// Add mouse event listeners to handle resizing
document.addEventListener("mousemove", onResizeMouseMove)
@ -48,17 +47,54 @@ export const createResizeStore = context => {
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
// Update width of column