From bb8388133a1bf4aab371b46f3dec95ed74437565 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 7 Oct 2022 08:05:44 +0100 Subject: [PATCH] Rewrite drag and drop from scratch using mouse position heuristics --- .../client/src/components/Component.svelte | 20 +- .../src/components/preview/DNDHandler.svelte | 351 +++++++++++------- .../src/components/preview/Placeholder.svelte | 11 + .../preview/PlaceholderOverlay.svelte | 43 +++ packages/client/src/index.js | 1 + packages/client/src/stores/builder.js | 19 +- packages/client/src/stores/components.js | 11 +- packages/client/src/stores/screens.js | 32 +- 8 files changed, 315 insertions(+), 173 deletions(-) create mode 100644 packages/client/src/components/preview/Placeholder.svelte create mode 100644 packages/client/src/components/preview/PlaceholderOverlay.svelte diff --git a/packages/client/src/components/Component.svelte b/packages/client/src/components/Component.svelte index f3c3e4b25c..93620606c8 100644 --- a/packages/client/src/components/Component.svelte +++ b/packages/client/src/components/Component.svelte @@ -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} > {#if hasMissingRequiredSettings} {:else if children.length} - {#each children as child (child._id)} - + {#each children as child, idx (child._id)} + {/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; diff --git a/packages/client/src/components/preview/DNDHandler.svelte b/packages/client/src/components/preview/DNDHandler.svelte index 03ee97d610..37bd9d3402 100644 --- a/packages/client/src/components/preview/DNDHandler.svelte +++ b/packages/client/src/components/preview/DNDHandler.svelte @@ -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 @@ - +{#if $builderStore.isDragging} + +{/if} + + + + + + + diff --git a/packages/client/src/components/preview/Placeholder.svelte b/packages/client/src/components/preview/Placeholder.svelte new file mode 100644 index 0000000000..3457a49e6d --- /dev/null +++ b/packages/client/src/components/preview/Placeholder.svelte @@ -0,0 +1,11 @@ +
+ + diff --git a/packages/client/src/components/preview/PlaceholderOverlay.svelte b/packages/client/src/components/preview/PlaceholderOverlay.svelte new file mode 100644 index 0000000000..86950c533e --- /dev/null +++ b/packages/client/src/components/preview/PlaceholderOverlay.svelte @@ -0,0 +1,43 @@ + + +{#if left != null} +
+{/if} + + diff --git a/packages/client/src/index.js b/packages/client/src/index.js index 0e8ab8c258..e31de18e4a 100644 --- a/packages/client/src/index.js +++ b/packages/client/src/index.js @@ -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##"], diff --git a/packages/client/src/stores/builder.js b/packages/client/src/stores/builder.js index f935c580b6..0930ef62b8 100644 --- a/packages/client/src/stores/builder.js +++ b/packages/client/src/stores/builder.js @@ -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 }) }, diff --git a/packages/client/src/stores/components.js b/packages/client/src/stores/components.js index ee75be484a..e73b498648 100644 --- a/packages/client/src/stores/components.js +++ b/packages/client/src/stores/components.js @@ -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 diff --git a/packages/client/src/stores/screens.js b/packages/client/src/stores/screens.js index 2920ee333e..e642fa9004 100644 --- a/packages/client/src/stores/screens.js +++ b/packages/client/src/stores/screens.js @@ -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) } }