Rework grid layouts to automatically grow as required

This commit is contained in:
Andrew Kingston 2024-08-09 18:04:23 +01:00
parent 2c9b8ac941
commit aaa33acc1c
No known key found for this signature in database
4 changed files with 183 additions and 79 deletions

View File

@ -1,50 +1,136 @@
<script>
import { getContext } from "svelte"
import { getContext, onMount } from "svelte"
import { writable } from "svelte/store"
import { GridRowHeight, GridColumns } from "constants"
const component = getContext("component")
const { styleable } = getContext("sdk")
const { styleable, builderStore } = getContext("sdk")
const context = getContext("context")
const cols = 12
const rowHeight = 24
let width
let height
let ref
let rows = 1
let children = writable({})
let mounted = false
$: rows = calculateRequiredRows($children, mobile)
$: mobile = $context.device.mobile
$: empty = $component.empty
$: rows = Math.max(1, Math.floor(height / rowHeight))
$: colSize = width / cols
$: rowSize = height / rows
$: colSize = width / GridColumns
$: height = rows * GridRowHeight
// Calculates the minimum number of rows required to render all child
// components, on a certain device type
const calculateRequiredRows = (children, mobile) => {
const key = mobile ? "mobileRowEnd" : "desktopRowEnd"
let max = 2
for (let id of Object.keys(children)) {
if (children[id][key] > max) {
max = children[id][key]
}
}
return max - 1
}
// Stores metadata about a child node as constraints for determining grid size
const storeChild = node => {
children.update(state => ({
...state,
[node.dataset.id]: {
desktopRowEnd: parseInt(node.dataset.gridDesktopRowEnd),
mobileRowEnd: parseInt(node.dataset.gridMobileRowEnd),
},
}))
}
// Removes constraint metadata for a certain child node
const removeChild = node => {
children.update(state => {
delete state[node.dataset.id]
return { ...state }
})
}
onMount(() => {
let observer
if ($builderStore.inBuilder) {
// Set up an observer to watch for changes in metadata attributes of child
// components, as well as child addition and deletion
observer = new MutationObserver(mutations => {
for (let mutation of mutations) {
const { target, type, addedNodes, removedNodes } = mutation
if (target === ref) {
if (addedNodes[0]?.classList?.contains("component")) {
// We've added a new child component inside the grid, so we need
// to consider it when determining required rows
storeChild(addedNodes[0])
} else if (removedNodes[0]?.classList?.contains("component")) {
// We've removed a child component inside the grid, so we need
// to stop considering it when determining required rows
removeChild(removedNodes[0])
}
} else if (
type === "attributes" &&
target.parentNode === ref &&
target.classList.contains("component")
) {
// We've updated the size or position of a child
storeChild(target)
}
}
})
observer.observe(ref, {
childList: true,
attributes: true,
subtree: true,
attributeFilter: [
"data-grid-desktop-row-end",
"data-grid-mobile-row-end",
],
})
}
// Now that the observer is set up, we mark the grid as mounted to mount
// our child components
mounted = true
// Cleanup our observer
return () => {
observer?.disconnect()
}
})
</script>
<div
bind:this={ref}
class="grid"
class:mobile
bind:clientWidth={width}
bind:clientHeight={height}
use:styleable={{
...$component.styles,
normal: {
...$component.styles?.normal,
"--cols": cols,
"--height": `${height}px`,
"--cols": GridColumns,
"--rows": rows,
"--col-size": colSize,
"--row-size": rowSize,
"--row-size": GridRowHeight,
},
empty: false,
}}
data-rows={rows}
data-cols={cols}
data-cols={GridColumns}
data-col-size={colSize}
>
<div class="underlay">
{#each { length: cols * rows } as _}
<div class="placeholder" />
{/each}
</div>
{#if $builderStore.inBuilder}
<div class="underlay">
{#each { length: GridColumns * rows } as _, idx}
<div class="placeholder" class:first-col={idx % GridColumns === 0} />
{/each}
</div>
{/if}
<!-- Only render the slot if not empty, as we don't want the placeholder -->
{#if !empty}
{#if !empty && mounted}
<slot />
{/if}
</div>
@ -52,7 +138,6 @@
<style>
.grid {
position: relative;
height: 400px;
/*
Prevent cross-grid variable inheritance. The other variables for alignment
@ -71,8 +156,11 @@
.grid,
.underlay {
height: var(--height) !important;
min-height: none !important;
max-height: none !important;
display: grid;
grid-template-rows: repeat(var(--rows), 1fr);
grid-template-rows: repeat(var(--rows), calc(var(--row-size) * 1px));
grid-template-columns: repeat(var(--cols), 1fr);
gap: 0;
}
@ -84,7 +172,6 @@
width: 100%;
height: 100%;
border-top: 1px solid var(--spectrum-global-color-gray-900);
border-left: 1px solid var(--spectrum-global-color-gray-900);
opacity: 0.1;
pointer-events: none;
}
@ -95,6 +182,9 @@
border-bottom: 1px solid var(--spectrum-global-color-gray-900);
border-right: 1px solid var(--spectrum-global-color-gray-900);
}
.placeholder.first-col {
border-left: 1px solid var(--spectrum-global-color-gray-900);
}
/* Highlight grid lines when resizing children */
:global(.grid.highlight > .underlay) {
@ -131,20 +221,10 @@
)
)
);
/* Row end is always provided by the gridLayout action */
--row-start: var(--grid-desktop-row-start, var(--grid-mobile-row-start, 1));
--row-end: var(
--grid-desktop-row-end,
var(
--grid-mobile-row-end,
round(
up,
calc(
(var(--grid-spacing) * 2 + var(--default-height)) / var(--row-size) +
1
)
)
)
);
--row-end: var(--grid-desktop-row-end, var(--grid-mobile-row-end));
/* Flex vars */
--h-align: var(--grid-desktop-h-align, var(--grid-mobile-h-align));
@ -156,8 +236,8 @@
max(2, var(--col-end)),
calc(var(--cols) + 1)
) !important;
grid-row-start: min(max(1, var(--row-start)), var(--rows)) !important;
grid-row-end: min(max(2, var(--row-end)), calc(var(--rows) + 1)) !important;
grid-row-start: max(1, var(--row-start)) !important;
grid-row-end: max(2, var(--row-end)) !important;
/* Flex container styles */
flex-direction: column;
@ -182,18 +262,7 @@
)
);
--row-start: var(--grid-mobile-row-start, var(--grid-desktop-row-start, 1));
--row-end: var(
--grid-mobile-row-end,
var(
--grid-desktop-row-end,
round(
up,
calc(
(var(--spacing) * 2 + var(--default-height)) / var(--row-size) + 1
)
)
)
);
--row-end: var(--grid-mobile-row-end, var(--grid-desktop-row-end));
/* Flex vars */
--h-align: var(--grid-mobile-h-align, var(--grid-desktop-h-align));

View File

@ -2,6 +2,7 @@
import { onMount, onDestroy } from "svelte"
import { builderStore, componentStore } from "stores"
import { Utils, memo } from "@budibase/frontend-core"
import { GridRowHeight } from "constants"
import {
isGridEvent,
getGridParent,
@ -15,8 +16,8 @@
"data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
let dragInfo
let styles = memo()
let gridStyles = memo()
let id
// Grid CSS variables
$: device = $builderStore.previewDevice
@ -28,12 +29,14 @@
}
// Some memoisation of primitive types for performance
$: id = dragInfo?.id || id
$: id = dragInfo?.id
$: gridId = dragInfo?.gridId
// Set ephemeral grid styles on the dragged component
// Set ephemeral styles
$: instance = componentStore.actions.getComponentInstance(id)
$: componentStyles = getComponentStyles($gridStyles)
$: $instance?.setEphemeralStyles(componentStyles)
$: gridInstance = componentStore.actions.getComponentInstance(gridId)
$: $instance?.setEphemeralStyles(enrichComponentStyles($styles))
$: $gridInstance?.setEphemeralStyles($gridStyles)
// Sugar for a combination of both min and max
const minMax = (value, min, max) => Math.min(max, Math.max(min, value))
@ -44,47 +47,41 @@
return Array.from(component?.children || [])[0]
}
const getComponentStyles = gridStyles => {
let styles = { ...gridStyles }
if (gridStyles) {
styles["z-index"] = 999
styles["pointer-events"] = "none"
const enrichComponentStyles = styles => {
let clone = { ...styles }
if (styles) {
clone["z-index"] = 999
clone["pointer-events"] = "none"
}
return styles
return clone
}
const processEvent = Utils.throttle((mouseX, mouseY) => {
if (!dragInfo?.grid) {
return
}
const { mode, side, gridId, grid } = dragInfo
const { startX, startY, rowStart, rowEnd, colStart, colEnd } = grid
const domGrid = getDOMNode(gridId)
if (!domGrid) {
return
}
const cols = parseInt(domGrid.dataset.cols)
const rows = parseInt(domGrid.dataset.rows)
const { width, height } = domGrid.getBoundingClientRect()
const colWidth = width / cols
const colSize = parseInt(domGrid.dataset.colSize)
const diffX = mouseX - startX
let deltaX = Math.round(diffX / colWidth)
const rowHeight = height / rows
let deltaX = Math.round(diffX / colSize)
const diffY = mouseY - startY
let deltaY = Math.round(diffY / rowHeight)
let deltaY = Math.round(diffY / GridRowHeight)
if (mode === "move") {
deltaX = minMax(deltaX, 1 - colStart, cols + 1 - colEnd)
deltaY = minMax(deltaY, 1 - rowStart, rows + 1 - rowEnd)
deltaY = Math.max(deltaY, 1 - rowStart)
const newStyles = {
[vars.colStart]: colStart + deltaX,
[vars.colEnd]: colEnd + deltaX,
[vars.rowStart]: rowStart + deltaY,
[vars.rowEnd]: rowEnd + deltaY,
}
gridStyles.set(newStyles)
styles.set(newStyles)
} else if (mode === "resize") {
let newStyles = {}
if (side === "right") {
@ -108,7 +105,7 @@
newStyles[vars.colStart] = Math.min(colStart + deltaX, colEnd - 1)
newStyles[vars.rowStart] = Math.min(rowStart + deltaY, rowEnd - 1)
}
gridStyles.set(newStyles)
styles.set(newStyles)
}
}, 10)
@ -180,15 +177,14 @@
return
}
const gridCols = parseInt(domGrid.dataset.cols)
const gridRows = parseInt(domGrid.dataset.rows)
const styles = getComputedStyle(domComponent.parentNode)
dragInfo.grid = {
startX: e.clientX,
startY: e.clientY,
// Ensure things are within limits
rowStart: minMax(styles["grid-row-start"], 1, gridRows),
rowEnd: minMax(styles["grid-row-end"], 2, gridRows + 1),
rowStart: Math.max(styles["grid-row-start"], 1),
rowEnd: Math.max(styles["grid-row-end"], 2),
colStart: minMax(styles["grid-column-start"], 1, gridCols),
colEnd: minMax(styles["grid-column-end"], 2, gridCols + 1),
}
@ -210,8 +206,11 @@
const { id, gridId, domTarget } = dragInfo
// Save changes
if ($styles) {
await builderStore.actions.updateStyles($styles, id)
}
if ($gridStyles) {
await builderStore.actions.updateStyles($gridStyles, id)
await builderStore.actions.updateStyles($gridStyles, gridId)
}
// Reset DOM
@ -227,9 +226,22 @@
// Reset state
dragInfo = null
styles.set(null)
gridStyles.set(null)
}
const calculateRequiredRows = () => {
let required = 1
const children = document.querySelectorAll(`.${gridId}-dom > .component`)
for (let child of children) {
const rowEnd = child.dataset.grid_desktop_col_end
if (rowEnd > required) {
required = rowEnd
}
}
return required - 1
}
onMount(() => {
document.addEventListener("dragstart", onDragStart, false)
document.addEventListener("dragenter", onDragEnter, false)

View File

@ -15,3 +15,6 @@ export const ActionTypes = {
export const DNDPlaceholderID = "dnd-placeholder"
export const ScreenslotType = "screenslot"
export const GridRowHeight = 24
export const GridColumns = 12
export const GridSpacing = 4

View File

@ -1,3 +1,5 @@
import { GridSpacing } from "constants"
import { GridRowHeight } from "constants"
import { builderStore } from "stores"
import { buildStyleString } from "utils/styleable.js"
@ -84,15 +86,23 @@ export const gridLayout = (node, metadata) => {
}
// Generate base set of grid CSS vars based for this component
let width = errored ? 500 : definition.size?.width || 200
let height = errored ? 60 : definition.size?.height || 200
width += 2 * GridSpacing
height += 2 * GridSpacing
const hAlign = errored ? "stretch" : definition?.grid?.hAlign || "stretch"
const vAlign = errored ? "stretch" : definition?.grid?.vAlign || "center"
const vars = {
"--default-width": errored ? 500 : definition.size?.width || 200,
"--default-height": errored ? 60 : definition.size?.height || 200,
"--default-width": width,
"--default-height": height,
"--grid-desktop-h-align": hAlign,
"--grid-mobile-h-align": hAlign,
"--grid-desktop-v-align": vAlign,
"--grid-mobile-v-align": vAlign,
// Variables for automatically determining grid height
"--grid-desktop-row-end": Math.ceil(height / GridRowHeight) + 1,
"--grid-mobile-row-end": Math.ceil(height / GridRowHeight) + 1,
}
// Extract any other CSS variables from the saved component styles
@ -103,6 +113,16 @@ export const gridLayout = (node, metadata) => {
}
}
// Apply some metadata to data attributes to speed up lookups
const desktopRowEnd = `${vars["--grid-desktop-row-end"]}`
const mobileRowEnd = `${vars["--grid-mobile-row-end"]}`
if (node.dataset.gridDesktopRowEnd !== desktopRowEnd) {
node.dataset.gridDesktopRowEnd = desktopRowEnd
}
if (node.dataset.gridMobileRowEnd !== mobileRowEnd) {
node.dataset.gridMobileRowEnd = mobileRowEnd
}
// Apply all CSS variables to the wrapper
node.style = buildStyleString(vars)