fix(web): preserve stacked asset selection when tagging faces

Change-Id: Iec1507560f99f2e9433bd5cf6b460b176a6a6964
This commit is contained in:
midzelis
2026-03-07 21:14:45 +00:00
parent 2015f95ff5
commit 1d9308eb40
5 changed files with 70 additions and 47 deletions
@@ -9,6 +9,7 @@
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import { AssetAction, ProjectionType } from '$lib/constants'; import { AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte'; import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
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';
@@ -99,9 +100,10 @@
const stackSelectedThumbnailSize = 65; const stackSelectedThumbnailSize = 65;
let previewStackedAsset: AssetResponseDto | undefined = $state(); let previewStackedAsset: AssetResponseDto | undefined = $state();
let stack: StackResponseDto | null = $state(null); let stack: StackResponseDto | undefined = $state();
let selectedStackAsset: AssetResponseDto | undefined = $state();
const asset = $derived(previewStackedAsset ?? cursor.current); 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();
@@ -114,17 +116,29 @@
playOriginalVideo = value; playOriginalVideo = value;
}; };
const selectStackedAsset = async (id: string) => {
ocrManager.clear();
selectedStackAsset = await assetCacheManager.getAsset({ id });
if (!sharedLink) {
await ocrManager.getAssetOcr(id);
}
};
const refreshStack = async () => { const refreshStack = async () => {
if (authManager.isSharedLink || !withStacked) { if (authManager.isSharedLink || !withStacked) {
return; return;
} }
if (asset.stack) { if (!cursor.current.stack) {
stack = await getStack({ id: asset.stack.id }); stack = undefined;
selectedStackAsset = undefined;
return;
} }
if (!stack?.assets.some(({ id }) => id === asset.id)) { stack = await getStack({ id: cursor.current.stack.id });
stack = null; const primaryAsset = stack?.assets.find(({ id }) => id === stack?.primaryAssetId);
if (primaryAsset) {
await selectStackedAsset(primaryAsset.id);
} }
}; };
@@ -182,11 +196,21 @@
onClose?.(asset.id); onClose?.(asset.id);
}; };
const refreshPreservingSelection = async () => {
const id = asset.id;
assetCacheManager.invalidateAsset(id);
if (selectedStackAsset) {
await selectStackedAsset(id);
} else {
const refreshedAsset = await assetCacheManager.getAsset({ id });
assetViewerManager.setAsset(refreshedAsset);
}
onAssetChange?.(asset);
};
const closeEditor = async () => { const closeEditor = async () => {
if (editManager.hasAppliedEdits) { if (editManager.hasAppliedEdits) {
const refreshedAsset = await getAssetInfo({ id: asset.id }); await refreshPreservingSelection();
onAssetChange?.(refreshedAsset);
assetViewerManager.setAsset(refreshedAsset);
} }
assetViewerManager.closeEditor(); assetViewerManager.closeEditor();
}; };
@@ -285,10 +309,6 @@
} }
}; };
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
};
const handlePreAction = (action: Action) => { const handlePreAction = (action: Action) => {
preAction?.(action); preAction?.(action);
}; };
@@ -301,7 +321,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];
} }
@@ -309,7 +329,7 @@
} }
case AssetAction.STACK: case AssetAction.STACK:
case AssetAction.SET_STACK_PRIMARY_ASSET: { case AssetAction.SET_STACK_PRIMARY_ASSET: {
stack = action.stack; stack = action.stack ?? undefined;
break; break;
} }
case AssetAction.SET_PERSON_FEATURED_PHOTO: { case AssetAction.SET_PERSON_FEATURED_PHOTO: {
@@ -368,7 +388,7 @@
$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()));
}); });
@@ -533,7 +553,12 @@
{:else if viewerKind === 'CropArea'} {:else if viewerKind === 'CropArea'}
<CropArea {asset} /> <CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'} {:else if viewerKind === 'PhotoViewer'}
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} /> <PhotoViewer
cursor={{ ...cursor, current: asset }}
{sharedLink}
{onSwipe}
onTagFace={refreshPreservingSelection}
/>
{:else if viewerKind === 'VideoViewer'} {:else if viewerKind === 'VideoViewer'}
<VideoViewer <VideoViewer
{asset} {asset}
@@ -586,7 +611,7 @@
translate="yes" translate="yes"
> >
{#if showDetailPanel} {#if showDetailPanel}
<DetailPanel {asset} currentAlbum={album} /> <DetailPanel {asset} currentAlbum={album} onRefreshPeople={refreshPreservingSelection} />
{:else if assetViewerManager.isShowEditor} {:else if assetViewerManager.isShowEditor}
<EditorPanel {asset} onClose={closeEditor} /> <EditorPanel {asset} onClose={closeEditor} />
{/if} {/if}
@@ -598,27 +623,24 @@
<div id="stack-slideshow" class="pointer-events-none absolute bottom-0 col-span-4 col-start-1 w-full"> <div id="stack-slideshow" class="pointer-events-none absolute bottom-0 col-span-4 col-start-1 w-full">
<div class="no-wrap horizontal-scrollbar relative flex flex-row overflow-x-auto overflow-y-hidden"> <div class="no-wrap horizontal-scrollbar relative flex flex-row overflow-x-auto overflow-y-hidden">
{#each stackedAssets as stackedAsset (stackedAsset.id)} {#each stackedAssets as stackedAsset (stackedAsset.id)}
{@const isSelected = stackedAsset.id === (selectedStackAsset?.id ?? cursor.current.id)}
<div <div
class={['pointer-events-auto relative inline-block px-1 pb-2 transition-all']} class={['pointer-events-auto relative inline-block px-1 pb-2 transition-all']}
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'} style:bottom={isSelected ? '0' : '-10px'}
> >
<Thumbnail <Thumbnail
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }} imageClass={{ 'border-2 border-white': isSelected }}
brokenAssetClass="text-xs" brokenAssetClass="text-xs"
dimmed={stackedAsset.id !== asset.id} dimmed={!isSelected}
asset={toTimelineAsset(stackedAsset)} asset={toTimelineAsset(stackedAsset)}
onClick={() => { onClick={() => selectStackedAsset(stackedAsset.id)}
cursor.current = stackedAsset;
previewStackedAsset = undefined;
}}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
readonly readonly
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize} thumbnailSize={isSelected ? stackSelectedThumbnailSize : stackThumbnailSize}
showStackedIcon={false} showStackedIcon={false}
disableLinkMouseOver disableLinkMouseOver
/> />
{#if stackedAsset.id === asset.id} {#if isSelected}
<div class="flex w-full place-content-center place-items-center"> <div class="flex w-full place-content-center place-items-center">
<div class="mt-0.5 flex size-2 rounded-full bg-white"></div> <div class="mt-0.5 flex size-2 rounded-full bg-white"></div>
</div> </div>
@@ -16,13 +16,7 @@
import { getByteUnitString } from '$lib/utils/byte-units'; import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { getParentPath } from '$lib/utils/tree-utils'; import { getParentPath } from '$lib/utils/tree-utils';
import { import { AssetMediaSize, getAllAlbums, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
AssetMediaSize,
getAllAlbums,
getAssetInfo,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui'; import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js'; import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
@@ -37,9 +31,10 @@
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
currentAlbum?: AlbumResponseDto | null; currentAlbum?: AlbumResponseDto | null;
onRefreshPeople?: () => Promise<void>;
} }
let { asset, currentAlbum = null }: Props = $props(); let { asset, currentAlbum = null, onRefreshPeople }: Props = $props();
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId); let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
let latlng = $derived( let latlng = $derived(
@@ -94,11 +89,6 @@
return undefined; return undefined;
}; };
const handleRefreshPeople = async () => {
asset = await getAssetInfo({ id: asset.id });
assetViewerManager.closeEditFacesPanel();
};
const getAssetFolderHref = (asset: AssetResponseDto) => { const getAssetFolderHref = (asset: AssetResponseDto) => {
// Remove the last part of the path to get the parent path // Remove the last part of the path to get the parent path
return Route.folders({ path: getParentPath(asset.originalPath) }); return Route.folders({ path: getParentPath(asset.originalPath) });
@@ -385,6 +375,6 @@
assetId={asset.id} assetId={asset.id}
assetType={asset.type} assetType={asset.type}
onClose={() => assetViewerManager.closeEditFacesPanel()} onClose={() => assetViewerManager.closeEditFacesPanel()}
onRefresh={handleRefreshPeople} onRefresh={() => void onRefreshPeople?.()}
/> />
{/if} {/if}
@@ -30,9 +30,10 @@
onReady?: () => void; onReady?: () => void;
onError?: () => void; onError?: () => void;
onSwipe?: (event: SwipeCustomEvent) => void; onSwipe?: (event: SwipeCustomEvent) => void;
onTagFace?: () => Promise<void>;
}; };
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props(); let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe, onTagFace }: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore; const { slideshowState, slideshowLook } = slideshowStore;
const asset = $derived(cursor.current); const asset = $derived(cursor.current);
@@ -285,6 +286,12 @@
</AdaptiveImage> </AdaptiveImage>
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef} {#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} /> <FaceEditor
htmlElement={assetViewerManager.imgRef}
{containerWidth}
{containerHeight}
assetId={asset.id}
{onTagFace}
/>
{/if} {/if}
</div> </div>
@@ -18,9 +18,10 @@
containerWidth: number; containerWidth: number;
containerHeight: number; containerHeight: number;
assetId: string; assetId: string;
onTagFace?: () => Promise<void>;
}; };
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props(); let { htmlElement, containerWidth, containerHeight, assetId, onTagFace }: Props = $props();
let canvasEl: HTMLCanvasElement | undefined = $state(); let canvasEl: HTMLCanvasElement | undefined = $state();
let canvas: Canvas | undefined = $state(); let canvas: Canvas | undefined = $state();
@@ -325,7 +326,7 @@
}, },
}); });
await assetViewerManager.setAssetId(assetId); await onTagFace?.();
} catch (error) { } catch (error) {
handleError(error, 'Error tagging face'); handleError(error, 'Error tagging face');
} finally { } finally {
@@ -178,7 +178,10 @@
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id); peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
await assetViewerManager.setAssetId(assetId); onRefresh();
if (peopleWithFaces.length === 0) {
onClose();
}
} catch (error) { } catch (error) {
handleError(error, $t('error_delete_face')); handleError(error, $t('error_delete_face'));
} }