From 488329d8621a8719c735911019a3a41322ace1b2 Mon Sep 17 00:00:00 2001 From: midzelis Date: Sun, 21 Sep 2025 23:14:59 +0000 Subject: [PATCH] refactor(web): extract timeline selection logic into SelectableSegment and SelectableDay components - Move asset selection, range selection, and keyboard interaction logic to SelectableSegment - Extract day group selection logic to SelectableDay component - Simplify Timeline component by removing selection-related state and handlers - Fix scroll compensation handling with dedicated while loop - Remove unused keyboard handlers from Scrubber component --- .../assets/thumbnail/thumbnail.svelte | 1 + .../timeline/AssetSelectionController.svelte | 189 ++++++++++ .../timeline/DaySelectionController.svelte | 62 ++++ .../lib/components/timeline/Timeline.svelte | 334 +++--------------- .../[[assetId=id]]/+page.svelte | 2 +- .../[[assetId=id]]/+page.svelte | 2 +- .../(user)/utilities/geolocation/+page.svelte | 19 +- 7 files changed, 312 insertions(+), 297 deletions(-) create mode 100644 web/src/lib/components/timeline/AssetSelectionController.svelte create mode 100644 web/src/lib/components/timeline/DaySelectionController.svelte diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 1047f4a2df..2a66669afc 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -121,6 +121,7 @@ const onMouseLeave = () => { mouseOver = false; + onMouseEvent?.({ isMouseOver: false, selectedGroupIndex: groupIndex }); }; let timer: ReturnType | null = null; diff --git a/web/src/lib/components/timeline/AssetSelectionController.svelte b/web/src/lib/components/timeline/AssetSelectionController.svelte new file mode 100644 index 0000000000..e93b8e63c8 --- /dev/null +++ b/web/src/lib/components/timeline/AssetSelectionController.svelte @@ -0,0 +1,189 @@ + + + + +{@render content({ + onAssetOpen: handleOnAssetOpen, + onAssetSelect: (asset) => { + void handleSelectAssets(asset); + }, + onAssetHover: handleOnHover, +})} diff --git a/web/src/lib/components/timeline/DaySelectionController.svelte b/web/src/lib/components/timeline/DaySelectionController.svelte new file mode 100644 index 0000000000..96f94247d3 --- /dev/null +++ b/web/src/lib/components/timeline/DaySelectionController.svelte @@ -0,0 +1,62 @@ + + +{@render content({ + onDayGroupSelect, + onDayGroupAssetSelect, +})} diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index 714b7bb4b6..af8702efb8 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -3,6 +3,8 @@ import { page } from '$app/state'; import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer'; import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; + import AssetSelectionController from '$lib/components/timeline/AssetSelectionController.svelte'; + import DaySelectionController from '$lib/components/timeline/DaySelectionController.svelte'; import Month from '$lib/components/timeline/Month.svelte'; import Scrubber from '$lib/components/timeline/Scrubber.svelte'; import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte'; @@ -11,17 +13,14 @@ import HotModuleReload from '$lib/elements/HotModuleReload.svelte'; import Portal from '$lib/elements/Portal.svelte'; import Skeleton from '$lib/elements/Skeleton.svelte'; - import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte'; import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types'; - import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte'; - import { isAssetViewerRoute, navigate } from '$lib/utils/navigation'; + import { isAssetViewerRoute } from '$lib/utils/navigation'; import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util'; import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk'; import { DateTime } from 'luxon'; @@ -51,22 +50,12 @@ album?: AlbumResponseDto | null; person?: PersonResponseDto | null; isShowDeleteConfirmation?: boolean; - onSelect?: (asset: TimelineAsset) => void; + onAssetOpen?: (asset: TimelineAsset, defaultAssetOpen: () => void) => void; + onAssetSelect?: (asset: TimelineAsset) => void; onEscape?: () => void; children?: Snippet; empty?: Snippet; customThumbnailLayout?: Snippet<[TimelineAsset]>; - onThumbnailClick?: ( - asset: TimelineAsset, - timelineManager: TimelineManager, - dayGroup: DayGroup, - onClick: ( - timelineManager: TimelineManager, - assets: TimelineAsset[], - groupTitle: string, - asset: TimelineAsset, - ) => void, - ) => void; } let { @@ -83,12 +72,13 @@ album = null, person = null, isShowDeleteConfirmation = $bindable(false), - onSelect = () => {}, + + onAssetSelect, + onAssetOpen, onEscape = () => {}, children, empty, customThumbnailLayout, - onThumbnailClick, }: Props = $props(); timelineManager = new TimelineManager(); @@ -255,7 +245,6 @@ }); const updateIsScrolling = () => (timelineManager.scrolling = true); - // note: don't throttle, debounch, or otherwise do this function async - it causes flicker const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height); @@ -363,223 +352,14 @@ } }; - const handleSelectAsset = (asset: TimelineAsset) => { - if (!timelineManager.albumAssets.has(asset.id)) { - assetInteraction.selectAsset(asset); - } - }; - - let lastAssetMouseEvent: TimelineAsset | null = $state(null); - - let shiftKeyIsDown = $state(false); - - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Shift') { - event.preventDefault(); - shiftKeyIsDown = true; - } - }; - - const onKeyUp = (event: KeyboardEvent) => { - if (event.key === 'Shift') { - event.preventDefault(); - shiftKeyIsDown = false; - } - }; - const handleSelectAssetCandidates = (asset: TimelineAsset | null) => { - if (asset) { - void selectAssetCandidates(asset); - } - lastAssetMouseEvent = asset; - }; - - const handleGroupSelect = (dayGroup: DayGroup, assets: TimelineAsset[]) => { - const group = dayGroup.groupTitle; - if (assetInteraction.selectedGroup.has(group)) { - assetInteraction.removeGroupFromMultiselectGroup(group); - for (const asset of assets) { - assetInteraction.removeAssetFromMultiselectGroup(asset.id); - } - } else { - assetInteraction.addGroupToMultiselectGroup(group); - for (const asset of assets) { - handleSelectAsset(asset); - } - } - - if (timelineManager.assetCount == assetInteraction.selectedAssets.length) { - isSelectingAllAssets.set(true); - } else { - isSelectingAllAssets.set(false); - } - }; - - const onSelectAssets = async (asset: TimelineAsset) => { - if (!asset) { - return; - } - onSelect(asset); - - if (singleSelect) { - timelineManager.scrollTo(0); - return; - } - - const rangeSelection = assetInteraction.assetSelectionCandidates.length > 0; - const deselect = assetInteraction.hasSelectedAsset(asset.id); - - // Select/deselect already loaded assets - if (deselect) { - for (const candidate of assetInteraction.assetSelectionCandidates) { - assetInteraction.removeAssetFromMultiselectGroup(candidate.id); - } - assetInteraction.removeAssetFromMultiselectGroup(asset.id); - } else { - for (const candidate of assetInteraction.assetSelectionCandidates) { - handleSelectAsset(candidate); - } - handleSelectAsset(asset); - } - - assetInteraction.clearAssetSelectionCandidates(); - - if (assetInteraction.assetSelectionStart && rangeSelection) { - let startBucket = timelineManager.getMonthGroupByAssetId(assetInteraction.assetSelectionStart.id); - let endBucket = timelineManager.getMonthGroupByAssetId(asset.id); - - if (startBucket === null || endBucket === null) { - return; - } - - // Select/deselect assets in range (start,end) - let started = false; - for (const monthGroup of timelineManager.months) { - if (monthGroup === endBucket) { - break; - } - if (started) { - await timelineManager.loadMonthGroup(monthGroup.yearMonth); - for (const asset of monthGroup.assetsIterator()) { - if (deselect) { - assetInteraction.removeAssetFromMultiselectGroup(asset.id); - } else { - handleSelectAsset(asset); - } - } - } - if (monthGroup === startBucket) { - started = true; - } - } - - // Update date group selection in range [start,end] - started = false; - for (const monthGroup of timelineManager.months) { - if (monthGroup === startBucket) { - started = true; - } - if (started) { - // Split month group into day groups and check each group - for (const dayGroup of monthGroup.dayGroups) { - const dayGroupTitle = dayGroup.groupTitle; - if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) { - assetInteraction.addGroupToMultiselectGroup(dayGroupTitle); - } else { - assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle); - } - } - } - if (monthGroup === endBucket) { - break; - } - } - } - - assetInteraction.setAssetSelectionStart(deselect ? null : asset); - }; - - const selectAssetCandidates = async (endAsset: TimelineAsset) => { - if (!shiftKeyIsDown) { - return; - } - - const startAsset = assetInteraction.assetSelectionStart; - if (!startAsset) { - return; - } - - const assets = assetsSnapshot(await timelineManager.retrieveRange(startAsset, endAsset)); - assetInteraction.setAssetSelectionCandidates(assets); - }; - - $effect(() => { - if (!lastAssetMouseEvent) { - assetInteraction.clearAssetSelectionCandidates(); - } - }); - - $effect(() => { - if (!shiftKeyIsDown) { - assetInteraction.clearAssetSelectionCandidates(); - } - }); - - $effect(() => { - if (shiftKeyIsDown && lastAssetMouseEvent) { - void selectAssetCandidates(lastAssetMouseEvent); - } - }); - $effect(() => { if ($showAssetViewer) { const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60); void timelineManager.loadMonthGroup({ year: localDateTime.year, month: localDateTime.month }); } }); - - const assetSelectHandler = ( - timelineManager: TimelineManager, - asset: TimelineAsset, - assetsInDayGroup: TimelineAsset[], - groupTitle: string, - ) => { - void onSelectAssets(asset); - - // Check if all assets are selected in a group to toggle the group selection's icon - let selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) => - assetInteraction.hasSelectedAsset(asset.id), - ).length; - - // if all assets are selected in a group, add the group to selected group - if (selectedAssetsInGroupCount == assetsInDayGroup.length) { - assetInteraction.addGroupToMultiselectGroup(groupTitle); - } else { - assetInteraction.removeGroupFromMultiselectGroup(groupTitle); - } - - if (timelineManager.assetCount == assetInteraction.selectedAssets.length) { - isSelectingAllAssets.set(true); - } else { - isSelectingAllAssets.set(false); - } - }; - - const _onClick = ( - timelineManager: TimelineManager, - assets: TimelineAsset[], - groupTitle: string, - asset: TimelineAsset, - ) => { - if (isSelectionMode || assetInteraction.selectionActive) { - assetSelectHandler(timelineManager, asset, assets, groupTitle); - return; - } - void navigate({ targetRoute: 'current', assetId: asset.id }); - }; - - { const asset = page.url.searchParams.get('at'); @@ -615,21 +395,6 @@ {viewportTopMonth} {onScrub} bind:scrubberWidth - onScrubKeyDown={(evt) => { - evt.preventDefault(); - let amount = 50; - if (shiftKeyIsDown) { - amount = 500; - } - if (evt.key === 'ArrowUp') { - amount = -amount; - if (shiftKeyIsDown) { - scrollableElement?.scrollBy({ top: amount, behavior: 'smooth' }); - } - } else if (evt.key === 'ArrowDown') { - scrollableElement?.scrollBy({ top: amount, behavior: 'smooth' }); - } - }} /> {/if} @@ -685,47 +450,56 @@ style:transform={`translate3d(0,${absoluteHeight}px,0)`} style:width="100%" > - - {#snippet thumbnail({ asset, position, dayGroup, groupIndex })} - {@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)} - {@const isAssetSelected = - assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)} - {@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)} - { - if (typeof onThumbnailClick === 'function') { - onThumbnailClick(asset, timelineManager, dayGroup, _onClick); - } else { - _onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset); - } - }} - onSelect={() => { - if (isSelectionMode || assetInteraction.selectionActive) { - assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle); - return; - } - void onSelectAssets(asset); - }} - onMouseEvent={() => handleSelectAssetCandidates(asset)} - selected={isAssetSelected} - selectionCandidate={isAssetSelectionCandidate} - disabled={isAssetDisabled} - thumbnailWidth={position.width} - thumbnailHeight={position.height} - /> + {#snippet content({ onAssetOpen, onAssetSelect, onAssetHover })} + + {#snippet content({ onDayGroupSelect, onDayGroupAssetSelect })} + + {#snippet thumbnail({ asset, position, dayGroup, groupIndex })} + {@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)} + {@const isAssetSelected = + assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)} + {@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)} + onAssetOpen(asset)} + onSelect={() => onDayGroupAssetSelect(dayGroup, asset)} + onMouseEvent={(isMouseOver) => { + if (isMouseOver) { + onAssetHover(asset); + } else { + onAssetHover(null); + } + }} + selected={isAssetSelected} + selectionCandidate={isAssetSelectionCandidate} + disabled={isAssetDisabled} + thumbnailWidth={position.width} + thumbnailHeight={position.height} + /> + {/snippet} + + {/snippet} + {/snippet} - + {/if} {/each} diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index ff19871627..e080779c71 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -441,7 +441,7 @@ {isSelectionMode} {singleSelect} {showArchiveIcon} - {onSelect} + onAssetSelect={onSelect} onEscape={handleEscape} > {#if viewMode !== AlbumPageViewMode.SELECT_ASSETS} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index b29c54607a..36cdeddd67 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -371,7 +371,7 @@ {assetInteraction} isSelectionMode={viewMode === PersonPageViewMode.SELECT_PERSON} singleSelect={viewMode === PersonPageViewMode.SELECT_PERSON} - onSelect={handleSelectFeaturePhoto} + onAssetSelect={handleSelectFeaturePhoto} onEscape={handleEscape} > {#if viewMode === PersonPageViewMode.VIEW_ASSETS} diff --git a/web/src/routes/(user)/utilities/geolocation/+page.svelte b/web/src/routes/(user)/utilities/geolocation/+page.svelte index e5b5f20d99..97f37af5e3 100644 --- a/web/src/routes/(user)/utilities/geolocation/+page.svelte +++ b/web/src/routes/(user)/utilities/geolocation/+page.svelte @@ -5,7 +5,6 @@ import Timeline from '$lib/components/timeline/Timeline.svelte'; import { AssetAction } from '$lib/constants'; import { authManager } from '$lib/managers/auth-manager.svelte'; - import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte'; @@ -110,17 +109,7 @@ return !!asset.latitude && !!asset.longitude; }; - const handleThumbnailClick = ( - asset: TimelineAsset, - timelineManager: TimelineManager, - dayGroup: DayGroup, - onClick: ( - timelineManager: TimelineManager, - assets: TimelineAsset[], - groupTitle: string, - asset: TimelineAsset, - ) => void, - ) => { + const handleAssetOpen = (asset: TimelineAsset, defaultAssetOpen: () => void) => { if (hasGps(asset)) { locationUpdated = true; setTimeout(() => { @@ -128,9 +117,9 @@ }, 1500); location = { latitude: asset.latitude!, longitude: asset.longitude! }; void setQueryValue('at', asset.id); - } else { - onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset); + return; } + defaultAssetOpen(); }; @@ -194,7 +183,7 @@ removeAction={AssetAction.ARCHIVE} onEscape={handleEscape} withStacked - onThumbnailClick={handleThumbnailClick} + onAssetOpen={handleAssetOpen} > {#snippet customThumbnailLayout(asset: TimelineAsset)} {#if hasGps(asset)}