From 12a59f8c68e7b4626d89bcfeea45be6f63faf1c5 Mon Sep 17 00:00:00 2001 From: midzelis Date: Wed, 29 Oct 2025 13:17:06 +0000 Subject: [PATCH] Push up operations to VirtualScrollManager --- .../lib/components/timeline/Timeline.svelte | 2 +- .../ScrollSegment.svelte.ts | 31 ++++ .../VirtualScrollManager.svelte.ts | 122 +++++++++++++++- .../VirtualScrollManager/utils.svelte.ts | 52 +++++++ .../timeline-manager/TimelineDay.svelte.ts | 9 -- .../TimelineManager.svelte.ts | 136 +++--------------- .../timeline-manager/TimelineMonth.svelte.ts | 19 +-- 7 files changed, 223 insertions(+), 148 deletions(-) create mode 100644 web/src/lib/managers/VirtualScrollManager/utils.svelte.ts diff --git a/web/src/lib/components/timeline/Timeline.svelte b/web/src/lib/components/timeline/Timeline.svelte index 90c8815633..d92fd55443 100644 --- a/web/src/lib/components/timeline/Timeline.svelte +++ b/web/src/lib/components/timeline/Timeline.svelte @@ -174,7 +174,7 @@ }; const scrollAndLoadAsset = async (assetId: string) => { - const month = await timelineManager.findMonthForAsset(assetId); + const month = await timelineManager.search.getMonthForAsset(assetId); if (!month) { return false; } diff --git a/web/src/lib/managers/VirtualScrollManager/ScrollSegment.svelte.ts b/web/src/lib/managers/VirtualScrollManager/ScrollSegment.svelte.ts index 0a5058ba41..6454f18fe3 100644 --- a/web/src/lib/managers/VirtualScrollManager/ScrollSegment.svelte.ts +++ b/web/src/lib/managers/VirtualScrollManager/ScrollSegment.svelte.ts @@ -1,6 +1,7 @@ import type { TimelineAsset, UpdateGeometryOptions } from '$lib/managers/timeline-manager/types'; import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte'; import type { + AssetOperation, VirtualScrollManager, VisibleWindow, } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte'; @@ -196,6 +197,36 @@ export abstract class ScrollSegment { } this.updateIntersection({ intersecting: actuallyIntersecting || preIntersecting, actuallyIntersecting }); } + + runAssetOperation(ids: Set, operation: AssetOperation) { + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const unprocessedIds = new Set(ids); + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const processedIds = new Set(); + const moveAssets: TimelineAsset[] = []; + let changedGeometry = false; + for (const assetId of unprocessedIds) { + const index = this.viewerAssets.findIndex((viewAsset) => viewAsset.id == assetId); + if (index === -1) { + continue; + } + + const asset = this.viewerAssets[index].asset!; + const opResult = operation(asset); + let remove = false; + if (opResult) { + remove = (opResult as { remove: boolean }).remove ?? false; + } + + unprocessedIds.delete(assetId); + processedIds.add(assetId); + if (remove || this.scrollManager.isExcluded(asset)) { + this.viewerAssets.splice(index, 1); + changedGeometry = true; + } + } + return { moveAssets, processedIds, unprocessedIds, changedGeometry }; + } } /** diff --git a/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts b/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts index 35a956f604..47444f9be5 100644 --- a/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts +++ b/web/src/lib/managers/VirtualScrollManager/VirtualScrollManager.svelte.ts @@ -1,19 +1,21 @@ -import type { Viewport } from '$lib/managers/timeline-manager/types'; +import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types'; +import { setDifferenceInPlace } from '$lib/managers/timeline-manager/utils.svelte'; import type { ScrollSegment, SegmentIdentifier } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte'; +import { updateObject } from '$lib/managers/VirtualScrollManager/utils.svelte'; import { clamp, debounce } from 'lodash-es'; +export type VisibleWindow = { + top: number; + bottom: number; +}; +export type AssetOperation = (asset: TimelineAsset) => unknown; + type LayoutOptions = { headerHeight: number; rowHeight: number; gap: number; }; - -export type VisibleWindow = { - top: number; - bottom: number; -}; - type ViewportTopSegmentIntersection = { segment: ScrollSegment | null; // Where viewport top intersects segment (0 = segment top, 1 = segment bottom) @@ -278,6 +280,112 @@ export abstract class VirtualScrollManager { await segment.load(cancelable); } + + upsertAssets(assets: TimelineAsset[]) { + const notExcluded = assets.filter((asset) => !this.isExcluded(asset)); + const notUpdated = this.#updateAssets(notExcluded); + this.addAssetsToSegments(notUpdated); + } + + removeAssets(ids: string[]) { + this.#runAssetOperation(ids, () => ({ remove: true })); + } + + /** + * Executes the given operation against every passed in asset id. + * + * @returns An object with the changed ids, unprocessed ids, and if this resulted + * in changes of the timeline geometry. + */ + updateAssetOperation(ids: string[], operation: AssetOperation) { + return this.#runAssetOperation(ids, operation); + } + + isExcluded(_: TimelineAsset) { + return false; + } + + protected addAssetsToSegments(assets: TimelineAsset[]) { + if (assets.length === 0) { + return; + } + const context = this.createUpsertContext(); + const monthCount = this.segments.length; + for (const asset of assets) { + this.upsertAssetIntoSegment(asset, context); + } + if (this.segments.length !== monthCount) { + this.postCreateSegments(); + } + this.postUpsert(context); + this.updateIntersections(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected upsertAssetIntoSegment(asset: TimelineAsset, context: unknown): void {} + protected createUpsertContext(): unknown { + return undefined; + } + protected postUpsert(_: unknown): void {} + protected postCreateSegments(): void {} + + /** + * Looks up the specified asset from the TimelineAsset using its id, and then updates the + * existing object to match the rest of the TimelineAsset parameter. + + * @returns list of assets that were updated (not found) + */ + #updateAssets(updatedAssets: TimelineAsset[]) { + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const lookup = new Map(); + const ids = []; + for (const asset of updatedAssets) { + ids.push(asset.id); + lookup.set(asset.id, asset); + } + const { unprocessedIds } = this.#runAssetOperation(ids, (asset) => updateObject(asset, lookup.get(asset.id))); + const result: TimelineAsset[] = []; + for (const id of unprocessedIds) { + result.push(lookup.get(id)!); + } + return result; + } + + #runAssetOperation(ids: string[], operation: AssetOperation) { + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const changedMonths = new Set(); + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const idsToProcess = new Set(ids); + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const idsProcessed = new Set(); + const combinedMoveAssets: TimelineAsset[] = []; + for (const month of this.segments) { + if (idsToProcess.size > 0) { + const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation); + if (moveAssets.length > 0) { + combinedMoveAssets.push(...moveAssets); + } + setDifferenceInPlace(idsToProcess, processedIds); + for (const id of processedIds) { + idsProcessed.add(id); + } + if (changedGeometry) { + changedMonths.add(month); + } + } + } + if (combinedMoveAssets.length > 0) { + this.addAssetsToSegments(combinedMoveAssets); + } + const changedGeometry = changedMonths.size > 0; + for (const month of changedMonths) { + month.updateGeometry({ invalidateHeight: true }); + } + if (changedGeometry) { + this.updateIntersections(); + } + return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry }; + } } export const isEmptyViewport = (viewport: Viewport) => viewport.width === 0 || viewport.height === 0; diff --git a/web/src/lib/managers/VirtualScrollManager/utils.svelte.ts b/web/src/lib/managers/VirtualScrollManager/utils.svelte.ts new file mode 100644 index 0000000000..bad216dbfa --- /dev/null +++ b/web/src/lib/managers/VirtualScrollManager/utils.svelte.ts @@ -0,0 +1,52 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function updateObject(target: any, source: any): boolean { + if (!target) { + return false; + } + let updated = false; + for (const key in source) { + if (!Object.prototype.hasOwnProperty.call(source, key)) { + continue; + } + if (key === '__proto__' || key === 'constructor') { + continue; + } + const isDate = target[key] instanceof Date; + if (typeof target[key] === 'object' && !isDate) { + updated = updated || updateObject(target[key], source[key]); + } else { + if (target[key] !== source[key]) { + target[key] = source[key]; + updated = true; + } + } + } + return updated; +} + +export function setDifference(setA: Set, setB: Set): Set { + // Check if native Set.prototype.difference is available (ES2025) + + const setWithDifference = setA as unknown as Set & { difference?: (other: Set) => Set }; + if (setWithDifference.difference && typeof setWithDifference.difference === 'function') { + return setWithDifference.difference(setB); + } + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const result = new Set(); + for (const value of setA) { + if (!setB.has(value)) { + result.add(value); + } + } + return result; +} + +/** + * Removes all elements of setB from setA in-place (mutates setA). + */ +export function setDifferenceInPlace(setA: Set, setB: Set): Set { + for (const value of setB) { + setA.delete(value); + } + return setA; +} diff --git a/web/src/lib/managers/timeline-manager/TimelineDay.svelte.ts b/web/src/lib/managers/timeline-manager/TimelineDay.svelte.ts index 72dec0a6e2..3b5f6534e1 100644 --- a/web/src/lib/managers/timeline-manager/TimelineDay.svelte.ts +++ b/web/src/lib/managers/timeline-manager/TimelineDay.svelte.ts @@ -105,15 +105,6 @@ export class TimelineDay { } runAssetOperation(ids: Set, operation: AssetOperation) { - if (ids.size === 0) { - return { - moveAssets: [] as TimelineAsset[], - // eslint-disable-next-line svelte/prefer-svelte-reactivity - processedIds: new Set(), - unprocessedIds: ids, - changedGeometry: false, - }; - } // eslint-disable-next-line svelte/prefer-svelte-reactivity const unprocessedIds = new Set(ids); // eslint-disable-next-line svelte/prefer-svelte-reactivity diff --git a/web/src/lib/managers/timeline-manager/TimelineManager.svelte.ts b/web/src/lib/managers/timeline-manager/TimelineManager.svelte.ts index c8a8db8774..7bbc183381 100644 --- a/web/src/lib/managers/timeline-manager/TimelineManager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/TimelineManager.svelte.ts @@ -6,14 +6,13 @@ import { TimelineMonth } from '$lib/managers/timeline-manager/TimelineMonth.svel import { TimelineSearchExtension } from '$lib/managers/timeline-manager/TimelineSearchExtension.svelte'; import { TimelineWebsocketExtension } from '$lib/managers/timeline-manager/TimelineWebsocketExtension'; import type { - AssetOperation, Direction, ScrubberMonth, TimelineAsset, TimelineManagerOptions, Viewport, } from '$lib/managers/timeline-manager/types'; -import { isMismatched, setDifferenceInPlace, updateObject } from '$lib/managers/timeline-manager/utils.svelte'; +import { isMismatched } from '$lib/managers/timeline-manager/utils.svelte'; import { CancellableTask } from '$lib/utils/cancellable-task'; import { getSegmentIdentifier } from '$lib/utils/timeline-util'; import { AssetOrder, getTimeBuckets } from '@immich/sdk'; @@ -121,53 +120,11 @@ export class TimelineManager extends VirtualScrollManager { this.onUpdateViewport(oldViewport, viewport); } - upsertAssets(assets: TimelineAsset[]) { - const notExcluded = assets.filter((asset) => !this.isExcluded(asset)); - const notUpdated = this.#updateAssets(notExcluded); - this.addAssetsToSegments(notUpdated); - } - - /** - * Executes the given operation against every passed in asset id. - * - * @returns An object with the changed ids, unprocessed ids, and if this resulted - * in changes of the timeline geometry. - */ - updateAssetOperation(ids: string[], operation: AssetOperation) { - return this.#runAssetOperation(ids, operation); - } - - /** - * Looks up the specified asset from the TimelineAsset using its id, and then updates the - * existing object to match the rest of the TimelineAsset parameter. - - * @returns list of assets that were updated (not found) - */ - #updateAssets(updatedAssets: TimelineAsset[]) { - // eslint-disable-next-line svelte/prefer-svelte-reactivity - const lookup = new Map(); - const ids = []; - for (const asset of updatedAssets) { - ids.push(asset.id); - lookup.set(asset.id, asset); - } - const { unprocessedIds } = this.#runAssetOperation(ids, (asset) => updateObject(asset, lookup.get(asset.id))); - const result: TimelineAsset[] = []; - for (const id of unprocessedIds) { - result.push(lookup.get(id)!); - } - return result; - } - - removeAssets(ids: string[]) { - this.#runAssetOperation(ids, () => ({ remove: true })); - } - - protected createUpsertContext(): GroupInsertionCache { + protected override createUpsertContext(): GroupInsertionCache { return new GroupInsertionCache(); } - protected upsertAssetIntoSegment(asset: TimelineAsset, context: GroupInsertionCache): void { + protected override upsertAssetIntoSegment(asset: TimelineAsset, context: GroupInsertionCache): void { let month = this.search.findMonthByDate(asset.localDateTime); if (!month) { @@ -178,64 +135,30 @@ export class TimelineManager extends VirtualScrollManager { month.addTimelineAsset(asset, context); } - protected addAssetsToSegments(assets: TimelineAsset[]) { - if (assets.length === 0) { - return; - } - const context = this.createUpsertContext(); - const monthCount = this.segments.length; - for (const asset of assets) { - this.upsertAssetIntoSegment(asset, context); - } - if (this.segments.length !== monthCount) { - this.postCreateSegments(); - } - this.postUpsert(context); - this.updateIntersections(); + protected override postCreateSegments(): void { + this.segments.sort((a, b) => { + return a.yearMonth.year === b.yearMonth.year + ? b.yearMonth.month - a.yearMonth.month + : b.yearMonth.year - a.yearMonth.year; + }); } - #runAssetOperation(ids: string[], operation: AssetOperation) { - if (ids.length === 0) { - // eslint-disable-next-line svelte/prefer-svelte-reactivity - return { processedIds: new Set(), unprocessedIds: new Set(), changedGeometry: false }; + protected override postUpsert(context: GroupInsertionCache): void { + for (const group of context.existingDays) { + group.sortAssets(this.#options.order); } - // eslint-disable-next-line svelte/prefer-svelte-reactivity - const changedMonths = new Set(); - // eslint-disable-next-line svelte/prefer-svelte-reactivity - const idsToProcess = new Set(ids); - // eslint-disable-next-line svelte/prefer-svelte-reactivity - const idsProcessed = new Set(); - const combinedMoveAssets: TimelineAsset[] = []; - for (const month of this.segments) { - if (idsToProcess.size > 0) { - const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation); - if (moveAssets.length > 0) { - combinedMoveAssets.push(...moveAssets); - } - setDifferenceInPlace(idsToProcess, processedIds); - for (const id of processedIds) { - idsProcessed.add(id); - } - if (changedGeometry) { - changedMonths.add(month); - } - } + for (const month of context.monthsWithNewDays) { + month.sortDays(); } - if (combinedMoveAssets.length > 0) { - this.addAssetsToSegments(combinedMoveAssets); - } - const changedGeometry = changedMonths.size > 0; - for (const month of changedMonths) { + + for (const month of context.updatedMonths) { + month.sortDays(); month.updateGeometry({ invalidateHeight: true }); } - if (changedGeometry) { - this.updateIntersections(); - } - return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry }; } - isExcluded(asset: TimelineAsset) { + override isExcluded(asset: TimelineAsset) { return ( isMismatched(this.#options.visibility, asset.visibility) || isMismatched(this.#options.isFavorite, asset.isFavorite) || @@ -318,27 +241,4 @@ export class TimelineManager extends VirtualScrollManager { })); this.scrubberTimelineHeight = this.totalViewerHeight; } - - protected postCreateSegments(): void { - this.segments.sort((a, b) => { - return a.yearMonth.year === b.yearMonth.year - ? b.yearMonth.month - a.yearMonth.month - : b.yearMonth.year - a.yearMonth.year; - }); - } - - protected postUpsert(context: GroupInsertionCache): void { - for (const group of context.existingDays) { - group.sortAssets(this.#options.order); - } - - for (const month of context.monthsWithNewDays) { - month.sortDays(); - } - - for (const month of context.updatedMonths) { - month.sortDays(); - month.updateGeometry({ invalidateHeight: true }); - } - } } diff --git a/web/src/lib/managers/timeline-manager/TimelineMonth.svelte.ts b/web/src/lib/managers/timeline-manager/TimelineMonth.svelte.ts index a570460e64..e81c7fc02c 100644 --- a/web/src/lib/managers/timeline-manager/TimelineMonth.svelte.ts +++ b/web/src/lib/managers/timeline-manager/TimelineMonth.svelte.ts @@ -3,10 +3,11 @@ import { TimelineDay } from '$lib/managers/timeline-manager/TimelineDay.svelte'; import { GroupInsertionCache } from '$lib/managers/timeline-manager/TimelineInsertionCache.svelte'; import type { TimelineManager } from '$lib/managers/timeline-manager/TimelineManager.svelte'; import { onCreateTimelineMonth } from '$lib/managers/timeline-manager/TimelineTestHooks.svelte'; -import type { AssetDescriptor, AssetOperation, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types'; -import { setDifferenceInPlace } from '$lib/managers/timeline-manager/utils.svelte'; +import type { AssetDescriptor, Direction, TimelineAsset } from '$lib/managers/timeline-manager/types'; import { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte'; import { ScrollSegment, type SegmentIdentifier } from '$lib/managers/VirtualScrollManager/ScrollSegment.svelte'; +import { setDifferenceInPlace } from '$lib/managers/VirtualScrollManager/utils.svelte'; +import type { AssetOperation } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte'; import { formatGroupTitle, formatGroupTitleFull, @@ -68,7 +69,7 @@ export class TimelineMonth extends ScrollSegment { return assets; } - findAssetAbsolutePosition(assetId: string) { + override findAssetAbsolutePosition(assetId: string) { this.#clearDeferredLayout(); for (const day of this.days) { const viewerAsset = day.viewerAssets.find((viewAsset) => viewAsset.id === assetId); @@ -206,16 +207,7 @@ export class TimelineMonth extends ScrollSegment { return this.days[0]?.getFirstAsset(); } - runAssetOperation(ids: Set, operation: AssetOperation) { - if (ids.size === 0) { - return { - moveAssets: [] as TimelineAsset[], - // eslint-disable-next-line svelte/prefer-svelte-reactivity - processedIds: new Set(), - unprocessedIds: ids, - changedGeometry: false, - }; - } + override runAssetOperation(ids: Set, operation: AssetOperation) { const { days } = this; let combinedChangedGeometry = false; // eslint-disable-next-line svelte/prefer-svelte-reactivity @@ -228,6 +220,7 @@ export class TimelineMonth extends ScrollSegment { if (idsToProcess.size > 0) { const group = days[index]; const { moveAssets, processedIds, changedGeometry } = group.runAssetOperation(ids, operation); + if (moveAssets.length > 0) { combinedMoveAssets.push(...moveAssets); }