fix(frontend): restore grid position via URL anchor on return from viewer
Returning from the file viewer left the grid scrolled to the top: the position lived only in volatile module state and was never carried anywhere, and the scroll restore ran before SvelteKit's own scroll reset (on goto) clobbered it back to the top — worsened by the body, not <main>, being the effective scroller, so scrollTop restoration was inert. - The viewer's back/Escape now return to /files?anchor=<currentId> with noScroll, carrying the position in the URL (survives reload, no longer depends on hidden in-memory state). - The list restores grid DATA from the snapshot as before, but scrolls in afterNavigate — which runs AFTER SvelteKit's scroll handling — using scrollIntoView so it works whether <main> or the window scrolls. The ?anchor is consumed (stripped via shallow replaceState) once applied. - Deep link / hard reload with an anchor but no cached grid falls back to loading a page anchored at that file, then scrolling to it. - Snapshot is mirrored to sessionStorage so a refresh still restores. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { browser } from '$app/environment';
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '$lib/api/client';
|
||||||
import type { File, FileCursorPage } from '$lib/api/types';
|
import type { File, FileCursorPage } from '$lib/api/types';
|
||||||
|
|
||||||
@@ -9,13 +10,20 @@ export interface FilesQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A snapshot of the files grid, kept in memory so that opening a file and
|
* A snapshot of the files grid, kept so that opening a file and returning
|
||||||
* returning restores the same list (and scroll position) instead of reloading
|
* restores the same list (and scroll position) instead of reloading page 1 from
|
||||||
* page 1 from the top. The file viewer also reads this to derive prev/next and
|
* the top. The file viewer also reads this to derive prev/next, to find the list
|
||||||
* extends it as the user pages past the loaded set.
|
* URL to return to, and extends it as the user pages past the loaded set.
|
||||||
|
*
|
||||||
|
* Held in a module variable (survives client-side navigation) AND mirrored to
|
||||||
|
* sessionStorage (survives a full reload / deep navigation within the tab).
|
||||||
*/
|
*/
|
||||||
export interface FilesSnapshot {
|
export interface FilesSnapshot {
|
||||||
query: FilesQuery;
|
query: FilesQuery;
|
||||||
|
/** Search string of the list URL this grid was viewed at (e.g. "?filter=x"),
|
||||||
|
* so the viewer returns to the exact same filtered list rather than bare
|
||||||
|
* /files — otherwise the filter is lost and the snapshot no longer matches. */
|
||||||
|
listSearch: string;
|
||||||
files: File[];
|
files: File[];
|
||||||
nextCursor: string | null;
|
nextCursor: string | null;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
@@ -30,27 +38,63 @@ export function queryKey(q: FilesQuery): string {
|
|||||||
return `${q.sort}|${q.order}|${q.filter ?? ''}`;
|
return `${q.sort}|${q.order}|${q.filter ?? ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'filesSnapshot';
|
||||||
|
|
||||||
let snapshot: FilesSnapshot | null = null;
|
let snapshot: FilesSnapshot | null = null;
|
||||||
|
let hydrated = false;
|
||||||
let loading = false;
|
let loading = false;
|
||||||
|
|
||||||
|
/** Lazily restore the snapshot from sessionStorage the first time it's read so
|
||||||
|
* the position survives a page reload, not just client-side navigation. */
|
||||||
|
function hydrate(): void {
|
||||||
|
if (hydrated) return;
|
||||||
|
hydrated = true;
|
||||||
|
if (!browser) return;
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) snapshot = JSON.parse(raw) as FilesSnapshot;
|
||||||
|
} catch {
|
||||||
|
// Corrupt/missing — start fresh.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist(): void {
|
||||||
|
if (!browser) return;
|
||||||
|
try {
|
||||||
|
if (snapshot) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||||
|
else sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// Quota or serialization failure — non-critical, in-memory copy still works.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Save (replace) the current grid snapshot. */
|
/** Save (replace) the current grid snapshot. */
|
||||||
export function saveFilesSnapshot(s: FilesSnapshot): void {
|
export function saveFilesSnapshot(s: FilesSnapshot): void {
|
||||||
snapshot = s;
|
snapshot = s;
|
||||||
|
hydrated = true;
|
||||||
|
persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read the snapshot without consuming it. */
|
/** Read the snapshot without consuming it. */
|
||||||
export function peekFilesSnapshot(): FilesSnapshot | null {
|
export function peekFilesSnapshot(): FilesSnapshot | null {
|
||||||
|
hydrate();
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Forget the snapshot (e.g. on logout). */
|
/** Forget the snapshot (e.g. on logout). */
|
||||||
export function clearFilesSnapshot(): void {
|
export function clearFilesSnapshot(): void {
|
||||||
snapshot = null;
|
snapshot = null;
|
||||||
|
hydrated = true;
|
||||||
|
persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Record the file currently being viewed so back-navigation lands on it. */
|
/** Record the file currently being viewed so back-navigation lands on it. */
|
||||||
export function setLastOpened(id: string): void {
|
export function setLastOpened(id: string): void {
|
||||||
if (snapshot) snapshot = { ...snapshot, lastOpenedId: id };
|
hydrate();
|
||||||
|
if (snapshot) {
|
||||||
|
snapshot = { ...snapshot, lastOpenedId: id };
|
||||||
|
persist();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,6 +105,7 @@ export function setLastOpened(id: string): void {
|
|||||||
* a load is already in flight.
|
* a load is already in flight.
|
||||||
*/
|
*/
|
||||||
export async function loadMoreIntoSnapshot(limit: number): Promise<void> {
|
export async function loadMoreIntoSnapshot(limit: number): Promise<void> {
|
||||||
|
hydrate();
|
||||||
if (!snapshot || !snapshot.hasMore || loading) return;
|
if (!snapshot || !snapshot.hasMore || loading) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
@@ -77,6 +122,7 @@ export async function loadMoreIntoSnapshot(limit: number): Promise<void> {
|
|||||||
nextCursor: res.next_cursor ?? null,
|
nextCursor: res.next_cursor ?? null,
|
||||||
hasMore: !!res.next_cursor,
|
hasMore: !!res.next_cursor,
|
||||||
};
|
};
|
||||||
|
persist();
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical: leave the snapshot unchanged.
|
// Non-critical: leave the snapshot unchanged.
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { goto } from '$app/navigation';
|
import { afterNavigate, goto, replaceState } from '$app/navigation';
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '$lib/api/client';
|
||||||
import { ApiError } from '$lib/api/client';
|
import { ApiError } from '$lib/api/client';
|
||||||
import FileCard from '$lib/components/file/FileCard.svelte';
|
import FileCard from '$lib/components/file/FileCard.svelte';
|
||||||
@@ -21,7 +21,6 @@
|
|||||||
saveFilesSnapshot,
|
saveFilesSnapshot,
|
||||||
peekFilesSnapshot,
|
peekFilesSnapshot,
|
||||||
queryKey,
|
queryKey,
|
||||||
type FilesSnapshot,
|
|
||||||
} from '$lib/stores/filesCache';
|
} from '$lib/stores/filesCache';
|
||||||
|
|
||||||
let scrollContainer = $state<HTMLElement | undefined>();
|
let scrollContainer = $state<HTMLElement | undefined>();
|
||||||
@@ -92,48 +91,113 @@
|
|||||||
let filterOpen = $state(false);
|
let filterOpen = $state(false);
|
||||||
|
|
||||||
let filterParam = $derived(page.url.searchParams.get('filter'));
|
let filterParam = $derived(page.url.searchParams.get('filter'));
|
||||||
|
let anchorParam = $derived(page.url.searchParams.get('anchor'));
|
||||||
let activeTokens = $derived(parseDslFilter(filterParam));
|
let activeTokens = $derived(parseDslFilter(filterParam));
|
||||||
let sortState = $derived($fileSorting);
|
let sortState = $derived($fileSorting);
|
||||||
|
|
||||||
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${filterParam ?? ''}`);
|
let resetKey = $derived(`${sortState.sort}|${sortState.order}|${filterParam ?? ''}`);
|
||||||
let prevKey = $state('');
|
let prevKey = $state('');
|
||||||
|
|
||||||
|
// Restore the grid DATA on entry. Scroll restoration is handled separately in
|
||||||
|
// afterNavigate (below), which runs after SvelteKit's own scroll reset.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const key = resetKey;
|
const key = resetKey;
|
||||||
if (key === prevKey) return;
|
if (key === prevKey) return;
|
||||||
const firstRun = prevKey === '';
|
const firstRun = prevKey === '';
|
||||||
prevKey = key;
|
prevKey = key;
|
||||||
|
|
||||||
// On the first mount, restore the grid the user left when opening a file
|
// On entry, restore the grid the user left when opening a file (same
|
||||||
// (same sort/order/filter) so back-navigation keeps their place. Any later
|
// sort/order/filter) so back-navigation keeps their place. A later change
|
||||||
// change means the query itself changed → reset and reload from the top.
|
// means the query itself changed → reset and reload from the top.
|
||||||
const snap = peekFilesSnapshot();
|
const snap = peekFilesSnapshot();
|
||||||
if (firstRun && snap && queryKey(snap.query) === key) {
|
if (firstRun && snap && queryKey(snap.query) === key) {
|
||||||
files = snap.files;
|
files = snap.files;
|
||||||
nextCursor = snap.nextCursor;
|
nextCursor = snap.nextCursor;
|
||||||
hasMore = snap.hasMore;
|
hasMore = snap.hasMore;
|
||||||
void tick().then(() => restoreScroll(snap));
|
|
||||||
} else {
|
} else {
|
||||||
files = [];
|
files = [];
|
||||||
nextCursor = null;
|
nextCursor = null;
|
||||||
hasMore = true;
|
hasMore = true;
|
||||||
error = '';
|
error = '';
|
||||||
|
// Deep link / reload carrying a position anchor but no cached grid:
|
||||||
|
// load a window starting at the anchor so we have something to scroll to.
|
||||||
|
if (firstRun && anchorParam) {
|
||||||
|
void loadAroundAnchor(anchorParam);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Scroll the grid so the last-opened file is centred; fall back to the saved
|
// Scroll restoration runs here because afterNavigate fires AFTER SvelteKit has
|
||||||
// scroll offset if that card isn't present (e.g. nothing was opened).
|
// applied its own scroll handling, so our position wins instead of being reset
|
||||||
function restoreScroll(snap: FilesSnapshot) {
|
// to the top. The anchor (last-viewed file) is read from the URL.
|
||||||
if (!scrollContainer) return;
|
afterNavigate((nav) => {
|
||||||
const idx = snap.lastOpenedId ? files.findIndex((f) => f.id === snap.lastOpenedId) : -1;
|
const anchor = page.url.searchParams.get('anchor');
|
||||||
if (idx >= 0) {
|
if (anchor) {
|
||||||
const card = scrollContainer.querySelector<HTMLElement>(`[data-file-index="${idx}"]`);
|
scrollToFile(anchor);
|
||||||
|
consumeAnchor();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Plain entry/reload (no explicit anchor): fall back to the snapshot's
|
||||||
|
// last-opened file so a refresh still lands near where the user was.
|
||||||
|
if (nav.type === 'enter') {
|
||||||
|
scrollToFile(peekFilesSnapshot()?.lastOpenedId ?? null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll the grid so the given file is centred. Uses scrollIntoView (works
|
||||||
|
// whether the actual scroller is <main> or the window) and retries across
|
||||||
|
// frames because the cards may not be laid out yet right after a restore.
|
||||||
|
function scrollToFile(anchorId: string | null) {
|
||||||
|
if (!anchorId) return;
|
||||||
|
const attempt = (tries: number) => {
|
||||||
|
const idx = files.findIndex((f) => f.id === anchorId);
|
||||||
|
const card =
|
||||||
|
idx >= 0 && scrollContainer
|
||||||
|
? scrollContainer.querySelector<HTMLElement>(`[data-file-index="${idx}"]`)
|
||||||
|
: null;
|
||||||
if (card) {
|
if (card) {
|
||||||
card.scrollIntoView({ block: 'center' });
|
card.scrollIntoView({ block: 'center' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (tries > 0) requestAnimationFrame(() => attempt(tries - 1));
|
||||||
|
};
|
||||||
|
requestAnimationFrame(() => attempt(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the ?anchor= param once consumed so it doesn't linger in the URL or
|
||||||
|
// re-fire on later interactions. Shallow update — no navigation, no scroll.
|
||||||
|
function consumeAnchor() {
|
||||||
|
const url = new URL(page.url);
|
||||||
|
if (!url.searchParams.has('anchor')) return;
|
||||||
|
url.searchParams.delete('anchor');
|
||||||
|
replaceState(`${url.pathname}${url.search}`, page.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for a deep link / hard reload that has an anchor but no cached grid:
|
||||||
|
// fetch a page anchored at that file so we can scroll to it.
|
||||||
|
async function loadAroundAnchor(anchor: string) {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
anchor,
|
||||||
|
limit: String(LIMIT),
|
||||||
|
sort: sortState.sort,
|
||||||
|
order: sortState.order,
|
||||||
|
});
|
||||||
|
if (filterParam) params.set('filter', filterParam);
|
||||||
|
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
||||||
|
files = res.items ?? [];
|
||||||
|
nextCursor = res.next_cursor ?? null;
|
||||||
|
hasMore = !!res.next_cursor;
|
||||||
|
await tick();
|
||||||
|
scrollToFile(anchor);
|
||||||
|
consumeAnchor();
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof ApiError ? err.message : 'Failed to load files';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
}
|
}
|
||||||
scrollContainer.scrollTop = snap.scrollTop;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
@@ -183,6 +247,9 @@
|
|||||||
// and scroll position instead of reloading page 1 from the top.
|
// and scroll position instead of reloading page 1 from the top.
|
||||||
saveFilesSnapshot({
|
saveFilesSnapshot({
|
||||||
query: { sort: sortState.sort, order: sortState.order, filter: filterParam },
|
query: { sort: sortState.sort, order: sortState.order, filter: filterParam },
|
||||||
|
// Only the filter — never the transient ?anchor — defines the list URL
|
||||||
|
// to return to.
|
||||||
|
listSearch: filterParam ? `?filter=${encodeURIComponent(filterParam)}` : '',
|
||||||
files,
|
files,
|
||||||
nextCursor,
|
nextCursor,
|
||||||
hasMore,
|
hasMore,
|
||||||
|
|||||||
@@ -219,11 +219,23 @@
|
|||||||
goto(`/files/${f.id}`);
|
goto(`/files/${f.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return to the list the user came from, passing the current file as an
|
||||||
|
// ?anchor=<id> so the grid scrolls back to it (the position is carried in the
|
||||||
|
// URL — survives reload and doesn't depend on hidden in-memory state).
|
||||||
|
// noScroll stops SvelteKit from jumping the list to the top first.
|
||||||
|
function backToList() {
|
||||||
|
const snap = peekFilesSnapshot();
|
||||||
|
const params = new URLSearchParams(snap?.listSearch ?? '');
|
||||||
|
if (fileId) params.set('anchor', fileId);
|
||||||
|
const qs = params.toString();
|
||||||
|
goto('/files' + (qs ? `?${qs}` : ''), { noScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||||
if (e.key === 'ArrowLeft') navigateTo(prevFile);
|
if (e.key === 'ArrowLeft') navigateTo(prevFile);
|
||||||
if (e.key === 'ArrowRight') navigateTo(nextFile);
|
if (e.key === 'ArrowRight') navigateTo(nextFile);
|
||||||
if (e.key === 'Escape') goto('/files');
|
if (e.key === 'Escape') backToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Helpers ----
|
||||||
@@ -252,7 +264,7 @@
|
|||||||
<div class="viewer-page">
|
<div class="viewer-page">
|
||||||
<!-- Top bar -->
|
<!-- Top bar -->
|
||||||
<div class="top-bar">
|
<div class="top-bar">
|
||||||
<button class="back-btn" onclick={() => goto('/files')} aria-label="Back to files">
|
<button class="back-btn" onclick={backToList} aria-label="Back to files">
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
<path d="M12 4L6 10L12 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M12 4L6 10L12 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
Reference in New Issue
Block a user