Skip to content

Commit bc062da

Browse files
authored
feat(web): wasm justified layout (#19150)
* wasm justified layout * fix tests * redundant layout generation * raw position
1 parent 8038ae1 commit bc062da

File tree

13 files changed

+107
-146
lines changed

13 files changed

+107
-146
lines changed

web/eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ export default typescriptEslint.config(
118118
'unicorn/filename-case': 'off',
119119
'unicorn/prefer-top-level-await': 'off',
120120
'unicorn/import-style': 'off',
121+
'unicorn/no-for-loop': 'off',
121122
'svelte/button-has-type': 'error',
122123
'@typescript-eslint/await-thenable': 'error',
123124
'@typescript-eslint/no-floating-promises': 'error',

web/package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
},
2828
"dependencies": {
2929
"@formatjs/icu-messageformat-parser": "^2.9.8",
30+
"@immich/justified-layout-wasm": "^0.3.0",
3031
"@immich/sdk": "file:../open-api/typescript-sdk",
3132
"@immich/ui": "^0.22.7",
3233
"@mapbox/mapbox-gl-rtl-text": "0.2.3",

web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte

Lines changed: 36 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
1717
import { moveFocus } from '$lib/utils/focus-util';
1818
import { handleError } from '$lib/utils/handle-error';
19-
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
19+
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
2020
import { navigate } from '$lib/utils/navigation';
2121
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
2222
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
@@ -27,7 +27,7 @@
2727
import Portal from '../portal/portal.svelte';
2828
2929
interface Props {
30-
assets: (TimelineAsset | AssetResponseDto)[];
30+
assets: TimelineAsset[] | AssetResponseDto[];
3131
assetInteraction: AssetInteraction;
3232
disableAssetSelect?: boolean;
3333
showArchiveIcon?: boolean;
@@ -62,91 +62,39 @@
6262
6363
let { isViewing: isViewerOpen, asset: viewingAsset, setAssetId } = assetViewingStore;
6464
65-
let geometry: CommonJustifiedLayout | undefined = $state();
66-
67-
$effect(() => {
68-
const _assets = assets;
69-
updateSlidingWindow();
70-
71-
const rowWidth = Math.floor(viewport.width);
72-
const rowHeight = rowWidth < 850 ? 100 : 235;
73-
74-
geometry = getJustifiedLayoutFromAssets(_assets, {
65+
const geometry = $derived(
66+
getJustifiedLayoutFromAssets(assets, {
7567
spacing: 2,
76-
heightTolerance: 0.15,
77-
rowHeight,
78-
rowWidth,
79-
});
80-
});
81-
82-
let assetLayouts = $derived.by(() => {
83-
const assetLayout = [];
84-
let containerHeight = 0;
85-
let containerWidth = 0;
86-
if (geometry) {
87-
containerHeight = geometry.containerHeight;
88-
containerWidth = geometry.containerWidth;
89-
for (const [index, asset] of assets.entries()) {
90-
const top = geometry.getTop(index);
91-
const left = geometry.getLeft(index);
92-
const width = geometry.getWidth(index);
93-
const height = geometry.getHeight(index);
94-
95-
const layoutTopWithOffset = top + pageHeaderOffset;
96-
const layoutBottom = layoutTopWithOffset + height;
97-
98-
const display = layoutTopWithOffset < slidingWindow.bottom && layoutBottom > slidingWindow.top;
99-
100-
const layout = {
101-
asset,
102-
top,
103-
left,
104-
width,
105-
height,
106-
display,
107-
};
108-
109-
assetLayout.push(layout);
110-
}
111-
}
112-
113-
return {
114-
assetLayout,
115-
containerHeight,
116-
containerWidth,
117-
};
118-
});
68+
heightTolerance: 0.3,
69+
rowHeight: Math.floor(viewport.width) < 850 ? 100 : 235,
70+
rowWidth: Math.floor(viewport.width),
71+
}),
72+
);
11973
12074
let currentViewAssetIndex = 0;
12175
let shiftKeyIsDown = $state(false);
12276
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
123-
let slidingWindow = $state({ top: 0, bottom: 0 });
77+
let slidingTop = $state(0);
78+
let slidingBottom = $state(0);
12479
12580
const updateSlidingWindow = () => {
12681
const v = $state.snapshot(viewport);
12782
const top = (document.scrollingElement?.scrollTop || 0) - slidingWindowOffset;
128-
const bottom = top + v.height;
129-
const w = {
130-
top,
131-
bottom,
132-
};
133-
slidingWindow = w;
83+
slidingTop = top;
84+
slidingBottom = top + v.height;
13485
};
86+
$effect(updateSlidingWindow);
13587
const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true });
13688
13789
let lastIntersectedHeight = 0;
13890
$effect(() => {
13991
// notify we got to (near) the end of scroll
14092
const scrollPercentage =
141-
((slidingWindow.bottom - viewport.height) / (viewport.height - (document.scrollingElement?.clientHeight || 0))) *
142-
100;
143-
144-
if (scrollPercentage > 90) {
145-
const intersectedHeight = geometry?.containerHeight || 0;
146-
if (lastIntersectedHeight !== intersectedHeight) {
147-
debouncedOnIntersected();
148-
lastIntersectedHeight = intersectedHeight;
149-
}
93+
(slidingBottom - viewport.height) / (viewport.height - (document.scrollingElement?.clientHeight || 0));
94+
95+
if (scrollPercentage > 0.9 && lastIntersectedHeight !== geometry.containerHeight) {
96+
debouncedOnIntersected();
97+
lastIntersectedHeight = geometry.containerHeight;
15098
}
15199
});
152100
const viewAssetHandler = async (asset: TimelineAsset) => {
@@ -256,7 +204,7 @@
256204
isShowDeleteConfirmation = false;
257205
await deleteAssets(
258206
!(isTrashEnabled && !force),
259-
(assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))),
207+
(assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id)) as TimelineAsset[]),
260208
assetInteraction.selectedAssets,
261209
onReload,
262210
);
@@ -269,7 +217,7 @@
269217
assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
270218
);
271219
if (ids) {
272-
assets = assets.filter((asset) => !ids.includes(asset.id));
220+
assets = assets.filter((asset) => !ids.includes(asset.id)) as TimelineAsset[];
273221
deselectAllAssets();
274222
}
275223
};
@@ -454,7 +402,7 @@
454402
onkeyup={onKeyUp}
455403
onselectstart={onSelectStart}
456404
use:shortcuts={shortcutList}
457-
onscroll={() => updateSlidingWindow()}
405+
onscroll={updateSlidingWindow}
458406
/>
459407

460408
{#if isShowDeleteConfirmation}
@@ -468,13 +416,12 @@
468416
{#if assets.length > 0}
469417
<div
470418
style:position="relative"
471-
style:height={assetLayouts.containerHeight + 'px'}
472-
style:width={assetLayouts.containerWidth - 1 + 'px'}
419+
style:height={geometry.containerHeight + 'px'}
420+
style:width={geometry.containerWidth + 'px'}
473421
>
474-
{#each assetLayouts.assetLayout as layout, layoutIndex (layout.asset.id + '-' + layoutIndex)}
475-
{@const currentAsset = layout.asset}
476-
477-
{#if layout.display}
422+
{#each assets as asset, i (asset.id + i)}
423+
{#if geometry.getTop(i) + pageHeaderOffset < slidingBottom && geometry.getTop(i) + pageHeaderOffset + geometry.getHeight(i) > slidingTop}
424+
{@const layout = geometry.getPosition(i)}
478425
<div
479426
class="absolute"
480427
style:overflow="clip"
@@ -484,25 +431,25 @@
484431
readonly={disableAssetSelect}
485432
onClick={() => {
486433
if (assetInteraction.selectionActive) {
487-
handleSelectAssets(toTimelineAsset(currentAsset));
434+
handleSelectAssets(toTimelineAsset(asset));
488435
return;
489436
}
490-
void viewAssetHandler(toTimelineAsset(currentAsset));
437+
void viewAssetHandler(toTimelineAsset(asset));
491438
}}
492-
onSelect={() => handleSelectAssets(toTimelineAsset(currentAsset))}
493-
onMouseEvent={() => assetMouseEventHandler(toTimelineAsset(currentAsset))}
439+
onSelect={() => handleSelectAssets(toTimelineAsset(asset))}
440+
onMouseEvent={() => assetMouseEventHandler(toTimelineAsset(asset))}
494441
{showArchiveIcon}
495-
asset={toTimelineAsset(currentAsset)}
496-
selected={assetInteraction.hasSelectedAsset(currentAsset.id)}
497-
selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)}
442+
asset={toTimelineAsset(asset)}
443+
selected={assetInteraction.hasSelectedAsset(asset.id)}
444+
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
498445
thumbnailWidth={layout.width}
499446
thumbnailHeight={layout.height}
500447
/>
501-
{#if showAssetName && !isTimelineAsset(currentAsset)}
448+
{#if showAssetName && !isTimelineAsset(asset)}
502449
<div
503450
class="absolute text-center p-1 text-xs font-mono font-semibold w-full bottom-0 bg-linear-to-t bg-slate-50/75 dark:bg-slate-800/75 overflow-clip text-ellipsis whitespace-pre-wrap"
504451
>
505-
{currentAsset.originalFileName}
452+
{asset.originalFileName}
506453
</div>
507454
{/if}
508455
</div>

web/src/lib/managers/timeline-manager/day-group.svelte.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { AssetOrder } from '@immich/sdk';
22

33
import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
4-
import { getJustifiedLayoutFromAssets, getPosition } from '$lib/utils/layout-utils';
4+
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
55
import { plainDateTimeCompare } from '$lib/utils/timeline-util';
66

77
import type { MonthGroup } from './month-group.svelte';
@@ -153,8 +153,7 @@ export class DayGroup {
153153
this.width = geometry.containerWidth;
154154
this.height = assets.length === 0 ? 0 : geometry.containerHeight;
155155
for (let i = 0; i < this.viewerAssets.length; i++) {
156-
const position = getPosition(geometry, i);
157-
this.viewerAssets[i].position = position;
156+
this.viewerAssets[i].position = geometry.getPosition(i);
158157
}
159158
}
160159

web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock';
22
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
33
import { AbortError } from '$lib/utils';
44
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
5+
import { initSync } from '@immich/justified-layout-wasm';
56
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
67
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
8+
import { readFile } from 'node:fs/promises';
79
import { TimelineManager } from './timeline-manager.svelte';
810
import type { TimelineAsset } from './types';
911

@@ -23,6 +25,12 @@ function deriveLocalDateTimeFromFileCreatedAt(arg: TimelineAsset): TimelineAsset
2325
}
2426

2527
describe('TimelineManager', () => {
28+
beforeAll(async () => {
29+
// needed for Node.js
30+
const file = await readFile('node_modules/@immich/justified-layout-wasm/pkg/justified-layout-wasm_bg.wasm');
31+
initSync({ module: file });
32+
});
33+
2634
beforeEach(() => {
2735
vi.resetAllMocks();
2836
});
@@ -80,15 +88,15 @@ describe('TimelineManager', () => {
8088

8189
expect(plainMonths).toEqual(
8290
expect.arrayContaining([
83-
expect.objectContaining({ year: 2024, month: 3, height: 185.5 }),
84-
expect.objectContaining({ year: 2024, month: 2, height: 12_016 }),
91+
expect.objectContaining({ year: 2024, month: 3, height: 353.5 }),
92+
expect.objectContaining({ year: 2024, month: 2, height: 7786.452_636_718_75 }),
8593
expect.objectContaining({ year: 2024, month: 1, height: 286 }),
8694
]),
8795
);
8896
});
8997

9098
it('calculates timeline height', () => {
91-
expect(timelineManager.timelineHeight).toBe(12_487.5);
99+
expect(timelineManager.timelineHeight).toBe(8425.952_636_718_75);
92100
});
93101
});
94102

web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -377,13 +377,11 @@ export class TimelineManager {
377377
}
378378

379379
createLayoutOptions() {
380-
const viewportWidth = this.viewportWidth;
381-
382380
return {
383381
spacing: 2,
384-
heightTolerance: 0.15,
382+
heightTolerance: 0.3,
385383
rowHeight: this.#rowHeight,
386-
rowWidth: Math.floor(viewportWidth),
384+
rowWidth: Math.floor(this.viewportWidth),
387385
};
388386
}
389387

web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export class ViewerAsset {
1818
return calculateViewerAssetIntersecting(store, positionTop, this.position.height);
1919
});
2020

21-
position: CommonPosition | undefined = $state();
21+
position: CommonPosition | undefined = $state.raw();
2222
asset: TimelineAsset = <TimelineAsset>$state();
2323
id: string = $derived(this.asset.id);
2424

0 commit comments

Comments
 (0)