Add ability to drag new components into the preview

This commit is contained in:
Andrew Kingston 2022-10-14 13:37:14 +01:00
parent 29fdaab5fd
commit a71a553ee6
17 changed files with 318 additions and 174 deletions

View File

@ -1,6 +1,6 @@
import { get, writable } from "svelte/store"
import { cloneDeep } from "lodash/fp"
import { selectedScreen, selectedComponent } from "builderStore"
import { selectedScreen, selectedComponent, store } from "builderStore"
import {
datasources,
integrations,
@ -451,7 +451,7 @@ export const getFrontendStore = () => {
...extras,
}
},
create: async (componentName, presetProps) => {
create: async (componentName, presetProps, parent, index) => {
const state = get(store)
const componentInstance = store.actions.components.createInstance(
componentName,
@ -461,48 +461,62 @@ export const getFrontendStore = () => {
return
}
// Patch selected screen
await store.actions.screens.patch(screen => {
// Find the selected component
const currentComponent = findComponent(
screen.props,
state.selectedComponentId
)
if (!currentComponent) {
return false
}
// Find parent node to attach this component to
let parentComponent
if (currentComponent) {
// Use selected component as parent if one is selected
const definition = store.actions.components.getDefinition(
currentComponent._component
)
if (definition?.hasChildren) {
// Use selected component if it allows children
parentComponent = currentComponent
// Insert in position if specified
if (parent && index != null) {
await store.actions.screens.patch(screen => {
let parentComponent = findComponent(screen.props, parent)
if (!parentComponent._children?.length) {
parentComponent._children = [componentInstance]
} else {
// Otherwise we need to use the parent of this component
parentComponent = findComponentParent(
screen.props,
currentComponent._id
)
parentComponent._children.splice(index, 0, componentInstance)
}
} else {
// Use screen or layout if no component is selected
parentComponent = screen.props
}
})
}
// Attach new component
if (!parentComponent) {
return false
}
if (!parentComponent._children) {
parentComponent._children = []
}
parentComponent._children.push(componentInstance)
})
// Otherwise we work out where this component should be inserted
else {
await store.actions.screens.patch(screen => {
// Find the selected component
const currentComponent = findComponent(
screen.props,
state.selectedComponentId
)
if (!currentComponent) {
return false
}
// Find parent node to attach this component to
let parentComponent
if (currentComponent) {
// Use selected component as parent if one is selected
const definition = store.actions.components.getDefinition(
currentComponent._component
)
if (definition?.hasChildren) {
// Use selected component if it allows children
parentComponent = currentComponent
} else {
// Otherwise we need to use the parent of this component
parentComponent = findComponentParent(
screen.props,
currentComponent._id
)
}
} else {
// Use screen or layout if no component is selected
parentComponent = screen.props
}
// Attach new component
if (!parentComponent) {
return false
}
if (!parentComponent._children) {
parentComponent._children = []
}
parentComponent._children.push(componentInstance)
})
}
// Select new component
store.update(state => {
@ -990,6 +1004,19 @@ export const getFrontendStore = () => {
}))
},
},
dnd: {
start: component => {
store.actions.preview.sendEvent("dragging-new-component", {
dragging: true,
component,
})
},
stop: () => {
store.actions.preview.sendEvent("dragging-new-component", {
dragging: false,
})
},
},
}
return store

View File

@ -213,6 +213,9 @@
await store.actions.components.handleEjectBlock(id, definition)
} else if (type === "reload-plugin") {
await store.actions.components.refreshDefinitions()
} else if (type === "drop-new-component") {
const { component, parent, index } = data
await store.actions.components.create(component, null, parent, index)
} else {
console.warn(`Client sent unknown event type: ${type}`)
}

View File

@ -169,6 +169,14 @@
window.removeEventListener("keydown", handleKeyDown)
}
})
const onDragStart = component => {
store.actions.dnd.start(component)
}
const onDragEnd = () => {
store.actions.dnd.stop()
}
</script>
<div class="container" transition:fly|local={{ x: 260, duration: 300 }}>
@ -206,6 +214,9 @@
<div class="category-label">{category.name}</div>
{#each category.children as component}
<div
draggable="true"
on:dragstart={() => onDragStart(component.component)}
on:dragend={onDragEnd}
data-cy={`component-${component.name}`}
class="component"
class:selected={selectedIndex ===

View File

@ -2,21 +2,22 @@
import { onMount, onDestroy } from "svelte"
import { get } from "svelte/store"
import IndicatorSet from "./IndicatorSet.svelte"
import { builderStore, componentStore } from "stores"
import {
builderStore,
componentStore,
dndStore,
dndParent,
isDragging,
} from "stores"
import DNDPlaceholderOverlay from "./DNDPlaceholderOverlay.svelte"
import { Utils } from "@budibase/frontend-core"
import { findComponentById } from "utils/components.js"
let sourceInfo
let targetInfo
let dropInfo
// These reactive statements are just a trick to only update the store when
// the value of one of the properties actually changes
$: parent = dropInfo?.parent
$: index = dropInfo?.index
$: bounds = sourceInfo?.bounds
$: builderStore.actions.updateDNDPlaceholder(parent, index, bounds)
// 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
// Util to get the inner DOM node by a component ID
const getDOMNode = id => {
@ -37,19 +38,16 @@
// Callback when drag stops (whether dropped or not)
const stopDragging = () => {
// Reset state
sourceInfo = null
targetInfo = null
dropInfo = null
builderStore.actions.setDragging(false)
// Reset listener
if (sourceInfo) {
const component = document.getElementsByClassName(sourceInfo.id)[0]
if (source?.id) {
const component = document.getElementsByClassName(source?.id)[0]
if (component) {
component.removeEventListener("dragend", stopDragging)
}
}
// Reset state
dndStore.actions.reset()
}
// Callback when initially starting a drag on a draggable component
@ -66,6 +64,7 @@
component.addEventListener("dragend", stopDragging)
// Update state
const id = component.dataset.id
const parentId = component.dataset.parent
const parent = findComponentById(
get(componentStore).currentAsset.props,
@ -74,14 +73,13 @@
const index = parent._children.findIndex(
x => x._id === component.dataset.id
)
sourceInfo = {
id: component.dataset.id,
dndStore.actions.startDragging({
id,
bounds: component.children[0].getBoundingClientRect(),
parent: parentId,
index,
}
builderStore.actions.selectComponent(sourceInfo.id)
builderStore.actions.setDragging(true)
})
builderStore.actions.selectComponent(id)
// Set initial drop info to show placeholder exactly where the dragged
// component is.
@ -89,20 +87,20 @@
// the same handler as selecting a new component (which causes a client
// re-initialisation).
setTimeout(() => {
dropInfo = {
dndStore.actions.updateDrop({
parent: parentId,
index,
}
})
}, 0)
}
// Core logic for handling drop events and determining where to render the
// drop target placeholder
const processEvent = (mouseX, mouseY) => {
if (!targetInfo) {
if (!target) {
return null
}
let { id, parent, node, acceptsChildren, empty } = targetInfo
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
@ -115,10 +113,10 @@
// We're now hovering over something which does accept children.
// If it is empty, just go inside it.
if (empty) {
dropInfo = {
dndStore.actions.updateDrop({
parent: id,
index: 0,
}
})
return
}
@ -188,10 +186,10 @@
while (idx < breakpoints.length && breakpoints[idx] < mousePosition) {
idx++
}
dropInfo = {
dndStore.actions.updateDrop({
parent: id,
index: idx,
}
})
}
const throttledProcessEvent = Utils.throttle(processEvent, 130)
@ -202,7 +200,7 @@
// Callback when on top of a component
const onDragOver = e => {
if (!sourceInfo || !targetInfo) {
if (!source || !target) {
return
}
handleEvent(e)
@ -210,69 +208,80 @@
// Callback when entering a potential drop target
const onDragEnter = e => {
if (!sourceInfo) {
if (!source) {
return
}
// Find the next valid component to consider dropping over, ignoring nested
// block components
const component = e.target?.closest?.(
`.component:not(.block):not(.${sourceInfo.id})`
`.component:not(.block):not(.${source.id})`
)
if (component && component.classList.contains("droppable")) {
targetInfo = {
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 = () => {
let target, mode
// Convert parent + index into target + mode
if (sourceInfo && dropInfo?.parent && dropInfo.index != null) {
const parent = findComponentById(
get(componentStore).currentAsset?.props,
dropInfo.parent
)
if (!parent) {
return
}
// Do nothing if we didn't change the location
if (
sourceInfo.parent === dropInfo.parent &&
sourceInfo.index === dropInfo.index
) {
return
}
// Filter out source component and placeholder from consideration
const children = parent._children?.filter(
x => x._id !== "placeholder" && x._id !== sourceInfo.id
)
// Use inside if no existing children
if (!children?.length) {
target = parent._id
mode = "inside"
} else if (dropInfo.index === 0) {
target = children[0]?._id
mode = "above"
} else {
target = children[dropInfo.index - 1]?._id
mode = "below"
}
if (!source || !drop?.parent || drop?.index == null) {
return
}
if (target && mode) {
builderStore.actions.moveComponent(sourceInfo.id, target, mode)
// Check if we're adding a new component rather than moving one
if (source.newComponentType) {
builderStore.actions.dropNewComponent(
source.newComponentType,
drop.parent,
drop.index
)
}
// Convert parent + index into target + mode
let legacyDropTarget, legacyDropMode
const parent = findComponentById(
get(componentStore).currentAsset?.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 !== "placeholder" && 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
)
}
}
@ -298,13 +307,13 @@
</script>
<IndicatorSet
componentId={$builderStore.dndParent}
componentId={$dndParent}
color="var(--spectrum-global-color-static-green-500)"
zIndex="930"
transition
prefix="Inside"
/>
{#if $builderStore.isDragging}
{#if $isDragging}
<DNDPlaceholderOverlay />
{/if}

View File

@ -1,8 +1,8 @@
<script>
import { builderStore } from "stores"
import { dndBounds } from "stores"
import { DNDPlaceholderID } from "constants"
$: style = getStyle($builderStore.dndBounds)
$: style = getStyle($dndBounds)
const getStyle = bounds => {
if (!bounds) {

View File

@ -1,7 +1,7 @@
<script>
import { onMount, onDestroy } from "svelte"
import IndicatorSet from "./IndicatorSet.svelte"
import { builderStore } from "stores"
import { builderStore, isDragging } from "stores"
let componentId
$: zIndex = componentId === $builderStore.selectedComponentId ? 900 : 920
@ -30,7 +30,7 @@
</script>
<IndicatorSet
componentId={$builderStore.isDragging ? null : componentId}
componentId={$isDragging ? null : componentId}
color="var(--spectrum-global-color-static-blue-200)"
transition
{zIndex}

View File

@ -1,5 +1,5 @@
<script>
import { builderStore } from "stores"
import { builderStore, isDragging } from "stores"
import IndicatorSet from "./IndicatorSet.svelte"
$: color = $builderStore.editMode
@ -8,7 +8,7 @@
</script>
<IndicatorSet
componentId={$builderStore.selectedComponentId}
componentId={$isDragging ? null : $builderStore.selectedComponentId}
{color}
zIndex="910"
transition

View File

@ -3,7 +3,7 @@
import SettingsButton from "./SettingsButton.svelte"
import SettingsColorPicker from "./SettingsColorPicker.svelte"
import SettingsPicker from "./SettingsPicker.svelte"
import { builderStore, componentStore } from "stores"
import { builderStore, componentStore, isDragging } from "stores"
import { domDebounce } from "utils/domDebounce"
const verticalOffset = 36
@ -16,7 +16,7 @@
let measured = false
$: definition = $componentStore.selectedComponentDefinition
$: showBar = definition?.showSettingsBar && !$builderStore.isDragging
$: showBar = definition?.showSettingsBar && !$isDragging
$: settings = getBarSettings(definition)
const getBarSettings = definition => {

View File

@ -6,6 +6,7 @@ import {
blockStore,
componentStore,
environmentStore,
dndStore,
} from "./stores"
import loadSpectrumIcons from "@budibase/bbui/spectrum-icons-rollup.js"
import { get } from "svelte/store"
@ -60,6 +61,24 @@ const loadBudibase = async () => {
if (name === "eject-block") {
const block = blockStore.actions.getBlock(payload)
block?.eject()
} else if (name === "dragging-new-component") {
const { dragging, component } = payload
if (dragging) {
dndStore.actions.startDragging({
id: null,
parent: null,
bounds: {
height: 64,
width: 64,
},
index: null,
newComponentType: component,
})
builderStore.actions.setDraggingNewComponent(true)
} else {
dndStore.actions.reset()
builderStore.actions.setDraggingNewComponent(false)
}
}
}

View File

@ -16,13 +16,10 @@ const createBuilderStore = () => {
theme: null,
customTheme: null,
previewDevice: "desktop",
isDragging: false,
draggingNewComponent: false,
navigation: null,
hiddenComponentIds: [],
usedPlugins: null,
dndParent: null,
dndIndex: null,
dndBounds: null,
// Legacy - allow the builder to specify a layout
layout: null,
@ -70,11 +67,19 @@ const createBuilderStore = () => {
mode,
})
},
setDragging: dragging => {
if (dragging === get(store).isDragging) {
dropNewComponent: (component, parent, index) => {
console.log("dispatch", component, parent, index)
dispatchEvent("drop-new-component", {
component,
parent,
index,
})
},
setDraggingNewComponent: draggingNewComponent => {
if (draggingNewComponent === get(store).draggingNewComponent) {
return
}
store.update(state => ({ ...state, isDragging: dragging }))
store.update(state => ({ ...state, draggingNewComponent }))
},
setEditMode: enabled => {
if (enabled === get(store).editMode) {
@ -111,14 +116,6 @@ const createBuilderStore = () => {
// Notify the builder so we can reload component definitions
dispatchEvent("reload-plugin")
},
updateDNDPlaceholder: (parent, index, bounds) => {
store.update(state => {
state.dndParent = parent
state.dndIndex = index
state.dndBounds = bounds
return state
})
},
}
return {
...store,

View File

@ -4,6 +4,7 @@ import { findComponentById, findComponentPathById } from "../utils/components"
import { devToolsStore } from "./devTools"
import { screenStore } from "./screens"
import { builderStore } from "./builder"
import { dndParent } from "./dnd.js"
import Router from "../components/Router.svelte"
import DNDPlaceholder from "../components/preview/DNDPlaceholder.svelte"
import * as AppComponents from "../components/app/index.js"
@ -19,8 +20,8 @@ const createComponentStore = () => {
})
const derivedStore = derived(
[store, builderStore, devToolsStore, screenStore],
([$store, $builderState, $devToolsState, $screenState]) => {
[store, builderStore, devToolsStore, screenStore, dndParent],
([$store, $builderState, $devToolsState, $screenState, $dndParent]) => {
// Avoid any of this logic if we aren't in the builder preview
if (!$builderState.inBuilder && !$devToolsState.visible) {
return {}
@ -40,8 +41,7 @@ const createComponentStore = () => {
// Derive the selected component path
const selectedPath =
findComponentPathById(asset?.props, selectedComponentId) || []
const dndPath =
findComponentPathById(asset?.props, $builderState.dndParent) || []
const dndPath = findComponentPathById(asset?.props, $dndParent) || []
return {
customComponentManifest: $store.customComponentManifest,

View File

@ -0,0 +1,11 @@
import { derived } from "svelte/store"
import { devToolsStore } from "../devTools.js"
import { authStore } from "../auth.js"
// Derive the current role of the logged-in user
export const currentRole = derived(
[devToolsStore, authStore],
([$devToolsStore, $authStore]) => {
return ($devToolsStore.enabled && $devToolsStore.role) || $authStore?.roleId
}
)

View File

@ -0,0 +1,2 @@
export { isDragging } from "./isDragging.js"
export { currentRole } from "./currentRole.js"

View File

@ -0,0 +1,10 @@
import { derived } from "svelte/store"
import { dndStore } from "../dnd"
import { builderStore } from "../builder.js"
// Derive whether we are dragging or not
export const isDragging = derived(
[dndStore, builderStore],
([$dndStore, $builderStore]) =>
$dndStore.source != null || $builderStore.draggingNewComponent
)

View File

@ -0,0 +1,60 @@
import { writable, derived } from "svelte/store"
const createDndStore = () => {
const initialState = {
// Info about the dragged component
source: null,
// Info about the target component being hovered over
target: null,
// Info about where the component would be dropped
drop: null,
}
const store = writable(initialState)
// newComponentType is an optional field to signify we are creating a new
// component rather than moving an existing one
const startDragging = ({ id, parent, bounds, index, newComponentType }) => {
store.set({
...initialState,
source: { id, parent, bounds, index, newComponentType },
})
}
const updateTarget = ({ id, parent, node, empty, acceptsChildren }) => {
store.update(state => {
state.target = { id, parent, node, empty, acceptsChildren }
return state
})
}
const updateDrop = ({ parent, index }) => {
store.update(state => {
state.drop = { parent, index }
return state
})
}
const reset = () => {
store.set(initialState)
}
return {
subscribe: store.subscribe,
actions: {
startDragging,
updateTarget,
updateDrop,
reset,
},
}
}
export const dndStore = createDndStore()
export const dndParent = derived(dndStore, $dndStore => $dndStore.drop?.parent)
export const dndIndex = derived(dndStore, $dndStore => $dndStore.drop?.index)
export const dndBounds = derived(
dndStore,
$dndStore => $dndStore.source?.bounds
)

View File

@ -1,7 +1,3 @@
import { derived } from "svelte/store"
import { devToolsStore } from "./devTools.js"
import { authStore } from "./auth.js"
export { authStore } from "./auth"
export { appStore } from "./app"
export { notificationStore } from "./notification"
@ -19,6 +15,7 @@ export { uploadStore } from "./uploads.js"
export { rowSelectionStore } from "./rowSelection.js"
export { blockStore } from "./blocks.js"
export { environmentStore } from "./environment"
export { dndStore, dndIndex, dndParent, dndBounds } from "./dnd"
// Context stores are layered and duplicated, so it is not a singleton
export { createContextStore } from "./context"
@ -26,10 +23,5 @@ export { createContextStore } from "./context"
// Initialises an app by loading screens and routes
export { initialise } from "./initialise"
// Derive the current role of the logged-in user
export const currentRole = derived(
[devToolsStore, authStore],
([$devToolsStore, $authStore]) => {
return ($devToolsStore.enabled && $devToolsStore.role) || $authStore?.roleId
}
)
// Derived state
export * from "./derived"

View File

@ -2,6 +2,7 @@ import { derived } from "svelte/store"
import { routeStore } from "./routes"
import { builderStore } from "./builder"
import { appStore } from "./app"
import { dndIndex, dndParent } from "./dnd.js"
import { RoleUtils } from "@budibase/frontend-core"
import { findComponentById, findComponentParent } from "../utils/components.js"
import { Helpers } from "@budibase/bbui"
@ -9,8 +10,8 @@ import { DNDPlaceholderID, DNDPlaceholderType } from "constants"
const createScreenStore = () => {
const store = derived(
[appStore, routeStore, builderStore],
([$appStore, $routeStore, $builderStore]) => {
[appStore, routeStore, builderStore, dndParent, dndIndex],
([$appStore, $routeStore, $builderStore, $dndParent, $dndIndex]) => {
let activeLayout, activeScreen
let screens
@ -46,31 +47,33 @@ const createScreenStore = () => {
}
// Insert DND placeholder if required
const { dndParent, dndIndex, selectedComponentId } = $builderStore
if (activeScreen && dndParent && dndIndex != null) {
// Remove selected component from tree
let selectedParent = findComponentParent(
activeScreen.props,
selectedComponentId
)
selectedParent._children = selectedParent._children?.filter(
x => x._id !== selectedComponentId
)
if (activeScreen && $dndParent && $dndIndex != null) {
// Remove selected component from tree if we are moving an existing
// component
const { selectedComponentId, draggingNewComponent } = $builderStore
if (!draggingNewComponent) {
let selectedParent = findComponentParent(
activeScreen.props,
selectedComponentId
)
if (selectedParent) {
selectedParent._children = selectedParent._children?.filter(
x => x._id !== selectedComponentId
)
}
}
// Insert placeholder
// Insert placeholder component
const placeholder = {
_component: DNDPlaceholderID,
_id: DNDPlaceholderType,
static: true,
}
let parent = findComponentById(activeScreen.props, dndParent)
let parent = findComponentById(activeScreen.props, $dndParent)
if (!parent._children?.length) {
parent._children = [placeholder]
} else {
parent._children = parent._children.filter(
x => x._id !== selectedComponentId
)
parent._children.splice(dndIndex, 0, placeholder)
parent._children.splice($dndIndex, 0, placeholder)
}
}