forked from platypush/platypush
[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:
parent
44e319e7ca
commit
1316af9553
2 changed files with 590 additions and 0 deletions
|
@ -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>
|
|
@ -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>
|
Loading…
Reference in a new issue