feat(frontend): bidirectional lazy load for anchored grid returns
Returning to the grid at a deep position (deep link / hard reload to a file, then back → /files?anchor=<id>) used to load only a tiny forward window at the anchor. Now the grid fills the viewport around the anchor and pages in both directions as the user scrolls. - loadAroundAnchor fetches a window centred on the anchor and pre-fills a few pages each way sequentially, then centres on the anchor once. Doing the initial fill explicitly (rather than via the sentinels) keeps the pages contiguous and leaves the sentinels out of range, so there's no mount-time load storm. - loading starts true when the URL carries an ?anchor, so the child InfiniteScroll sentinels (whose effects run before this page's reset effect on mount) can't fire a stray page-1 loadMore that interleaves with loadAroundAnchor. - loadPrev pages backward (direction=backward) and prepends, then shifts the scroller down by the added height via flushSync (no paint between prepend and correction) so the viewport stays visually fixed. - InfiniteScroll gains an `edge` prop; a top instance (shown only when hasPrev) drives upward loading. Both loaders share the `loading` guard. - Mock: honour direction=backward and emit prev_cursor; the Go backend already supports backward keyset pagination. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,26 +3,32 @@
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
onLoadMore: () => void;
|
onLoadMore: () => void;
|
||||||
|
/** Which edge to watch: 'bottom' loads on scroll down, 'top' on scroll up. */
|
||||||
|
edge?: 'top' | 'bottom';
|
||||||
}
|
}
|
||||||
|
|
||||||
let { loading = false, hasMore = true, onLoadMore }: Props = $props();
|
let { loading = false, hasMore = true, onLoadMore, edge = 'bottom' }: Props = $props();
|
||||||
|
|
||||||
// Lookahead distance below the viewport at which we start loading.
|
// Lookahead distance past the viewport edge at which we start loading.
|
||||||
const MARGIN = 300;
|
const MARGIN = 300;
|
||||||
|
|
||||||
let sentinel = $state<HTMLDivElement | undefined>();
|
let sentinel = $state<HTMLDivElement | undefined>();
|
||||||
|
|
||||||
// Fire onLoadMore while the sentinel is within MARGIN px of the viewport
|
// True while the sentinel is within MARGIN px of the watched viewport edge.
|
||||||
// bottom. Measuring the sentinel's viewport rect (rather than a scroll
|
// Measuring the sentinel's viewport rect (rather than a scroll container's
|
||||||
// container's scrollHeight/clientHeight) makes this correct whether the page
|
// scrollHeight/clientHeight) makes this correct whether the page scrolls on
|
||||||
// scrolls on <main> or on the window — and it loads exactly enough pages to
|
// <main> or on the window, and loads only enough to reach past the viewport.
|
||||||
// reach past the viewport, instead of eagerly loading everything.
|
function nearViewport(): boolean {
|
||||||
|
if (!sentinel) return false;
|
||||||
|
const rect = sentinel.getBoundingClientRect();
|
||||||
|
return edge === 'bottom'
|
||||||
|
? rect.top <= window.innerHeight + MARGIN
|
||||||
|
: rect.bottom >= -MARGIN;
|
||||||
|
}
|
||||||
|
|
||||||
function maybeLoad() {
|
function maybeLoad() {
|
||||||
if (loading || !hasMore || !sentinel) return;
|
if (loading || !hasMore || !sentinel) return;
|
||||||
const rect = sentinel.getBoundingClientRect();
|
if (nearViewport()) onLoadMore();
|
||||||
if (rect.top <= window.innerHeight + MARGIN) {
|
|
||||||
onLoadMore();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load on scroll: the observer notifies us when the sentinel nears the viewport.
|
// Load on scroll: the observer notifies us when the sentinel nears the viewport.
|
||||||
@@ -39,9 +45,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// After each load settles (loading → false), re-check synchronously: if the
|
// After each load settles (loading → false), re-check synchronously: if the
|
||||||
// freshly appended content still didn't push the sentinel past the viewport,
|
// freshly added content still didn't push the sentinel past the viewport, load
|
||||||
// load again. This fills short pages without the throttled observer lagging
|
// again. This fills short pages without the throttled observer lagging.
|
||||||
// and over-fetching.
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!loading) maybeLoad();
|
if (!loading) maybeLoad();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { selectionStore, selectionActive } from '$lib/stores/selection';
|
import { selectionStore, selectionActive } from '$lib/stores/selection';
|
||||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
import BulkTagEditor from '$lib/components/file/BulkTagEditor.svelte';
|
import BulkTagEditor from '$lib/components/file/BulkTagEditor.svelte';
|
||||||
import { tick } from 'svelte';
|
import { tick, flushSync } from 'svelte';
|
||||||
import { parseDslFilter } from '$lib/utils/dsl';
|
import { parseDslFilter } from '$lib/utils/dsl';
|
||||||
import type { File, FileCursorPage, Pool, PoolOffsetPage } from '$lib/api/types';
|
import type { File, FileCursorPage, Pool, PoolOffsetPage } from '$lib/api/types';
|
||||||
import { appSettings } from '$lib/stores/appSettings';
|
import { appSettings } from '$lib/stores/appSettings';
|
||||||
@@ -81,8 +81,15 @@
|
|||||||
|
|
||||||
let files = $state<File[]>([]);
|
let files = $state<File[]>([]);
|
||||||
let nextCursor = $state<string | null>(null);
|
let nextCursor = $state<string | null>(null);
|
||||||
let loading = $state(false);
|
// Start busy when arriving with an ?anchor so the InfiniteScroll sentinels
|
||||||
|
// can't fire a stray page-1 loadMore before loadAroundAnchor takes over (their
|
||||||
|
// effects run before this component's reset effect on mount).
|
||||||
|
let loading = $state(Boolean(page.url.searchParams.get('anchor')));
|
||||||
let hasMore = $state(true);
|
let hasMore = $state(true);
|
||||||
|
// Backward pagination — only active after an anchored return, where the grid
|
||||||
|
// starts in the middle of the list and can grow upward as well as downward.
|
||||||
|
let prevCursor = $state<string | null>(null);
|
||||||
|
let hasPrev = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let filterOpen = $state(false);
|
let filterOpen = $state(false);
|
||||||
|
|
||||||
@@ -106,9 +113,13 @@
|
|||||||
files = [];
|
files = [];
|
||||||
nextCursor = null;
|
nextCursor = null;
|
||||||
hasMore = true;
|
hasMore = true;
|
||||||
|
// A plain list starts at the top, so there is nothing before it.
|
||||||
|
prevCursor = null;
|
||||||
|
hasPrev = false;
|
||||||
error = '';
|
error = '';
|
||||||
// Deep-link return carrying a position anchor but no loaded grid: load a
|
// Deep-link return carrying a position anchor but no loaded grid: load a
|
||||||
// window starting at the anchor instead of page 1, so we can scroll to it.
|
// window centred on the anchor instead of page 1, so we can scroll to it
|
||||||
|
// and grow the grid in both directions.
|
||||||
if (firstRun && anchorParam) {
|
if (firstRun && anchorParam) {
|
||||||
void loadAroundAnchor(anchorParam);
|
void loadAroundAnchor(anchorParam);
|
||||||
}
|
}
|
||||||
@@ -130,7 +141,7 @@
|
|||||||
// frames because the cards may not be laid out yet right after a restore.
|
// frames because the cards may not be laid out yet right after a restore.
|
||||||
function scrollToFile(anchorId: string | null) {
|
function scrollToFile(anchorId: string | null) {
|
||||||
if (!anchorId) return;
|
if (!anchorId) return;
|
||||||
const attempt = (tries: number) => {
|
const tryScroll = () => {
|
||||||
const idx = files.findIndex((f) => f.id === anchorId);
|
const idx = files.findIndex((f) => f.id === anchorId);
|
||||||
const card =
|
const card =
|
||||||
idx >= 0 && scrollContainer
|
idx >= 0 && scrollContainer
|
||||||
@@ -138,11 +149,19 @@
|
|||||||
: null;
|
: null;
|
||||||
if (card) {
|
if (card) {
|
||||||
card.scrollIntoView({ block: 'center' });
|
card.scrollIntoView({ block: 'center' });
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (tries > 0) requestAnimationFrame(() => attempt(tries - 1));
|
return false;
|
||||||
};
|
};
|
||||||
requestAnimationFrame(() => attempt(10));
|
// Centre immediately if the card is already laid out (it is, right after the
|
||||||
|
// anchored load's tick) so it's pinned before any scroll sentinel fires.
|
||||||
|
if (tryScroll()) return;
|
||||||
|
let tries = 10;
|
||||||
|
const loop = () => {
|
||||||
|
if (tryScroll() || tries-- <= 0) return;
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(loop);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop the ?anchor= param once consumed so it doesn't linger in the URL or
|
// Drop the ?anchor= param once consumed so it doesn't linger in the URL or
|
||||||
@@ -154,23 +173,60 @@
|
|||||||
replaceState(`${url.pathname}${url.search}`, page.state);
|
replaceState(`${url.pathname}${url.search}`, page.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for a deep link / hard reload that has an anchor but no cached grid:
|
// How many pages to pre-load on each side of the anchor so the viewport is
|
||||||
// fetch a page anchored at that file so we can scroll to it.
|
// covered and the scroll sentinels start out of range (no mount-time storm).
|
||||||
|
const ANCHOR_PREFILL_PAGES = 3;
|
||||||
|
|
||||||
|
function baseListParams(): URLSearchParams {
|
||||||
|
const p = new URLSearchParams({
|
||||||
|
limit: String(LIMIT),
|
||||||
|
sort: sortState.sort,
|
||||||
|
order: sortState.order,
|
||||||
|
});
|
||||||
|
if (filterParam) p.set('filter', filterParam);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep link / hard reload with an anchor but no loaded grid: fetch a window
|
||||||
|
// centred on that file and pre-fill a few pages each way, all sequentially, so
|
||||||
|
// the grid is filled around the anchor before we centre on it. The prev/next
|
||||||
|
// cursors then let it keep growing in both directions as the user scrolls.
|
||||||
async function loadAroundAnchor(anchor: string) {
|
async function loadAroundAnchor(anchor: string) {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const a = baseListParams();
|
||||||
anchor,
|
a.set('anchor', anchor);
|
||||||
limit: String(LIMIT),
|
const res = await api.get<FileCursorPage>(`/files?${a}`);
|
||||||
sort: sortState.sort,
|
|
||||||
order: sortState.order,
|
|
||||||
});
|
|
||||||
if (filterParam) params.set('filter', filterParam);
|
|
||||||
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
|
||||||
files = res.items ?? [];
|
files = res.items ?? [];
|
||||||
nextCursor = res.next_cursor ?? null;
|
nextCursor = res.next_cursor ?? null;
|
||||||
hasMore = !!res.next_cursor;
|
hasMore = !!res.next_cursor;
|
||||||
|
prevCursor = res.prev_cursor ?? null;
|
||||||
|
hasPrev = !!res.prev_cursor;
|
||||||
|
|
||||||
|
for (let i = 0; i < ANCHOR_PREFILL_PAGES && hasMore && nextCursor; i++) {
|
||||||
|
const p = baseListParams();
|
||||||
|
p.set('cursor', nextCursor);
|
||||||
|
const r = await api.get<FileCursorPage>(`/files?${p}`);
|
||||||
|
files = [...files, ...(r.items ?? [])];
|
||||||
|
nextCursor = r.next_cursor ?? null;
|
||||||
|
hasMore = !!r.next_cursor;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < ANCHOR_PREFILL_PAGES && hasPrev && prevCursor; i++) {
|
||||||
|
const p = baseListParams();
|
||||||
|
p.set('cursor', prevCursor);
|
||||||
|
p.set('direction', 'backward');
|
||||||
|
const r = await api.get<FileCursorPage>(`/files?${p}`);
|
||||||
|
const items = r.items ?? [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
hasPrev = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
files = [...items, ...files];
|
||||||
|
prevCursor = r.prev_cursor ?? null;
|
||||||
|
hasPrev = !!r.prev_cursor;
|
||||||
|
}
|
||||||
|
|
||||||
await tick();
|
await tick();
|
||||||
scrollToFile(anchor);
|
scrollToFile(anchor);
|
||||||
consumeAnchor();
|
consumeAnchor();
|
||||||
@@ -181,18 +237,65 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load the previous page (scrolling up) and prepend it. Content inserted above
|
||||||
|
// the viewport would push everything down, so we shift the scroll down by
|
||||||
|
// exactly the added height — applied synchronously (flushSync, no paint in
|
||||||
|
// between) so there's no visible jump. Shares the `loading` guard with loadMore
|
||||||
|
// so the two never mutate files concurrently.
|
||||||
|
async function loadPrev() {
|
||||||
|
if (loading || !hasPrev || !prevCursor) return;
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const params = baseListParams();
|
||||||
|
params.set('cursor', prevCursor);
|
||||||
|
params.set('direction', 'backward');
|
||||||
|
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
||||||
|
const items = res.items ?? [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
hasPrev = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture scroll state just before mutating (after the request, so the
|
||||||
|
// user's scrolling during it doesn't skew the offset).
|
||||||
|
const scroller = getScroller();
|
||||||
|
const beforeTop = scroller.scrollTop;
|
||||||
|
const beforeHeight = scroller.scrollHeight;
|
||||||
|
|
||||||
|
files = [...items, ...files];
|
||||||
|
prevCursor = res.prev_cursor ?? null;
|
||||||
|
hasPrev = !!res.prev_cursor;
|
||||||
|
|
||||||
|
flushSync(); // apply the prepend now, before the browser paints
|
||||||
|
scroller.scrollTop = beforeTop + (scroller.scrollHeight - beforeHeight);
|
||||||
|
} catch {
|
||||||
|
hasPrev = false;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The element that actually scrolls the grid: the nearest scrollable ancestor,
|
||||||
|
// or the document scroller (the grid's <main> doesn't scroll on its own here).
|
||||||
|
function getScroller(): HTMLElement {
|
||||||
|
let el: HTMLElement | null = scrollContainer ?? null;
|
||||||
|
while (el) {
|
||||||
|
const oy = getComputedStyle(el).overflowY;
|
||||||
|
if ((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight) {
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
return (document.scrollingElement as HTMLElement | null) ?? document.documentElement;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
if (loading || !hasMore) return;
|
if (loading || !hasMore) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = baseListParams();
|
||||||
limit: String(LIMIT),
|
|
||||||
sort: sortState.sort,
|
|
||||||
order: sortState.order,
|
|
||||||
});
|
|
||||||
if (nextCursor) params.set('cursor', nextCursor);
|
if (nextCursor) params.set('cursor', nextCursor);
|
||||||
if (filterParam) params.set('filter', filterParam);
|
|
||||||
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
||||||
files = [...files, ...(res.items ?? [])];
|
files = [...files, ...(res.items ?? [])];
|
||||||
nextCursor = res.next_cursor ?? null;
|
nextCursor = res.next_cursor ?? null;
|
||||||
@@ -379,6 +482,10 @@
|
|||||||
<p class="error" role="alert">{error}</p>
|
<p class="error" role="alert">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if hasPrev}
|
||||||
|
<InfiniteScroll {loading} hasMore={hasPrev} onLoadMore={loadPrev} edge="top" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each files as file, i (file.id)}
|
{#each files as file, i (file.id)}
|
||||||
<FileCard
|
<FileCard
|
||||||
|
|||||||
@@ -606,12 +606,25 @@ export function mockApiPlugin(): Plugin {
|
|||||||
return json(res, 200, { items: slice, next_cursor, prev_cursor });
|
return json(res, 200, { items: slice, next_cursor, prev_cursor });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const direction = qs.get('direction') ?? 'forward';
|
||||||
|
if (direction === 'backward' && cursor) {
|
||||||
|
// Cursor marks the current top boundary; return the page before it.
|
||||||
|
const end = Number(Buffer.from(cursor, 'base64').toString());
|
||||||
|
const start = Math.max(0, end - limit);
|
||||||
|
const slice = MOCK_FILES.slice(start, end);
|
||||||
|
const prev_cursor = start > 0
|
||||||
|
? Buffer.from(String(start)).toString('base64') : null;
|
||||||
|
const next_cursor = Buffer.from(String(end)).toString('base64');
|
||||||
|
return json(res, 200, { items: slice, next_cursor, prev_cursor });
|
||||||
|
}
|
||||||
const offset = cursor ? Number(Buffer.from(cursor, 'base64').toString()) : 0;
|
const offset = cursor ? Number(Buffer.from(cursor, 'base64').toString()) : 0;
|
||||||
const slice = MOCK_FILES.slice(offset, offset + limit);
|
const slice = MOCK_FILES.slice(offset, offset + limit);
|
||||||
const nextOffset = offset + slice.length;
|
const nextOffset = offset + slice.length;
|
||||||
const next_cursor = nextOffset < MOCK_FILES.length
|
const next_cursor = nextOffset < MOCK_FILES.length
|
||||||
? Buffer.from(String(nextOffset)).toString('base64') : null;
|
? Buffer.from(String(nextOffset)).toString('base64') : null;
|
||||||
return json(res, 200, { items: slice, next_cursor, prev_cursor: null });
|
const prev_cursor = offset > 0
|
||||||
|
? Buffer.from(String(offset)).toString('base64') : null;
|
||||||
|
return json(res, 200, { items: slice, next_cursor, prev_cursor });
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /tags/{id}/rules
|
// GET /tags/{id}/rules
|
||||||
|
|||||||
Reference in New Issue
Block a user