pull/24357/head
midzelis 2025-12-12 00:54:36 +00:00
parent 423d98cf39
commit ea1ef3df13
2 changed files with 307 additions and 131 deletions

View File

@ -21,6 +21,11 @@ export interface SwipeFeedbackOptions {
imageElement?: HTMLImageElement | HTMLVideoElement | null;
}
interface SwipeAnimations {
currentImageAnimation: Animation;
previewAnimation: Animation | null;
}
/**
* Action that provides visual feedback for horizontal swipe gestures.
* Allows the user to drag an element left or right (horizontal only),
@ -28,6 +33,15 @@ export interface SwipeFeedbackOptions {
* Optionally shows preview images on the left/right during swipe.
*/
export const swipeFeedback = (node: HTMLElement, options?: SwipeFeedbackOptions) => {
// Animation configuration
const ANIMATION_DURATION_MS = 300;
// Drag sensitivity: pixels needed to reach 100% animation progress
// Higher value = less sensitive (need to drag further)
// Lower value = more sensitive (animation advances quickly)
const DRAG_DISTANCE_FOR_FULL_ANIMATION = 400;
// Enable/disable scaling effect during animation
const ENABLE_SCALE_ANIMATION = false;
// Find the image element to apply custom transforms
let imgElement: HTMLImageElement | HTMLVideoElement | null =
options?.imageElement ?? node.querySelector('img') ?? node.querySelector('video');
@ -40,10 +54,22 @@ export const swipeFeedback = (node: HTMLElement, options?: SwipeFeedbackOptions)
let dragStartTime: Date | null = null;
let swipeAmount = 0;
// Web Animations API - bidirectional animations
let leftAnimations: SwipeAnimations | null = null;
let rightAnimations: SwipeAnimations | null = null;
// Set initial cursor
node.style.cursor = 'grab';
const resetPreviewContainers = () => {
// Cancel any active animations
leftAnimations?.currentImageAnimation?.cancel();
leftAnimations?.previewAnimation?.cancel();
rightAnimations?.currentImageAnimation?.cancel();
rightAnimations?.previewAnimation?.cancel();
leftAnimations = null;
rightAnimations = null;
// Reset transforms and opacity
if (leftPreviewContainer) {
leftPreviewContainer.style.transform = '';
@ -66,6 +92,94 @@ export const swipeFeedback = (node: HTMLElement, options?: SwipeFeedbackOptions)
currentOffsetX = 0;
};
/**
* Creates Web Animations API animations for swipe transitions.
* Matches the keyframes from Month.svelte view transitions.
* @param direction - 'left' for next (swipe left), 'right' for previous (swipe right)
*/
const createSwipeAnimations = (direction: 'left' | 'right'): SwipeAnimations | null => {
if (!imgElement) {
return null;
}
const duration = ANIMATION_DURATION_MS;
const easing = 'cubic-bezier(0.33, 1, 0.68, 1)'; // Match Month.svelte:156
// Set transform origin to center for proper scaling
imgElement.style.transformOrigin = 'center';
// Helper to build transform string with optional scale
const scale = (s: number) => (ENABLE_SCALE_ANIMATION ? ` scale(${s})` : '');
// Animation for current image flying out
// Note: Delayed opacity fade (stays at 1 until 20%, fades 20-80%) for tighter crossfade
const currentImageAnimation = imgElement.animate(
direction === 'left'
? [
// flyOutLeft - Month.svelte:280-289
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 0 },
{ transform: `translateX(-20vw)${scale(0.8)}`, opacity: '1', offset: 0.2 },
{ transform: `translateX(-50vw)${scale(0.5)}`, opacity: '0.5', offset: 0.5 },
{ transform: `translateX(-80vw)${scale(0.2)}`, opacity: '0', offset: 0.8 },
{ transform: `translateX(-100vw)${scale(0)}`, opacity: '0', offset: 1 },
]
: [
// flyOutRight - Month.svelte:303-312
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 0 },
{ transform: `translateX(20vw)${scale(0.8)}`, opacity: '1', offset: 0.2 },
{ transform: `translateX(50vw)${scale(0.5)}`, opacity: '0.5', offset: 0.5 },
{ transform: `translateX(80vw)${scale(0.2)}`, opacity: '0', offset: 0.8 },
{ transform: `translateX(100vw)${scale(0)}`, opacity: '0', offset: 1 },
],
{
duration,
easing,
fill: 'both',
},
);
// Animation for preview image flying in
const previewContainer = direction === 'left' ? rightPreviewContainer : leftPreviewContainer;
let previewAnimation: Animation | null = null;
if (previewContainer) {
// Set transform origin to center for proper scaling
previewContainer.style.transformOrigin = 'center';
previewAnimation = previewContainer.animate(
direction === 'left'
? [
// flyInRight - Month.svelte:291-300
// Note: Early opacity fade (starts at 0, fades 20-80%, stays at 1 after 80%)
{ transform: `translateX(100vw)${scale(0)}`, opacity: '0', offset: 0 },
{ transform: `translateX(80vw)${scale(0.2)}`, opacity: '0', offset: 0.2 },
{ transform: `translateX(50vw)${scale(0.5)}`, opacity: '0.5', offset: 0.5 },
{ transform: `translateX(20vw)${scale(0.8)}`, opacity: '1', offset: 0.8 },
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 1 },
]
: [
// flyInLeft - Month.svelte:269-278
{ transform: `translateX(-100vw)${scale(0)}`, opacity: '0', offset: 0 },
{ transform: `translateX(-80vw)${scale(0.2)}`, opacity: '0', offset: 0.2 },
{ transform: `translateX(-50vw)${scale(0.5)}`, opacity: '0.5', offset: 0.5 },
{ transform: `translateX(-20vw)${scale(0.8)}`, opacity: '1', offset: 0.8 },
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 1 },
],
{
duration,
easing,
fill: 'both',
},
);
}
// Pause both animations immediately - we'll control them manually via currentTime
currentImageAnimation.pause();
previewAnimation?.pause();
return { currentImageAnimation, previewAnimation };
};
// Create preview image containers
let leftPreviewContainer: HTMLDivElement | null = null;
let rightPreviewContainer: HTMLDivElement | null = null;
@ -121,34 +235,23 @@ export const swipeFeedback = (node: HTMLElement, options?: SwipeFeedbackOptions)
const viewportWidth = Number.parseFloat(parentComputedStyle.width);
const viewportHeight = Number.parseFloat(parentComputedStyle.height);
// Preview containers should be full viewport size
// Preview containers should be full viewport size, positioned at 0,0
// The animations will handle the translateX positioning
if (leftPreviewContainer) {
leftPreviewContainer.style.width = `${viewportWidth}px`;
leftPreviewContainer.style.height = `${viewportHeight}px`;
leftPreviewContainer.style.left = `${-viewportWidth}px`;
leftPreviewContainer.style.left = `0px`;
leftPreviewContainer.style.top = `0px`;
}
if (rightPreviewContainer) {
rightPreviewContainer.style.width = `${viewportWidth}px`;
rightPreviewContainer.style.height = `${viewportHeight}px`;
rightPreviewContainer.style.left = `${viewportWidth}px`;
rightPreviewContainer.style.left = `0px`;
rightPreviewContainer.style.top = `0px`;
}
};
const updatePreviewVisibility = () => {
// Show left preview when swiping right (offsetX > 0)
if (leftPreviewContainer) {
leftPreviewContainer.style.display = currentOffsetX > 0 ? 'block' : 'none';
}
// Show right preview when swiping left (offsetX < 0)
if (rightPreviewContainer) {
rightPreviewContainer.style.display = currentOffsetX < 0 ? 'block' : 'none';
}
};
const pointerDown = (event: PointerEvent) => {
if (options?.disabled || !imgElement) {
return;
@ -168,8 +271,14 @@ export const swipeFeedback = (node: HTMLElement, options?: SwipeFeedbackOptions)
// Also add document listeners as fallback
document.addEventListener('pointerup', pointerUp);
document.addEventListener('pointercancel', pointerUp);
// Ensure preview containers are created and positioned
ensurePreviewsCreated();
updatePreviewPositions();
// Note: We don't create animations here - they're lazy-created in pointerMove
// when we know which direction the user is swiping
event.preventDefault();
}
};
@ -181,24 +290,84 @@ export const swipeFeedback = (node: HTMLElement, options?: SwipeFeedbackOptions)
if (isDragging) {
currentOffsetX = event.clientX - startX;
swipeAmount = currentOffsetX;
const xDelta = event.clientX - startX;
swipeAmount = xDelta;
// Determine which direction we're swiping
const isSwipingLeft = currentOffsetX < 0;
const isSwipingRight = currentOffsetX > 0;
// Apply transform directly to the image element
// Only translate horizontally (no vertical movement)
imgElement.style.transform = `translate(${currentOffsetX}px, 0px)`;
// Apply same transform to preview containers so they move with the swipe
if (leftPreviewContainer) {
leftPreviewContainer.style.transform = `translate(${currentOffsetX}px, 0px)`;
}
if (rightPreviewContainer) {
rightPreviewContainer.style.transform = `translate(${currentOffsetX}px, 0px)`;
// Lazy create animations when first needed
if (isSwipingLeft && !leftAnimations) {
leftAnimations = createSwipeAnimations('left');
// Ensure the right preview container is visible
if (rightPreviewContainer) {
rightPreviewContainer.style.display = 'block';
rightPreviewContainer.style.zIndex = '1';
}
} else if (isSwipingRight && !rightAnimations) {
rightAnimations = createSwipeAnimations('right');
// Ensure the left preview container is visible
if (leftPreviewContainer) {
leftPreviewContainer.style.display = 'block';
leftPreviewContainer.style.zIndex = '1';
}
}
// Update preview visibility
updatePreviewVisibility();
// Calculate progress based on absolute drag distance
// Using a threshold distance to map to full animation (0-1)
const progress = Math.min(Math.abs(currentOffsetX) / DRAG_DISTANCE_FOR_FULL_ANIMATION, 1);
// Map progress to animation time
const animationTime = progress * ANIMATION_DURATION_MS;
console.log(
`Animation progress: ${(progress * 100).toFixed(1)}% | Time: ${animationTime.toFixed(1)}ms | Offset: ${currentOffsetX.toFixed(1)}px`,
);
if (isSwipingLeft && leftAnimations) {
// Ensure the right preview container is visible
if (rightPreviewContainer) {
rightPreviewContainer.style.display = 'block';
rightPreviewContainer.style.zIndex = '1';
}
// Scrub left animations forward
leftAnimations.currentImageAnimation.currentTime = animationTime;
if (leftAnimations.previewAnimation) {
leftAnimations.previewAnimation.currentTime = animationTime;
}
// Cancel and recreate right animations to prevent conflicts on imgElement
if (rightAnimations) {
rightAnimations.currentImageAnimation.cancel();
if (rightAnimations.previewAnimation) {
rightAnimations.previewAnimation.cancel();
}
rightAnimations = null;
if (leftPreviewContainer) {
leftPreviewContainer.style.display = 'none';
}
}
} else if (isSwipingRight && rightAnimations) {
// Ensure the left preview container is visible
if (leftPreviewContainer) {
leftPreviewContainer.style.display = 'block';
leftPreviewContainer.style.zIndex = '1';
}
// Scrub right animations forward
rightAnimations.currentImageAnimation.currentTime = animationTime;
if (rightAnimations.previewAnimation) {
rightAnimations.previewAnimation.currentTime = animationTime;
}
// Cancel and recreate left animations to prevent conflicts on imgElement
if (leftAnimations) {
leftAnimations.currentImageAnimation.cancel();
if (leftAnimations.previewAnimation) {
leftAnimations.previewAnimation.cancel();
}
leftAnimations = null;
if (rightPreviewContainer) {
rightPreviewContainer.style.display = 'none';
}
}
}
// Notify about swipe movement
options?.onSwipeMove?.(currentOffsetX);
event.preventDefault();
@ -210,40 +379,38 @@ export const swipeFeedback = (node: HTMLElement, options?: SwipeFeedbackOptions)
return;
}
// Add smooth transition
const transitionStyle = 'transform 0.3s ease-out';
imgElement.style.transition = transitionStyle;
if (leftPreviewContainer) {
leftPreviewContainer.style.transition = transitionStyle;
}
if (rightPreviewContainer) {
rightPreviewContainer.style.transition = transitionStyle;
}
// Determine which animations are active
const activeAnimations = currentOffsetX < 0 ? leftAnimations : rightAnimations;
const activePreviewContainer = currentOffsetX < 0 ? rightPreviewContainer : leftPreviewContainer;
// Reset transforms
imgElement.style.transform = 'translate(0px, 0px)';
if (leftPreviewContainer) {
leftPreviewContainer.style.transform = 'translate(0px, 0px)';
}
if (rightPreviewContainer) {
rightPreviewContainer.style.transform = 'translate(0px, 0px)';
}
if (activeAnimations) {
// Reverse the animation back to 0
activeAnimations.currentImageAnimation.playbackRate = -1;
if (activeAnimations.previewAnimation) {
activeAnimations.previewAnimation.playbackRate = -1;
}
// Remove transition after animation completes
setTimeout(() => {
if (imgElement) {
imgElement.style.transition = '';
}
if (leftPreviewContainer) {
leftPreviewContainer.style.transition = '';
}
if (rightPreviewContainer) {
rightPreviewContainer.style.transition = '';
}
}, 300);
// Play from current position back to start
activeAnimations.currentImageAnimation.play();
activeAnimations.previewAnimation?.play();
// Listen for finish event to clean up
const handleFinish = () => {
activeAnimations.currentImageAnimation.removeEventListener('finish', handleFinish);
// Reset to original state
activeAnimations.currentImageAnimation.cancel();
activeAnimations.previewAnimation?.cancel();
// Hide the preview container after animation completes
if (activePreviewContainer) {
activePreviewContainer.style.display = 'none';
activePreviewContainer.style.zIndex = '-1';
}
};
activeAnimations.currentImageAnimation.addEventListener('finish', handleFinish, { once: true });
}
currentOffsetX = 0;
updatePreviewVisibility();
};
const completeTransition = (direction: 'left' | 'right') => {
@ -255,75 +422,72 @@ export const swipeFeedback = (node: HTMLElement, options?: SwipeFeedbackOptions)
const activePreviewImg = direction === 'right' ? leftPreviewImg : rightPreviewImg;
const naturalWidth = activePreviewImg?.naturalWidth ?? 1;
const naturalHeight = activePreviewImg?.naturalHeight ?? 1;
console.log('nat', naturalWidth, naturalHeight);
// Call pre-commit callback BEFORE starting the animation
// This allows the parent component to update state with the preview dimensions
options?.onPreCommit?.(direction, naturalWidth, naturalHeight);
// Add smooth transition
const transitionStyle = 'transform 0.3s ease-out';
imgElement.style.transition = transitionStyle;
if (leftPreviewContainer) {
leftPreviewContainer.style.transition = transitionStyle;
}
if (rightPreviewContainer) {
rightPreviewContainer.style.transition = transitionStyle;
}
// Get the active animations
const activeAnimations = direction === 'left' ? leftAnimations : rightAnimations;
// Calculate the final offset to center the preview
const parentElement = node.parentElement;
if (!parentElement) {
return;
}
const viewportWidth = Number.parseFloat(globalThis.getComputedStyle(parentElement).width);
if (activeAnimations) {
// Get current time before modifying animation
const currentTime = Number(activeAnimations.currentImageAnimation.currentTime) || 0;
console.log(`Committing transition from ${currentTime}ms / ${ANIMATION_DURATION_MS}ms`);
// Slide everything to complete the transition
// If swiping right (direction='right'), slide everything right by viewport width
// If swiping left (direction='left'), slide everything left by viewport width
const finalOffset = direction === 'right' ? viewportWidth : -viewportWidth;
// If animation is already at or near the end, skip to finish immediately
if (currentTime >= ANIMATION_DURATION_MS - 5) {
console.log('Animation already complete, finishing immediately');
// Listen for transition end
const handleTransitionEnd = () => {
if (!imgElement) {
// Keep the preview visible by hiding the main image but showing the preview
imgElement.style.opacity = '0';
// Show the preview that's now in the center
const activePreview = direction === 'right' ? leftPreviewContainer : rightPreviewContainer;
if (activePreview) {
activePreview.style.zIndex = '1'; // Bring to front
}
// Trigger navigation (dimensions were already passed in onPreCommit)
options?.onSwipeCommit?.(direction);
return;
}
imgElement.removeEventListener('transitionend', handleTransitionEnd);
// Keep the preview visible by hiding the main image but showing the preview
// The preview is now centered, and we want it to stay visible while the new component loads
imgElement.style.opacity = '0';
// Show the preview that's now in the center
const activePreview = direction === 'right' ? leftPreviewContainer : rightPreviewContainer;
if (activePreview) {
activePreview.style.zIndex = '1'; // Bring to front
// Ensure playback rate is forward (in case it was reversed)
activeAnimations.currentImageAnimation.playbackRate = 1;
if (activeAnimations.previewAnimation) {
activeAnimations.previewAnimation.playbackRate = 1;
}
// Remove transitions
imgElement.style.transition = '';
if (leftPreviewContainer) {
leftPreviewContainer.style.transition = '';
}
if (rightPreviewContainer) {
rightPreviewContainer.style.transition = '';
}
// Play the animation to completion from current position
activeAnimations.currentImageAnimation.play();
activeAnimations.previewAnimation?.play();
// Trigger navigation (dimensions were already passed in onPreCommit)
options?.onSwipeCommit?.(direction);
};
// Listen for animation finish
const handleFinish = () => {
if (!imgElement) {
return;
}
imgElement.addEventListener('transitionend', handleTransitionEnd, { once: true });
activeAnimations.currentImageAnimation.removeEventListener('finish', handleFinish);
// Apply the final transform to trigger animation
imgElement.style.transform = `translate(${finalOffset}px, 0px)`;
if (leftPreviewContainer) {
leftPreviewContainer.style.transform = `translate(${finalOffset}px, 0px)`;
}
if (rightPreviewContainer) {
rightPreviewContainer.style.transform = `translate(${finalOffset}px, 0px)`;
// Keep the preview visible by hiding the main image but showing the preview
// The preview is now centered, and we want it to stay visible while the new component loads
imgElement.style.opacity = '0';
// Show the preview that's now in the center
const activePreview = direction === 'right' ? leftPreviewContainer : rightPreviewContainer;
if (activePreview) {
activePreview.style.zIndex = '1'; // Bring to front
}
// Trigger navigation (dimensions were already passed in onPreCommit)
options?.onSwipeCommit?.(direction);
};
activeAnimations.currentImageAnimation.addEventListener('finish', handleFinish, { once: true });
}
};
@ -346,16 +510,22 @@ export const swipeFeedback = (node: HTMLElement, options?: SwipeFeedbackOptions)
const threshold = options?.swipeThreshold ?? 45;
const timeTaken = Date.now() - (dragStartTime?.getTime() ?? 0);
const velocity = Math.abs(swipeAmount) / timeTaken;
console.log('velocity', velocity, swipeAmount);
if (Math.abs(swipeAmount) < threshold || velocity < 0.11) {
// Calculate animation progress (same calculation as in pointerMove)
const progress = Math.min(Math.abs(currentOffsetX) / DRAG_DISTANCE_FOR_FULL_ANIMATION, 1);
// Commit if EITHER:
// 1. High velocity (fast swipe) OR
// 2. Animation progress is over 25%
const hasEnoughVelocity = velocity >= 0.11;
const hasEnoughProgress = progress > 0.25;
if (Math.abs(swipeAmount) < threshold || (!hasEnoughVelocity && !hasEnoughProgress)) {
resetPosition();
return;
}
// Check if swipe exceeded threshold
const commitDirection = currentOffsetX > 0 ? 'right' : 'left';
// Call onSwipeEnd callback
@ -416,6 +586,12 @@ export const swipeFeedback = (node: HTMLElement, options?: SwipeFeedbackOptions)
}
},
destroy() {
// Cancel all animations
leftAnimations?.currentImageAnimation?.cancel();
leftAnimations?.previewAnimation?.cancel();
rightAnimations?.currentImageAnimation?.cancel();
rightAnimations?.previewAnimation?.cancel();
node.removeEventListener('pointerdown', pointerDown);
node.removeEventListener('pointermove', pointerMove);
node.removeEventListener('pointerup', pointerUp);

View File

@ -209,28 +209,28 @@
}
:global(::view-transition-old(next)) {
animation: 500ms flyOutLeft forwards;
animation: 250ms flyOutLeft forwards;
transform-origin: center;
height: 100%;
object-fit: contain;
}
:global(::view-transition-new(next)) {
animation: 500ms flyInRight forwards;
animation: 250ms flyInRight forwards;
transform-origin: center;
height: 100%;
object-fit: contain;
}
:global(::view-transition-old(previous)) {
animation: 500ms flyOutRight forwards;
animation: 250ms flyOutRight forwards;
transform-origin: center;
height: 100%;
object-fit: contain;
}
:global(::view-transition-new(previous)) {
animation: 500ms flyInLeft forwards;
animation: 250ms flyInLeft forwards;
transform-origin: center;
height: 100%;
object-fit: contain;
@ -250,7 +250,7 @@
:global(::view-transition-old(previous)),
:global(::view-transition-old(next)) {
animation: 500ms fadeOut forwards;
animation: 250ms fadeOut forwards;
transform-origin: center;
height: 100%;
width: 100%;
@ -259,7 +259,7 @@
:global(::view-transition-new(previous)),
:global(::view-transition-new(next)) {
animation: 500ms fadeIn forwards;
animation: 250ms fadeIn forwards;
transform-origin: center;
height: 100%;
width: 100%;
@ -268,33 +268,33 @@
}
@keyframes -global-flyInLeft {
from {
transform: translateX(-100vw) scale(0);
transform: translateX(-100vw);
opacity: 0;
}
to {
transform: translateX(0) scale(1);
transform: translateX(0);
opacity: 1;
}
}
@keyframes -global-flyOutLeft {
from {
transform: translateX(0) scale(1);
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(-100vw) scale(0);
transform: translateX(-100vw);
opacity: 0;
}
}
@keyframes -global-flyInRight {
from {
transform: translateX(100vw) scale(0);
transform: translateX(100vw);
opacity: 0;
}
to {
transform: translateX(0) scale(1);
transform: translateX(0);
opacity: 1;
}
}
@ -302,11 +302,11 @@
/* Fly out to right */
@keyframes -global-flyOutRight {
from {
transform: translateX(0) scale(1);
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100vw) scale(0);
transform: translateX(100vw);
opacity: 0;
}
}