feat: timeline performance (#16446)
* Squash - feature complete * remove need to init assetstore * More optimizations. No need to init. Fix tests * lint * add missing selector for e2e * e2e selectors again * Update: fully reactive store, some transitions, bugfixes * merge fallout * Test fallout * safari quirk * security * lint * lint * Bug fixes * lint/format * accidental commit * lock * null check, more throttle * revert long duration * Fix intersection bounds * Fix bugs in intersection calculation * lint, tweak scrubber ui a tiny bit * bugfix - deselecting asset doesnt work * fix not loading bucket, scroll off-by-1 error, jsdoc, naming
This commit is contained in:
@@ -1,56 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { intersectionObserver } from '$lib/actions/intersection-observer';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
|
||||
import { AssetBucket, type AssetStore, type Viewport } from '$lib/stores/assets-store.svelte';
|
||||
import { AssetBucket } from '$lib/stores/assets-store.svelte';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import {
|
||||
findTotalOffset,
|
||||
type DateGroup,
|
||||
type ScrollTargetListener,
|
||||
getDateLocaleString,
|
||||
} from '$lib/utils/timeline-util';
|
||||
import { getDateLocaleString } from '$lib/utils/timeline-util';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { scale } from 'svelte/transition';
|
||||
|
||||
export let element: HTMLElement | undefined = undefined;
|
||||
export let isSelectionMode = false;
|
||||
export let viewport: Viewport;
|
||||
export let singleSelect = false;
|
||||
export let withStacked = false;
|
||||
export let showArchiveIcon = false;
|
||||
export let assetGridElement: HTMLElement | undefined = undefined;
|
||||
export let renderThumbsAtBottomMargin: string | undefined = undefined;
|
||||
export let renderThumbsAtTopMargin: string | undefined = undefined;
|
||||
export let assetStore: AssetStore;
|
||||
export let bucket: AssetBucket;
|
||||
export let assetInteraction: AssetInteraction;
|
||||
import { flip } from 'svelte/animate';
|
||||
|
||||
export let onScrollTarget: ScrollTargetListener | undefined = undefined;
|
||||
export let onAssetInGrid: ((asset: AssetResponseDto) => void) | undefined = undefined;
|
||||
export let onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void;
|
||||
export let onSelectAssets: (asset: AssetResponseDto) => void;
|
||||
export let onSelectAssetCandidates: (asset: AssetResponseDto | null) => void;
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
|
||||
const componentId = generateId();
|
||||
$: bucketDate = bucket.bucketDate;
|
||||
$: dateGroups = bucket.dateGroups;
|
||||
let { isUploading } = uploadAssetsStore;
|
||||
|
||||
const {
|
||||
DATEGROUP: { INTERSECTION_ROOT_TOP, INTERSECTION_ROOT_BOTTOM },
|
||||
} = TUNABLES;
|
||||
/* TODO figure out a way to calculate this*/
|
||||
const TITLE_HEIGHT = 51;
|
||||
interface Props {
|
||||
isSelectionMode: boolean;
|
||||
singleSelect: boolean;
|
||||
withStacked: boolean;
|
||||
showArchiveIcon: boolean;
|
||||
bucket: AssetBucket;
|
||||
assetInteraction: AssetInteraction;
|
||||
|
||||
let isMouseOverGroup = false;
|
||||
let hoveredDateGroup = '';
|
||||
onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void;
|
||||
onSelectAssets: (asset: AssetResponseDto) => void;
|
||||
onSelectAssetCandidates: (asset: AssetResponseDto | null) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
isSelectionMode,
|
||||
singleSelect,
|
||||
withStacked,
|
||||
showArchiveIcon,
|
||||
bucket = $bindable(),
|
||||
assetInteraction,
|
||||
onSelect,
|
||||
onSelectAssets,
|
||||
onSelectAssetCandidates,
|
||||
}: Props = $props();
|
||||
|
||||
let isMouseOverGroup = $state(false);
|
||||
let hoveredDateGroup = $state();
|
||||
|
||||
const transitionDuration = $derived.by(() => (bucket.store.suspendTransitions && !$isUploading ? 0 : 150));
|
||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||
const onClick = (assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
assetSelectHandler(asset, assets, groupTitle);
|
||||
@@ -59,13 +54,6 @@
|
||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
};
|
||||
|
||||
const onRetrieveElement = (dateGroup: DateGroup, asset: AssetResponseDto, element: HTMLElement) => {
|
||||
if (assetGridElement && onScrollTarget) {
|
||||
const offset = findTotalOffset(element, assetGridElement) - TITLE_HEIGHT;
|
||||
onScrollTarget({ bucket, dateGroup, asset, offset });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => onSelect({ title, assets });
|
||||
|
||||
const assetSelectHandler = (asset: AssetResponseDto, assetsInDateGroup: AssetResponseDto[], groupTitle: string) => {
|
||||
@@ -73,7 +61,7 @@
|
||||
|
||||
// Check if all assets are selected in a group to toggle the group selection's icon
|
||||
let selectedAssetsInGroupCount = assetsInDateGroup.filter((asset) =>
|
||||
assetInteraction.selectedAssets.has(asset),
|
||||
assetInteraction.hasSelectedAsset(asset.id),
|
||||
).length;
|
||||
|
||||
// if all assets are selected in a group, add the group to selected group
|
||||
@@ -83,7 +71,9 @@
|
||||
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
|
||||
}
|
||||
};
|
||||
|
||||
const snapshotAssetArray = (assets: AssetResponseDto[]) => {
|
||||
return assets.map((a) => $state.snapshot(a));
|
||||
};
|
||||
const assetMouseEventHandler = (groupTitle: string, asset: AssetResponseDto | null) => {
|
||||
// Show multi select icon on hover on date group
|
||||
hoveredDateGroup = groupTitle;
|
||||
@@ -96,155 +86,100 @@
|
||||
const assetOnFocusHandler = (asset: AssetResponseDto) => {
|
||||
assetInteraction.focussedAssetId = asset.id;
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
assetStore.taskManager.removeAllTasksForComponent(componentId);
|
||||
});
|
||||
function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) {
|
||||
return intersectable.filter((int) => int.intersecting);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" data-bucket-date={bucketDate} bind:this={element}>
|
||||
{#each dateGroups as dateGroup, groupIndex (dateGroup.date)}
|
||||
{@const display =
|
||||
dateGroup.intersecting || !!dateGroup.assets.some((asset) => asset.id === assetStore.pendingScrollAssetId)}
|
||||
{@const geometry = dateGroup.geometry!}
|
||||
{#each filterIntersecting(bucket.dateGroups) as dateGroup, groupIndex (dateGroup.date)}
|
||||
{@const absoluteWidth = dateGroup.left}
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<section
|
||||
class={[
|
||||
{ 'transition-all': !bucket.store.suspendTransitions },
|
||||
!bucket.store.suspendTransitions && `delay-${transitionDuration}`,
|
||||
]}
|
||||
data-group
|
||||
style:position="absolute"
|
||||
style:transform={`translate3d(${absoluteWidth}px,${dateGroup.top}px,0)`}
|
||||
onmouseenter={() => {
|
||||
isMouseOverGroup = true;
|
||||
assetMouseEventHandler(dateGroup.groupTitle, null);
|
||||
}}
|
||||
onmouseleave={() => {
|
||||
isMouseOverGroup = false;
|
||||
assetMouseEventHandler(dateGroup.groupTitle, null);
|
||||
}}
|
||||
>
|
||||
<!-- Date group title -->
|
||||
<div
|
||||
id="date-group"
|
||||
use:intersectionObserver={{
|
||||
onIntersect: () => {
|
||||
assetStore.taskManager.intersectedDateGroup(componentId, dateGroup, () =>
|
||||
assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: true }),
|
||||
);
|
||||
},
|
||||
onSeparate: () => {
|
||||
assetStore.taskManager.separatedDateGroup(componentId, dateGroup, () =>
|
||||
assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }),
|
||||
);
|
||||
},
|
||||
top: INTERSECTION_ROOT_TOP,
|
||||
bottom: INTERSECTION_ROOT_BOTTOM,
|
||||
root: assetGridElement,
|
||||
}}
|
||||
data-display={display}
|
||||
data-date-group={dateGroup.date}
|
||||
style:height={dateGroup.height + 'px'}
|
||||
style:width={geometry.containerWidth + 'px'}
|
||||
style:overflow="clip"
|
||||
class="flex z-[100] pt-[calc(1.75rem+1px)] 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.width + 'px'}
|
||||
>
|
||||
{#if !display}
|
||||
<Skeleton height={dateGroup.height + 'px'} title={dateGroup.groupTitle} />
|
||||
{/if}
|
||||
{#if display}
|
||||
<!-- Asset Group By Date -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
{#if !singleSelect && ((hoveredDateGroup === dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}
|
||||
<div
|
||||
on:mouseenter={() =>
|
||||
assetStore.taskManager.queueScrollSensitiveTask({
|
||||
componentId,
|
||||
task: () => {
|
||||
isMouseOverGroup = true;
|
||||
assetMouseEventHandler(dateGroup.groupTitle, null);
|
||||
},
|
||||
})}
|
||||
on:mouseleave={() => {
|
||||
assetStore.taskManager.queueScrollSensitiveTask({
|
||||
componentId,
|
||||
task: () => {
|
||||
isMouseOverGroup = false;
|
||||
assetMouseEventHandler(dateGroup.groupTitle, null);
|
||||
},
|
||||
});
|
||||
}}
|
||||
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
|
||||
class="inline-block px-2 hover:cursor-pointer"
|
||||
onclick={() => handleSelectGroup(dateGroup.groupTitle, snapshotAssetArray(dateGroup.getAssets()))}
|
||||
onkeydown={() => handleSelectGroup(dateGroup.groupTitle, snapshotAssetArray(dateGroup.getAssets()))}
|
||||
>
|
||||
<!-- Date group title -->
|
||||
<div
|
||||
class="flex z-[100] sticky top-[-1px] pt-[calc(1.75rem+1px)] 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={geometry.containerWidth + 'px'}
|
||||
>
|
||||
{#if !singleSelect && ((hoveredDateGroup == dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}
|
||||
<div
|
||||
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
|
||||
class="inline-block px-2 hover:cursor-pointer"
|
||||
on:click={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)}
|
||||
on:keydown={() => handleSelectGroup(dateGroup.groupTitle, dateGroup.assets)}
|
||||
>
|
||||
{#if assetInteraction.selectedGroup.has(dateGroup.groupTitle)}
|
||||
<Icon path={mdiCheckCircle} size="24" color="#4250af" />
|
||||
{:else}
|
||||
<Icon path={mdiCircleOutline} size="24" color="#757575" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<span class="w-full truncate first-letter:capitalize" title={getDateLocaleString(dateGroup.date)}>
|
||||
{dateGroup.groupTitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Image grid -->
|
||||
<div
|
||||
class="relative overflow-clip"
|
||||
style:height={geometry.containerHeight + 'px'}
|
||||
style:width={geometry.containerWidth + 'px'}
|
||||
>
|
||||
{#each dateGroup.assets as asset, i (asset.id)}
|
||||
<!-- getting these together here in this order is very cache-efficient -->
|
||||
{@const top = geometry.getTop(i)}
|
||||
{@const left = geometry.getLeft(i)}
|
||||
{@const width = geometry.getWidth(i)}
|
||||
{@const height = geometry.getHeight(i)}
|
||||
<!-- update ASSET_GRID_PADDING-->
|
||||
<div
|
||||
use:intersectionObserver={{
|
||||
onIntersect: () => onAssetInGrid?.(asset),
|
||||
top: `${-TITLE_HEIGHT}px`,
|
||||
bottom: `${-(viewport.height - TITLE_HEIGHT - 1)}px`,
|
||||
right: `${-(viewport.width - 1)}px`,
|
||||
root: assetGridElement,
|
||||
}}
|
||||
data-asset-id={asset.id}
|
||||
class="absolute"
|
||||
style:top={top + 'px'}
|
||||
style:left={left + 'px'}
|
||||
style:width={width + 'px'}
|
||||
style:height={height + 'px'}
|
||||
>
|
||||
<Thumbnail
|
||||
{dateGroup}
|
||||
{assetStore}
|
||||
intersectionConfig={{
|
||||
root: assetGridElement,
|
||||
bottom: renderThumbsAtBottomMargin,
|
||||
top: renderThumbsAtTopMargin,
|
||||
}}
|
||||
retrieveElement={assetStore.pendingScrollAssetId === asset.id}
|
||||
onRetrieveElement={(element) => onRetrieveElement(dateGroup, asset, element)}
|
||||
showStackedIcon={withStacked}
|
||||
{showArchiveIcon}
|
||||
{asset}
|
||||
{groupIndex}
|
||||
onClick={(asset) => onClick(dateGroup.assets, dateGroup.groupTitle, asset)}
|
||||
onSelect={(asset) => assetSelectHandler(asset, dateGroup.assets, dateGroup.groupTitle)}
|
||||
onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, asset)}
|
||||
selected={assetInteraction.selectedAssets.has(asset) || assetStore.albumAssets.has(asset.id)}
|
||||
handleFocus={() => assetOnFocusHandler(asset)}
|
||||
focussed={assetInteraction.isFocussedAsset(asset)}
|
||||
selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)}
|
||||
disabled={assetStore.albumAssets.has(asset.id)}
|
||||
thumbnailWidth={width}
|
||||
thumbnailHeight={height}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if assetInteraction.selectedGroup.has(dateGroup.groupTitle)}
|
||||
<Icon path={mdiCheckCircle} size="24" color="#4250af" />
|
||||
{:else}
|
||||
<Icon path={mdiCircleOutline} size="24" color="#757575" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<span class="w-full truncate first-letter:capitalize" title={getDateLocaleString(dateGroup.date)}>
|
||||
{dateGroup.groupTitle}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<!-- Image grid -->
|
||||
<div class="relative overflow-clip" style:height={dateGroup.height + 'px'} style:width={dateGroup.width + 'px'}>
|
||||
{#each filterIntersecting(dateGroup.intersetingAssets) as intersectingAsset (intersectingAsset.id)}
|
||||
{@const position = intersectingAsset.position!}
|
||||
{@const asset = intersectingAsset.asset!}
|
||||
|
||||
<!-- {#if intersectingAsset.intersecting} -->
|
||||
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
||||
<div
|
||||
data-asset-id={asset.id}
|
||||
class="absolute"
|
||||
style:top={position.top + 'px'}
|
||||
style:left={position.left + 'px'}
|
||||
style:width={position.width + 'px'}
|
||||
style:height={position.height + 'px'}
|
||||
out:scale|global={{ start: 0.1, duration: scaleDuration }}
|
||||
animate:flip={{ duration: transitionDuration }}
|
||||
>
|
||||
<Thumbnail
|
||||
showStackedIcon={withStacked}
|
||||
{showArchiveIcon}
|
||||
{asset}
|
||||
{groupIndex}
|
||||
focussed={assetInteraction.isFocussedAsset(asset)}
|
||||
onClick={(asset) => onClick(dateGroup.getAssets(), dateGroup.groupTitle, asset)}
|
||||
onSelect={(asset) => assetSelectHandler(asset, dateGroup.getAssets(), dateGroup.groupTitle)}
|
||||
onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, $state.snapshot(asset))}
|
||||
selected={assetInteraction.hasSelectedAsset(asset.id) || dateGroup.bucket.store.albumAssets.has(asset.id)}
|
||||
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
||||
handleFocus={() => assetOnFocusHandler(asset)}
|
||||
disabled={dateGroup.bucket.store.albumAssets.has(asset.id)}
|
||||
thumbnailWidth={position.width}
|
||||
thumbnailHeight={position.height}
|
||||
/>
|
||||
</div>
|
||||
<!-- {/if} -->
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
#asset-group-by-date {
|
||||
section {
|
||||
contain: layout paint style;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,38 +4,26 @@
|
||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||
import { AppRoute, AssetAction } from '$lib/constants';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { AssetBucket, AssetStore, type BucketListener, type ViewportXY } from '$lib/stores/assets-store.svelte';
|
||||
import { locale, showDeleteModal } from '$lib/stores/preferences.store';
|
||||
import { AssetBucket, AssetStore } from '$lib/stores/assets-store.svelte';
|
||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||
import { isSearchEnabled } from '$lib/stores/search.store';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { deleteAssets } from '$lib/utils/actions';
|
||||
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import {
|
||||
formatGroupTitle,
|
||||
splitBucketIntoDateGroups,
|
||||
type ScrubberListener,
|
||||
type ScrollTargetListener,
|
||||
} from '$lib/utils/timeline-util';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import { type ScrubberListener } from '$lib/utils/timeline-util';
|
||||
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
|
||||
import { throttle } from 'lodash-es';
|
||||
import { onDestroy, onMount, type Snippet } from 'svelte';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import Portal from '../shared-components/portal/portal.svelte';
|
||||
import Scrubber from '../shared-components/scrubber/scrubber.svelte';
|
||||
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
|
||||
import AssetDateGroup from './asset-date-group.svelte';
|
||||
import DeleteAssetDialog from './delete-asset-dialog.svelte';
|
||||
|
||||
import { resizeObserver } from '$lib/actions/resize-observer';
|
||||
import MeasureDateGroup from '$lib/components/photos-page/measure-date-group.svelte';
|
||||
import { intersectionObserver } from '$lib/actions/intersection-observer';
|
||||
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
|
||||
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { UpdatePayload } from 'vite';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { isTimelineScrolling } from '$lib/stores/timeline.store';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -81,64 +69,41 @@
|
||||
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget } = assetViewingStore;
|
||||
|
||||
const viewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 });
|
||||
const safeViewport: ViewportXY = $state({ width: 0, height: 0, x: 0, y: 0 });
|
||||
|
||||
const componentId = generateId();
|
||||
let element: HTMLElement | undefined = $state();
|
||||
|
||||
let timelineElement: HTMLElement | undefined = $state();
|
||||
let showShortcuts = $state(false);
|
||||
let showSkeleton = $state(true);
|
||||
let internalScroll = false;
|
||||
let navigating = false;
|
||||
let preMeasure: AssetBucket[] = $state([]);
|
||||
let lastIntersectedBucketDate: string | undefined;
|
||||
let scrubBucketPercent = $state(0);
|
||||
let scrubBucket: { bucketDate: string | undefined } | undefined = $state();
|
||||
let scrubOverallPercent: number = $state(0);
|
||||
let topSectionHeight = $state(0);
|
||||
let topSectionOffset = $state(0);
|
||||
|
||||
// 60 is the bottom spacer element at 60px
|
||||
let bottomSectionHeight = 60;
|
||||
let leadout = $state(false);
|
||||
|
||||
const {
|
||||
ASSET_GRID: { NAVIGATE_ON_ASSET_IN_VIEW },
|
||||
BUCKET: {
|
||||
INTERSECTION_ROOT_TOP: BUCKET_INTERSECTION_ROOT_TOP,
|
||||
INTERSECTION_ROOT_BOTTOM: BUCKET_INTERSECTION_ROOT_BOTTOM,
|
||||
},
|
||||
THUMBNAIL: {
|
||||
INTERSECTION_ROOT_TOP: THUMBNAIL_INTERSECTION_ROOT_TOP,
|
||||
INTERSECTION_ROOT_BOTTOM: THUMBNAIL_INTERSECTION_ROOT_BOTTOM,
|
||||
},
|
||||
} = TUNABLES;
|
||||
|
||||
const isViewportOrigin = () => {
|
||||
return viewport.height === 0 && viewport.width === 0;
|
||||
};
|
||||
const isEqual = (a: ViewportXY, b: ViewportXY) => {
|
||||
return a.height == b.height && a.width == b.width && a.x === b.x && a.y === b.y;
|
||||
};
|
||||
|
||||
const completeNav = () => {
|
||||
navigating = false;
|
||||
if (internalScroll) {
|
||||
internalScroll = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const completeNav = async () => {
|
||||
if ($gridScrollTarget?.at) {
|
||||
void assetStore.scheduleScrollToAssetId($gridScrollTarget, () => {
|
||||
try {
|
||||
const bucket = await assetStore.findBucketForAsset($gridScrollTarget.at);
|
||||
if (bucket) {
|
||||
const height = bucket.findAssetAbsolutePosition($gridScrollTarget.at);
|
||||
if (height) {
|
||||
element?.scrollTo({ top: height });
|
||||
showSkeleton = false;
|
||||
assetStore.updateIntersections();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
element?.scrollTo({ top: 0 });
|
||||
showSkeleton = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
element?.scrollTo({ top: 0 });
|
||||
showSkeleton = false;
|
||||
}
|
||||
};
|
||||
|
||||
beforeNavigate(() => (assetStore.suspendTransitions = true));
|
||||
afterNavigate((nav) => {
|
||||
const { complete, type } = nav;
|
||||
if (type === 'enter') {
|
||||
@@ -147,10 +112,6 @@
|
||||
complete.then(completeNav, completeNav);
|
||||
});
|
||||
|
||||
beforeNavigate(() => {
|
||||
navigating = true;
|
||||
});
|
||||
|
||||
const hmrSupport = () => {
|
||||
// when hmr happens, skeleton is initialized to true by default
|
||||
// normally, loading asset-grid is part of a navigation event, and the completion of
|
||||
@@ -165,7 +126,6 @@
|
||||
|
||||
if (assetGridUpdate) {
|
||||
setTimeout(() => {
|
||||
void assetStore.updateViewport(safeViewport, true);
|
||||
const asset = $page.url.searchParams.get('at');
|
||||
if (asset) {
|
||||
$gridScrollTarget = { at: asset };
|
||||
@@ -193,94 +153,60 @@
|
||||
return () => void 0;
|
||||
};
|
||||
|
||||
const scrollTolastIntersectedBucket = (adjustedBucket: AssetBucket, delta: number) => {
|
||||
if (lastIntersectedBucketDate) {
|
||||
const currentIndex = assetStore.buckets.findIndex((b) => b.bucketDate === lastIntersectedBucketDate);
|
||||
const deltaIndex = assetStore.buckets.indexOf(adjustedBucket);
|
||||
|
||||
if (deltaIndex < currentIndex) {
|
||||
element?.scrollBy(0, delta);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const bucketListener: BucketListener = (event) => {
|
||||
const { type } = event;
|
||||
if (type === 'bucket-height') {
|
||||
const { bucket, delta } = event;
|
||||
scrollTolastIntersectedBucket(bucket, delta);
|
||||
}
|
||||
};
|
||||
const updateIsScrolling = () => (assetStore.scrolling = true);
|
||||
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
||||
const updateSlidingWindow = () => assetStore.updateSlidingWindow(element?.scrollTop || 0);
|
||||
const compensateScrollCallback = (delta: number) => element?.scrollBy(0, delta);
|
||||
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (assetStore.topSectionHeight = height);
|
||||
|
||||
onMount(() => {
|
||||
void assetStore
|
||||
.init({ bucketListener })
|
||||
.then(() => (assetStore.connect(), assetStore.updateViewport(safeViewport)));
|
||||
assetStore.setCompensateScrollCallback(compensateScrollCallback);
|
||||
if (!enableRouting) {
|
||||
showSkeleton = false;
|
||||
}
|
||||
const dispose = hmrSupport();
|
||||
const disposeHmr = hmrSupport();
|
||||
return () => {
|
||||
assetStore.disconnect();
|
||||
assetStore.destroy();
|
||||
dispose();
|
||||
assetStore.setCompensateScrollCallback();
|
||||
disposeHmr();
|
||||
};
|
||||
});
|
||||
|
||||
const _updateViewport = () => void assetStore.updateViewport(safeViewport);
|
||||
const updateViewport = throttle(_updateViewport, 16);
|
||||
|
||||
function getOffset(bucketDate: string) {
|
||||
let offset = 0;
|
||||
for (let a = 0; a < assetStore.buckets.length; a++) {
|
||||
if (assetStore.buckets[a].bucketDate === bucketDate) {
|
||||
break;
|
||||
}
|
||||
offset += assetStore.buckets[a].bucketHeight;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
const getMaxScrollPercent = () =>
|
||||
(assetStore.timelineHeight + bottomSectionHeight + topSectionHeight - safeViewport.height) /
|
||||
(assetStore.timelineHeight + bottomSectionHeight + topSectionHeight);
|
||||
const getMaxScrollPercent = () => {
|
||||
const totalHeight = assetStore.timelineHeight + bottomSectionHeight + assetStore.topSectionHeight;
|
||||
return (totalHeight - assetStore.viewportHeight) / totalHeight;
|
||||
};
|
||||
|
||||
const getMaxScroll = () => {
|
||||
if (!element || !timelineElement) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight);
|
||||
return assetStore.topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight);
|
||||
};
|
||||
|
||||
const scrollToBucketAndOffset = (bucket: AssetBucket, bucketScrollPercent: number) => {
|
||||
const topOffset = getOffset(bucket.bucketDate) + topSectionHeight + topSectionOffset;
|
||||
const topOffset = bucket.top;
|
||||
const maxScrollPercent = getMaxScrollPercent();
|
||||
const delta = bucket.bucketHeight * bucketScrollPercent;
|
||||
const scrollTop = (topOffset + delta) * maxScrollPercent;
|
||||
|
||||
if (!element) {
|
||||
return;
|
||||
if (element) {
|
||||
element.scrollTop = scrollTop;
|
||||
}
|
||||
|
||||
element.scrollTop = scrollTop;
|
||||
};
|
||||
|
||||
const _onScrub: ScrubberListener = (
|
||||
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
|
||||
const onScrub: ScrubberListener = (
|
||||
bucketDate: string | undefined,
|
||||
scrollPercent: number,
|
||||
bucketScrollPercent: number,
|
||||
) => {
|
||||
if (!bucketDate || assetStore.timelineHeight < safeViewport.height * 2) {
|
||||
if (!bucketDate || assetStore.timelineHeight < assetStore.viewportHeight * 2) {
|
||||
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
||||
|
||||
const maxScroll = getMaxScroll();
|
||||
const offset = maxScroll * scrollPercent;
|
||||
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.scrollTop = offset;
|
||||
} else {
|
||||
const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
|
||||
@@ -290,47 +216,16 @@
|
||||
scrollToBucketAndOffset(bucket, bucketScrollPercent);
|
||||
}
|
||||
};
|
||||
const onScrub = throttle(_onScrub, 16, { leading: false, trailing: true });
|
||||
|
||||
const stopScrub: ScrubberListener = async (
|
||||
bucketDate: string | undefined,
|
||||
_scrollPercent: number,
|
||||
bucketScrollPercent: number,
|
||||
) => {
|
||||
if (!bucketDate || assetStore.timelineHeight < safeViewport.height * 2) {
|
||||
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
||||
return;
|
||||
}
|
||||
const bucket = assetStore.buckets.find((b) => b.bucketDate === bucketDate);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
if (bucket && !bucket.measured) {
|
||||
preMeasure.push(bucket);
|
||||
await assetStore.loadBucket(bucketDate, { preventCancel: true, pending: true });
|
||||
await bucket.measuredPromise;
|
||||
scrollToBucketAndOffset(bucket, bucketScrollPercent);
|
||||
}
|
||||
};
|
||||
|
||||
let scrollObserverTimer: NodeJS.Timeout;
|
||||
|
||||
const _handleTimelineScroll = () => {
|
||||
$isTimelineScrolling = true;
|
||||
if (scrollObserverTimer) {
|
||||
clearTimeout(scrollObserverTimer);
|
||||
}
|
||||
scrollObserverTimer = setTimeout(() => {
|
||||
$isTimelineScrolling = false;
|
||||
}, 1000);
|
||||
|
||||
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker
|
||||
const handleTimelineScroll = () => {
|
||||
leadout = false;
|
||||
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (assetStore.timelineHeight < safeViewport.height * 2) {
|
||||
if (assetStore.timelineHeight < assetStore.viewportHeight * 2) {
|
||||
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
|
||||
const maxScroll = getMaxScroll();
|
||||
scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
|
||||
@@ -338,8 +233,8 @@
|
||||
scrubBucket = undefined;
|
||||
scrubBucketPercent = 0;
|
||||
} else {
|
||||
let top = element?.scrollTop;
|
||||
if (top < topSectionHeight) {
|
||||
let top = element.scrollTop;
|
||||
if (top < assetStore.topSectionHeight) {
|
||||
// in the lead-in area
|
||||
scrubBucket = undefined;
|
||||
scrubBucketPercent = 0;
|
||||
@@ -352,18 +247,24 @@
|
||||
let maxScrollPercent = getMaxScrollPercent();
|
||||
let found = false;
|
||||
|
||||
// create virtual buckets....
|
||||
const vbuckets = [
|
||||
{ bucketHeight: topSectionHeight, bucketDate: undefined },
|
||||
...assetStore.buckets,
|
||||
{ bucketHeight: bottomSectionHeight, bucketDate: undefined },
|
||||
];
|
||||
|
||||
for (const bucket of vbuckets) {
|
||||
let next = top - bucket.bucketHeight * maxScrollPercent;
|
||||
const bucketsLength = assetStore.buckets.length;
|
||||
for (let i = -1; i < bucketsLength + 1; i++) {
|
||||
let bucket: { bucketDate: string | undefined } | undefined;
|
||||
let bucketHeight = 0;
|
||||
if (i === -1) {
|
||||
// lead-in
|
||||
bucketHeight = assetStore.topSectionHeight;
|
||||
} else if (i === bucketsLength) {
|
||||
// lead-out
|
||||
bucketHeight = bottomSectionHeight;
|
||||
} else {
|
||||
bucket = assetStore.buckets[i];
|
||||
bucketHeight = assetStore.buckets[i].bucketHeight;
|
||||
}
|
||||
let next = top - bucketHeight * maxScrollPercent;
|
||||
if (next < 0) {
|
||||
scrubBucket = bucket;
|
||||
scrubBucketPercent = top / (bucket.bucketHeight * maxScrollPercent);
|
||||
scrubBucketPercent = top / (bucketHeight * maxScrollPercent);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
@@ -377,34 +278,6 @@
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleTimelineScroll = throttle(_handleTimelineScroll, 16, { leading: false, trailing: true });
|
||||
|
||||
const _onAssetInGrid = async (asset: AssetResponseDto) => {
|
||||
if (!enableRouting || navigating || internalScroll) {
|
||||
return;
|
||||
}
|
||||
$gridScrollTarget = { at: asset.id };
|
||||
internalScroll = true;
|
||||
await navigate(
|
||||
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
|
||||
{ replaceState: true, forceNavigate: true },
|
||||
);
|
||||
};
|
||||
const onAssetInGrid = NAVIGATE_ON_ASSET_IN_VIEW
|
||||
? throttle(_onAssetInGrid, 16, { leading: false, trailing: true })
|
||||
: () => void 0;
|
||||
|
||||
const onScrollTarget: ScrollTargetListener = ({ bucket, offset }) => {
|
||||
element?.scrollTo({ top: offset });
|
||||
if (!bucket.measured) {
|
||||
preMeasure.push(bucket);
|
||||
}
|
||||
showSkeleton = false;
|
||||
assetStore.clearPendingScroll();
|
||||
// set intersecting true manually here, to reduce flicker that happens when
|
||||
// clearing pending scroll, but the intersection observer hadn't yet had time to run
|
||||
assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
|
||||
};
|
||||
|
||||
const trashOrDelete = async (force: boolean = false) => {
|
||||
isShowDeleteConfirmation = false;
|
||||
@@ -439,11 +312,9 @@
|
||||
};
|
||||
|
||||
const toggleArchive = async () => {
|
||||
const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
|
||||
if (ids) {
|
||||
assetStore.removeAssets(ids);
|
||||
deselectAllAssets();
|
||||
}
|
||||
await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
|
||||
assetStore.updateAssets(assetInteraction.selectedAssetsArray);
|
||||
deselectAllAssets();
|
||||
};
|
||||
|
||||
const focusElement = () => {
|
||||
@@ -458,23 +329,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
function handleIntersect(bucket: AssetBucket) {
|
||||
// updateLastIntersectedBucketDate();
|
||||
const task = () => {
|
||||
assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
|
||||
void assetStore.loadBucket(bucket.bucketDate);
|
||||
};
|
||||
assetStore.taskManager.intersectedBucket(componentId, bucket, task);
|
||||
}
|
||||
|
||||
function handleSeparate(bucket: AssetBucket) {
|
||||
const task = () => {
|
||||
assetStore.updateBucket(bucket.bucketDate, { intersecting: false });
|
||||
bucket.cancel();
|
||||
};
|
||||
assetStore.taskManager.separatedBucket(componentId, bucket, task);
|
||||
}
|
||||
|
||||
const handlePrevious = async () => {
|
||||
const previousAsset = await assetStore.getPreviousAsset($viewingAsset);
|
||||
|
||||
@@ -610,7 +464,6 @@
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(asset);
|
||||
|
||||
if (singleSelect && element) {
|
||||
@@ -619,7 +472,7 @@
|
||||
}
|
||||
|
||||
const rangeSelection = assetInteraction.assetSelectionCandidates.size > 0;
|
||||
const deselect = assetInteraction.selectedAssets.has(asset);
|
||||
const deselect = assetInteraction.hasSelectedAsset(asset.id);
|
||||
|
||||
// Select/deselect already loaded assets
|
||||
if (deselect) {
|
||||
@@ -637,39 +490,48 @@
|
||||
assetInteraction.clearAssetSelectionCandidates();
|
||||
|
||||
if (assetInteraction.assetSelectionStart && rangeSelection) {
|
||||
let startBucketIndex = assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id);
|
||||
let endBucketIndex = assetStore.getBucketIndexByAssetId(asset.id);
|
||||
let startBucket = assetStore.getBucketIndexByAssetId(assetInteraction.assetSelectionStart.id);
|
||||
let endBucket = assetStore.getBucketIndexByAssetId(asset.id);
|
||||
|
||||
if (startBucketIndex === null || endBucketIndex === null) {
|
||||
if (startBucket === null || endBucket === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (endBucketIndex < startBucketIndex) {
|
||||
[startBucketIndex, endBucketIndex] = [endBucketIndex, startBucketIndex];
|
||||
}
|
||||
|
||||
// Select/deselect assets in all intermediate buckets
|
||||
for (let bucketIndex = startBucketIndex + 1; bucketIndex < endBucketIndex; bucketIndex++) {
|
||||
const bucket = assetStore.buckets[bucketIndex];
|
||||
await assetStore.loadBucket(bucket.bucketDate);
|
||||
for (const asset of bucket.assets) {
|
||||
if (deselect) {
|
||||
assetInteraction.removeAssetFromMultiselectGroup(asset);
|
||||
} else {
|
||||
handleSelectAsset(asset);
|
||||
// Select/deselect assets in range (start,end]
|
||||
let started = false;
|
||||
for (const bucket of assetStore.buckets) {
|
||||
if (bucket === startBucket) {
|
||||
started = true;
|
||||
}
|
||||
if (bucket === endBucket) {
|
||||
break;
|
||||
}
|
||||
if (started) {
|
||||
await assetStore.loadBucket(bucket.bucketDate);
|
||||
for (const asset of bucket.getAssets()) {
|
||||
if (deselect) {
|
||||
assetInteraction.removeAssetFromMultiselectGroup(asset);
|
||||
} else {
|
||||
handleSelectAsset(asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update date group selection
|
||||
for (let bucketIndex = startBucketIndex; bucketIndex <= endBucketIndex; bucketIndex++) {
|
||||
const bucket = assetStore.buckets[bucketIndex];
|
||||
started = false;
|
||||
for (const bucket of assetStore.buckets) {
|
||||
if (bucket === startBucket) {
|
||||
started = true;
|
||||
}
|
||||
if (bucket === endBucket) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Split bucket into date groups and check each group
|
||||
const assetsGroupByDate = splitBucketIntoDateGroups(bucket, $locale);
|
||||
for (const dateGroup of assetsGroupByDate) {
|
||||
const dateGroupTitle = formatGroupTitle(dateGroup.date);
|
||||
if (dateGroup.assets.every((a) => assetInteraction.selectedAssets.has(a))) {
|
||||
for (const dateGroup of bucket.dateGroups) {
|
||||
const dateGroupTitle = dateGroup.groupTitle;
|
||||
if (dateGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
|
||||
assetInteraction.addGroupToMultiselectGroup(dateGroupTitle);
|
||||
} else {
|
||||
assetInteraction.removeGroupFromMultiselectGroup(dateGroupTitle);
|
||||
@@ -691,14 +553,16 @@
|
||||
return;
|
||||
}
|
||||
|
||||
let start = assetStore.assets.findIndex((a) => a.id === startAsset.id);
|
||||
let end = assetStore.assets.findIndex((a) => a.id === endAsset.id);
|
||||
const assets = assetStore.getAssets();
|
||||
|
||||
let start = assets.findIndex((a) => a.id === startAsset.id);
|
||||
let end = assets.findIndex((a) => a.id === endAsset.id);
|
||||
|
||||
if (start > end) {
|
||||
[start, end] = [end, start];
|
||||
}
|
||||
|
||||
assetInteraction.setAssetSelectionCandidates(assetStore.assets.slice(start, end + 1));
|
||||
assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1));
|
||||
};
|
||||
|
||||
const onSelectStart = (e: Event) => {
|
||||
@@ -710,14 +574,14 @@
|
||||
const focusNextAsset = async () => {
|
||||
if (assetInteraction.focussedAssetId === null) {
|
||||
const firstAsset = assetStore.getFirstAsset();
|
||||
if (firstAsset !== null) {
|
||||
if (firstAsset) {
|
||||
assetInteraction.focussedAssetId = firstAsset.id;
|
||||
}
|
||||
} else {
|
||||
const focussedAsset = assetStore.assets.find((asset) => asset.id === assetInteraction.focussedAssetId);
|
||||
const focussedAsset = assetStore.getAssets().find((asset) => asset.id === assetInteraction.focussedAssetId);
|
||||
if (focussedAsset) {
|
||||
const nextAsset = await assetStore.getNextAsset(focussedAsset);
|
||||
if (nextAsset !== null) {
|
||||
if (nextAsset) {
|
||||
assetInteraction.focussedAssetId = nextAsset.id;
|
||||
}
|
||||
}
|
||||
@@ -726,7 +590,7 @@
|
||||
|
||||
const focusPreviousAsset = async () => {
|
||||
if (assetInteraction.focussedAssetId !== null) {
|
||||
const focussedAsset = assetStore.assets.find((asset) => asset.id === assetInteraction.focussedAssetId);
|
||||
const focussedAsset = assetStore.getAssets().find((asset) => asset.id === assetInteraction.focussedAssetId);
|
||||
if (focussedAsset) {
|
||||
const previousAsset = await assetStore.getPreviousAsset(focussedAsset);
|
||||
if (previousAsset) {
|
||||
@@ -736,11 +600,8 @@
|
||||
}
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
assetStore.taskManager.removeAllTasksForComponent(componentId);
|
||||
});
|
||||
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
||||
let isEmpty = $derived(assetStore.initialized && assetStore.buckets.length === 0);
|
||||
let isEmpty = $derived(assetStore.isInitialized && assetStore.buckets.length === 0);
|
||||
let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id));
|
||||
|
||||
$effect(() => {
|
||||
@@ -749,23 +610,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (element && isViewportOrigin()) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
viewport.height = rect.height;
|
||||
viewport.width = rect.width;
|
||||
viewport.x = rect.x;
|
||||
viewport.y = rect.y;
|
||||
}
|
||||
if (!isViewportOrigin() && !isEqual(viewport, safeViewport)) {
|
||||
safeViewport.height = viewport.height;
|
||||
safeViewport.width = viewport.width;
|
||||
safeViewport.x = viewport.x;
|
||||
safeViewport.y = viewport.y;
|
||||
updateViewport();
|
||||
}
|
||||
});
|
||||
|
||||
let shortcutList = $derived(
|
||||
(() => {
|
||||
if ($isSearchEnabled || $showAssetViewer) {
|
||||
@@ -829,19 +673,34 @@
|
||||
{#if showShortcuts}
|
||||
<ShowShortcuts onClose={() => (showShortcuts = !showShortcuts)} />
|
||||
{/if}
|
||||
|
||||
{#if assetStore.buckets.length > 0}
|
||||
<Scrubber
|
||||
invisible={showSkeleton}
|
||||
{assetStore}
|
||||
height={safeViewport.height}
|
||||
timelineTopOffset={topSectionHeight}
|
||||
height={assetStore.viewportHeight}
|
||||
timelineTopOffset={assetStore.topSectionHeight}
|
||||
timelineBottomOffset={bottomSectionHeight}
|
||||
{leadout}
|
||||
{scrubOverallPercent}
|
||||
{scrubBucketPercent}
|
||||
{scrubBucket}
|
||||
{onScrub}
|
||||
{stopScrub}
|
||||
onScrubKeyDown={(evt) => {
|
||||
evt.preventDefault();
|
||||
let amount = 50;
|
||||
if (shiftKeyIsDown) {
|
||||
amount = 500;
|
||||
}
|
||||
if (evt.key === 'ArrowUp') {
|
||||
amount = -amount;
|
||||
if (shiftKeyIsDown) {
|
||||
element?.scrollBy({ top: amount, behavior: 'smooth' });
|
||||
}
|
||||
} else if (evt.key === 'ArrowDown') {
|
||||
element?.scrollBy({ top: amount, behavior: 'smooth' });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -850,90 +709,67 @@
|
||||
id="asset-grid"
|
||||
class="scrollbar-hidden h-full overflow-y-auto outline-none {isEmpty ? 'm-0' : 'ml-4 tall:ml-0 mr-[60px]'}"
|
||||
tabindex="-1"
|
||||
use:resizeObserver={({ height, width }) => ((viewport.width = width), (viewport.height = height))}
|
||||
bind:clientHeight={assetStore.viewportHeight}
|
||||
bind:clientWidth={null, (v) => ((assetStore.viewportWidth = v), updateSlidingWindow())}
|
||||
bind:this={element}
|
||||
onscroll={() => ((assetStore.lastScrollTime = Date.now()), handleTimelineScroll())}
|
||||
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
|
||||
>
|
||||
<section
|
||||
use:resizeObserver={({ target, height }) => ((topSectionHeight = height), (topSectionOffset = target.offsetTop))}
|
||||
class:invisible={showSkeleton}
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if isEmpty}
|
||||
<!-- (optional) empty placeholder -->
|
||||
{@render empty?.()}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section
|
||||
bind:this={timelineElement}
|
||||
id="virtual-timeline"
|
||||
class:invisible={showSkeleton}
|
||||
style:height={assetStore.timelineHeight + 'px'}
|
||||
>
|
||||
<section
|
||||
use:resizeObserver={topSectionResizeObserver}
|
||||
class:invisible={showSkeleton}
|
||||
style:position="absolute"
|
||||
style:left="0"
|
||||
style:right="0"
|
||||
>
|
||||
{@render children?.()}
|
||||
{#if isEmpty}
|
||||
<!-- (optional) empty placeholder -->
|
||||
{@render empty?.()}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#each assetStore.buckets as bucket (bucket.viewId)}
|
||||
{@const isPremeasure = preMeasure.includes(bucket)}
|
||||
{@const display = bucket.intersecting || bucket === assetStore.pendingScrollBucket || isPremeasure}
|
||||
{@const display = bucket.intersecting}
|
||||
{@const absoluteHeight = bucket.top}
|
||||
|
||||
<div
|
||||
class="bucket"
|
||||
style:overflow={bucket.measured ? 'visible' : 'clip'}
|
||||
use:intersectionObserver={[
|
||||
{
|
||||
key: bucket.viewId,
|
||||
onIntersect: () => handleIntersect(bucket),
|
||||
onSeparate: () => handleSeparate(bucket),
|
||||
top: BUCKET_INTERSECTION_ROOT_TOP,
|
||||
bottom: BUCKET_INTERSECTION_ROOT_BOTTOM,
|
||||
root: element,
|
||||
},
|
||||
{
|
||||
key: bucket.viewId + '.bucketintersection',
|
||||
onIntersect: () => (lastIntersectedBucketDate = bucket.bucketDate),
|
||||
top: '0px',
|
||||
bottom: '-' + Math.max(0, safeViewport.height - 1) + 'px',
|
||||
left: '0px',
|
||||
right: '0px',
|
||||
},
|
||||
]}
|
||||
data-bucket-display={bucket.intersecting}
|
||||
data-bucket-date={bucket.bucketDate}
|
||||
style:height={bucket.bucketHeight + 'px'}
|
||||
>
|
||||
{#if display && !bucket.measured}
|
||||
<MeasureDateGroup
|
||||
{bucket}
|
||||
{assetStore}
|
||||
onMeasured={() => (preMeasure = preMeasure.filter((b) => b !== bucket))}
|
||||
></MeasureDateGroup>
|
||||
{/if}
|
||||
|
||||
{#if !display || !bucket.measured}
|
||||
<Skeleton height={bucket.bucketHeight + 'px'} title={`${bucket.bucketDateFormattted}`} />
|
||||
{/if}
|
||||
{#if display && bucket.measured}
|
||||
{#if !bucket.isLoaded}
|
||||
<div
|
||||
style:height={bucket.bucketHeight + 'px'}
|
||||
style:position="absolute"
|
||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||
style:width="100%"
|
||||
>
|
||||
<Skeleton height={bucket.bucketHeight} title={bucket.bucketDateFormatted} />
|
||||
</div>
|
||||
{:else if display}
|
||||
<div
|
||||
class="bucket"
|
||||
style:height={bucket.bucketHeight + 'px'}
|
||||
style:position="absolute"
|
||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||
style:width="100%"
|
||||
>
|
||||
<AssetDateGroup
|
||||
assetGridElement={element}
|
||||
renderThumbsAtTopMargin={THUMBNAIL_INTERSECTION_ROOT_TOP}
|
||||
renderThumbsAtBottomMargin={THUMBNAIL_INTERSECTION_ROOT_BOTTOM}
|
||||
{withStacked}
|
||||
{showArchiveIcon}
|
||||
{assetStore}
|
||||
{assetInteraction}
|
||||
{isSelectionMode}
|
||||
{singleSelect}
|
||||
{onScrollTarget}
|
||||
{onAssetInGrid}
|
||||
{bucket}
|
||||
viewport={safeViewport}
|
||||
onSelect={({ title, assets }) => handleGroupSelect(title, assets)}
|
||||
onSelectAssetCandidates={handleSelectAssetCandidates}
|
||||
onSelectAssets={handleSelectAssets}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
<div class="h-[60px]"></div>
|
||||
<!-- <div class="h-[60px]" style:position="absolute" style:left="0" style:right="0" style:bottom="0"></div> -->
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -965,6 +801,9 @@
|
||||
}
|
||||
|
||||
.bucket {
|
||||
contain: layout size;
|
||||
contain: layout size paint;
|
||||
transform-style: flat;
|
||||
backface-visibility: hidden;
|
||||
transform-origin: center center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
<script lang="ts" 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.svelte';
|
||||
|
||||
interface Props {
|
||||
assetStore: AssetStore;
|
||||
bucket: AssetBucket;
|
||||
onMeasured: () => void;
|
||||
}
|
||||
|
||||
let { assetStore, bucket, onMeasured }: Props = $props();
|
||||
|
||||
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, 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 (dateGroup.date)}
|
||||
<div id="date-group" data-date-group={dateGroup.date}>
|
||||
<div use:resizeObserver={({ height }) => assetStore.updateBucketDateGroup(bucket, dateGroup, { 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>
|
||||
@@ -1,30 +1,28 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
title?: string | null;
|
||||
height?: string | null;
|
||||
height: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
let { title = null, height = null }: Props = $props();
|
||||
let { height = 0, title }: Props = $props();
|
||||
</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 class="overflow-clip" style:height={height + 'px'}>
|
||||
<div
|
||||
class="flex z-[100] pt-[calc(1.75rem+1px)] 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"
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<div class="animate-pulse absolute w-full h-full" data-skeleton="true"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#skeleton {
|
||||
[data-skeleton] {
|
||||
background-image: url('/light_skeleton.png');
|
||||
background-repeat: repeat;
|
||||
background-size: 235px, 235px;
|
||||
}
|
||||
:global(.dark) #skeleton {
|
||||
:global(.dark) [data-skeleton] {
|
||||
background-image: url('/dark_skeleton.png');
|
||||
}
|
||||
@keyframes delayedVisibility {
|
||||
@@ -32,8 +30,10 @@
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
#skeleton {
|
||||
[data-skeleton] {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.1s forwards delayedVisibility;
|
||||
animation:
|
||||
0s linear 0.1s forwards delayedVisibility,
|
||||
pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user