Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions web/src/lib/actions/zoom-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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();
},
};
Expand Down
3 changes: 2 additions & 1 deletion web/src/lib/components/asset-viewer/photo-viewer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,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);
Expand Down
31 changes: 31 additions & 0 deletions web/src/lib/managers/asset-viewer-manager.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>('asset-viewer-state', false);

Expand All @@ -21,6 +22,7 @@ export type Events = {

export class AssetViewerManager extends BaseEventManager<Events> {
#zoomState = $state(createDefaultZoomState());
#animationFrameId: number | null = null;

imgRef = $state<HTMLImageElement | undefined>();
isShowActivityPanel = $state(false);
Expand All @@ -45,6 +47,7 @@ export class AssetViewerManager extends BaseEventManager<Events> {
}

set zoom(zoom: number) {
this.cancelZoomAnimation();
this.zoomState = { ...this.zoomState, currentZoom: zoom };
}

Expand All @@ -69,7 +72,35 @@ export class AssetViewerManager extends BaseEventManager<Events> {
this.#zoomState = state;
}

cancelZoomAnimation() {
if (this.#animationFrameId !== null) {
cancelAnimationFrame(this.#animationFrameId);
this.#animationFrameId = null;
}
}

animatedZoom(targetZoom: number, duration = 300) {
this.cancelZoomAnimation();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be more elegant to if (this.#animationFrameId) { return; }? I.e., let the animation finish instead of cancelling it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cancelling is intentional here — animatedZoom is called from onZoom() which toggles between zoom 1 and 2. If the user clicks the zoom button mid-animation, we want to immediately start animating toward the new target (potentially the opposite direction) rather than ignoring their input until the current animation finishes. An early return would make the UI feel unresponsive in that case.

Also, with an early return, calling animatedZoom(2) while animatedZoom(3) is running would silently drop the zoom-to-2 request, which isn't the latest user intent.


const startZoom = this.#zoomState.currentZoom;
const startTime = performance.now();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need this?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what the question is refering to: using performance.now() or using #isAnimatingFrame as a guard, or maybe we should be using tween instead of rAF?

I'll take a stab at all of those ;-)

  1. RAF in general requires you to handle 'dropped frames' - which keeps it fast. Basically, its time-based instead of frame-based. RAF gives you a timestamp, and you figure out how far along you should be (interpolated), and then return the state of the animation at time T.

  2. The zoom is controlled by @zoom-image/core's state object, not CSS, so we can't use CSS transitions. The rAF loop interpolates the zoom value with cubicOut easing. In this next update, I've simplified it from the original version — the animation is cancelled naturally when the user interacts (wheel/pointerdown) via the zoom-image action, so there's no extra bookkeeping flags needed in the manager.

  3. I tried using tween - but because of the need to do cancelation, it doesn't work well, would be more difficult to reason, and wouldn't end up saving any code.


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();
}

Expand Down
Loading