Compare commits

...

5 Commits

Author SHA1 Message Date
midzelis
e95ca39e4f refactor(web): convert timeline layout options to derived state
- Replace createLayoutOptions() method with derived layoutOptions property for better reactivity. 
- Remove duplicate refreshLayout() implementation 
- Simplify setLayoutOptions() by removing unnecessary change tracking.
2025-09-28 20:55:19 +00:00
midzelis
990fdeccb4 refactor: remove scroll compensation logic from photostream components 2025-09-28 20:55:19 +00:00
midzelis
2561a25261 fix: reactivity warnings in searchbox 2025-09-28 20:55:19 +00:00
midzelis
666bbf2503 fix: padding for searchresults/photostream 2025-09-28 20:55:19 +00:00
midzelis
009e1def3e feat: search results keyboard actions 2025-09-28 20:55:19 +00:00
17 changed files with 352 additions and 183 deletions

View File

@@ -1,5 +1,11 @@
import type { ActionReturn } from 'svelte/action';
interface ExplainedShortcut {
key: string[];
action: string;
info?: string;
}
export type Shortcut = {
key: string;
alt?: boolean;
@@ -14,6 +20,7 @@ export type ShortcutOptions<T = HTMLElement> = {
ignoreInputFields?: boolean;
onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown;
preventDefault?: boolean;
explainedShortcut?: ExplainedShortcut;
};
export const shortcutLabel = (shortcut: Shortcut) => {

View File

@@ -0,0 +1,237 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
import { AppRoute } from '$lib/constants';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
import { moveFocus } from '$lib/utils/focus-util';
import { AssetVisibility } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { t } from 'svelte-i18n';
let { isViewing: isViewerOpen } = assetViewingStore;
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
interface Props {
timelineManager: PhotostreamManager;
assetInteraction: AssetInteraction;
isShowDeleteConfirmation?: boolean;
onReload?: (() => void) | undefined;
}
let {
timelineManager,
assetInteraction,
isShowDeleteConfirmation = false,
onReload,
}: Props = $props();
const selectAllAssets = () => {
const allAssets = timelineManager.months.flatMap((segment) => segment.assets);
assetInteraction.selectAssets(allAssets);
};
const deselectAllAssets = () => {
cancelMultiselect(assetInteraction);
};
const onDelete = () => {
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(hasTrashedAsset));
};
const onForceDelete = () => {
if ($showDeleteModal) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(true));
};
const trashOrDelete = async (force: boolean = false) => {
isShowDeleteConfirmation = false;
await deleteAssets(
!(isTrashEnabled && !force),
(assetIds) => timelineManager.removeAssets(assetIds),
assetInteraction.selectedAssets,
onReload,
);
assetInteraction.clearMultiselect();
};
const toggleArchive = async () => {
const assets = assetInteraction.selectedAssets;
const visibility = assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive;
const ids = await archiveAssets(assets, visibility);
const idSet = new Set(ids);
if (ids) {
for (const asset of assets) {
if (idSet.has(asset.id)) {
asset.visibility = visibility;
}
}
deselectAllAssets();
}
};
const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'next');
const focusPreviousAsset = () =>
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'previous');
let isShortcutModalOpen = false;
const handleOpenShortcutModal = async () => {
if (isShortcutModalOpen) {
return;
}
isShortcutModalOpen = true;
await modalManager.show(ShortcutsModal, { shortcuts: getShortcuts() });
isShortcutModalOpen = false;
};
const getShortcuts = () => {
const general = Object.values(generalShortcuts);
const actions = Object.values(actionsShortcuts);
return {
general: general
.filter((general) => 'explainedShortcut' in general)
.map((generalShortcut) => generalShortcut.explainedShortcut!),
actions: actions.filter((action) => 'explainedShortcut' in action).map((action) => action.explainedShortcut!),
};
};
const generalShortcuts = {
OPEN_HELP: {
shortcut: { key: '?', shift: true },
onShortcut: handleOpenShortcutModal,
explainedShortcut: {
key: ['⇧', '?'],
action: 'Open this dialog',
},
},
EXPLORE: {
shortcut: { key: '/' },
onShortcut: () => goto(AppRoute.EXPLORE),
explainedShortcut: {
key: ['/'],
action: $t('explore'),
},
},
SELECT_ALL: {
shortcut: { key: 'A', ctrl: true },
onShortcut: () => selectAllAssets(),
explainedShortcut: {
key: ['Ctrl', 'a'],
action: $t('select_all'),
},
},
ARROW_RIGHT: {
shortcut: { key: 'ArrowRight' },
preventDefault: false,
onShortcut: focusNextAsset,
explainedShortcut: {
key: ['←', '→'],
action: $t('previous_or_next_photo'),
},
},
ARROW_LEFT: {
shortcut: { key: 'ArrowLeft' },
preventDefault: false,
onShortcut: focusPreviousAsset,
},
};
const actionsShortcuts = {
ESCAPE: {
shortcut: { key: 'Escape' },
onShortcut: deselectAllAssets,
explainedShortcut: {
key: ['Esc'],
action: $t('back_close_deselect'),
},
},
DELETE: {
shortcut: { key: 'Delete' },
onShortcut: onDelete,
explainedShortcut: {
key: ['Del'],
action: $t('trash_delete_asset'),
info: $t('shift_to_permanent_delete'),
},
},
FORCE_DELETE: {
shortcut: { key: 'Delete', shift: true },
onShortcut: onForceDelete,
},
DESELECT_ALL: {
shortcut: { key: 'D', ctrl: true },
onShortcut: () => deselectAllAssets(),
explainedShortcut: {
key: ['Ctrl', 'd'],
action: $t('deselect_all'),
},
},
TOGGLE_ARCHIVE: {
shortcut: { key: 'a', shift: true },
onShortcut: toggleArchive,
explainedShortcut: {
key: ['⇧', 'a'],
action: $t('select_all'),
},
},
};
const shortcutList = $derived(
(() => {
if ($isViewerOpen) {
return [];
}
const shortcuts: ShortcutOptions[] = [
generalShortcuts.OPEN_HELP,
generalShortcuts.EXPLORE,
generalShortcuts.SELECT_ALL,
generalShortcuts.ARROW_RIGHT,
generalShortcuts.ARROW_LEFT,
];
if (assetInteraction.selectionActive) {
shortcuts.push(
actionsShortcuts.ESCAPE,
actionsShortcuts.DELETE,
actionsShortcuts.FORCE_DELETE,
actionsShortcuts.DESELECT_ALL,
actionsShortcuts.TOGGLE_ARCHIVE,
);
}
return shortcuts;
})(),
);
</script>
<svelte:document use:shortcuts={shortcutList} />
{#if isShowDeleteConfirmation}
<DeleteAssetDialog
size={assetInteraction.selectedAssets.length}
onCancel={() => (isShowDeleteConfirmation = false)}
onConfirm={() => handlePromiseError(trashOrDelete(true))}
/>
{/if}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import SearchKeyboardShortcuts from '$lib/components/search/SearchKeyboardShortcuts.svelte';
import SearchResultsAssetViewer from '$lib/components/search/SearchResultsAssetViewer.svelte';
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
@@ -21,8 +22,13 @@
searchResultsManager: SearchResultsManager;
}
let { searchResultsManager, assetInteraction, children, stylePaddingHorizontalPx, styleMarginTopPx }: Props =
$props();
let {
searchResultsManager,
assetInteraction,
children,
stylePaddingHorizontalPx = 0,
styleMarginTopPx,
}: Props = $props();
let viewer: Photostream | undefined = $state(undefined);
const onAfterNavigateComplete = ({ scrollToAssetQueryParam }: { scrollToAssetQueryParam: boolean }) =>
@@ -33,6 +39,7 @@
{#snippet assetViewer({ onViewerClose })}
<SearchResultsAssetViewer timelineManager={searchResultsManager} {onViewerClose} />
{/snippet}
<SearchKeyboardShortcuts {assetInteraction} timelineManager={searchResultsManager} />
<Photostream
bind:this={viewer}
{stylePaddingHorizontalPx}
@@ -48,13 +55,11 @@
{@render children?.()}
{#snippet skeleton({ segment })}
<Skeleton height={segment.height - segment.timelineManager.headerHeight} />
<Skeleton height={segment.height - segment.timelineManager.headerHeight} {stylePaddingHorizontalPx} />
{/snippet}
{#snippet segment({ segment, onScrollCompensationMonthInDOM })}
{#snippet segment({ segment })}
<SelectableSegment
{segment}
{onScrollCompensationMonthInDOM}
timelineManager={searchResultsManager}
{assetInteraction}
isSelectionMode={false}

View File

@@ -33,9 +33,13 @@
let isFocus = $state(false);
let close: (() => Promise<void>) | undefined;
let navigating = false;
const listboxId = generateId();
onDestroy(() => {
if (navigating) {
return;
}
searchStore.isSearchEnabled = false;
});
@@ -44,6 +48,7 @@
closeDropdown();
searchStore.isSearchEnabled = false;
navigating = true;
await goto(`${AppRoute.SEARCH}?${params}`);
};
@@ -73,6 +78,9 @@
};
const onFocusOut = () => {
if (navigating) {
return;
}
searchStore.isSearchEnabled = false;
};
@@ -161,6 +169,9 @@
};
const closeDropdown = () => {
if (navigating) {
return;
}
showSuggestions = false;
isFocus = false;
searchHistoryBox?.clearSelection();

View File

@@ -15,7 +15,6 @@
{
segment: PhotostreamSegment;
scrollToFunction: (top: number) => void;
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
},
]
>;
@@ -107,44 +106,13 @@
updateSlidingWindow();
};
const scrollBy = (y: number) => {
if (element) {
element.scrollBy(0, y);
}
updateSlidingWindow();
};
const handleTriggeredScrollCompensation = (compensation: { heightDelta?: number; scrollTop?: number }) => {
const { heightDelta, scrollTop } = compensation;
if (heightDelta !== undefined) {
scrollBy(heightDelta);
} else if (scrollTop !== undefined) {
scrollTo(scrollTop);
}
timelineManager.clearScrollCompensation();
};
const getAssetHeight = (assetId: string, monthGroup: PhotostreamSegment) => {
// the following method may trigger any layouts, so need to
// handle any scroll compensation that may have been set
const height = monthGroup.findAssetAbsolutePosition(assetId);
// this is in a while loop, since scrollCompensations invoke scrolls
// which may load months, triggering more scrollCompensations. Call
// this in a loop, until no more layouts occur.
while (timelineManager.scrollCompensation.monthGroup) {
handleTriggeredScrollCompensation(timelineManager.scrollCompensation);
}
return height;
};
export const scrollToAssetId = async (assetId: string) => {
const monthGroup = await timelineManager.findSegmentForAssetId(assetId);
if (!monthGroup) {
return false;
}
const height = getAssetHeight(assetId, monthGroup);
const height = monthGroup.findAssetAbsolutePosition(assetId);
scrollTo(height);
return true;
};
@@ -262,7 +230,7 @@
<div
class="month-group"
style:margin-bottom={timelineManager.createLayoutOptions().spacing + 'px'}
style:margin-bottom={timelineManager.layoutOptions.spacing + 'px'}
style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:height={`${monthGroup.height}px`}
@@ -274,7 +242,6 @@
{@render segment({
segment: monthGroup,
scrollToFunction: scrollTo,
onScrollCompensationMonthInDOM: handleTriggeredScrollCompensation,
})}
{/if}
</div>

View File

@@ -24,7 +24,6 @@
[
{
segment: PhotostreamSegment;
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
},
]
>;

View File

@@ -5,7 +5,6 @@
import { navigate } from '$lib/utils/navigation';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import { searchStore } from '$lib/stores/search.svelte';
@@ -21,28 +20,17 @@
},
]
>;
segment: PhotostreamSegment;
isSelectionMode: boolean;
singleSelect: boolean;
timelineManager: PhotostreamManager;
assetInteraction: AssetInteraction;
onAssetOpen?: (asset: TimelineAsset, defaultAssetOpen: () => void) => void;
onAssetSelect?: (asset: TimelineAsset) => void;
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
}
let {
segment,
content,
isSelectionMode,
singleSelect,
assetInteraction,
timelineManager,
onAssetOpen,
onAssetSelect,
onScrollCompensationMonthInDOM,
}: Props = $props();
let { content, isSelectionMode, singleSelect, assetInteraction, timelineManager, onAssetOpen, onAssetSelect }: Props =
$props();
let shiftKeyIsDown = $state(false);
let isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
@@ -189,12 +177,6 @@
const assets = assetsSnapshot(timelineManager.retrieveLoadedRange(startAsset, endAsset));
assetInteraction.setAssetSelectionCandidates(assets);
};
$effect.root(() => {
if (timelineManager.scrollCompensation.monthGroup === segment) {
onScrollCompensationMonthInDOM(timelineManager.scrollCompensation);
}
});
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />

View File

@@ -99,10 +99,8 @@
title={(segment as MonthGroup).monthGroupTitle}
/>
{/snippet}
{#snippet segment({ segment, onScrollCompensationMonthInDOM })}
{#snippet segment({ segment })}
<SelectableSegment
{segment}
{onScrollCompensationMonthInDOM}
{timelineManager}
{assetInteraction}
{isSelectionMode}

View File

@@ -1,7 +1,7 @@
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task';
import { clamp, debounce } from 'lodash-es';
import { debounce } from 'lodash-es';
import type {
PhotostreamSegment,
@@ -16,21 +16,27 @@ import type {
export abstract class PhotostreamManager {
isInitialized = $state(false);
topSectionHeight = $state(0);
bottomSectionHeight = $state(60);
abstract get months(): PhotostreamSegment[];
assetCount = $derived.by(() => this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
timelineHeight = $derived.by(
() => this.months.reduce((accumulator, b) => accumulator + b.height, 0) + this.topSectionHeight,
);
assetCount = $derived.by(() => this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
topIntersectingMonthGroup: PhotostreamSegment | undefined = $state();
visibleWindow = $derived.by(() => ({
top: this.#scrollTop,
bottom: this.#scrollTop + this.viewportHeight,
}));
layoutOptions = $derived({
spacing: 2,
heightTolerance: 0.15,
rowHeight: this.rowHeight,
rowWidth: Math.floor(this.viewportWidth),
});
protected initTask = new CancellableTask(
() => (this.isInitialized = true),
() => (this.isInitialized = false),
@@ -49,26 +55,14 @@ export abstract class PhotostreamManager {
#suspendTransitions = $state(false);
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
scrollCompensation: {
heightDelta: number | undefined;
scrollTop: number | undefined;
monthGroup: PhotostreamSegment | undefined;
} = $state({
heightDelta: 0,
scrollTop: 0,
monthGroup: undefined,
});
#updatingIntersections = false;
constructor() {}
abstract get months(): PhotostreamSegment[];
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: TimelineManagerLayoutOptions) {
let changed = false;
changed ||= this.#setHeaderHeight(headerHeight);
changed ||= this.#setGap(gap);
changed ||= this.#setRowHeight(rowHeight);
if (changed) {
this.refreshLayout();
}
this.#setHeaderHeight(headerHeight);
this.#setGap(gap);
this.#setRowHeight(rowHeight);
}
#setHeaderHeight(value: number) {
@@ -158,39 +152,17 @@ export abstract class PhotostreamManager {
}
}
clearScrollCompensation() {
this.scrollCompensation = {
heightDelta: undefined,
scrollTop: undefined,
monthGroup: undefined,
};
}
updateIntersections() {
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
if (this.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
return;
}
let topIntersectingMonthGroup = undefined;
this.#updatingIntersections = true;
for (const month of this.months) {
updateIntersectionMonthGroup(this, month);
if (!topIntersectingMonthGroup && month.actuallyIntersecting) {
topIntersectingMonthGroup = month;
}
}
if (topIntersectingMonthGroup !== undefined && this.topIntersectingMonthGroup !== topIntersectingMonthGroup) {
this.topIntersectingMonthGroup = topIntersectingMonthGroup;
}
for (const month of this.months) {
if (month === this.topIntersectingMonthGroup) {
this.topIntersectingMonthGroup.percent = clamp(
(this.visibleWindow.top - this.topIntersectingMonthGroup.top) / this.topIntersectingMonthGroup.height,
0,
1,
);
} else {
month.percent = 0;
}
}
this.#updatingIntersections = false;
}
async init() {
@@ -234,17 +206,6 @@ export abstract class PhotostreamManager {
this.updateIntersections();
}
createLayoutOptions() {
const viewportWidth = this.viewportWidth;
return {
spacing: 2,
heightTolerance: 0.15,
rowHeight: this.#rowHeight,
rowWidth: Math.floor(viewportWidth),
};
}
async loadSegment(identifier: SegmentIdentifier, options?: { cancelable: boolean }): Promise<void> {
let cancelable = true;
if (options) {
@@ -279,13 +240,6 @@ export abstract class PhotostreamManager {
return Promise.resolve(void 0);
}
refreshLayout() {
for (const month of this.months) {
updateGeometry(this, month, { invalidateHeight: true });
}
this.updateIntersections();
}
getMaxScrollPercent() {
const totalHeight = this.timelineHeight + this.bottomSectionHeight + this.topSectionHeight;
return (totalHeight - this.viewportHeight) / totalHeight;

View File

@@ -4,6 +4,7 @@ import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import { getTestHook } from '$lib/managers/photostream-manager/TestHooks.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
@@ -20,7 +21,6 @@ export abstract class PhotostreamSegment {
#assets = $derived.by(() => this.viewerAssets.map((viewerAsset) => viewerAsset.asset));
initialCount = $state(0);
percent = $state(0);
assetsCount = $derived.by(() => (this.isLoaded ? this.viewerAssets.length : this.initialCount));
loader = new CancellableTask(
@@ -30,6 +30,10 @@ export abstract class PhotostreamSegment {
);
isHeightActual = $state(false);
constructor() {
getTestHook()?.hookSegment(this);
}
abstract get timelineManager(): PhotostreamManager;
abstract get identifier(): SegmentIdentifier;
@@ -66,9 +70,13 @@ export abstract class PhotostreamSegment {
}
async load(cancelable: boolean): Promise<'DONE' | 'WAITED' | 'CANCELED' | 'LOADED' | 'ERRORED'> {
return await this.loader.execute(async (signal: AbortSignal) => {
const executionStatus = await this.loader.execute(async (signal: AbortSignal) => {
await this.fetch(signal);
}, cancelable);
if (executionStatus === 'LOADED') {
this.layout();
}
return executionStatus;
}
protected abstract fetch(signal: AbortSignal): Promise<void>;
@@ -88,42 +96,34 @@ export abstract class PhotostreamSegment {
if (this.#height === height) {
return;
}
const { timelineManager: store, percent } = this;
const index = store.months.indexOf(this);
let needsIntersectionUpdate = false;
const timelineManager = this.timelineManager;
const index = timelineManager.months.indexOf(this);
const heightDelta = height - this.#height;
this.#height = height;
const prevMonthGroup = store.months[index - 1];
const prevMonthGroup = timelineManager.months[index - 1];
if (prevMonthGroup) {
const newTop = prevMonthGroup.#top + prevMonthGroup.#height;
if (this.#top !== newTop) {
this.#top = newTop;
}
}
for (let cursor = index + 1; cursor < store.months.length; cursor++) {
const monthGroup = this.timelineManager.months[cursor];
if (heightDelta === 0) {
return;
}
for (let cursor = index + 1; cursor < timelineManager.months.length; cursor++) {
const monthGroup = timelineManager.months[cursor];
const newTop = monthGroup.#top + heightDelta;
if (monthGroup.#top !== newTop) {
monthGroup.#top = newTop;
needsIntersectionUpdate = true;
}
}
if (store.topIntersectingMonthGroup) {
const currentIndex = store.months.indexOf(store.topIntersectingMonthGroup);
if (currentIndex > 0) {
if (index < currentIndex) {
store.scrollCompensation = {
heightDelta,
scrollTop: undefined,
monthGroup: this,
};
} else if (percent > 0) {
const top = this.top + height * percent;
store.scrollCompensation = {
heightDelta: undefined,
scrollTop: top,
monthGroup: this,
};
}
}
if (needsIntersectionUpdate) {
timelineManager.updateIntersections();
}
}

View File

@@ -0,0 +1,11 @@
import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
let testHooks: { hookSegment: (segment: PhotostreamSegment) => void } | undefined = undefined;
export function setTestHook(hooks: { hookSegment: (segment: PhotostreamSegment) => void }) {
testHooks = hooks;
}
export function getTestHook() {
return testHooks;
}

View File

@@ -74,7 +74,7 @@ export class SearchResultsSegment extends PhotostreamSegment {
layout(): void {
const timelineAssets = this.#viewerAssets.map((viewerAsset) => viewerAsset.asset);
const layoutOptions = this.timelineManager.createLayoutOptions();
const layoutOptions = this.timelineManager.layoutOptions;
const geometry = getJustifiedLayoutFromAssets(timelineAssets, layoutOptions);
this.height = timelineAssets.length === 0 ? 0 : geometry.containerHeight + this.timelineManager.headerHeight;

View File

@@ -51,7 +51,7 @@ export function calculateSegmentIntersecting(
}
/**
* Calculate intersection for viewer assets with additional parameters like header height and scroll compensation
* Calculate intersection for viewer assets with additional parameters like header height
*/
export function calculateViewerAssetIntersecting(
timelineManager: PhotostreamManager,
@@ -60,13 +60,8 @@ export function calculateViewerAssetIntersecting(
expandTop: number = INTERSECTION_EXPAND_TOP,
expandBottom: number = INTERSECTION_EXPAND_BOTTOM,
) {
const scrollCompensationHeightDelta = timelineManager.scrollCompensation?.heightDelta ?? 0;
const topWindow =
timelineManager.visibleWindow.top - timelineManager.headerHeight - expandTop + scrollCompensationHeightDelta;
const bottomWindow =
timelineManager.visibleWindow.bottom + timelineManager.headerHeight + expandBottom + scrollCompensationHeightDelta;
const topWindow = timelineManager.visibleWindow.top - timelineManager.headerHeight - expandTop;
const bottomWindow = timelineManager.visibleWindow.bottom + timelineManager.headerHeight + expandBottom;
const positionBottom = positionTop + positionHeight;
return isIntersecting(positionTop, positionBottom, topWindow, bottomWindow);

View File

@@ -34,7 +34,7 @@ export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthG
let dayGroupRow = 0;
let dayGroupCol = 0;
const options = timelineManager.createLayoutOptions();
const options = timelineManager.layoutOptions;
for (const dayGroup of month.dayGroups) {
dayGroup.layout(options, noDefer);

View File

@@ -73,6 +73,10 @@ export class MonthGroup extends PhotostreamSegment {
return loadFromTimeBuckets(this.timelineManager, this, this.timelineManager.options, signal);
}
layout(noDefer?: boolean) {
layoutMonthGroup(this.timelineManager, this, noDefer);
}
get lastDayGroup() {
return this.dayGroups.at(-1);
}
@@ -306,10 +310,6 @@ export class MonthGroup extends PhotostreamSegment {
this.loader?.cancel();
}
layout(noDefer?: boolean) {
layoutMonthGroup(this.timelineManager, this, noDefer);
}
#clearDeferredLayout() {
const hasDeferred = this.dayGroups.some((group) => group.deferredLayout);
if (hasDeferred) {

View File

@@ -1,4 +1,6 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
import { setTestHook } from '$lib/managers/photostream-manager/TestHooks.svelte';
import {
findMonthGroupForAsset,
getMonthGroupByDate,
@@ -7,6 +9,7 @@ import { AbortError } from '$lib/utils';
import { fromISODateTimeUTCToObject, getSegmentIdentifier } from '$lib/utils/timeline-util';
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
import type { MockedFunction } from 'vitest';
import { TimelineManager } from './timeline-manager.svelte';
import type { TimelineAsset } from './types';
@@ -57,8 +60,16 @@ describe('TimelineManager', () => {
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
);
const spys: { segment: PhotostreamSegment; cancelSpy: MockedFunction<() => void> }[] = [];
beforeEach(async () => {
timelineManager = new TimelineManager();
setTestHook({
hookSegment: (segment) => {
spys.push({ segment, cancelSpy: vi.spyOn(segment, 'cancel') as MockedFunction<() => void> });
},
});
sdkMock.getTimeBuckets.mockResolvedValue([
{ count: 1, timeBucket: '2024-03-01' },
{ count: 100, timeBucket: '2024-02-01' },
@@ -71,7 +82,7 @@ describe('TimelineManager', () => {
it('should load months in viewport', () => {
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
expect(spys[2].cancelSpy).toHaveBeenCalled();
});
it('calculates month height', () => {
@@ -85,13 +96,13 @@ describe('TimelineManager', () => {
expect.arrayContaining([
expect.objectContaining({ year: 2024, month: 3, height: 165.5 }),
expect.objectContaining({ year: 2024, month: 2, height: 11_996 }),
expect.objectContaining({ year: 2024, month: 1, height: 286 }),
expect.objectContaining({ year: 2024, month: 1, height: 48 }),
]),
);
});
it('calculates timeline height', () => {
expect(timelineManager.timelineHeight).toBe(12_447.5);
expect(timelineManager.timelineHeight).toBe(12_209.5);
});
});

View File

@@ -14,7 +14,6 @@ import { isEqual } from 'lodash-es';
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
import { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import {
addAssetsToMonthGroups,
runAssetOperation,
@@ -264,13 +263,6 @@ export class TimelineManager extends PhotostreamManager {
return [...unprocessedIds];
}
refreshLayout() {
for (const month of this.months) {
updateGeometry(this, month, { invalidateHeight: true });
}
this.updateIntersections();
}
getFirstAsset(): TimelineAsset | undefined {
return this.months[0]?.getFirstAsset();
}