diff --git a/i18n/en.json b/i18n/en.json index 7adbba3c88..8d593d9e87 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -993,12 +993,14 @@ "error": "Error", "error_change_sort_album": "Failed to change album sort order", "error_delete_face": "Error deleting face from asset", + "error_getting_asset_information": "Error getting asset information: {message}", "error_getting_places": "Error getting places", "error_loading_image": "Error loading image", "error_loading_partners": "Error loading partners: {error}", "error_saving_image": "Error: {error}", "error_tag_face_bounding_box": "Error tagging face - cannot get bounding box coordinates", "error_title": "Error - Something went wrong", + "error_while_navigating": "Error while navigating: {message}", "errors": { "cannot_navigate_next_asset": "Cannot navigate to the next asset", "cannot_navigate_previous_asset": "Cannot navigate to previous asset", diff --git a/web/src/lib/actions/thumbhash.ts b/web/src/lib/actions/thumbhash.ts index e49f04dbee..872d3d03bf 100644 --- a/web/src/lib/actions/thumbhash.ts +++ b/web/src/lib/actions/thumbhash.ts @@ -3,17 +3,27 @@ import { thumbHashToRGBA } from 'thumbhash'; /** * Renders a thumbnail onto a canvas from a base64 encoded hash. - * @param canvas - * @param param1 object containing the base64 encoded hash (base64Thumbhash: yourString) */ -export function thumbhash(canvas: HTMLCanvasElement, { base64ThumbHash }: { base64ThumbHash: string }) { - const ctx = canvas.getContext('2d'); - if (ctx) { - const { w, h, rgba } = thumbHashToRGBA(decodeBase64(base64ThumbHash)); - const pixels = ctx.createImageData(w, h); - canvas.width = w; - canvas.height = h; - pixels.data.set(rgba); - ctx.putImageData(pixels, 0, 0); - } +export function thumbhash(canvas: HTMLCanvasElement, options: { base64ThumbHash: string }) { + render(canvas, options); + + return { + update(newOptions: { base64ThumbHash: string }) { + render(canvas, newOptions); + }, + }; } + +const render = (canvas: HTMLCanvasElement, options: { base64ThumbHash: string }) => { + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + const { w, h, rgba } = thumbHashToRGBA(decodeBase64(options.base64ThumbHash)); + const pixels = ctx.createImageData(w, h); + canvas.width = w; + canvas.height = h; + pixels.data.set(rgba); + ctx.putImageData(pixels, 0, 0); +}; diff --git a/web/src/lib/actions/zoom-image.ts b/web/src/lib/actions/zoom-image.ts index e67d3e1928..36cce538d1 100644 --- a/web/src/lib/actions/zoom-image.ts +++ b/web/src/lib/actions/zoom-image.ts @@ -1,48 +1,42 @@ import { photoZoomState } from '$lib/stores/zoom-image.store'; -import { useZoomImageWheel } from '@zoom-image/svelte'; +import { createZoomImageWheel } from '@zoom-image/core'; import { get } from 'svelte/store'; export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => { - const { createZoomImage, zoomImageState, setZoomImageState } = useZoomImageWheel(); - - createZoomImage(node, { + const state = get(photoZoomState); + const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, + initialState: state, }); - const state = get(photoZoomState); - if (state) { - setZoomImageState(state); - } + const unsubscribes = [ + photoZoomState.subscribe((state) => zoomInstance.setState(state)), + zoomInstance.subscribe(({ state }) => { + photoZoomState.set(state); + }), + ]; - // Store original event handlers so we can prevent them when disabled - const wheelHandler = (event: WheelEvent) => { + const stopIfDisabled = (event: Event) => { if (options?.disabled) { event.stopImmediatePropagation(); } }; - const pointerDownHandler = (event: PointerEvent) => { - if (options?.disabled) { - event.stopImmediatePropagation(); - } - }; - - // Add handlers at capture phase with higher priority - node.addEventListener('wheel', wheelHandler, { capture: true }); - node.addEventListener('pointerdown', pointerDownHandler, { capture: true }); - - const unsubscribes = [photoZoomState.subscribe(setZoomImageState), zoomImageState.subscribe(photoZoomState.set)]; + node.addEventListener('wheel', stopIfDisabled, { capture: true }); + node.addEventListener('pointerdown', stopIfDisabled, { capture: true }); + node.style.overflow = 'visible'; return { update(newOptions?: { disabled?: boolean }) { options = newOptions; }, destroy() { - node.removeEventListener('wheel', wheelHandler, { capture: true }); - node.removeEventListener('pointerdown', pointerDownHandler, { capture: true }); for (const unsubscribe of unsubscribes) { unsubscribe(); } + node.removeEventListener('wheel', stopIfDisabled, { capture: true }); + node.removeEventListener('pointerdown', stopIfDisabled, { capture: true }); + zoomInstance.cleanup(); }, }; }; diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 9e08142cc7..3c1d562e03 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -245,33 +245,38 @@ cancelPreloadsBeforeNavigation(order); - void tracker.invoke(async () => { - let hasNext = false; + void tracker.invoke( + async () => { + let hasNext = false; - if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) { - hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); - if (!hasNext) { - const asset = await onRandom?.(); - if (asset) { - slideshowHistory.queue(asset); - hasNext = true; + if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) { + hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next(); + if (!hasNext) { + const asset = await onRandom?.(); + if (asset) { + slideshowHistory.queue(asset); + hasNext = true; + } } + } else { + hasNext = + order === 'previous' + ? await navigateToAsset(cursor.previousAsset) + : await navigateToAsset(cursor.nextAsset); } - } else { - hasNext = - order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset); - } - if ($slideshowState !== SlideshowState.PlaySlideshow) { - return; - } + if ($slideshowState !== SlideshowState.PlaySlideshow) { + return; + } - if (hasNext) { - $restartSlideshowProgress = true; - } else { - await handleStopSlideshow(); - } - }); + if (hasNext) { + $restartSlideshowProgress = true; + } else { + await handleStopSlideshow(); + } + }, + (error: Error) => $t('error_while_navigating', { values: { message: error.message } }), + ); }; // const showEditor = () => { diff --git a/web/src/lib/components/timeline/TimelineAssetViewer.svelte b/web/src/lib/components/timeline/TimelineAssetViewer.svelte index 06ff61d180..95cc55dd9e 100644 --- a/web/src/lib/components/timeline/TimelineAssetViewer.svelte +++ b/web/src/lib/components/timeline/TimelineAssetViewer.svelte @@ -11,10 +11,12 @@ import { handlePromiseError } from '$lib/utils'; import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions'; import { navigateToAsset } from '$lib/utils/asset-utils'; + import { handleErrorAsync } from '$lib/utils/handle-error'; import { navigate } from '$lib/utils/navigation'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, getAssetInfo } from '@immich/sdk'; import { onDestroy, onMount, untrack } from 'svelte'; + import { t } from 'svelte-i18n'; let { asset: viewingAsset, gridScrollTarget } = assetViewingStore; @@ -38,28 +40,27 @@ person, }: Props = $props(); - const getNextAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => { - const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset); - if (earlierTimelineAsset) { - const asset = await assetCacheManager.getAsset({ ...authManager.params, id: earlierTimelineAsset.id }); - if (preload) { - // also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete - void getNextAsset(asset, false); - } - return asset; - } + const getAsset = (id: string) => { + return handleErrorAsync( + () => assetCacheManager.getAsset({ ...authManager.params, id }), + (error: Error) => $t('error_getting_asset_information', { values: { message: error.message } }), + ); }; - const getPreviousAsset = async (currentAsset: AssetResponseDto, preload: boolean = true) => { - const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset); - if (laterTimelineAsset) { - const asset = await assetCacheManager.getAsset({ ...authManager.params, id: laterTimelineAsset.id }); - if (preload) { - // also pre-cache an extra one, to pre-cache these assetInfos for the next nav after this one is complete - void getPreviousAsset(asset, false); - } - return asset; + const getNextAsset = async (currentAsset: AssetResponseDto) => { + const earlierTimelineAsset = await timelineManager.getEarlierAsset(currentAsset); + if (!earlierTimelineAsset) { + return; } + return getAsset(earlierTimelineAsset.id); + }; + + const getPreviousAsset = async (currentAsset: AssetResponseDto) => { + const laterTimelineAsset = await timelineManager.getLaterAsset(currentAsset); + if (!laterTimelineAsset) { + return; + } + return getAsset(laterTimelineAsset.id); }; let assetCursor = $state({ @@ -87,10 +88,12 @@ const handleRandom = async () => { const randomAsset = await timelineManager.getRandomAsset(); - if (randomAsset) { - await navigate({ targetRoute: 'current', assetId: randomAsset.id }); - return { id: randomAsset.id }; + if (!randomAsset) { + return; } + + await navigate({ targetRoute: 'current', assetId: randomAsset.id }); + return { id: randomAsset.id }; }; const handleClose = async (asset: { id: string }) => { @@ -180,12 +183,14 @@ }; const handleUndoDelete = async (assets: TimelineAsset[]) => { timelineManager.upsertAssets(assets); - if (assets.length > 0) { - const restoredAsset = assets[0]; - const asset = await getAssetInfo({ ...authManager.params, id: restoredAsset.id }); - assetViewingStore.setAsset(asset); - await navigate({ targetRoute: 'current', assetId: restoredAsset.id }); + if (assets.length === 0) { + return; } + + const restoredAsset = assets[0]; + const asset = await getAssetInfo({ ...authManager.params, id: restoredAsset.id }); + assetViewingStore.setAsset(asset); + await navigate({ targetRoute: 'current', assetId: restoredAsset.id }); }; const handleUpdateOrUpload = (asset: AssetResponseDto) => { diff --git a/web/src/lib/stores/zoom-image.store.ts b/web/src/lib/stores/zoom-image.store.ts index 2c6ee18972..63c20b2c21 100644 --- a/web/src/lib/stores/zoom-image.store.ts +++ b/web/src/lib/stores/zoom-image.store.ts @@ -1,4 +1,25 @@ import type { ZoomImageWheelState } from '@zoom-image/core'; -import { writable } from 'svelte/store'; +import { derived, writable } from 'svelte/store'; -export const photoZoomState = writable(); +export const photoZoomState = writable({ + currentRotation: 0, + currentZoom: 1, + enable: true, + currentPositionX: 0, + currentPositionY: 0, +}); + +export const photoZoomTransform = derived( + photoZoomState, + ($state) => `translate(${$state.currentPositionX}px,${$state.currentPositionY}px) scale(${$state.currentZoom})`, +); + +export const resetZoomState = () => { + photoZoomState.set({ + currentRotation: 0, + currentZoom: 1, + enable: true, + currentPositionX: 0, + currentPositionY: 0, + }); +}; diff --git a/web/src/lib/utils/handle-error.ts b/web/src/lib/utils/handle-error.ts index 6db28123c2..e9a94730f8 100644 --- a/web/src/lib/utils/handle-error.ts +++ b/web/src/lib/utils/handle-error.ts @@ -19,12 +19,17 @@ export function getServerErrorMessage(error: unknown) { return data?.message || error.message; } -export function handleError(error: unknown, message: string) { - if ((error as Error)?.name === 'AbortError') { +export function standardizeError(error: unknown) { + return error instanceof Error ? error : new Error(String(error)); +} + +export function handleError(error: unknown, localizedMessage: string) { + const standardizedError = standardizeError(error); + if (standardizedError.name === 'AbortError') { return; } - console.error(`[handleError]: ${message}`, error, (error as Error)?.stack); + console.error(`[handleError]: ${standardizedError}`, error, standardizedError.stack); try { let serverMessage = getServerErrorMessage(error); @@ -32,13 +37,25 @@ export function handleError(error: unknown, message: string) { serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`; } - const errorMessage = serverMessage || message; + const errorMessage = serverMessage || localizedMessage; toastManager.danger(errorMessage); return errorMessage; } catch (error) { console.error(error); - return message; + return localizedMessage; + } +} + +export async function handleErrorAsync( + fn: () => Promise, + messageGenerator: (error: Error) => string, +): Promise { + try { + return await fn(); + } catch (error: unknown) { + handleError(error, messageGenerator(standardizeError(error))); + return undefined; } } diff --git a/web/src/lib/utils/invocationTracker.ts b/web/src/lib/utils/invocationTracker.ts index 7d42d8c613..1d0cd13f1a 100644 --- a/web/src/lib/utils/invocationTracker.ts +++ b/web/src/lib/utils/invocationTracker.ts @@ -1,3 +1,5 @@ +import { handleError, standardizeError } from '$lib/utils/handle-error'; + /** * Tracks the state of asynchronous invocations to handle race conditions and stale operations. * This class helps manage concurrent operations by tracking which invocations are active @@ -51,10 +53,12 @@ export class InvocationTracker { return this.invocationsStarted !== this.invocationsEnded; } - async invoke(invocable: () => Promise) { + async invoke(invocable: () => Promise, messageGenerator: (error: Error) => string) { const invocation = this.startInvocation(); try { return await invocable(); + } catch (error: unknown) { + handleError(error, messageGenerator(standardizeError(error))); } finally { invocation.endInvocation(); } diff --git a/web/src/lib/utils/people-utils.ts b/web/src/lib/utils/people-utils.ts index 5fb03842b8..28425b948f 100644 --- a/web/src/lib/utils/people-utils.ts +++ b/web/src/lib/utils/people-utils.ts @@ -24,11 +24,11 @@ export interface boundingBox { export const getBoundingBox = ( faces: Faces[], zoom: ZoomImageWheelState, - photoViewer: HTMLImageElement | null, + photoViewer: HTMLImageElement | undefined, ): boundingBox[] => { const boxes: boundingBox[] = []; - if (photoViewer === null) { + if (!photoViewer) { return boxes; } const clientHeight = photoViewer.clientHeight; @@ -93,7 +93,7 @@ export const zoomImageToBase64 = async ( image = img; } - if (image === null) { + if (!image) { return null; } const { boundingBoxX1: x1, boundingBoxX2: x2, boundingBoxY1: y1, boundingBoxY2: y2, imageWidth, imageHeight } = face; @@ -121,11 +121,9 @@ export const zoomImageToBase64 = async ( canvas.height = faceHeight; const context = canvas.getContext('2d'); - if (context) { - context.drawImage(faceImage, coordinates.x1, coordinates.y1, faceWidth, faceHeight, 0, 0, faceWidth, faceHeight); - - return canvas.toDataURL(); - } else { + if (!context) { return null; } + context.drawImage(faceImage, coordinates.x1, coordinates.y1, faceWidth, faceHeight, 0, 0, faceWidth, faceHeight); + return canvas.toDataURL(); };