Compare commits

...

1 Commits

Author SHA1 Message Date
midzelis
6bc1033647 feat: foundation utilities for adaptive image loading 2026-02-25 18:59:43 +00:00
6 changed files with 180 additions and 4 deletions

View File

@@ -0,0 +1,63 @@
import { cancelImageUrl } from '$lib/utils/sw-messaging';
const destroyImageElement = (
imgElement: HTMLImageElement,
currentSrc: string | undefined,
handleLoad: () => void,
handleError: () => void,
) => {
imgElement.removeEventListener('load', handleLoad);
imgElement.removeEventListener('error', handleError);
cancelImageUrl(currentSrc);
imgElement.remove();
};
const createImageElement = (
src: string | undefined,
onLoad: () => void,
onError: () => void,
onStart?: () => void,
) => {
if (!src) {
return undefined;
}
const img = document.createElement('img');
img.addEventListener('load', onLoad);
img.addEventListener('error', onError);
onStart?.();
img.src = src;
return img;
};
export function loadImage(
src: string,
onLoad: () => void,
onError: () => void,
onStart?: () => void,
) {
let destroyed = false;
const wrapper = (fn: (() => void) | undefined) => () => {
if (destroyed) {
return;
}
fn?.();
};
const wrappedOnLoad = wrapper(onLoad);
const wrappedOnError = wrapper(onError);
const wrappedOnStart = wrapper(onStart);
const img = createImageElement(src, wrappedOnLoad, wrappedOnError, wrappedOnStart);
if (!img) {
return () => {};
}
return () => {
destroyed = true;
destroyImageElement(img, src, wrappedOnLoad, wrappedOnError);
};
}
export type LoadImageFunction = typeof loadImage;

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import type { ClassValue } from 'svelte/elements';
interface Props {
class?: ClassValue;
}
let { class: className = '' }: Props = $props();
</script>
<div class="absolute h-full w-full bg-gray-300 dark:bg-gray-700 {className}"></div>

View File

@@ -4,7 +4,38 @@ import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
type AllAssetMediaSize = AssetMediaSize | 'all';
type AssetLoadState = 'loading' | 'cancelled';
class ImageManager {
// track recently canceled assets, so know if an load "error" is due to
// cancelation
private assetStates = new Map<string, AssetLoadState>();
private readonly MAX_TRACKED_ASSETS = 10;
private trackAction(asset: AssetResponseDto, action: AssetLoadState) {
// Remove if exists to reset insertion order
this.assetStates.delete(asset.id);
this.assetStates.set(asset.id, action);
// Only keep recent assets (Map maintains insertion order)
if (this.assetStates.size > this.MAX_TRACKED_ASSETS) {
const firstKey = this.assetStates.keys().next().value!;
this.assetStates.delete(firstKey);
}
}
isCanceled(asset: AssetResponseDto) {
return 'cancelled' === this.assetStates.get(asset.id);
}
trackLoad(asset: AssetResponseDto) {
this.trackAction(asset, 'loading');
}
trackCancelled(asset: AssetResponseDto) {
this.trackAction(asset, 'cancelled');
}
preload(asset: AssetResponseDto | undefined, size: AssetMediaSize = AssetMediaSize.Preview) {
if (!asset) {
return;
@@ -15,6 +46,8 @@ class ImageManager {
return;
}
this.trackLoad(asset);
const img = new Image();
img.src = url;
}
@@ -24,6 +57,8 @@ class ImageManager {
return;
}
this.trackCancelled(asset);
const sizes = size === 'all' ? Object.values(AssetMediaSize) : [size];
for (const size of sizes) {
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });

View File

@@ -198,13 +198,10 @@ export const getAssetUrl = ({
sharedLink,
forceOriginal = false,
}: {
asset: AssetResponseDto | undefined;
asset: AssetResponseDto;
sharedLink?: SharedLinkResponseDto;
forceOriginal?: boolean;
}) => {
if (!asset) {
return;
}
const id = asset.id;
const cacheKey = asset.thumbhash;
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {

View File

@@ -0,0 +1,54 @@
import { scaleToFit } from '$lib/utils/layout-utils';
describe('scaleToFit', () => {
const tests = [
{
name: 'landscape image in square container',
dimensions: { width: 2000, height: 1000 },
container: { width: 500, height: 500 },
expected: { width: 500, height: 250 },
},
{
name: 'portrait image in square container',
dimensions: { width: 1000, height: 2000 },
container: { width: 500, height: 500 },
expected: { width: 250, height: 500 },
},
{
name: 'square image in square container',
dimensions: { width: 1000, height: 1000 },
container: { width: 500, height: 500 },
expected: { width: 500, height: 500 },
},
{
name: 'landscape image in landscape container',
dimensions: { width: 1600, height: 900 },
container: { width: 800, height: 600 },
expected: { width: 800, height: 450 },
},
{
name: 'portrait image in portrait container',
dimensions: { width: 900, height: 1600 },
container: { width: 600, height: 800 },
expected: { width: 450, height: 800 },
},
{
name: 'image matches container exactly',
dimensions: { width: 500, height: 300 },
container: { width: 500, height: 300 },
expected: { width: 500, height: 300 },
},
{
name: 'image smaller than container scales up',
dimensions: { width: 100, height: 50 },
container: { width: 400, height: 400 },
expected: { width: 400, height: 200 },
},
];
for (const { name, dimensions, container, expected } of tests) {
it(`should handle ${name}`, () => {
expect(scaleToFit(dimensions, container)).toEqual(expected);
});
}
});

View File

@@ -129,3 +129,19 @@ export type CommonPosition = {
width: number;
height: number;
};
// Scales dimensions to fit within a container (like object-fit: contain)
export const scaleToFit = (
dimensions: { width: number; height: number },
container: { width: number; height: number },
) => {
const scaleX = container.width / dimensions.width;
const scaleY = container.height / dimensions.height;
const scale = Math.min(scaleX, scaleY);
return {
width: dimensions.width * scale,
height: dimensions.height * scale,
};
};