Push up operations to VirtualScrollManager

pull/23516/head
midzelis 2025-10-29 13:17:06 +00:00
parent de84e46f62
commit 12a59f8c68
7 changed files with 223 additions and 148 deletions

View File

@ -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;
}

View File

@ -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<string>, operation: AssetOperation) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const unprocessedIds = new Set<string>(ids);
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const processedIds = new Set<string>();
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 };
}
}
/**

View File

@ -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<string, TimelineAsset>();
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<ScrollSegment>();
// 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<string>();
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;

View File

@ -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<T>(setA: Set<T>, setB: Set<T>): Set<T> {
// Check if native Set.prototype.difference is available (ES2025)
const setWithDifference = setA as unknown as Set<T> & { difference?: (other: Set<T>) => Set<T> };
if (setWithDifference.difference && typeof setWithDifference.difference === 'function') {
return setWithDifference.difference(setB);
}
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const result = new Set<T>();
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<T>(setA: Set<T>, setB: Set<T>): Set<T> {
for (const value of setB) {
setA.delete(value);
}
return setA;
}

View File

@ -105,15 +105,6 @@ export class TimelineDay {
}
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
if (ids.size === 0) {
return {
moveAssets: [] as TimelineAsset[],
// eslint-disable-next-line svelte/prefer-svelte-reactivity
processedIds: new Set<string>(),
unprocessedIds: ids,
changedGeometry: false,
};
}
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const unprocessedIds = new Set<string>(ids);
// eslint-disable-next-line svelte/prefer-svelte-reactivity

View File

@ -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<string, TimelineAsset>();
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<string>(), unprocessedIds: new Set<string>(), 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<TimelineMonth>();
// 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<string>();
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 });
}
}
}

View File

@ -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<string>, operation: AssetOperation) {
if (ids.size === 0) {
return {
moveAssets: [] as TimelineAsset[],
// eslint-disable-next-line svelte/prefer-svelte-reactivity
processedIds: new Set<string>(),
unprocessedIds: ids,
changedGeometry: false,
};
}
override runAssetOperation(ids: Set<string>, 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);
}