diff --git a/web/src/lib/actions/swipe-feedback.ts b/web/src/lib/actions/swipe-feedback.ts new file mode 100644 index 0000000000..921d3212cf --- /dev/null +++ b/web/src/lib/actions/swipe-feedback.ts @@ -0,0 +1,473 @@ +import { preloadManager } from '$lib/managers/PreloadManager.svelte'; + +export interface SwipeFeedbackOptions { + disabled?: boolean; + onSwipeEnd?: (offsetX: number) => void; + onSwipeMove?: (offsetX: number) => void; + /** Preview shown on left when swiping right */ + leftPreviewUrl?: string | null; + /** Preview shown on right when swiping left */ + rightPreviewUrl?: string | null; + /** Called after animation completes when threshold exceeded */ + onSwipeCommit?: (direction: 'left' | 'right') => void; + /** Minimum pixels to activate swipe (default: 45) */ + swipeThreshold?: number; + /** When changed, preview containers are reset */ + currentAssetUrl?: string | null; + /** Element to apply swipe transforms to */ + target?: HTMLElement | null; +} + +interface SwipeAnimations { + currentImageAnimation: Animation; + previewAnimation: Animation | null; +} + +/** + * Horizontal swipe gesture with visual feedback and optional preview images. + * Requires swipeSubject to be provided in options. + */ +export const swipeFeedback = (node: HTMLElement, options?: SwipeFeedbackOptions) => { + const ANIMATION_DURATION_MS = 300; + const ENABLE_SCALE_ANIMATION = false; + + let target = options?.target; + + let isDragging = false; + let startX = 0; + let currentOffsetX = 0; + + let lastAssetUrl = options?.currentAssetUrl; + let dragStartTime: Date | null = null; + let swipeAmount = 0; + + let leftAnimations: SwipeAnimations | null = null; + let rightAnimations: SwipeAnimations | null = null; + + node.style.cursor = 'grab'; + + const getContainersForDirection = (direction: 'left' | 'right') => ({ + animations: direction === 'left' ? leftAnimations : rightAnimations, + previewContainer: direction === 'left' ? rightPreviewContainer : leftPreviewContainer, + oppositeAnimations: direction === 'left' ? rightAnimations : leftAnimations, + oppositeContainer: direction === 'left' ? leftPreviewContainer : rightPreviewContainer, + }); + + const isValidPointerEvent = (event: PointerEvent) => + event.isPrimary && (event.pointerType !== 'mouse' || event.button === 0); + + const cancelAnimations = (animations: SwipeAnimations | null) => { + animations?.currentImageAnimation?.cancel(); + animations?.previewAnimation?.cancel(); + }; + + const resetContainerStyle = (container: HTMLElement | null) => { + if (!container) { + return; + } + container.style.transform = ''; + container.style.transition = ''; + container.style.zIndex = '-1'; + container.style.display = 'none'; + }; + + const resetPreviewContainers = () => { + cancelAnimations(leftAnimations); + cancelAnimations(rightAnimations); + leftAnimations = null; + rightAnimations = null; + + resetContainerStyle(leftPreviewContainer); + resetContainerStyle(rightPreviewContainer); + if (target) { + target.style.transform = ''; + target.style.transition = ''; + target.style.opacity = ''; + } + currentOffsetX = 0; + }; + + 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 }, + ]; + }; + + const createSwipeAnimations = (direction: 'left' | 'right'): SwipeAnimations | null => { + if (!target) { + return null; + } + + target.style.transformOrigin = 'center'; + + const currentImageAnimation = target.animate(createAnimationKeyframes(direction, false), { + duration: ANIMATION_DURATION_MS, + easing: 'linear', + fill: 'both', + }); + + const { previewContainer } = getContainersForDirection(direction); + 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(); + + return { currentImageAnimation, previewAnimation }; + }; + + let leftPreviewContainer: HTMLDivElement | null = null; + let rightPreviewContainer: HTMLDivElement | null = null; + let leftPreviewImg: HTMLImageElement | null = null; + let rightPreviewImg: HTMLImageElement | null = null; + + const createPreviewContainer = (): { container: HTMLDivElement; img: HTMLImageElement } => { + const container = document.createElement('div'); + container.style.position = 'absolute'; + container.style.pointerEvents = 'none'; + container.style.display = 'none'; + container.style.zIndex = '-1'; + + const img = document.createElement('img'); + img.style.width = '100%'; + img.style.height = '100%'; + img.style.objectFit = 'contain'; + img.draggable = false; + img.alt = ''; + + container.append(img); + node.parentElement?.append(container); + + return { container, img }; + }; + + const ensurePreviewCreated = ( + url: string | null | undefined, + container: HTMLDivElement | null, + img: HTMLImageElement | null, + ) => { + if (!url || container) { + return { container, img }; + } + const preview = createPreviewContainer(); + preview.img.src = url; + return preview; + }; + + const ensurePreviewsCreated = () => { + if (options?.leftPreviewUrl && !leftPreviewContainer) { + const preview = ensurePreviewCreated(options.leftPreviewUrl, leftPreviewContainer, leftPreviewImg); + leftPreviewContainer = preview.container; + leftPreviewImg = preview.img; + } + + if (options?.rightPreviewUrl && !rightPreviewContainer) { + const preview = ensurePreviewCreated(options.rightPreviewUrl, rightPreviewContainer, rightPreviewImg); + rightPreviewContainer = preview.container; + rightPreviewImg = preview.img; + } + }; + + const positionContainer = (container: HTMLElement | null, width: number, height: number) => { + if (!container) { + return; + } + Object.assign(container.style, { + width: `${width}px`, + height: `${height}px`, + left: '0px', + top: '0px', + }); + }; + + const updatePreviewPositions = () => { + const parentElement = node.parentElement; + if (!parentElement) { + return; + } + + const { width, height } = globalThis.getComputedStyle(parentElement); + const viewportWidth = Number.parseFloat(width); + const viewportHeight = Number.parseFloat(height); + + positionContainer(leftPreviewContainer, viewportWidth, viewportHeight); + positionContainer(rightPreviewContainer, viewportWidth, viewportHeight); + }; + + const calculateAnimationProgress = (dragPixels: number) => Math.min(dragPixels / window.innerWidth, 1); + + const pointerDown = (event: PointerEvent) => { + if (options?.disabled || !target || !isValidPointerEvent(event)) { + return; + } + + isDragging = true; + startX = event.clientX; + swipeAmount = 0; + + node.style.cursor = 'grabbing'; + node.setPointerCapture(event.pointerId); + dragStartTime = new Date(); + + document.addEventListener('pointerup', pointerUp); + document.addEventListener('pointercancel', pointerUp); + + ensurePreviewsCreated(); + updatePreviewPositions(); + + event.preventDefault(); + }; + + const setContainerVisibility = (container: HTMLElement | null, visible: boolean) => { + if (!container) { + return; + } + container.style.display = visible ? 'block' : 'none'; + if (visible) { + container.style.zIndex = '1'; + } + }; + + const updateAnimationTime = (animations: SwipeAnimations, time: number) => { + animations.currentImageAnimation.currentTime = time; + if (animations.previewAnimation) { + animations.previewAnimation.currentTime = time; + } + }; + + const pointerMove = (event: PointerEvent) => { + if (options?.disabled || !target || !isDragging) { + return; + } + + currentOffsetX = event.clientX - startX; + swipeAmount = currentOffsetX; + + const direction = currentOffsetX < 0 ? 'left' : 'right'; + const animationTime = calculateAnimationProgress(Math.abs(currentOffsetX)) * ANIMATION_DURATION_MS; + const { animations, previewContainer, oppositeAnimations, oppositeContainer } = + getContainersForDirection(direction); + + if (!animations) { + if (direction === 'left') { + leftAnimations = createSwipeAnimations('left'); + } else { + rightAnimations = createSwipeAnimations('right'); + } + setContainerVisibility(previewContainer, true); + } + + const currentAnimations = direction === 'left' ? leftAnimations : rightAnimations; + if (currentAnimations) { + setContainerVisibility(previewContainer, true); + updateAnimationTime(currentAnimations, animationTime); + if (oppositeAnimations) { + cancelAnimations(oppositeAnimations); + if (direction === 'left') { + rightAnimations = null; + } else { + leftAnimations = null; + } + setContainerVisibility(oppositeContainer, false); + } + } + options?.onSwipeMove?.(currentOffsetX); + event.preventDefault(); + }; + + const setPlaybackRate = (animations: SwipeAnimations, rate: number) => { + animations.currentImageAnimation.playbackRate = rate; + if (animations.previewAnimation) { + animations.previewAnimation.playbackRate = rate; + } + }; + + const playAnimations = (animations: SwipeAnimations) => { + animations.currentImageAnimation.play(); + animations.previewAnimation?.play(); + }; + + const resetPosition = () => { + if (!target) { + return; + } + + const direction = currentOffsetX < 0 ? 'left' : 'right'; + const { animations, previewContainer } = getContainersForDirection(direction); + + if (!animations) { + currentOffsetX = 0; + return; + } + + setPlaybackRate(animations, -1); + playAnimations(animations); + + const handleFinish = () => { + animations.currentImageAnimation.removeEventListener('finish', handleFinish); + cancelAnimations(animations); + resetContainerStyle(previewContainer); + }; + animations.currentImageAnimation.addEventListener('finish', handleFinish, { once: true }); + + currentOffsetX = 0; + }; + + const commitSwipe = (direction: 'left' | 'right') => { + if (!target) { + return; + } + target.style.opacity = '0'; + const { previewContainer } = getContainersForDirection(direction); + if (previewContainer) { + previewContainer.style.zIndex = '1'; + } + options?.onSwipeCommit?.(direction); + }; + + const completeTransition = (direction: 'left' | 'right') => { + if (!target) { + return; + } + + const { animations } = getContainersForDirection(direction); + if (!animations) { + return; + } + + const currentTime = Number(animations.currentImageAnimation.currentTime) || 0; + + if (currentTime >= ANIMATION_DURATION_MS - 5) { + commitSwipe(direction); + return; + } + + setPlaybackRate(animations, 1); + playAnimations(animations); + + const handleFinish = () => { + if (target) { + commitSwipe(direction); + } + }; + animations.currentImageAnimation.addEventListener('finish', handleFinish, { once: true }); + }; + + const pointerUp = (event: PointerEvent) => { + if (!isDragging || !isValidPointerEvent(event) || !target) { + return; + } + + isDragging = false; + node.style.cursor = 'grab'; + if (node.hasPointerCapture(event.pointerId)) { + node.releasePointerCapture(event.pointerId); + } + document.removeEventListener('pointerup', pointerUp); + document.removeEventListener('pointercancel', pointerUp); + + const threshold = options?.swipeThreshold ?? 45; + const velocity = Math.abs(swipeAmount) / (Date.now() - (dragStartTime?.getTime() ?? 0)); + const progress = calculateAnimationProgress(Math.abs(currentOffsetX)); + + if (Math.abs(swipeAmount) < threshold || (velocity < 0.11 && progress <= 0.25)) { + resetPosition(); + return; + } + + options?.onSwipeEnd?.(currentOffsetX); + completeTransition(currentOffsetX > 0 ? 'right' : 'left'); + }; + + node.addEventListener('pointerdown', pointerDown); + node.addEventListener('pointermove', pointerMove); + node.addEventListener('pointerup', pointerUp); + node.addEventListener('pointercancel', pointerUp); + + return { + update(newOptions?: SwipeFeedbackOptions) { + if (newOptions?.target && newOptions.target !== target) { + resetPreviewContainers(); + target = newOptions.target; + } + + if (newOptions?.currentAssetUrl && newOptions.currentAssetUrl !== lastAssetUrl) { + resetPreviewContainers(); + lastAssetUrl = newOptions.currentAssetUrl; + } + const lastLeftPreviewUrl = options?.leftPreviewUrl; + const lastRightPreviewUrl = options?.rightPreviewUrl; + if ( + lastLeftPreviewUrl && + lastLeftPreviewUrl != newOptions?.leftPreviewUrl && + lastLeftPreviewUrl !== newOptions?.currentAssetUrl + ) { + preloadManager.cancelPreloadUrl(lastLeftPreviewUrl); + } + if ( + lastRightPreviewUrl && + lastRightPreviewUrl != newOptions?.rightPreviewUrl && + lastRightPreviewUrl !== newOptions?.currentAssetUrl + ) { + preloadManager.cancelPreloadUrl(lastRightPreviewUrl); + } + options = newOptions; + + if (options?.leftPreviewUrl) { + if (leftPreviewImg) { + leftPreviewImg.src = options.leftPreviewUrl; + } else { + const preview = ensurePreviewCreated(options.leftPreviewUrl, leftPreviewContainer, leftPreviewImg); + leftPreviewContainer = preview.container; + leftPreviewImg = preview.img; + } + } + + if (options?.rightPreviewUrl) { + if (rightPreviewImg) { + rightPreviewImg.src = options.rightPreviewUrl; + } else { + const preview = ensurePreviewCreated(options.rightPreviewUrl, rightPreviewContainer, rightPreviewImg); + rightPreviewContainer = preview.container; + rightPreviewImg = preview.img; + } + } + }, + destroy() { + cancelAnimations(leftAnimations); + cancelAnimations(rightAnimations); + + node.removeEventListener('pointerdown', pointerDown); + node.removeEventListener('pointermove', pointerMove); + node.removeEventListener('pointerup', pointerUp); + node.removeEventListener('pointercancel', pointerUp); + document.removeEventListener('pointerup', pointerUp); + document.removeEventListener('pointercancel', pointerUp); + leftPreviewContainer?.remove(); + rightPreviewContainer?.remove(); + node.style.cursor = ''; + }, + }; +}; diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts index e67d3e1928..9fc13488e9 100644 --- a/web/src/lib/actions/zoom-image.ts +++ b/web/src/lib/actions/zoom-image.ts @@ -3,46 +3,63 @@ import { useZoomImageWheel } from '@zoom-image/svelte'; import { get } from 'svelte/store'; export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => { - const { createZoomImage, zoomImageState, setZoomImageState } = useZoomImageWheel(); - - createZoomImage(node, { - maxZoom: 10, - }); - - const state = get(photoZoomState); - if (state) { - setZoomImageState(state); - } - - // Store original event handlers so we can prevent them when disabled - const wheelHandler = (event: WheelEvent) => { + let unsubscribes: (() => void)[] = []; + const createZoomAction = (newOptions?: { disabled?: boolean }) => { + options = newOptions; if (options?.disabled) { - event.stopImmediatePropagation(); - } - }; - - const pointerDownHandler = (event: PointerEvent) => { - if (options?.disabled) { - event.stopImmediatePropagation(); - } - }; - - // Add handlers at capture phase with higher priority - node.addEventListener('wheel', wheelHandler, { capture: true }); - node.addEventListener('pointerdown', pointerDownHandler, { capture: true }); - - const unsubscribes = [photoZoomState.subscribe(setZoomImageState), zoomImageState.subscribe(photoZoomState.set)]; - - return { - update(newOptions?: { disabled?: boolean }) { - options = newOptions; - }, - destroy() { - node.removeEventListener('wheel', wheelHandler, { capture: true }); - node.removeEventListener('pointerdown', pointerDownHandler, { capture: true }); for (const unsubscribe of unsubscribes) { unsubscribe(); } + unsubscribes = []; + } else { + const { createZoomImage, zoomImageState, setZoomImageState } = useZoomImageWheel(); + + createZoomImage(node, { + maxZoom: 10, + }); + + const state = get(photoZoomState); + if (state) { + setZoomImageState(state); + } + + node.style.overflow = 'visible'; + + // Store original event handlers so we can prevent them when disabled + const wheelHandler = (event: WheelEvent) => { + if (options?.disabled) { + event.stopImmediatePropagation(); + } + }; + + const pointerDownHandler = (event: PointerEvent) => { + if (options?.disabled) { + event.stopImmediatePropagation(); + } + }; + + // Add handlers at capture phase with higher priority + node.addEventListener('wheel', wheelHandler, { capture: true }); + node.addEventListener('pointerdown', pointerDownHandler, { capture: true }); + + unsubscribes = [ + photoZoomState.subscribe(setZoomImageState), + zoomImageState.subscribe(photoZoomState.set), + () => node.removeEventListener('wheel', wheelHandler, { capture: true }), + () => node.removeEventListener('pointerdown', pointerDownHandler, { capture: true }), + ]; + } + }; + createZoomAction(); + return { + update(newOptions?: { disabled?: boolean }) { + createZoomAction(newOptions); + }, + destroy() { + for (const unsubscribe of unsubscribes) { + unsubscribe(); + } + unsubscribes = []; }, }; }; diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index c1e0cbf2b1..f0483cf49b 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -96,7 +96,6 @@ stopProgress: stopSlideshowProgress, slideshowNavigation, slideshowState, - slideshowTransition, } = slideshowStore; const stackThumbnailSize = 60; const stackSelectedThumbnailSize = 65; @@ -327,6 +326,7 @@ const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => { previewStackedAsset = isMouseOver ? asset : undefined; }; + const handlePreAction = (action: Action) => { preAction?.(action); }; @@ -506,13 +506,14 @@ cursor={{ ...cursor, current: previewStackedAsset! }} onPreviousAsset={() => navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} - haveFadeTransition={false} {sharedLink} /> {:else if viewerKind === 'StackVideoViewer'} navigateAsset('previous')} @@ -525,6 +526,9 @@ {:else if viewerKind === 'LiveVideoViewer'} navigateAsset('previous')} onNextAsset={() => navigateAsset('next')} {sharedLink} - haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition} onFree={() => eventManager.emit('AssetViewerFree')} /> {:else if viewerKind === 'VideoViewer'} import { shortcuts } from '$lib/actions/shortcut'; + import { swipeFeedback } from '$lib/actions/swipe-feedback'; import { zoomImageAction } from '$lib/actions/zoom-image'; import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte'; import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; - import { assetViewerFadeDuration } from '$lib/constants'; import { castManager } from '$lib/managers/cast-manager.svelte'; import { preloadManager } from '$lib/managers/PreloadManager.svelte'; import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; @@ -14,8 +14,9 @@ import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store'; import { photoZoomState } from '$lib/stores/zoom-image.store'; import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils'; - import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils'; + import { canCopyImageToClipboard, copyImageToClipboard, getDimensions } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; + import { scaleToFit } from '$lib/utils/layout-utils'; import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils'; import { getBoundingBox } from '$lib/utils/people-utils'; import { getAltText } from '$lib/utils/thumbnail-util'; @@ -23,16 +24,13 @@ import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk'; import { LoadingSpinner, toastManager } from '@immich/ui'; import { onDestroy, onMount, untrack } from 'svelte'; - import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures'; import { t } from 'svelte-i18n'; - import { fade } from 'svelte/transition'; import type { AssetCursor } from './asset-viewer.svelte'; interface Props { cursor: AssetCursor; - element?: HTMLDivElement | undefined; - haveFadeTransition?: boolean; - sharedLink?: SharedLinkResponseDto | undefined; + element?: HTMLDivElement; + sharedLink?: SharedLinkResponseDto; onPreviousAsset?: (() => void) | null; onFree?: (() => void) | null; onBusy?: (() => void) | null; @@ -46,8 +44,7 @@ let { cursor, element = $bindable(), - haveFadeTransition = true, - sharedLink = undefined, + sharedLink, onPreviousAsset = null, onNextAsset = null, onFree = null, @@ -67,6 +64,16 @@ let loader = $state(); + const box = $derived.by(() => { + const { width, height } = scaledDimensions; + return { + width: width + 'px', + height: height + 'px', + left: (containerWidth - width) / 2 + 'px', + top: (containerHeight - height) / 2 + 'px', + }; + }); + photoZoomState.set({ currentRotation: 0, currentZoom: 1, @@ -122,21 +129,12 @@ event.preventDefault(); handlePromiseError(copyImage()); }; - - const onSwipe = (event: SwipeCustomEvent) => { - if ($photoZoomState.currentZoom > 1) { - return; - } - - if (ocrManager.showOverlay) { - return; - } - - if (onNextAsset && event.detail.direction === 'left') { + const handleSwipeCommit = (direction: 'left' | 'right') => { + if (direction === 'left' && onNextAsset) { + // Swiped left, go to next asset onNextAsset(); - } - - if (onPreviousAsset && event.detail.direction === 'right') { + } else if (direction === 'right' && onPreviousAsset) { + // Swiped right, go to previous asset onPreviousAsset(); } }; @@ -167,12 +165,20 @@ onLoad?.(); onFree?.(); imageLoaded = true; + dimensions = { + width: loader?.naturalWidth ?? 1, + height: loader?.naturalHeight ?? 1, + }; originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original'; }; const onerror = () => { onError?.(); onFree?.(); + dimensions = { + width: loader?.naturalWidth ?? 1, + height: loader?.naturalHeight ?? 1, + }; imageError = imageLoaded = true; }; @@ -181,18 +187,36 @@ if (!imageLoaded && !imageError) { onFree?.(); } - preloadManager.cancelPreloadUrl(imageLoaderUrl); + if (imageLoaderUrl) { + preloadManager.cancelPreloadUrl(imageLoaderUrl); + } }; }); - let imageLoaderUrl = $derived( + const imageLoaderUrl = $derived( getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }), ); + const previousAssetUrl = $derived(getAssetUrl({ asset: cursor.previousAsset, sharedLink })); + const nextAssetUrl = $derived(getAssetUrl({ asset: cursor.nextAsset, sharedLink })); + + const exifDimensions = $derived( + asset.exifInfo?.exifImageHeight && asset.exifInfo.exifImageHeight + ? (getDimensions(asset.exifInfo) as { width: number; height: number }) + : null, + ); let containerWidth = $state(0); let containerHeight = $state(0); + const container = $derived({ + width: containerWidth, + height: containerHeight, + }); + let dimensions = $derived(exifDimensions ?? { width: 1, height: 1 }); + const scaledDimensions = $derived(scaleToFit(dimensions, container)); - let lastUrl: string | undefined | null; + let lastUrl: string | undefined | null | null; + let lastPreviousUrl: string | undefined | null; + let lastNextUrl: string | undefined | null; $effect(() => { if (!lastUrl) { @@ -200,13 +224,21 @@ } if (lastUrl && lastUrl !== imageLoaderUrl) { untrack(() => { - imageLoaded = false; + const isPreviewedImage = imageLoaderUrl === lastPreviousUrl || imageLoaderUrl === lastNextUrl; + + if (!isPreviewedImage) { + // It is a previewed image - prevent flicker - skip spinner but still let loader go through lifecycle + imageLoaded = false; + } + originalImageLoaded = false; imageError = false; onBusy?.(); }); } lastUrl = imageLoaderUrl; + lastPreviousUrl = previousAssetUrl; + lastNextUrl = nextAssetUrl; }); @@ -230,18 +262,21 @@ class="relative h-full w-full select-none" bind:clientWidth={containerWidth} bind:clientHeight={containerHeight} + use:swipeFeedback={{ + disabled: isOcrActive || $photoZoomState.currentZoom > 1, + onSwipeCommit: handleSwipeCommit, + leftPreviewUrl: previousAssetUrl, + rightPreviewUrl: nextAssetUrl, + currentAssetUrl: imageLoaderUrl, + target: $photoViewerImgElement, + }} > - {#if !imageLoaded} -
- -
- {:else if !imageError} -
+
+ {#if !imageLoaded} +
+ +
+ {:else if !imageError} {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} {/if} - {$getAltText(toTimelineAsset(asset))} - - {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox} -
- {/each} +
+ {$getAltText(toTimelineAsset(asset))} + + {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox} +
+ {/each} - {#each ocrBoxes as ocrBox (ocrBox.id)} - - {/each} -
+ {#each ocrBoxes as ocrBox (ocrBox.id)} + + {/each} +
- {#if isFaceEditMode.value} - + {#if isFaceEditMode.value} + + {/if} {/if} - {/if} +