Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions frontend/src/app/(app)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,14 @@ import type { MediaShellItem } from '@/lib/types/media';
export default function GalleryPage() {
const {
items: shellItems,
windowStart,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
fetchPreviousPage,
hasPreviousPage,
isFetchingPreviousPage,
seekToIndex,
isSeeking,
} = useShellData();
Expand Down Expand Up @@ -121,6 +125,13 @@ export default function GalleryPage() {
// "more is coming" state as a scroll-driven page fetch, and it
// also keeps the grid's scroll lookahead from racing it.
isLoadingMore={isSearchActive ? false : isFetchingNextPage || isSeeking}
// The window slides both ways: scrolling back up past the
// evicted edge refetches the page above. `isSeeking` blocks the
// top lookahead during a jump for the same reason as the bottom.
onLoadPrevious={isSearchActive ? undefined : fetchPreviousPage}
hasPrevious={isSearchActive ? false : hasPreviousPage}
isLoadingPrevious={isSearchActive ? false : isFetchingPreviousPage || isSeeking}
windowStart={isSearchActive ? 0 : windowStart}
onSeekToIndex={isSearchActive ? undefined : seekToIndex}
/>
</ErrorBoundary>
Expand Down
78 changes: 78 additions & 0 deletions frontend/src/components/gallery/GalleryGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ interface GalleryGridProps {
hasMore?: boolean;
/** Whether a page fetch triggered by onLoadMore is in flight. */
isLoadingMore?: boolean;
/** Called when the scroll position nears the start of the loaded window. */
onLoadPrevious?: () => void;
/** Whether a page exists above the loaded window. */
hasPrevious?: boolean;
/** Whether a page fetch triggered by onLoadPrevious is in flight. */
isLoadingPrevious?: boolean;
/** Global index of the first loaded item; the window's absolute start. */
windowStart?: number;
/** Lets a timeline jump load the page holding an item index directly. */
onSeekToIndex?: (index: number) => Promise<void>;
}
Expand Down Expand Up @@ -94,6 +102,10 @@ export function GalleryGrid({
onLoadMore,
hasMore,
isLoadingMore,
onLoadPrevious,
hasPrevious,
isLoadingPrevious,
windowStart = 0,
onSeekToIndex,
}: GalleryGridProps) {
const containerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -282,6 +294,59 @@ export function GalleryGrid({
// own identity would loop.
}, [virtualRows]);

/**
* Keep the viewport pinned to the same content when the window slides.
*
* The window is a fixed span of pages: scrolling down evicts the top page and
* scrolling up prepends one. Either way the row set above the viewport changes,
* so every remaining row shifts and — because the browser keeps `scrollTop`
* numerically fixed — the content under the user would jump. This corrects for
* it by measuring the row straddling the top edge before and after the change
* and adjusting `scrollTop` by the exact delta.
*
* Heights are exact (justified layout is a pure function of the stored
* dimensions), so this is a correction, not an estimate. A jump replaces the
* whole window, so its anchor key is gone — the effect no-ops and the scrollbar
* lands the jump itself. Runs in a layout effect so the fix lands before paint.
*/
const prevRowsRef = useRef<VirtualRow[] | null>(null);
useLayoutEffect(() => {
const container = containerRef.current;
const prev = prevRowsRef.current;
prevRowsRef.current = virtualRows;
if (!container || !prev || prev === virtualRows) return;

const scrollTop = container.scrollTop;
if (scrollTop <= 0) return; // pinned to the top: nothing above to preserve

// Row occupying the top edge in the OLD layout, and its offset.
let acc = 0;
let anchorKey: string | null = null;
let anchorOld = 0;
for (const r of prev) {
if (acc > scrollTop) break;
anchorKey = r.key;
anchorOld = acc;
acc += r.height;
}
if (!anchorKey) return;

// The same row's offset in the NEW layout.
let anchorNew: number | null = null;
let nacc = 0;
for (const r of virtualRows) {
if (r.key === anchorKey) {
anchorNew = nacc;
break;
}
nacc += r.height;
}
if (anchorNew === null) return; // anchor evicted (a jump) — leave it to the scrollbar

const delta = anchorNew - anchorOld;
if (delta !== 0) container.scrollTop = scrollTop + delta;
}, [virtualRows]);

const virtualItems = virtualizer.getVirtualItems();

/**
Expand All @@ -302,6 +367,18 @@ export function GalleryGrid({
}
}, [distanceToEnd, viewportHeight, totalSize, hasMore, isLoadingMore, onLoadMore]);

/**
* Mirror of the above for the top edge: once the window has slid down (front
* pages evicted), scrolling back up refetches the page above before reaching
* it. The re-anchor effect keeps the viewport still as the page prepends.
*/
useEffect(() => {
if (!hasPrevious || isLoadingPrevious || totalSize <= 0) return;
if (scrollOffset <= viewportHeight * LOAD_MORE_VIEWPORT_LOOKAHEAD) {
onLoadPrevious?.();
}
}, [scrollOffset, viewportHeight, totalSize, hasPrevious, isLoadingPrevious, onLoadPrevious]);

return (
<div className="h-full relative">
<div
Expand Down Expand Up @@ -384,6 +461,7 @@ export function GalleryGrid({
timeline={timeline}
hasMore={hasMore}
onLoadMore={onLoadMore}
windowStart={windowStart}
onSeekToIndex={onSeekToIndex}
/>
</div>
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/components/gallery/PhotoGallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ interface PhotoGalleryProps {
hasMore?: boolean;
/** Whether a page fetch triggered by onLoadMore is in flight. */
isLoadingMore?: boolean;
/** Called when the grid scrolls near the start of the loaded window. */
onLoadPrevious?: () => void;
/** Whether a page exists above the loaded window (it slid down past it). */
hasPrevious?: boolean;
/** Whether a page fetch triggered by onLoadPrevious is in flight. */
isLoadingPrevious?: boolean;
/** Global index of the first loaded item; the window's absolute start. */
windowStart?: number;
/**
* Loads the page holding a global item index directly. Lets the timeline jump
* to a month far below the loaded range without walking every page to it.
Expand All @@ -68,6 +76,10 @@ export function PhotoGallery({
onLoadMore,
hasMore,
isLoadingMore,
onLoadPrevious,
hasPrevious,
isLoadingPrevious,
windowStart,
onSeekToIndex,
}: PhotoGalleryProps) {
const [lightboxId, setLightboxId] = useState<string | null>(null);
Expand Down Expand Up @@ -163,6 +175,10 @@ export function PhotoGallery({
onLoadMore={onLoadMore}
hasMore={hasMore}
isLoadingMore={isLoadingMore}
onLoadPrevious={onLoadPrevious}
hasPrevious={hasPrevious}
isLoadingPrevious={isLoadingPrevious}
windowStart={windowStart}
onSeekToIndex={onSeekToIndex}
/>
</div>
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/gallery/TimelineScrollbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ interface TimelineScrollbarProps {
hasMore?: boolean;
/** Lets a jump to an unloaded month pull the pages it needs. */
onLoadMore?: () => void;
/** Global index of the first loaded row; the loaded window's absolute start. */
windowStart?: number;
/** Loads the page holding a global item index directly, skipping the rest. */
onSeekToIndex?: (index: number) => Promise<void>;
}
Expand All @@ -24,6 +26,7 @@ export function TimelineScrollbar({
timeline: timelineProp,
hasMore,
onLoadMore,
windowStart,
onSeekToIndex,
}: TimelineScrollbarProps) {
const { data: globalTimeline } = useTimeline();
Expand All @@ -42,6 +45,7 @@ export function TimelineScrollbar({
} = useTimelineScrollbar(containerRef, virtualRows, timeline, {
hasMore,
onLoadMore,
windowStart,
onSeekToIndex,
});

Expand Down
Loading
Loading