kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
277 wiersze
6.5 KiB
Vue
277 wiersze
6.5 KiB
Vue
<script setup lang="ts">
|
|
import { useMouse, useCurrentElement, useRafFn, useElementByPoint } from '@vueuse/core'
|
|
import { ref, watchEffect, reactive, watch } from 'vue'
|
|
|
|
// @ts-expect-error no typings
|
|
import { RecycleScroller } from 'vue-virtual-scroller'
|
|
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
|
|
|
interface Events {
|
|
(e: 'reorder', from: number, to: number): void
|
|
(e: 'visible'): void
|
|
}
|
|
|
|
interface Props {
|
|
list: object[]
|
|
size: number
|
|
}
|
|
|
|
const emit = defineEmits<Events>()
|
|
const props = defineProps<Props>()
|
|
|
|
const ghostContainer = ref()
|
|
const hoveredIndex = ref()
|
|
const draggedItem = ref()
|
|
const position = ref('after')
|
|
|
|
const getIndex = (element: HTMLElement) => +(element?.getAttribute('data-index') ?? 0)
|
|
|
|
const isTouch = ref(false)
|
|
const onMousedown = (event: MouseEvent | TouchEvent) => {
|
|
const element = event.target as HTMLElement
|
|
const dragItem = element.closest('.drag-item') as HTMLElement
|
|
if (!dragItem || !element.classList.contains('handle')) return
|
|
|
|
// Touch devices stop emitting touch events while container is scrolled
|
|
// NOTE: FF does not support TouchEvent constructor
|
|
isTouch.value = window.TouchEvent
|
|
? event instanceof TouchEvent
|
|
: !(event instanceof MouseEvent)
|
|
|
|
const ghost = dragItem.cloneNode(true) as HTMLElement
|
|
ghost.classList.add('drag-ghost')
|
|
ghostContainer.value.appendChild(ghost)
|
|
|
|
const index = getIndex(dragItem)
|
|
document.body.classList.add('dragging')
|
|
hoveredIndex.value = index
|
|
draggedItem.value = {
|
|
item: props.list[index],
|
|
ghost,
|
|
index
|
|
}
|
|
|
|
resume()
|
|
}
|
|
|
|
// Touch and mobile devices support
|
|
const onTouchmove = (event: TouchEvent) => {
|
|
if (draggedItem.value) {
|
|
event.preventDefault()
|
|
}
|
|
}
|
|
|
|
document.addEventListener('touchcancel', (_event: TouchEvent) => {
|
|
cleanup()
|
|
})
|
|
|
|
const reorder = (_event: MouseEvent | TouchEvent) => {
|
|
if (draggedItem.value) {
|
|
const from = draggedItem.value.index
|
|
let to = hoveredIndex.value
|
|
|
|
if (from === to) return cleanup()
|
|
to -= +(position.value === 'before')
|
|
to += +(from > to)
|
|
|
|
if (from === to) return cleanup()
|
|
emit('reorder', from, to)
|
|
}
|
|
|
|
cleanup()
|
|
}
|
|
|
|
document.addEventListener('mouseup', reorder)
|
|
document.addEventListener('touchend', reorder)
|
|
|
|
const cleanup = () => {
|
|
pause()
|
|
document.body.classList.remove('dragging')
|
|
draggedItem.value?.ghost?.remove()
|
|
draggedItem.value = undefined
|
|
hoveredIndex.value = undefined
|
|
scrollDirection.value = undefined
|
|
}
|
|
|
|
const scrollDirection = ref()
|
|
const containerSize = reactive({ bottom: 0, top: 0 })
|
|
const { x, y: screenY } = useMouse({ type: 'client' })
|
|
|
|
const { element: hoveredElement, pause: dragTrackPause, resume: dragTrackStart } = useElementByPoint({ x, y: screenY })
|
|
dragTrackPause()
|
|
|
|
// Disable element lookup
|
|
watch(draggedItem, (dragging) => {
|
|
if (dragging) {
|
|
dragTrackStart()
|
|
return
|
|
}
|
|
|
|
dragTrackPause()
|
|
}, { immediate: true })
|
|
|
|
// Find current index and position on both desktop and mobile devices
|
|
watchEffect(() => {
|
|
if (draggedItem.value) {
|
|
const dragItem = (hoveredElement.value as HTMLElement)?.closest('.drag-item') as HTMLElement
|
|
if (!dragItem) return
|
|
|
|
hoveredIndex.value = getIndex(dragItem)
|
|
const { y } = dragItem.getBoundingClientRect()
|
|
position.value = screenY.value - y < props.size / 2 ? 'before' : 'after'
|
|
}
|
|
})
|
|
|
|
// Automatically scroll when on the edge
|
|
watchEffect(() => {
|
|
const { top, bottom } = containerSize
|
|
const y = Math.min(bottom, Math.max(top, screenY.value))
|
|
|
|
if (draggedItem.value) {
|
|
ghostContainer.value.style.top = `${y}px`
|
|
|
|
scrollDirection.value = y === top
|
|
? 'up'
|
|
: y === bottom
|
|
? 'down'
|
|
: undefined
|
|
|
|
return
|
|
}
|
|
|
|
scrollDirection.value = undefined
|
|
})
|
|
|
|
const el = useCurrentElement()
|
|
const resize = () => {
|
|
const element = el.value as HTMLElement
|
|
containerSize.top = element.offsetTop
|
|
containerSize.bottom = element.offsetHeight + containerSize.top
|
|
}
|
|
|
|
// Scrolling when item held near top/bottom border
|
|
let lastDate = +new Date()
|
|
const { resume, pause } = useRafFn(() => {
|
|
const now = +new Date()
|
|
const direction = scrollDirection.value
|
|
|
|
if (!(el.value instanceof HTMLElement)) return
|
|
if (direction && el.value?.children[0] && !isTouch.value) {
|
|
el.value.children[0].scrollTop += 200 / (now - lastDate) * (direction === 'up' ? -1 : 1)
|
|
}
|
|
|
|
lastDate = now
|
|
}, { immediate: false })
|
|
|
|
const virtualList = ref()
|
|
const scrollToIndex = (index: number, block: 'center' | 'start' | 'end' = 'start') => {
|
|
if (!virtualList.value) return
|
|
|
|
const position = block === 'start'
|
|
? index * props.size
|
|
: block === 'end'
|
|
? (index + 1) * props.size - virtualList.value.$el.offsetHeight
|
|
: (index + 0.5) * props.size - virtualList.value.$el.offsetHeight / 2
|
|
|
|
virtualList.value.scrollToPosition(position)
|
|
}
|
|
|
|
defineExpose({
|
|
scroller: virtualList,
|
|
scrollToIndex,
|
|
cleanup
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<recycle-scroller
|
|
ref="virtualList"
|
|
class="virtual-list drag-container"
|
|
key-field="key"
|
|
:items="list"
|
|
:item-size="size"
|
|
:grid-items="1"
|
|
@mousedown="onMousedown"
|
|
@touchstart="onMousedown"
|
|
@touchmove="onTouchmove"
|
|
@resize="resize"
|
|
@visible="emit('visible')"
|
|
>
|
|
<template #before>
|
|
<slot name="header" />
|
|
</template>
|
|
|
|
<template #default="{ item, index }">
|
|
<slot
|
|
:classlist="[draggedItem && hoveredIndex === index && `drop-${position}`, 'drag-item']"
|
|
:item="item"
|
|
:index="index"
|
|
/>
|
|
</template>
|
|
|
|
<template #after>
|
|
<slot name="footer" />
|
|
</template>
|
|
</recycle-scroller>
|
|
|
|
<div
|
|
ref="ghostContainer"
|
|
class="ghost-container"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss">
|
|
.drag-container {
|
|
position: relative;
|
|
}
|
|
|
|
.dragging {
|
|
user-select: none;
|
|
cursor: grab !important;
|
|
}
|
|
|
|
.drop-before {
|
|
box-shadow: 0 -1px 0 var(--vibrant-color),
|
|
inset 0 1px 0 var(--vibrant-color);
|
|
|
|
&:last-child {
|
|
box-shadow: inset 0 2px 0 var(--vibrant-color);
|
|
}
|
|
}
|
|
|
|
.drop-after {
|
|
box-shadow: 0 1px 0 var(--vibrant-color),
|
|
inset 0 -1px 0 var(--vibrant-color);
|
|
|
|
&:last-child {
|
|
box-shadow: inset 0 -2px 0 var(--vibrant-color);
|
|
}
|
|
}
|
|
|
|
.drag-ghost {
|
|
background: transparent !important;
|
|
}
|
|
|
|
.ghost-container {
|
|
position: absolute;
|
|
pointer-events: none;
|
|
z-index: 1002;
|
|
width: 100%;
|
|
transform: translateY(-50%);
|
|
left: 0;
|
|
top: 0;
|
|
opacity: 0.8;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.theme-light .ghost-container {
|
|
background: rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.vue-recycle-scroller__item-view {
|
|
width: 100% !important;
|
|
}
|
|
</style>
|