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 9c82a9d073
commit bb8388133a
8 changed files with 315 additions and 173 deletions

View File

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

View File

@ -12,10 +12,12 @@
import { get } from "svelte/store"
import IndicatorSet from "./IndicatorSet.svelte"
import DNDPositionIndicator from "./DNDPositionIndicator.svelte"
import { builderStore } from "stores"
import { builderStore, componentStore } from "stores"
import PlaceholderOverlay from "./PlaceholderOverlay.svelte"
let dragInfo
let dropInfo
let placeholderInfo
const getEdges = (bounds, mousePoint) => {
const { width, height, top, left } = bounds
@ -33,39 +35,37 @@
return Math.sqrt(deltaX * deltaX + deltaY * deltaY)
}
const getDOMNodeForComponent = component => {
const parent = component.closest(".component")
const children = Array.from(parent.children)
return children[0]
const getDOMNode = id => {
const component = document.getElementsByClassName(id)[0]
return [...component.children][0]
}
// Callback when initially starting a drag on a draggable component
const onDragStart = e => {
const parent = e.target.closest(".component")
if (!parent?.classList.contains("draggable")) {
const component = e.target.closest(".component")
if (!component?.classList.contains("draggable")) {
return
}
// Update state
dragInfo = {
target: parent.dataset.id,
parent: parent.dataset.parent,
target: component.dataset.id,
}
builderStore.actions.selectComponent(dragInfo.target)
builderStore.actions.setDragging(true)
// Highlight being dragged by setting opacity
const child = getDOMNodeForComponent(e.target)
const child = getDOMNode(component.dataset.id)
if (child) {
child.style.opacity = "0.5"
}
}
// Callback when drag stops (whether dropped or not)
const onDragEnd = e => {
const onDragEnd = () => {
// Reset opacity style
if (dragInfo) {
const child = getDOMNodeForComponent(e.target)
const child = getDOMNode(dragInfo.target)
if (child) {
child.style.opacity = ""
}
@ -77,84 +77,179 @@
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) {
return null
}
e.preventDefault()
const { droppableInside, bounds } = dropInfo
const { top, left, height, width } = bounds
let { id, parent, node, index, acceptsChildren, empty } = dropInfo
const mouseY = e.clientY
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
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 (!dropInfo.bounds) {
// } else {
// dropInfo.bounds.top = node.offsetTop
// dropInfo.bounds.left = node.offsetLeft
// console.log(node.offsetTop)
// }
// console.log("calc")
// dropInfo.bounds = bounds
// 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
if (!sides.length) {
if (droppableInside) {
return {
...dropInfo,
mode: "inside",
side: null,
}
} else {
return null
// We're now hovering over something which does accept children.
// If it is empty, just go inside it
if (empty) {
placeholderInfo = {
parent: id,
index: 0,
}
return
}
// When one edge matches, use that edge
if (sides.length === 1) {
if ([Sides.Top, Sides.Left].includes(sides[0])) {
return {
...dropInfo,
mode: "above",
side: sides[0],
}
} else {
return {
...dropInfo,
mode: "below",
side: sides[0],
}
}
// We're now hovering over something which accepts children and is not
// empty, so we need to work out where to inside the placeholder
// Check we're actually inside
// if (
// mouseY < top ||
// mouseY > top + height ||
// mouseX < left ||
// mouseX > left + width
// ) {
// console.log("not inside")
// return
// }
// 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
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)
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,
}
placeholderInfo = {
parent: id,
index: idx,
}
// // 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
@ -163,15 +258,7 @@
if (!dragInfo || !dropInfo) {
return
}
e.preventDefault()
const nextDropInfo = validateDrop(dropInfo, e)
if (nextDropInfo) {
console.log("set from over")
dropInfo = nextDropInfo
console.log(dropInfo.mode, dropInfo.target)
}
handleEvent(e)
}
// Callback when entering a potential drop target
@ -181,53 +268,53 @@
return
}
// Update drop target
const dropTarget = e.target.closest(".component")
builderStore.actions.setDropTarget(dropTarget?.dataset.id)
// // Do nothing if this is the placeholder
// Do nothing if this is the placeholder
// if (element.dataset.id === "placeholder") {
// console.log("placeholder")
// return
// }
const element = e.target.closest(".component:not(.block)")
const component = e.target.closest(".component:not(.block)")
if (
element &&
element.classList.contains("droppable") &&
element.dataset.id !== dragInfo.target
component &&
component.classList.contains("droppable") &&
component.dataset.id !== dragInfo.target
) {
// Do nothing if this is the same target
if (element.dataset.id === dropInfo?.target) {
if (component.dataset.id === dropInfo?.target) {
return
}
// Ensure the dragging flag is always set.
// There's a bit of a race condition between the app reinitialisation
// after selecting the DND component and setting this the first time
if (!get(builderStore).isDragging) {
builderStore.actions.setDragging(true)
}
// Store target ID
const target = element.dataset.id
// if (!get(builderStore).isDragging) {
// builderStore.actions.setDragging(true)
// }
// Precompute and store some info to avoid recalculating everything in
// dragOver
const child = getDOMNodeForComponent(e.target)
const bounds = child.getBoundingClientRect()
let nextDropInfo = {
target,
name: element.dataset.name,
icon: element.dataset.icon,
droppableInside: element.classList.contains("empty"),
bounds,
}
nextDropInfo = validateDrop(nextDropInfo, e)
if (nextDropInfo) {
console.log("set from enter")
dropInfo = nextDropInfo
dropInfo = {
id: component.dataset.id,
parent: component.dataset.parent,
index: parseInt(component.dataset.index),
node: getDOMNode(component.dataset.id),
empty: component.classList.contains("empty"),
acceptsChildren: component.classList.contains("parent"),
}
// console.log(
// "enter",
// component.dataset.name,
// "id",
// dropInfo.id,
// "parent",
// dropInfo.parent,
// "index",
// dropInfo.index
// )
handleEvent(e)
} else {
// dropInfo = null
}
@ -240,18 +327,22 @@
// Callback when dropping a drag on top of some component
const onDrop = e => {
e.preventDefault()
dropInfo = null
placeholderInfo = null
dragInfo = null
builderStore.actions.setDragging(false)
if (dropInfo?.mode) {
builderStore.actions.moveComponent(
dragInfo.target,
dropInfo.target,
dropInfo.mode
)
// builderStore.actions.moveComponent(
// dragInfo.target,
// dropInfo.target,
// dropInfo.mode
// )
}
}
$: mode = dropInfo?.mode
$: target = dropInfo?.target
$: builderStore.actions.updateDNDPlaceholder(mode, target)
$: parent = placeholderInfo?.parent
$: index = placeholderInfo?.index
$: builderStore.actions.updateDNDPlaceholder(parent, index)
onMount(() => {
// Events fired on the draggable target
@ -279,16 +370,20 @@
</script>
<IndicatorSet
componentId={dropInfo?.mode === "inside" ? dropInfo.target : null}
componentId={$builderStore.dndParent}
color="var(--spectrum-global-color-static-green-500)"
zIndex="930"
transition
prefix="Inside"
/>
<DNDPositionIndicator
{dropInfo}
color="var(--spectrum-global-color-static-green-500)"
zIndex="940"
transition
/>
{#if $builderStore.isDragging}
<PlaceholderOverlay />
{/if}
<!--<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 () => {
// Update builder store with any builder flags
builderStore.set({
...get(builderStore),
inBuilder: !!window["##BUDIBASE_IN_BUILDER##"],
layout: window["##BUDIBASE_PREVIEW_LAYOUT##"],
screen: window["##BUDIBASE_PREVIEW_SCREEN##"],

View File

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

View File

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

View File

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