335 lines
9.9 KiB
Svelte
335 lines
9.9 KiB
Svelte
<script>
|
|
import { onMount, onDestroy } from "svelte"
|
|
import { get } from "svelte/store"
|
|
import IndicatorSet from "./IndicatorSet.svelte"
|
|
import { builderStore, screenStore, dndStore, dndParent } from "stores"
|
|
import DNDPlaceholderOverlay from "./DNDPlaceholderOverlay.svelte"
|
|
import { Utils } from "@budibase/frontend-core"
|
|
import { findComponentById } from "utils/components.js"
|
|
import { DNDPlaceholderID } from "constants"
|
|
|
|
const ThrottleRate = 130
|
|
|
|
// Cache some dnd store state as local variables as it massively helps
|
|
// performance. It lets us avoid calling svelte getters on every DOM action.
|
|
$: source = $dndStore.source
|
|
$: target = $dndStore.target
|
|
$: drop = $dndStore.drop
|
|
|
|
const insideGrid = e => {
|
|
return e.target?.closest?.(".grid") != null
|
|
}
|
|
|
|
// Util to get the inner DOM node by a component ID
|
|
const getDOMNode = id => {
|
|
const component = document.getElementsByClassName(id)[0]
|
|
return [...component.children][0]
|
|
}
|
|
|
|
// Util to calculate the variance of a set of data
|
|
const variance = arr => {
|
|
const mean = arr.reduce((a, b) => a + b, 0) / arr.length
|
|
let squareSum = 0
|
|
arr.forEach(value => {
|
|
const delta = value - mean
|
|
squareSum += delta * delta
|
|
})
|
|
return squareSum / arr.length
|
|
}
|
|
|
|
// Callback when drag stops (whether dropped or not)
|
|
const stopDragging = () => {
|
|
// Reset listener
|
|
if (source?.id) {
|
|
const component = document.getElementsByClassName(source?.id)[0]
|
|
if (component) {
|
|
component.removeEventListener("dragend", stopDragging)
|
|
}
|
|
}
|
|
|
|
// Reset state
|
|
dndStore.actions.reset()
|
|
builderStore.actions.setDragging(false)
|
|
}
|
|
|
|
// Callback when initially starting a drag on a draggable component
|
|
const onDragStart = e => {
|
|
if (insideGrid(e)) {
|
|
return
|
|
}
|
|
const component = e.target.closest(".component")
|
|
if (!component?.classList.contains("draggable")) {
|
|
return
|
|
}
|
|
|
|
// Hide drag ghost image
|
|
e.dataTransfer.setDragImage(new Image(), 0, 0)
|
|
|
|
// Add event handler to clear all drag state when dragging ends
|
|
component.addEventListener("dragend", stopDragging)
|
|
|
|
// Update state
|
|
const id = component.dataset.id
|
|
const parentId = component.dataset.parent
|
|
const parent = findComponentById(
|
|
get(screenStore).activeScreen?.props,
|
|
parentId
|
|
)
|
|
const index = parent._children.findIndex(
|
|
x => x._id === component.dataset.id
|
|
)
|
|
dndStore.actions.startDraggingExistingComponent({
|
|
id,
|
|
bounds: component.children[0].getBoundingClientRect(),
|
|
parent: parentId,
|
|
index,
|
|
})
|
|
builderStore.actions.selectComponent(id)
|
|
builderStore.actions.setDragging(true)
|
|
|
|
// Set initial drop info to show placeholder exactly where the dragged
|
|
// component is.
|
|
// Execute this asynchronously to prevent bugs caused by updating state in
|
|
// the same handler as selecting a new component (which causes a client
|
|
// re-initialisation).
|
|
setTimeout(() => {
|
|
dndStore.actions.updateDrop({
|
|
parent: parentId,
|
|
index,
|
|
})
|
|
}, 0)
|
|
}
|
|
|
|
// Core logic for handling drop events and determining where to render the
|
|
// drop target placeholder
|
|
const processEvent = Utils.throttle((mouseX, mouseY) => {
|
|
if (!target) {
|
|
return
|
|
}
|
|
let { id, parent, node, acceptsChildren, empty } = target
|
|
|
|
// If we're over something that does not accept children then we go up a
|
|
// level and consider the mouse position relative to the parent
|
|
if (!acceptsChildren) {
|
|
id = parent
|
|
empty = false
|
|
node = getDOMNode(parent)
|
|
}
|
|
|
|
// We're now hovering over something which does accept children.
|
|
// If it is empty, just go inside it.
|
|
if (empty) {
|
|
dndStore.actions.updateDrop({
|
|
parent: id,
|
|
index: 0,
|
|
})
|
|
return
|
|
}
|
|
|
|
// As the first DOM node in a component may not necessarily contain the
|
|
// child components, we can find to try the parent of the first child
|
|
// component and use that as the real parent DOM node
|
|
const childNode = node.getElementsByClassName("component")[0]
|
|
if (childNode?.parentNode) {
|
|
node = childNode.parentNode
|
|
}
|
|
|
|
// Append an ephemeral div to allow us to determine layout if only one
|
|
// child exists
|
|
let ephemeralDiv
|
|
if (node.children.length === 1) {
|
|
ephemeralDiv = document.createElement("div")
|
|
ephemeralDiv.dataset.id = DNDPlaceholderID
|
|
node.appendChild(ephemeralDiv)
|
|
}
|
|
|
|
// We're now hovering over something which accepts children and is not
|
|
// empty, so we need to work out where to inside the placeholder
|
|
// Calculate the coordinates of various locations on each child.
|
|
const childCoords = [...(node.children || [])].map(node => {
|
|
const child = node.children?.[0] || node
|
|
const bounds = child.getBoundingClientRect()
|
|
return {
|
|
placeholder: node.dataset.id === DNDPlaceholderID,
|
|
centerX: bounds.left + bounds.width / 2,
|
|
centerY: bounds.top + bounds.height / 2,
|
|
left: bounds.left,
|
|
right: bounds.right,
|
|
top: bounds.top,
|
|
bottom: bounds.bottom,
|
|
}
|
|
})
|
|
|
|
// Now that we've calculated the position of the children, we no longer need
|
|
// the ephemeral div
|
|
if (ephemeralDiv) {
|
|
node.removeChild(ephemeralDiv)
|
|
}
|
|
|
|
// Calculate the variance between each set of positions on the children
|
|
const variances = Object.keys(childCoords[0])
|
|
.filter(x => x !== "placeholder")
|
|
.map(key => {
|
|
const coords = childCoords.map(x => x[key])
|
|
return {
|
|
variance: variance(coords),
|
|
side: key,
|
|
}
|
|
})
|
|
|
|
// Sort by variance. The lowest variance position indicates whether we are
|
|
// in a row or column layout
|
|
variances.sort((a, b) => {
|
|
return a.variance < b.variance ? -1 : 1
|
|
})
|
|
const column = ["centerX", "left", "right"].includes(variances[0].side)
|
|
|
|
// Calculate breakpoints between child components so we can determine the
|
|
// index to drop the component in.
|
|
// We want to ignore the placeholder from this calculation as it should not
|
|
// be considered a real child of the parent.
|
|
let breakpoints = childCoords
|
|
.filter(x => !x.placeholder)
|
|
.map(x => {
|
|
return column ? x.centerY : x.centerX
|
|
})
|
|
|
|
// Determine the index to drop the component in
|
|
const mousePosition = column ? mouseY : mouseX
|
|
let idx = 0
|
|
while (idx < breakpoints.length && breakpoints[idx] < mousePosition) {
|
|
idx++
|
|
}
|
|
dndStore.actions.updateDrop({
|
|
parent: id,
|
|
index: idx,
|
|
})
|
|
}, ThrottleRate)
|
|
|
|
const handleEvent = e => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
processEvent(e.clientX, e.clientY)
|
|
}
|
|
|
|
// Callback when on top of a component.
|
|
const onDragOver = e => {
|
|
if (!source || !target || insideGrid(e)) {
|
|
return
|
|
}
|
|
handleEvent(e)
|
|
}
|
|
|
|
// Callback when entering a potential drop target
|
|
const onDragEnter = e => {
|
|
if (!source || insideGrid(e)) {
|
|
return
|
|
}
|
|
|
|
// Find the next valid component to consider dropping over, ignoring nested
|
|
// block components
|
|
const component = e.target?.closest?.(
|
|
`.component:not(.block):not(.${source.id})`
|
|
)
|
|
if (component && component.classList.contains("droppable")) {
|
|
dndStore.actions.updateTarget({
|
|
id: component.dataset.id,
|
|
parent: component.dataset.parent,
|
|
node: getDOMNode(component.dataset.id),
|
|
empty: component.classList.contains("empty"),
|
|
acceptsChildren: component.classList.contains("parent"),
|
|
})
|
|
handleEvent(e)
|
|
}
|
|
}
|
|
|
|
// Callback when dropping a drag on top of some component
|
|
const onDrop = e => {
|
|
if (!source || !drop?.parent || drop?.index == null || insideGrid(e)) {
|
|
return
|
|
}
|
|
|
|
// Check if we're adding a new component rather than moving one
|
|
if (source.newComponentType) {
|
|
builderStore.actions.dropNewComponent(
|
|
source.newComponentType,
|
|
drop.parent,
|
|
drop.index
|
|
)
|
|
return
|
|
}
|
|
|
|
// Convert parent + index into target + mode
|
|
let legacyDropTarget, legacyDropMode
|
|
const parent = findComponentById(
|
|
get(screenStore).activeScreen?.props,
|
|
drop.parent
|
|
)
|
|
if (!parent) {
|
|
return
|
|
}
|
|
|
|
// Do nothing if we didn't change the location
|
|
if (source.parent === drop.parent && source.index === drop.index) {
|
|
return
|
|
}
|
|
|
|
// Filter out source component and placeholder from consideration
|
|
const children = parent._children?.filter(
|
|
x => x._id !== DNDPlaceholderID && x._id !== source.id
|
|
)
|
|
|
|
// Use inside if no existing children
|
|
if (!children?.length) {
|
|
legacyDropTarget = parent._id
|
|
legacyDropMode = "inside"
|
|
} else if (drop.index === 0) {
|
|
legacyDropTarget = children[0]?._id
|
|
legacyDropMode = "above"
|
|
} else {
|
|
legacyDropTarget = children[drop.index - 1]?._id
|
|
legacyDropMode = "below"
|
|
}
|
|
|
|
if (legacyDropTarget && legacyDropMode) {
|
|
builderStore.actions.moveComponent(
|
|
source.id,
|
|
legacyDropTarget,
|
|
legacyDropMode
|
|
)
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
// Events fired on the draggable target
|
|
document.addEventListener("dragstart", onDragStart, false)
|
|
|
|
// Events fired on the drop targets
|
|
document.addEventListener("dragover", onDragOver, false)
|
|
document.addEventListener("dragenter", onDragEnter, false)
|
|
document.addEventListener("drop", onDrop, false)
|
|
})
|
|
|
|
onDestroy(() => {
|
|
// Events fired on the draggable target
|
|
document.removeEventListener("dragstart", onDragStart, false)
|
|
|
|
// Events fired on the drop targets
|
|
document.removeEventListener("dragover", onDragOver, false)
|
|
document.removeEventListener("dragenter", onDragEnter, false)
|
|
document.removeEventListener("drop", onDrop, false)
|
|
})
|
|
</script>
|
|
|
|
<IndicatorSet
|
|
componentId={$dndParent}
|
|
color="var(--spectrum-global-color-static-green-500)"
|
|
zIndex="930"
|
|
transition
|
|
prefix="Inside"
|
|
/>
|
|
|
|
{#if $builderStore.dragging}
|
|
<DNDPlaceholderOverlay />
|
|
{/if}
|