Skip to content

Commit 571a4fc

Browse files
jonadelinejrmi
andauthored
feat: improve drag and drop (baserow#5404)
--------- Co-authored-by: Jeremie Pardou <571533+jrmi@users.noreply.github.com>
1 parent 8d41ae1 commit 571a4fc

8 files changed

Lines changed: 192 additions & 9 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"type": "feature",
3+
"message": "Improve drag and drop UX",
4+
"issue_origin": "github",
5+
"issue_number": 5143,
6+
"domain": "builder",
7+
"bullet_points": [],
8+
"created_at": "2026-05-20"
9+
}

web-frontend/modules/builder/components/elements/ElementPreview.vue

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<template>
22
<div
3+
ref="elementPreviewRef"
34
:key="element.id"
45
class="element-preview"
56
:class="{
@@ -15,6 +16,7 @@
1516
}"
1617
:draggable="isDraggable"
1718
@click="onSelect"
19+
@mousedown="canUpdate && isSelected && onDragHandleMouseDown($event)"
1820
@dragstart.stop="onDragStart"
1921
@dragend="onDragEnd"
2022
@dragenter="onDragEnter"
@@ -48,8 +50,10 @@
4850
@duplicate="duplicateElement"
4951
@select-parent="selectParentElement()"
5052
@drag-handle-mousedown="onDragHandleMouseDown"
53+
@mousedown.stop
5154
/>
5255
<PageElement
56+
ref="elementRef"
5357
:element="element"
5458
:mode="mode"
5559
class="element--read-only"
@@ -72,7 +76,7 @@
7276
</template>
7377

7478
<script>
75-
import { computed, inject } from 'vue'
79+
import { computed, inject, ref } from 'vue'
7680
import { useStore, mapActions, mapGetters } from 'vuex'
7781
import ElementMenu from '@baserow/modules/builder/components/elements/ElementMenu'
7882
import InsertElementButton from '@baserow/modules/builder/components/elements/InsertElementButton'
@@ -113,6 +117,8 @@ export default {
113117
},
114118
emits: ['move'],
115119
setup(props) {
120+
const elementPreviewRef = ref(null)
121+
const elementRef = ref(null)
116122
const store = useStore()
117123
const builder = inject('builder')
118124
@@ -129,13 +135,71 @@ export default {
129135
props.element.parent_element_id
130136
)
131137
})
138+
139+
function getDragImageScale(rect) {
140+
const defaultDragImageScale = 0.5
141+
const maxHeightRatioBeforeScalingDown = 0.3
142+
const viewportHeight =
143+
window.innerHeight || document.documentElement.clientHeight
144+
const maxHeightBeforeScalingDown =
145+
viewportHeight * maxHeightRatioBeforeScalingDown
146+
const defaultScaledHeight = rect.height * defaultDragImageScale
147+
148+
if (
149+
!viewportHeight ||
150+
defaultScaledHeight <= maxHeightBeforeScalingDown
151+
) {
152+
return defaultDragImageScale
153+
}
154+
155+
return maxHeightBeforeScalingDown / rect.height
156+
}
157+
158+
function createDragImage() {
159+
const source = elementRef.value.$el
160+
const rect = source.getBoundingClientRect()
161+
const dragImageScale = getDragImageScale(rect)
162+
163+
const clone = source.cloneNode(true)
164+
165+
Object.assign(clone.style, {
166+
boxSizing: 'border-box',
167+
width: `${rect.width}px`,
168+
minWidth: `${rect.width}px`,
169+
maxWidth: `${rect.width}px`,
170+
transform: `scale(${dragImageScale})`,
171+
transformOrigin: 'top left',
172+
})
173+
174+
const container = document.createElement('div')
175+
Object.assign(container.style, {
176+
position: 'fixed',
177+
top: '0',
178+
left: '0',
179+
pointerEvents: 'none',
180+
})
181+
container.appendChild(clone)
182+
elementPreviewRef.value.appendChild(container)
183+
requestAnimationFrame(() => {
184+
// immediately remove the cloned element
185+
elementPreviewRef.value.removeChild(container)
186+
})
187+
188+
return container
189+
}
190+
132191
return {
133-
...useElementDraggable({ element: props.element }),
192+
...useElementDraggable({
193+
element: props.element,
194+
getDragImage: createDragImage,
195+
}),
134196
...useDropElementTarget({
135197
parentElement,
136198
referenceElement: props.element,
137199
placeInContainer: props.element.place_in_container,
138200
}),
201+
elementPreviewRef,
202+
elementRef,
139203
parentElement,
140204
elementPage,
141205
}

web-frontend/modules/builder/composables/useDropElementTarget.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,14 @@ export function useDropElementTarget({
187187

188188
syncDropPosition(event)
189189

190+
// Empty placeholders have no reference element, so dragover marks them
191+
// active directly when the cursor is over their real DOM box.
192+
if (!unref(referenceElement)) {
193+
// force dragenter local state because native dragenter event is not always reliable.
194+
dragEnterCount = Math.max(dragEnterCount, 1)
195+
dndContext.dropTargetId = uid
196+
}
197+
190198
event.preventDefault()
191199
event.stopPropagation()
192200
}

web-frontend/modules/builder/composables/useElementDraggable.js

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
1-
import { ref, onUnmounted, inject } from 'vue'
1+
import { computed, ref, onUnmounted, inject, unref } from 'vue'
22

33
/**
44
* Manages the drag-source behaviour of a builder element in the page preview.
55
*
66
* Responsibilities:
7-
* - Activate the HTML5 draggable attribute only while the drag handle is held
8-
* down
7+
* - Activate the HTML5 draggable attribute only while the drag handle or the
8+
* preview source is held down
99
* - Write / clear the shared dndContext when a drag starts or ends.
1010
* - Clean up the mouseup listener automatically when the component unmounts.
1111
*
1212
* @param {Function} getElement
1313
* @returns {{ isDraggable: Ref<boolean>, onDragHandleMouseDown: Function,
1414
* onDragStart: Function, onDragEnd: Function }}
1515
*/
16-
export function useElementDraggable({ element }) {
16+
export function useElementDraggable({ element, getDragImage = null }) {
1717
const dndContext = inject('dndContext')
1818

1919
const isDraggable = ref(false)
@@ -34,6 +34,17 @@ export function useElementDraggable({ element }) {
3434
function onDragStart(event) {
3535
event.dataTransfer.effectAllowed = 'move'
3636
event.dataTransfer.setData('text/plain', String(unref(element).id))
37+
38+
if (typeof getDragImage === 'function') {
39+
const dragElement = getDragImage()
40+
const cloneRect = dragElement.getBoundingClientRect()
41+
event.dataTransfer.setDragImage(
42+
dragElement,
43+
cloneRect.width / 2,
44+
cloneRect.height / 2
45+
)
46+
}
47+
3748
dndContext.draggedElement = unref(element)
3849
}
3950

web-frontend/modules/core/assets/scss/components/builder/add_element_zone.scss

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
width: calc(100% - 40px);
66
height: 120px;
77
border: 1px dashed $palette-neutral-400;
8-
background-color: $palette-neutral-50;
8+
background-color: rgba($palette-neutral-50, 0.5);
99
margin: 20px;
1010

1111
@include rounded($rounded-md);
1212

1313
&:hover {
1414
border: 1px solid $palette-blue-500;
15-
background-color: $palette-blue-50;
15+
background-color: rgba($palette-blue-50, 0.8);
1616
border-color: $palette-blue-300;
1717
cursor: pointer;
1818
}

web-frontend/modules/core/assets/scss/components/builder/element.scss

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@
7777
}
7878

7979
.element--read-only {
80+
user-select: none;
81+
8082
& .ab-button,
8183
& .ab-input {
8284
pointer-events: none;

web-frontend/modules/core/assets/scss/components/builder/element_preview.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777
}
7878

7979
&.element-preview--active {
80-
cursor: inherit;
80+
cursor: grab;
8181

8282
&::before {
8383
@include absolute(0, 0, 0, 0);
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import flushPromises from 'flush-promises'
2+
import { defineComponent, reactive } from 'vue'
3+
import { mountSuspended } from '@nuxt/test-utils/runtime'
4+
import { useDropElementTarget } from '@baserow/modules/builder/composables/useDropElementTarget'
5+
6+
describe('useDropElementTarget', () => {
7+
let testApp = null
8+
let store = null
9+
10+
const page = { id: 1, shared: false, elementMap: {} }
11+
const sharedPage = { id: 2, shared: true, elementMap: {} }
12+
const builder = { id: 1, pages: [page, sharedPage] }
13+
const workspace = { id: 1 }
14+
15+
const mountDropTarget = ({ dndContext, parentElement = null }) => {
16+
const DropTarget = defineComponent({
17+
template: `
18+
<div
19+
data-test-id="drop-target"
20+
@dragenter="onDragEnter"
21+
@dragover="onDragOver"
22+
@dragleave="onDragLeave"
23+
@drop="onDrop"
24+
/>
25+
`,
26+
setup() {
27+
return useDropElementTarget({
28+
parentElement,
29+
page,
30+
})
31+
},
32+
})
33+
34+
return mountSuspended(DropTarget, {
35+
global: {
36+
provide: {
37+
builder,
38+
dndContext,
39+
workspace,
40+
},
41+
},
42+
})
43+
}
44+
45+
beforeEach(() => {
46+
testApp = useNuxtApp()
47+
store = testApp.$store
48+
})
49+
50+
afterEach(() => {
51+
vi.restoreAllMocks()
52+
})
53+
54+
test('can drop on an empty placeholder after dragover without dragenter', async () => {
55+
const draggedElement = {
56+
id: 10,
57+
type: 'heading',
58+
page_id: page.id,
59+
parent_element_id: null,
60+
place_in_container: null,
61+
}
62+
const dndContext = reactive({
63+
draggedElement,
64+
dropTargetId: null,
65+
})
66+
const dispatchSpy = vi.spyOn(store, 'dispatch').mockResolvedValue()
67+
68+
const wrapper = await mountDropTarget({ dndContext })
69+
const target = wrapper.find('[data-test-id="drop-target"]')
70+
71+
await target.trigger('dragover')
72+
73+
expect(dndContext.dropTargetId).not.toBe(null)
74+
75+
await target.trigger('drop')
76+
await flushPromises()
77+
78+
expect(dispatchSpy).toHaveBeenCalledWith('element/move', {
79+
builder,
80+
page,
81+
elementId: draggedElement.id,
82+
beforeElementId: null,
83+
parentElementId: null,
84+
placeInContainer: null,
85+
targetPage: page,
86+
})
87+
expect(dndContext.dropTargetId).toBe(null)
88+
})
89+
})

0 commit comments

Comments
 (0)