mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
feat: adaptive progressive image loading for photo viewer
This commit is contained in:
@@ -1,10 +1,7 @@
|
|||||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||||
import { Page, expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { utils } from 'src/utils';
|
import { utils } from 'src/utils';
|
||||||
|
|
||||||
function imageLocator(page: Page) {
|
|
||||||
return page.getByAltText('Image taken').locator('visible=true');
|
|
||||||
}
|
|
||||||
test.describe('Photo Viewer', () => {
|
test.describe('Photo Viewer', () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
let asset: AssetMediaResponseDto;
|
let asset: AssetMediaResponseDto;
|
||||||
@@ -26,31 +23,44 @@ test.describe('Photo Viewer', () => {
|
|||||||
|
|
||||||
test('loads original photo when zoomed', async ({ page }) => {
|
test('loads original photo when zoomed', async ({ page }) => {
|
||||||
await page.goto(`/photos/${asset.id}`);
|
await page.goto(`/photos/${asset.id}`);
|
||||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
|
||||||
const box = await imageLocator(page).boundingBox();
|
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||||
|
const original = page.getByTestId('original').filter({ visible: true });
|
||||||
|
|
||||||
|
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||||
|
const box = await thumbnail.boundingBox();
|
||||||
expect(box).toBeTruthy();
|
expect(box).toBeTruthy();
|
||||||
const { x, y, width, height } = box!;
|
const { x, y, width, height } = box!;
|
||||||
await page.mouse.move(x + width / 2, y + height / 2);
|
await page.mouse.move(x + width / 2, y + height / 2);
|
||||||
await page.mouse.wheel(0, -1);
|
await page.mouse.wheel(0, -1);
|
||||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
|
await expect(original).toBeInViewport();
|
||||||
|
await expect(original).toHaveAttribute('src', /original/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
|
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
|
||||||
await page.goto(`/photos/${rawAsset.id}`);
|
await page.goto(`/photos/${rawAsset.id}`);
|
||||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
|
||||||
const box = await imageLocator(page).boundingBox();
|
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||||
|
const original = page.getByTestId('original').filter({ visible: true });
|
||||||
|
|
||||||
|
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||||
|
const box = await thumbnail.boundingBox();
|
||||||
expect(box).toBeTruthy();
|
expect(box).toBeTruthy();
|
||||||
const { x, y, width, height } = box!;
|
const { x, y, width, height } = box!;
|
||||||
await page.mouse.move(x + width / 2, y + height / 2);
|
await page.mouse.move(x + width / 2, y + height / 2);
|
||||||
await page.mouse.wheel(0, -1);
|
await page.mouse.wheel(0, -1);
|
||||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
|
await expect(original).toHaveAttribute('src', /fullsize/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reloads photo when checksum changes', async ({ page }) => {
|
test('reloads photo when checksum changes', async ({ page }) => {
|
||||||
await page.goto(`/photos/${asset.id}`);
|
await page.goto(`/photos/${asset.id}`);
|
||||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
|
||||||
const initialSrc = await imageLocator(page).getAttribute('src');
|
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||||
|
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||||
|
|
||||||
|
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||||
|
const initialSrc = await thumbnail.getAttribute('src');
|
||||||
await utils.replaceAsset(admin.accessToken, asset.id);
|
await utils.replaceAsset(admin.accessToken, asset.id);
|
||||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
|
await expect(preview).not.toHaveAttribute('src', initialSrc!);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -99,13 +99,13 @@ export const setupTimelineMockApiRoutes = async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
|
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
|
||||||
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
|
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail|fullsize)/;
|
||||||
const match = request.url().match(pattern);
|
const match = request.url().match(pattern);
|
||||||
if (!match?.groups) {
|
if (!match?.groups) {
|
||||||
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
|
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (match.groups.size === 'preview') {
|
if (match.groups.size === 'preview' || match.groups.size === 'fullsize') {
|
||||||
if (!route.request().serviceWorker()) {
|
if (!route.request().serviceWorker()) {
|
||||||
return route.continue();
|
return route.continue();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ test.describe('broken-asset responsiveness', () => {
|
|||||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||||
await page.waitForSelector('#immich-asset-viewer');
|
await page.waitForSelector('#immich-asset-viewer');
|
||||||
|
|
||||||
const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]');
|
const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]');
|
||||||
await expect(viewerBrokenAsset).toBeVisible();
|
await expect(viewerBrokenAsset).toBeVisible();
|
||||||
|
|
||||||
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
|
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
generateTimelineData,
|
generateTimelineData,
|
||||||
TimelineAssetConfig,
|
TimelineAssetConfig,
|
||||||
TimelineData,
|
TimelineData,
|
||||||
|
toAssetResponseDto,
|
||||||
} from 'src/ui/generators/timeline';
|
} from 'src/ui/generators/timeline';
|
||||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||||
@@ -53,7 +54,7 @@ test.describe('search gallery-viewer', () => {
|
|||||||
assets: {
|
assets: {
|
||||||
total: searchAssets.length,
|
total: searchAssets.length,
|
||||||
count: searchAssets.length,
|
count: searchAssets.length,
|
||||||
items: searchAssets,
|
items: searchAssets.map((asset) => toAssetResponseDto(asset)),
|
||||||
facets: [],
|
facets: [],
|
||||||
nextPage: null,
|
nextPage: null,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -163,13 +163,11 @@ export const assetViewerUtils = {
|
|||||||
return page.locator('#immich-asset-viewer');
|
return page.locator('#immich-asset-viewer');
|
||||||
},
|
},
|
||||||
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
||||||
|
const previewUrl = `/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true`;
|
||||||
await page
|
await page
|
||||||
.locator(
|
.getByTestId('preview')
|
||||||
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
|
.and(page.locator(`[src="${previewUrl}"]`))
|
||||||
)
|
.or(page.locator(`video[poster="${previewUrl}"]`))
|
||||||
.or(
|
|
||||||
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
|
|
||||||
)
|
|
||||||
.waitFor();
|
.waitFor();
|
||||||
},
|
},
|
||||||
async expectActiveAssetToBe(page: Page, assetId: string) {
|
async expectActiveAssetToBe(page: Page, assetId: string) {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||||
|
|
||||||
|
export function loadImage(src: string, onLoad: () => void, onError: () => void, onStart?: () => void) {
|
||||||
|
let destroyed = false;
|
||||||
|
|
||||||
|
const handleLoad = () => !destroyed && onLoad();
|
||||||
|
const handleError = () => !destroyed && onError();
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.addEventListener('load', handleLoad);
|
||||||
|
img.addEventListener('error', handleError);
|
||||||
|
|
||||||
|
onStart?.();
|
||||||
|
img.src = src;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
destroyed = true;
|
||||||
|
img.removeEventListener('load', handleLoad);
|
||||||
|
img.removeEventListener('error', handleError);
|
||||||
|
cancelImageUrl(src);
|
||||||
|
img.remove();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoadImageFunction = typeof loadImage;
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { createZoomImageWheel } from '@zoom-image/core';
|
import { createZoomImageWheel } from '@zoom-image/core';
|
||||||
|
|
||||||
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
|
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean; zoomTarget?: HTMLElement }) => {
|
||||||
const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState });
|
const zoomInstance = createZoomImageWheel(node, {
|
||||||
|
maxZoom: 10,
|
||||||
|
initialState: assetViewerManager.zoomState,
|
||||||
|
zoomTarget: options?.zoomTarget,
|
||||||
|
});
|
||||||
|
|
||||||
const unsubscribes = [
|
const unsubscribes = [
|
||||||
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
|
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
|
||||||
@@ -20,8 +24,11 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
|
|||||||
|
|
||||||
node.style.overflow = 'visible';
|
node.style.overflow = 'visible';
|
||||||
return {
|
return {
|
||||||
update(newOptions?: { disabled?: boolean }) {
|
update(newOptions?: { disabled?: boolean; zoomTarget?: HTMLElement }) {
|
||||||
options = newOptions;
|
options = newOptions;
|
||||||
|
if (newOptions?.zoomTarget !== undefined) {
|
||||||
|
zoomInstance.setState({ zoomTarget: newOptions.zoomTarget });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
for (const unsubscribe of unsubscribes) {
|
for (const unsubscribe of unsubscribes) {
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { thumbhash } from '$lib/actions/thumbhash';
|
||||||
|
import AlphaBackground from '$lib/components/AlphaBackground.svelte';
|
||||||
|
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||||
|
import Image from '$lib/components/Image.svelte';
|
||||||
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
|
import { AdaptiveImageLoader, ImageStatus } from '$lib/utils/adaptive-image-loader.svelte';
|
||||||
|
import { getDimensions } from '$lib/utils/asset-utils';
|
||||||
|
import { scaleToFit } from '$lib/utils/container-utils';
|
||||||
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
|
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
||||||
|
import { LoadingSpinner } from '@immich/ui';
|
||||||
|
import { untrack, type Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
asset: AssetResponseDto;
|
||||||
|
sharedLink?: SharedLinkResponseDto;
|
||||||
|
imageClass?: string;
|
||||||
|
container: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
onUrlChange?: (url: string) => void;
|
||||||
|
onImageReady?: () => void;
|
||||||
|
onError?: () => void;
|
||||||
|
ref?: HTMLDivElement;
|
||||||
|
imgRef?: HTMLImageElement;
|
||||||
|
backdrop?: Snippet;
|
||||||
|
overlays?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(),
|
||||||
|
// eslint-disable-next-line no-useless-assignment
|
||||||
|
imgRef = $bindable(),
|
||||||
|
asset,
|
||||||
|
sharedLink,
|
||||||
|
imageClass = '',
|
||||||
|
container,
|
||||||
|
onUrlChange,
|
||||||
|
onImageReady,
|
||||||
|
onError,
|
||||||
|
backdrop,
|
||||||
|
overlays,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const loaderKey = $derived(`${asset.id}:${asset.thumbhash}:${sharedLink?.id}`);
|
||||||
|
|
||||||
|
const adaptiveImageLoader = $derived.by(() => {
|
||||||
|
void loaderKey;
|
||||||
|
|
||||||
|
return untrack(
|
||||||
|
() =>
|
||||||
|
new AdaptiveImageLoader(asset, sharedLink, {
|
||||||
|
currentZoomFn: () => assetViewerManager.zoom,
|
||||||
|
onImageReady,
|
||||||
|
onError,
|
||||||
|
onUrlChange,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect.pre(() => {
|
||||||
|
const loader = adaptiveImageLoader;
|
||||||
|
untrack(() => assetViewerManager.resetZoomState());
|
||||||
|
return () => {
|
||||||
|
loader.destroy();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageDimensions = $derived.by(() => {
|
||||||
|
if ((asset.width ?? 0) > 0 && (asset.height ?? 0) > 0) {
|
||||||
|
return { width: asset.width!, height: asset.height! };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.exifInfo?.exifImageHeight && asset.exifInfo.exifImageWidth) {
|
||||||
|
return getDimensions(asset.exifInfo) as { width: number; height: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width: 1, height: 1 };
|
||||||
|
});
|
||||||
|
|
||||||
|
const scaledDimensions = $derived(scaleToFit(imageDimensions, container));
|
||||||
|
|
||||||
|
const renderDimensions = $derived.by(() => {
|
||||||
|
const { width, height } = scaledDimensions;
|
||||||
|
return {
|
||||||
|
width: width + 'px',
|
||||||
|
height: height + 'px',
|
||||||
|
left: (container.width - width) / 2 + 'px',
|
||||||
|
top: (container.height - height) / 2 + 'px',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaderState = $derived(adaptiveImageLoader.state);
|
||||||
|
const imageAltText = $derived(loaderState.previewUrl ? $getAltText(toTimelineAsset(asset)) : '');
|
||||||
|
|
||||||
|
const showAlphaBackground = $derived(
|
||||||
|
!loaderState.hasError &&
|
||||||
|
['thumbnail', 'loading-thumbnail', 'loading-preview', 'loading-original', 'preview', 'original'].includes(
|
||||||
|
loaderState.quality,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const showSpinner = $derived(!asset.thumbhash && loaderState.quality === 'basic');
|
||||||
|
const showBrokenAsset = $derived(loaderState.hasError);
|
||||||
|
const showThumbhash = $derived(['basic', 'loading-thumbnail'].includes(loaderState.quality));
|
||||||
|
const showThumbnail = true;
|
||||||
|
const showPreview = true;
|
||||||
|
const showOriginal = true;
|
||||||
|
|
||||||
|
// Effect: Upgrade to original when user zooms in
|
||||||
|
$effect(() => {
|
||||||
|
if (assetViewerManager.zoom > 1 && loaderState.quality === 'preview') {
|
||||||
|
untrack(() => {
|
||||||
|
void adaptiveImageLoader.triggerOriginal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let thumbnailElement = $state<HTMLImageElement>();
|
||||||
|
let previewElement = $state<HTMLImageElement>();
|
||||||
|
let originalElement = $state<HTMLImageElement>();
|
||||||
|
|
||||||
|
const loadedOriginalElement = $derived(
|
||||||
|
loaderState.originalImage === ImageStatus.Success ? originalElement : undefined,
|
||||||
|
);
|
||||||
|
const loadedPreviewElement = $derived(loaderState.previewImage === ImageStatus.Success ? previewElement : undefined);
|
||||||
|
const loadedThumbnailElement = $derived(
|
||||||
|
loaderState.thumbnailImage === ImageStatus.Success ? thumbnailElement : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
imgRef = loadedOriginalElement ?? loadedPreviewElement ?? loadedThumbnailElement;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative h-full w-full" bind:this={ref}>
|
||||||
|
{@render backdrop?.()}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute"
|
||||||
|
style:left={renderDimensions.left}
|
||||||
|
style:top={renderDimensions.top}
|
||||||
|
style:width={renderDimensions.width}
|
||||||
|
style:height={renderDimensions.height}
|
||||||
|
>
|
||||||
|
{#if showAlphaBackground}
|
||||||
|
<AlphaBackground class="-z-3" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showThumbhash}
|
||||||
|
{#if asset.thumbhash}
|
||||||
|
<!-- Thumbhash / spinner layer -->
|
||||||
|
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute -z-2"></canvas>
|
||||||
|
{:else if showSpinner}
|
||||||
|
<div id="spinner" class="absolute flex h-full items-center justify-center">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showThumbnail}
|
||||||
|
{#key adaptiveImageLoader}
|
||||||
|
{@const loader = adaptiveImageLoader}
|
||||||
|
<div class="absolute top-0 z-1" style:width={renderDimensions.width} style:height={renderDimensions.height}>
|
||||||
|
<Image
|
||||||
|
src={loaderState.thumbnailUrl}
|
||||||
|
onStart={() => loader.onThumbnailStart()}
|
||||||
|
onLoad={() => loader.onThumbnailLoad()}
|
||||||
|
onError={() => loader.onThumbnailError()}
|
||||||
|
bind:ref={thumbnailElement}
|
||||||
|
class={['absolute h-full', 'w-full']}
|
||||||
|
alt=""
|
||||||
|
role="presentation"
|
||||||
|
data-testid="thumbnail"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showBrokenAsset}
|
||||||
|
<BrokenAsset class="text-xl h-full w-full absolute" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showPreview}
|
||||||
|
{#key adaptiveImageLoader}
|
||||||
|
{@const loader = adaptiveImageLoader}
|
||||||
|
<div class="absolute top-0 z-2" style:width={renderDimensions.width} style:height={renderDimensions.height}>
|
||||||
|
<Image
|
||||||
|
src={loaderState.previewUrl}
|
||||||
|
onStart={() => loader.onPreviewStart()}
|
||||||
|
onLoad={() => loader.onPreviewLoad()}
|
||||||
|
onError={() => loader.onPreviewError()}
|
||||||
|
bind:ref={previewElement}
|
||||||
|
class={['h-full', 'w-full', imageClass]}
|
||||||
|
alt={imageAltText}
|
||||||
|
draggable={false}
|
||||||
|
data-testid="preview"
|
||||||
|
/>
|
||||||
|
{@render overlays?.()}
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showOriginal}
|
||||||
|
{#key adaptiveImageLoader}
|
||||||
|
{@const loader = adaptiveImageLoader}
|
||||||
|
<div class="absolute top-0 z-3" style:width={renderDimensions.width} style:height={renderDimensions.height}>
|
||||||
|
<Image
|
||||||
|
src={loaderState.originalUrl}
|
||||||
|
onStart={() => loader.onOriginalStart()}
|
||||||
|
onLoad={() => loader.onOriginalLoad()}
|
||||||
|
onError={() => loader.onOriginalError()}
|
||||||
|
bind:ref={originalElement}
|
||||||
|
class={['h-full', 'w-full', imageClass]}
|
||||||
|
alt={imageAltText}
|
||||||
|
draggable={false}
|
||||||
|
data-testid="original"
|
||||||
|
/>
|
||||||
|
{@render overlays?.()}
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes delayedVisibility {
|
||||||
|
to {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spinner {
|
||||||
|
visibility: hidden;
|
||||||
|
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { focusTrap } from '$lib/actions/focus-trap';
|
import { focusTrap } from '$lib/actions/focus-trap';
|
||||||
|
import { loadImage } from '$lib/actions/image-loader.svelte';
|
||||||
import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
||||||
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
|
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
|
||||||
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
|
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
|
||||||
@@ -11,15 +12,16 @@
|
|||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
|
||||||
import { getAssetActions } from '$lib/services/asset.service';
|
import { getAssetActions } from '$lib/services/asset.service';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||||
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
||||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { getSharedLink, handlePromiseError } from '$lib/utils';
|
import { getSharedLink, handlePromiseError } from '$lib/utils';
|
||||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||||
|
import { AdaptiveImageLoader } from '$lib/utils/adaptive-image-loader.svelte';
|
||||||
import { navigateToAsset } from '$lib/utils/asset-utils';
|
import { navigateToAsset } from '$lib/utils/asset-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { CommandPaletteDefaultProvider } from '@immich/ui';
|
import { CommandPaletteDefaultProvider } from '@immich/ui';
|
||||||
import { onDestroy, onMount, untrack } from 'svelte';
|
import { onDestroy, onMount, untrack } from 'svelte';
|
||||||
|
import type { SwipeCustomEvent } from 'svelte-gestures';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||||
@@ -92,20 +95,20 @@
|
|||||||
stopProgress: stopSlideshowProgress,
|
stopProgress: stopSlideshowProgress,
|
||||||
slideshowNavigation,
|
slideshowNavigation,
|
||||||
slideshowState,
|
slideshowState,
|
||||||
slideshowTransition,
|
|
||||||
slideshowRepeat,
|
slideshowRepeat,
|
||||||
} = slideshowStore;
|
} = slideshowStore;
|
||||||
const stackThumbnailSize = 60;
|
const stackThumbnailSize = 60;
|
||||||
const stackSelectedThumbnailSize = 65;
|
const stackSelectedThumbnailSize = 65;
|
||||||
|
|
||||||
const asset = $derived(cursor.current);
|
let stack: StackResponseDto | undefined = $state();
|
||||||
|
let selectedStackAsset = $derived(stack?.assets.find(({ id }) => id === stack?.primaryAssetId));
|
||||||
|
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||||
|
|
||||||
|
const asset = $derived(previewStackedAsset ?? selectedStackAsset ?? cursor.current);
|
||||||
const nextAsset = $derived(cursor.nextAsset);
|
const nextAsset = $derived(cursor.nextAsset);
|
||||||
const previousAsset = $derived(cursor.previousAsset);
|
const previousAsset = $derived(cursor.previousAsset);
|
||||||
let sharedLink = getSharedLink();
|
let sharedLink = getSharedLink();
|
||||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
|
||||||
let fullscreenElement = $state<Element>();
|
let fullscreenElement = $state<Element>();
|
||||||
let unsubscribes: (() => void)[] = [];
|
|
||||||
let stack: StackResponseDto | null = $state(null);
|
|
||||||
|
|
||||||
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
|
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
|
||||||
let slideshowStartAssetId = $state<string>();
|
let slideshowStartAssetId = $state<string>();
|
||||||
@@ -115,62 +118,61 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const refreshStack = async () => {
|
const refreshStack = async () => {
|
||||||
if (authManager.isSharedLink) {
|
if (authManager.isSharedLink || !withStacked) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset.stack) {
|
if (!cursor.current.stack) {
|
||||||
stack = await getStack({ id: asset.stack.id });
|
stack = undefined;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!stack?.assets.some(({ id }) => id === asset.id)) {
|
stack = await getStack({ id: cursor.current.stack.id });
|
||||||
stack = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
untrack(() => {
|
|
||||||
imageManager.preload(stack?.assets[1]);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFavorite = async () => {
|
const handleFavorite = async () => {
|
||||||
if (album && album.isActivityEnabled) {
|
if (!album || !album.isActivityEnabled) {
|
||||||
try {
|
return;
|
||||||
await activityManager.toggleLike();
|
}
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('errors.unable_to_change_favorite'));
|
try {
|
||||||
}
|
await activityManager.toggleLike();
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, $t('errors.unable_to_change_favorite'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
syncAssetViewerOpenClass(true);
|
syncAssetViewerOpenClass(true);
|
||||||
unsubscribes.push(
|
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
|
||||||
slideshowState.subscribe((value) => {
|
if (value === SlideshowState.PlaySlideshow) {
|
||||||
if (value === SlideshowState.PlaySlideshow) {
|
slideshowHistory.reset();
|
||||||
slideshowHistory.reset();
|
slideshowHistory.queue(toTimelineAsset(asset));
|
||||||
slideshowHistory.queue(toTimelineAsset(asset));
|
handlePromiseError(handlePlaySlideshow());
|
||||||
handlePromiseError(handlePlaySlideshow());
|
} else if (value === SlideshowState.StopSlideshow) {
|
||||||
} else if (value === SlideshowState.StopSlideshow) {
|
handlePromiseError(handleStopSlideshow());
|
||||||
handlePromiseError(handleStopSlideshow());
|
}
|
||||||
}
|
});
|
||||||
}),
|
|
||||||
slideshowNavigation.subscribe((value) => {
|
const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => {
|
||||||
if (value === SlideshowNavigation.Shuffle) {
|
if (value === SlideshowNavigation.Shuffle) {
|
||||||
slideshowHistory.reset();
|
slideshowHistory.reset();
|
||||||
slideshowHistory.queue(toTimelineAsset(asset));
|
slideshowHistory.queue(toTimelineAsset(asset));
|
||||||
}
|
}
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
return () => {
|
||||||
|
slideshowStateUnsubscribe();
|
||||||
|
slideshowNavigationUnsubscribe();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
for (const unsubscribe of unsubscribes) {
|
|
||||||
unsubscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
activityManager.reset();
|
activityManager.reset();
|
||||||
assetViewerManager.closeEditor();
|
assetViewerManager.closeEditor();
|
||||||
syncAssetViewerOpenClass(false);
|
syncAssetViewerOpenClass(false);
|
||||||
|
destroyNextPreloader();
|
||||||
|
destroyPreviousPreloader();
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeViewer = () => {
|
const closeViewer = () => {
|
||||||
@@ -186,9 +188,64 @@
|
|||||||
assetViewerManager.closeEditor();
|
assetViewerManager.closeEditor();
|
||||||
};
|
};
|
||||||
|
|
||||||
const tracker = new InvocationTracker();
|
let nextPreloader: AdaptiveImageLoader | undefined;
|
||||||
|
let previousPreloader: AdaptiveImageLoader | undefined;
|
||||||
|
|
||||||
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
|
const startPreloader = (asset: AssetResponseDto | undefined) => {
|
||||||
|
if (!asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loader = new AdaptiveImageLoader(asset, undefined, undefined, loadImage);
|
||||||
|
loader.start();
|
||||||
|
return loader;
|
||||||
|
};
|
||||||
|
|
||||||
|
const destroyPreviousPreloader = () => {
|
||||||
|
previousPreloader?.destroy();
|
||||||
|
previousPreloader = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const destroyNextPreloader = () => {
|
||||||
|
nextPreloader?.destroy();
|
||||||
|
nextPreloader = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelPreloadsBeforeNavigation = (direction: 'previous' | 'next') => {
|
||||||
|
if (direction === 'next') {
|
||||||
|
destroyPreviousPreloader();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
destroyNextPreloader();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePreloadsAfterNavigation = (oldCursor: AssetCursor, newCursor: AssetCursor) => {
|
||||||
|
const movedForward = newCursor.current.id === oldCursor.nextAsset?.id;
|
||||||
|
const movedBackward = newCursor.current.id === oldCursor.previousAsset?.id;
|
||||||
|
|
||||||
|
const shouldDestroyPrevious = !movedBackward;
|
||||||
|
const shouldDestroyNext = !movedForward;
|
||||||
|
|
||||||
|
if (shouldDestroyPrevious) {
|
||||||
|
destroyPreviousPreloader();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldDestroyNext) {
|
||||||
|
destroyNextPreloader();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (movedForward) {
|
||||||
|
nextPreloader = startPreloader(newCursor.nextAsset);
|
||||||
|
} else if (movedBackward) {
|
||||||
|
previousPreloader = startPreloader(newCursor.previousAsset);
|
||||||
|
} else {
|
||||||
|
// Non-adjacent navigation (e.g., slideshow random)
|
||||||
|
previousPreloader = startPreloader(newCursor.previousAsset);
|
||||||
|
nextPreloader = startPreloader(newCursor.nextAsset);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tracker = new InvocationTracker();
|
||||||
|
const navigateAsset = (order?: 'previous' | 'next') => {
|
||||||
if (!order) {
|
if (!order) {
|
||||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||||
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
||||||
@@ -197,8 +254,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
e?.stopPropagation();
|
cancelPreloadsBeforeNavigation(order);
|
||||||
imageManager.cancel(asset);
|
|
||||||
if (tracker.isActive()) {
|
if (tracker.isActive()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -220,16 +277,18 @@
|
|||||||
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
|
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
if ($slideshowState !== SlideshowState.PlaySlideshow) {
|
||||||
if (hasNext) {
|
return;
|
||||||
$restartSlideshowProgress = true;
|
}
|
||||||
} else if ($slideshowRepeat && slideshowStartAssetId) {
|
|
||||||
// Loop back to starting asset
|
if (hasNext) {
|
||||||
await setAssetId(slideshowStartAssetId);
|
$restartSlideshowProgress = true;
|
||||||
$restartSlideshowProgress = true;
|
} else if ($slideshowRepeat && slideshowStartAssetId) {
|
||||||
} else {
|
// Loop back to starting asset
|
||||||
await handleStopSlideshow();
|
await setAssetId(slideshowStartAssetId);
|
||||||
}
|
$restartSlideshowProgress = true;
|
||||||
|
} else {
|
||||||
|
await handleStopSlideshow();
|
||||||
}
|
}
|
||||||
}, $t('error_while_navigating'));
|
}, $t('error_while_navigating'));
|
||||||
};
|
};
|
||||||
@@ -274,12 +333,20 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
|
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
|
||||||
previewStackedAsset = isMouseOver ? asset : undefined;
|
if (isMouseOver) {
|
||||||
|
previewStackedAsset = stackedAsset;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStackedAssetsMouseLeave = () => {
|
||||||
|
previewStackedAsset = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const handlePreAction = (action: Action) => {
|
const handlePreAction = (action: Action) => {
|
||||||
preAction?.(action);
|
preAction?.(action);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAction = async (action: Action) => {
|
const handleAction = async (action: Action) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case AssetAction.DELETE:
|
case AssetAction.DELETE:
|
||||||
@@ -288,7 +355,7 @@
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case AssetAction.REMOVE_ASSET_FROM_STACK: {
|
case AssetAction.REMOVE_ASSET_FROM_STACK: {
|
||||||
stack = action.stack;
|
stack = action.stack ?? undefined;
|
||||||
if (stack) {
|
if (stack) {
|
||||||
cursor.current = stack.assets[0];
|
cursor.current = stack.assets[0];
|
||||||
}
|
}
|
||||||
@@ -345,24 +412,48 @@
|
|||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
await refreshStack();
|
await refreshStack();
|
||||||
ocrManager.clear();
|
ocrManager.clear();
|
||||||
if (!sharedLink) {
|
if (sharedLink) {
|
||||||
if (previewStackedAsset) {
|
return;
|
||||||
await ocrManager.getAssetOcr(previewStackedAsset.id);
|
|
||||||
}
|
|
||||||
await ocrManager.getAssetOcr(asset.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (previewStackedAsset) {
|
||||||
|
await ocrManager.getAssetOcr(previewStackedAsset.id);
|
||||||
|
}
|
||||||
|
await ocrManager.getAssetOcr(asset.id);
|
||||||
};
|
};
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
asset;
|
cursor.current;
|
||||||
untrack(() => handlePromiseError(refresh()));
|
untrack(() => handlePromiseError(refresh()));
|
||||||
imageManager.preload(cursor.nextAsset);
|
});
|
||||||
imageManager.preload(cursor.previousAsset);
|
|
||||||
|
let lastCursor = $state<AssetCursor>();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (cursor.current.id === lastCursor?.current.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lastCursor) {
|
||||||
|
selectedStackAsset = undefined;
|
||||||
|
previewStackedAsset = undefined;
|
||||||
|
// After navigation completes, reconcile preloads with full state information
|
||||||
|
updatePreloadsAfterNavigation(lastCursor, cursor);
|
||||||
|
}
|
||||||
|
if (!lastCursor) {
|
||||||
|
// "first time" load, start preloads
|
||||||
|
if (cursor.nextAsset) {
|
||||||
|
nextPreloader = startPreloader(cursor.nextAsset);
|
||||||
|
}
|
||||||
|
if (cursor.previousAsset) {
|
||||||
|
previousPreloader = startPreloader(cursor.previousAsset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastCursor = cursor;
|
||||||
});
|
});
|
||||||
|
|
||||||
const viewerKind = $derived.by(() => {
|
const viewerKind = $derived.by(() => {
|
||||||
if (previewStackedAsset) {
|
if (previewStackedAsset) {
|
||||||
return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer';
|
return asset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer';
|
||||||
}
|
}
|
||||||
if (asset.type === AssetTypeEnum.Video) {
|
if (asset.type === AssetTypeEnum.Video) {
|
||||||
return 'VideoViewer';
|
return 'VideoViewer';
|
||||||
@@ -403,6 +494,24 @@
|
|||||||
assetViewerManager.isShowDetailPanel &&
|
assetViewerManager.isShowDetailPanel &&
|
||||||
!assetViewerManager.isShowEditor,
|
!assetViewerManager.isShowEditor,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onSwipe = (event: SwipeCustomEvent) => {
|
||||||
|
if (assetViewerManager.zoom > 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ocrManager.showOverlay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.detail.direction === 'left') {
|
||||||
|
navigateAsset('next');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.detail.direction === 'right') {
|
||||||
|
navigateAsset('previous');
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
|
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
|
||||||
@@ -448,23 +557,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && previousAsset}
|
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
|
||||||
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
|
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
|
||||||
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Asset Viewer -->
|
<!-- Asset Viewer -->
|
||||||
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||||
{#if viewerKind === 'StackPhotoViewer'}
|
{#if viewerKind === 'StackVideoViewer'}
|
||||||
<PhotoViewer
|
|
||||||
cursor={{ ...cursor, current: previewStackedAsset! }}
|
|
||||||
onPreviousAsset={() => navigateAsset('previous')}
|
|
||||||
onNextAsset={() => navigateAsset('next')}
|
|
||||||
haveFadeTransition={false}
|
|
||||||
{sharedLink}
|
|
||||||
/>
|
|
||||||
{:else if viewerKind === 'StackVideoViewer'}
|
|
||||||
<VideoViewer
|
<VideoViewer
|
||||||
asset={previewStackedAsset!}
|
asset={previewStackedAsset!}
|
||||||
cacheKey={previewStackedAsset!.thumbhash}
|
cacheKey={previewStackedAsset!.thumbhash}
|
||||||
@@ -494,13 +595,7 @@
|
|||||||
{:else if viewerKind === 'CropArea'}
|
{:else if viewerKind === 'CropArea'}
|
||||||
<CropArea {asset} />
|
<CropArea {asset} />
|
||||||
{:else if viewerKind === 'PhotoViewer'}
|
{:else if viewerKind === 'PhotoViewer'}
|
||||||
<PhotoViewer
|
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
|
||||||
{cursor}
|
|
||||||
onPreviousAsset={() => navigateAsset('previous')}
|
|
||||||
onNextAsset={() => navigateAsset('next')}
|
|
||||||
{sharedLink}
|
|
||||||
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
|
|
||||||
/>
|
|
||||||
{:else if viewerKind === 'VideoViewer'}
|
{:else if viewerKind === 'VideoViewer'}
|
||||||
<VideoViewer
|
<VideoViewer
|
||||||
{asset}
|
{asset}
|
||||||
@@ -535,7 +630,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && nextAsset}
|
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
|
||||||
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
|
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
|
||||||
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
||||||
</div>
|
</div>
|
||||||
@@ -563,10 +658,14 @@
|
|||||||
{#if stack && withStacked && !assetViewerManager.isShowEditor}
|
{#if stack && withStacked && !assetViewerManager.isShowEditor}
|
||||||
{@const stackedAssets = stack.assets}
|
{@const stackedAssets = stack.assets}
|
||||||
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
|
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
|
||||||
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
|
<div
|
||||||
|
role="presentation"
|
||||||
|
class="relative inline-flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar pointer-events-auto"
|
||||||
|
onmouseleave={handleStackedAssetsMouseLeave}
|
||||||
|
>
|
||||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||||
<div
|
<div
|
||||||
class={['inline-block px-1 relative transition-all pb-2 pointer-events-auto']}
|
class={['inline-block px-1 relative transition-all pb-2']}
|
||||||
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
|
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
|
||||||
>
|
>
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
@@ -575,7 +674,7 @@
|
|||||||
dimmed={stackedAsset.id !== asset.id}
|
dimmed={stackedAsset.id !== asset.id}
|
||||||
asset={toTimelineAsset(stackedAsset)}
|
asset={toTimelineAsset(stackedAsset)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
cursor.current = stackedAsset;
|
selectedStackAsset = stackedAsset;
|
||||||
previewStackedAsset = undefined;
|
previewStackedAsset = undefined;
|
||||||
}}
|
}}
|
||||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||||
@@ -585,11 +684,9 @@
|
|||||||
disableLinkMouseOver
|
disableLinkMouseOver
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if stackedAsset.id === asset.id}
|
<div class="w-full flex place-items-center place-content-center">
|
||||||
<div class="w-full flex place-items-center place-content-center">
|
<div class={['w-2 h-2 rounded-full flex mt-0.5', { 'bg-white': stackedAsset.id === asset.id }]}></div>
|
||||||
<div class="w-2 h-2 bg-white rounded-full flex mt-0.5"></div>
|
</div>
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
import { getContentMetrics, getNaturalSize } from '$lib/utils/container-utils';
|
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||||
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
||||||
@@ -81,15 +81,20 @@
|
|||||||
await getPeople();
|
await getPeople();
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
const imageContentMetrics = $derived.by(() => {
|
||||||
const metrics = getContentMetrics(htmlElement);
|
const natural = getNaturalSize(htmlElement);
|
||||||
|
const container = { width: containerWidth, height: containerHeight };
|
||||||
const imageBoundingBox = {
|
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container);
|
||||||
top: metrics.offsetY,
|
return {
|
||||||
left: metrics.offsetX,
|
contentWidth,
|
||||||
width: metrics.contentWidth,
|
contentHeight,
|
||||||
height: metrics.contentHeight,
|
offsetX: (containerWidth - contentWidth) / 2,
|
||||||
|
offsetY: (containerHeight - contentHeight) / 2,
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const { offsetX, offsetY } = imageContentMetrics;
|
||||||
|
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
return;
|
return;
|
||||||
@@ -105,8 +110,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
faceRect.set({
|
faceRect.set({
|
||||||
top: imageBoundingBox.top + 200,
|
top: offsetY + 200,
|
||||||
left: imageBoundingBox.left + 200,
|
left: offsetX + 200,
|
||||||
});
|
});
|
||||||
|
|
||||||
faceRect.setCoords();
|
faceRect.setCoords();
|
||||||
@@ -214,13 +219,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { left, top, width, height } = faceRect.getBoundingRect();
|
const { left, top, width, height } = faceRect.getBoundingRect();
|
||||||
const metrics = getContentMetrics(htmlElement);
|
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
|
||||||
const natural = getNaturalSize(htmlElement);
|
const natural = getNaturalSize(htmlElement);
|
||||||
|
|
||||||
const scaleX = natural.width / metrics.contentWidth;
|
const scaleX = natural.width / contentWidth;
|
||||||
const scaleY = natural.height / metrics.contentHeight;
|
const scaleY = natural.height / contentHeight;
|
||||||
const imageX = (left - metrics.offsetX) * scaleX;
|
const imageX = (left - offsetX) * scaleX;
|
||||||
const imageY = (top - metrics.offsetY) * scaleY;
|
const imageY = (top - offsetY) * scaleY;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
imageWidth: natural.width,
|
imageWidth: natural.width,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
<div class="absolute left-0 top-0">
|
<div class="absolute left-0 top-0">
|
||||||
<div
|
<div
|
||||||
class="absolute flex items-center justify-center text-transparent text-sm border-2 border-blue-500 bg-blue-500/10 px-2 py-1 pointer-events-auto cursor-text whitespace-pre-wrap wrap-break-word select-text transition-all hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3"
|
class="absolute flex items-center justify-center text-transparent text-sm border-2 border-blue-500 bg-blue-500/10 px-2 py-1 pointer-events-auto cursor-text whitespace-pre-wrap wrap-break-word select-text transition-colors hover:text-white hover:bg-black/60 hover:border-blue-600 hover:border-3"
|
||||||
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0;"
|
style="font-size: {fontSize}; width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: 0 0;"
|
||||||
>
|
>
|
||||||
{ocrBox.text}
|
{ocrBox.text}
|
||||||
|
|||||||
@@ -1,66 +1,56 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { shortcuts } from '$lib/actions/shortcut';
|
import { shortcuts } from '$lib/actions/shortcut';
|
||||||
|
import { thumbhash } from '$lib/actions/thumbhash';
|
||||||
import { zoomImageAction } from '$lib/actions/zoom-image';
|
import { zoomImageAction } from '$lib/actions/zoom-image';
|
||||||
|
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
|
||||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||||
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
||||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
|
||||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||||
import { assetViewerFadeDuration } from '$lib/constants';
|
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||||
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||||
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||||
import { type ContentMetrics, getContentMetrics } from '$lib/utils/container-utils';
|
import { type ContentMetrics, getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
import { type SharedLinkResponseDto } from '@immich/sdk';
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toastManager } from '@immich/ui';
|
||||||
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
|
|
||||||
import { LoadingSpinner, toastManager } from '@immich/ui';
|
|
||||||
import { onDestroy, untrack } from 'svelte';
|
import { onDestroy, untrack } from 'svelte';
|
||||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
import type { AssetCursor } from './asset-viewer.svelte';
|
import type { AssetCursor } from './asset-viewer.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cursor: AssetCursor;
|
cursor: AssetCursor;
|
||||||
element?: HTMLDivElement | undefined;
|
element?: HTMLDivElement;
|
||||||
haveFadeTransition?: boolean;
|
sharedLink?: SharedLinkResponseDto;
|
||||||
sharedLink?: SharedLinkResponseDto | undefined;
|
onReady?: () => void;
|
||||||
onPreviousAsset?: (() => void) | null;
|
onError?: () => void;
|
||||||
onNextAsset?: (() => void) | null;
|
onSwipe?: (event: SwipeCustomEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
|
||||||
cursor,
|
|
||||||
element = $bindable(),
|
|
||||||
haveFadeTransition = true,
|
|
||||||
sharedLink = undefined,
|
|
||||||
onPreviousAsset = null,
|
|
||||||
onNextAsset = null,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const { slideshowState, slideshowLook } = slideshowStore;
|
const { slideshowState, slideshowLook } = slideshowStore;
|
||||||
const asset = $derived(cursor.current);
|
const asset = $derived(cursor.current);
|
||||||
|
|
||||||
let imageLoaded: boolean = $state(false);
|
|
||||||
let originalImageLoaded: boolean = $state(false);
|
|
||||||
let imageError: boolean = $state(false);
|
|
||||||
let visibleImageReady: boolean = $state(false);
|
let visibleImageReady: boolean = $state(false);
|
||||||
|
|
||||||
let loader = $state<HTMLImageElement>();
|
let previousAssetId: string | undefined;
|
||||||
|
|
||||||
$effect.pre(() => {
|
$effect.pre(() => {
|
||||||
void asset.id;
|
const id = asset.id;
|
||||||
|
if (id === previousAssetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previousAssetId = id;
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
assetViewerManager.resetZoomState();
|
assetViewerManager.resetZoomState();
|
||||||
|
visibleImageReady = false;
|
||||||
$boundingBoxesArray = [];
|
$boundingBoxesArray = [];
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -69,25 +59,30 @@
|
|||||||
$boundingBoxesArray = [];
|
$boundingBoxesArray = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let containerWidth = $state(0);
|
||||||
|
let containerHeight = $state(0);
|
||||||
|
|
||||||
|
const container = $derived({
|
||||||
|
width: containerWidth,
|
||||||
|
height: containerHeight,
|
||||||
|
});
|
||||||
|
|
||||||
const overlayMetrics = $derived.by((): ContentMetrics => {
|
const overlayMetrics = $derived.by((): ContentMetrics => {
|
||||||
if (!assetViewerManager.imgRef || !visibleImageReady) {
|
if (!assetViewerManager.imgRef || !visibleImageReady) {
|
||||||
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { contentWidth, contentHeight, offsetX, offsetY } = getContentMetrics(assetViewerManager.imgRef);
|
const natural = getNaturalSize(assetViewerManager.imgRef);
|
||||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
const scaled = scaleToFit(natural, container);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contentWidth: contentWidth * currentZoom,
|
contentWidth: scaled.width,
|
||||||
contentHeight: contentHeight * currentZoom,
|
contentHeight: scaled.height,
|
||||||
offsetX: offsetX * currentZoom + currentPositionX,
|
offsetX: 0,
|
||||||
offsetY: offsetY * currentZoom + currentPositionY,
|
offsetY: 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
let ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
|
const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
|
||||||
|
|
||||||
let isOcrActive = $derived(ocrManager.showOverlay);
|
|
||||||
|
|
||||||
const onCopy = async () => {
|
const onCopy = async () => {
|
||||||
if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) {
|
if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) {
|
||||||
@@ -124,29 +119,15 @@
|
|||||||
handlePromiseError(onCopy());
|
handlePromiseError(onCopy());
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSwipe = (event: SwipeCustomEvent) => {
|
let currentPreviewUrl = $state<string>();
|
||||||
if (assetViewerManager.zoom > 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ocrManager.showOverlay) {
|
const onUrlChange = (url: string) => {
|
||||||
return;
|
currentPreviewUrl = url;
|
||||||
}
|
|
||||||
|
|
||||||
if (onNextAsset && event.detail.direction === 'left') {
|
|
||||||
onNextAsset();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onPreviousAsset && event.detail.direction === 'right') {
|
|
||||||
onPreviousAsset();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || assetViewerManager.zoom > 1));
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (imageLoaderUrl) {
|
if (currentPreviewUrl) {
|
||||||
void cast(imageLoaderUrl);
|
void cast(currentPreviewUrl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -164,37 +145,11 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onload = () => {
|
const blurredSlideshow = $derived(
|
||||||
imageLoaded = true;
|
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
|
||||||
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
|
|
||||||
};
|
|
||||||
|
|
||||||
const onerror = () => {
|
|
||||||
imageError = imageLoaded = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
onDestroy(() => imageManager.cancel(asset, targetImageSize));
|
|
||||||
|
|
||||||
let imageLoaderUrl = $derived(
|
|
||||||
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let containerWidth = $state(0);
|
let adaptiveImage = $state<HTMLDivElement | undefined>();
|
||||||
let containerHeight = $state(0);
|
|
||||||
|
|
||||||
let lastUrl: string | undefined;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (lastUrl && lastUrl !== imageLoaderUrl) {
|
|
||||||
untrack(() => {
|
|
||||||
imageLoaded = false;
|
|
||||||
originalImageLoaded = false;
|
|
||||||
imageError = false;
|
|
||||||
visibleImageReady = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
lastUrl = imageLoaderUrl;
|
|
||||||
});
|
|
||||||
|
|
||||||
const faceToNameMap = $derived.by(() => {
|
const faceToNameMap = $derived.by(() => {
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||||
@@ -209,28 +164,6 @@
|
|||||||
|
|
||||||
const faces = $derived(Array.from(faceToNameMap.keys()));
|
const faces = $derived(Array.from(faceToNameMap.keys()));
|
||||||
|
|
||||||
const handleImageMouseMove = (event: MouseEvent) => {
|
|
||||||
$boundingBoxesArray = [];
|
|
||||||
if (!assetViewerManager.imgRef || !element || isFaceEditMode.value || ocrManager.showOverlay) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerRect = element.getBoundingClientRect();
|
|
||||||
const mouseX = event.clientX - containerRect.left;
|
|
||||||
const mouseY = event.clientY - containerRect.top;
|
|
||||||
|
|
||||||
const faceBoxes = getBoundingBox(faces, overlayMetrics);
|
|
||||||
|
|
||||||
for (const [index, box] of faceBoxes.entries()) {
|
|
||||||
if (mouseX >= box.left && mouseX <= box.left + box.width && mouseY >= box.top && mouseY <= box.top + box.height) {
|
|
||||||
$boundingBoxesArray.push(faces[index]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImageMouseLeave = () => {
|
|
||||||
$boundingBoxesArray = [];
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AssetViewerEvents {onCopy} {onZoom} />
|
<AssetViewerEvents {onCopy} {onZoom} />
|
||||||
@@ -243,53 +176,57 @@
|
|||||||
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
|
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{#if imageError}
|
|
||||||
<div id="broken-asset" class="h-full w-full">
|
|
||||||
<BrokenAsset class="text-xl h-full w-full" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} />
|
|
||||||
<div
|
<div
|
||||||
bind:this={element}
|
bind:this={element}
|
||||||
class="relative h-full w-full select-none"
|
class="relative h-full w-full select-none"
|
||||||
bind:clientWidth={containerWidth}
|
bind:clientWidth={containerWidth}
|
||||||
bind:clientHeight={containerHeight}
|
bind:clientHeight={containerHeight}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
onmousemove={handleImageMouseMove}
|
use:zoomImageAction={{ disabled: isFaceEditMode.value, zoomTarget: adaptiveImage }}
|
||||||
onmouseleave={handleImageMouseLeave}
|
{...useSwipe((event) => onSwipe?.(event))}
|
||||||
>
|
>
|
||||||
{#if !imageLoaded}
|
<AdaptiveImage
|
||||||
<div id="spinner" class="flex h-full items-center justify-center">
|
{asset}
|
||||||
<LoadingSpinner />
|
{sharedLink}
|
||||||
</div>
|
{container}
|
||||||
{:else if !imageError}
|
imageClass={`${$slideshowState === SlideshowState.None ? 'object-contain' : slideshowLookCssMapping[$slideshowLook]}`}
|
||||||
<div
|
{onUrlChange}
|
||||||
use:zoomImageAction={{ disabled: isOcrActive }}
|
onImageReady={() => {
|
||||||
{...useSwipe(onSwipe)}
|
visibleImageReady = true;
|
||||||
class="h-full w-full"
|
onReady?.();
|
||||||
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
|
}}
|
||||||
>
|
onError={() => {
|
||||||
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
onError?.();
|
||||||
<img
|
onReady?.();
|
||||||
src={imageLoaderUrl}
|
}}
|
||||||
alt=""
|
bind:imgRef={assetViewerManager.imgRef}
|
||||||
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
|
bind:ref={adaptiveImage}
|
||||||
draggable="false"
|
>
|
||||||
/>
|
{#snippet backdrop()}
|
||||||
|
{#if blurredSlideshow}
|
||||||
|
<canvas
|
||||||
|
use:thumbhash={{ base64ThumbHash: asset.thumbhash! }}
|
||||||
|
class="-z-1 absolute top-0 left-0 start-0 h-dvh w-dvw"
|
||||||
|
></canvas>
|
||||||
{/if}
|
{/if}
|
||||||
<img
|
{/snippet}
|
||||||
bind:this={assetViewerManager.imgRef}
|
{#snippet overlays()}
|
||||||
src={imageLoaderUrl}
|
{#if !isFaceEditMode.value && !ocrManager.showOverlay}
|
||||||
onload={() => (visibleImageReady = true)}
|
{#each getBoundingBox(faces, overlayMetrics) as boundingbox, index (boundingbox.id)}
|
||||||
alt={$getAltText(toTimelineAsset(asset))}
|
<div
|
||||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
class="absolute pointer-events-auto"
|
||||||
? 'object-contain'
|
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||||
: slideshowLookCssMapping[$slideshowLook]}"
|
role="presentation"
|
||||||
draggable="false"
|
onmouseenter={() => { $boundingBoxesArray = [faces[index]]; }}
|
||||||
/>
|
onmouseleave={() => { $boundingBoxesArray = []; }}
|
||||||
|
></div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox, index (boundingbox.id)}
|
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox, index (boundingbox.id)}
|
||||||
<div
|
<div
|
||||||
class="absolute border-solid border-white border-3 rounded-lg"
|
class="absolute border-solid border-white border-3 rounded-lg pointer-events-none"
|
||||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||||
></div>
|
></div>
|
||||||
{#if faceToNameMap.get($boundingBoxesArray[index])}
|
{#if faceToNameMap.get($boundingBoxesArray[index])}
|
||||||
@@ -306,23 +243,10 @@
|
|||||||
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
||||||
<OcrBoundingBox {ocrBox} />
|
<OcrBoundingBox {ocrBox} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
{/snippet}
|
||||||
|
</AdaptiveImage>
|
||||||
|
|
||||||
{#if isFaceEditMode.value}
|
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
||||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
|
||||||
@keyframes delayedVisibility {
|
|
||||||
to {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#broken-asset,
|
|
||||||
#spinner {
|
|
||||||
visibility: hidden;
|
|
||||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -4,19 +4,42 @@ import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
|
|||||||
|
|
||||||
type AllAssetMediaSize = AssetMediaSize | 'all';
|
type AllAssetMediaSize = AssetMediaSize | 'all';
|
||||||
|
|
||||||
|
type AssetLoadState = 'loading' | 'cancelled';
|
||||||
|
|
||||||
class ImageManager {
|
class ImageManager {
|
||||||
|
private assetStates = new Map<string, AssetLoadState>();
|
||||||
|
private readonly MAX_TRACKED_ASSETS = 10;
|
||||||
|
|
||||||
|
private trackAction(asset: AssetResponseDto, action: AssetLoadState) {
|
||||||
|
this.assetStates.delete(asset.id);
|
||||||
|
this.assetStates.set(asset.id, action);
|
||||||
|
|
||||||
|
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) {
|
preload(asset: AssetResponseDto | undefined, size: AssetMediaSize = AssetMediaSize.Preview) {
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const src = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
|
||||||
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
|
this.trackLoad(asset);
|
||||||
if (!url) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.src = url;
|
img.src = src;
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(asset: AssetResponseDto | undefined, size: AllAssetMediaSize = AssetMediaSize.Preview) {
|
cancel(asset: AssetResponseDto | undefined, size: AllAssetMediaSize = AssetMediaSize.Preview) {
|
||||||
@@ -24,6 +47,8 @@ class ImageManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.trackCancelled(asset);
|
||||||
|
|
||||||
const sizes = size === 'all' ? Object.values(AssetMediaSize) : [size];
|
const sizes = size === 'all' ? Object.values(AssetMediaSize) : [size];
|
||||||
for (const size of sizes) {
|
for (const size of sizes) {
|
||||||
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
|
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import type { LoadImageFunction } from '$lib/actions/image-loader.svelte';
|
||||||
|
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||||
|
import { getAssetMediaUrl, getAssetUrl } from '$lib/utils';
|
||||||
|
import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quality levels for progressive image loading
|
||||||
|
*/
|
||||||
|
type ImageQuality =
|
||||||
|
| 'basic'
|
||||||
|
| 'loading-thumbnail'
|
||||||
|
| 'thumbnail'
|
||||||
|
| 'loading-preview'
|
||||||
|
| 'preview'
|
||||||
|
| 'loading-original'
|
||||||
|
| 'original';
|
||||||
|
|
||||||
|
const qualityOrder: Record<ImageQuality, number> = {
|
||||||
|
basic: 0,
|
||||||
|
'loading-thumbnail': 1,
|
||||||
|
thumbnail: 2,
|
||||||
|
'loading-preview': 3,
|
||||||
|
preview: 4,
|
||||||
|
'loading-original': 5,
|
||||||
|
original: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ImageLoaderState {
|
||||||
|
previewUrl?: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
originalUrl?: string;
|
||||||
|
quality: ImageQuality;
|
||||||
|
hasError: boolean;
|
||||||
|
thumbnailImage: ImageStatus;
|
||||||
|
previewImage: ImageStatus;
|
||||||
|
originalImage: ImageStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ImageStatus {
|
||||||
|
Unloaded = 'Unloaded',
|
||||||
|
Success = 'Success',
|
||||||
|
Error = 'Error',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coordinates adaptive loading of a single asset image:
|
||||||
|
* thumbhash → thumbnail → preview → original (on zoom)
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
let nextLoaderId = 0;
|
||||||
|
|
||||||
|
export class AdaptiveImageLoader {
|
||||||
|
readonly id = nextLoaderId++;
|
||||||
|
|
||||||
|
private internalState = $state<ImageLoaderState>({
|
||||||
|
quality: 'basic',
|
||||||
|
hasError: false,
|
||||||
|
thumbnailImage: ImageStatus.Unloaded,
|
||||||
|
previewImage: ImageStatus.Unloaded,
|
||||||
|
originalImage: ImageStatus.Unloaded,
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly currentZoomFn?: () => number;
|
||||||
|
private readonly imageLoader?: LoadImageFunction;
|
||||||
|
private readonly destroyFunctions: (() => void)[] = [];
|
||||||
|
readonly thumbnailUrl: string;
|
||||||
|
readonly previewUrl: string;
|
||||||
|
readonly originalUrl: string;
|
||||||
|
readonly asset: AssetResponseDto;
|
||||||
|
readonly sharedLink?: SharedLinkResponseDto;
|
||||||
|
readonly callbacks?: {
|
||||||
|
currentZoomFn: () => number;
|
||||||
|
onUrlChange?: (url: string) => void;
|
||||||
|
onImageReady?: () => void;
|
||||||
|
onError?: () => void;
|
||||||
|
};
|
||||||
|
destroyed = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
asset: AssetResponseDto,
|
||||||
|
sharedLink: SharedLinkResponseDto | undefined,
|
||||||
|
callbacks?: {
|
||||||
|
currentZoomFn: () => number;
|
||||||
|
onUrlChange?: (url: string) => void;
|
||||||
|
onImageReady?: () => void;
|
||||||
|
onError?: () => void;
|
||||||
|
},
|
||||||
|
imageLoader?: LoadImageFunction,
|
||||||
|
) {
|
||||||
|
imageManager.trackLoad(asset);
|
||||||
|
this.asset = asset;
|
||||||
|
this.callbacks = callbacks;
|
||||||
|
this.imageLoader = imageLoader;
|
||||||
|
this.thumbnailUrl = getAssetMediaUrl({ id: asset.id, cacheKey: asset.thumbhash, size: AssetMediaSize.Thumbnail });
|
||||||
|
this.previewUrl = getAssetUrl({ asset, sharedLink })!;
|
||||||
|
this.originalUrl = getAssetUrl({ asset, sharedLink, forceOriginal: true })!;
|
||||||
|
this.internalState.thumbnailUrl = this.thumbnailUrl;
|
||||||
|
this.sharedLink = sharedLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.imageLoader) {
|
||||||
|
throw new Error('Start requires imageLoader to be specified');
|
||||||
|
}
|
||||||
|
this.destroyFunctions.push(
|
||||||
|
this.imageLoader(
|
||||||
|
this.thumbnailUrl,
|
||||||
|
() => this.onThumbnailLoad(),
|
||||||
|
() => this.onThumbnailError(),
|
||||||
|
() => this.onThumbnailStart(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get state(): ImageLoaderState {
|
||||||
|
return this.internalState;
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldUpdateQuality(newQuality: ImageQuality): boolean {
|
||||||
|
const currentLevel = qualityOrder[this.internalState.quality];
|
||||||
|
const newLevel = qualityOrder[newQuality];
|
||||||
|
return newLevel > currentLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
onThumbnailStart() {
|
||||||
|
if (this.destroyed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.shouldUpdateQuality('loading-thumbnail')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.internalState.quality = 'loading-thumbnail';
|
||||||
|
}
|
||||||
|
|
||||||
|
onThumbnailLoad() {
|
||||||
|
if (this.destroyed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.shouldUpdateQuality('thumbnail')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.internalState.quality = 'thumbnail';
|
||||||
|
this.internalState.thumbnailImage = ImageStatus.Success;
|
||||||
|
this.callbacks?.onUrlChange?.(this.thumbnailUrl);
|
||||||
|
this.callbacks?.onImageReady?.();
|
||||||
|
this.triggerMainImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
onThumbnailError() {
|
||||||
|
if (this.destroyed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.internalState.hasError = true;
|
||||||
|
this.internalState.thumbnailUrl = undefined;
|
||||||
|
this.internalState.thumbnailImage = ImageStatus.Error;
|
||||||
|
this.callbacks?.onError?.();
|
||||||
|
this.triggerMainImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerMainImage() {
|
||||||
|
const wantsOriginal = (this.currentZoomFn?.() ?? 1) > 1;
|
||||||
|
return wantsOriginal ? this.triggerOriginal() : this.triggerPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerPreview() {
|
||||||
|
if (!this.previewUrl) {
|
||||||
|
// no preview, try original?
|
||||||
|
this.triggerOriginal();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.internalState.previewUrl) {
|
||||||
|
// Already triggered
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
this.internalState.hasError = false;
|
||||||
|
this.internalState.previewUrl = this.previewUrl;
|
||||||
|
if (this.imageLoader) {
|
||||||
|
this.destroyFunctions.push(
|
||||||
|
this.imageLoader(
|
||||||
|
this.previewUrl,
|
||||||
|
|
||||||
|
() => this.onPreviewLoad(),
|
||||||
|
() => this.onPreviewError(),
|
||||||
|
() => this.onPreviewStart(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPreviewStart() {
|
||||||
|
if (this.destroyed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.shouldUpdateQuality('loading-preview')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.internalState.quality = 'loading-preview';
|
||||||
|
}
|
||||||
|
|
||||||
|
onPreviewLoad() {
|
||||||
|
if (this.destroyed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.internalState.previewUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.shouldUpdateQuality('preview')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.internalState.quality = 'preview';
|
||||||
|
this.internalState.previewImage = ImageStatus.Success;
|
||||||
|
this.callbacks?.onUrlChange?.(this.previewUrl);
|
||||||
|
this.callbacks?.onImageReady?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
onPreviewError() {
|
||||||
|
if (this.destroyed || imageManager.isCanceled(this.asset)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.internalState.hasError = true;
|
||||||
|
this.internalState.previewImage = ImageStatus.Error;
|
||||||
|
this.internalState.previewUrl = undefined;
|
||||||
|
this.callbacks?.onError?.();
|
||||||
|
this.triggerOriginal();
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerOriginal() {
|
||||||
|
if (!this.originalUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.internalState.originalUrl) {
|
||||||
|
// Already triggered
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
this.internalState.hasError = false;
|
||||||
|
this.internalState.originalUrl = this.originalUrl;
|
||||||
|
|
||||||
|
if (this.imageLoader) {
|
||||||
|
this.destroyFunctions.push(
|
||||||
|
this.imageLoader(
|
||||||
|
this.originalUrl,
|
||||||
|
|
||||||
|
() => this.onOriginalLoad(),
|
||||||
|
() => this.onOriginalError(),
|
||||||
|
() => this.onOriginalStart(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onOriginalStart() {
|
||||||
|
if (this.destroyed || imageManager.isCanceled(this.asset)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.shouldUpdateQuality('loading-original')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.internalState.quality = 'loading-original';
|
||||||
|
}
|
||||||
|
|
||||||
|
onOriginalLoad() {
|
||||||
|
if (this.destroyed || imageManager.isCanceled(this.asset)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.internalState.originalUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.shouldUpdateQuality('original')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.internalState.quality = 'original';
|
||||||
|
this.internalState.originalImage = ImageStatus.Success;
|
||||||
|
this.callbacks?.onUrlChange?.(this.originalUrl);
|
||||||
|
this.callbacks?.onImageReady?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
onOriginalError() {
|
||||||
|
if (this.destroyed || imageManager.isCanceled(this.asset)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.internalState.hasError = true;
|
||||||
|
this.internalState.originalImage = ImageStatus.Error;
|
||||||
|
this.internalState.originalUrl = undefined;
|
||||||
|
this.callbacks?.onError?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.destroyed = true;
|
||||||
|
if (this.imageLoader) {
|
||||||
|
for (const destroy of this.destroyFunctions) {
|
||||||
|
destroy();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.cancel(this.asset);
|
||||||
|
}
|
||||||
|
cancel(asset: AssetResponseDto | undefined) {
|
||||||
|
imageManager.cancel(asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { scaleToFit } from '$lib/utils/container-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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user