Merge pull request #2677 from Budibase/dnd

Drag and drop
This commit is contained in:
Martin McKeaveney 2021-09-30 15:13:25 +01:00 committed by GitHub
commit 17acae87c0
15 changed files with 405 additions and 27 deletions

View File

@ -165,7 +165,7 @@ Cypress.Commands.add("getComponent", componentId => {
.its("body")
.should("not.be.null")
.then(cy.wrap)
.find(`[data-component-id=${componentId}]`)
.find(`[data-id=${componentId}]`)
})
Cypress.Commands.add("navigateToFrontend", () => {

View File

@ -1,4 +1,5 @@
<script>
import { get } from "svelte/store"
import { onMount, onDestroy } from "svelte"
import { store, currentAsset } from "builderStore"
import iframeTemplate from "./iframeTemplate"
@ -7,6 +8,7 @@
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { ProgressCircle, Layout, Heading, Body } from "@budibase/bbui"
import ErrorSVG from "assets/error.svg?raw"
import { findComponent, findComponentPath } from "builderStore/storeUtils"
let iframe
let layout
@ -102,7 +104,7 @@
iframe.contentWindow.addEventListener("keydown", handleKeydownEvent)
})
// remove all iframe event listeners on component destroy
// Remove all iframe event listeners on component destroy
onDestroy(() => {
if (iframe.contentWindow) {
iframe.contentWindow.removeEventListener("bb-event", handleBudibaseEvent)
@ -122,6 +124,26 @@
// Wait for this event to show the client library if intelligent
// loading is supported
loading = false
} else if (type === "move-component") {
const { componentId, destinationComponentId } = data
const rootComponent = get(currentAsset).props
// Get source and destination components
const source = findComponent(rootComponent, componentId)
const destination = findComponent(rootComponent, destinationComponentId)
// Stop if the target is a child of source
const path = findComponentPath(source, destinationComponentId)
const ids = path.map(component => component._id)
if (ids.includes(data.destinationComponentId)) {
return
}
// Cut and paste the component to the new destination
if (source && destination) {
store.actions.components.copy(source, true)
store.actions.components.paste(destination, data.mode)
}
} else {
console.warning(`Client sent unknown event type: ${type}`)
}

View File

@ -23,6 +23,7 @@
import SelectionIndicator from "components/preview/SelectionIndicator.svelte"
import HoverIndicator from "components/preview/HoverIndicator.svelte"
import CustomThemeWrapper from "./CustomThemeWrapper.svelte"
import DNDHandler from "components/preview/DNDHandler.svelte"
import ErrorSVG from "builder/assets/error.svg"
// Provide contexts
@ -104,7 +105,10 @@
<div id="app-root">
<CustomThemeWrapper>
{#key $screenStore.activeLayout._id}
<Component instance={$screenStore.activeLayout.props} />
<Component
isLayout
instance={$screenStore.activeLayout.props}
/>
{/key}
<!-- Layers on top of app -->
@ -122,6 +126,7 @@
{#if $builderStore.inBuilder}
<SelectionIndicator />
<HoverIndicator />
<DNDHandler />
{/if}
</div>
</StateBindingsProvider>

View File

@ -11,6 +11,8 @@
import Placeholder from "components/app/Placeholder.svelte"
export let instance = {}
export let isLayout = false
export let isScreen = false
// The enriched component settings
let enrichedSettings
@ -49,11 +51,11 @@
$: children = instance._children || []
$: id = instance._id
$: name = instance._instanceName
$: empty =
!children.length &&
definition?.hasChildren &&
definition?.showEmptyState !== false &&
$builderStore.inBuilder
$: interactive =
$builderStore.inBuilder &&
($builderStore.previewType === "layout" || insideScreenslot)
$: empty = interactive && !children.length && definition?.hasChildren
$: emptyState = empty && definition?.showEmptyState !== false
$: rawProps = getRawProps(instance)
$: instanceKey = JSON.stringify(rawProps)
$: updateComponentProps(rawProps, instanceKey, $context)
@ -61,16 +63,16 @@
$builderStore.inBuilder &&
$builderStore.selectedComponentId === instance._id
$: inSelectedPath = $builderStore.selectedComponentPath?.includes(id)
$: interactive = $builderStore.previewType === "layout" || insideScreenslot
$: evaluateConditions(enrichedSettings?._conditions)
$: componentSettings = { ...enrichedSettings, ...conditionalSettings }
$: renderKey = `${propsHash}-${emptyState}`
// Update component context
$: componentStore.set({
id,
children: children.length,
styles: { ...instance._styles, id, empty, interactive },
empty,
styles: { ...instance._styles, id, empty: emptyState, interactive },
empty: emptyState,
selected,
name,
})
@ -169,13 +171,22 @@
conditionalSettings = result.settingUpdates
visible = nextVisible
}
// Drag and drop helper tags
$: draggable = interactive && !isLayout && !isScreen
$: droppable = interactive && !isLayout && !isScreen
</script>
{#key propsHash}
{#key renderKey}
{#if constructor && componentSettings && (visible || inSelectedPath)}
<!-- The ID is used as a class because getElementsByClassName is O(1) -->
<!-- and the performance matters for the selection indicators -->
<div
class={`component ${id}`}
data-type={interactive ? "component" : ""}
class:draggable
class:droppable
class:empty
class:interactive
data-id={id}
data-name={name}
>
@ -184,7 +195,7 @@
{#each children as child (child._id)}
<svelte:self instance={child} />
{/each}
{:else if empty}
{:else if emptyState}
<Placeholder />
{/if}
</svelte:component>
@ -196,4 +207,10 @@
.component {
display: contents;
}
.interactive :global(*:hover) {
cursor: pointer;
}
.draggable :global(*:hover) {
cursor: grab;
}
</style>

View File

@ -22,6 +22,6 @@
<!-- Ensure to fully remount when screen changes -->
{#key screenDefinition?._id}
<Provider key="url" data={params}>
<Component instance={screenDefinition} />
<Component isScreen instance={screenDefinition} />
</Provider>
{/key}

View File

@ -31,4 +31,7 @@
.spectrum-Button--overBackground:hover {
color: #555;
}
.spectrum-Button::after {
display: none;
}
</style>

View File

@ -34,7 +34,7 @@
display: flex;
max-width: 100%;
}
.valid-container :global([data-type="component"] > *) {
.valid-container :global(.component > *) {
max-width: 100%;
}
.direction-row {
@ -46,7 +46,7 @@
/* Grow containers inside a row need 0 width 0 so that they ignore content */
/* The nested selector for data-type is the wrapper around all components */
.direction-row :global(> [data-type="component"] > .size-grow) {
.direction-row :global(> .component > .size-grow) {
width: 0;
}

View File

@ -0,0 +1,240 @@
<script context="module">
export const Sides = {
Top: "Top",
Right: "Right",
Bottom: "Bottom",
Left: "Left",
}
</script>
<script>
import { onMount } from "svelte"
import { get } from "svelte/store"
import IndicatorSet from "./IndicatorSet.svelte"
import DNDPositionIndicator from "./DNDPositionIndicator.svelte"
import { builderStore } from "stores"
let dragInfo
let dropInfo
const getEdges = (bounds, mousePoint) => {
const { width, height, top, left } = bounds
return {
[Sides.Top]: [mousePoint[0], top],
[Sides.Right]: [left + width, mousePoint[1]],
[Sides.Bottom]: [mousePoint[0], top + height],
[Sides.Left]: [left, mousePoint[1]],
}
}
const calculatePointDelta = (point1, point2) => {
const deltaX = Math.abs(point1[0] - point2[0])
const deltaY = Math.abs(point1[1] - point2[1])
return Math.sqrt(deltaX * deltaX + deltaY * deltaY)
}
const getDOMNodeForComponent = component => {
const parent = component.closest(".component")
const children = Array.from(parent.childNodes)
return children?.find(node => node?.nodeType === 1)
}
// Callback when initially starting a drag on a draggable component
const onDragStart = e => {
const parent = e.target.closest(".component")
if (!parent?.classList.contains("draggable")) {
return
}
// Update state
dragInfo = {
target: parent.dataset.id,
parent: parent.dataset.parent,
}
builderStore.actions.selectComponent(dragInfo.target)
builderStore.actions.setDragging(true)
// Highlight being dragged by setting opacity
const child = getDOMNodeForComponent(e.target)
if (child) {
child.style.opacity = "0.5"
}
}
// Callback when drag stops (whether dropped or not)
const onDragEnd = e => {
// Reset opacity style
if (dragInfo) {
const child = getDOMNodeForComponent(e.target)
if (child) {
child.style.opacity = ""
}
}
// Reset state and styles
dragInfo = null
dropInfo = null
builderStore.actions.setDragging(false)
}
// Callback when on top of a component
const onDragOver = e => {
// Skip if we aren't validly dragging currently
if (!dragInfo || !dropInfo) {
return
}
e.preventDefault()
const { droppableInside, bounds } = dropInfo
const { top, left, height, width } = bounds
const mouseY = e.clientY
const mouseX = e.clientX
const snapFactor = droppableInside ? 0.33 : 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)
}
// When no edges match, drop inside if possible
if (!sides.length) {
dropInfo.mode = droppableInside ? "inside" : null
dropInfo.side = null
return
}
// When one edge matches, use that edge
if (sides.length === 1) {
dropInfo.side = sides[0]
if ([Sides.Top, Sides.Left].includes(sides[0])) {
dropInfo.mode = "above"
} else {
dropInfo.mode = "below"
}
return
}
// 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)) {
dropInfo.mode = "above"
} else {
dropInfo.mode = "below"
}
}
// Callback when entering a potential drop target
const onDragEnter = e => {
// Skip if we aren't validly dragging currently
if (!dragInfo) {
return
}
const element = e.target.closest(".component")
if (
element &&
element.classList.contains("droppable") &&
element.dataset.id !== dragInfo.target
) {
// Do nothing if this is the same target
if (element.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
// Precompute and store some info to avoid recalculating everything in
// dragOver
const child = getDOMNodeForComponent(e.target)
const bounds = child.getBoundingClientRect()
dropInfo = {
target,
name: element.dataset.name,
droppableInside: element.classList.contains("empty"),
bounds,
}
} else {
dropInfo = null
}
}
// Callback when leaving a potential drop target.
// Since we don't style our targets, we don't need to unset anything.
const onDragLeave = () => {}
// Callback when dropping a drag on top of some component
const onDrop = e => {
e.preventDefault()
if (dropInfo?.mode) {
builderStore.actions.moveComponent(
dragInfo.target,
dropInfo.target,
dropInfo.mode
)
}
}
onMount(() => {
// Events fired on the draggable target
document.addEventListener("dragstart", onDragStart, false)
document.addEventListener("dragend", onDragEnd, false)
// Events fired on the drop targets
document.addEventListener("dragover", onDragOver, false)
document.addEventListener("dragenter", onDragEnter, false)
document.addEventListener("dragleave", onDragLeave, false)
document.addEventListener("drop", onDrop, false)
return () => {
// Events fired on the draggable target
document.removeEventListener("dragstart", onDragStart, false)
document.removeEventListener("dragend", onDragEnd, false)
// Events fired on the drop targets
document.removeEventListener("dragover", onDragOver, false)
document.removeEventListener("dragenter", onDragEnter, false)
document.removeEventListener("dragleave", onDragLeave, false)
document.removeEventListener("drop", onDrop, false)
}
})
</script>
<IndicatorSet
componentId={dropInfo?.mode === "inside" ? dropInfo.target : null}
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
/>

View File

@ -0,0 +1,54 @@
<script>
import Indicator from "./Indicator.svelte"
import { Sides } from "./DNDHandler.svelte"
export let dropInfo
export let zIndex
export let color
export let transition
$: dimensions = getDimensions(dropInfo)
$: prefix = dropInfo?.mode === "above" ? "Before" : "After"
$: text = `${prefix} ${dropInfo?.name}`
$: renderKey = `${dropInfo?.target}-${dropInfo?.side}`
const getDimensions = info => {
const { bounds, side } = info ?? {}
if (!bounds || !side) {
return null
}
const { left, top, width, height } = bounds
if (side === Sides.Top || side === Sides.Bottom) {
return {
top: side === Sides.Top ? top - 4 : top + height,
left: left - 2,
width: width + 4,
height: 0,
}
} else {
return {
top: top - 2,
left: side === Sides.Left ? left - 4 : left + width,
width: 0,
height: height + 4,
}
}
}
</script>
{#key renderKey}
{#if dimensions && dropInfo?.mode !== "inside"}
<Indicator
left={Math.round(dimensions.left)}
top={Math.round(dimensions.top)}
width={dimensions.width}
height={dimensions.height}
{text}
{zIndex}
{color}
{transition}
alignRight={dropInfo?.side === Sides.Right}
line
/>
{/if}
{/key}

View File

@ -7,7 +7,7 @@
$: zIndex = componentId === $builderStore.selectedComponentId ? 900 : 920
const onMouseOver = e => {
const element = e.target.closest("[data-type='component']")
const element = e.target.closest(".interactive.component")
const newId = element?.dataset?.id
if (newId !== componentId) {
componentId = newId
@ -30,7 +30,7 @@
</script>
<IndicatorSet
{componentId}
componentId={$builderStore.isDragging ? null : componentId}
color="var(--spectrum-global-color-static-blue-200)"
transition
{zIndex}

View File

@ -9,6 +9,10 @@
export let color
export let zIndex
export let transition = false
export let line = false
export let alignRight = false
$: flipped = top < 20
</script>
<div
@ -18,11 +22,12 @@
}}
out:fade={{ duration: transition ? 130 : 0 }}
class="indicator"
class:flipped={top < 20}
class:flipped
class:line
style="top: {top}px; left: {left}px; width: {width}px; height: {height}px; --color: {color}; --zIndex: {zIndex};"
>
{#if text}
<div class="text" class:flipped={top < 20}>
<div class="text" class:flipped class:line class:right={alignRight}>
{text}
</div>
{/if}
@ -30,6 +35,7 @@
<style>
.indicator {
right: 0;
position: absolute;
z-index: var(--zIndex);
border: 2px solid var(--color);
@ -42,6 +48,9 @@
.indicator.flipped {
border-top-left-radius: 4px;
}
.indicator.line {
border-radius: 4px !important;
}
.text {
background-color: var(--color);
color: white;
@ -61,9 +70,18 @@
justify-content: flex-start;
align-items: center;
}
.text.line {
transform: translateY(-50%);
border-radius: 4px;
}
.text.flipped {
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
transform: translateY(0%);
top: -2px;
}
.text.right {
right: -2px;
left: auto;
}
</style>

View File

@ -7,6 +7,7 @@
export let color
export let transition
export let zIndex
export let prefix = null
let indicators = []
let interval
@ -51,6 +52,9 @@
const parents = document.getElementsByClassName(componentId)
if (parents.length) {
text = parents[0].dataset.name
if (prefix) {
text = `${prefix} ${text}`
}
}
// Batch reads to minimize reflow

View File

@ -16,7 +16,7 @@
let measured = false
$: definition = $builderStore.selectedComponentDefinition
$: showBar = definition?.showSettingsBar
$: showBar = definition?.showSettingsBar && !$builderStore.isDragging
$: settings = definition?.settings?.filter(setting => setting.showInBar) ?? []
const updatePosition = () => {

View File

@ -23,6 +23,7 @@ const createBuilderStore = () => {
theme: null,
customTheme: null,
previewDevice: "desktop",
isDragging: false,
}
const writableStore = writable(initialState)
const derivedStore = derived(writableStore, $state => {
@ -64,13 +65,24 @@ const createBuilderStore = () => {
dispatchEvent("preview-loaded")
},
setSelectedPath: path => {
console.log("set to ")
console.log(path)
writableStore.update(state => {
state.selectedPath = path
return state
})
},
moveComponent: (componentId, destinationComponentId, mode) => {
dispatchEvent("move-component", {
componentId,
destinationComponentId,
mode,
})
},
setDragging: dragging => {
writableStore.update(state => {
state.isDragging = dragging
return state
})
},
}
return {
...writableStore,

View File

@ -23,10 +23,14 @@ export const styleable = (node, styles = {}) => {
let applyHoverStyles
let selectComponent
// Allow dragging if required
const parent = node.closest(".component")
if (parent && parent.classList.contains("draggable")) {
node.setAttribute("draggable", true)
}
// Creates event listeners and applies initial styles
const setupStyles = (newStyles = {}) => {
// Use empty state styles as base styles if required, but let them, get
// overridden by any user specified styles
let baseStyles = {}
if (newStyles.empty) {
baseStyles.border = "2px dashed var(--spectrum-global-color-gray-600)"
@ -45,7 +49,6 @@ export const styleable = (node, styles = {}) => {
// Applies a style string to a DOM node
const applyStyles = styleString => {
node.style = styleString
node.dataset.componentId = componentId
}
// Applies the "normal" style definition