Merge pull request #8376 from Budibase/cheeks-lab-day-grid

Grid component + builder performance improvements
This commit is contained in:
Andrew Kingston 2022-10-25 08:20:37 +01:00 committed by GitHub
commit 75aae5b7e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 987 additions and 314 deletions

View File

@ -182,7 +182,70 @@ export const getFrontendStore = () => {
return state return state
}) })
}, },
validate: screen => {
// Recursive function to find any illegal children in component trees
const findIllegalChild = (
component,
illegalChildren = [],
legalDirectChildren = []
) => {
const type = component._component
if (illegalChildren.includes(type)) {
return type
}
if (
legalDirectChildren.length &&
!legalDirectChildren.includes(type)
) {
return type
}
if (!component?._children?.length) {
return
}
const definition = store.actions.components.getDefinition(
component._component
)
// Reset whitelist for direct children
legalDirectChildren = []
if (definition?.legalDirectChildren?.length) {
legalDirectChildren = definition.legalDirectChildren.map(x => {
return `@budibase/standard-components/${x}`
})
}
// Append blacklisted components and remove duplicates
if (definition?.illegalChildren?.length) {
const blacklist = definition.illegalChildren.map(x => {
return `@budibase/standard-components/${x}`
})
illegalChildren = [...new Set([...illegalChildren, ...blacklist])]
}
// Recurse on all children
for (let child of component._children) {
const illegalChild = findIllegalChild(
child,
illegalChildren,
legalDirectChildren
)
if (illegalChild) {
return illegalChild
}
}
}
// Validate the entire tree and throw an error if an illegal child is
// found anywhere
const illegalChild = findIllegalChild(screen.props)
if (illegalChild) {
const def = store.actions.components.getDefinition(illegalChild)
throw `You can't place a ${def.name} here`
}
},
save: async screen => { save: async screen => {
store.actions.screens.validate(screen)
const state = get(store) const state = get(store)
const creatingNewScreen = screen._id === undefined const creatingNewScreen = screen._id === undefined
const savedScreen = await API.saveScreen(screen) const savedScreen = await API.saveScreen(screen)
@ -445,7 +508,11 @@ export const getFrontendStore = () => {
return { return {
_id: Helpers.uuid(), _id: Helpers.uuid(),
_component: definition.component, _component: definition.component,
_styles: { normal: {}, hover: {}, active: {} }, _styles: {
normal: {},
hover: {},
active: {},
},
_instanceName: `New ${definition.friendlyName || definition.name}`, _instanceName: `New ${definition.friendlyName || definition.name}`,
...cloneDeep(props), ...cloneDeep(props),
...extras, ...extras,
@ -533,12 +600,11 @@ export const getFrontendStore = () => {
}, },
patch: async (patchFn, componentId, screenId) => { patch: async (patchFn, componentId, screenId) => {
// Use selected component by default // Use selected component by default
if (!componentId && !screenId) { if (!componentId || !screenId) {
const state = get(store) const state = get(store)
componentId = state.selectedComponentId componentId = componentId || state.selectedComponentId
screenId = state.selectedScreenId screenId = screenId || state.selectedScreenId
} }
// Invalid if only a screen or component ID provided
if (!componentId || !screenId || !patchFn) { if (!componentId || !screenId || !patchFn) {
return return
} }
@ -601,18 +667,16 @@ export const getFrontendStore = () => {
}) })
// Select the parent if cutting // Select the parent if cutting
if (cut) { if (cut && selectParent) {
const screen = get(selectedScreen) const screen = get(selectedScreen)
const parent = findComponentParent(screen?.props, component._id) const parent = findComponentParent(screen?.props, component._id)
if (parent) { if (parent) {
if (selectParent) {
store.update(state => { store.update(state => {
state.selectedComponentId = parent._id state.selectedComponentId = parent._id
return state return state
}) })
} }
} }
}
}, },
paste: async (targetComponent, mode, targetScreen) => { paste: async (targetComponent, mode, targetScreen) => {
const state = get(store) const state = get(store)
@ -621,16 +685,24 @@ export const getFrontendStore = () => {
} }
let newComponentId let newComponentId
// Remove copied component if cutting, regardless if pasting works
let componentToPaste = cloneDeep(state.componentToPaste)
if (componentToPaste.isCut) {
store.update(state => {
delete state.componentToPaste
return state
})
}
// Patch screen // Patch screen
const patch = screen => { const patch = screen => {
// Get up to date ref to target // Get up to date ref to target
targetComponent = findComponent(screen.props, targetComponent._id) targetComponent = findComponent(screen.props, targetComponent._id)
if (!targetComponent) { if (!targetComponent) {
return return false
} }
const cut = state.componentToPaste.isCut const cut = componentToPaste.isCut
const originalId = state.componentToPaste._id const originalId = componentToPaste._id
let componentToPaste = cloneDeep(state.componentToPaste)
delete componentToPaste.isCut delete componentToPaste.isCut
// Make new component unique if copying // Make new component unique if copying
@ -685,11 +757,8 @@ export const getFrontendStore = () => {
const targetScreenId = targetScreen?._id || state.selectedScreenId const targetScreenId = targetScreen?._id || state.selectedScreenId
await store.actions.screens.patch(patch, targetScreenId) await store.actions.screens.patch(patch, targetScreenId)
// Select the new component
store.update(state => { store.update(state => {
// Remove copied component if cutting
if (state.componentToPaste.isCut) {
delete state.componentToPaste
}
state.selectedScreenId = targetScreenId state.selectedScreenId = targetScreenId
state.selectedComponentId = newComponentId state.selectedComponentId = newComponentId
return state return state
@ -893,6 +962,15 @@ export const getFrontendStore = () => {
} }
}) })
}, },
updateStyles: async (styles, id) => {
const patchFn = component => {
component._styles.normal = {
...component._styles.normal,
...styles,
}
}
await store.actions.components.patch(patchFn, id)
},
updateCustomStyle: async style => { updateCustomStyle: async style => {
await store.actions.components.patch(component => { await store.actions.components.patch(component => {
component._styles.custom = style component._styles.custom = style

View File

@ -86,7 +86,11 @@
: [], : [],
isBudibaseEvent: true, isBudibaseEvent: true,
usedPlugins: $store.usedPlugins, usedPlugins: $store.usedPlugins,
location: window.location, location: {
protocol: window.location.protocol,
hostname: window.location.hostname,
port: window.location.port,
},
} }
// Refresh the preview when required // Refresh the preview when required
@ -99,7 +103,7 @@
) )
// Register handler to send custom to the preview // Register handler to send custom to the preview
$: store.actions.preview.registerEventHandler((name, payload) => { $: sendPreviewEvent = (name, payload) => {
iframe?.contentWindow.postMessage( iframe?.contentWindow.postMessage(
JSON.stringify({ JSON.stringify({
name, name,
@ -108,53 +112,53 @@
runtimeEvent: true, runtimeEvent: true,
}) })
) )
}) }
$: store.actions.preview.registerEventHandler(sendPreviewEvent)
// Update the iframe with the builder info to render the correct preview // Update the iframe with the builder info to render the correct preview
const refreshContent = message => { const refreshContent = message => {
iframe?.contentWindow.postMessage(message) iframe?.contentWindow.postMessage(message)
} }
const receiveMessage = message => { const receiveMessage = async message => {
const handlers = { if (!message?.data?.type) {
[MessageTypes.READY]: () => {
// Initialise the app when mounted
if ($store.clientFeatures.messagePassing) {
if (!loading) return
}
// Display preview immediately if the intelligent loading feature
// is not supported
if (!$store.clientFeatures.intelligentLoading) {
loading = false
}
refreshContent(json)
},
[MessageTypes.ERROR]: event => {
// Catch any app errors
loading = false
error = event.error || "An unknown error occurred"
},
}
const messageHandler = handlers[message.data.type] || handleBudibaseEvent
messageHandler(message)
}
const handleBudibaseEvent = async event => {
const { type, data } = event.data || event.detail
if (!type) {
return return
} }
// Await the event handler
try { try {
if (type === "select-component" && data.id) { await handleBudibaseEvent(message)
} catch (error) {
notifications.error(error || "Error handling event from app preview")
}
// Reply that the event has been completed
if (message.data?.id) {
sendPreviewEvent("event-completed", message.data?.id)
}
}
const handleBudibaseEvent = async event => {
const { type, data } = event.data
if (type === MessageTypes.READY) {
// Initialise the app when mounted
if (!loading) {
return
}
refreshContent(json)
} else if (type === MessageTypes.ERROR) {
// Catch any app errors
loading = false
error = event.error || "An unknown error occurred"
} else if (type === "select-component" && data.id) {
$store.selectedComponentId = data.id $store.selectedComponentId = data.id
if (!$isActive("./components")) { if (!$isActive("./components")) {
$goto("./components") $goto("./components")
} }
} else if (type === "update-prop") { } else if (type === "update-prop") {
await store.actions.components.updateSetting(data.prop, data.value) await store.actions.components.updateSetting(data.prop, data.value)
} else if (type === "update-styles") {
await store.actions.components.updateStyles(data.styles, data.id)
} else if (type === "delete-component" && data.id) { } else if (type === "delete-component" && data.id) {
// Legacy type, can be deleted in future // Legacy type, can be deleted in future
confirmDeleteComponent(data.id) confirmDeleteComponent(data.id)
@ -187,7 +191,7 @@
// Cut and paste the component to the new destination // Cut and paste the component to the new destination
if (source && destination) { if (source && destination) {
store.actions.components.copy(source, true) store.actions.components.copy(source, true, false)
await store.actions.components.paste(destination, data.mode) await store.actions.components.paste(destination, data.mode)
} }
} else if (type === "click-nav") { } else if (type === "click-nav") {
@ -219,10 +223,6 @@
} else { } else {
console.warn(`Client sent unknown event type: ${type}`) console.warn(`Client sent unknown event type: ${type}`)
} }
} catch (error) {
console.warn(error)
notifications.error("Error handling event from app preview")
}
} }
const confirmDeleteComponent = componentId => { const confirmDeleteComponent = componentId => {
@ -254,42 +254,10 @@
onMount(() => { onMount(() => {
window.addEventListener("message", receiveMessage) window.addEventListener("message", receiveMessage)
if (!$store.clientFeatures.messagePassing) {
// Legacy - remove in later versions of BB
iframe.contentWindow.addEventListener(
"ready",
() => {
receiveMessage({ data: { type: MessageTypes.READY } })
},
{ once: true }
)
iframe.contentWindow.addEventListener(
"error",
event => {
receiveMessage({
data: { type: MessageTypes.ERROR, error: event.detail },
})
},
{ once: true }
)
// Add listener for events sent by client library in preview
iframe.contentWindow.addEventListener("bb-event", handleBudibaseEvent)
}
}) })
// Remove all iframe event listeners on component destroy
onDestroy(() => { onDestroy(() => {
window.removeEventListener("message", receiveMessage) window.removeEventListener("message", receiveMessage)
if (iframe.contentWindow) {
if (!$store.clientFeatures.messagePassing) {
// Legacy - remove in later versions of BB
iframe.contentWindow.removeEventListener(
"bb-event",
handleBudibaseEvent
)
}
}
}) })
</script> </script>

View File

@ -80,10 +80,9 @@
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
} }
return handler(component) return await handler(component)
} catch (error) { } catch (error) {
console.error(error) notifications.error(error || "Error handling key press")
notifications.error("Error handling key press")
} }
} }

View File

@ -70,34 +70,12 @@
closedNodes = closedNodes closedNodes = closedNodes
} }
const onDrop = async (e, component) => { const onDrop = async e => {
e.stopPropagation() e.stopPropagation()
try { try {
const compDef = store.actions.components.getDefinition(
$dndStore.source?._component
)
if (!compDef) {
return
}
const compTypeName = compDef.name.toLowerCase()
const path = findComponentPath(currentScreen.props, component._id)
for (let pathComp of path) {
const pathCompDef = store.actions.components.getDefinition(
pathComp?._component
)
if (pathCompDef?.illegalChildren?.indexOf(compTypeName) > -1) {
notifications.warning(
`${compDef.name} cannot be a child of ${pathCompDef.name} (${pathComp._instanceName})`
)
return
}
}
await dndStore.actions.drop() await dndStore.actions.drop()
} catch (error) { } catch (error) {
console.error(error) notifications.error(error || "Error saving component")
notifications.error("Error saving component")
} }
} }
@ -137,9 +115,7 @@
on:dragstart={() => dndStore.actions.dragstart(component)} on:dragstart={() => dndStore.actions.dragstart(component)}
on:dragover={dragover(component, index)} on:dragover={dragover(component, index)}
on:iconClick={() => toggleNodeOpen(component._id)} on:iconClick={() => toggleNodeOpen(component._id)}
on:drop={e => { on:drop={onDrop}
onDrop(e, component)
}}
text={getComponentText(component)} text={getComponentText(component)}
icon={getComponentIcon(component)} icon={getComponentIcon(component)}
withArrow={componentHasChildren(component)} withArrow={componentHasChildren(component)}

View File

@ -11,9 +11,10 @@
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import structure from "./componentStructure.json" import structure from "./componentStructure.json"
import { store, selectedComponent } from "builderStore" import { store, selectedComponent, selectedScreen } from "builderStore"
import { onMount } from "svelte" import { onMount } from "svelte"
import { fly } from "svelte/transition" import { fly } from "svelte/transition"
import { findComponentPath } from "builderStore/componentUtils"
let section = "components" let section = "components"
let searchString let searchString
@ -21,8 +22,10 @@
let selectedIndex let selectedIndex
let componentList = [] let componentList = []
$: currentDefinition = store.actions.components.getDefinition( $: allowedComponents = getAllowedComponents(
$selectedComponent?._component $store.components,
$selectedScreen,
$selectedComponent
) )
$: enrichedStructure = enrichStructure( $: enrichedStructure = enrichStructure(
structure, structure,
@ -31,13 +34,50 @@
) )
$: filteredStructure = filterStructure( $: filteredStructure = filterStructure(
enrichedStructure, enrichedStructure,
section, allowedComponents,
currentDefinition,
searchString searchString
) )
$: blocks = enrichedStructure.find(x => x.name === "Blocks").children $: blocks = enrichedStructure.find(x => x.name === "Blocks").children
$: orderMap = createComponentOrderMap(componentList) $: orderMap = createComponentOrderMap(componentList)
const getAllowedComponents = (allComponents, screen, component) => {
const path = findComponentPath(screen?.props, component?._id)
if (!path?.length) {
return []
}
// Get initial set of allowed components
let allowedComponents = []
const definition = store.actions.components.getDefinition(
component?._component
)
if (definition.legalDirectChildren?.length) {
allowedComponents = definition.legalDirectChildren.map(x => {
return `@budibase/standard-components/${x}`
})
} else {
allowedComponents = Object.keys(allComponents)
}
// Build up list of illegal children from ancestors
let illegalChildren = definition.illegalChildren || []
path.forEach(ancestor => {
const def = store.actions.components.getDefinition(ancestor._component)
const blacklist = def?.illegalChildren?.map(x => {
return `@budibase/standard-components/${x}`
})
illegalChildren = [...illegalChildren, ...(blacklist || [])]
})
illegalChildren = [...new Set(illegalChildren)]
// Filter out illegal children from allowed components
allowedComponents = allowedComponents.filter(x => {
return !illegalChildren.includes(x)
})
return allowedComponents
}
// Creates a simple lookup map from an array, so we can find the selected // Creates a simple lookup map from an array, so we can find the selected
// component much faster // component much faster
const createComponentOrderMap = list => { const createComponentOrderMap = list => {
@ -90,7 +130,7 @@
return enrichedStructure return enrichedStructure
} }
const filterStructure = (structure, section, currentDefinition, search) => { const filterStructure = (structure, allowedComponents, search) => {
selectedIndex = search ? 0 : null selectedIndex = search ? 0 : null
componentList = [] componentList = []
if (!structure?.length) { if (!structure?.length) {
@ -114,7 +154,7 @@
} }
// Check if the component is allowed as a child // Check if the component is allowed as a child
return !currentDefinition?.illegalChildren?.includes(name) return allowedComponents.includes(child.component)
}) })
if (matchedChildren.length) { if (matchedChildren.length) {
filteredStructure.push({ filteredStructure.push({
@ -138,7 +178,7 @@
await store.actions.components.create(component) await store.actions.components.create(component)
$goto("../") $goto("../")
} catch (error) { } catch (error) {
notifications.error("Error creating component") notifications.error(error || "Error creating component")
} }
} }

View File

@ -15,7 +15,8 @@
"icon": "ClassicGridView", "icon": "ClassicGridView",
"children": [ "children": [
"container", "container",
"section" "section",
"grid"
] ]
}, },
{ {

View File

@ -87,7 +87,7 @@
"showSettingsBar": true, "showSettingsBar": true,
"size": { "size": {
"width": 400, "width": 400,
"height": 100 "height": 200
}, },
"styles": [ "styles": [
"padding", "padding",
@ -5037,6 +5037,45 @@
} }
] ]
}, },
"grid": {
"name": "Grid (Beta)",
"icon": "ViewGrid",
"hasChildren": true,
"styles": [
"size"
],
"illegalChildren": ["section", "grid"],
"legalDirectChildren": [
"container",
"tableblock",
"cardsblock",
"repeaterblock",
"formblock"
],
"size": {
"width": 800,
"height": 400
},
"showEmptyState": false,
"settings": [
{
"type": "number",
"label": "Rows",
"key": "rows",
"defaultValue": 12,
"min": 1,
"max": 32
},
{
"type": "number",
"label": "Columns",
"key": "cols",
"defaultValue": 12,
"min": 1,
"max": 32
}
]
},
"formblock": { "formblock": {
"name": "Form Block", "name": "Form Block",
"icon": "Form", "icon": "Form",

View File

@ -30,6 +30,7 @@
import HoverIndicator from "components/preview/HoverIndicator.svelte" import HoverIndicator from "components/preview/HoverIndicator.svelte"
import CustomThemeWrapper from "./CustomThemeWrapper.svelte" import CustomThemeWrapper from "./CustomThemeWrapper.svelte"
import DNDHandler from "components/preview/DNDHandler.svelte" import DNDHandler from "components/preview/DNDHandler.svelte"
import GridDNDHandler from "components/preview/GridDNDHandler.svelte"
import KeyboardManager from "components/preview/KeyboardManager.svelte" import KeyboardManager from "components/preview/KeyboardManager.svelte"
import DevToolsHeader from "components/devtools/DevToolsHeader.svelte" import DevToolsHeader from "components/devtools/DevToolsHeader.svelte"
import DevTools from "components/devtools/DevTools.svelte" import DevTools from "components/devtools/DevTools.svelte"
@ -196,6 +197,7 @@
{/if} {/if}
{#if $builderStore.inBuilder} {#if $builderStore.inBuilder}
<DNDHandler /> <DNDHandler />
<GridDNDHandler />
{/if} {/if}
</div> </div>
</QueryParamsProvider> </QueryParamsProvider>

View File

@ -21,8 +21,8 @@
devToolsStore, devToolsStore,
componentStore, componentStore,
appStore, appStore,
dndIsDragging,
dndComponentPath, dndComponentPath,
dndIsDragging,
} from "stores" } from "stores"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { getActiveConditions, reduceConditionActions } from "utils/conditions" import { getActiveConditions, reduceConditionActions } from "utils/conditions"
@ -90,6 +90,10 @@
let settingsDefinitionMap let settingsDefinitionMap
let missingRequiredSettings = false let missingRequiredSettings = false
// Temporary styles which can be added in the app preview for things like DND.
// We clear these whenever a new instance is received.
let ephemeralStyles
// Set up initial state for each new component instance // Set up initial state for each new component instance
$: initialise(instance) $: initialise(instance)
@ -171,6 +175,10 @@
children: children.length, children: children.length,
styles: { styles: {
...instance._styles, ...instance._styles,
normal: {
...instance._styles?.normal,
...ephemeralStyles,
},
custom: customCSS, custom: customCSS,
id, id,
empty: emptyState, empty: emptyState,
@ -449,6 +457,7 @@
getRawSettings: () => ({ ...staticSettings, ...dynamicSettings }), getRawSettings: () => ({ ...staticSettings, ...dynamicSettings }),
getDataContext: () => get(context), getDataContext: () => get(context),
reload: () => initialise(instance, true), reload: () => initialise(instance, true),
setEphemeralStyles: styles => (ephemeralStyles = styles),
}) })
} }
}) })

View File

@ -0,0 +1,102 @@
<script>
import { getContext } from "svelte"
const component = getContext("component")
const { styleable, builderStore } = getContext("sdk")
export let cols = 12
export let rows = 12
// Deliberately non-reactive as we want this fixed whenever the grid renders
const defaultColSpan = Math.ceil((cols + 1) / 2)
const defaultRowSpan = Math.ceil((rows + 1) / 2)
$: coords = generateCoords(rows, cols)
const generateCoords = (rows, cols) => {
let grid = []
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
grid.push({ row, col })
}
}
return grid
}
</script>
<div
class="grid"
use:styleable={{
...$component.styles,
normal: {
...$component.styles?.normal,
"--cols": cols,
"--rows": rows,
"--default-col-span": defaultColSpan,
"--default-row-span": defaultRowSpan,
gap: "0 !important",
},
}}
data-rows={rows}
data-cols={cols}
>
{#if $builderStore.inBuilder}
<div class="underlay">
{#each coords as coord}
<div class="placeholder" />
{/each}
</div>
{/if}
<slot />
</div>
<style>
/*
Ensure all children of containers which are top level children of
grids do not overflow
*/
:global(.grid > .component > .valid-container > .component > *) {
max-height: 100%;
max-width: 100%;
}
/* Ensure all top level children have some grid styles set */
:global(.grid > .component > *) {
overflow: hidden;
width: auto;
height: auto;
grid-column-start: 1;
grid-column-end: var(--default-col-span);
grid-row-start: 1;
grid-row-end: var(--default-row-span);
max-height: 100%;
max-width: 100%;
}
.grid {
position: relative;
height: 400px;
}
.grid,
.underlay {
display: grid;
grid-template-rows: repeat(var(--rows), 1fr);
grid-template-columns: repeat(var(--cols), 1fr);
}
.underlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
grid-gap: 2px;
background-color: var(--spectrum-global-color-gray-200);
border: 2px solid var(--spectrum-global-color-gray-200);
}
.underlay {
z-index: -1;
}
.placeholder {
background-color: var(--spectrum-global-color-gray-100);
}
</style>

View File

@ -34,6 +34,7 @@ export { default as spectrumcard } from "./SpectrumCard.svelte"
export { default as tag } from "./Tag.svelte" export { default as tag } from "./Tag.svelte"
export { default as markdownviewer } from "./MarkdownViewer.svelte" export { default as markdownviewer } from "./MarkdownViewer.svelte"
export { default as embeddedmap } from "./embedded-map/EmbeddedMap.svelte" export { default as embeddedmap } from "./embedded-map/EmbeddedMap.svelte"
export { default as grid } from "./Grid.svelte"
export * from "./charts" export * from "./charts"
export * from "./forms" export * from "./forms"
export * from "./table" export * from "./table"

View File

@ -22,6 +22,18 @@
$: target = $dndStore.target $: target = $dndStore.target
$: drop = $dndStore.drop $: drop = $dndStore.drop
// Local flag for whether we are awaiting an async drop event
let dropping = false
// Util to check if a DND event originates from a grid (or inside a grid).
// This is important as we do not handle grid DND in this handler.
const isGridEvent = e => {
return e.target
?.closest?.(".component")
?.parentNode?.closest?.(".component")
?.childNodes[0]?.classList.contains("grid")
}
// Util to get the inner DOM node by a component ID // Util to get the inner DOM node by a component ID
const getDOMNode = id => { const getDOMNode = id => {
const component = document.getElementsByClassName(id)[0] const component = document.getElementsByClassName(id)[0]
@ -41,6 +53,10 @@
// Callback when drag stops (whether dropped or not) // Callback when drag stops (whether dropped or not)
const stopDragging = () => { const stopDragging = () => {
if (dropping) {
return
}
// Reset listener // Reset listener
if (source?.id) { if (source?.id) {
const component = document.getElementsByClassName(source?.id)[0] const component = document.getElementsByClassName(source?.id)[0]
@ -55,6 +71,9 @@
// Callback when initially starting a drag on a draggable component // Callback when initially starting a drag on a draggable component
const onDragStart = e => { const onDragStart = e => {
if (isGridEvent(e)) {
return
}
const component = e.target.closest(".component") const component = e.target.closest(".component")
if (!component?.classList.contains("draggable")) { if (!component?.classList.contains("draggable")) {
return return
@ -99,9 +118,9 @@
// Core logic for handling drop events and determining where to render the // Core logic for handling drop events and determining where to render the
// drop target placeholder // drop target placeholder
const processEvent = (mouseX, mouseY) => { const processEvent = Utils.throttle((mouseX, mouseY) => {
if (!target) { if (!target) {
return null return
} }
let { id, parent, node, acceptsChildren, empty } = target let { id, parent, node, acceptsChildren, empty } = target
@ -201,15 +220,15 @@
parent: id, parent: id,
index: idx, index: idx,
}) })
} }, ThrottleRate)
const throttledProcessEvent = Utils.throttle(processEvent, ThrottleRate)
const handleEvent = e => { const handleEvent = e => {
e.preventDefault() e.preventDefault()
throttledProcessEvent(e.clientX, e.clientY) e.stopPropagation()
processEvent(e.clientX, e.clientY)
} }
// Callback when on top of a component // Callback when on top of a component.
const onDragOver = e => { const onDragOver = e => {
if (!source || !target) { if (!source || !target) {
return return
@ -241,18 +260,21 @@
} }
// Callback when dropping a drag on top of some component // Callback when dropping a drag on top of some component
const onDrop = () => { const onDrop = async () => {
if (!source || !drop?.parent || drop?.index == null) { if (!source || !drop?.parent || drop?.index == null) {
return return
} }
// Check if we're adding a new component rather than moving one // Check if we're adding a new component rather than moving one
if (source.newComponentType) { if (source.newComponentType) {
builderStore.actions.dropNewComponent( dropping = true
await builderStore.actions.dropNewComponent(
source.newComponentType, source.newComponentType,
drop.parent, drop.parent,
drop.index drop.index
) )
dropping = false
stopDragging()
return return
} }
@ -289,11 +311,14 @@
} }
if (legacyDropTarget && legacyDropMode) { if (legacyDropTarget && legacyDropMode) {
builderStore.actions.moveComponent( dropping = true
await builderStore.actions.moveComponent(
source.id, source.id,
legacyDropTarget, legacyDropTarget,
legacyDropMode legacyDropMode
) )
dropping = false
stopDragging()
} }
} }

View File

@ -1,33 +0,0 @@
<script>
import { dndBounds } from "stores"
import { DNDPlaceholderID } from "constants"
$: style = getStyle($dndBounds)
const getStyle = bounds => {
if (!bounds) {
return null
}
return `--height: ${bounds.height}px; --width: ${bounds.width}px;`
}
</script>
{#if style}
<div class="wrapper">
<div class="placeholder" id={DNDPlaceholderID} {style} />
</div>
{/if}
<style>
.wrapper {
overflow: hidden;
}
.placeholder {
display: block;
height: var(--height);
width: var(--width);
max-height: 100%;
max-width: 100%;
opacity: 0;
}
</style>

View File

@ -6,7 +6,8 @@
let left, top, height, width let left, top, height, width
const updatePosition = () => { const updatePosition = () => {
const node = document.getElementById(DNDPlaceholderID) const node =
document.getElementsByClassName(DNDPlaceholderID)[0]?.childNodes[0]
if (!node) { if (!node) {
height = 0 height = 0
width = 0 width = 0

View File

@ -0,0 +1,227 @@
<script>
import { onMount, onDestroy } from "svelte"
import { builderStore, componentStore } from "stores"
import { Utils } from "@budibase/frontend-core"
let dragInfo
let gridStyles
let id
// Some memoisation of primitive types for performance
$: jsonStyles = JSON.stringify(gridStyles)
$: id = dragInfo?.id || id
// Set ephemeral grid styles on the dragged component
$: componentStore.actions.getComponentInstance(id)?.setEphemeralStyles({
...gridStyles,
...(gridStyles ? { "z-index": 999 } : null),
})
// Util to check if a DND event originates from a grid (or inside a grid).
// This is important as we do not handle grid DND in this handler.
const isGridEvent = e => {
return (
e.target
.closest?.(".component")
?.parentNode.closest(".component")
?.childNodes[0].classList.contains("grid") ||
e.target.classList.contains("anchor")
)
}
// Util to get the inner DOM node by a component ID
const getDOMNode = id => {
const component = document.getElementsByClassName(id)[0]
return [...component.children][0]
}
const processEvent = Utils.throttle((mouseX, mouseY) => {
if (!dragInfo?.grid) {
return
}
const { mode, side, gridId, grid } = dragInfo
const {
startX,
startY,
rowStart,
rowEnd,
colStart,
colEnd,
rowDeltaMin,
rowDeltaMax,
colDeltaMin,
colDeltaMax,
} = grid
const domGrid = getDOMNode(gridId)
const cols = parseInt(domGrid.dataset.cols)
const rows = parseInt(domGrid.dataset.rows)
const { width, height } = domGrid.getBoundingClientRect()
const colWidth = width / cols
const diffX = mouseX - startX
let deltaX = Math.round(diffX / colWidth)
const rowHeight = height / rows
const diffY = mouseY - startY
let deltaY = Math.round(diffY / rowHeight)
if (mode === "move") {
deltaY = Math.min(Math.max(deltaY, rowDeltaMin), rowDeltaMax)
deltaX = Math.min(Math.max(deltaX, colDeltaMin), colDeltaMax)
const newStyles = {
"grid-row-start": rowStart + deltaY,
"grid-row-end": rowEnd + deltaY,
"grid-column-start": colStart + deltaX,
"grid-column-end": colEnd + deltaX,
}
if (JSON.stringify(newStyles) !== jsonStyles) {
gridStyles = newStyles
}
} else if (mode === "resize") {
let newStyles = {}
if (side === "right") {
newStyles["grid-column-end"] = Math.max(colEnd + deltaX, colStart + 1)
} else if (side === "left") {
newStyles["grid-column-start"] = Math.min(colStart + deltaX, colEnd - 1)
} else if (side === "top") {
newStyles["grid-row-start"] = Math.min(rowStart + deltaY, rowEnd - 1)
} else if (side === "bottom") {
newStyles["grid-row-end"] = Math.max(rowEnd + deltaY, rowStart + 1)
} else if (side === "bottom-right") {
newStyles["grid-column-end"] = Math.max(colEnd + deltaX, colStart + 1)
newStyles["grid-row-end"] = Math.max(rowEnd + deltaY, rowStart + 1)
} else if (side === "bottom-left") {
newStyles["grid-column-start"] = Math.min(colStart + deltaX, colEnd - 1)
newStyles["grid-row-end"] = Math.max(rowEnd + deltaY, rowStart + 1)
} else if (side === "top-right") {
newStyles["grid-column-end"] = Math.max(colEnd + deltaX, colStart + 1)
newStyles["grid-row-start"] = Math.min(rowStart + deltaY, rowEnd - 1)
} else if (side === "top-left") {
newStyles["grid-column-start"] = Math.min(colStart + deltaX, colEnd - 1)
newStyles["grid-row-start"] = Math.min(rowStart + deltaY, rowEnd - 1)
}
if (JSON.stringify(newStyles) !== jsonStyles) {
gridStyles = newStyles
}
}
}, 100)
const handleEvent = e => {
e.preventDefault()
e.stopPropagation()
processEvent(e.clientX, e.clientY)
}
// Callback when initially starting a drag on a draggable component
const onDragStart = e => {
if (!isGridEvent(e)) {
return
}
// Hide drag ghost image
e.dataTransfer.setDragImage(new Image(), 0, 0)
// Extract state
let mode, id, side
if (e.target.classList.contains("anchor")) {
// Handle resize
mode = "resize"
id = e.target.dataset.id
side = e.target.dataset.side
} else {
// Handle move
mode = "move"
const component = e.target.closest(".component")
id = component.dataset.id
}
// Find grid parent
const domComponent = getDOMNode(id)
const gridId = domComponent?.closest(".grid")?.parentNode.dataset.id
if (!gridId) {
return
}
// Update state
dragInfo = {
domTarget: e.target,
id,
gridId,
mode,
side,
}
// Add event handler to clear all drag state when dragging ends
dragInfo.domTarget.addEventListener("dragend", stopDragging)
}
// Callback when entering a potential drop target
const onDragEnter = e => {
// Skip if we aren't validly dragging currently
if (!dragInfo || dragInfo.grid) {
return
}
const domGrid = getDOMNode(dragInfo.gridId)
const gridCols = parseInt(domGrid.dataset.cols)
const gridRows = parseInt(domGrid.dataset.rows)
const domNode = getDOMNode(dragInfo.id)
const styles = window.getComputedStyle(domNode)
if (domGrid) {
const minMax = (value, min, max) => Math.min(max, Math.max(min, value))
const getStyle = x => parseInt(styles?.[x] || "0")
const getColStyle = x => minMax(getStyle(x), 1, gridCols + 1)
const getRowStyle = x => minMax(getStyle(x), 1, gridRows + 1)
dragInfo.grid = {
startX: e.clientX,
startY: e.clientY,
rowStart: getRowStyle("grid-row-start"),
rowEnd: getRowStyle("grid-row-end"),
colStart: getColStyle("grid-column-start"),
colEnd: getColStyle("grid-column-end"),
rowDeltaMin: 1 - getRowStyle("grid-row-start"),
rowDeltaMax: gridRows + 1 - getRowStyle("grid-row-end"),
colDeltaMin: 1 - getColStyle("grid-column-start"),
colDeltaMax: gridCols + 1 - getColStyle("grid-column-end"),
}
handleEvent(e)
}
}
const onDragOver = e => {
if (!dragInfo?.grid) {
return
}
handleEvent(e)
}
// Callback when drag stops (whether dropped or not)
const stopDragging = async () => {
// Save changes
if (gridStyles) {
await builderStore.actions.updateStyles(gridStyles, dragInfo.id)
}
// Reset listener
if (dragInfo?.domTarget) {
dragInfo.domTarget.removeEventListener("dragend", stopDragging)
}
// Reset state
dragInfo = null
gridStyles = null
}
onMount(() => {
document.addEventListener("dragstart", onDragStart, false)
document.addEventListener("dragenter", onDragEnter, false)
document.addEventListener("dragover", onDragOver, false)
})
onDestroy(() => {
document.removeEventListener("dragstart", onDragStart, false)
document.removeEventListener("dragenter", onDragEnter, false)
document.removeEventListener("dragover", onDragOver, false)
})
</script>

View File

@ -4,11 +4,25 @@
import { builderStore, dndIsDragging } from "stores" import { builderStore, dndIsDragging } from "stores"
let componentId let componentId
$: zIndex = componentId === $builderStore.selectedComponentId ? 900 : 920 $: zIndex = componentId === $builderStore.selectedComponentId ? 900 : 920
const onMouseOver = e => { const onMouseOver = e => {
// Ignore if dragging
if (e.buttons > 0) {
return
}
let newId
if (e.target.classList.contains("anchor")) {
// Handle resize anchors
newId = e.target.dataset.id
} else {
// Handle normal components
const element = e.target.closest(".interactive.component") const element = e.target.closest(".interactive.component")
const newId = element?.dataset?.id newId = element?.dataset?.id
}
if (newId !== componentId) { if (newId !== componentId) {
componentId = newId componentId = newId
} }
@ -34,4 +48,5 @@
color="var(--spectrum-global-color-static-blue-200)" color="var(--spectrum-global-color-static-blue-200)"
transition transition
{zIndex} {zIndex}
allowResizeAnchors
/> />

View File

@ -10,9 +10,22 @@
export let icon export let icon
export let color export let color
export let zIndex export let zIndex
export let componentId
export let transition = false export let transition = false
export let line = false export let line = false
export let alignRight = false export let alignRight = false
export let showResizeAnchors = false
const AnchorSides = [
"right",
"left",
"top",
"bottom",
"bottom-right",
"bottom-left",
"top-right",
"top-left",
]
$: flipped = top < 24 $: flipped = top < 24
</script> </script>
@ -40,6 +53,18 @@
{/if} {/if}
</div> </div>
{/if} {/if}
{#if showResizeAnchors}
{#each AnchorSides as side}
<div
draggable="true"
class="anchor {side}"
data-side={side}
data-id={componentId}
>
<div class="anchor-inner" />
</div>
{/each}
{/if}
</div> </div>
<style> <style>
@ -105,4 +130,64 @@
/* Icon styles */ /* Icon styles */
.label :global(.spectrum-Icon + .text) { .label :global(.spectrum-Icon + .text) {
} }
/* Anchor */
.anchor {
--size: 24px;
position: absolute;
width: var(--size);
height: var(--size);
pointer-events: all;
display: grid;
place-items: center;
border-radius: 50%;
}
.anchor-inner {
width: 12px;
height: 12px;
background: white;
border: 2px solid var(--color);
pointer-events: none;
}
.anchor.right {
right: calc(var(--size) / -2 - 1px);
top: calc(50% - var(--size) / 2);
cursor: e-resize;
}
.anchor.left {
left: calc(var(--size) / -2 - 1px);
top: calc(50% - var(--size) / 2);
cursor: w-resize;
}
.anchor.bottom {
left: calc(50% - var(--size) / 2 + 1px);
bottom: calc(var(--size) / -2 - 1px);
cursor: s-resize;
}
.anchor.top {
left: calc(50% - var(--size) / 2 + 1px);
top: calc(var(--size) / -2 - 1px);
cursor: n-resize;
}
.anchor.bottom-right {
right: calc(var(--size) / -2 - 1px);
bottom: calc(var(--size) / -2 - 1px);
cursor: se-resize;
}
.anchor.bottom-left {
left: calc(var(--size) / -2 - 1px);
bottom: calc(var(--size) / -2 - 1px);
cursor: sw-resize;
}
.anchor.top-right {
right: calc(var(--size) / -2 - 1px);
top: calc(var(--size) / -2 - 1px);
cursor: ne-resize;
}
.anchor.top-left {
left: calc(var(--size) / -2 - 1px);
top: calc(var(--size) / -2 - 1px);
cursor: nw-resize;
}
</style> </style>

View File

@ -9,11 +9,13 @@
export let transition export let transition
export let zIndex export let zIndex
export let prefix = null export let prefix = null
export let allowResizeAnchors = false
let indicators = [] let indicators = []
let interval let interval
let text let text
let icon let icon
let insideGrid = false
$: visibleIndicators = indicators.filter(x => x.visible) $: visibleIndicators = indicators.filter(x => x.visible)
$: offset = $builderStore.inBuilder ? 0 : 2 $: offset = $builderStore.inBuilder ? 0 : 2
@ -23,6 +25,20 @@
let callbackCount = 0 let callbackCount = 0
let nextIndicators = [] let nextIndicators = []
const checkInsideGrid = id => {
const component = document.getElementsByClassName(id)[0]
const domNode = component?.children[0]
// Ignore grid itself
if (domNode?.classList.contains("grid")) {
return false
}
return component?.parentNode
?.closest?.(".component")
?.childNodes[0]?.classList.contains("grid")
}
const createIntersectionCallback = idx => entries => { const createIntersectionCallback = idx => entries => {
if (callbackCount >= observers.length) { if (callbackCount >= observers.length) {
return return
@ -52,6 +68,11 @@
observers = [] observers = []
nextIndicators = [] nextIndicators = []
// Check if we're inside a grid
if (allowResizeAnchors) {
insideGrid = checkInsideGrid(componentId)
}
// Determine next set of indicators // Determine next set of indicators
const parents = document.getElementsByClassName(componentId) const parents = document.getElementsByClassName(componentId)
if (parents.length) { if (parents.length) {
@ -127,6 +148,8 @@
height={indicator.height} height={indicator.height}
text={idx === 0 ? text : null} text={idx === 0 ? text : null}
icon={idx === 0 ? icon : null} icon={idx === 0 ? icon : null}
showResizeAnchors={allowResizeAnchors && insideGrid}
{componentId}
{transition} {transition}
{zIndex} {zIndex}
{color} {color}

View File

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

View File

@ -17,6 +17,11 @@
$: definition = $componentStore.selectedComponentDefinition $: definition = $componentStore.selectedComponentDefinition
$: showBar = definition?.showSettingsBar && !$dndIsDragging $: showBar = definition?.showSettingsBar && !$dndIsDragging
$: {
if (!showBar) {
measured = false
}
}
$: settings = getBarSettings(definition) $: settings = getBarSettings(definition)
const getBarSettings = definition => { const getBarSettings = definition => {

View File

@ -32,5 +32,4 @@ export const ActionTypes = {
} }
export const DNDPlaceholderID = "dnd-placeholder" export const DNDPlaceholderID = "dnd-placeholder"
export const DNDPlaceholderType = "dnd-placeholder"
export const ScreenslotType = "screenslot" export const ScreenslotType = "screenslot"

View File

@ -7,6 +7,7 @@ import {
componentStore, componentStore,
environmentStore, environmentStore,
dndStore, dndStore,
eventStore,
} from "./stores" } from "./stores"
import loadSpectrumIcons from "@budibase/bbui/spectrum-icons-rollup.js" import loadSpectrumIcons from "@budibase/bbui/spectrum-icons-rollup.js"
import { get } from "svelte/store" import { get } from "svelte/store"
@ -46,7 +47,9 @@ const loadBudibase = async () => {
appStore.actions.setAppId(window["##BUDIBASE_APP_ID##"]) appStore.actions.setAppId(window["##BUDIBASE_APP_ID##"])
// Fetch environment info // Fetch environment info
if (!get(environmentStore)?.loaded) {
await environmentStore.actions.fetchEnvironment() await environmentStore.actions.fetchEnvironment()
}
// Enable dev tools or not. We need to be using a dev app and not inside // Enable dev tools or not. We need to be using a dev app and not inside
// the builder preview to enable them. // the builder preview to enable them.
@ -54,15 +57,17 @@ const loadBudibase = async () => {
devToolsStore.actions.setEnabled(enableDevTools) devToolsStore.actions.setEnabled(enableDevTools)
// Register handler for runtime events from the builder // Register handler for runtime events from the builder
window.handleBuilderRuntimeEvent = (name, payload) => { window.handleBuilderRuntimeEvent = (type, data) => {
if (!window["##BUDIBASE_IN_BUILDER##"]) { if (!window["##BUDIBASE_IN_BUILDER##"]) {
return return
} }
if (name === "eject-block") { if (type === "event-completed") {
const block = blockStore.actions.getBlock(payload) eventStore.actions.resolveEvent(data)
} else if (type === "eject-block") {
const block = blockStore.actions.getBlock(data)
block?.eject() block?.eject()
} else if (name === "dragging-new-component") { } else if (type === "dragging-new-component") {
const { dragging, component } = payload const { dragging, component } = data
if (dragging) { if (dragging) {
const definition = const definition =
componentStore.actions.getComponentDefinition(component) componentStore.actions.getComponentDefinition(component)

View File

@ -1,10 +1,7 @@
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
import { API } from "api" import { API } from "api"
import { devToolsStore } from "./devTools.js" import { devToolsStore } from "./devTools.js"
import { eventStore } from "./events.js"
const dispatchEvent = (type, data = {}) => {
window.parent.postMessage({ type, data })
}
const createBuilderStore = () => { const createBuilderStore = () => {
const initialState = { const initialState = {
@ -19,6 +16,7 @@ const createBuilderStore = () => {
navigation: null, navigation: null,
hiddenComponentIds: [], hiddenComponentIds: [],
usedPlugins: null, usedPlugins: null,
eventResolvers: {},
// Legacy - allow the builder to specify a layout // Legacy - allow the builder to specify a layout
layout: null, layout: null,
@ -35,22 +33,25 @@ const createBuilderStore = () => {
selectedComponentId: id, selectedComponentId: id,
})) }))
devToolsStore.actions.setAllowSelection(false) devToolsStore.actions.setAllowSelection(false)
dispatchEvent("select-component", { id }) eventStore.actions.dispatchEvent("select-component", { id })
}, },
updateProp: (prop, value) => { updateProp: (prop, value) => {
dispatchEvent("update-prop", { prop, value }) eventStore.actions.dispatchEvent("update-prop", { prop, value })
},
updateStyles: async (styles, id) => {
await eventStore.actions.dispatchEvent("update-styles", { styles, id })
}, },
keyDown: (key, ctrlKey) => { keyDown: (key, ctrlKey) => {
dispatchEvent("key-down", { key, ctrlKey }) eventStore.actions.dispatchEvent("key-down", { key, ctrlKey })
}, },
duplicateComponent: id => { duplicateComponent: id => {
dispatchEvent("duplicate-component", { id }) eventStore.actions.dispatchEvent("duplicate-component", { id })
}, },
deleteComponent: id => { deleteComponent: id => {
dispatchEvent("delete-component", { id }) eventStore.actions.dispatchEvent("delete-component", { id })
}, },
notifyLoaded: () => { notifyLoaded: () => {
dispatchEvent("preview-loaded") eventStore.actions.dispatchEvent("preview-loaded")
}, },
analyticsPing: async () => { analyticsPing: async () => {
try { try {
@ -59,15 +60,15 @@ const createBuilderStore = () => {
// Do nothing // Do nothing
} }
}, },
moveComponent: (componentId, destinationComponentId, mode) => { moveComponent: async (componentId, destinationComponentId, mode) => {
dispatchEvent("move-component", { await eventStore.actions.dispatchEvent("move-component", {
componentId, componentId,
destinationComponentId, destinationComponentId,
mode, mode,
}) })
}, },
dropNewComponent: (component, parent, index) => { dropNewComponent: (component, parent, index) => {
dispatchEvent("drop-new-component", { eventStore.actions.dispatchEvent("drop-new-component", {
component, component,
parent, parent,
index, index,
@ -80,16 +81,16 @@ const createBuilderStore = () => {
store.update(state => ({ ...state, editMode: enabled })) store.update(state => ({ ...state, editMode: enabled }))
}, },
clickNav: () => { clickNav: () => {
dispatchEvent("click-nav") eventStore.actions.dispatchEvent("click-nav")
}, },
requestAddComponent: () => { requestAddComponent: () => {
dispatchEvent("request-add-component") eventStore.actions.dispatchEvent("request-add-component")
}, },
highlightSetting: setting => { highlightSetting: setting => {
dispatchEvent("highlight-setting", { setting }) eventStore.actions.dispatchEvent("highlight-setting", { setting })
}, },
ejectBlock: (id, definition) => { ejectBlock: (id, definition) => {
dispatchEvent("eject-block", { id, definition }) eventStore.actions.dispatchEvent("eject-block", { id, definition })
}, },
updateUsedPlugin: (name, hash) => { updateUsedPlugin: (name, hash) => {
// Check if we used this plugin // Check if we used this plugin
@ -106,7 +107,7 @@ const createBuilderStore = () => {
} }
// Notify the builder so we can reload component definitions // Notify the builder so we can reload component definitions
dispatchEvent("reload-plugin") eventStore.actions.dispatchEvent("reload-plugin")
}, },
} }
return { return {

View File

@ -5,9 +5,8 @@ import { devToolsStore } from "./devTools"
import { screenStore } from "./screens" import { screenStore } from "./screens"
import { builderStore } from "./builder" import { builderStore } from "./builder"
import Router from "../components/Router.svelte" import Router from "../components/Router.svelte"
import DNDPlaceholder from "../components/preview/DNDPlaceholder.svelte"
import * as AppComponents from "../components/app/index.js" import * as AppComponents from "../components/app/index.js"
import { DNDPlaceholderType, ScreenslotType } from "../constants.js" import { ScreenslotType } from "../constants.js"
const budibasePrefix = "@budibase/standard-components/" const budibasePrefix = "@budibase/standard-components/"
@ -49,6 +48,9 @@ const createComponentStore = () => {
) )
const registerInstance = (id, instance) => { const registerInstance = (id, instance) => {
if (!id) {
return
}
store.update(state => { store.update(state => {
// If this is a custom component, flag it so we can reload this component // If this is a custom component, flag it so we can reload this component
// later if required // later if required
@ -68,6 +70,9 @@ const createComponentStore = () => {
} }
const unregisterInstance = id => { const unregisterInstance = id => {
if (!id) {
return
}
store.update(state => { store.update(state => {
// Remove from custom component map if required // Remove from custom component map if required
const component = state.mountedComponents[id]?.instance?.component const component = state.mountedComponents[id]?.instance?.component
@ -103,8 +108,6 @@ const createComponentStore = () => {
// Screenslot is an edge case // Screenslot is an edge case
if (type === ScreenslotType) { if (type === ScreenslotType) {
type = `${budibasePrefix}${type}` type = `${budibasePrefix}${type}`
} else if (type === DNDPlaceholderType) {
return {}
} }
// Handle built-in components // Handle built-in components
@ -124,8 +127,6 @@ const createComponentStore = () => {
} }
if (type === ScreenslotType) { if (type === ScreenslotType) {
return Router return Router
} else if (type === DNDPlaceholderType) {
return DNDPlaceholder
} }
// Handle budibase components // Handle budibase components
@ -140,6 +141,13 @@ const createComponentStore = () => {
return customComponentManifest?.[type]?.Component return customComponentManifest?.[type]?.Component
} }
const getComponentInstance = id => {
if (!id) {
return null
}
return get(store).mountedComponents[id]
}
const registerCustomComponent = ({ Component, schema, version }) => { const registerCustomComponent = ({ Component, schema, version }) => {
if (!Component || !schema?.schema?.name || !version) { if (!Component || !schema?.schema?.name || !version) {
return return
@ -171,6 +179,7 @@ const createComponentStore = () => {
getComponentById, getComponentById,
getComponentDefinition, getComponentDefinition,
getComponentConstructor, getComponentConstructor,
getComponentInstance,
registerCustomComponent, registerCustomComponent,
}, },
} }

View File

@ -1,4 +1,5 @@
import { writable, derived } from "svelte/store" import { writable } from "svelte/store"
import { computed } from "../utils/computed.js"
const createDndStore = () => { const createDndStore = () => {
const initialState = { const initialState = {
@ -77,14 +78,11 @@ export const dndStore = createDndStore()
// performance by deriving any state that needs to be externally observed. // performance by deriving any state that needs to be externally observed.
// By doing this and using primitives, we can avoid invalidating other stores // By doing this and using primitives, we can avoid invalidating other stores
// or components which depend on DND state unless values actually change. // or components which depend on DND state unless values actually change.
export const dndIsDragging = derived(dndStore, $dndStore => !!$dndStore.source) export const dndParent = computed(dndStore, x => x.drop?.parent)
export const dndParent = derived(dndStore, $dndStore => $dndStore.drop?.parent) export const dndIndex = computed(dndStore, x => x.drop?.index)
export const dndIndex = derived(dndStore, $dndStore => $dndStore.drop?.index) export const dndBounds = computed(dndStore, x => x.source?.bounds)
export const dndBounds = derived( export const dndIsDragging = computed(dndStore, x => !!x.source)
export const dndIsNewComponent = computed(
dndStore, dndStore,
$dndStore => $dndStore.source?.bounds x => x.source?.newComponentType != null
)
export const dndIsNewComponent = derived(
dndStore,
$dndStore => $dndStore.source?.newComponentType != null
) )

View File

@ -2,6 +2,7 @@ import { API } from "api"
import { writable } from "svelte/store" import { writable } from "svelte/store"
const initialState = { const initialState = {
loaded: false,
cloud: false, cloud: false,
} }
@ -15,6 +16,7 @@ const createEnvironmentStore = () => {
store.set({ store.set({
...initialState, ...initialState,
...environment, ...environment,
loaded: true,
}) })
} catch (error) { } catch (error) {
store.set(initialState) store.set(initialState)

View File

@ -0,0 +1,31 @@
import { writable, get } from "svelte/store"
const createEventStore = () => {
const initialState = {
eventResolvers: {},
}
const store = writable(initialState)
const actions = {
dispatchEvent: (type, data) => {
const id = Math.random()
return new Promise(resolve => {
window.parent.postMessage({ type, data, id })
store.update(state => {
state.eventResolvers[id] = resolve
return state
})
})
},
resolveEvent: data => {
get(store).eventResolvers[data]?.()
},
}
return {
subscribe: store.subscribe,
actions,
}
}
export const eventStore = createEventStore()

View File

@ -15,6 +15,7 @@ export { uploadStore } from "./uploads.js"
export { rowSelectionStore } from "./rowSelection.js" export { rowSelectionStore } from "./rowSelection.js"
export { blockStore } from "./blocks.js" export { blockStore } from "./blocks.js"
export { environmentStore } from "./environment" export { environmentStore } from "./environment"
export { eventStore } from "./events.js"
export { export {
dndStore, dndStore,
dndIndex, dndIndex,

View File

@ -2,11 +2,11 @@ import { derived } from "svelte/store"
import { routeStore } from "./routes" import { routeStore } from "./routes"
import { builderStore } from "./builder" import { builderStore } from "./builder"
import { appStore } from "./app" import { appStore } from "./app"
import { dndIndex, dndParent, dndIsNewComponent } from "./dnd.js" import { dndIndex, dndParent, dndIsNewComponent, dndBounds } from "./dnd.js"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
import { findComponentById, findComponentParent } from "../utils/components.js" import { findComponentById, findComponentParent } from "../utils/components.js"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { DNDPlaceholderID, DNDPlaceholderType } from "constants" import { DNDPlaceholderID } from "constants"
const createScreenStore = () => { const createScreenStore = () => {
const store = derived( const store = derived(
@ -17,6 +17,7 @@ const createScreenStore = () => {
dndParent, dndParent,
dndIndex, dndIndex,
dndIsNewComponent, dndIsNewComponent,
dndBounds,
], ],
([ ([
$appStore, $appStore,
@ -25,6 +26,7 @@ const createScreenStore = () => {
$dndParent, $dndParent,
$dndIndex, $dndIndex,
$dndIsNewComponent, $dndIsNewComponent,
$dndBounds,
]) => { ]) => {
let activeLayout, activeScreen let activeLayout, activeScreen
let screens let screens
@ -62,32 +64,43 @@ const createScreenStore = () => {
// Insert DND placeholder if required // Insert DND placeholder if required
if (activeScreen && $dndParent && $dndIndex != null) { if (activeScreen && $dndParent && $dndIndex != null) {
// Remove selected component from tree if we are moving an existing
// component
const { selectedComponentId } = $builderStore const { selectedComponentId } = $builderStore
if (!$dndIsNewComponent) {
// Extract and save the selected component as we need a reference to it
// later, and we may be removing it
let selectedParent = findComponentParent( let selectedParent = findComponentParent(
activeScreen.props, activeScreen.props,
selectedComponentId selectedComponentId
) )
if (selectedParent) {
// Remove selected component from tree if we are moving an existing
// component
if (!$dndIsNewComponent && selectedParent) {
selectedParent._children = selectedParent._children?.filter( selectedParent._children = selectedParent._children?.filter(
x => x._id !== selectedComponentId x => x._id !== selectedComponentId
) )
} }
}
// Insert placeholder component // Insert placeholder component
const placeholder = { const componentToInsert = {
_component: DNDPlaceholderID, _component: "@budibase/standard-components/container",
_id: DNDPlaceholderType, _id: DNDPlaceholderID,
_styles: {
normal: {
width: `${$dndBounds?.width || 400}px`,
height: `${$dndBounds?.height || 200}px`,
opacity: 0,
},
},
static: true, static: true,
} }
let parent = findComponentById(activeScreen.props, $dndParent) let parent = findComponentById(activeScreen.props, $dndParent)
if (parent) {
if (!parent._children?.length) { if (!parent._children?.length) {
parent._children = [placeholder] parent._children = [componentToInsert]
} else { } else {
parent._children.splice($dndIndex, 0, placeholder) parent._children.splice($dndIndex, 0, componentToInsert)
}
} }
} }

View File

@ -0,0 +1,38 @@
import { writable } from "svelte/store"
/**
* Extension of Svelte's built in "derived" stores, which the addition of deep
* comparison of non-primitives. Falls back to using shallow comparison for
* primitive types to avoid performance penalties.
* Useful for instances where a deep comparison is cheaper than an additional
* store invalidation.
* @param store the store to observer
* @param deriveValue the derivation function
* @returns {Writable<*>} a derived svelte store containing just the derived value
*/
export const computed = (store, deriveValue) => {
const initialValue = deriveValue(store)
const computedStore = writable(initialValue)
let lastKey = getKey(initialValue)
store.subscribe(state => {
const value = deriveValue(state)
const key = getKey(value)
if (key !== lastKey) {
lastKey = key
computedStore.set(value)
}
})
return computedStore
}
// Helper function to serialise any value into a primitive which can be cheaply
// and shallowly compared
const getKey = value => {
if (value == null || typeof value !== "object") {
return value
} else {
return JSON.stringify(value)
}
}

View File

@ -6,17 +6,29 @@
*/ */
export const sequential = fn => { export const sequential = fn => {
let queue = [] let queue = []
return async (...params) => { return (...params) => {
return new Promise((resolve, reject) => {
queue.push(async () => { queue.push(async () => {
await fn(...params) let data, error
try {
data = await fn(...params)
} catch (err) {
error = err
}
queue.shift() queue.shift()
if (queue.length) { if (queue.length) {
await queue[0]() queue[0]()
}
if (error) {
reject(error)
} else {
resolve(data)
} }
}) })
if (queue.length === 1) { if (queue.length === 1) {
await queue[0]() queue[0]()
} }
})
} }
} }