[UI] Added general-purpose drag-and-drop components.

This is to bridge the gap between pointer-based and touch-based devices
and provide a drag-and-drop implementation that exposes a consistent API
for both the interfaces.

These components work by wrapping an underlying draggable/droppable DOM
element and proxying the event handlers consistently when drag/touch
events are detected.

This allows to listen to high-level drag/drop events even on touch-based
interface based on touch start/move/end events.

Example usage:

```vue
<template>
  <div class="draggable" ref="draggable">
    I can be dragged.
  </div>

  <div class="droppable" ref="droppable">
    Drop elements here.
  </div>

  <Draggable :element="$refs.draggable"
             @drag="console.log('The element is being dragged')"
             @drop="console.log('The element is been dropped')" />

  <Droppable :element="$refs.droppable"
             @dragenter="console.log('Entering')"
             @dragleave="console.log('Leaving')"
             @dragover="console.log('Dragging over')"
             @drop="console.log('Dropped!')" />
</template>

<style lang="scss" scoped>
.draggable {
  &.dragged {
    opacity: 0.5;
  }
}

.droppable {
  &.active {
    border: 1px solid green;
  }

  &.selected {
    background: yellow;
  }
}
</style>
```
This commit is contained in:
Fabio Manganiello 2024-09-05 01:41:04 +02:00
parent 44e319e7ca
commit 1316af9553
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
2 changed files with 590 additions and 0 deletions

View file

@ -0,0 +1,416 @@
<template>
<div class="dragged"
:class="{ hidden: !draggingVisible }"
:style="{ top: `${top}px`, left: `${left}px` }">
<div class="content"
v-html="element?.outerHTML || '...'"
v-if="draggingVisible" />
</div>
</template>
<script>
export default {
emits: [
'contextmenu',
'drag',
'drop',
],
props: {
disabled: {
type: Boolean,
default: false,
},
element: {
type: Object,
},
touchDragStartThreshold: {
type: Number,
default: 500,
},
touchDragMoveCancelDistance: {
type: Number,
default: 10,
},
value: {
type: Object,
default: () => ({}),
},
},
data() {
return {
dragging: false,
draggingHTML: null,
eventsHandlers: {
contextmenu: this.onContextMenu,
drag: this.onDrag,
dragend: this.onDragEnd,
dragstart: this.onDragStart,
drop: this.onDrop,
touchcancel: this.onDrop,
touchend: this.onTouchEnd,
touchmove: this.onTouchMove,
touchstart: this.onTouchStart,
},
initialCursorOffset: null,
left: 0,
top: 0,
touchDragStartTimer: null,
touchScrollDirection: [0, 0],
touchScrollSpeed: 10,
touchScrollTimer: null,
touchStart: null,
touchOverElement: null,
}
},
computed: {
draggingVisible() {
return this.dragging && this.touchStart
},
shouldScroll() {
return this.touchScrollDirection[0] || this.touchScrollDirection[1]
},
},
methods: {
onContextMenu(event) {
// If the element is disabled, or there's no touch start event, then we should
// emit the contextmenu event as usual.
if (this.disabled || !this.touchStart) {
this.$emit('contextmenu', event)
return
}
// Otherwise, if a touch start event was registered, then we should prevent the
// context menu event from being emitted, as it's most likely a long touch event
// that should trigger the drag event.
event.preventDefault()
event.stopPropagation()
this.onDragStart(event)
},
onDragStart(event) {
if (this.disabled) {
return
}
this.dragging = true
this.draggingHTML = this.$slots.default?.()?.el?.outerHTML
event.value = this.value
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('application/json', JSON.stringify(this.value))
}
this.cancelTouchDragStart()
this.$emit('drag', event)
},
onDragEnd(event) {
if (this.disabled) {
return
}
this.reset()
this.$emit('drop', event)
},
onTouchStart(event) {
if (this.disabled) {
return
}
const touch = event.touches?.[0]
if (!touch) {
return
}
this.touchStart = [touch.clientX, touch.clientY]
this.cancelTouchDragStart()
this.touchDragStartTimer = setTimeout(() => {
this.onDragStart(event)
}, this.touchDragStartThreshold)
},
onTouchMove(event) {
if (this.disabled) {
return
}
const touch = event.touches?.[0]
if (!(touch && this.touchStart)) {
return
}
// If we received a touch move event, and there's a touch drag start timer,
// then most likely the user is not trying to drag the element, but just scrolling.
// In this case, we should cancel the drag start timer.
if (this.touchDragStartTimer) {
const distance = Math.hypot(
touch.clientX - this.touchStart[0],
touch.clientY - this.touchStart[1]
)
if (distance > this.touchDragMoveCancelDistance) {
this.reset()
return
}
this.onDragStart(event)
}
event.preventDefault()
const { clientX, clientY } = touch
this.left = clientX
this.top = clientY
this.left = clientX - this.touchStart[0]
this.top = clientY - this.touchStart[1]
this.touchScroll(event)
// Get droppable element under the touch, excluding the current dragged element
let droppable = document.elementsFromPoint(clientX, clientY).filter(
el => el.dataset?.droppable && !el.classList.contains('dragged')
)?.[0]
if (!droppable) {
this.touchOverElement = null
return
}
this.dispatchEvent('dragenter', droppable)
this.touchOverElement = droppable
},
touchScroll(event) {
if (this.disabled) {
return
}
const parent = this.getScrollableParent()
if (!parent) {
return
}
const touch = event.touches?.[0]
if (!touch) {
return
}
const { clientX, clientY } = touch
const rect = parent.getBoundingClientRect()
const touchOffset = [
(clientX - rect.left) / rect.width,
(clientY - rect.top) / rect.height
]
const scrollDirection = [0, 0]
if (touchOffset[0] < 0) {
scrollDirection[0] = -1
} else if (touchOffset[0] > 1) {
scrollDirection[0] = 1
}
if (touchOffset[1] < 0) {
scrollDirection[1] = -1
} else if (touchOffset[1] > 1) {
scrollDirection[1] = 1
}
this.handleTouchScroll(scrollDirection, parent)
},
onTouchEnd(event) {
if (this.disabled) {
return
}
const droppable = this.touchOverElement
if (droppable) {
this.dispatchEvent('drop', droppable)
}
this.onDragEnd(event)
},
onDrop(event) {
if (this.disabled) {
return
}
this.reset()
this.$emit('drop', event)
},
handleTouchScroll(value, parent) {
this.touchScrollDirection = value
if (!(value[0] || value[1])) {
this.cancelScroll()
return
}
if (this.touchScrollTimer) {
return
}
this.touchScrollTimer = setInterval(() => {
if (!parent) {
return
}
const [x, y] = value
parent.scrollBy(x * this.touchScrollSpeed, y * this.touchScrollSpeed)
}, 1000 / 60)
},
getScrollableParent() {
let parent = this.element?.parentElement
while (parent) {
if (
parent.scrollHeight > parent.clientHeight ||
parent.scrollWidth > parent.clientWidth
) {
const style = window.getComputedStyle(parent)
if (['scroll', 'auto'].includes(style.overflowY) || ['scroll', 'auto'].includes(style.overflowX)) {
return parent
}
}
parent = parent.parentElement
}
return null
},
dispatchEvent(type, droppable) {
droppable.dispatchEvent(
new DragEvent(
type, {
target: {
...droppable,
value: this.value,
}
}
)
)
},
cancelScroll() {
this.touchScrollDirection = [0, 0]
if (this.touchScrollTimer) {
clearInterval(this.touchScrollTimer)
this.touchScrollTimer = null
}
},
cancelTouchDragStart() {
if (this.touchDragStartTimer) {
clearTimeout(this.touchDragStartTimer)
this.touchDragStartTimer = null
}
},
reset() {
this.cancelTouchDragStart()
this.cancelScroll()
this.dragging = false
this.touchStart = null
this.touchOverElement = null
this.left = 0
this.top = 0
this.initialCursorOffset = null
},
installHandlers() {
console.debug('Installing drag handlers on', this.element)
this.element?.setAttribute('draggable', true)
Object.entries(this.eventsHandlers).forEach(([event, handler]) => {
this.element?.addEventListener(event, handler)
})
},
uninstallHandlers() {
console.debug('Uninstalling drag handlers from', this.element)
this.element?.setAttribute('draggable', false)
Object.entries(this.eventsHandlers).forEach(([event, handler]) => {
this.element?.removeEventListener(event, handler)
})
},
},
watch: {
dragging() {
if (this.dragging) {
this.element?.classList.add('dragged')
this.$nextTick(() => {
if (!this.touchStart) {
return
}
this.initialCursorOffset = [
this.element?.offsetLeft - this.touchStart[0],
this.element?.offsetTop - this.touchStart[1]
]
})
} else {
this.element?.classList.remove('dragged')
}
},
disabled(value) {
if (value) {
this.reset()
this.uninstallHandlers()
} else {
this.installHandlers()
}
},
element() {
this.uninstallHandlers()
this.installHandlers()
},
touchOverElement(value, oldValue) {
if (value === oldValue) {
return
}
if (oldValue) {
this.dispatchEvent('dragleave', oldValue)
}
if (value) {
this.dispatchEvent('dragenter', value)
}
},
},
mounted() {
this.installHandlers()
},
unmounted() {
this.uninstallHandlers()
},
}
</script>
<style lang="scss" scoped>
.dragged {
position: absolute;
max-width: 100%;
max-height: 100%;
transform: scale(0.5);
z-index: 1000;
}
</style>

View file

@ -0,0 +1,174 @@
<template>
<div class="droppable" />
</template>
<script>
export default {
emits: [
'dragenter',
'dragleave',
'dragover',
'drop',
],
props: {
element: {
type: Object,
},
active: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
data() {
return {
eventsHandlers: {
dragenter: this.onDragEnter,
dragleave: this.onDragLeave,
dragover: this.onDragOver,
drop: this.onDrop,
},
selected: false,
}
},
methods: {
onDragEnter(event) {
if (this.disabled || this.selected) {
return
}
this.selected = true
this.$emit('dragenter', event)
},
onDragLeave(event) {
if (this.disabled || !this.selected) {
return
}
const rect = this.element.getBoundingClientRect()
if (
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom
) {
return
}
this.selected = false
this.$emit('dragleave', event)
},
onDragOver(event) {
if (this.disabled) {
return
}
event.preventDefault()
this.selected = true
this.$emit('dragover', event)
},
onDrop(event) {
if (this.disabled) {
return
}
this.selected = false
this.$emit('drop', event)
},
installHandlers() {
const el = this.element
if (!el) {
return
}
console.debug('Installing drop handlers on', this.element)
if (el.dataset) {
el.dataset.droppable = true;
}
if (el.addEventListener) {
Object.entries(this.eventsHandlers).forEach(([event, handler]) => {
el.addEventListener(event, handler)
})
}
},
uninstallHandlers() {
const el = this.element
if (!el) {
return
}
console.debug('Uninstalling drop handlers from', this.element)
if (el.dataset?.droppable) {
delete el.dataset.droppable;
}
if (el.removeEventListener) {
Object.entries(this.eventsHandlers).forEach(([event, handler]) => {
el.removeEventListener(event, handler)
})
}
},
},
watch: {
active() {
if (this.active) {
this.element?.classList.add('active')
} else {
this.element?.classList.remove('active')
}
},
disabled: {
handler() {
if (this.disabled) {
this.element?.classList.add('disabled')
} else {
this.element?.classList.remove('disabled')
}
},
},
element: {
handler() {
this.uninstallHandlers()
this.installHandlers()
},
},
selected: {
handler(value, oldValue) {
if (value && !oldValue) {
this.element?.classList.add('selected')
} else if (!value && oldValue) {
this.element?.classList.remove('selected')
}
},
},
},
mounted() {
this.$nextTick(() => {
this.installHandlers()
})
},
unmounted() {
this.uninstallHandlers()
},
}
</script>