mirror of https://github.com/immich-app/immich.git
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>pull/11968/head
parent
07538299cf
commit
837b1e4929
@ -1,4 +1,7 @@
|
||||
export const autoGrowHeight = (textarea: HTMLTextAreaElement, height = 'auto') => {
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
textarea.style.height = height;
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
};
|
||||
|
||||
@ -0,0 +1,152 @@
|
||||
type Config = IntersectionObserverActionProperties & {
|
||||
observer?: IntersectionObserver;
|
||||
};
|
||||
type TrackedProperties = {
|
||||
root?: Element | Document | null;
|
||||
threshold?: number | number[];
|
||||
top?: string;
|
||||
right?: string;
|
||||
bottom?: string;
|
||||
left?: string;
|
||||
};
|
||||
type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElement) => unknown;
|
||||
type OnSeperateCallback = (element: HTMLElement) => unknown;
|
||||
type IntersectionObserverActionProperties = {
|
||||
key?: string;
|
||||
onSeparate?: OnSeperateCallback;
|
||||
onIntersect?: OnIntersectCallback;
|
||||
|
||||
root?: Element | Document | null;
|
||||
threshold?: number | number[];
|
||||
top?: string;
|
||||
right?: string;
|
||||
bottom?: string;
|
||||
left?: string;
|
||||
|
||||
disabled?: boolean;
|
||||
};
|
||||
type TaskKey = HTMLElement | string;
|
||||
|
||||
function isEquivalent(a: TrackedProperties, b: TrackedProperties) {
|
||||
return (
|
||||
a?.bottom === b?.bottom &&
|
||||
a?.top === b?.top &&
|
||||
a?.left === b?.left &&
|
||||
a?.right == b?.right &&
|
||||
a?.threshold === b?.threshold &&
|
||||
a?.root === b?.root
|
||||
);
|
||||
}
|
||||
|
||||
const elementToConfig = new Map<TaskKey, Config>();
|
||||
|
||||
const observe = (key: HTMLElement | string, target: HTMLElement, properties: IntersectionObserverActionProperties) => {
|
||||
if (!target.isConnected) {
|
||||
elementToConfig.get(key)?.observer?.unobserve(target);
|
||||
return;
|
||||
}
|
||||
const {
|
||||
root,
|
||||
threshold,
|
||||
top = '0px',
|
||||
right = '0px',
|
||||
bottom = '0px',
|
||||
left = '0px',
|
||||
onSeparate,
|
||||
onIntersect,
|
||||
} = properties;
|
||||
const rootMargin = `${top} ${right} ${bottom} ${left}`;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries: IntersectionObserverEntry[]) => {
|
||||
// This IntersectionObserver is limited to observing a single element, the one the
|
||||
// action is attached to. If there are multiple entries, it means that this
|
||||
// observer is being notified of multiple events that have occured quickly together,
|
||||
// and the latest element is the one we are interested in.
|
||||
|
||||
entries.sort((a, b) => a.time - b.time);
|
||||
|
||||
const latestEntry = entries.pop();
|
||||
if (latestEntry?.isIntersecting) {
|
||||
onIntersect?.(latestEntry);
|
||||
} else {
|
||||
onSeparate?.(target);
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin,
|
||||
threshold,
|
||||
root,
|
||||
},
|
||||
);
|
||||
observer.observe(target);
|
||||
elementToConfig.set(key, { ...properties, observer });
|
||||
};
|
||||
|
||||
function configure(key: HTMLElement | string, element: HTMLElement, properties: IntersectionObserverActionProperties) {
|
||||
elementToConfig.set(key, properties);
|
||||
observe(key, element, properties);
|
||||
}
|
||||
|
||||
function _intersectionObserver(
|
||||
key: HTMLElement | string,
|
||||
element: HTMLElement,
|
||||
properties: IntersectionObserverActionProperties,
|
||||
) {
|
||||
if (properties.disabled) {
|
||||
properties.onIntersect?.(element);
|
||||
} else {
|
||||
configure(key, element, properties);
|
||||
}
|
||||
return {
|
||||
update(properties: IntersectionObserverActionProperties) {
|
||||
const config = elementToConfig.get(key);
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
if (isEquivalent(config, properties)) {
|
||||
return;
|
||||
}
|
||||
configure(key, element, properties);
|
||||
},
|
||||
destroy: () => {
|
||||
if (properties.disabled) {
|
||||
properties.onSeparate?.(element);
|
||||
} else {
|
||||
const config = elementToConfig.get(key);
|
||||
const { observer, onSeparate } = config || {};
|
||||
observer?.unobserve(element);
|
||||
elementToConfig.delete(key);
|
||||
if (onSeparate) {
|
||||
onSeparate?.(element);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function intersectionObserver(
|
||||
element: HTMLElement,
|
||||
properties: IntersectionObserverActionProperties | IntersectionObserverActionProperties[],
|
||||
) {
|
||||
// svelte doesn't allow multiple use:action directives of the same kind on the same element,
|
||||
// so accept an array when multiple configurations are needed.
|
||||
if (Array.isArray(properties)) {
|
||||
if (!properties.every((p) => p.key)) {
|
||||
throw new Error('Multiple configurations must specify key');
|
||||
}
|
||||
const observers = properties.map((p) => _intersectionObserver(p.key as string, element, p));
|
||||
return {
|
||||
update: (properties: IntersectionObserverActionProperties[]) => {
|
||||
for (const [i, props] of properties.entries()) {
|
||||
observers[i].update(props);
|
||||
}
|
||||
},
|
||||
destroy: () => {
|
||||
for (const observer of observers) {
|
||||
observer.destroy();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
return _intersectionObserver(element, element, properties);
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void;
|
||||
|
||||
let observer: ResizeObserver;
|
||||
let callbacks: WeakMap<HTMLElement, OnResizeCallback>;
|
||||
|
||||
/**
|
||||
* Installs a resizeObserver on the given element - when the element changes
|
||||
* size, invokes a callback function with the width/height. Intended as a
|
||||
* replacement for bind:clientWidth and bind:clientHeight in svelte4 which use
|
||||
* an iframe to measure the size of the element, which can be bad for
|
||||
* performance and memory usage. In svelte5, they adapted bind:clientHeight and
|
||||
* bind:clientWidth to use an internal resize observer.
|
||||
*
|
||||
* TODO: When svelte5 is ready, go back to bind:clientWidth and
|
||||
* bind:clientHeight.
|
||||
*/
|
||||
export function resizeObserver(element: HTMLElement, onResize: OnResizeCallback) {
|
||||
if (!observer) {
|
||||
callbacks = new WeakMap();
|
||||
observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const onResize = callbacks.get(entry.target as HTMLElement);
|
||||
if (onResize) {
|
||||
onResize({
|
||||
target: entry.target as HTMLElement,
|
||||
width: entry.borderBoxSize[0].inlineSize,
|
||||
height: entry.borderBoxSize[0].blockSize,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
callbacks.set(element, onResize);
|
||||
observer.observe(element);
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
callbacks.delete(element);
|
||||
observer.unobserve(element);
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import { decodeBase64 } from '$lib/utils';
|
||||
import { thumbHashToRGBA } from 'thumbhash';
|
||||
|
||||
export function thumbhash(canvas: HTMLCanvasElement, { base64ThumbHash }: { base64ThumbHash: string }) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const { w, h, rgba } = thumbHashToRGBA(decodeBase64(base64ThumbHash));
|
||||
const pixels = ctx.createImageData(w, h);
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
pixels.data.set(rgba);
|
||||
ctx.putImageData(pixels, 0, 0);
|
||||
}
|
||||
}
|
||||
@ -1,82 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { BucketPosition } from '$lib/stores/assets.store';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
|
||||
export let once = false;
|
||||
export let top = 0;
|
||||
export let bottom = 0;
|
||||
export let left = 0;
|
||||
export let right = 0;
|
||||
export let root: HTMLElement | null = null;
|
||||
|
||||
export let intersecting = false;
|
||||
let container: HTMLDivElement;
|
||||
const dispatch = createEventDispatcher<{
|
||||
hidden: HTMLDivElement;
|
||||
intersected: {
|
||||
container: HTMLDivElement;
|
||||
position: BucketPosition;
|
||||
};
|
||||
}>();
|
||||
|
||||
onMount(() => {
|
||||
if (typeof IntersectionObserver !== 'undefined') {
|
||||
const rootMargin = `${top}px ${right}px ${bottom}px ${left}px`;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
intersecting = entries.some((entry) => entry.isIntersecting);
|
||||
if (!intersecting) {
|
||||
dispatch('hidden', container);
|
||||
}
|
||||
|
||||
if (intersecting && once) {
|
||||
observer.unobserve(container);
|
||||
}
|
||||
|
||||
if (intersecting) {
|
||||
let position: BucketPosition = BucketPosition.Visible;
|
||||
if (entries[0].boundingClientRect.top + 50 > entries[0].intersectionRect.bottom) {
|
||||
position = BucketPosition.Below;
|
||||
} else if (entries[0].boundingClientRect.bottom < 0) {
|
||||
position = BucketPosition.Above;
|
||||
}
|
||||
|
||||
dispatch('intersected', {
|
||||
container,
|
||||
position,
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin,
|
||||
root,
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(container);
|
||||
return () => observer.unobserve(container);
|
||||
}
|
||||
|
||||
// The following is a fallback for older browsers
|
||||
function handler() {
|
||||
const bcr = container.getBoundingClientRect();
|
||||
|
||||
intersecting =
|
||||
bcr.bottom + bottom > 0 &&
|
||||
bcr.right + right > 0 &&
|
||||
bcr.top - top < window.innerHeight &&
|
||||
bcr.left - left < window.innerWidth;
|
||||
|
||||
if (intersecting && once) {
|
||||
window.removeEventListener('scroll', handler);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handler);
|
||||
return () => window.removeEventListener('scroll', handler);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={container}>
|
||||
<slot {intersecting} />
|
||||
</div>
|
||||
@ -0,0 +1,89 @@
|
||||
<script lang="ts" context="module">
|
||||
const recentTimes: number[] = [];
|
||||
// TODO: track average time to measure, and use this to populate TUNABLES.ASSETS_STORE.CHECK_INTERVAL_MS
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function adjustTunables(avg: number) {}
|
||||
function addMeasure(time: number) {
|
||||
recentTimes.push(time);
|
||||
if (recentTimes.length > 10) {
|
||||
recentTimes.shift();
|
||||
}
|
||||
const sum = recentTimes.reduce((acc: number, val: number) => {
|
||||
return acc + val;
|
||||
}, 0);
|
||||
const avg = sum / recentTimes.length;
|
||||
adjustTunables(avg);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { resizeObserver } from '$lib/actions/resize-observer';
|
||||
import type { AssetBucket, AssetStore, BucketListener } from '$lib/stores/assets.store';
|
||||
|
||||
export let assetStore: AssetStore;
|
||||
export let bucket: AssetBucket;
|
||||
export let onMeasured: () => void;
|
||||
|
||||
async function _measure(element: Element) {
|
||||
try {
|
||||
await bucket.complete;
|
||||
const t1 = Date.now();
|
||||
let heightPending = bucket.dateGroups.some((group) => !group.heightActual);
|
||||
if (heightPending) {
|
||||
const listener: BucketListener = (event) => {
|
||||
const { type } = event;
|
||||
if (type === 'height') {
|
||||
const { bucket: changedBucket } = event;
|
||||
if (changedBucket === bucket && type === 'height') {
|
||||
heightPending = bucket.dateGroups.some((group) => !group.heightActual);
|
||||
if (!heightPending) {
|
||||
const height = element.getBoundingClientRect().height;
|
||||
if (height !== 0) {
|
||||
$assetStore.updateBucket(bucket.bucketDate, { height: height, measured: true });
|
||||
}
|
||||
|
||||
onMeasured();
|
||||
$assetStore.removeListener(listener);
|
||||
const t2 = Date.now();
|
||||
|
||||
addMeasure((t2 - t1) / bucket.bucketCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
assetStore.addListener(listener);
|
||||
}
|
||||
} catch {
|
||||
// ignore if complete rejects (canceled load)
|
||||
}
|
||||
}
|
||||
function measure(element: Element) {
|
||||
void _measure(element);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section id="measure-asset-group-by-date" class="flex flex-wrap gap-x-12" use:measure>
|
||||
{#each bucket.dateGroups as dateGroup}
|
||||
<div id="date-group" data-date-group={dateGroup.date}>
|
||||
<div
|
||||
use:resizeObserver={({ height }) => $assetStore.updateBucketDateGroup(bucket, dateGroup, { height: height })}
|
||||
>
|
||||
<div
|
||||
class="flex z-[100] sticky top-[-1px] pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
|
||||
style:width={dateGroup.geometry.containerWidth + 'px'}
|
||||
>
|
||||
<span class="w-full truncate first-letter:capitalize">
|
||||
{dateGroup.groupTitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative overflow-clip"
|
||||
style:height={dateGroup.geometry.containerHeight + 'px'}
|
||||
style:width={dateGroup.geometry.containerWidth + 'px'}
|
||||
style:visibility={'hidden'}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
export let title: string | null = null;
|
||||
export let height: string | null = null;
|
||||
</script>
|
||||
|
||||
<div class="overflow-clip" style={`height: ${height}`}>
|
||||
{#if title}
|
||||
<div
|
||||
class="flex z-[100] sticky top-0 pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
|
||||
>
|
||||
<span class="w-full truncate first-letter:capitalize">{title}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div id="skeleton" style={`height: ${height}`}></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#skeleton {
|
||||
background-image: url('/light_skeleton.png');
|
||||
background-repeat: repeat;
|
||||
background-size: 235px, 235px;
|
||||
}
|
||||
:global(.dark) #skeleton {
|
||||
background-image: url('/dark_skeleton.png');
|
||||
}
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
#skeleton {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.1s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
||||
@ -1,183 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { AssetStore, AssetBucket } from '$lib/stores/assets.store';
|
||||
import type { DateTime } from 'luxon';
|
||||
import { fromLocalDateTime } from '$lib/utils/timeline-util';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
|
||||
export let timelineY = 0;
|
||||
export let height = 0;
|
||||
export let assetStore: AssetStore;
|
||||
|
||||
let isHover = false;
|
||||
let isDragging = false;
|
||||
let isAnimating = false;
|
||||
let hoverLabel = '';
|
||||
let hoverY = 0;
|
||||
let clientY = 0;
|
||||
let windowHeight = 0;
|
||||
let scrollBar: HTMLElement | undefined;
|
||||
|
||||
const toScrollY = (timelineY: number) => (timelineY / $assetStore.timelineHeight) * height;
|
||||
const toTimelineY = (scrollY: number) => Math.round((scrollY * $assetStore.timelineHeight) / height);
|
||||
|
||||
const HOVER_DATE_HEIGHT = 30;
|
||||
const MIN_YEAR_LABEL_DISTANCE = 16;
|
||||
|
||||
$: {
|
||||
hoverY = clamp(height - windowHeight + clientY, 0, height);
|
||||
if (scrollBar) {
|
||||
const rect = scrollBar.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + Math.min(hoverY, height - 1);
|
||||
updateLabel(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
$: scrollY = toScrollY(timelineY);
|
||||
|
||||
class Segment {
|
||||
public count = 0;
|
||||
public height = 0;
|
||||
public timeGroup = '';
|
||||
public date!: DateTime;
|
||||
public hasLabel = false;
|
||||
}
|
||||
|
||||
const calculateSegments = (buckets: AssetBucket[]) => {
|
||||
let height = 0;
|
||||
let previous: Segment;
|
||||
return buckets.map((bucket) => {
|
||||
const segment = new Segment();
|
||||
segment.count = bucket.assets.length;
|
||||
segment.height = toScrollY(bucket.bucketHeight);
|
||||
segment.timeGroup = bucket.bucketDate;
|
||||
segment.date = fromLocalDateTime(segment.timeGroup);
|
||||
|
||||
if (previous?.date.year !== segment.date.year && height > MIN_YEAR_LABEL_DISTANCE) {
|
||||
previous.hasLabel = true;
|
||||
height = 0;
|
||||
}
|
||||
|
||||
height += segment.height;
|
||||
previous = segment;
|
||||
return segment;
|
||||
});
|
||||
};
|
||||
|
||||
$: segments = calculateSegments($assetStore.buckets);
|
||||
|
||||
const dispatch = createEventDispatcher<{ scrollTimeline: number }>();
|
||||
const scrollTimeline = () => dispatch('scrollTimeline', toTimelineY(hoverY));
|
||||
|
||||
const updateLabel = (cursorX: number, cursorY: number) => {
|
||||
const segment = document.elementsFromPoint(cursorX, cursorY).find(({ id }) => id === 'time-segment');
|
||||
if (!segment) {
|
||||
return;
|
||||
}
|
||||
const attr = (segment as HTMLElement).dataset.date;
|
||||
if (!attr) {
|
||||
return;
|
||||
}
|
||||
hoverLabel = new Date(attr).toLocaleString($locale, {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => {
|
||||
const wasDragging = isDragging;
|
||||
|
||||
isDragging = event.isDragging ?? isDragging;
|
||||
clientY = event.clientY;
|
||||
|
||||
if (wasDragging === false && isDragging) {
|
||||
scrollTimeline();
|
||||
}
|
||||
|
||||
if (!isDragging || isAnimating) {
|
||||
return;
|
||||
}
|
||||
|
||||
isAnimating = true;
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
scrollTimeline();
|
||||
isAnimating = false;
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
bind:innerHeight={windowHeight}
|
||||
on:mousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })}
|
||||
on:mousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })}
|
||||
on:mouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
|
||||
/>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
|
||||
{#if $assetStore.timelineHeight > height}
|
||||
<div
|
||||
id="immich-scrubbable-scrollbar"
|
||||
class="absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize"
|
||||
style:width={isDragging ? '100vw' : '60px'}
|
||||
style:height={height + 'px'}
|
||||
style:background-color={isDragging ? 'transparent' : 'transparent'}
|
||||
draggable="false"
|
||||
bind:this={scrollBar}
|
||||
on:mouseenter={() => (isHover = true)}
|
||||
on:mouseleave={() => (isHover = false)}
|
||||
>
|
||||
{#if isHover || isDragging}
|
||||
<div
|
||||
id="time-label"
|
||||
class="pointer-events-none absolute right-0 z-[100] min-w-24 w-fit whitespace-nowrap rounded-tl-md border-b-2 border-immich-primary bg-immich-bg py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||
style:top="{clamp(hoverY - HOVER_DATE_HEIGHT, 0, height - HOVER_DATE_HEIGHT - 2)}px"
|
||||
>
|
||||
{hoverLabel}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Scroll Position Indicator Line -->
|
||||
{#if !isDragging}
|
||||
<div
|
||||
class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
|
||||
style:top="{scrollY}px"
|
||||
/>
|
||||
{/if}
|
||||
<!-- Time Segment -->
|
||||
{#each segments as segment}
|
||||
<div
|
||||
id="time-segment"
|
||||
class="relative"
|
||||
data-date={segment.date}
|
||||
style:height={segment.height + 'px'}
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
>
|
||||
{#if segment.hasLabel}
|
||||
<div
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
class="absolute right-0 bottom-0 z-10 pr-5 text-[12px] dark:text-immich-dark-fg font-immich-mono"
|
||||
>
|
||||
{segment.date.year}
|
||||
</div>
|
||||
{:else if segment.height > 5}
|
||||
<div
|
||||
aria-label={segment.timeGroup + ' ' + segment.count}
|
||||
class="absolute right-0 mr-3 block h-[4px] w-[4px] rounded-full bg-gray-300"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
#immich-scrubbable-scrollbar,
|
||||
#time-segment {
|
||||
contain: layout;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,281 @@
|
||||
<script lang="ts">
|
||||
import type { AssetStore, AssetBucket, BucketListener } from '$lib/stores/assets.store';
|
||||
import type { DateTime } from 'luxon';
|
||||
import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let timelineTopOffset = 0;
|
||||
export let timelineBottomOffset = 0;
|
||||
export let height = 0;
|
||||
export let assetStore: AssetStore;
|
||||
export let invisible = false;
|
||||
export let scrubOverallPercent: number = 0;
|
||||
export let scrubBucketPercent: number = 0;
|
||||
export let scrubBucket: { bucketDate: string | undefined } | undefined = undefined;
|
||||
export let leadout: boolean = false;
|
||||
export let onScrub: ScrubberListener | undefined = undefined;
|
||||
export let startScrub: ScrubberListener | undefined = undefined;
|
||||
export let stopScrub: ScrubberListener | undefined = undefined;
|
||||
|
||||
let isHover = false;
|
||||
let isDragging = false;
|
||||
let hoverLabel: string | undefined;
|
||||
let bucketDate: string | undefined;
|
||||
let hoverY = 0;
|
||||
let clientY = 0;
|
||||
let windowHeight = 0;
|
||||
let scrollBar: HTMLElement | undefined;
|
||||
let segments: Segment[] = [];
|
||||
|
||||
const toScrollY = (percent: number) => percent * (height - HOVER_DATE_HEIGHT * 2);
|
||||
const toTimelineY = (scrollY: number) => scrollY / (height - HOVER_DATE_HEIGHT * 2);
|
||||
|
||||
const HOVER_DATE_HEIGHT = 31.75;
|
||||
const MIN_YEAR_LABEL_DISTANCE = 16;
|
||||
const MIN_DOT_DISTANCE = 8;
|
||||
|
||||
const toScrollFromBucketPercentage = (
|
||||
scrubBucket: { bucketDate: string | undefined } | undefined,
|
||||
scrubBucketPercent: number,
|
||||
scrubOverallPercent: number,
|
||||
) => {
|
||||
if (scrubBucket) {
|
||||
let offset = relativeTopOffset;
|
||||
let match = false;
|
||||
for (const segment of segments) {
|
||||
if (segment.bucketDate === scrubBucket.bucketDate) {
|
||||
offset += scrubBucketPercent * segment.height;
|
||||
match = true;
|
||||
break;
|
||||
}
|
||||
offset += segment.height;
|
||||
}
|
||||
if (!match) {
|
||||
offset += scrubBucketPercent * relativeBottomOffset;
|
||||
}
|
||||
// 2px is the height of the indicator
|
||||
return offset - 2;
|
||||
} else if (leadout) {
|
||||
let offset = relativeTopOffset;
|
||||
for (const segment of segments) {
|
||||
offset += segment.height;
|
||||
}
|
||||
offset += scrubOverallPercent * relativeBottomOffset;
|
||||
return offset - 2;
|
||||
} else {
|
||||
// 2px is the height of the indicator
|
||||
return scrubOverallPercent * (height - HOVER_DATE_HEIGHT * 2) - 2;
|
||||
}
|
||||
};
|
||||
$: scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
|
||||
$: timelineFullHeight = $assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset;
|
||||
$: relativeTopOffset = toScrollY(timelineTopOffset / timelineFullHeight);
|
||||
$: relativeBottomOffset = toScrollY(timelineBottomOffset / timelineFullHeight);
|
||||
|
||||
const listener: BucketListener = (event) => {
|
||||
const { type } = event;
|
||||
if (type === 'viewport') {
|
||||
segments = calculateSegments($assetStore.buckets);
|
||||
scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
assetStore.addListener(listener);
|
||||
return () => assetStore.removeListener(listener);
|
||||
});
|
||||
|
||||
type Segment = {
|
||||
count: number;
|
||||
height: number;
|
||||
dateFormatted: string;
|
||||
bucketDate: string;
|
||||
date: DateTime;
|
||||
hasLabel: boolean;
|
||||
hasDot: boolean;
|
||||
};
|
||||
|
||||
const calculateSegments = (buckets: AssetBucket[]) => {
|
||||
let height = 0;
|
||||
let dotHeight = 0;
|
||||
|
||||
let segments: Segment[] = [];
|
||||
let previousLabeledSegment: Segment | undefined;
|
||||
|
||||
for (const [i, bucket] of buckets.entries()) {
|
||||
const scrollBarPercentage =
|
||||
bucket.bucketHeight / ($assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset);
|
||||
|
||||
const segment = {
|
||||
count: bucket.assets.length,
|
||||
height: toScrollY(scrollBarPercentage),
|
||||
bucketDate: bucket.bucketDate,
|
||||
date: fromLocalDateTime(bucket.bucketDate),
|
||||
dateFormatted: bucket.bucketDateFormattted,
|
||||
hasLabel: false,
|
||||
hasDot: false,
|
||||
};
|
||||
|
||||
if (i === 0) {
|
||||
segment.hasDot = true;
|
||||
segment.hasLabel = true;
|
||||
previousLabeledSegment = segment;
|
||||
} else {
|
||||
if (previousLabeledSegment?.date?.year !== segment.date.year && height > MIN_YEAR_LABEL_DISTANCE) {
|
||||
height = 0;
|
||||
segment.hasLabel = true;
|
||||
previousLabeledSegment = segment;
|
||||
}
|
||||
if (i !== 1 && segment.height > 5 && dotHeight > MIN_DOT_DISTANCE) {
|
||||
segment.hasDot = true;
|
||||
dotHeight = 0;
|
||||
}
|
||||
|
||||
height += segment.height;
|
||||
dotHeight += segment.height;
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
|
||||
hoverLabel = segments[0]?.dateFormatted;
|
||||
return segments;
|
||||
};
|
||||
|
||||
const updateLabel = (segment: HTMLElement) => {
|
||||
hoverLabel = segment.dataset.label;
|
||||
bucketDate = segment.dataset.timeSegmentBucketDate;
|
||||
};
|
||||
|
||||
const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => {
|
||||
const wasDragging = isDragging;
|
||||
|
||||
isDragging = event.isDragging ?? isDragging;
|
||||
clientY = event.clientY;
|
||||
|
||||
if (!scrollBar) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = scrollBar.getBoundingClientRect()!;
|
||||
const lower = 0;
|
||||
const upper = rect?.height - HOVER_DATE_HEIGHT * 2;
|
||||
hoverY = clamp(clientY - rect?.top - HOVER_DATE_HEIGHT, lower, upper);
|
||||
const x = rect!.left + rect!.width / 2;
|
||||
const elems = document.elementsFromPoint(x, clientY);
|
||||
const segment = elems.find(({ id }) => id === 'time-segment');
|
||||
let bucketPercentY = 0;
|
||||
if (segment) {
|
||||
updateLabel(segment as HTMLElement);
|
||||
const sr = segment.getBoundingClientRect();
|
||||
const sy = sr.y;
|
||||
const relativeY = clientY - sy;
|
||||
bucketPercentY = relativeY / sr.height;
|
||||
} else {
|
||||
const leadin = elems.find(({ id }) => id === 'lead-in');
|
||||
if (leadin) {
|
||||
updateLabel(leadin as HTMLElement);
|
||||
} else {
|
||||
bucketDate = undefined;
|
||||
bucketPercentY = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const scrollPercent = toTimelineY(hoverY);
|
||||
if (wasDragging === false && isDragging) {
|
||||
void startScrub?.(bucketDate, scrollPercent, bucketPercentY);
|
||||
void onScrub?.(bucketDate, scrollPercent, bucketPercentY);
|
||||
}
|
||||
|
||||
if (wasDragging && !isDragging) {
|
||||
void stopScrub?.(bucketDate, scrollPercent, bucketPercentY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
void onScrub?.(bucketDate, scrollPercent, bucketPercentY);
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
bind:innerHeight={windowHeight}
|
||||
on:mousemove={({ clientY }) => (isDragging || isHover) && handleMouseEvent({ clientY })}
|
||||
on:mousedown={({ clientY }) => isHover && handleMouseEvent({ clientY, isDragging: true })}
|
||||
on:mouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
|
||||
/>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
|
||||
<div
|
||||
id="immich-scrubbable-scrollbar"
|
||||
class={`absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize`}
|
||||
style:padding-top={HOVER_DATE_HEIGHT + 'px'}
|
||||
style:padding-bottom={HOVER_DATE_HEIGHT + 'px'}
|
||||
class:invisible
|
||||
style:width={isDragging ? '100vw' : '60px'}
|
||||
style:height={height + 'px'}
|
||||
style:background-color={isDragging ? 'transparent' : 'transparent'}
|
||||
draggable="false"
|
||||
bind:this={scrollBar}
|
||||
on:mouseenter={() => (isHover = true)}
|
||||
on:mouseleave={() => (isHover = false)}
|
||||
>
|
||||
{#if hoverLabel && (isHover || isDragging)}
|
||||
<div
|
||||
id="time-label"
|
||||
class="truncate opacity-85 pointer-events-none absolute right-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-immich-bg py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||
style:top="{hoverY + 2}px"
|
||||
>
|
||||
{hoverLabel}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Scroll Position Indicator Line -->
|
||||
{#if !isDragging}
|
||||
<div
|
||||
class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
|
||||
style:top="{scrollY + HOVER_DATE_HEIGHT}px"
|
||||
/>
|
||||
{/if}
|
||||
<div id="lead-in" class="relative" style:height={relativeTopOffset + 'px'} data-label={segments.at(0)?.dateFormatted}>
|
||||
{#if relativeTopOffset > 6}
|
||||
<div class="absolute right-[0.75rem] h-[4px] w-[4px] rounded-full bg-gray-300" />
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Time Segment -->
|
||||
{#each segments as segment}
|
||||
<div
|
||||
id="time-segment"
|
||||
class="relative"
|
||||
data-time-segment-bucket-date={segment.date}
|
||||
data-label={segment.dateFormatted}
|
||||
style:height={segment.height + 'px'}
|
||||
aria-label={segment.dateFormatted + ' ' + segment.count}
|
||||
>
|
||||
{#if segment.hasLabel}
|
||||
<div
|
||||
aria-label={segment.dateFormatted + ' ' + segment.count}
|
||||
class="absolute right-[1.25rem] top-[-16px] z-10 text-[12px] dark:text-immich-dark-fg font-immich-mono"
|
||||
>
|
||||
{segment.date.year}
|
||||
</div>
|
||||
{/if}
|
||||
{#if segment.hasDot}
|
||||
<div
|
||||
aria-label={segment.dateFormatted + ' ' + segment.count}
|
||||
class="absolute right-[0.75rem] bottom-0 h-[4px] w-[4px] rounded-full bg-gray-300"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<div id="lead-out" class="relative" style:height={relativeBottomOffset + 'px'}></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#immich-scrubbable-scrollbar,
|
||||
#time-segment {
|
||||
contain: layout size style;
|
||||
}
|
||||
</style>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
},
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
Loading…
Reference in New Issue