Skip to content

Commit 333b458

Browse files
committed
refactor: use ghost animation instead separate list
fixes #24
2 parents 1d8fd35 + 135a8f0 commit 333b458

File tree

9 files changed

+421
-99
lines changed

9 files changed

+421
-99
lines changed

example/interactive-demo/Actions.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ defineEmits<Emits>()
3535
Anim. reset
3636
</button>
3737
</div>
38-
<div class="flex justify-center gap-4">
38+
<div class="flex justify-center gap-1">
3939
<button
4040
class="btn btn-circle btn-error"
4141
:disabled="isEnd && !loop"

src/FlashCard.vue

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
<script setup lang="ts">
22
import type { DragPosition, DragSetupParams } from './utils/useDragSetup'
3-
import { onMounted, useTemplateRef, watch } from 'vue'
3+
import { onBeforeUnmount, onMounted, useTemplateRef, watch } from 'vue'
44
import ApproveIcon from './components/icons/ApproveIcon.vue'
55
import RejectIcon from './components/icons/RejectIcon.vue'
66
import { SwipeAction, useDragSetup } from './utils/useDragSetup'
7+
import { useGhostAnimation } from './utils/useGhostAnimation'
78
89
export interface FlashCardProps extends DragSetupParams {
910
// Completely disable dragging feature
@@ -117,10 +118,56 @@ watch(() => params.disableDrag, () => {
117118
setupInteract()
118119
})
119120
121+
// Ghost animation composable
122+
const { isAnimating: isGhostAnimating, createGhost, cleanup: cleanupGhost } = useGhostAnimation(el)
123+
124+
// Helper to trigger ghost animation
125+
function triggerGhostAnimation() {
126+
if (!animation || !el.value)
127+
return
128+
129+
requestAnimationFrame(() => {
130+
createGhost(
131+
{
132+
animationType: animation.type,
133+
isRestoring: animation.isRestoring,
134+
swipeDirection: params.swipeDirection,
135+
initialPosition: animation.initialPosition,
136+
getTransformStyle,
137+
},
138+
() => emit('animationend'),
139+
)
140+
})
141+
}
142+
143+
// Watch for animation prop changes (serialize to detect deep changes)
144+
watch(() => JSON.stringify(animation), (newAnimationStr, oldAnimationStr) => {
145+
// Skip if element is not mounted yet
146+
if (!el.value) {
147+
return
148+
}
149+
150+
// If animation changed to a new one while old is still running, cleanup first
151+
if (oldAnimationStr && newAnimationStr && oldAnimationStr !== newAnimationStr) {
152+
cleanupGhost()
153+
}
154+
155+
triggerGhostAnimation()
156+
})
157+
120158
onMounted(() => {
121159
if (el.value?.offsetHeight) {
122160
emit('mounted', el.value?.offsetHeight)
123161
}
162+
163+
// If animation is set, trigger ghost creation after mount
164+
if (animation) {
165+
triggerGhostAnimation()
166+
}
167+
})
168+
169+
onBeforeUnmount(() => {
170+
cleanupGhost()
124171
})
125172
126173
defineExpose({
@@ -135,17 +182,12 @@ defineExpose({
135182
:class="{
136183
'flash-card--dragging': isDragging,
137184
'flash-card--drag-disabled': params.disableDrag,
185+
'flash-card--hidden': isGhostAnimating,
138186
}"
139187
:style="{ transform: `translate3D(${position.x}px, ${position.y}px, 0)` }"
140188
>
141189
<div
142190
class="flash-card__animation-wrapper"
143-
:class="{
144-
[`flash-card-animation--${animation?.type}`]: animation?.type,
145-
[`flash-card-animation--${animation?.type}-restore`]: animation?.isRestoring,
146-
[`flash-card-animation--${params.swipeDirection}`]: animation?.type,
147-
}"
148-
@animationend="emit('animationend')"
149191
>
150192
<div class="flash-card__transform" :style="getTransformStyle(position)">
151193
<slot :is-dragging="isDragging" />
@@ -199,6 +241,10 @@ defineExpose({
199241
pointer-events: none;
200242
}
201243
244+
.flash-card--hidden {
245+
visibility: hidden;
246+
}
247+
202248
/* Base animations (horizontal by default) */
203249
.flash-card-animation--approve { animation: approve-horizontal 0.4s cubic-bezier(0.4,0,0.2,1) forwards; }
204250
.flash-card-animation--reject { animation: reject-horizontal 0.4s cubic-bezier(0.4,0,0.2,1) forwards; }
@@ -218,14 +264,14 @@ defineExpose({
218264
.flash-card-animation--vertical.flash-card-animation--reject-restore { animation: restore-reject-vertical 0.4s cubic-bezier(0.4,0,0.2,1) forwards; }
219265
220266
/* Horizontal keyframes */
221-
@keyframes approve-horizontal { 0%{opacity:1;} 100%{transform:translateX(320px) rotate(15deg);opacity:0;} }
222-
@keyframes reject-horizontal { 0%{opacity:1;} 100%{transform:translateX(-320px) rotate(-15deg);opacity:0;} }
223-
@keyframes restore-approve-horizontal { 0%{transform:translateX(320px) rotate(15deg);opacity:0;} 100%{transform:translateX(0) rotate(0deg);opacity:1;} }
224-
@keyframes restore-reject-horizontal { 0%{transform:translateX(-320px) rotate(-15deg);opacity:0;} 100%{transform:translateX(0) rotate(0deg);opacity:1;} }
267+
@keyframes approve-horizontal { to {transform:translateX(320px) rotate(15deg);opacity:0;} }
268+
@keyframes reject-horizontal { to {transform:translateX(-320px) rotate(-15deg);opacity:0;} }
269+
@keyframes restore-approve-horizontal { from {transform:translateX(320px) rotate(15deg);opacity:0;} to {transform:translateX(0) rotate(0deg);opacity:1;} }
270+
@keyframes restore-reject-horizontal { from {transform:translateX(-320px) rotate(-15deg);opacity:0;} to {transform:translateX(0) rotate(0deg);opacity:1;} }
225271
226272
/* Vertical keyframes */
227-
@keyframes approve-vertical { 0%{opacity:1;} 100%{transform:translateY(-320px);opacity:0;} }
228-
@keyframes reject-vertical { 0%{opacity:1;} 100%{transform:translateY(320px);opacity:0;} }
229-
@keyframes restore-approve-vertical { 0%{transform:translateY(-320px);opacity:0;} 100%{transform:translateY(0);opacity:1;} }
230-
@keyframes restore-reject-vertical { 0%{transform:translateY(320px);opacity:0;} 100%{transform:translateY(0);opacity:1;} }
273+
@keyframes approve-vertical { to {transform:translateY(-320px);opacity:0;} }
274+
@keyframes reject-vertical { to {transform:translateY(320px);opacity:0;} }
275+
@keyframes restore-approve-vertical { from {transform:translateY(-320px);opacity:0;} to {transform:translateY(0);opacity:1;} }
276+
@keyframes restore-reject-vertical { from {transform:translateY(320px);opacity:0;} to {transform:translateY(0);opacity:1;} }
231277
</style>

src/FlashCards.vue

Lines changed: 16 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -209,54 +209,33 @@ defineExpose({
209209
No more cards!
210210
</slot>
211211
</div>
212-
<!-- Обычные карточки стека -->
212+
<!-- Unified card list - single v-for -->
213213
<div
214-
v-for="({ item, itemId }, domIndex) in stackList"
215-
:key="`stack-${itemId}`"
214+
v-for="({ item, itemId, stackIndex, isAnimating, animation }, domIndex) in stackList"
215+
:key="`card-${itemId}`"
216216
:data-item-id="itemId"
217217
class="flashcards__card-wrapper"
218+
:class="{ 'flashcards__card-wrapper--animating': isAnimating }"
218219
:style="[
219-
{ zIndex: stackList.length - domIndex },
220-
getCardStyle(domIndex + cardsInTransition.filter(c =>
221-
c.animation?.isRestoring,
222-
).length),
220+
{
221+
zIndex: isAnimating
222+
? stackList.length * 2 + domIndex
223+
: stackList.length - domIndex,
224+
},
225+
getCardStyle(stackIndex),
223226
]"
224227
>
225228
<FlashCard
226229
v-bind="props"
227230
class="flashcards__card"
228-
:class="{ 'flashcards__card--active': itemId === currentItemId }"
229-
:disable-drag="isDragDisabled"
231+
:class="{
232+
'flashcards__card--active': itemId === currentItemId && !isAnimating,
233+
'flashcards__card--animating': isAnimating,
234+
}"
235+
:animation="isAnimating ? animation : undefined"
236+
:disable-drag="isDragDisabled || isAnimating"
230237
@complete="(action, pos) => handleCardSwipe(itemId, action, pos)"
231238
@mounted="containerHeight = Math.max($event, 0)"
232-
@dragstart="emit('dragstart', item)"
233-
@dragmove="(type, delta) => emit('dragmove', item, type, delta)"
234-
@dragend="emit('dragend', item)"
235-
>
236-
<template #default>
237-
<slot :item="item" :active-item-key="currentItemId" />
238-
</template>
239-
<template #reject="{ delta }">
240-
<slot name="reject" :item="item" :delta="delta" />
241-
</template>
242-
<template #approve="{ delta }">
243-
<slot name="approve" :item="item" :delta="delta" />
244-
</template>
245-
</FlashCard>
246-
</div>
247-
248-
<!-- Animating cards -->
249-
<div
250-
v-for="({ item, itemId, animation }, domIndex) in cardsInTransition"
251-
:key="`anim-${itemId}`"
252-
:data-item-id="itemId"
253-
class="flashcards__card-wrapper flashcards__card-wrapper--animating"
254-
:style="[{ zIndex: stackList.length * 2 + domIndex }, getCardStyle(cardsInTransition.length - domIndex - 1)]"
255-
>
256-
<FlashCard
257-
v-bind="props"
258-
class="flashcards__card flashcards__card--animating"
259-
:animation="animation"
260239
@animationend="() => removeAnimatingCard(itemId)"
261240
@dragstart="emit('dragstart', item)"
262241
@dragmove="(type, delta) => emit('dragmove', item, type, delta)"

src/utils/useGhostAnimation.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import type { Ref } from 'vue'
2+
import { ref } from 'vue'
3+
4+
export interface GhostAnimationOptions {
5+
/**
6+
* Element to clone
7+
*/
8+
element: HTMLElement
9+
10+
/**
11+
* Animation type (approve/reject)
12+
*/
13+
animationType: string
14+
15+
/**
16+
* Whether this is a restore animation
17+
*/
18+
isRestoring: boolean
19+
20+
/**
21+
* Swipe direction for animation classes
22+
*/
23+
swipeDirection?: string
24+
25+
/**
26+
* Initial position from drag (optional)
27+
*/
28+
initialPosition?: {
29+
x: number
30+
y: number
31+
}
32+
33+
/**
34+
* Function to get transform style based on position
35+
*/
36+
getTransformStyle?: (position: { x: number, y: number, delta: number, type: any }) => string | null
37+
38+
/**
39+
* Callback when animation ends
40+
*/
41+
onAnimationEnd: () => void
42+
}
43+
44+
/**
45+
* Creates and manages a ghost clone for animations
46+
*/
47+
export function createGhostElement(options: GhostAnimationOptions): HTMLElement {
48+
const {
49+
element,
50+
animationType,
51+
isRestoring,
52+
swipeDirection,
53+
initialPosition,
54+
getTransformStyle,
55+
onAnimationEnd,
56+
} = options
57+
58+
// Clone the card element
59+
const clone = element.cloneNode(true) as HTMLElement
60+
clone.classList.add('flash-card--ghost')
61+
clone.style.pointerEvents = 'none'
62+
63+
// Get current position from the card
64+
const rect = element.getBoundingClientRect()
65+
66+
// Position ghost exactly over original
67+
clone.style.position = 'fixed'
68+
clone.style.top = `${rect.top}px`
69+
clone.style.left = `${rect.left}px`
70+
clone.style.width = `${rect.width}px`
71+
clone.style.height = `${rect.height}px`
72+
clone.style.zIndex = '9999'
73+
clone.style.transform = 'none'
74+
75+
// Get animation wrapper
76+
const animationWrapper = clone.querySelector('.flash-card__animation-wrapper') as HTMLElement
77+
const transformWrapper = animationWrapper?.querySelector('.flash-card__transform') as HTMLElement
78+
79+
// Apply initial position if provided (for drag animations)
80+
if (initialPosition && !isRestoring && animationWrapper && transformWrapper) {
81+
clone.style.top = `${rect.top - initialPosition.y}px`
82+
clone.style.left = `${rect.left - initialPosition.x}px`
83+
animationWrapper.style.transform = `translate3d(${initialPosition.x}px, ${initialPosition.y}px, 0)`
84+
}
85+
86+
// Always apply current transform style to ghost if available
87+
if (animationWrapper && transformWrapper) {
88+
// First, copy the current computed transform from the original element
89+
const originalTransformWrapper = element.querySelector('.flash-card__transform') as HTMLElement
90+
if (originalTransformWrapper) {
91+
const computedStyle = window.getComputedStyle(originalTransformWrapper)
92+
const currentTransform = computedStyle.transform
93+
if (currentTransform && currentTransform !== 'none') {
94+
transformWrapper.style.transform = currentTransform
95+
}
96+
}
97+
98+
// Then apply additional transform style if provided
99+
if (getTransformStyle) {
100+
const rotationStyle = getTransformStyle({ x: 0, y: 0, delta: 0, type: null, ...initialPosition })
101+
if (rotationStyle) {
102+
// Extract transform value from rotationStyle and apply it
103+
const transformMatch = rotationStyle.match(/transform:\s*([^;]+)/)
104+
if (transformMatch) {
105+
transformWrapper.style.transform = transformMatch[1]
106+
}
107+
}
108+
}
109+
}
110+
111+
// Insert into document
112+
document.body.appendChild(clone)
113+
114+
// Force reflow
115+
void clone.offsetHeight
116+
117+
// Apply animation classes
118+
if (animationWrapper) {
119+
animationWrapper.classList.add(`flash-card-animation--${animationType}`)
120+
if (isRestoring) {
121+
animationWrapper.classList.add(`flash-card-animation--${animationType}-restore`)
122+
}
123+
if (swipeDirection) {
124+
animationWrapper.classList.add(`flash-card-animation--${swipeDirection}`)
125+
}
126+
}
127+
128+
// Listen for animation end
129+
const handleAnimationEnd = (e: AnimationEvent) => {
130+
if (e.target !== animationWrapper)
131+
return
132+
133+
clone.removeEventListener('animationend', handleAnimationEnd)
134+
onAnimationEnd()
135+
}
136+
137+
clone.addEventListener('animationend', handleAnimationEnd)
138+
139+
return clone
140+
}
141+
142+
/**
143+
* Composable for managing ghost animations
144+
*/
145+
export function useGhostAnimation(elementRef: Ref<HTMLElement | null | undefined>) {
146+
const ghostElement = ref<HTMLElement | null>(null)
147+
const isAnimating = ref(false)
148+
149+
function cleanup() {
150+
ghostElement.value?.remove()
151+
ghostElement.value = null
152+
isAnimating.value = false
153+
}
154+
155+
function createGhost(options: Omit<GhostAnimationOptions, 'element' | 'onAnimationEnd'>, onAnimationEnd: () => void) {
156+
if (!elementRef.value)
157+
return
158+
159+
// Cleanup existing ghost
160+
if (ghostElement.value) {
161+
cleanup()
162+
}
163+
164+
ghostElement.value = createGhostElement({
165+
...options,
166+
element: elementRef.value,
167+
onAnimationEnd: () => {
168+
cleanup()
169+
onAnimationEnd()
170+
},
171+
})
172+
173+
isAnimating.value = true
174+
}
175+
176+
return {
177+
ghostElement,
178+
isAnimating,
179+
createGhost,
180+
cleanup,
181+
}
182+
}

0 commit comments

Comments
 (0)