diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index c90ab10c9a..4122954303 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -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 diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte index 309b676a70..a4c4c5b839 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte @@ -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}`) } diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/new/_components/NewComponentPanel.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/new/_components/NewComponentPanel.svelte index 8cef10fb26..07781441c5 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/new/_components/NewComponentPanel.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/new/_components/NewComponentPanel.svelte @@ -169,6 +169,14 @@ window.removeEventListener("keydown", handleKeyDown) } }) + + const onDragStart = component => { + store.actions.dnd.start(component) + } + + const onDragEnd = () => { + store.actions.dnd.stop() + }
@@ -206,6 +214,9 @@
{category.name}
{#each category.children as component}
onDragStart(component.component)} + on:dragend={onDragEnd} data-cy={`component-${component.name}`} class="component" class:selected={selectedIndex === diff --git a/packages/client/src/components/preview/DNDHandler.svelte b/packages/client/src/components/preview/DNDHandler.svelte index 40f52dd1ae..361f11dff2 100644 --- a/packages/client/src/components/preview/DNDHandler.svelte +++ b/packages/client/src/components/preview/DNDHandler.svelte @@ -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 @@ -{#if $builderStore.isDragging} +{#if $isDragging} {/if} diff --git a/packages/client/src/components/preview/DNDPlaceholder.svelte b/packages/client/src/components/preview/DNDPlaceholder.svelte index 9efabdbc27..3725f9e06e 100644 --- a/packages/client/src/components/preview/DNDPlaceholder.svelte +++ b/packages/client/src/components/preview/DNDPlaceholder.svelte @@ -1,8 +1,8 @@ - import { builderStore } from "stores" + import { builderStore, isDragging } from "stores" import IndicatorSet from "./IndicatorSet.svelte" $: color = $builderStore.editMode @@ -8,7 +8,7 @@ { diff --git a/packages/client/src/index.js b/packages/client/src/index.js index 526ed1c10d..17f1c94359 100644 --- a/packages/client/src/index.js +++ b/packages/client/src/index.js @@ -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) + } } } diff --git a/packages/client/src/stores/builder.js b/packages/client/src/stores/builder.js index 0bb5693edb..135c08343e 100644 --- a/packages/client/src/stores/builder.js +++ b/packages/client/src/stores/builder.js @@ -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, diff --git a/packages/client/src/stores/components.js b/packages/client/src/stores/components.js index e1d6012150..71b7deb5a6 100644 --- a/packages/client/src/stores/components.js +++ b/packages/client/src/stores/components.js @@ -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, diff --git a/packages/client/src/stores/derived/currentRole.js b/packages/client/src/stores/derived/currentRole.js new file mode 100644 index 0000000000..28287e1ea4 --- /dev/null +++ b/packages/client/src/stores/derived/currentRole.js @@ -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 + } +) diff --git a/packages/client/src/stores/derived/index.js b/packages/client/src/stores/derived/index.js new file mode 100644 index 0000000000..85df1aaafa --- /dev/null +++ b/packages/client/src/stores/derived/index.js @@ -0,0 +1,2 @@ +export { isDragging } from "./isDragging.js" +export { currentRole } from "./currentRole.js" diff --git a/packages/client/src/stores/derived/isDragging.js b/packages/client/src/stores/derived/isDragging.js new file mode 100644 index 0000000000..d3edd586c6 --- /dev/null +++ b/packages/client/src/stores/derived/isDragging.js @@ -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 +) diff --git a/packages/client/src/stores/dnd.js b/packages/client/src/stores/dnd.js new file mode 100644 index 0000000000..805ec8c339 --- /dev/null +++ b/packages/client/src/stores/dnd.js @@ -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 +) diff --git a/packages/client/src/stores/index.js b/packages/client/src/stores/index.js index 5b77762223..f86c0a0517 100644 --- a/packages/client/src/stores/index.js +++ b/packages/client/src/stores/index.js @@ -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" diff --git a/packages/client/src/stores/screens.js b/packages/client/src/stores/screens.js index 5c630d2adf..b0bc06eee9 100644 --- a/packages/client/src/stores/screens.js +++ b/packages/client/src/stores/screens.js @@ -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) } }