Merge pull request #8267 from Budibase/dnd-improvements

Drag and drop V2
This commit is contained in:
Andrew Kingston 2022-10-18 18:29:41 +01:00 committed by GitHub
commit 64b39a8478
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 995 additions and 431 deletions

View File

@ -451,7 +451,7 @@ export const getFrontendStore = () => {
...extras, ...extras,
} }
}, },
create: async (componentName, presetProps) => { create: async (componentName, presetProps, parent, index) => {
const state = get(store) const state = get(store)
const componentInstance = store.actions.components.createInstance( const componentInstance = store.actions.components.createInstance(
componentName, componentName,
@ -461,48 +461,62 @@ export const getFrontendStore = () => {
return return
} }
// Patch selected screen // Insert in position if specified
await store.actions.screens.patch(screen => { if (parent && index != null) {
// Find the selected component await store.actions.screens.patch(screen => {
const currentComponent = findComponent( let parentComponent = findComponent(screen.props, parent)
screen.props, if (!parentComponent._children?.length) {
state.selectedComponentId parentComponent._children = [componentInstance]
)
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 { } else {
// Otherwise we need to use the parent of this component parentComponent._children.splice(index, 0, componentInstance)
parentComponent = findComponentParent(
screen.props,
currentComponent._id
)
} }
} else { })
// Use screen or layout if no component is selected }
parentComponent = screen.props
}
// Attach new component // Otherwise we work out where this component should be inserted
if (!parentComponent) { else {
return false await store.actions.screens.patch(screen => {
} // Find the selected component
if (!parentComponent._children) { const currentComponent = findComponent(
parentComponent._children = [] screen.props,
} state.selectedComponentId
parentComponent._children.push(componentInstance) )
}) 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 // Select new component
store.update(state => { 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 return store

View File

@ -10,6 +10,7 @@ export const syncURLToState = options => {
fallbackUrl, fallbackUrl,
store, store,
routify, routify,
beforeNavigate,
} = options || {} } = options || {}
if ( if (
!urlParam || !urlParam ||
@ -41,6 +42,15 @@ export const syncURLToState = options => {
// Navigate to a certain URL // Navigate to a certain URL
const gotoUrl = (url, params) => { const gotoUrl = (url, params) => {
if (beforeNavigate) {
const res = beforeNavigate(url, params)
if (res?.url) {
url = res.url
}
if (res?.params) {
params = res.params
}
}
log("Navigating to", url, "with params", params) log("Navigating to", url, "with params", params)
cachedGoto(url, params) cachedGoto(url, params)
} }

View File

@ -213,6 +213,9 @@
await store.actions.components.handleEjectBlock(id, definition) await store.actions.components.handleEjectBlock(id, definition)
} else if (type === "reload-plugin") { } else if (type === "reload-plugin") {
await store.actions.components.refreshDefinitions() 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 { } else {
console.warn(`Client sent unknown event type: ${type}`) console.warn(`Client sent unknown event type: ${type}`)
} }

View File

@ -76,6 +76,9 @@
const compDef = store.actions.components.getDefinition( const compDef = store.actions.components.getDefinition(
$dndStore.source?._component $dndStore.source?._component
) )
if (!compDef) {
return
}
const compTypeName = compDef.name.toLowerCase() const compTypeName = compDef.name.toLowerCase()
const path = findComponentPath(currentScreen.props, component._id) const path = findComponentPath(currentScreen.props, component._id)

View File

@ -7,6 +7,18 @@
import ComponentListPanel from "./_components/navigation/ComponentListPanel.svelte" import ComponentListPanel from "./_components/navigation/ComponentListPanel.svelte"
import ComponentSettingsPanel from "./_components/settings/ComponentSettingsPanel.svelte" import ComponentSettingsPanel from "./_components/settings/ComponentSettingsPanel.svelte"
const cleanUrl = url => {
// Strip trailing slashes
if (url?.endsWith("/index")) {
url = url.replace("/index", "")
}
// Hide new component panel whenever component ID changes
if (url?.endsWith("/new")) {
url = url.replace("/new", "")
}
return { url }
}
// Keep URL and state in sync for selected component ID // Keep URL and state in sync for selected component ID
const stopSyncing = syncURLToState({ const stopSyncing = syncURLToState({
urlParam: "componentId", urlParam: "componentId",
@ -15,6 +27,7 @@
fallbackUrl: "../", fallbackUrl: "../",
store, store,
routify, routify,
beforeNavigate: cleanUrl,
}) })
onDestroy(stopSyncing) onDestroy(stopSyncing)

View File

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

View File

@ -85,6 +85,10 @@
"icon": "Selection", "icon": "Selection",
"hasChildren": true, "hasChildren": true,
"showSettingsBar": true, "showSettingsBar": true,
"size": {
"width": 400,
"height": 100
},
"styles": [ "styles": [
"padding", "padding",
"size", "size",
@ -255,6 +259,10 @@
"section" "section"
], ],
"showEmptyState": false, "showEmptyState": false,
"size": {
"width": 400,
"height": 100
},
"settings": [ "settings": [
{ {
"type": "section", "type": "section",
@ -276,6 +284,10 @@
"icon": "Button", "icon": "Button",
"editable": true, "editable": true,
"showSettingsBar": true, "showSettingsBar": true,
"size": {
"width": 105,
"height": 35
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -368,6 +380,10 @@
"illegalChildren": [ "illegalChildren": [
"section" "section"
], ],
"size": {
"width": 400,
"height": 10
},
"settings": [ "settings": [
{ {
"type": "select", "type": "select",
@ -405,6 +421,10 @@
], ],
"hasChildren": true, "hasChildren": true,
"showSettingsBar": true, "showSettingsBar": true,
"size": {
"width": 400,
"height": 100
},
"settings": [ "settings": [
{ {
"type": "dataProvider", "type": "dataProvider",
@ -584,6 +604,7 @@
] ]
}, },
"card": { "card": {
"deprecated": true,
"name": "Vertical Card", "name": "Vertical Card",
"description": "A basic card component that can contain content and actions.", "description": "A basic card component that can contain content and actions.",
"icon": "ViewColumn", "icon": "ViewColumn",
@ -664,6 +685,10 @@
], ],
"showSettingsBar": true, "showSettingsBar": true,
"editable": true, "editable": true,
"size": {
"width": 400,
"height": 30
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -786,6 +811,10 @@
], ],
"showSettingsBar": true, "showSettingsBar": true,
"editable": true, "editable": true,
"size": {
"width": 400,
"height": 40
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -903,6 +932,10 @@
"name": "Tag", "name": "Tag",
"icon": "Label", "icon": "Label",
"showSettingsBar": true, "showSettingsBar": true,
"size": {
"width": 100,
"height": 25
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -954,12 +987,13 @@
"name": "Image", "name": "Image",
"description": "A basic component for displaying images", "description": "A basic component for displaying images",
"icon": "Image", "icon": "Image",
"illegalChildren": [
"section"
],
"styles": [ "styles": [
"size" "size"
], ],
"size": {
"width": 400,
"height": 300
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -976,9 +1010,10 @@
"styles": [ "styles": [
"size" "size"
], ],
"illegalChildren": [ "size": {
"section" "width": 400,
], "height": 300
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -1036,9 +1071,10 @@
"name": "Icon", "name": "Icon",
"description": "A basic component for displaying icons", "description": "A basic component for displaying icons",
"icon": "Shapes", "icon": "Shapes",
"illegalChildren": [ "size": {
"section" "width": 25,
], "height": 25
},
"settings": [ "settings": [
{ {
"type": "icon", "type": "icon",
@ -1155,9 +1191,10 @@
"icon": "Link", "icon": "Link",
"showSettingsBar": true, "showSettingsBar": true,
"editable": true, "editable": true,
"illegalChildren": [ "size": {
"section" "width": 200,
], "height": 30
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -1267,12 +1304,10 @@
] ]
}, },
"cardhorizontal": { "cardhorizontal": {
"deprecated": true,
"name": "Horizontal Card", "name": "Horizontal Card",
"description": "A basic card component that can contain content and actions.", "description": "A basic card component that can contain content and actions.",
"icon": "ViewRow", "icon": "ViewRow",
"illegalChildren": [
"section"
],
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -1363,27 +1398,31 @@
"name": "Stat Card", "name": "Stat Card",
"description": "A card component for displaying numbers.", "description": "A card component for displaying numbers.",
"icon": "Card", "icon": "Card",
"illegalChildren": [ "size": {
"section" "width": 260,
], "height": 143
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
"label": "Title", "label": "Title",
"key": "title", "key": "title",
"placeholder": "Total Revenue" "placeholder": "Total Revenue",
"defaultValue": "Title"
}, },
{ {
"type": "text", "type": "text",
"label": "Value", "label": "Value",
"key": "value", "key": "value",
"placeholder": "$1,981,983" "placeholder": "$1,981,983",
"defaultValue": "Value"
}, },
{ {
"type": "text", "type": "text",
"label": "Label", "label": "Label",
"key": "label", "key": "label",
"placeholder": "Stripe" "placeholder": "Stripe",
"defaultValue": "Label"
} }
] ]
}, },
@ -1391,12 +1430,13 @@
"name": "Embed", "name": "Embed",
"icon": "Code", "icon": "Code",
"description": "Embed content from 3rd party sources", "description": "Embed content from 3rd party sources",
"illegalChildren": [
"section"
],
"styles": [ "styles": [
"size" "size"
], ],
"size": {
"width": 400,
"height": 100
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -1410,9 +1450,10 @@
"name": "Bar Chart", "name": "Bar Chart",
"description": "Bar chart", "description": "Bar chart",
"icon": "GraphBarVertical", "icon": "GraphBarVertical",
"illegalChildren": [ "size": {
"section" "width": 600,
], "height": 400
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -1571,9 +1612,10 @@
"name": "Line Chart", "name": "Line Chart",
"description": "Line chart", "description": "Line chart",
"icon": "GraphTrend", "icon": "GraphTrend",
"illegalChildren": [ "size": {
"section" "width": 600,
], "height": 400
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -1731,9 +1773,10 @@
"name": "Area Chart", "name": "Area Chart",
"description": "Line chart", "description": "Line chart",
"icon": "GraphAreaStacked", "icon": "GraphAreaStacked",
"illegalChildren": [ "size": {
"section" "width": 600,
], "height": 400
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -1903,9 +1946,10 @@
"name": "Pie Chart", "name": "Pie Chart",
"description": "Pie chart", "description": "Pie chart",
"icon": "GraphPie", "icon": "GraphPie",
"illegalChildren": [ "size": {
"section" "width": 600,
], "height": 400
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -2031,9 +2075,10 @@
"name": "Donut Chart", "name": "Donut Chart",
"description": "Donut chart", "description": "Donut chart",
"icon": "GraphDonut", "icon": "GraphDonut",
"illegalChildren": [ "size": {
"section" "width": 600,
], "height": 400
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -2159,9 +2204,10 @@
"name": "Candlestick Chart", "name": "Candlestick Chart",
"description": "Candlestick chart", "description": "Candlestick chart",
"icon": "GraphBarVerticalStacked", "icon": "GraphBarVerticalStacked",
"illegalChildren": [ "size": {
"section" "width": 600,
], "height": 400
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -2266,6 +2312,10 @@
"styles": [ "styles": [
"size" "size"
], ],
"size": {
"width": 400,
"height": 400
},
"settings": [ "settings": [
{ {
"type": "select", "type": "select",
@ -2352,6 +2402,10 @@
"styles": [ "styles": [
"size" "size"
], ],
"size": {
"width": 400,
"height": 400
},
"settings": [ "settings": [
{ {
"type": "number", "type": "number",
@ -2372,6 +2426,10 @@
"size" "size"
], ],
"hasChildren": true, "hasChildren": true,
"size": {
"width": 400,
"height": 400
},
"settings": [ "settings": [
{ {
"type": "select", "type": "select",
@ -2398,13 +2456,14 @@
"stringfield": { "stringfield": {
"name": "Text Field", "name": "Text Field",
"icon": "Text", "icon": "Text",
"illegalChildren": [
"section"
],
"styles": [ "styles": [
"size" "size"
], ],
"editable": true, "editable": true,
"size": {
"width": 400,
"height": 50
},
"settings": [ "settings": [
{ {
"type": "field/string", "type": "field/string",
@ -2492,9 +2551,10 @@
"size" "size"
], ],
"editable": true, "editable": true,
"illegalChildren": [ "size": {
"section" "width": 400,
], "height": 50
},
"settings": [ "settings": [
{ {
"type": "field/number", "type": "field/number",
@ -2548,9 +2608,10 @@
"size" "size"
], ],
"editable": true, "editable": true,
"illegalChildren": [ "size": {
"section" "width": 400,
], "height": 50
},
"settings": [ "settings": [
{ {
"type": "field/string", "type": "field/string",
@ -2604,9 +2665,10 @@
"size" "size"
], ],
"editable": true, "editable": true,
"illegalChildren": [ "size": {
"section" "width": 400,
], "height": 50
},
"settings": [ "settings": [
{ {
"type": "field/options", "type": "field/options",
@ -2771,9 +2833,10 @@
"size" "size"
], ],
"editable": true, "editable": true,
"illegalChildren": [ "size": {
"section" "width": 400,
], "height": 50
},
"settings": [ "settings": [
{ {
"type": "field/array", "type": "field/array",
@ -2929,9 +2992,10 @@
"name": "Checkbox", "name": "Checkbox",
"icon": "SelectBox", "icon": "SelectBox",
"editable": true, "editable": true,
"illegalChildren": [ "size": {
"section" "width": 400,
], "height": 50
},
"settings": [ "settings": [
{ {
"type": "field/boolean", "type": "field/boolean",
@ -3009,6 +3073,10 @@
"size" "size"
], ],
"editable": true, "editable": true,
"size": {
"width": 400,
"height": 150
},
"settings": [ "settings": [
{ {
"type": "field/longform", "type": "field/longform",
@ -3084,9 +3152,10 @@
"size" "size"
], ],
"editable": true, "editable": true,
"illegalChildren": [ "size": {
"section" "width": 400,
], "height": 50
},
"settings": [ "settings": [
{ {
"type": "field/datetime", "type": "field/datetime",
@ -3163,9 +3232,10 @@
"styles": [ "styles": [
"size" "size"
], ],
"illegalChildren": [ "size": {
"section" "width": 400,
], "height": 50
},
"settings": [ "settings": [
{ {
"type": "field/barcode/qr", "type": "field/barcode/qr",
@ -3214,29 +3284,27 @@
"size" "size"
], ],
"draggable": false, "draggable": false,
"illegalChildren": [ "size": {
"section" "width": 400,
], "height": 320
},
"settings": [ "settings": [
{ {
"type": "dataProvider", "type": "dataProvider",
"label": "Provider", "label": "Provider",
"key": "dataProvider", "key": "dataProvider"
"required": true
}, },
{ {
"type": "field", "type": "field",
"label": "Latitude Key", "label": "Latitude Key",
"key": "latitudeKey", "key": "latitudeKey",
"dependsOn": "dataProvider", "dependsOn": "dataProvider"
"required": true
}, },
{ {
"type": "field", "type": "field",
"label": "Longitude Key", "label": "Longitude Key",
"key": "longitudeKey", "key": "longitudeKey",
"dependsOn": "dataProvider", "dependsOn": "dataProvider"
"required": true
}, },
{ {
"type": "field", "type": "field",
@ -3330,9 +3398,10 @@
"size" "size"
], ],
"editable": true, "editable": true,
"illegalChildren": [ "size": {
"section" "width": 400,
], "height": 200
},
"settings": [ "settings": [
{ {
"type": "field/attachment", "type": "field/attachment",
@ -3387,9 +3456,10 @@
"size" "size"
], ],
"editable": true, "editable": true,
"illegalChildren": [ "size": {
"section" "width": 400,
], "height": 50
},
"settings": [ "settings": [
{ {
"type": "field/link", "type": "field/link",
@ -3449,6 +3519,10 @@
"size" "size"
], ],
"editable": true, "editable": true,
"size": {
"width": 400,
"height": 100
},
"settings": [ "settings": [
{ {
"type": "field/json", "type": "field/json",
@ -3497,6 +3571,10 @@
"size" "size"
], ],
"editable": true, "editable": true,
"size": {
"width": 400,
"height": 200
},
"settings": [ "settings": [
{ {
"type": "field/attachment", "type": "field/attachment",
@ -3559,6 +3637,10 @@
"actions": [ "actions": [
"RefreshDatasource" "RefreshDatasource"
], ],
"size": {
"width": 400,
"height": 100
},
"settings": [ "settings": [
{ {
"type": "dataSource", "type": "dataSource",
@ -3639,6 +3721,10 @@
], ],
"hasChildren": true, "hasChildren": true,
"showEmptyState": false, "showEmptyState": false,
"size": {
"width": 600,
"height": 400
},
"settings": [ "settings": [
{ {
"type": "dataProvider", "type": "dataProvider",
@ -3737,6 +3823,10 @@
"size" "size"
], ],
"hasChildren": false, "hasChildren": false,
"size": {
"width": 200,
"height": 50
},
"settings": [ "settings": [
{ {
"type": "dataProvider", "type": "dataProvider",
@ -3773,21 +3863,28 @@
"styles": [ "styles": [
"size" "size"
], ],
"size": {
"width": 300,
"height": 120
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
"key": "title", "key": "title",
"label": "Title" "label": "Title",
"defaultValue": "Title"
}, },
{ {
"type": "text", "type": "text",
"key": "subtitle", "key": "subtitle",
"label": "Subtitle" "label": "Subtitle",
"defaultValue": "Subtitle"
}, },
{ {
"type": "text", "type": "text",
"key": "description", "key": "description",
"label": "Description" "label": "Description",
"defaultValue": "Description"
}, },
{ {
"type": "text", "type": "text",
@ -3831,6 +3928,10 @@
"name": "Dynamic Filter", "name": "Dynamic Filter",
"icon": "Filter", "icon": "Filter",
"showSettingsBar": true, "showSettingsBar": true,
"size": {
"width": 100,
"height": 35
},
"settings": [ "settings": [
{ {
"type": "dataProvider", "type": "dataProvider",
@ -3878,6 +3979,10 @@
"styles": [ "styles": [
"size" "size"
], ],
"size": {
"width": 600,
"height": 400
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -4043,6 +4148,10 @@
"styles": [ "styles": [
"size" "size"
], ],
"size": {
"width": 600,
"height": 400
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -4101,19 +4210,22 @@
"type": "text", "type": "text",
"key": "cardTitle", "key": "cardTitle",
"label": "Title", "label": "Title",
"nested": true "nested": true,
"defaultValue": "Title"
}, },
{ {
"type": "text", "type": "text",
"key": "cardSubtitle", "key": "cardSubtitle",
"label": "Subtitle", "label": "Subtitle",
"nested": true "nested": true,
"defaultValue": "Subtitle"
}, },
{ {
"type": "text", "type": "text",
"key": "cardDescription", "key": "cardDescription",
"label": "Description", "label": "Description",
"nested": true "nested": true,
"defaultValue": "Description"
}, },
{ {
"type": "text", "type": "text",
@ -4215,6 +4327,10 @@
], ],
"hasChildren": true, "hasChildren": true,
"showSettingsBar": true, "showSettingsBar": true,
"size": {
"width": 400,
"height": 100
},
"settings": [ "settings": [
{ {
"type": "dataSource", "type": "dataSource",
@ -4437,6 +4553,10 @@
"styles": [ "styles": [
"size" "size"
], ],
"size": {
"width": 400,
"height": 100
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -4454,6 +4574,10 @@
], ],
"block": true, "block": true,
"info": "Form blocks are only compatible with internal or SQL tables", "info": "Form blocks are only compatible with internal or SQL tables",
"size": {
"width": 400,
"height": 400
},
"settings": [ "settings": [
{ {
"type": "select", "type": "select",

View File

@ -67,6 +67,9 @@
// any depth // any depth
id: $component.id, id: $component.id,
// Name can be used down the tree in placeholders
name: $component.name,
// We register block components with their raw props so that we can eject // We register block components with their raw props so that we can eject
// blocks later on // blocks later on
registerComponent: registerBlockComponent, registerComponent: registerBlockComponent,

View File

@ -16,7 +16,14 @@
propsAreSame, propsAreSame,
getSettingsDefinition, getSettingsDefinition,
} from "utils/componentProps" } from "utils/componentProps"
import { builderStore, devToolsStore, componentStore, appStore } from "stores" import {
builderStore,
devToolsStore,
componentStore,
appStore,
dndIsDragging,
dndComponentPath,
} from "stores"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { getActiveConditions, reduceConditionActions } from "utils/conditions" import { getActiveConditions, reduceConditionActions } from "utils/conditions"
import Placeholder from "components/app/Placeholder.svelte" import Placeholder from "components/app/Placeholder.svelte"
@ -27,6 +34,7 @@
export let isLayout = false export let isLayout = false
export let isScreen = false export let isScreen = false
export let isBlock = false export let isBlock = false
export let parent = null
// Get parent contexts // Get parent contexts
const context = getContext("context") const context = getContext("context")
@ -97,6 +105,7 @@
$builderStore.inBuilder && $builderStore.selectedComponentId === id $builderStore.inBuilder && $builderStore.selectedComponentId === id
$: inSelectedPath = $componentStore.selectedComponentPath?.includes(id) $: inSelectedPath = $componentStore.selectedComponentPath?.includes(id)
$: inDragPath = inSelectedPath && $builderStore.editMode $: inDragPath = inSelectedPath && $builderStore.editMode
$: inDndPath = $dndComponentPath?.includes(id)
// Derive definition properties which can all be optional, so need to be // Derive definition properties which can all be optional, so need to be
// coerced to booleans // coerced to booleans
@ -108,7 +117,7 @@
// Interactive components can be selected, dragged and highlighted inside // Interactive components can be selected, dragged and highlighted inside
// the builder preview // the builder preview
$: builderInteractive = $: builderInteractive =
$builderStore.inBuilder && insideScreenslot && !isBlock $builderStore.inBuilder && insideScreenslot && !isBlock && !instance.static
$: devToolsInteractive = $devToolsStore.allowSelection && !isBlock $: devToolsInteractive = $devToolsStore.allowSelection && !isBlock
$: interactive = builderInteractive || devToolsInteractive $: interactive = builderInteractive || devToolsInteractive
$: editing = editable && selected && $builderStore.editMode $: editing = editable && selected && $builderStore.editMode
@ -118,7 +127,7 @@
!isLayout && !isLayout &&
!isScreen && !isScreen &&
definition?.draggable !== false definition?.draggable !== false
$: droppable = interactive && !isLayout && !isScreen $: droppable = interactive
$: builderHidden = $: builderHidden =
$builderStore.inBuilder && $builderStore.hiddenComponentIds?.includes(id) $builderStore.inBuilder && $builderStore.hiddenComponentIds?.includes(id)
@ -126,8 +135,9 @@
// Empty states can be shown for these components, but can be disabled // Empty states can be shown for these components, but can be disabled
// in the component manifest. // in the component manifest.
$: empty = $: empty =
(interactive && !children.length && hasChildren) || !isBlock &&
hasMissingRequiredSettings ((interactive && !children.length && hasChildren) ||
hasMissingRequiredSettings)
$: emptyState = empty && showEmptyState $: emptyState = empty && showEmptyState
// Enrich component settings // Enrich component settings
@ -149,6 +159,12 @@
// Scroll the selected element into view // Scroll the selected element into view
$: selected && scrollIntoView() $: selected && scrollIntoView()
// When dragging and dropping, pad components to allow dropping between
// nested layers. Only reset this when dragging stops.
let pad = false
$: pad = pad || (interactive && hasChildren && inDndPath)
$: $dndIsDragging, (pad = false)
// Update component context // Update component context
$: store.set({ $: store.set({
id, id,
@ -405,6 +421,11 @@
} }
const scrollIntoView = () => { const scrollIntoView = () => {
// Don't scroll into view if we selected this component because we were
// starting dragging on it
if (get(dndIsDragging)) {
return
}
const node = document.getElementsByClassName(id)?.[0]?.children[0] const node = document.getElementsByClassName(id)?.[0]?.children[0]
if (!node) { if (!node) {
return return
@ -452,17 +473,20 @@
class:empty class:empty
class:interactive class:interactive
class:editing class:editing
class:pad
class:parent={hasChildren}
class:block={isBlock} class:block={isBlock}
data-id={id} data-id={id}
data-name={name} data-name={name}
data-icon={icon} data-icon={icon}
data-parent={parent}
> >
<svelte:component this={constructor} bind:this={ref} {...initialSettings}> <svelte:component this={constructor} bind:this={ref} {...initialSettings}>
{#if hasMissingRequiredSettings} {#if hasMissingRequiredSettings}
<ComponentPlaceholder /> <ComponentPlaceholder />
{:else if children.length} {:else if children.length}
{#each children as child (child._id)} {#each children as child (child._id)}
<svelte:self instance={child} /> <svelte:self instance={child} parent={id} />
{/each} {/each}
{:else if emptyState} {:else if emptyState}
{#if isScreen} {#if isScreen}
@ -481,16 +505,14 @@
.component { .component {
display: contents; display: contents;
} }
.component.pad :global(> *) {
.interactive :global(*:hover) { padding: var(--spacing-l) !important;
cursor: pointer; gap: var(--spacing-l) !important;
border: 2px dashed var(--spectrum-global-color-gray-400) !important;
border-radius: 4px !important;
transition: padding 260ms ease-out, border 260ms ease-out;
} }
.interactive :global(*) {
.draggable :global(*:hover) { cursor: default;
cursor: grab;
}
.editing :global(*:hover) {
cursor: auto;
} }
</style> </style>

View File

@ -3,13 +3,14 @@
const { builderStore } = getContext("sdk") const { builderStore } = getContext("sdk")
const component = getContext("component") const component = getContext("component")
const block = getContext("block")
export let text export let text
</script> </script>
{#if $builderStore.inBuilder} {#if $builderStore.inBuilder}
<div> <div>
{text || $component.name || "Placeholder"} {text || block?.name || $component.name || "Placeholder"}
</div> </div>
{/if} {/if}

View File

@ -1,201 +1,298 @@
<script context="module">
export const Sides = {
Top: "Top",
Right: "Right",
Bottom: "Bottom",
Left: "Left",
}
</script>
<script> <script>
import { onMount, onDestroy } from "svelte" import { onMount, onDestroy } from "svelte"
import { get } from "svelte/store" import { get } from "svelte/store"
import IndicatorSet from "./IndicatorSet.svelte" import IndicatorSet from "./IndicatorSet.svelte"
import DNDPositionIndicator from "./DNDPositionIndicator.svelte" import {
import { builderStore } from "stores" builderStore,
screenStore,
dndStore,
dndParent,
dndIsDragging,
} from "stores"
import DNDPlaceholderOverlay from "./DNDPlaceholderOverlay.svelte"
import { Utils } from "@budibase/frontend-core"
import { findComponentById } from "utils/components.js"
import { DNDPlaceholderID } from "constants"
let dragInfo const ThrottleRate = 130
let dropInfo
const getEdges = (bounds, mousePoint) => { // Cache some dnd store state as local variables as it massively helps
const { width, height, top, left } = bounds // performance. It lets us avoid calling svelte getters on every DOM action.
return { $: source = $dndStore.source
[Sides.Top]: [mousePoint[0], top], $: target = $dndStore.target
[Sides.Right]: [left + width, mousePoint[1]], $: drop = $dndStore.drop
[Sides.Bottom]: [mousePoint[0], top + height],
[Sides.Left]: [left, mousePoint[1]], // Util to get the inner DOM node by a component ID
const getDOMNode = id => {
const component = document.getElementsByClassName(id)[0]
return [...component.children][0]
}
// Util to calculate the variance of a set of data
const variance = arr => {
const mean = arr.reduce((a, b) => a + b, 0) / arr.length
let squareSum = 0
arr.forEach(value => {
const delta = value - mean
squareSum += delta * delta
})
return squareSum / arr.length
}
// Callback when drag stops (whether dropped or not)
const stopDragging = () => {
// Reset listener
if (source?.id) {
const component = document.getElementsByClassName(source?.id)[0]
if (component) {
component.removeEventListener("dragend", stopDragging)
}
} }
}
const calculatePointDelta = (point1, point2) => { // Reset state
const deltaX = Math.abs(point1[0] - point2[0]) dndStore.actions.reset()
const deltaY = Math.abs(point1[1] - point2[1])
return Math.sqrt(deltaX * deltaX + deltaY * deltaY)
}
const getDOMNodeForComponent = component => {
const parent = component.closest(".component")
const children = Array.from(parent.children)
return children[0]
} }
// 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 => {
const parent = e.target.closest(".component") const component = e.target.closest(".component")
if (!parent?.classList.contains("draggable")) { if (!component?.classList.contains("draggable")) {
return return
} }
// Update state // Hide drag ghost image
dragInfo = { e.dataTransfer.setDragImage(new Image(), 0, 0)
target: parent.dataset.id,
parent: parent.dataset.parent,
}
builderStore.actions.selectComponent(dragInfo.target)
builderStore.actions.setDragging(true)
// Highlight being dragged by setting opacity // Add event handler to clear all drag state when dragging ends
const child = getDOMNodeForComponent(e.target) component.addEventListener("dragend", stopDragging)
if (child) {
child.style.opacity = "0.5" // Update state
} const id = component.dataset.id
const parentId = component.dataset.parent
const parent = findComponentById(
get(screenStore).activeScreen?.props,
parentId
)
const index = parent._children.findIndex(
x => x._id === component.dataset.id
)
dndStore.actions.startDraggingExistingComponent({
id,
bounds: component.children[0].getBoundingClientRect(),
parent: parentId,
index,
})
builderStore.actions.selectComponent(id)
// Set initial drop info to show placeholder exactly where the dragged
// component is.
// Execute this asynchronously to prevent bugs caused by updating state in
// the same handler as selecting a new component (which causes a client
// re-initialisation).
setTimeout(() => {
dndStore.actions.updateDrop({
parent: parentId,
index,
})
}, 0)
} }
// Callback when drag stops (whether dropped or not) // Core logic for handling drop events and determining where to render the
const onDragEnd = e => { // drop target placeholder
// Reset opacity style const processEvent = (mouseX, mouseY) => {
if (dragInfo) { if (!target) {
const child = getDOMNodeForComponent(e.target) return null
if (child) { }
child.style.opacity = "" 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
if (!acceptsChildren) {
id = parent
empty = false
node = getDOMNode(parent)
} }
// Reset state and styles // We're now hovering over something which does accept children.
dragInfo = null // If it is empty, just go inside it.
dropInfo = null if (empty) {
builderStore.actions.setDragging(false) dndStore.actions.updateDrop({
parent: id,
index: 0,
})
return
}
// As the first DOM node in a component may not necessarily contain the
// child components, we can find to try the parent of the first child
// component and use that as the real parent DOM node
const childNode = node.getElementsByClassName("component")[0]
if (childNode?.parentNode) {
node = childNode.parentNode
}
// Append an ephemeral div to allow us to determine layout if only one
// child exists
let ephemeralDiv
if (node.children.length === 1) {
ephemeralDiv = document.createElement("div")
ephemeralDiv.dataset.id = DNDPlaceholderID
node.appendChild(ephemeralDiv)
}
// We're now hovering over something which accepts children and is not
// empty, so we need to work out where to inside the placeholder
// Calculate the coordinates of various locations on each child.
const childCoords = [...(node.children || [])].map(node => {
const child = node.children?.[0] || node
const bounds = child.getBoundingClientRect()
return {
placeholder: node.dataset.id === DNDPlaceholderID,
centerX: bounds.left + bounds.width / 2,
centerY: bounds.top + bounds.height / 2,
left: bounds.left,
right: bounds.right,
top: bounds.top,
bottom: bounds.bottom,
}
})
// Now that we've calculated the position of the children, we no longer need
// the ephemeral div
if (ephemeralDiv) {
node.removeChild(ephemeralDiv)
}
// Calculate the variance between each set of positions on the children
const variances = Object.keys(childCoords[0])
.filter(x => x !== "placeholder")
.map(key => {
const coords = childCoords.map(x => x[key])
return {
variance: variance(coords),
side: key,
}
})
// Sort by variance. The lowest variance position indicates whether we are
// in a row or column layout
variances.sort((a, b) => {
return a.variance < b.variance ? -1 : 1
})
const column = ["centerX", "left", "right"].includes(variances[0].side)
// Calculate breakpoints between child components so we can determine the
// index to drop the component in.
// We want to ignore the placeholder from this calculation as it should not
// be considered a real child of the parent.
let breakpoints = childCoords
.filter(x => !x.placeholder)
.map(x => {
return column ? x.centerY : x.centerX
})
// Determine the index to drop the component in
const mousePosition = column ? mouseY : mouseX
let idx = 0
while (idx < breakpoints.length && breakpoints[idx] < mousePosition) {
idx++
}
dndStore.actions.updateDrop({
parent: id,
index: idx,
})
}
const throttledProcessEvent = Utils.throttle(processEvent, ThrottleRate)
const handleEvent = e => {
e.preventDefault()
throttledProcessEvent(e.clientX, e.clientY)
} }
// Callback when on top of a component // Callback when on top of a component
const onDragOver = e => { const onDragOver = e => {
// Skip if we aren't validly dragging currently if (!source || !target) {
if (!dragInfo || !dropInfo) {
return return
} }
handleEvent(e)
e.preventDefault()
const { droppableInside, bounds } = dropInfo
const { top, left, height, width } = bounds
const mouseY = e.clientY
const mouseX = e.clientX
const snapFactor = droppableInside ? 0.33 : 0.5
const snapLimitV = Math.min(40, height * snapFactor)
const snapLimitH = Math.min(40, width * snapFactor)
// Determine all sies we are within snap range of
let sides = []
if (mouseY <= top + snapLimitV) {
sides.push(Sides.Top)
} else if (mouseY >= top + height - snapLimitV) {
sides.push(Sides.Bottom)
}
if (mouseX < left + snapLimitH) {
sides.push(Sides.Left)
} else if (mouseX > left + width - snapLimitH) {
sides.push(Sides.Right)
}
// When no edges match, drop inside if possible
if (!sides.length) {
dropInfo.mode = droppableInside ? "inside" : null
dropInfo.side = null
return
}
// When one edge matches, use that edge
if (sides.length === 1) {
dropInfo.side = sides[0]
if ([Sides.Top, Sides.Left].includes(sides[0])) {
dropInfo.mode = "above"
} else {
dropInfo.mode = "below"
}
return
}
// When 2 edges match, work out which is closer
const mousePoint = [mouseX, mouseY]
const edges = getEdges(bounds, mousePoint)
const edge1 = edges[sides[0]]
const delta1 = calculatePointDelta(mousePoint, edge1)
const edge2 = edges[sides[1]]
const delta2 = calculatePointDelta(mousePoint, edge2)
const edge = delta1 < delta2 ? sides[0] : sides[1]
dropInfo.side = edge
if ([Sides.Top, Sides.Left].includes(edge)) {
dropInfo.mode = "above"
} else {
dropInfo.mode = "below"
}
} }
// Callback when entering a potential drop target // Callback when entering a potential drop target
const onDragEnter = e => { const onDragEnter = e => {
// Skip if we aren't validly dragging currently if (!source) {
if (!dragInfo || !e.target.closest) {
return return
} }
const element = e.target.closest(".component:not(.block)") // Find the next valid component to consider dropping over, ignoring nested
if ( // block components
element && const component = e.target?.closest?.(
element.classList.contains("droppable") && `.component:not(.block):not(.${source.id})`
element.dataset.id !== dragInfo.target )
) { if (component && component.classList.contains("droppable")) {
// Do nothing if this is the same target dndStore.actions.updateTarget({
if (element.dataset.id === dropInfo?.target) { id: component.dataset.id,
return parent: component.dataset.parent,
} node: getDOMNode(component.dataset.id),
empty: component.classList.contains("empty"),
// Ensure the dragging flag is always set. acceptsChildren: component.classList.contains("parent"),
// There's a bit of a race condition between the app reinitialisation })
// after selecting the DND component and setting this the first time handleEvent(e)
if (!get(builderStore).isDragging) {
builderStore.actions.setDragging(true)
}
// Store target ID
const target = element.dataset.id
// Precompute and store some info to avoid recalculating everything in
// dragOver
const child = getDOMNodeForComponent(e.target)
const bounds = child.getBoundingClientRect()
dropInfo = {
target,
name: element.dataset.name,
icon: element.dataset.icon,
droppableInside: element.classList.contains("empty"),
bounds,
}
} else {
dropInfo = null
} }
} }
// Callback when leaving a potential drop target.
// Since we don't style our targets, we don't need to unset anything.
const onDragLeave = () => {}
// Callback when dropping a drag on top of some component // Callback when dropping a drag on top of some component
const onDrop = e => { const onDrop = () => {
e.preventDefault() if (!source || !drop?.parent || drop?.index == null) {
if (dropInfo?.mode) { return
}
// Check if we're adding a new component rather than moving one
if (source.newComponentType) {
builderStore.actions.dropNewComponent(
source.newComponentType,
drop.parent,
drop.index
)
return
}
// Convert parent + index into target + mode
let legacyDropTarget, legacyDropMode
const parent = findComponentById(
get(screenStore).activeScreen?.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 !== DNDPlaceholderID && 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( builderStore.actions.moveComponent(
dragInfo.target, source.id,
dropInfo.target, legacyDropTarget,
dropInfo.mode legacyDropMode
) )
} }
} }
@ -203,39 +300,32 @@
onMount(() => { onMount(() => {
// Events fired on the draggable target // Events fired on the draggable target
document.addEventListener("dragstart", onDragStart, false) document.addEventListener("dragstart", onDragStart, false)
document.addEventListener("dragend", onDragEnd, false)
// Events fired on the drop targets // Events fired on the drop targets
document.addEventListener("dragover", onDragOver, false) document.addEventListener("dragover", onDragOver, false)
document.addEventListener("dragenter", onDragEnter, false) document.addEventListener("dragenter", onDragEnter, false)
document.addEventListener("dragleave", onDragLeave, false)
document.addEventListener("drop", onDrop, false) document.addEventListener("drop", onDrop, false)
}) })
onDestroy(() => { onDestroy(() => {
// Events fired on the draggable target // Events fired on the draggable target
document.removeEventListener("dragstart", onDragStart, false) document.removeEventListener("dragstart", onDragStart, false)
document.removeEventListener("dragend", onDragEnd, false)
// Events fired on the drop targets // Events fired on the drop targets
document.removeEventListener("dragover", onDragOver, false) document.removeEventListener("dragover", onDragOver, false)
document.removeEventListener("dragenter", onDragEnter, false) document.removeEventListener("dragenter", onDragEnter, false)
document.removeEventListener("dragleave", onDragLeave, false)
document.removeEventListener("drop", onDrop, false) document.removeEventListener("drop", onDrop, false)
}) })
</script> </script>
<IndicatorSet <IndicatorSet
componentId={dropInfo?.mode === "inside" ? dropInfo.target : null} componentId={$dndParent}
color="var(--spectrum-global-color-static-green-500)" color="var(--spectrum-global-color-static-green-500)"
zIndex="930" zIndex="930"
transition transition
prefix="Inside" prefix="Inside"
/> />
<DNDPositionIndicator {#if $dndIsDragging}
{dropInfo} <DNDPlaceholderOverlay />
color="var(--spectrum-global-color-static-green-500)" {/if}
zIndex="940"
transition
/>

View File

@ -0,0 +1,33 @@
<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

@ -0,0 +1,47 @@
<script>
import { onMount } from "svelte"
import { DNDPlaceholderID } from "constants"
import { domDebounce } from "utils/domDebounce.js"
let left, top, height, width
const updatePosition = () => {
const node = document.getElementById(DNDPlaceholderID)
if (!node) {
height = 0
width = 0
} else {
const bounds = node.getBoundingClientRect()
left = bounds.left
top = bounds.top
height = bounds.height
width = bounds.width
}
}
const debouncedUpdate = domDebounce(updatePosition)
onMount(() => {
const interval = setInterval(debouncedUpdate, 100)
return () => {
clearInterval(interval)
}
})
</script>
{#if left != null && top != null && width && height}
<div
class="overlay"
style="left: {left}px; top: {top}px; width: {width}px; height: {height}px;"
/>
{/if}
<style>
.overlay {
position: fixed;
z-index: 800;
background: hsl(160, 64%, 90%);
border-radius: 4px;
transition: all 130ms ease-out;
border: 2px solid var(--spectrum-global-color-static-green-500);
}
</style>

View File

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

View File

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

View File

@ -19,8 +19,8 @@
<div <div
in:fade={{ in:fade={{
delay: transition ? 130 : 0, delay: transition ? 100 : 0,
duration: transition ? 130 : 0, duration: transition ? 100 : 0,
}} }}
class="indicator" class="indicator"
class:flipped class:flipped

View File

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

View File

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

View File

@ -30,3 +30,7 @@ export const ActionTypes = {
ClearForm: "ClearForm", ClearForm: "ClearForm",
ChangeFormStep: "ChangeFormStep", ChangeFormStep: "ChangeFormStep",
} }
export const DNDPlaceholderID = "dnd-placeholder"
export const DNDPlaceholderType = "dnd-placeholder"
export const ScreenslotType = "screenslot"

View File

@ -6,6 +6,7 @@ import {
blockStore, blockStore,
componentStore, componentStore,
environmentStore, environmentStore,
dndStore,
} 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"
@ -25,6 +26,7 @@ let app
const loadBudibase = async () => { const loadBudibase = async () => {
// Update builder store with any builder flags // Update builder store with any builder flags
builderStore.set({ builderStore.set({
...get(builderStore),
inBuilder: !!window["##BUDIBASE_IN_BUILDER##"], inBuilder: !!window["##BUDIBASE_IN_BUILDER##"],
layout: window["##BUDIBASE_PREVIEW_LAYOUT##"], layout: window["##BUDIBASE_PREVIEW_LAYOUT##"],
screen: window["##BUDIBASE_PREVIEW_SCREEN##"], screen: window["##BUDIBASE_PREVIEW_SCREEN##"],
@ -59,6 +61,15 @@ const loadBudibase = async () => {
if (name === "eject-block") { if (name === "eject-block") {
const block = blockStore.actions.getBlock(payload) const block = blockStore.actions.getBlock(payload)
block?.eject() block?.eject()
} else if (name === "dragging-new-component") {
const { dragging, component } = payload
if (dragging) {
const definition =
componentStore.actions.getComponentDefinition(component)
dndStore.actions.startDraggingNewComponent({ component, definition })
} else {
dndStore.actions.reset()
}
} }
} }

View File

@ -16,7 +16,6 @@ const createBuilderStore = () => {
theme: null, theme: null,
customTheme: null, customTheme: null,
previewDevice: "desktop", previewDevice: "desktop",
isDragging: false,
navigation: null, navigation: null,
hiddenComponentIds: [], hiddenComponentIds: [],
usedPlugins: null, usedPlugins: null,
@ -67,11 +66,12 @@ const createBuilderStore = () => {
mode, mode,
}) })
}, },
setDragging: dragging => { dropNewComponent: (component, parent, index) => {
if (dragging === get(store).isDragging) { dispatchEvent("drop-new-component", {
return component,
} parent,
store.update(state => ({ ...state, isDragging: dragging })) index,
})
}, },
setEditMode: enabled => { setEditMode: enabled => {
if (enabled === get(store).editMode) { if (enabled === get(store).editMode) {

View File

@ -5,7 +5,9 @@ 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"
const budibasePrefix = "@budibase/standard-components/" const budibasePrefix = "@budibase/standard-components/"
@ -18,26 +20,21 @@ const createComponentStore = () => {
const derivedStore = derived( const derivedStore = derived(
[store, builderStore, devToolsStore, screenStore], [store, builderStore, devToolsStore, screenStore],
([$store, $builderState, $devToolsState, $screenState]) => { ([$store, $builderStore, $devToolsStore, $screenStore]) => {
const { inBuilder, selectedComponentId } = $builderStore
// Avoid any of this logic if we aren't in the builder preview // Avoid any of this logic if we aren't in the builder preview
if (!$builderState.inBuilder && !$devToolsState.visible) { if (!inBuilder && !$devToolsStore.visible) {
return {} return {}
} }
// Derive the selected component instance and definition const root = $screenStore.activeScreen?.props
let asset const component = findComponentById(root, selectedComponentId)
const { screen, selectedComponentId } = $builderState
if ($builderState.inBuilder) {
asset = screen
} else {
asset = $screenState.activeScreen
}
const component = findComponentById(asset?.props, selectedComponentId)
const definition = getComponentDefinition(component?._component) const definition = getComponentDefinition(component?._component)
// Derive the selected component path // Derive the selected component path
const path = const selectedPath =
findComponentPathById(asset?.props, selectedComponentId) || [] findComponentPathById(root, selectedComponentId) || []
return { return {
customComponentManifest: $store.customComponentManifest, customComponentManifest: $store.customComponentManifest,
@ -45,9 +42,8 @@ const createComponentStore = () => {
$store.mountedComponents[selectedComponentId], $store.mountedComponents[selectedComponentId],
selectedComponent: component, selectedComponent: component,
selectedComponentDefinition: definition, selectedComponentDefinition: definition,
selectedComponentPath: path?.map(component => component._id), selectedComponentPath: selectedPath?.map(component => component._id),
mountedComponentCount: Object.keys($store.mountedComponents).length, mountedComponentCount: Object.keys($store.mountedComponents).length,
currentAsset: asset,
} }
} }
) )
@ -95,8 +91,8 @@ const createComponentStore = () => {
} }
const getComponentById = id => { const getComponentById = id => {
const asset = get(derivedStore).currentAsset const root = get(screenStore).activeScreen?.props
return findComponentById(asset?.props, id) return findComponentById(root, id)
} }
const getComponentDefinition = type => { const getComponentDefinition = type => {
@ -105,8 +101,10 @@ const createComponentStore = () => {
} }
// Screenslot is an edge case // Screenslot is an edge case
if (type === "screenslot") { if (type === ScreenslotType) {
type = `${budibasePrefix}${type}` type = `${budibasePrefix}${type}`
} else if (type === DNDPlaceholderType) {
return {}
} }
// Handle built-in components // Handle built-in components
@ -124,8 +122,10 @@ const createComponentStore = () => {
if (!type) { if (!type) {
return null return null
} }
if (type === "screenslot") { if (type === ScreenslotType) {
return Router return Router
} else if (type === DNDPlaceholderType) {
return DNDPlaceholder
} }
// Handle budibase components // Handle budibase components

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,13 @@
import { derived } from "svelte/store"
import { findComponentPathById } from "utils/components.js"
import { dndParent } from "../dnd.js"
import { screenStore } from "../screens.js"
export const dndComponentPath = derived(
[dndParent, screenStore],
([$dndParent, $screenStore]) => {
const root = $screenStore.activeScreen?.props
const path = findComponentPathById(root, $dndParent) || []
return path?.map(component => component._id)
}
)

View File

@ -0,0 +1,5 @@
// These derived stores are pulled out from their parent stores to avoid
// dependency loops. By inverting store dependencies and extracting them
// separately we can keep our actual stores lean and performant.
export { currentRole } from "./currentRole.js"
export { dndComponentPath } from "./dndComponentPath.js"

View File

@ -0,0 +1,90 @@
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)
const startDraggingExistingComponent = ({ id, parent, bounds, index }) => {
store.set({
...initialState,
source: { id, parent, bounds, index },
})
}
const startDraggingNewComponent = ({ component, definition }) => {
if (!component) {
return
}
// Get size of new component so we can show a properly sized placeholder
const width = definition?.size?.width || 128
const height = definition?.size?.height || 64
store.set({
...initialState,
source: {
id: null,
parent: null,
bounds: { height, width },
index: null,
newComponentType: component,
},
})
}
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: {
startDraggingExistingComponent,
startDraggingNewComponent,
updateTarget,
updateDrop,
reset,
},
}
}
export const dndStore = createDndStore()
// The DND store is updated extremely frequently, so we can greatly improve
// performance by deriving any state that needs to be externally observed.
// By doing this and using primitives, we can avoid invalidating other stores
// or components which depend on DND state unless values actually change.
export const dndIsDragging = derived(dndStore, $dndStore => !!$dndStore.source)
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
)
export const dndIsNewComponent = derived(
dndStore,
$dndStore => $dndStore.source?.newComponentType != null
)

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 { authStore } from "./auth"
export { appStore } from "./app" export { appStore } from "./app"
export { notificationStore } from "./notification" export { notificationStore } from "./notification"
@ -19,6 +15,14 @@ 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 {
dndStore,
dndIndex,
dndParent,
dndBounds,
dndIsNewComponent,
dndIsDragging,
} from "./dnd"
// Context stores are layered and duplicated, so it is not a singleton // Context stores are layered and duplicated, so it is not a singleton
export { createContextStore } from "./context" export { createContextStore } from "./context"
@ -26,10 +30,5 @@ export { createContextStore } from "./context"
// Initialises an app by loading screens and routes // Initialises an app by loading screens and routes
export { initialise } from "./initialise" export { initialise } from "./initialise"
// Derive the current role of the logged-in user // Derived state
export const currentRole = derived( export * from "./derived"
[devToolsStore, authStore],
([$devToolsStore, $authStore]) => {
return ($devToolsStore.enabled && $devToolsStore.role) || $authStore?.roleId
}
)

View File

@ -2,18 +2,36 @@ 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 { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
import { findComponentById, findComponentParent } from "../utils/components.js"
import { Helpers } from "@budibase/bbui"
import { DNDPlaceholderID, DNDPlaceholderType } from "constants"
const createScreenStore = () => { const createScreenStore = () => {
const store = derived( const store = derived(
[appStore, routeStore, builderStore], [
([$appStore, $routeStore, $builderStore]) => { appStore,
routeStore,
builderStore,
dndParent,
dndIndex,
dndIsNewComponent,
],
([
$appStore,
$routeStore,
$builderStore,
$dndParent,
$dndIndex,
$dndIsNewComponent,
]) => {
let activeLayout, activeScreen let activeLayout, activeScreen
let screens let screens
if ($builderStore.inBuilder) { if ($builderStore.inBuilder) {
// Use builder defined definitions if inside the builder preview // Use builder defined definitions if inside the builder preview
activeScreen = $builderStore.screen activeScreen = Helpers.cloneDeep($builderStore.screen)
screens = [activeScreen] screens = [activeScreen]
// Legacy - allow the builder to specify a layout // Legacy - allow the builder to specify a layout
@ -24,8 +42,10 @@ const createScreenStore = () => {
// Find the correct screen by matching the current route // Find the correct screen by matching the current route
screens = $appStore.screens || [] screens = $appStore.screens || []
if ($routeStore.activeRoute) { if ($routeStore.activeRoute) {
activeScreen = screens.find( activeScreen = Helpers.cloneDeep(
screen => screen._id === $routeStore.activeRoute.screenId screens.find(
screen => screen._id === $routeStore.activeRoute.screenId
)
) )
} }
@ -40,6 +60,37 @@ const createScreenStore = () => {
} }
} }
// Insert DND placeholder if required
if (activeScreen && $dndParent && $dndIndex != null) {
// Remove selected component from tree if we are moving an existing
// component
const { selectedComponentId } = $builderStore
if (!$dndIsNewComponent) {
let selectedParent = findComponentParent(
activeScreen.props,
selectedComponentId
)
if (selectedParent) {
selectedParent._children = selectedParent._children?.filter(
x => x._id !== selectedComponentId
)
}
}
// Insert placeholder component
const placeholder = {
_component: DNDPlaceholderID,
_id: DNDPlaceholderType,
static: true,
}
let parent = findComponentById(activeScreen.props, $dndParent)
if (!parent._children?.length) {
parent._children = [placeholder]
} else {
parent._children.splice($dndIndex, 0, placeholder)
}
}
// Assign ranks to screens, preferring higher roles and home screens // Assign ranks to screens, preferring higher roles and home screens
screens.forEach(screen => { screens.forEach(screen => {
const roleId = screen.routing.roleId const roleId = screen.routing.roleId

View File

@ -60,3 +60,25 @@ export const findChildrenByType = (component, type, children = []) => {
findChildrenByType(child, type, children) findChildrenByType(child, type, children)
}) })
} }
/**
* Recursively searches for the parent component of a specific component ID
*/
export const findComponentParent = (rootComponent, id, parentComponent) => {
if (!rootComponent || !id) {
return null
}
if (rootComponent._id === id) {
return parentComponent
}
if (!rootComponent._children) {
return null
}
for (const child of rootComponent._children) {
const childResult = findComponentParent(child, id, rootComponent)
if (childResult) {
return childResult
}
}
return null
}

View File

@ -27,7 +27,7 @@ export const styleable = (node, styles = {}) => {
const setupStyles = (newStyles = {}) => { const setupStyles = (newStyles = {}) => {
let baseStyles = {} let baseStyles = {}
if (newStyles.empty) { if (newStyles.empty) {
baseStyles.border = "2px dashed var(--spectrum-global-color-gray-600)" baseStyles.border = "2px dashed var(--spectrum-global-color-gray-400)"
baseStyles.padding = "var(--spacing-l)" baseStyles.padding = "var(--spacing-l)"
baseStyles.overflow = "hidden" baseStyles.overflow = "hidden"
} }

View File

@ -40,3 +40,37 @@ export const debounce = (callback, minDelay = 1000) => {
}) })
} }
} }
/**
* Utility to throttle invocations of a synchronous function. This is better
* than a simple debounce invocation for a number of reasons. Features include:
* - First invocation is immediate (no initial delay)
* - Every invocation has the latest params (no stale params)
* - There will always be a final invocation with the last params (no missing
* final update)
* @param callback
* @param minDelay
* @returns {Function} a throttled version function
*/
export const throttle = (callback, minDelay = 1000) => {
let lastParams
let stalled = false
let pending = false
const invoke = (...params) => {
lastParams = params
if (stalled) {
pending = true
return
}
callback(...lastParams)
stalled = true
setTimeout(() => {
stalled = false
if (pending) {
pending = false
invoke(...lastParams)
}
}, minDelay)
}
return invoke
}