Rewrite drag and drop from scratch using mouse position heuristics

This commit is contained in:
Andrew Kingston 2022-10-07 08:05:44 +01:00
parent 428b786184
commit 4322345aca
8 changed files with 315 additions and 173 deletions

View File

@ -27,6 +27,8 @@
export let isLayout = false export let isLayout = false
export let isScreen = false export let isScreen = false
export let isBlock = false export let isBlock = false
export let parent = null
export let index = 0
// Get parent contexts // Get parent contexts
const context = getContext("context") const context = getContext("context")
@ -96,7 +98,7 @@
$: selected = $: selected =
$builderStore.inBuilder && $builderStore.selectedComponentId === id $builderStore.inBuilder && $builderStore.selectedComponentId === id
$: inSelectedPath = $componentStore.selectedComponentPath?.includes(id) $: inSelectedPath = $componentStore.selectedComponentPath?.includes(id)
$: inDropPath = $componentStore.dropPath?.includes(id) $: isDndParent = $componentStore.dndParent === id
$: inDragPath = inSelectedPath && $builderStore.editMode $: inDragPath = inSelectedPath && $builderStore.editMode
// Derive definition properties which can all be optional, so need to be // Derive definition properties which can all be optional, so need to be
@ -119,7 +121,7 @@
!isLayout && !isLayout &&
!isScreen && !isScreen &&
definition?.draggable !== false definition?.draggable !== false
$: droppable = interactive && !isLayout && !isScreen $: droppable = interactive
$: builderHidden = $: builderHidden =
$builderStore.inBuilder && $builderStore.hiddenComponentIds?.includes(id) $builderStore.inBuilder && $builderStore.hiddenComponentIds?.includes(id)
@ -451,21 +453,24 @@
class:draggable class:draggable
class:droppable class:droppable
class:empty class:empty
class:parent={hasChildren}
class:interactive class:interactive
class:editing class:editing
class:block={isBlock} class:block={isBlock}
class:explode={children.length && !isLayout && inDropPath && false} class:explode={interactive && hasChildren && $builderStore.isDragging}
class:placeholder={id === "placeholder"}
data-id={id} data-id={id}
data-name={name} data-name={name}
data-icon={icon} data-icon={icon}
data-placeholder={id === "placeholder"} data-parent={parent}
data-index={index}
> >
<svelte:component this={constructor} bind:this={ref} {...initialSettings}> <svelte:component this={constructor} bind:this={ref} {...initialSettings}>
{#if hasMissingRequiredSettings} {#if hasMissingRequiredSettings}
<ComponentPlaceholder /> <ComponentPlaceholder />
{:else if children.length} {:else if children.length}
{#each children as child (child._id)} {#each children as child, idx (child._id)}
<svelte:self instance={child} /> <svelte:self instance={child} parent={id} index={idx} />
{/each} {/each}
{:else if emptyState} {:else if emptyState}
{#if isScreen} {#if isScreen}
@ -488,8 +493,9 @@
transition: padding 250ms ease, border 250ms ease; transition: padding 250ms ease, border 250ms ease;
} }
.component.explode :global(> *) { .component.explode :global(> *) {
padding: 12px 4px !important; padding: 16px !important;
border: 2px dashed var(--spectrum-global-color-gray-400) !important; border: 2px dashed var(--spectrum-global-color-gray-400) !important;
border-radius: 4px !important;
} }
.interactive :global(*:hover) { .interactive :global(*:hover) {
cursor: pointer; cursor: pointer;

View File

@ -12,10 +12,12 @@
import { get } from "svelte/store" import { get } from "svelte/store"
import IndicatorSet from "./IndicatorSet.svelte" import IndicatorSet from "./IndicatorSet.svelte"
import DNDPositionIndicator from "./DNDPositionIndicator.svelte" import DNDPositionIndicator from "./DNDPositionIndicator.svelte"
import { builderStore } from "stores" import { builderStore, componentStore } from "stores"
import PlaceholderOverlay from "./PlaceholderOverlay.svelte"
let dragInfo let dragInfo
let dropInfo let dropInfo
let placeholderInfo
const getEdges = (bounds, mousePoint) => { const getEdges = (bounds, mousePoint) => {
const { width, height, top, left } = bounds const { width, height, top, left } = bounds
@ -33,39 +35,37 @@
return Math.sqrt(deltaX * deltaX + deltaY * deltaY) return Math.sqrt(deltaX * deltaX + deltaY * deltaY)
} }
const getDOMNodeForComponent = component => { const getDOMNode = id => {
const parent = component.closest(".component") const component = document.getElementsByClassName(id)[0]
const children = Array.from(parent.children) return [...component.children][0]
return children[0]
} }
// Callback when initially starting a drag on a draggable component // Callback when initially starting a drag on a draggable component
const onDragStart = e => { const onDragStart = e => {
const parent = e.target.closest(".component") const component = e.target.closest(".component")
if (!parent?.classList.contains("draggable")) { if (!component?.classList.contains("draggable")) {
return return
} }
// Update state // Update state
dragInfo = { dragInfo = {
target: parent.dataset.id, target: component.dataset.id,
parent: parent.dataset.parent,
} }
builderStore.actions.selectComponent(dragInfo.target) builderStore.actions.selectComponent(dragInfo.target)
builderStore.actions.setDragging(true) builderStore.actions.setDragging(true)
// Highlight being dragged by setting opacity // Highlight being dragged by setting opacity
const child = getDOMNodeForComponent(e.target) const child = getDOMNode(component.dataset.id)
if (child) { if (child) {
child.style.opacity = "0.5" child.style.opacity = "0.5"
} }
} }
// Callback when drag stops (whether dropped or not) // Callback when drag stops (whether dropped or not)
const onDragEnd = e => { const onDragEnd = () => {
// Reset opacity style // Reset opacity style
if (dragInfo) { if (dragInfo) {
const child = getDOMNodeForComponent(e.target) const child = getDOMNode(dragInfo.target)
if (child) { if (child) {
child.style.opacity = "" child.style.opacity = ""
} }
@ -77,84 +77,179 @@
builderStore.actions.setDragging(false) builderStore.actions.setDragging(false)
} }
const validateDrop = (dropInfo, e) => { 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
}
const handleEvent = e => {
if (!dropInfo) { if (!dropInfo) {
return null return null
} }
e.preventDefault()
const { droppableInside, bounds } = dropInfo let { id, parent, node, index, acceptsChildren, empty } = dropInfo
const { top, left, height, width } = bounds
const mouseY = e.clientY const mouseY = e.clientY
const mouseX = e.clientX const mouseX = e.clientX
const snapFactor = droppableInside ? 0.25 : 0.5
const snapLimitV = Math.min(40, height * snapFactor)
const snapLimitH = Math.min(40, width * snapFactor)
// Determine all sies we are within snap range of // if (!dropInfo.bounds) {
let sides = [] // } else {
if (mouseY <= top + snapLimitV) { // dropInfo.bounds.top = node.offsetTop
sides.push(Sides.Top) // dropInfo.bounds.left = node.offsetLeft
} else if (mouseY >= top + height - snapLimitV) { // console.log(node.offsetTop)
sides.push(Sides.Bottom) // }
}
if (mouseX < left + snapLimitH) { // console.log("calc")
sides.push(Sides.Left) // dropInfo.bounds = bounds
} else if (mouseX > left + width - snapLimitH) {
sides.push(Sides.Right) // If we're over something that does not accept children then we must go
// above or below this component
if (!acceptsChildren) {
id = parent
acceptsChildren = true
empty = false
node = getDOMNode(parent)
//
//
// const bounds = node.getBoundingClientRect()
// const { top, left, height, width } = bounds
// const snapFactor = 0.5
// const snapLimitV = Math.min(40, height * snapFactor)
// const snapLimitH = Math.min(40, width * snapFactor)
//
// // Determine all sides we are within snap range of
// let sides = []
// if (mouseY <= top + snapLimitV) {
// sides.push(Sides.Top)
// } else if (mouseY >= top + height - snapLimitV) {
// sides.push(Sides.Bottom)
// }
// if (mouseX < left + snapLimitH) {
// sides.push(Sides.Left)
// } else if (mouseX > left + width - snapLimitH) {
// sides.push(Sides.Right)
// }
//
// // If we're somehow not in range of any side, do nothing
// if (!sides.length) {
// console.log("no sides match")
// return
// }
//
// let side
// if (sides.length === 1) {
// // When one edge matches, use that edge
// side = sides[0]
// } else {
// // When 2 edges match, work out which is closer
// const mousePoint = [mouseX, mouseY]
// const edges = getEdges(bounds, mousePoint)
// const edge1 = edges[sides[0]]
// const delta1 = calculatePointDelta(mousePoint, edge1)
// const edge2 = edges[sides[1]]
// const delta2 = calculatePointDelta(mousePoint, edge2)
// side = delta1 < delta2 ? sides[0] : sides[1]
// }
// if ([Sides.Top, Sides.Left].includes(side)) {
// // Before, so use the current index
// console.log("before")
// placeholderInfo = {
// parent: parent,
// index: index,
// }
// } else {
// console.log("after")
// // After, so use the next index
// placeholderInfo = {
// parent: parent,
// index: index + 1,
// }
// }
} }
// When no edges match, drop inside if possible // We're now hovering over something which does accept children.
if (!sides.length) { // If it is empty, just go inside it
if (droppableInside) { if (empty) {
return { placeholderInfo = {
...dropInfo, parent: id,
mode: "inside", index: 0,
side: null,
}
} else {
return null
} }
return
} }
// When one edge matches, use that edge // We're now hovering over something which accepts children and is not
if (sides.length === 1) { // empty, so we need to work out where to inside the placeholder
if ([Sides.Top, Sides.Left].includes(sides[0])) {
return { // Check we're actually inside
...dropInfo, // if (
mode: "above", // mouseY < top ||
side: sides[0], // mouseY > top + height ||
} // mouseX < left ||
} else { // mouseX > left + width
return { // ) {
...dropInfo, // console.log("not inside")
mode: "below", // return
side: sides[0], // }
}
} // Get all DOM nodes of children of this component.
// Filter out the placeholder as we don't want it to affect the index of
// the new placeholder.
const children = [...(node.children || [])]
.filter(x => !x.classList.contains("placeholder"))
.map(x => x.children[0])
// Calculate centers of each child
const centers = children.map(child => {
const childBounds = child.getBoundingClientRect()
return [
childBounds.left + childBounds.width / 2,
childBounds.top + childBounds.height / 2,
]
})
// Calculate variance of X and Y centers to determine layout
const xCoords = centers.map(x => x[0])
const yCoords = centers.map(x => x[1])
const xVariance = variance(xCoords)
const yVariance = variance(yCoords)
const column = xVariance <= yVariance
console.log(column ? "COL" : "ROW")
// Now that we know the layout, find which children in this axis we are
// between
const childPositions = column ? yCoords : xCoords
const mousePosition = column ? mouseY : mouseX
let idx = 0
while (idx < children.length && childPositions[idx] < mousePosition) {
idx++
} }
// When 2 edges match, work out which is closer placeholderInfo = {
const mousePoint = [mouseX, mouseY] parent: id,
const edges = getEdges(bounds, mousePoint) index: idx,
const edge1 = edges[sides[0]]
const delta1 = calculatePointDelta(mousePoint, edge1)
const edge2 = edges[sides[1]]
const delta2 = calculatePointDelta(mousePoint, edge2)
const edge = delta1 < delta2 ? sides[0] : sides[1]
dropInfo.side = edge
if ([Sides.Top, Sides.Left].includes(edge)) {
return {
...dropInfo,
mode: "above",
side: edge,
}
} else {
return {
...dropInfo,
mode: "below",
side: edge,
}
} }
// // When no edges match, drop inside if possible
// if (!sides.length) {
// if (empty) {
// console.log("allowed inside")
// return {
// ...dropInfo,
// mode: "inside",
// side: null,
// bounds,
// }
// } else {
// // No sides but also not empty?
// console.log("no sides match, but not empty")
// return null
// }
// }
} }
// Callback when on top of a component // Callback when on top of a component
@ -163,15 +258,7 @@
if (!dragInfo || !dropInfo) { if (!dragInfo || !dropInfo) {
return return
} }
handleEvent(e)
e.preventDefault()
const nextDropInfo = validateDrop(dropInfo, e)
if (nextDropInfo) {
console.log("set from over")
dropInfo = nextDropInfo
console.log(dropInfo.mode, dropInfo.target)
}
} }
// Callback when entering a potential drop target // Callback when entering a potential drop target
@ -181,53 +268,53 @@
return return
} }
// Update drop target // Do nothing if this is the placeholder
const dropTarget = e.target.closest(".component")
builderStore.actions.setDropTarget(dropTarget?.dataset.id)
// // Do nothing if this is the placeholder
// if (element.dataset.id === "placeholder") { // if (element.dataset.id === "placeholder") {
// console.log("placeholder") // console.log("placeholder")
// return // return
// } // }
const element = e.target.closest(".component:not(.block)") const component = e.target.closest(".component:not(.block)")
if ( if (
element && component &&
element.classList.contains("droppable") && component.classList.contains("droppable") &&
element.dataset.id !== dragInfo.target component.dataset.id !== dragInfo.target
) { ) {
// Do nothing if this is the same target // Do nothing if this is the same target
if (element.dataset.id === dropInfo?.target) { if (component.dataset.id === dropInfo?.target) {
return return
} }
// Ensure the dragging flag is always set. // Ensure the dragging flag is always set.
// There's a bit of a race condition between the app reinitialisation // There's a bit of a race condition between the app reinitialisation
// after selecting the DND component and setting this the first time // after selecting the DND component and setting this the first time
if (!get(builderStore).isDragging) { // if (!get(builderStore).isDragging) {
builderStore.actions.setDragging(true) // builderStore.actions.setDragging(true)
} // }
// Store target ID
const target = element.dataset.id
// Precompute and store some info to avoid recalculating everything in // Precompute and store some info to avoid recalculating everything in
// dragOver // dragOver
const child = getDOMNodeForComponent(e.target) dropInfo = {
const bounds = child.getBoundingClientRect() id: component.dataset.id,
let nextDropInfo = { parent: component.dataset.parent,
target, index: parseInt(component.dataset.index),
name: element.dataset.name, node: getDOMNode(component.dataset.id),
icon: element.dataset.icon, empty: component.classList.contains("empty"),
droppableInside: element.classList.contains("empty"), acceptsChildren: component.classList.contains("parent"),
bounds,
}
nextDropInfo = validateDrop(nextDropInfo, e)
if (nextDropInfo) {
console.log("set from enter")
dropInfo = nextDropInfo
} }
// console.log(
// "enter",
// component.dataset.name,
// "id",
// dropInfo.id,
// "parent",
// dropInfo.parent,
// "index",
// dropInfo.index
// )
handleEvent(e)
} else { } else {
// dropInfo = null // dropInfo = null
} }
@ -240,18 +327,22 @@
// Callback when dropping a drag on top of some component // Callback when dropping a drag on top of some component
const onDrop = e => { const onDrop = e => {
e.preventDefault() e.preventDefault()
dropInfo = null
placeholderInfo = null
dragInfo = null
builderStore.actions.setDragging(false)
if (dropInfo?.mode) { if (dropInfo?.mode) {
builderStore.actions.moveComponent( // builderStore.actions.moveComponent(
dragInfo.target, // dragInfo.target,
dropInfo.target, // dropInfo.target,
dropInfo.mode // dropInfo.mode
) // )
} }
} }
$: mode = dropInfo?.mode $: parent = placeholderInfo?.parent
$: target = dropInfo?.target $: index = placeholderInfo?.index
$: builderStore.actions.updateDNDPlaceholder(mode, target) $: builderStore.actions.updateDNDPlaceholder(parent, index)
onMount(() => { onMount(() => {
// Events fired on the draggable target // Events fired on the draggable target
@ -279,16 +370,20 @@
</script> </script>
<IndicatorSet <IndicatorSet
componentId={dropInfo?.mode === "inside" ? dropInfo.target : null} componentId={$builderStore.dndParent}
color="var(--spectrum-global-color-static-green-500)" color="var(--spectrum-global-color-static-green-500)"
zIndex="930" zIndex="930"
transition transition
prefix="Inside" prefix="Inside"
/> />
<DNDPositionIndicator {#if $builderStore.isDragging}
{dropInfo} <PlaceholderOverlay />
color="var(--spectrum-global-color-static-green-500)" {/if}
zIndex="940"
transition <!--<DNDPositionIndicator-->
/> <!-- {dropInfo}-->
<!-- color="var(&#45;&#45;spectrum-global-color-static-green-500)"-->
<!-- zIndex="940"-->
<!-- transition-->
<!--/>-->

View File

@ -0,0 +1,11 @@
<div id="placeholder" class="placeholder" />
<style>
.placeholder {
display: block;
min-height: 64px;
min-width: 64px;
flex: 0 0 64px;
opacity: 0;
}
</style>

View File

@ -0,0 +1,43 @@
<script>
import { onMount } from "svelte"
let left, top, height, width
onMount(() => {
const interval = setInterval(() => {
const node = document.getElementById("placeholder")
if (!node) {
height = 0
width = 0
} else {
const bounds = node.getBoundingClientRect()
left = bounds.left
top = bounds.top
height = bounds.height
width = bounds.width
}
}, 100)
return () => {
clearInterval(interval)
}
})
</script>
{#if left != null}
<div
class="overlay"
style="left: {left}px; top: {top}px; width: {width}px; height: {height}px;"
/>
{/if}
<style>
.overlay {
position: fixed;
z-index: 800;
background: hsl(160, 64%, 90%);
border-radius: 4px;
transition: all 130ms ease-out;
border: 2px solid var(--spectrum-global-color-static-green-500);
}
</style>

View File

@ -24,6 +24,7 @@ let app
const loadBudibase = async () => { const loadBudibase = async () => {
// Update builder store with any builder flags // Update builder store with any builder flags
builderStore.set({ builderStore.set({
...get(builderStore),
inBuilder: !!window["##BUDIBASE_IN_BUILDER##"], inBuilder: !!window["##BUDIBASE_IN_BUILDER##"],
layout: window["##BUDIBASE_PREVIEW_LAYOUT##"], layout: window["##BUDIBASE_PREVIEW_LAYOUT##"],
screen: window["##BUDIBASE_PREVIEW_SCREEN##"], screen: window["##BUDIBASE_PREVIEW_SCREEN##"],

View File

@ -21,9 +21,9 @@ const createBuilderStore = () => {
navigation: null, navigation: null,
hiddenComponentIds: [], hiddenComponentIds: [],
usedPlugins: null, usedPlugins: null,
dndMode: null,
dndTarget: null, dndParent: null,
dropTarget: null, dndIndex: null,
// Legacy - allow the builder to specify a layout // Legacy - allow the builder to specify a layout
layout: null, layout: null,
@ -106,17 +106,10 @@ const createBuilderStore = () => {
// Notify the builder so we can reload component definitions // Notify the builder so we can reload component definitions
dispatchEvent("reload-plugin") dispatchEvent("reload-plugin")
}, },
updateDNDPlaceholder: (mode, target) => { updateDNDPlaceholder: (parent, index) => {
console.log(mode, target)
store.update(state => { store.update(state => {
state.dndMode = mode state.dndParent = parent
state.dndTarget = target state.dndIndex = index
return state
})
},
setDropTarget: target => {
store.update(state => {
state.dropTarget = target
return state return state
}) })
}, },

View File

@ -5,6 +5,7 @@ import { devToolsStore } from "./devTools"
import { screenStore } from "./screens" import { screenStore } from "./screens"
import { builderStore } from "./builder" import { builderStore } from "./builder"
import Router from "../components/Router.svelte" import Router from "../components/Router.svelte"
import Placeholder from "../components/preview/Placeholder.svelte"
import * as AppComponents from "../components/app/index.js" import * as AppComponents from "../components/app/index.js"
const budibasePrefix = "@budibase/standard-components/" const budibasePrefix = "@budibase/standard-components/"
@ -38,11 +39,6 @@ const createComponentStore = () => {
// Derive the selected component path // Derive the selected component path
const selectedPath = const selectedPath =
findComponentPathById(asset?.props, selectedComponentId) || [] findComponentPathById(asset?.props, selectedComponentId) || []
let dropPath = []
if ($builderState.isDragging) {
dropPath =
findComponentPathById(asset?.props, $builderState.dropTarget) || []
}
return { return {
customComponentManifest: $store.customComponentManifest, customComponentManifest: $store.customComponentManifest,
@ -53,7 +49,6 @@ const createComponentStore = () => {
selectedComponentPath: selectedPath?.map(component => component._id), selectedComponentPath: selectedPath?.map(component => component._id),
mountedComponentCount: Object.keys($store.mountedComponents).length, mountedComponentCount: Object.keys($store.mountedComponents).length,
currentAsset: asset, currentAsset: asset,
dropPath: dropPath?.map(component => component._id),
} }
} }
) )
@ -113,6 +108,8 @@ const createComponentStore = () => {
// Screenslot is an edge case // Screenslot is an edge case
if (type === "screenslot") { if (type === "screenslot") {
type = `${budibasePrefix}${type}` type = `${budibasePrefix}${type}`
} else if (type === "placeholder") {
return {}
} }
// Handle built-in components // Handle built-in components
@ -132,6 +129,8 @@ const createComponentStore = () => {
} }
if (type === "screenslot") { if (type === "screenslot") {
return Router return Router
} else if (type === "placeholder") {
return Placeholder
} }
// Handle budibase components // Handle budibase components

View File

@ -48,30 +48,24 @@ const createScreenStore = () => {
} }
// Insert DND placeholder if required // Insert DND placeholder if required
const { dndTarget, dndMode, selectedComponentId } = $builderStore const { dndParent, dndIndex } = $builderStore
const insert = false const insert = true
if (insert && activeScreen && dndTarget && dndMode) { if (insert && activeScreen && dndParent && dndIndex != null) {
let selectedComponent = findComponentById( // let selectedComponent = findComponentById(
activeScreen.props, // activeScreen.props,
selectedComponentId // selectedComponentId
) // )
// delete selectedComponent._component
const placeholder = { const placeholder = {
...selectedComponent, _component: "placeholder",
_id: "placeholder", _id: "placeholder",
static: true, static: true,
} }
// delete selectedComponent._component let parent = findComponentById(activeScreen.props, dndParent)
if (dndMode === "inside") { if (!parent._children?.length) {
const target = findComponentById(activeScreen.props, dndTarget) parent._children = [placeholder]
target._children = [placeholder]
} else { } else {
const path = findComponentPathById(activeScreen.props, dndTarget) parent._children.splice(dndIndex, 0, placeholder)
const parent = path?.[path.length - 2]
if (parent) {
const idx = parent._children.findIndex(x => x._id === dndTarget)
const delta = dndMode === "below" ? 1 : -1
parent._children.splice(idx + delta, 0, placeholder)
}
} }
} }