feat(web): Scroll to asset in gridview; increase gridview perf; reduce memory; scrollbar ticks in fixed position (#10646)

* Squashed

* Change strategy - now pre-measure buckets offscreen, so don't need to worry about sub-bucket scroll preservation

* Reduce jank on scroll, delay DOM updates until after scroll

* css opt, log measure time

* Trickle out queue while scrolling, flush when stopped

* yay

* Cleanup cleanup...

* everybody...

* everywhere...

* Clean up cleanup!

* Everybody do their share

* CLEANUP!

* package-lock ?

* dynamic measure, todo

* Fix web test

* type lint

* fix e2e

* e2e test

* Better scrollbar

* Tuning, and more tunables

* Tunable tweaks, more tunables

* Scrollbar dots and viewport events

* lint

* Tweaked tunnables, use requestIdleCallback for garbage tasks, bug fixes

* New tunables, and don't update url by default

* Bug fixes

* Bug fix, with debug

* Fix flickr, fix graybox bug, reduced debug

* Refactor/cleanup

* Fix

* naming

* Final cleanup

* review comment

* Forgot to update this after naming change

* scrubber works, with debug

* cleanup

* Rename scrollbar to scrubber

* rename  to

* left over rename and change to previous album bar

* bugfix addassets, comments

* missing destroy(), cleanup

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Min Idzelis 2024-08-21 22:15:21 -04:00 committed by GitHub
parent 07538299cf
commit 837b1e4929
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 2947 additions and 843 deletions

View file

@ -0,0 +1,465 @@
import type { AssetBucket, AssetStore } from '$lib/stores/assets.store';
import { generateId } from '$lib/utils/generate-id';
import { cancelIdleCB, idleCB } from '$lib/utils/idle-callback-support';
import { KeyedPriorityQueue } from '$lib/utils/keyed-priority-queue';
import { type DateGroup } from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import { type AssetResponseDto } from '@immich/sdk';
import { clamp } from 'lodash-es';
type Task = () => void;
class InternalTaskManager {
assetStore: AssetStore;
componentTasks = new Map<string, Set<string>>();
priorityQueue = new KeyedPriorityQueue<string, Task>();
idleQueue = new Map<string, Task>();
taskCleaners = new Map<string, Task>();
queueTimer: ReturnType<typeof setTimeout> | undefined;
lastIdle: number | undefined;
constructor(assetStore: AssetStore) {
this.assetStore = assetStore;
}
destroy() {
this.componentTasks.clear();
this.priorityQueue.clear();
this.idleQueue.clear();
this.taskCleaners.clear();
clearTimeout(this.queueTimer);
if (this.lastIdle) {
cancelIdleCB(this.lastIdle);
}
}
getOrCreateComponentTasks(componentId: string) {
let componentTaskSet = this.componentTasks.get(componentId);
if (!componentTaskSet) {
componentTaskSet = new Set<string>();
this.componentTasks.set(componentId, componentTaskSet);
}
return componentTaskSet;
}
deleteFromComponentTasks(componentId: string, taskId: string) {
if (this.componentTasks.has(componentId)) {
const componentTaskSet = this.componentTasks.get(componentId);
componentTaskSet?.delete(taskId);
if (componentTaskSet?.size === 0) {
this.componentTasks.delete(componentId);
}
}
}
drainIntersectedQueue() {
let count = 0;
for (let t = this.priorityQueue.shift(); t; t = this.priorityQueue.shift()) {
t.value();
if (this.taskCleaners.has(t.key)) {
this.taskCleaners.get(t.key)!();
this.taskCleaners.delete(t.key);
}
if (count++ >= TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS) {
this.scheduleDrainIntersectedQueue(TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS_DELAY_MS);
break;
}
}
}
scheduleDrainIntersectedQueue(delay: number = TUNABLES.SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS) {
clearTimeout(this.queueTimer);
this.queueTimer = setTimeout(() => {
const delta = Date.now() - this.assetStore.lastScrollTime;
if (delta < TUNABLES.SCROLL_TASK_QUEUE.MIN_DELAY_MS) {
let amount = clamp(
1 + Math.round(this.priorityQueue.length / TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR),
1,
TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS * 2,
);
const nextDelay = clamp(
amount > 1
? Math.round(delay / TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR)
: TUNABLES.SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS,
TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MIN_DELAY,
TUNABLES.SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MAX_DELAY,
);
while (amount > 0) {
this.priorityQueue.shift()?.value();
amount--;
}
if (this.priorityQueue.length > 0) {
this.scheduleDrainIntersectedQueue(nextDelay);
}
} else {
this.drainIntersectedQueue();
}
}, delay);
}
removeAllTasksForComponent(componentId: string) {
if (this.componentTasks.has(componentId)) {
const tasksIds = this.componentTasks.get(componentId) || [];
for (const taskId of tasksIds) {
this.priorityQueue.remove(taskId);
this.idleQueue.delete(taskId);
if (this.taskCleaners.has(taskId)) {
const cleanup = this.taskCleaners.get(taskId);
this.taskCleaners.delete(taskId);
cleanup!();
}
}
}
this.componentTasks.delete(componentId);
}
queueScrollSensitiveTask({
task,
cleanup,
componentId,
priority = 10,
taskId = generateId(),
}: {
task: Task;
cleanup?: Task;
componentId: string;
priority?: number;
taskId?: string;
}) {
this.priorityQueue.push(taskId, task, priority);
if (cleanup) {
this.taskCleaners.set(taskId, cleanup);
}
this.getOrCreateComponentTasks(componentId).add(taskId);
const lastTime = this.assetStore.lastScrollTime;
const delta = Date.now() - lastTime;
if (lastTime != 0 && delta < TUNABLES.SCROLL_TASK_QUEUE.MIN_DELAY_MS) {
this.scheduleDrainIntersectedQueue();
} else {
// flush the queue early
clearTimeout(this.queueTimer);
this.drainIntersectedQueue();
}
}
scheduleDrainSeparatedQueue() {
if (this.lastIdle) {
cancelIdleCB(this.lastIdle);
}
this.lastIdle = idleCB(
() => {
let count = 0;
let entry = this.idleQueue.entries().next().value;
while (entry) {
const [taskId, task] = entry;
this.idleQueue.delete(taskId);
task();
if (count++ >= TUNABLES.SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS) {
break;
}
entry = this.idleQueue.entries().next().value;
}
if (this.idleQueue.size > 0) {
this.scheduleDrainSeparatedQueue();
}
},
{ timeout: 1000 },
);
}
queueSeparateTask({
task,
cleanup,
componentId,
taskId,
}: {
task: Task;
cleanup: Task;
componentId: string;
taskId: string;
}) {
this.idleQueue.set(taskId, task);
this.taskCleaners.set(taskId, cleanup);
this.getOrCreateComponentTasks(componentId).add(taskId);
this.scheduleDrainSeparatedQueue();
}
removeIntersectedTask(taskId: string) {
const removed = this.priorityQueue.remove(taskId);
if (this.taskCleaners.has(taskId)) {
const cleanup = this.taskCleaners.get(taskId);
this.taskCleaners.delete(taskId);
cleanup!();
}
return removed;
}
removeSeparateTask(taskId: string) {
const removed = this.idleQueue.delete(taskId);
if (this.taskCleaners.has(taskId)) {
const cleanup = this.taskCleaners.get(taskId);
this.taskCleaners.delete(taskId);
cleanup!();
}
return removed;
}
}
export class AssetGridTaskManager {
private internalManager: InternalTaskManager;
constructor(assetStore: AssetStore) {
this.internalManager = new InternalTaskManager(assetStore);
}
tasks: Map<AssetBucket, BucketTask> = new Map();
queueScrollSensitiveTask({
task,
cleanup,
componentId,
priority = 10,
taskId = generateId(),
}: {
task: Task;
cleanup?: Task;
componentId: string;
priority?: number;
taskId?: string;
}) {
return this.internalManager.queueScrollSensitiveTask({ task, cleanup, componentId, priority, taskId });
}
removeAllTasksForComponent(componentId: string) {
return this.internalManager.removeAllTasksForComponent(componentId);
}
destroy() {
return this.internalManager.destroy();
}
private getOrCreateBucketTask(bucket: AssetBucket) {
let bucketTask = this.tasks.get(bucket);
if (!bucketTask) {
bucketTask = this.createBucketTask(bucket);
}
return bucketTask;
}
private createBucketTask(bucket: AssetBucket) {
const bucketTask = new BucketTask(this.internalManager, this, bucket);
this.tasks.set(bucket, bucketTask);
return bucketTask;
}
intersectedBucket(componentId: string, bucket: AssetBucket, task: Task) {
const bucketTask = this.getOrCreateBucketTask(bucket);
bucketTask.scheduleIntersected(componentId, task);
}
seperatedBucket(componentId: string, bucket: AssetBucket, seperated: Task) {
const bucketTask = this.getOrCreateBucketTask(bucket);
bucketTask.scheduleSeparated(componentId, seperated);
}
intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) {
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
bucketTask.intersectedDateGroup(componentId, dateGroup, intersected);
}
seperatedDateGroup(componentId: string, dateGroup: DateGroup, seperated: Task) {
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
bucketTask.separatedDateGroup(componentId, dateGroup, seperated);
}
intersectedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, intersected: Task) {
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup);
dateGroupTask.intersectedThumbnail(componentId, asset, intersected);
}
seperatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, seperated: Task) {
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup);
dateGroupTask.separatedThumbnail(componentId, asset, seperated);
}
}
class IntersectionTask {
internalTaskManager: InternalTaskManager;
seperatedKey;
intersectedKey;
priority;
intersected: Task | undefined;
separated: Task | undefined;
constructor(internalTaskManager: InternalTaskManager, keyPrefix: string, key: string, priority: number) {
this.internalTaskManager = internalTaskManager;
this.seperatedKey = keyPrefix + ':s:' + key;
this.intersectedKey = keyPrefix + ':i:' + key;
this.priority = priority;
}
trackIntersectedTask(componentId: string, task: Task) {
const execTask = () => {
if (this.separated) {
return;
}
task?.();
};
this.intersected = execTask;
const cleanup = () => {
this.intersected = undefined;
this.internalTaskManager.deleteFromComponentTasks(componentId, this.intersectedKey);
};
return { task: execTask, cleanup };
}
trackSeperatedTask(componentId: string, task: Task) {
const execTask = () => {
if (this.intersected) {
return;
}
task?.();
};
this.separated = execTask;
const cleanup = () => {
this.separated = undefined;
this.internalTaskManager.deleteFromComponentTasks(componentId, this.seperatedKey);
};
return { task: execTask, cleanup };
}
removePendingSeparated() {
if (this.separated) {
this.internalTaskManager.removeSeparateTask(this.seperatedKey);
}
}
removePendingIntersected() {
if (this.intersected) {
this.internalTaskManager.removeIntersectedTask(this.intersectedKey);
}
}
scheduleIntersected(componentId: string, intersected: Task) {
this.removePendingSeparated();
if (this.intersected) {
return;
}
const { task, cleanup } = this.trackIntersectedTask(componentId, intersected);
this.internalTaskManager.queueScrollSensitiveTask({
task,
cleanup,
componentId: componentId,
priority: this.priority,
taskId: this.intersectedKey,
});
}
scheduleSeparated(componentId: string, separated: Task) {
this.removePendingIntersected();
if (this.separated) {
return;
}
const { task, cleanup } = this.trackSeperatedTask(componentId, separated);
this.internalTaskManager.queueSeparateTask({
task,
cleanup,
componentId: componentId,
taskId: this.seperatedKey,
});
}
}
class BucketTask extends IntersectionTask {
assetBucket: AssetBucket;
assetGridTaskManager: AssetGridTaskManager;
// indexed by dateGroup's date
dateTasks: Map<DateGroup, DateGroupTask> = new Map();
constructor(internalTaskManager: InternalTaskManager, parent: AssetGridTaskManager, assetBucket: AssetBucket) {
super(internalTaskManager, 'b', assetBucket.bucketDate, TUNABLES.BUCKET.PRIORITY);
this.assetBucket = assetBucket;
this.assetGridTaskManager = parent;
}
getOrCreateDateGroupTask(dateGroup: DateGroup) {
let dateGroupTask = this.dateTasks.get(dateGroup);
if (!dateGroupTask) {
dateGroupTask = this.createDateGroupTask(dateGroup);
}
return dateGroupTask;
}
createDateGroupTask(dateGroup: DateGroup) {
const dateGroupTask = new DateGroupTask(this.internalTaskManager, this, dateGroup);
this.dateTasks.set(dateGroup, dateGroupTask);
return dateGroupTask;
}
removePendingSeparated() {
super.removePendingSeparated();
for (const dateGroupTask of this.dateTasks.values()) {
dateGroupTask.removePendingSeparated();
}
}
intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) {
const dateGroupTask = this.getOrCreateDateGroupTask(dateGroup);
dateGroupTask.scheduleIntersected(componentId, intersected);
}
separatedDateGroup(componentId: string, dateGroup: DateGroup, separated: Task) {
const dateGroupTask = this.getOrCreateDateGroupTask(dateGroup);
dateGroupTask.scheduleSeparated(componentId, separated);
}
}
class DateGroupTask extends IntersectionTask {
dateGroup: DateGroup;
bucketTask: BucketTask;
// indexed by thumbnail's asset
thumbnailTasks: Map<AssetResponseDto, ThumbnailTask> = new Map();
constructor(internalTaskManager: InternalTaskManager, parent: BucketTask, dateGroup: DateGroup) {
super(internalTaskManager, 'dg', dateGroup.date.toString(), TUNABLES.DATEGROUP.PRIORITY);
this.dateGroup = dateGroup;
this.bucketTask = parent;
}
removePendingSeparated() {
super.removePendingSeparated();
for (const thumbnailTask of this.thumbnailTasks.values()) {
thumbnailTask.removePendingSeparated();
}
}
getOrCreateThumbnailTask(asset: AssetResponseDto) {
let thumbnailTask = this.thumbnailTasks.get(asset);
if (!thumbnailTask) {
thumbnailTask = new ThumbnailTask(this.internalTaskManager, this, asset);
this.thumbnailTasks.set(asset, thumbnailTask);
}
return thumbnailTask;
}
intersectedThumbnail(componentId: string, asset: AssetResponseDto, intersected: Task) {
const thumbnailTask = this.getOrCreateThumbnailTask(asset);
thumbnailTask.scheduleIntersected(componentId, intersected);
}
separatedThumbnail(componentId: string, asset: AssetResponseDto, seperated: Task) {
const thumbnailTask = this.getOrCreateThumbnailTask(asset);
thumbnailTask.scheduleSeparated(componentId, seperated);
}
}
class ThumbnailTask extends IntersectionTask {
asset: AssetResponseDto;
dateGroupTask: DateGroupTask;
constructor(internalTaskManager: InternalTaskManager, parent: DateGroupTask, asset: AssetResponseDto) {
super(internalTaskManager, 't', asset.id, TUNABLES.THUMBNAIL.PRIORITY);
this.asset = asset;
this.dateGroupTask = parent;
}
}

View file

@ -4,7 +4,7 @@ import { NotificationType, notificationController } from '$lib/components/shared
import { AppRoute } from '$lib/constants';
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store';
import { isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store';
import { downloadManager } from '$lib/stores/download';
import { preferences } from '$lib/stores/user.store';
import { downloadRequest, getKey, withError } from '$lib/utils';
@ -403,7 +403,7 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteractionSt
try {
for (const bucket of assetStore.buckets) {
await assetStore.loadBucket(bucket.bucketDate, BucketPosition.Unknown);
await assetStore.loadBucket(bucket.bucketDate);
if (!get(isSelectingAllAssets)) {
break; // Cancelled

View file

@ -0,0 +1,20 @@
interface RequestIdleCallback {
didTimeout?: boolean;
timeRemaining?(): DOMHighResTimeStamp;
}
interface RequestIdleCallbackOptions {
timeout?: number;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function fake_requestIdleCallback(cb: (deadline: RequestIdleCallback) => any, _?: RequestIdleCallbackOptions) {
const start = Date.now();
return setTimeout(cb({ didTimeout: false, timeRemaining: () => Math.max(0, 50 - (Date.now() - start)) }), 100);
}
function fake_cancelIdleCallback(id: number) {
return clearTimeout(id);
}
export const idleCB = window.requestIdleCallback || fake_requestIdleCallback;
export const cancelIdleCB = window.cancelIdleCallback || fake_cancelIdleCallback;

View file

@ -0,0 +1,50 @@
export class KeyedPriorityQueue<K, T> {
private items: { key: K; value: T; priority: number }[] = [];
private set: Set<K> = new Set();
clear() {
this.items = [];
this.set.clear();
}
remove(key: K) {
const removed = this.set.delete(key);
if (removed) {
const idx = this.items.findIndex((i) => i.key === key);
if (idx >= 0) {
this.items.splice(idx, 1);
}
}
return removed;
}
push(key: K, value: T, priority: number) {
if (this.set.has(key)) {
return this.length;
}
for (let i = 0; i < this.items.length; i++) {
if (this.items[i].priority > priority) {
this.set.add(key);
this.items.splice(i, 0, { key, value, priority });
return this.length;
}
}
this.set.add(key);
return this.items.push({ key, value, priority });
}
shift() {
let item = this.items.shift();
while (item) {
if (this.set.has(item.key)) {
this.set.delete(item.key);
return item;
}
item = this.items.shift();
}
}
get length() {
return this.set.size;
}
}

View file

@ -5,6 +5,9 @@ import { getAssetInfo } from '@immich/sdk';
import type { NavigationTarget } from '@sveltejs/kit';
import { get } from 'svelte/store';
export type AssetGridRouteSearchParams = {
at: string | null | undefined;
};
export const isExternalUrl = (url: string): boolean => {
return new URL(url, window.location.href).origin !== window.location.origin;
};
@ -33,17 +36,38 @@ function currentUrlWithoutAsset() {
export function currentUrlReplaceAssetId(assetId: string) {
const $page = get(page);
const params = new URLSearchParams($page.url.search);
// always remove the assetGridScrollTargetParams
params.delete('at');
const searchparams = params.size > 0 ? '?' + params.toString() : '';
// this contains special casing for the /photos/:assetId photos route, which hangs directly
// off / instead of a subpath, unlike every other asset-containing route.
return isPhotosRoute($page.route.id)
? `${AppRoute.PHOTOS}/${assetId}${$page.url.search}`
: `${$page.url.pathname.replace(/(\/photos.*)$/, '')}/photos/${assetId}${$page.url.search}`;
? `${AppRoute.PHOTOS}/${assetId}${searchparams}`
: `${$page.url.pathname.replace(/(\/photos.*)$/, '')}/photos/${assetId}${searchparams}`;
}
function replaceScrollTarget(url: string, searchParams?: AssetGridRouteSearchParams | null) {
const $page = get(page);
const parsed = new URL(url, $page.url);
const { at: assetId } = searchParams || { at: null };
if (!assetId) {
return parsed.pathname;
}
const params = new URLSearchParams($page.url.search);
if (assetId) {
params.set('at', assetId);
}
return parsed.pathname + '?' + params.toString();
}
function currentUrl() {
const $page = get(page);
const current = $page.url;
return current.pathname + current.search;
return current.pathname + current.search + current.hash;
}
interface Route {
@ -55,24 +79,58 @@ interface Route {
interface AssetRoute extends Route {
targetRoute: 'current';
assetId: string | null;
assetId: string | null | undefined;
}
interface AssetGridRoute extends Route {
targetRoute: 'current';
assetId: string | null | undefined;
assetGridRouteSearchParams: AssetGridRouteSearchParams | null | undefined;
}
type ImmichRoute = AssetRoute | AssetGridRoute;
type NavOptions = {
/* navigate even if url is the same */
forceNavigate?: boolean | undefined;
replaceState?: boolean | undefined;
noScroll?: boolean | undefined;
keepFocus?: boolean | undefined;
invalidateAll?: boolean | undefined;
state?: App.PageState | undefined;
};
function isAssetRoute(route: Route): route is AssetRoute {
return route.targetRoute === 'current' && 'assetId' in route;
}
async function navigateAssetRoute(route: AssetRoute) {
function isAssetGridRoute(route: Route): route is AssetGridRoute {
return route.targetRoute === 'current' && 'assetId' in route && 'assetGridRouteSearchParams' in route;
}
async function navigateAssetRoute(route: AssetRoute, options?: NavOptions) {
const { assetId } = route;
const next = assetId ? currentUrlReplaceAssetId(assetId) : currentUrlWithoutAsset();
if (next !== currentUrl()) {
await goto(next, { replaceState: false });
const current = currentUrl();
if (next !== current || options?.forceNavigate) {
await goto(next, options);
}
}
export function navigate<T extends Route>(change: T): Promise<void> {
if (isAssetRoute(change)) {
return navigateAssetRoute(change);
async function navigateAssetGridRoute(route: AssetGridRoute, options?: NavOptions) {
const { assetId, assetGridRouteSearchParams: assetGridScrollTarget } = route;
const assetUrl = assetId ? currentUrlReplaceAssetId(assetId) : currentUrlWithoutAsset();
const next = replaceScrollTarget(assetUrl, assetGridScrollTarget);
const current = currentUrl();
if (next !== current || options?.forceNavigate) {
await goto(next, options);
}
}
export function navigate(change: ImmichRoute, options?: NavOptions): Promise<void> {
if (isAssetGridRoute(change)) {
return navigateAssetGridRoute(change, options);
} else if (isAssetRoute(change)) {
return navigateAssetRoute(change, options);
}
// future navigation requests here
throw `Invalid navigation: ${JSON.stringify(change)}`;

View file

@ -0,0 +1,21 @@
export class PriorityQueue<T> {
private items: { value: T; priority: number }[] = [];
push(value: T, priority: number) {
for (let i = 0; i < this.items.length; i++) {
if (this.items[i].priority > priority) {
this.items.splice(i, 0, { value, priority });
return this.length;
}
}
return this.items.push({ value, priority });
}
shift() {
return this.items.shift();
}
get length() {
return this.items.length;
}
}

View file

@ -1,9 +1,38 @@
import type { AssetBucket } from '$lib/stores/assets.store';
import { locale } from '$lib/stores/preferences.store';
import type { AssetResponseDto } from '@immich/sdk';
import { groupBy, sortBy } from 'lodash-es';
import type createJustifiedLayout from 'justified-layout';
import { groupBy, memoize, sortBy } from 'lodash-es';
import { DateTime } from 'luxon';
import { get } from 'svelte/store';
export type DateGroup = {
date: DateTime;
groupTitle: string;
assets: AssetResponseDto[];
height: number;
heightActual: boolean;
intersecting: boolean;
geometry: Geometry;
bucket: AssetBucket;
};
export type ScrubberListener = (
bucketDate: string | undefined,
overallScrollPercent: number,
bucketScrollPercent: number,
) => void | Promise<void>;
export type ScrollTargetListener = ({
bucket,
dateGroup,
asset,
offset,
}: {
bucket: AssetBucket;
dateGroup: DateGroup;
asset: AssetResponseDto;
offset: number;
}) => void;
export const fromLocalDateTime = (localDateTime: string) =>
DateTime.fromISO(localDateTime, { zone: 'UTC', locale: get(locale) });
@ -48,20 +77,48 @@ export function formatGroupTitle(_date: DateTime): string {
return date.toLocaleString(groupDateFormat);
}
export function splitBucketIntoDateGroups(
assets: AssetResponseDto[],
locale: string | undefined,
): AssetResponseDto[][] {
const grouped = groupBy(assets, (asset) =>
type Geometry = ReturnType<typeof createJustifiedLayout> & {
containerWidth: number;
};
function emptyGeometry() {
return {
containerWidth: 0,
containerHeight: 0,
widowCount: 0,
boxes: [],
};
}
const formatDateGroupTitle = memoize(formatGroupTitle);
export function splitBucketIntoDateGroups(bucket: AssetBucket, locale: string | undefined): DateGroup[] {
const grouped = groupBy(bucket.assets, (asset) =>
fromLocalDateTime(asset.localDateTime).toLocaleString(groupDateFormat, { locale }),
);
return sortBy(grouped, (group) => assets.indexOf(group[0]));
const sorted = sortBy(grouped, (group) => bucket.assets.indexOf(group[0]));
return sorted.map((group) => {
const date = fromLocalDateTime(group[0].localDateTime).startOf('day');
return {
date,
groupTitle: formatDateGroupTitle(date),
assets: group,
height: 0,
heightActual: false,
intersecting: false,
geometry: emptyGeometry(),
bucket: bucket,
};
});
}
export type LayoutBox = {
aspectRatio: number;
top: number;
left: number;
width: number;
height: number;
left: number;
forcedAspectRatio?: boolean;
};
export function calculateWidth(boxes: LayoutBox[]): number {
@ -71,6 +128,14 @@ export function calculateWidth(boxes: LayoutBox[]): number {
width = box.left + box.width;
}
}
return width;
}
export function findTotalOffset(element: HTMLElement, stop: HTMLElement) {
let offset = 0;
while (element.offsetParent && element !== stop) {
offset += element.offsetTop;
element = element.offsetParent as HTMLElement;
}
return offset;
}

View file

@ -0,0 +1,63 @@
function getBoolean(string: string | null, fallback: boolean) {
if (string === null) {
return fallback;
}
return 'true' === string;
}
function getNumber(string: string | null, fallback: number) {
if (string === null) {
return fallback;
}
return Number.parseInt(string);
}
function getFloat(string: string | null, fallback: number) {
if (string === null) {
return fallback;
}
return Number.parseFloat(string);
}
export const TUNABLES = {
SCROLL_TASK_QUEUE: {
TRICKLE_BONUS_FACTOR: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_BONUS_FACTOR'), 25),
TRICKLE_ACCELERATION_FACTOR: getFloat(localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATION_FACTOR'), 1.5),
TRICKLE_ACCELERATED_MIN_DELAY: getNumber(
localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MIN_DELAY'),
8,
),
TRICKLE_ACCELERATED_MAX_DELAY: getNumber(
localStorage.getItem('SCROLL_TASK_QUEUE.TRICKLE_ACCELERATED_MAX_DELAY'),
2000,
),
DRAIN_MAX_TASKS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS'), 15),
DRAIN_MAX_TASKS_DELAY_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.DRAIN_MAX_TASKS_DELAY_MS'), 16),
MIN_DELAY_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.MIN_DELAY_MS')!, 200),
CHECK_INTERVAL_MS: getNumber(localStorage.getItem('SCROLL_TASK_QUEUE.CHECK_INTERVAL_MS'), 16),
},
INTERSECTION_OBSERVER_QUEUE: {
DRAIN_MAX_TASKS: getNumber(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.DRAIN_MAX_TASKS'), 15),
THROTTLE_MS: getNumber(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.THROTTLE_MS'), 16),
THROTTLE: getBoolean(localStorage.getItem('INTERSECTION_OBSERVER_QUEUE.THROTTLE'), true),
},
ASSET_GRID: {
NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(localStorage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false),
},
BUCKET: {
PRIORITY: getNumber(localStorage.getItem('BUCKET.PRIORITY'), 2),
INTERSECTION_ROOT_TOP: localStorage.getItem('BUCKET.INTERSECTION_ROOT_TOP') || '300%',
INTERSECTION_ROOT_BOTTOM: localStorage.getItem('BUCKET.INTERSECTION_ROOT_BOTTOM') || '300%',
},
DATEGROUP: {
PRIORITY: getNumber(localStorage.getItem('DATEGROUP.PRIORITY'), 4),
INTERSECTION_DISABLED: getBoolean(localStorage.getItem('DATEGROUP.INTERSECTION_DISABLED'), false),
INTERSECTION_ROOT_TOP: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_TOP') || '150%',
INTERSECTION_ROOT_BOTTOM: localStorage.getItem('DATEGROUP.INTERSECTION_ROOT_BOTTOM') || '150%',
},
THUMBNAIL: {
PRIORITY: getNumber(localStorage.getItem('THUMBNAIL.PRIORITY'), 8),
INTERSECTION_ROOT_TOP: localStorage.getItem('THUMBNAIL.INTERSECTION_ROOT_TOP') || '250%',
INTERSECTION_ROOT_BOTTOM: localStorage.getItem('THUMBNAIL.INTERSECTION_ROOT_BOTTOM') || '250%',
},
IMAGE_THUMBNAIL: {
THUMBHASH_FADE_DURATION: getNumber(localStorage.getItem('THUMBHASH_FADE_DURATION'), 150),
},
};