draggable / touchable card v0.1
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
<template>
|
||||
<article class="card w-56 h-80 min-w-56 min-h-80" :class="[
|
||||
isFaceUp ? 'face-up' : 'face-down',
|
||||
{ 'current-track': playerStore.currentTrack?.id === track.id }
|
||||
]">
|
||||
<div class="flip-inner">
|
||||
<article role="button" @click.stop="cardClick" @keydown.enter.stop="cardClick" @keydown.space.prevent.stop="cardClick"
|
||||
@touchstart="touchStart" @touchmove="touchMove" @touchend="touchEnd" class="card" :class="[
|
||||
isFaceUp ? 'face-up' : 'face-down',
|
||||
{ 'current-track': playerStore.currentTrack?.id === track.id },
|
||||
{ 'is-dragging': isDragging }
|
||||
]">
|
||||
<div class="flip-inner" ref="cardElement">
|
||||
<!-- Face-Up -->
|
||||
<main draggable="true" @dragstart="dragStart" @dragend="dragEnd"
|
||||
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 hover:shadow-xl hover:scale-110 transition-all rounded-2xl shadow-lg flex flex-col overflow-hidden">
|
||||
|
||||
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 right-7">
|
||||
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 left-7">
|
||||
<div class="suit text-7xl absolute"
|
||||
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">
|
||||
<img draggable="false" :src="`/${props.track.card?.suit}.svg`" />
|
||||
@@ -25,38 +27,31 @@
|
||||
</div>
|
||||
|
||||
<!-- Cover -->
|
||||
|
||||
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl cursor-pointer"
|
||||
@click="playerStore.playTrack(track)">
|
||||
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl cursor-pointer">
|
||||
<playButton :objectToPlay="track" />
|
||||
<img draggable="false" v-if="isFaceUp" :src="coverUrl" alt="Pochette de l'album" loading="lazy"
|
||||
class="w-full h-full object-cover object-center" />
|
||||
</figure>
|
||||
|
||||
<!-- Body -->
|
||||
|
||||
<div
|
||||
class="p-3 text-center bg-white rounded-b-xl opacity-0 -mt-16 hover:opacity-100 hover:-mt-0 transition-all duration-300">
|
||||
class="card-body p-3 text-center bg-white rounded-b-xl opacity-0 -mt-16 hover:opacity-100 hover:-mt-0 transition-all duration-300">
|
||||
<div v-if="isOrder" class="label">
|
||||
{{ props.track.order }}
|
||||
</div>
|
||||
<h2 class="select-text text-sm text-neutral-500 first-letter:uppercase truncate">
|
||||
{{ props.track.title }}
|
||||
{{ props.track.title || 'title' }}
|
||||
</h2>
|
||||
<p v-if="isPlaylistTrack" class="select-text text-base text-neutral-800 font-bold capitalize truncate">
|
||||
{{ props.track.artist.name }}
|
||||
{{ props.track.artist.name || 'artist' }}
|
||||
</p>
|
||||
<!-- <p class="select-text">
|
||||
{{ props.track.url.split('/')[4]?.split('__')[0] }}
|
||||
</p> -->
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Face-Down -->
|
||||
<footer
|
||||
class="face-down backdrop-blur-sm z-10 card w-56 h-80 p-3 bg-opacity-10 bg-white rounded-2xl shadow-lg flex flex-col overflow-hidden">
|
||||
<figure @click.stop="playerStore.playTrack(track)"
|
||||
class="h-full flex text-center rounded-xl justify-center cursor-pointer"
|
||||
<figure class="h-full flex text-center rounded-xl justify-center cursor-pointer"
|
||||
:style="{ backgroundColor: cardColor }">
|
||||
<playButton :objectToPlay="track" />
|
||||
<img draggable="false" src="/face-down.svg" />
|
||||
@@ -64,6 +59,36 @@
|
||||
</footer>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Clone fantôme pour le drag tactile -->
|
||||
<Teleport to="body">
|
||||
<div v-if="isDragging && touchClone" ref="ghostElement"
|
||||
class="ghost-card fixed pointer-events-none z-[9999] w-56 h-80" :style="{
|
||||
left: touchClone.x + 'px',
|
||||
top: touchClone.y + 'px',
|
||||
transform: 'translate(-50%, -50%)'
|
||||
}">
|
||||
<div class="flip-inner">
|
||||
<main
|
||||
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 rounded-2xl shadow-2xl flex flex-col overflow-hidden bg-white bg-opacity-90">
|
||||
|
||||
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 left-7">
|
||||
<div class="suit text-7xl absolute"
|
||||
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">
|
||||
<img draggable="false" :src="`/${props.track.card?.suit}.svg`" />
|
||||
</div>
|
||||
<div class="rank text-white font-bold absolute -mt-1">
|
||||
{{ props.track.card?.rank }}
|
||||
</div>
|
||||
</div>
|
||||
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl">
|
||||
<img draggable="false" :src="coverUrl" alt="Pochette de l'album"
|
||||
class="w-full h-full object-cover object-center" />
|
||||
</figure>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -91,17 +116,166 @@ const dataStore = useDataStore()
|
||||
const cardColor = computed(() => dataStore.getYearColor(props.track.year || 0))
|
||||
const coverUrl = computed(() => props.track.coverId || '/card-dock.svg')
|
||||
|
||||
const isDragging = ref(false)
|
||||
const cardElement = ref<HTMLElement | null>(null)
|
||||
const ghostElement = ref<HTMLElement | null>(null)
|
||||
|
||||
// État pour le tactile
|
||||
const touchClone = ref<{ x: number, y: number } | null>(null)
|
||||
const touchStartPos = ref<{ x: number, y: number } | null>(null)
|
||||
const longPressTimer = ref<number | null>(null)
|
||||
const LONG_PRESS_DURATION = 200 // ms
|
||||
const hasMovedDuringPress = ref(false)
|
||||
|
||||
const cardClick = () => {
|
||||
if (!isDragging.value && !hasMovedDuringPress.value) {
|
||||
console.log('card click')
|
||||
playerStore.playTrack(props.track)
|
||||
}
|
||||
hasMovedDuringPress.value = false
|
||||
}
|
||||
|
||||
// Drag desktop
|
||||
const dragStart = (event: DragEvent) => {
|
||||
if (event.dataTransfer) {
|
||||
if (event.dataTransfer && cardElement.value) {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('application/json', JSON.stringify(props.track));
|
||||
isDragging.value = true
|
||||
}
|
||||
};
|
||||
|
||||
const instance = getCurrentInstance();
|
||||
const dragEnd = (event: DragEvent) => {
|
||||
console.log('drag end');
|
||||
isDragging.value = false
|
||||
|
||||
if (event.dataTransfer?.dropEffect === 'move' && instance?.vnode?.el?.parentNode) {
|
||||
instance.vnode.el.parentNode.removeChild(instance.vnode.el);
|
||||
const parent = instance.parent;
|
||||
if (parent?.update) {
|
||||
parent.update();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Touch events
|
||||
const touchStart = (event: TouchEvent) => {
|
||||
const touch = event.touches[0]
|
||||
touchStartPos.value = { x: touch.clientX, y: touch.clientY }
|
||||
hasMovedDuringPress.value = false
|
||||
|
||||
// Démarrer un timer pour le long press
|
||||
longPressTimer.value = window.setTimeout(() => {
|
||||
startTouchDrag(touch)
|
||||
}, LONG_PRESS_DURATION)
|
||||
}
|
||||
|
||||
const startTouchDrag = (touch: Touch) => {
|
||||
isDragging.value = true
|
||||
touchClone.value = {
|
||||
x: touch.clientX,
|
||||
y: touch.clientY
|
||||
}
|
||||
|
||||
// Vibration feedback si disponible
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(50)
|
||||
}
|
||||
}
|
||||
|
||||
const touchMove = (event: TouchEvent) => {
|
||||
if (longPressTimer.value) {
|
||||
// Annuler le long press si l'utilisateur bouge trop
|
||||
const touch = event.touches[0]
|
||||
const dx = touch.clientX - (touchStartPos.value?.x || 0)
|
||||
const dy = touch.clientY - (touchStartPos.value?.y || 0)
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if (distance > 10) {
|
||||
clearTimeout(longPressTimer.value)
|
||||
longPressTimer.value = null
|
||||
hasMovedDuringPress.value = true
|
||||
}
|
||||
}
|
||||
|
||||
if (isDragging.value && touchClone.value) {
|
||||
event.preventDefault()
|
||||
const touch = event.touches[0]
|
||||
touchClone.value = {
|
||||
x: touch.clientX,
|
||||
y: touch.clientY
|
||||
}
|
||||
|
||||
// Déterminer l'élément sous le doigt
|
||||
checkDropTarget(touch.clientX, touch.clientY)
|
||||
}
|
||||
}
|
||||
|
||||
const touchEnd = (event: TouchEvent) => {
|
||||
if (longPressTimer.value) {
|
||||
clearTimeout(longPressTimer.value)
|
||||
longPressTimer.value = null
|
||||
}
|
||||
|
||||
if (isDragging.value) {
|
||||
event.preventDefault()
|
||||
const touch = event.changedTouches[0]
|
||||
|
||||
// Trouver l'élément de drop
|
||||
const dropTarget = document.elementFromPoint(touch.clientX, touch.clientY)
|
||||
const bucket = dropTarget?.closest('.bucket')
|
||||
|
||||
// Nettoyer les classes de drop target
|
||||
document.querySelectorAll('.bucket').forEach(b => {
|
||||
b.classList.remove('drop-target-active')
|
||||
})
|
||||
|
||||
if (bucket) {
|
||||
// Émettre un événement personnalisé pour le bucket
|
||||
const dropEvent = new CustomEvent('card-dropped-touch', {
|
||||
detail: props.track,
|
||||
bubbles: true
|
||||
})
|
||||
bucket.dispatchEvent(dropEvent)
|
||||
|
||||
// Vibration de confirmation
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(100)
|
||||
}
|
||||
|
||||
// Supprimer la carte
|
||||
if (instance?.vnode?.el?.parentNode) {
|
||||
instance.vnode.el.parentNode.removeChild(instance.vnode.el);
|
||||
const parent = instance.parent;
|
||||
if (parent?.update) {
|
||||
parent.update();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isDragging.value = false
|
||||
touchClone.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const checkDropTarget = (x: number, y: number) => {
|
||||
const element = document.elementFromPoint(x, y)
|
||||
const bucket = element?.closest('.bucket')
|
||||
|
||||
if (bucket) {
|
||||
bucket.classList.add('drop-target-active')
|
||||
} else {
|
||||
document.querySelectorAll('.bucket').forEach(b => {
|
||||
b.classList.remove('drop-target-active')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
onUnmounted(() => {
|
||||
if (longPressTimer.value) {
|
||||
clearTimeout(longPressTimer.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -121,7 +295,8 @@ const dragEnd = (event: DragEvent) => {
|
||||
/* Flip effect */
|
||||
.card {
|
||||
perspective: 1000px;
|
||||
@apply transition-all scale-100;
|
||||
@apply transition-all scale-100 w-56 h-80 min-w-56 min-h-80;
|
||||
touch-action: none;
|
||||
|
||||
.flip-inner {
|
||||
position: relative;
|
||||
@@ -171,7 +346,7 @@ const dragEnd = (event: DragEvent) => {
|
||||
|
||||
&.current-track,
|
||||
&:focus {
|
||||
@apply z-50;
|
||||
@apply z-50 scale-110;
|
||||
|
||||
.face-up {
|
||||
@apply shadow-2xl;
|
||||
@@ -180,7 +355,9 @@ const dragEnd = (event: DragEvent) => {
|
||||
transform 0.6s;
|
||||
}
|
||||
|
||||
@apply scale-110;
|
||||
.play-button {
|
||||
@apply opacity-100;
|
||||
}
|
||||
}
|
||||
|
||||
.play-button {
|
||||
@@ -207,5 +384,37 @@ const dragEnd = (event: DragEvent) => {
|
||||
@apply scale-90;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-dragging {
|
||||
@apply opacity-50 scale-95 rotate-6;
|
||||
cursor: grabbing !important;
|
||||
|
||||
.face-up {
|
||||
@apply shadow-2xl;
|
||||
}
|
||||
|
||||
.play-button,
|
||||
.card-body {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Ghost card styles */
|
||||
.ghost-card {
|
||||
transition: none;
|
||||
|
||||
.card {
|
||||
@apply shadow-2xl scale-95 rotate-6;
|
||||
|
||||
.play-button,
|
||||
.card-body {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.flip-inner {
|
||||
perspective: 1000px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user