Skip to content

feat: rework photo-viewer/asset-viewer - introduce adaptive-image.svelte#25234

Closed
midzelis wants to merge 1 commit intomainfrom
refactor/adaptive_image
Closed

feat: rework photo-viewer/asset-viewer - introduce adaptive-image.svelte#25234
midzelis wants to merge 1 commit intomainfrom
refactor/adaptive_image

Conversation

@midzelis
Copy link
Collaborator

@midzelis midzelis commented Jan 13, 2026

Large rework photo-viewer/asset-viewer

Instead of spinner, use a new adaptive-image component. This component will display the thumbhash as the base layer - quickly followed up by the (highly likely) pre-cached low-res thumbnail, and then kick off the preview-size thumbnail. Mouse-wheel zoom is supported using all formats: thumbhash, preview. Zoom will automatically 'upgrade' the preview thumbnail to be 'fullsize' (or original) on zoom, like before, but now there will be no flicker when the full quality one loads.

All next/previous thumbnails are eagerly pre-loaded, and all pending images are canceled if navigation occurs before they finish loading. Preloads are lower priority, and will back off if a main-image is being loaded.

@github-actions
Copy link
Contributor

github-actions bot commented Jan 13, 2026

Preview environment has been removed.

@alextran1502
Copy link
Member

When I stop at an asset, wait a few seconds and navigate to the next. I'd expect the full preview image is loaded right away, but instead I am still seeing the thumbhash flash in

@midzelis midzelis force-pushed the refactor/adaptive_image branch 4 times, most recently from 994bdef to 8a0f839 Compare January 15, 2026 05:06
Base automatically changed from push-qtxrpwpzzmvv to main January 15, 2026 11:55
@midzelis midzelis force-pushed the refactor/adaptive_image branch from 5d4c3ef to 584dc1e Compare January 15, 2026 15:16
@midzelis midzelis changed the title refactor: rework photo-viewer/asset-viewer - introduce adaptive-image.svelte, increase performance esp. on low BW conn refactor: rework photo-viewer/asset-viewer - introduce adaptive-image.svelte Jan 15, 2026
@midzelis midzelis force-pushed the refactor/adaptive_image branch from 2204308 to 0b05772 Compare January 15, 2026 21:49
@midzelis midzelis added preview and removed preview labels Jan 15, 2026
@midzelis midzelis force-pushed the refactor/adaptive_image branch 8 times, most recently from 895202e to f6862aa Compare January 20, 2026 15:57
@midzelis midzelis changed the title refactor: rework photo-viewer/asset-viewer - introduce adaptive-image.svelte feat: rework photo-viewer/asset-viewer - introduce adaptive-image.svelte Jan 20, 2026
@midzelis midzelis force-pushed the refactor/adaptive_image branch from 6c672b8 to 009def8 Compare January 24, 2026 15:42
@midzelis midzelis force-pushed the refactor/adaptive_image branch 2 times, most recently from 0e6eaf2 to 7139757 Compare January 26, 2026 00:24
@midzelis midzelis force-pushed the refactor/adaptive_image branch from 7139757 to 70953a5 Compare January 27, 2026 00:26
@midzelis midzelis force-pushed the refactor/adaptive_image branch from 70953a5 to 49791dd Compare January 28, 2026 15:18
@midzelis midzelis force-pushed the refactor/adaptive_image branch 2 times, most recently from f58dcb2 to a705be8 Compare February 4, 2026 04:22
Comment on lines +49 to +69
if (params.alt !== undefined) {
img.alt = params.alt;
}
if (params.draggable !== undefined) {
img.draggable = params.draggable;
}
if (params.imgClass) {
img.className = classValueToString(params.imgClass);
}
if (params.role) {
img.role = params.role;
}
if (params.style !== undefined) {
img.setAttribute('style', params.style);
}
if (params.title !== undefined && params.title !== null) {
img.title = params.title;
}
if (params.loading !== undefined) {
img.loading = params.loading;
}
Copy link
Member

Choose a reason for hiding this comment

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

Is this just an Object.assign(img, params)?

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 reworked image-loader -

const createImageElement = (
  src: string | undefined,
  properties: ImageLoaderProperties,
  onLoad: () => void,
  onError: () => void,
  onStart?: () => void,
) => {
  if (!src) {
    return undefined;
  }
  const img = document.createElement('img');

  if (properties.alt !== undefined) {
    img.alt = properties.alt;
  }
  if (properties.draggable !== undefined) {
    img.draggable = properties.draggable;
  }
  if (properties.loading !== undefined) {
    img.loading = properties.loading;
  }

  img.addEventListener('load', onLoad);
  img.addEventListener('error', onError);

  onStart?.();
  img.src = src;

  return img;
};

there are only 3 props now. Also - I can't really use object.assign, since that will assign 'undefined' to properties like img.alt which assigned the STRING 'undefined' to alt. I think this is cleanest for now.

Copy link
Member

Choose a reason for hiding this comment

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

To be clear, what I meant is Object.assign(img, properties)

Base automatically changed from push-lmxsupnmxspl to main February 12, 2026 16:25
@midzelis midzelis force-pushed the refactor/adaptive_image branch 4 times, most recently from 67a5d22 to d4e7b1b Compare February 15, 2026 20:48
let previousAssetId: string | undefined;
let previousSharedLinkId: string | undefined;

const adaptiveImageLoader = $derived.by(() => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

You should not assign state inside a $derived please change this to an $effect

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 looked into converting this to $effect and it doesn't work cleanly here. The core issue is timing: $derived.by evaluates synchronously before the template renders, so adaptiveImageLoader is always available. $effect runs after the render cycle, which means the loader would be undefined for the first frame (and on every asset change).

This matters because adaptiveImageLoader in the template. Converting to $effect would require ?. null guards at ~20 places. So, the code becomes less readable (IMHO)

The $derived.by + untrack() pattern here is basically memoization: "if asset/sharedLink haven't changed, return the cached loader; otherwise create a new one." The other "state"" is really just the previous generation of the same loader. The mutations inside untrack() are specifically to maintain that memoization without creating circular reactivity.

Copy link
Member

Choose a reason for hiding this comment

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

If you want it to run before the DOM renders use $effect.pre?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

That may work at runtime, but typescript then need to use !. instead of ?. in about 12 places, though realistically, it can be condensed down to probably 4. I'll try it.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Wouldn't a pattern like this work?

const createAdaptiveImageLoader = () =>
  new AdaptiveImageLoader(asset, sharedLink, {
    currentZoomFn: () => assetViewerManager.zoom,
    onImageReady,
    onError,
    onUrlChange,
  });

const loaderKey = $derived(`${asset.id}-${asset.thumbhash}-${sharedLink?.id}`);
let adaptiveImageLoader = $state(createAdaptiveImageLoader());

$effect.pre(() => {
  void loaderKey;

  untrack(() => {
    assetViewerManager.resetZoomState();
    adaptiveImageLoader = createAdaptiveImageLoader();
  });

  return () => {
    adaptiveImageLoader.destroy();
  };
});

@midzelis midzelis force-pushed the refactor/adaptive_image branch 6 times, most recently from 2bdcf0d to 15d3613 Compare February 17, 2026 16:38
Copy link
Member

@danieldietzler danieldietzler left a comment

Choose a reason for hiding this comment

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

Ended up being busy with other stuff and have to run now, so only some superficial comments

onStart?: () => void,
) => {
if (!src) {
return undefined;
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
return undefined;
return;

Comment on lines +33 to +41
if (properties.alt !== undefined) {
img.alt = properties.alt;
}
if (properties.draggable !== undefined) {
img.draggable = properties.draggable;
}
if (properties.loading !== undefined) {
img.loading = properties.loading;
}
Copy link
Member

Choose a reason for hiding this comment

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

Do you even need these guards? If, for instance, properties.alt is undefined, img.alt will be undefined too since you don't set it anywhere. So, might as well always set img.alt = properties.alt

const listHeight = Math.min(MAX_LIST_HEIGHT, containerHeight - gap * 2 - chromeHeight);
const selectorHeight = listHeight + chromeHeight;

const clampTop = (top: number) => Math.max(gap, Math.min(top, containerHeight - selectorHeight - gap));
Copy link
Member

Choose a reason for hiding this comment

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

Very minor nit; lodash already provides a clamp helper function https://lodash.com/docs/4.17.23#clamp. That might clean up things a bit here

Comment on lines +182 to +189
const overlapX = Math.max(
0,
Math.min(position.left + selectorWidth, faceBox.left + faceBox.width) - Math.max(position.left, faceBox.left),
);
const overlapY = Math.max(
0,
Math.min(position.top + selectorHeight, faceBox.top + faceBox.height) - Math.max(position.top, faceBox.top),
);
Copy link
Member

Choose a reason for hiding this comment

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

This is kind of hard to follow without taking notes 😅 Could you extract some of those intermediate calculations out into their own variables with descriptive names?

Comment on lines +49 to +69
if (params.alt !== undefined) {
img.alt = params.alt;
}
if (params.draggable !== undefined) {
img.draggable = params.draggable;
}
if (params.imgClass) {
img.className = classValueToString(params.imgClass);
}
if (params.role) {
img.role = params.role;
}
if (params.style !== undefined) {
img.setAttribute('style', params.style);
}
if (params.title !== undefined && params.title !== null) {
img.title = params.title;
}
if (params.loading !== undefined) {
img.loading = params.loading;
}
Copy link
Member

Choose a reason for hiding this comment

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

To be clear, what I meant is Object.assign(img, properties)

@jrasm91
Copy link
Member

jrasm91 commented Feb 17, 2026

This PR has a lot of issues, but the biggest issue is that it is way too big. Please submit pull requests that are of a reasonable size and scope.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants