diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts index 6288daa380..602ed9bd63 100644 --- a/web/src/lib/actions/zoom-image.ts +++ b/web/src/lib/actions/zoom-image.ts @@ -9,14 +9,15 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)), ]; - const stopIfDisabled = (event: Event) => { + const onInteractionStart = (event: Event) => { if (options?.disabled) { event.stopImmediatePropagation(); } + assetViewerManager.cancelZoomAnimation(); }; - node.addEventListener('wheel', stopIfDisabled, { capture: true }); - node.addEventListener('pointerdown', stopIfDisabled, { capture: true }); + node.addEventListener('wheel', onInteractionStart, { capture: true }); + node.addEventListener('pointerdown', onInteractionStart, { capture: true }); node.style.overflow = 'visible'; return { @@ -27,8 +28,8 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea for (const unsubscribe of unsubscribes) { unsubscribe(); } - node.removeEventListener('wheel', stopIfDisabled, { capture: true }); - node.removeEventListener('pointerdown', stopIfDisabled, { capture: true }); + node.removeEventListener('wheel', onInteractionStart, { capture: true }); + node.removeEventListener('pointerdown', onInteractionStart, { capture: true }); zoomInstance.cleanup(); }, }; diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index fd87450d58..957c72507d 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -103,7 +103,8 @@ }; const onZoom = () => { - assetViewerManager.zoom = assetViewerManager.zoom > 1 ? 1 : 2; + const targetZoom = assetViewerManager.zoom > 1 ? 1 : 2; + assetViewerManager.animatedZoom(targetZoom); }; const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow); diff --git a/web/src/lib/managers/asset-viewer-manager.svelte.ts b/web/src/lib/managers/asset-viewer-manager.svelte.ts index 36047d4690..0facbcdf47 100644 --- a/web/src/lib/managers/asset-viewer-manager.svelte.ts +++ b/web/src/lib/managers/asset-viewer-manager.svelte.ts @@ -2,6 +2,7 @@ import { canCopyImageToClipboard } from '$lib/utils/asset-utils'; import { BaseEventManager } from '$lib/utils/base-event-manager.svelte'; import { PersistedLocalStorage } from '$lib/utils/persisted'; import type { ZoomImageWheelState } from '@zoom-image/core'; +import { cubicOut } from 'svelte/easing'; const isShowDetailPanel = new PersistedLocalStorage('asset-viewer-state', false); @@ -21,6 +22,7 @@ export type Events = { export class AssetViewerManager extends BaseEventManager { #zoomState = $state(createDefaultZoomState()); + #animationFrameId: number | null = null; imgRef = $state(); isShowActivityPanel = $state(false); @@ -45,6 +47,7 @@ export class AssetViewerManager extends BaseEventManager { } set zoom(zoom: number) { + this.cancelZoomAnimation(); this.zoomState = { ...this.zoomState, currentZoom: zoom }; } @@ -69,7 +72,35 @@ export class AssetViewerManager extends BaseEventManager { this.#zoomState = state; } + cancelZoomAnimation() { + if (this.#animationFrameId !== null) { + cancelAnimationFrame(this.#animationFrameId); + this.#animationFrameId = null; + } + } + + animatedZoom(targetZoom: number, duration = 300) { + this.cancelZoomAnimation(); + + const startZoom = this.#zoomState.currentZoom; + const startTime = performance.now(); + + const frame = (currentTime: number) => { + const elapsed = currentTime - startTime; + const linearProgress = Math.min(elapsed / duration, 1); + const easedProgress = cubicOut(linearProgress); + const interpolatedZoom = startZoom + (targetZoom - startZoom) * easedProgress; + + this.zoomState = { ...this.#zoomState, currentZoom: interpolatedZoom }; + + this.#animationFrameId = linearProgress < 1 ? requestAnimationFrame(frame) : null; + }; + + this.#animationFrameId = requestAnimationFrame(frame); + } + resetZoomState() { + this.cancelZoomAnimation(); this.zoomState = createDefaultZoomState(); }