feat: swipe feedback

This commit is contained in:
midzelis
2026-01-12 23:43:41 +00:00
parent f6862aa94f
commit 3c07fe0116
8 changed files with 498 additions and 28 deletions

View File

@@ -156,6 +156,11 @@ export function imageLoader(
const handleElementCreated = (img: HTMLImageElement) => {
if (img) {
node.append(img);
// const a = document.createElement('p');
// a.classList.add('absolute', 'h-full', 'w-full', 'top-0');
// a.textContent = img.src;
// node.append(a);
currentCallbacks.onElementCreated?.(img);
}
};

View File

@@ -9,10 +9,25 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
initialState: state,
});
let isUpdatingFromInstance = false;
let isUpdatingFromStore = false;
const unsubscribes = [
photoZoomState.subscribe((state) => zoomInstance.setState(state)),
photoZoomState.subscribe((state) => {
if (isUpdatingFromInstance || options?.disabled) {
return;
}
isUpdatingFromStore = true;
zoomInstance.setState(state);
isUpdatingFromStore = false;
}),
zoomInstance.subscribe(({ state }) => {
if (isUpdatingFromStore || options?.disabled) {
return;
}
isUpdatingFromInstance = true;
photoZoomState.set(state);
isUpdatingFromInstance = false;
}),
];

View File

@@ -28,11 +28,13 @@
onImageReady?: () => void;
onError?: () => void;
imgElement?: HTMLImageElement;
imgContainerElement?: HTMLElement;
overlays?: Snippet;
}
let {
imgElement = $bindable<HTMLImageElement | undefined>(),
imgContainerElement = $bindable<HTMLElement | undefined>(),
asset,
sharedLink,
zoomDisabled = false,
@@ -125,6 +127,7 @@
style:top={renderDimensions.top}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
bind:this={imgContainerElement}
>
{#if asset.thumbhash}
<!-- Thumbhash / spinner layer -->

View File

@@ -201,12 +201,30 @@
let nextPreloader: AdaptiveImageLoader | undefined;
let previousPreloader: AdaptiveImageLoader | undefined;
let nextPreviewUrl = $state<string | undefined>();
let previousPreviewUrl = $state<string | undefined>();
const startPreloader = (asset: AssetResponseDto | undefined) => {
const setPreviewUrl = (direction: 'next' | 'previous', url: string | undefined) => {
if (direction === 'next') {
nextPreviewUrl = url;
} else {
previousPreviewUrl = url;
}
};
const startPreloader = (asset: AssetResponseDto | undefined, direction: 'next' | 'previous') => {
if (!asset) {
return;
}
const loader = new AdaptiveImageLoader(asset, undefined, undefined, loadImage);
const loader = new AdaptiveImageLoader(
asset,
undefined,
{
currentZoomFn: () => 1,
onQualityUpgrade: (url) => setPreviewUrl(direction, url),
},
loadImage,
);
loader.start();
return loader;
};
@@ -214,14 +232,17 @@
const destroyPreviousPreloader = () => {
previousPreloader?.destroy();
previousPreloader = undefined;
previousPreviewUrl = undefined;
};
const destroyNextPreloader = () => {
nextPreloader?.destroy();
nextPreloader = undefined;
nextPreviewUrl = undefined;
};
const cancelPreloadsBeforeNavigation = (direction: 'previous' | 'next') => {
setPreviewUrl(direction, undefined);
if (direction === 'next') {
destroyPreviousPreloader();
return;
@@ -236,22 +257,30 @@
const shouldDestroyPrevious = movedForward || !movedBackward;
const shouldDestroyNext = movedBackward || !movedForward;
if (shouldDestroyPrevious) {
destroyPreviousPreloader();
}
if (shouldDestroyNext) {
destroyNextPreloader();
}
if (movedForward) {
nextPreloader = startPreloader(newCursor.nextAsset);
// When moving forward: old next becomes current, shift preview URLs
const oldNextUrl = nextPreviewUrl;
destroyPreviousPreloader();
previousPreviewUrl = oldNextUrl;
destroyNextPreloader();
nextPreloader = startPreloader(newCursor.nextAsset, 'next');
} else if (movedBackward) {
previousPreloader = startPreloader(newCursor.previousAsset);
// When moving backward: old previous becomes current, shift preview URLs
const oldPreviousUrl = previousPreviewUrl;
destroyNextPreloader();
nextPreviewUrl = oldPreviousUrl;
destroyPreviousPreloader();
previousPreloader = startPreloader(newCursor.previousAsset, 'previous');
} else {
// Non-adjacent navigation (e.g., slideshow random)
previousPreloader = startPreloader(newCursor.previousAsset);
nextPreloader = startPreloader(newCursor.nextAsset);
// Non-adjacent navigation (e.g., slideshow random) - clear everything
if (shouldDestroyPrevious) {
destroyPreviousPreloader();
}
if (shouldDestroyNext) {
destroyNextPreloader();
}
previousPreloader = startPreloader(newCursor.previousAsset, 'previous');
nextPreloader = startPreloader(newCursor.nextAsset, 'next');
}
};
@@ -448,10 +477,10 @@
if (!lastCursor && cursor) {
// "first time" load, start preloads
if (cursor.nextAsset) {
nextPreloader = startPreloader(cursor.nextAsset);
nextPreloader = startPreloader(cursor.nextAsset, 'next');
}
if (cursor.previousAsset) {
previousPreloader = startPreloader(cursor.previousAsset);
previousPreloader = startPreloader(cursor.previousAsset, 'previous');
}
}
lastCursor = cursor;
@@ -565,7 +594,13 @@
<!-- Asset Viewer -->
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
{#if viewerKind === 'StackPhotoViewer'}
<PhotoViewer bind:zoomToggle bind:copyImage cursor={{ ...cursor, current: previewStackedAsset! }} {sharedLink} />
<PhotoViewer
bind:zoomToggle
bind:copyImage
cursor={{ ...cursor, current: previewStackedAsset! }}
{sharedLink}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'previous' : 'next')}
/>
{:else if viewerKind === 'StackVideoViewer'}
<VideoViewer
assetId={previewStackedAsset!.id}
@@ -595,7 +630,13 @@
{:else if viewerKind === 'CropArea'}
<CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'}
<PhotoViewer bind:zoomToggle bind:copyImage {cursor} {sharedLink} />
<PhotoViewer
bind:zoomToggle
bind:copyImage
{cursor}
{sharedLink}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
/>
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
assetId={asset.id}

View File

@@ -3,6 +3,7 @@
import AdaptiveImage from '$lib/components/asset-viewer/adaptive-image.svelte';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
import SwipeFeedback from '$lib/components/asset-viewer/swipe-feedback.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
@@ -17,7 +18,7 @@
import { getBoundingBox } from '$lib/utils/people-utils';
import { type SharedLinkResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { onDestroy } from 'svelte';
import { onDestroy, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import type { AssetCursor } from './asset-viewer.svelte';
@@ -26,6 +27,7 @@
element?: HTMLDivElement;
sharedLink?: SharedLinkResponseDto;
onReady?: () => void;
onSwipe?: (direction: 'left' | 'right') => void;
copyImage?: () => Promise<void>;
zoomToggle?: () => void;
}
@@ -35,6 +37,7 @@
element = $bindable(),
sharedLink,
onReady,
onSwipe,
copyImage = $bindable(),
zoomToggle = $bindable(),
}: Props = $props();
@@ -118,6 +121,15 @@
width: containerWidth,
height: containerHeight,
});
let imgContainerElement = $state<HTMLElement | undefined>();
let swipeFeedbackReset = $state<(() => void) | undefined>();
$effect(() => {
// Reset swipe feedback when asset changes
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset.id;
untrack(() => swipeFeedbackReset?.());
});
</script>
<svelte:document
@@ -129,11 +141,14 @@
]}
/>
<div
bind:this={element}
<SwipeFeedback
bind:element
class="relative h-full w-full select-none"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
disabled={isOcrActive || $photoZoomState.currentZoom > 1}
{onSwipe}
bind:reset={swipeFeedbackReset}
>
<AdaptiveImage
{asset}
@@ -146,6 +161,7 @@
onImageReady={() => onReady?.()}
onError={() => onReady?.()}
bind:imgElement={$photoViewerImgElement}
bind:imgContainerElement
>
{#snippet overlays()}
<!-- eslint-disable-next-line svelte/require-each-key -->
@@ -165,4 +181,32 @@
{#if isFaceEditMode.value}
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
</div>
{#snippet leftPreview()}
{#if cursor.previousAsset}
<AdaptiveImage
asset={cursor.previousAsset}
{sharedLink}
{container}
zoomDisabled={true}
imageClass="object-contain"
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
/>
{/if}
{/snippet}
{#snippet rightPreview()}
{#if cursor.nextAsset}
<AdaptiveImage
asset={cursor.nextAsset}
{sharedLink}
{container}
zoomDisabled={true}
imageClass="object-contain"
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
/>
{/if}
{/snippet}
</SwipeFeedback>

View File

@@ -0,0 +1,356 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
disabled?: boolean;
onSwipeEnd?: (offsetX: number) => void;
onSwipeMove?: (offsetX: number) => void;
onSwipe?: (direction: 'left' | 'right') => void;
swipeThreshold?: number;
class?: string;
element?: HTMLDivElement;
clientWidth?: number;
clientHeight?: number;
reset?: () => void;
children: Snippet;
leftPreview?: Snippet;
rightPreview?: Snippet;
}
let {
disabled = false,
onSwipeEnd,
onSwipeMove,
onSwipe,
swipeThreshold = 45,
class: className = '',
element = $bindable(),
clientWidth = $bindable(),
clientHeight = $bindable(),
reset = $bindable(),
children,
leftPreview,
rightPreview,
}: Props = $props();
interface SwipeAnimations {
currentImageAnimation: Animation;
previewAnimation: Animation | null;
abortController: AbortController;
}
const ANIMATION_DURATION_MS = 300;
// Tolerance to avoid edge cases where animation is nearly complete but not exactly at end
const ANIMATION_COMPLETION_TOLERANCE_MS = 5;
// Minimum velocity to trigger swipe (tuned for natural flick gesture)
const MIN_SWIPE_VELOCITY = 0.11; // pixels per millisecond
// Require 25% drag progress if velocity is too low (prevents accidental swipes)
const MIN_PROGRESS_THRESHOLD = 0.25;
const ENABLE_SCALE_ANIMATION = false;
let contentElement: HTMLElement | undefined = $state();
let leftPreviewContainer: HTMLDivElement | undefined = $state();
let rightPreviewContainer: HTMLDivElement | undefined = $state();
let isDragging = $state(false);
let startX = $state(0);
let currentOffsetX = $state(0);
let dragStartTime: number | null = $state(null);
let leftAnimations: SwipeAnimations | null = $state(null);
let rightAnimations: SwipeAnimations | null = $state(null);
let isSwipeInProgress = $state(false);
const cursorStyle = $derived(disabled ? '' : isSwipeInProgress ? 'wait' : isDragging ? 'grabbing' : 'grab');
const isValidPointerEvent = (event: PointerEvent) =>
event.isPrimary && (event.pointerType !== 'mouse' || event.button === 0);
const createSwipeAnimations = (direction: 'left' | 'right'): SwipeAnimations | null => {
if (!contentElement) {
return null;
}
const createAnimationKeyframes = (direction: 'left' | 'right', isPreview: boolean) => {
const scale = (s: number) => (ENABLE_SCALE_ANIMATION ? ` scale(${s})` : '');
const sign = direction === 'left' ? -1 : 1;
if (isPreview) {
return [
{ transform: `translateX(${sign * -100}vw)${scale(0)}`, opacity: '0', offset: 0 },
{ transform: `translateX(${sign * -80}vw)${scale(0.2)}`, opacity: '0', offset: 0.2 },
{ transform: `translateX(${sign * -50}vw)${scale(0.5)}`, opacity: '0.5', offset: 0.5 },
{ transform: `translateX(${sign * -20}vw)${scale(0.8)}`, opacity: '1', offset: 0.8 },
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 1 },
];
}
return [
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 0 },
{ transform: `translateX(${sign * 100}vw)${scale(0)}`, opacity: '0', offset: 1 },
];
};
contentElement.style.transformOrigin = 'center';
const currentImageAnimation = contentElement.animate(createAnimationKeyframes(direction, false), {
duration: ANIMATION_DURATION_MS,
easing: 'linear',
fill: 'both',
});
// Preview slides in from opposite side of swipe direction
const previewContainer = direction === 'left' ? rightPreviewContainer : leftPreviewContainer;
let previewAnimation: Animation | null = null;
if (previewContainer) {
previewContainer.style.transformOrigin = 'center';
previewAnimation = previewContainer.animate(createAnimationKeyframes(direction, true), {
duration: ANIMATION_DURATION_MS,
easing: 'linear',
fill: 'both',
});
}
currentImageAnimation.pause();
previewAnimation?.pause();
const abortController = new AbortController();
return { currentImageAnimation, previewAnimation, abortController };
};
const setAnimationTime = (animations: SwipeAnimations, time: number) => {
animations.currentImageAnimation.currentTime = time;
if (animations.previewAnimation) {
animations.previewAnimation.currentTime = time;
}
};
const playAnimation = (animations: SwipeAnimations, playbackRate: number) => {
animations.currentImageAnimation.playbackRate = playbackRate;
if (animations.previewAnimation) {
animations.previewAnimation.playbackRate = playbackRate;
}
animations.currentImageAnimation.play();
animations.previewAnimation?.play();
};
const cancelAnimations = (animations: SwipeAnimations | null) => {
if (!animations) {
return;
}
animations.abortController.abort();
animations.currentImageAnimation.cancel();
animations.previewAnimation?.cancel();
};
const handlePointerDown = (event: PointerEvent) => {
if (disabled || !contentElement || !isValidPointerEvent(event) || !element || isSwipeInProgress) {
return;
}
startDrag(event);
event.preventDefault();
};
const startDrag = (event: PointerEvent) => {
if (!element) {
return;
}
isDragging = true;
startX = event.clientX;
currentOffsetX = 0;
element.setPointerCapture(event.pointerId);
dragStartTime = Date.now();
};
const handlePointerMove = (event: PointerEvent) => {
if (disabled || !contentElement || !isDragging || isSwipeInProgress) {
return;
}
currentOffsetX = event.clientX - startX;
const direction = currentOffsetX < 0 ? 'left' : 'right';
const animationTime = Math.min(Math.abs(currentOffsetX) / window.innerWidth, 1) * ANIMATION_DURATION_MS;
if (direction === 'left') {
if (!leftAnimations) {
leftAnimations = createSwipeAnimations('left');
}
if (leftAnimations) {
setAnimationTime(leftAnimations, animationTime);
}
if (rightAnimations) {
cancelAnimations(rightAnimations);
rightAnimations = null;
}
} else {
if (!rightAnimations) {
rightAnimations = createSwipeAnimations('right');
}
if (rightAnimations) {
setAnimationTime(rightAnimations, animationTime);
}
if (leftAnimations) {
cancelAnimations(leftAnimations);
leftAnimations = null;
}
}
onSwipeMove?.(currentOffsetX);
event.preventDefault(); // Prevent scrolling during drag
};
const handlePointerUp = (event: PointerEvent) => {
if (!isDragging || !isValidPointerEvent(event) || !contentElement || !element) {
return;
}
isDragging = false;
if (element.hasPointerCapture(event.pointerId)) {
element.releasePointerCapture(event.pointerId);
}
const dragDuration = dragStartTime ? Date.now() - dragStartTime : 0;
const velocity = dragDuration > 0 ? Math.abs(currentOffsetX) / dragDuration : 0;
const progress = Math.min(Math.abs(currentOffsetX) / window.innerWidth, 1);
if (
Math.abs(currentOffsetX) < swipeThreshold ||
(velocity < MIN_SWIPE_VELOCITY && progress <= MIN_PROGRESS_THRESHOLD)
) {
resetPosition();
return;
}
isSwipeInProgress = true;
onSwipeEnd?.(currentOffsetX);
completeTransition(currentOffsetX > 0 ? 'right' : 'left');
};
const resetPosition = () => {
if (!contentElement) {
return;
}
const direction = currentOffsetX < 0 ? 'left' : 'right';
const animations = direction === 'left' ? leftAnimations : rightAnimations;
if (!animations) {
currentOffsetX = 0;
return;
}
playAnimation(animations, -1);
const handleFinish = () => {
cancelAnimations(animations);
if (direction === 'left') {
leftAnimations = null;
} else {
rightAnimations = null;
}
};
animations.currentImageAnimation.addEventListener('finish', handleFinish, {
signal: animations.abortController.signal,
});
currentOffsetX = 0;
};
const completeTransition = (direction: 'left' | 'right') => {
if (!contentElement) {
return;
}
const animations = direction === 'left' ? leftAnimations : rightAnimations;
if (!animations) {
return;
}
const currentTime = Number(animations.currentImageAnimation.currentTime) || 0;
if (currentTime >= ANIMATION_DURATION_MS - ANIMATION_COMPLETION_TOLERANCE_MS) {
onSwipe?.(direction);
return;
}
playAnimation(animations, 1);
const handleFinish = () => {
if (contentElement) {
onSwipe?.(direction);
}
};
animations.currentImageAnimation.addEventListener('finish', handleFinish, {
signal: animations.abortController.signal,
});
};
const resetPreviewContainers = () => {
cancelAnimations(leftAnimations);
cancelAnimations(rightAnimations);
leftAnimations = null;
rightAnimations = null;
if (contentElement) {
contentElement.style.transform = '';
contentElement.style.transition = '';
contentElement.style.opacity = '';
}
currentOffsetX = 0;
};
const resetSwipeFeedback = () => {
resetPreviewContainers();
isSwipeInProgress = false;
};
reset = resetSwipeFeedback;
</script>
<!-- Listen on window to catch pointer release outside element (due to setPointerCapture) -->
<svelte:window onpointerup={handlePointerUp} onpointercancel={handlePointerUp} />
<div
bind:this={element}
bind:clientWidth
bind:clientHeight
class={className}
style:cursor={cursorStyle}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
role="presentation"
>
{#if leftPreview}
<!-- Swiping right reveals left preview -->
<div
bind:this={leftPreviewContainer}
class="absolute inset-0"
style:pointer-events="none"
style:display={rightAnimations ? 'block' : 'none'}
>
{@render leftPreview()}
</div>
{/if}
{#if rightPreview}
<!-- Swiping left reveals right preview -->
<div
bind:this={rightPreviewContainer}
class="absolute inset-0"
style:pointer-events="none"
style:display={leftAnimations ? 'block' : 'none'}
>
{@render rightPreview()}
</div>
{/if}
<div bind:this={contentElement} class="h-full w-full" style:cursor={cursorStyle}>
{@render children()}
</div>
</div>

View File

@@ -7,14 +7,15 @@ class ImageManager {
if (!asset) {
return;
}
this.preloadImageUrl(getAssetUrlForKind(asset, kind));
}
const url = getAssetUrlForKind(asset, kind);
if (!url) {
preloadImageUrl(src: string | undefined) {
if (!src) {
return;
}
const img = new Image();
img.src = url;
img.src = src;
}
cancel(asset: AssetResponseDto | undefined, kind: ImageKind | 'all' = 'preview') {

View File

@@ -48,6 +48,7 @@ export class AdaptiveImageLoader {
private readonly currentZoomFn?: () => number;
private readonly onImageReady?: () => void;
private readonly onError?: () => void;
private readonly onQualityUpgrade?: (url: string, quality: ImageQuality) => void;
private readonly imageLoader?: LoadImageFunction;
private readonly destroyFunctions: (() => void)[] = [];
readonly thumbnailUrl: string;
@@ -61,6 +62,7 @@ export class AdaptiveImageLoader {
currentZoomFn: () => number;
onImageReady?: () => void;
onError?: () => void;
onQualityUpgrade?: (url: string, quality: ImageQuality) => void;
},
imageLoader?: LoadImageFunction,
) {
@@ -68,6 +70,7 @@ export class AdaptiveImageLoader {
this.currentZoomFn = callbacks?.currentZoomFn;
this.onImageReady = callbacks?.onImageReady;
this.onError = callbacks?.onError;
this.onQualityUpgrade = callbacks?.onQualityUpgrade;
this.imageLoader = imageLoader;
this.thumbnailUrl = getAssetUrlForKind(asset, 'thumbnail');
@@ -103,6 +106,7 @@ export class AdaptiveImageLoader {
this.state.quality = 'thumbnail';
this.state.thumbnailImage = ImageStatus.Success;
this.onImageReady?.();
this.onQualityUpgrade?.(this.thumbnailUrl, 'thumbnail');
this.triggerMainImage();
}
@@ -145,6 +149,7 @@ export class AdaptiveImageLoader {
this.state.quality = 'preview';
this.state.previewImage = ImageStatus.Success;
this.onImageReady?.();
this.onQualityUpgrade?.(this.previewUrl, 'preview');
}
onPreviewError() {