From 7166377a23a9f04f014fc87e02284aea02fec347 Mon Sep 17 00:00:00 2001 From: midzelis Date: Fri, 27 Mar 2026 22:29:44 +0000 Subject: [PATCH] refactor(web): replace face hover with overlay elements and migrate people store to manager Replace listener-based mouse hit-testing with per-face overlay elements for face hover detection, and migrate people store state to AssetViewerManager. - Render invisible divs for each face as pointer-event hover targets - Show white border + name tooltip on active hover - Add face bounding box markers to photo sphere viewer (panorama support) - Migrate boundingBoxesArray/showingHiddenPeople from people.store.ts to AssetViewerManager - Delete people.store.ts (Faces interface moved to asset-viewer-manager) Change-Id: Ied4c3894cc58874ed65725dcb8cc7ce86a6a6964 --- .../asset-viewer/detail-panel.svelte | 20 ++-- .../photo-sphere-viewer-adapter.svelte | 17 ++-- .../asset-viewer/photo-viewer.svelte | 91 +++++++++---------- .../faces-page/person-side-panel.svelte | 9 +- .../managers/asset-viewer-manager.svelte.ts | 37 ++++++++ web/src/lib/stores/people.store.ts | 13 --- web/src/lib/utils/people-utils.spec.ts | 2 +- web/src/lib/utils/people-utils.ts | 2 +- 8 files changed, 104 insertions(+), 87 deletions(-) delete mode 100644 web/src/lib/stores/people.store.ts diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 8e1ec4acf7..3ac6418983 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -10,7 +10,6 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { Route } from '$lib/route'; - import { boundingBoxesArray } from '$lib/stores/people.store'; import { locale } from '$lib/stores/preferences.store'; import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils'; import { delay, getDimensions } from '$lib/utils/asset-utils'; @@ -56,7 +55,6 @@ let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId); let people = $derived(asset.people || []); let unassignedFaces = $derived(asset.unassignedFaces || []); - let showingHiddenPeople = $state(false); let latlng = $derived( (() => { const lat = asset.exifInfo?.latitude; @@ -173,12 +171,12 @@ {#if people.some((person) => person.isHidden)} (showingHiddenPeople = !showingHiddenPeople)} + onclick={() => assetViewerManager.toggleHiddenPeople()} /> {/if} {#each people as person, index (person.id)} - {#if showingHiddenPeople || !person.isHidden} - {@const isHighlighted = people[index].faces.some((f) => $boundingBoxesArray.some((b) => b.id === f.id))} + {#if assetViewerManager.isShowingHiddenPeople || !person.isHidden} + {@const isHighlighted = people[index].faces.some((f) => + assetViewerManager.highlightedFaces.some((b) => b.id === f.id), + )} ($boundingBoxesArray = people[index].faces)} - onblur={() => ($boundingBoxesArray = [])} - onmouseover={() => ($boundingBoxesArray = people[index].faces)} - onmouseleave={() => ($boundingBoxesArray = [])} + onfocus={() => assetViewerManager.setHighlightedFaces(people[index].faces)} + onblur={() => assetViewerManager.clearHighlightedFaces()} + onpointerenter={() => assetViewerManager.setHighlightedFaces(people[index].faces)} + onpointerleave={() => assetViewerManager.clearHighlightedFaces()} >
import { shortcuts } from '$lib/actions/shortcut'; import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte'; - import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; + import { assetViewerManager, type Faces } from '$lib/managers/asset-viewer-manager.svelte'; import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte'; - import { boundingBoxesArray, type Faces } from '$lib/stores/people.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { calculateBoundingBoxMatrix, getOcrBoundingBoxes, type Point } from '$lib/utils/ocr-utils'; import { @@ -55,14 +54,9 @@ let viewer: Viewer; let animationInProgress: { cancel: () => void } | undefined; - let previousFaces: Faces[] = []; - const boundingBoxesUnsubscribe = boundingBoxesArray.subscribe((faces: Faces[]) => { - // Debounce; don't do anything when the data didn't actually change. - if (faces === previousFaces) { - return; - } - previousFaces = faces; + $effect(() => { + const faces: Faces[] = assetViewerManager.highlightedFaces; if (animationInProgress) { animationInProgress.cancel(); @@ -105,7 +99,7 @@ textureX: x, textureY: y, zoom: Math.min(viewer.getZoomLevel(), 75), - speed: 500, // duration in ms + speed: 500, }); } }); @@ -247,7 +241,8 @@ if (viewer) { viewer.destroy(); } - boundingBoxesUnsubscribe(); + assetViewerManager.clearHighlightedFaces(); + assetViewerManager.hideHiddenPeople(); assetViewerManager.zoom = 1; }); diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 775e9017ce..aa90d08ec4 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -6,10 +6,9 @@ import Thumbhash from '$lib/components/Thumbhash.svelte'; import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte'; import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte'; - import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; + import { assetViewerManager, type Faces } from '$lib/managers/asset-viewer-manager.svelte'; import { castManager } from '$lib/managers/cast-manager.svelte'; import { ocrManager } from '$lib/stores/ocr.svelte'; - import { boundingBoxesArray, type Faces } from '$lib/stores/people.store'; import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { handlePromiseError } from '$lib/utils'; import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils'; @@ -50,12 +49,13 @@ untrack(() => { assetViewerManager.resetZoomState(); visibleImageReady = false; - $boundingBoxesArray = []; + assetViewerManager.clearHighlightedFaces(); }); }); onDestroy(() => { - $boundingBoxesArray = []; + assetViewerManager.clearHighlightedFaces(); + assetViewerManager.hideHiddenPeople(); }); let containerWidth = $state(0); @@ -74,15 +74,13 @@ return scaleToFit(getNaturalSize(assetViewerManager.imgRef), { width: containerWidth, height: containerHeight }); }); - const highlightedBoxes = $derived(getBoundingBox($boundingBoxesArray, overlaySize)); + const highlightedBoxes = $derived(getBoundingBox(assetViewerManager.highlightedFaces, overlaySize)); const isHighlighting = $derived(highlightedBoxes.length > 0); let visibleBoxes = $state([]); - let visibleBoundingBoxes = $state([]); $effect(() => { if (isHighlighting) { visibleBoxes = highlightedBoxes; - visibleBoundingBoxes = $boundingBoxesArray; } }); @@ -160,6 +158,9 @@ // eslint-disable-next-line svelte/prefer-svelte-reactivity const map = new Map(); for (const person of asset.people ?? []) { + if (person.isHidden && !assetViewerManager.isShowingHiddenPeople) { + continue; + } for (const face of person.faces ?? []) { map.set(face, person.name); } @@ -168,36 +169,31 @@ }); const faces = $derived(Array.from(faceToNameMap.keys())); - - const handleImageMouseMove = (event: MouseEvent) => { - $boundingBoxesArray = []; - if (!assetViewerManager.imgRef || !element || assetViewerManager.isFaceEditMode || ocrManager.showOverlay) { - return; + const boundingBoxes = $derived.by(() => { + if (assetViewerManager.isFaceEditMode || ocrManager.showOverlay) { + return []; } - const natural = getNaturalSize(assetViewerManager.imgRef); - const scaled = scaleToFit(natural, container); - const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState; + const knownBoxes = getBoundingBox(faces, overlaySize); + const result = knownBoxes.map((box, index) => ({ + ...box, + face: faces[index], + name: faceToNameMap.get(faces[index]), + })); - const contentOffsetX = (container.width - scaled.width) / 2; - const contentOffsetY = (container.height - scaled.height) / 2; - - const containerRect = element.getBoundingClientRect(); - const mouseX = (event.clientX - containerRect.left - contentOffsetX * currentZoom - currentPositionX) / currentZoom; - const mouseY = (event.clientY - containerRect.top - contentOffsetY * currentZoom - currentPositionY) / currentZoom; - - const faceBoxes = getBoundingBox(faces, overlaySize); - - 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]); - } + if (assetViewerManager.highlightedFaces.length === 0) { + return result; } - }; - const handleImageMouseLeave = () => { - $boundingBoxesArray = []; - }; + const knownIds = new Set(faces.map((f) => f.id)); + const unassignedFaces = assetViewerManager.highlightedFaces.filter((f) => !knownIds.has(f.id)); + const unassignedBoxes = getBoundingBox(unassignedFaces, overlaySize); + for (let i = 0; i < unassignedBoxes.length; i++) { + result.push({ ...unassignedBoxes[i], face: unassignedFaces[i], name: undefined }); + } + + return result; + }); @@ -218,8 +214,6 @@ bind:clientHeight={containerHeight} role="presentation" ondblclick={onZoom} - onmousemove={handleImageMouseMove} - onmouseleave={handleImageMouseLeave} use:zoomImageAction={{ zoomTarget: adaptiveImage }} {...useSwipe((event) => onSwipe?.(event))} > @@ -261,22 +255,27 @@ - {#each visibleBoxes as boundingbox, index (boundingbox.id)} -
- {#if faceToNameMap.get(visibleBoundingBoxes[index])} +
+ {#each boundingBoxes as boundingbox (boundingbox.id)} + {@const isActive = assetViewerManager.highlightedFaces.some((f) => f.id === boundingbox.id)} + +
assetViewerManager.setHighlightedFaces([boundingbox.face])} + onpointerleave={() => assetViewerManager.clearHighlightedFaces()} + > + {#if isActive && boundingbox.name} {/if} - {/each} -
+ + {/each} {#each ocrBoxes as ocrBox (ocrBox.id)} diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte index fa5afadfa6..e34d861310 100644 --- a/web/src/lib/components/faces-page/person-side-panel.svelte +++ b/web/src/lib/components/faces-page/person-side-panel.svelte @@ -4,7 +4,6 @@ import { timeBeforeShowLoadingSpinner } from '$lib/constants'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; - import { boundingBoxesArray } from '$lib/stores/people.store'; import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; import { zoomImageToBase64 } from '$lib/utils/people-utils'; @@ -239,15 +238,15 @@ {:else} {#each peopleWithFaces as face, index (face.id)} {@const personName = face.person ? face.person?.name : $t('face_unassigned')} - {@const isHighlighted = $boundingBoxesArray.some((b) => b.id === face.id)} + {@const isHighlighted = assetViewerManager.highlightedFaces.some((b) => b.id === face.id)}
($boundingBoxesArray = [peopleWithFaces[index]])} - onmouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])} - onmouseleave={() => ($boundingBoxesArray = [])} + onfocus={() => assetViewerManager.setHighlightedFaces([peopleWithFaces[index]])} + onmouseover={() => assetViewerManager.setHighlightedFaces([peopleWithFaces[index]])} + onmouseleave={() => assetViewerManager.clearHighlightedFaces()} >
{#if selectedPersonToCreate[face.id]} diff --git a/web/src/lib/managers/asset-viewer-manager.svelte.ts b/web/src/lib/managers/asset-viewer-manager.svelte.ts index 72da457a91..ac6502bf00 100644 --- a/web/src/lib/managers/asset-viewer-manager.svelte.ts +++ b/web/src/lib/managers/asset-viewer-manager.svelte.ts @@ -8,6 +8,16 @@ import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; import type { ZoomImageWheelState } from '@zoom-image/core'; import { cubicOut } from 'svelte/easing'; +export interface Faces { + id: string; + imageHeight: number; + imageWidth: number; + boundingBoxX1: number; + boundingBoxX2: number; + boundingBoxY1: number; + boundingBoxY2: number; +} + const isShowDetailPanel = new PersistedLocalStorage('asset-viewer-state', false); const isShowAssetPath = new PersistedLocalStorage('asset-viewer-show-path', false); @@ -48,6 +58,8 @@ class AssetViewerManager extends BaseEventManager { #isEditFacesPanelOpen = $state(false); #viewingAssetStoreState = $state(); #viewState = $state(false); + #highlightedFaces = $state([]); + #showingHiddenPeople = $state(false); gridScrollTarget = $state(); get asset() { @@ -209,6 +221,31 @@ class AssetViewerManager extends BaseEventManager { this.closeFaceEditMode(); this.closeEditFacesPanel(); } + + get highlightedFaces() { + return this.#highlightedFaces; + } + + setHighlightedFaces(faces: Faces[]) { + this.#highlightedFaces = faces; + } + + clearHighlightedFaces() { + this.#highlightedFaces = []; + } + + get isShowingHiddenPeople() { + return this.#showingHiddenPeople; + } + + toggleHiddenPeople() { + this.#showingHiddenPeople = !this.#showingHiddenPeople; + } + + hideHiddenPeople() { + this.#showingHiddenPeople = false; + } + setAsset(asset: AssetResponseDto) { this.#viewingAssetStoreState = asset; this.#viewState = true; diff --git a/web/src/lib/stores/people.store.ts b/web/src/lib/stores/people.store.ts deleted file mode 100644 index 34e927cf36..0000000000 --- a/web/src/lib/stores/people.store.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { writable } from 'svelte/store'; - -export interface Faces { - id: string; - imageHeight: number; - imageWidth: number; - boundingBoxX1: number; - boundingBoxX2: number; - boundingBoxY1: number; - boundingBoxY2: number; -} - -export const boundingBoxesArray = writable([]); diff --git a/web/src/lib/utils/people-utils.spec.ts b/web/src/lib/utils/people-utils.spec.ts index f27a1855b5..0601f5022a 100644 --- a/web/src/lib/utils/people-utils.spec.ts +++ b/web/src/lib/utils/people-utils.spec.ts @@ -1,4 +1,4 @@ -import type { Faces } from '$lib/stores/people.store'; +import type { Faces } from '$lib/managers/asset-viewer-manager.svelte'; import type { Size } from '$lib/utils/container-utils'; import { getBoundingBox } from '$lib/utils/people-utils'; diff --git a/web/src/lib/utils/people-utils.ts b/web/src/lib/utils/people-utils.ts index f7f9f4ee42..16ddfc4add 100644 --- a/web/src/lib/utils/people-utils.ts +++ b/web/src/lib/utils/people-utils.ts @@ -1,4 +1,4 @@ -import type { Faces } from '$lib/stores/people.store'; +import type { Faces } from '$lib/managers/asset-viewer-manager.svelte'; import { getAssetMediaUrl } from '$lib/utils'; import { mapNormalizedRectToContent, type Rect, type Size } from '$lib/utils/container-utils'; import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk';