From ed633991817648079002508ea978e23812a5ce39 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 + .../components/timeline/SelectableDay.svelte | 62 +++ .../timeline/SelectableSegment.svelte | 208 ++++++++++ .../lib/components/timeline/Timeline.svelte | 364 ++++-------------- .../PhotostreamManager.svelte.ts | 14 +- .../[[assetId=id]]/+page.svelte | 2 +- .../[[assetId=id]]/+page.svelte | 2 +- .../(user)/utilities/geolocation/+page.svelte | 19 +- 8 files changed, 358 insertions(+), 314 deletions(-) create mode 100644 web/src/lib/components/timeline/SelectableDay.svelte create mode 100644 web/src/lib/components/timeline/SelectableSegment.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/SelectableDay.svelte b/web/src/lib/components/timeline/SelectableDay.svelte new file mode 100644 index 0000000000..96f94247d3 --- /dev/null +++ b/web/src/lib/components/timeline/SelectableDay.svelte @@ -0,0 +1,62 @@ + + +{@render content({ + onDayGroupSelect, + onDayGroupAssetSelect, +})} diff --git a/web/src/lib/components/timeline/SelectableSegment.svelte b/web/src/lib/components/timeline/SelectableSegment.svelte new file mode 100644 index 0000000000..55dfaa371d --- /dev/null +++ b/web/src/lib/components/timeline/SelectableSegment.svelte @@ -0,0 +1,208 @@ + + + + +{@render content({ + onAssetOpen: handleOnAssetOpen, + onAssetSelect: (asset) => { + void handleSelectAssets(asset); + }, + onAssetHover: handleOnHover, +})} diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index 9f24ab9d30..d4797ce5f4 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -5,20 +5,19 @@ import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import MonthSegment from '$lib/components/timeline/MonthSegment.svelte'; import Scrubber from '$lib/components/timeline/Scrubber.svelte'; + import SelectableDay from '$lib/components/timeline/SelectableDay.svelte'; + import SelectableSegment from '$lib/components/timeline/SelectableSegment.svelte'; import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte'; import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte'; import { AssetAction } from '$lib/constants'; 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 type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset } 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 { navigate } from '$lib/utils/navigation'; import { @@ -54,22 +53,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 { @@ -85,12 +74,13 @@ album = null, person = null, isShowDeleteConfirmation = $bindable(false), - onSelect = () => {}, + + onAssetSelect, + onAssetOpen, onEscape = () => {}, children, empty, customThumbnailLayout, - onThumbnailClick, }: Props = $props(); let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore; @@ -149,14 +139,26 @@ scrollTo(0); }; + const handleTriggeredScrollCompensation = (compensation: { heightDelta?: number; scrollTop?: number }) => { + const { heightDelta, scrollTop } = compensation; + if (heightDelta !== undefined) { + scrollBy(heightDelta); + } else if (scrollTop !== undefined) { + scrollTo(scrollTop); + } + timelineManager.clearScrollCompensation(); + }; + const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => { // the following method may trigger any layouts, so need to // handle any scroll compensation that may have been set const height = monthGroup!.findAssetAbsolutePosition(assetId); + // this is in a while loop, since scrollCompensations invoke scrolls + // which may load months, triggering more scrollCompensations. Call + // this in a loop, until no more layouts occur. while (timelineManager.scrollCompensation.monthGroup) { - handleScrollCompensation(timelineManager.scrollCompensation); - timelineManager.clearScrollCompensation(); + handleTriggeredScrollCompensation(timelineManager.scrollCompensation); } return height; }; @@ -252,19 +254,6 @@ // note: don't throttle, debounch, or otherwise do this function async - it causes flicker const updateSlidingWindow = () => timelineManager.updateSlidingWindow(element?.scrollTop || 0); - const handleScrollCompensation = ({ heightDelta, scrollTop }: { heightDelta?: number; scrollTop?: number }) => { - if (heightDelta !== undefined) { - scrollBy(heightDelta); - } else if (scrollTop !== undefined) { - scrollTo(scrollTop); - } - // Yes, updateSlideWindow() is called by the onScroll event triggered as a result of - // the above calls. However, this delay is enough time to set the intersecting property - // of the monthGroup to false, then true, which causes the DOM nodes to be recreated, - // causing bad perf, and also, disrupting focus of those elements. - updateSlidingWindow(); - }; - const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height); onMount(() => { @@ -390,223 +379,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) { - scrollTop(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.loadSegment(monthGroup.identifier); - 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.loadSegment(getSegmentIdentifier({ 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 }); - }; - - { - evt.preventDefault(); - let amount = 50; - if (shiftKeyIsDown) { - amount = 500; - } - if (evt.key === 'ArrowUp') { - amount = -amount; - if (shiftKeyIsDown) { - element?.scrollBy({ top: amount, behavior: 'smooth' }); - } - } else if (evt.key === 'ArrowDown') { - element?.scrollBy({ top: amount, behavior: 'smooth' }); - } - }} /> {/if} @@ -702,47 +467,58 @@ 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/lib/managers/photostream-manager/PhotostreamManager.svelte.ts b/web/src/lib/managers/photostream-manager/PhotostreamManager.svelte.ts index f2e52a16aa..49e0814115 100644 --- a/web/src/lib/managers/photostream-manager/PhotostreamManager.svelte.ts +++ b/web/src/lib/managers/photostream-manager/PhotostreamManager.svelte.ts @@ -299,11 +299,15 @@ export abstract class PhotostreamManager { return this.topSectionHeight + this.bottomSectionHeight + (this.timelineHeight - this.viewportHeight); } - retrieveRange(start: AssetDescriptor, end: AssetDescriptor): Promise { + retrieveLoadedRange(start: AssetDescriptor, end: AssetDescriptor): TimelineAsset[] { const range: TimelineAsset[] = []; let collecting = false; for (const month of this.months) { + if (collecting && !month.isLoaded) { + // if there are any unloaded months in the range, return empty [] + return []; + } for (const asset of month.assets) { if (asset.id === start.id) { collecting = true; @@ -312,11 +316,15 @@ export abstract class PhotostreamManager { range.push(asset); } if (asset.id === end.id) { - return Promise.resolve(range); + return range; } } } - return Promise.resolve(range); + return range; + } + + retrieveRange(start: AssetDescriptor, end: AssetDescriptor): Promise { + return Promise.resolve(this.retrieveLoadedRange(start, end)); } updateAssetOperation(ids: string[], operation: AssetOperation) { 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 706c412004..e3ff59f808 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 @@ -448,7 +448,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 fbea92d8b5..6dea55dd45 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 @@ -385,7 +385,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 1724158a3a..a302ba558c 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(); }; @@ -193,7 +182,7 @@ removeAction={AssetAction.ARCHIVE} onEscape={handleEscape} withStacked - onThumbnailClick={handleThumbnailClick} + onAssetOpen={handleAssetOpen} > {#snippet customThumbnailLayout(asset: TimelineAsset)} {#if hasGps(asset)}