From b163a07e84906c0b86b13b16cd5bf541c1b00530 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sun, 26 Oct 2025 10:36:47 +0100 Subject: [PATCH] lazy-thumbnails --- frontend/src/documents/DocumentsTable.jsx | 88 ++++++++++++++++++++--- 1 file changed, 77 insertions(+), 11 deletions(-) diff --git a/frontend/src/documents/DocumentsTable.jsx b/frontend/src/documents/DocumentsTable.jsx index 26993b0..954ae4f 100644 --- a/frontend/src/documents/DocumentsTable.jsx +++ b/frontend/src/documents/DocumentsTable.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; import { getTagColorStyle } from '../utils/colors'; import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon, TrashIcon } from '../ui/icons'; @@ -13,13 +13,62 @@ const getPageCount = (doc) => ? doc.current_version.metadata.page_count : null; +// Detects when an element becomes visible within a scroll container. +const useLazyVisibility = (rootRef, resetKey) => { + const targetRef = useRef(null); + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + setIsVisible(false); + }, [resetKey]); + + const rootNode = rootRef?.current || null; + + useEffect(() => { + if (isVisible) { + return undefined; + } + const element = targetRef.current; + if (!element) { + return undefined; + } + if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') { + setIsVisible(true); + return undefined; + } + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setIsVisible(true); + observer.disconnect(); + } + }); + }, + { + root: rootNode, + rootMargin: '200px 0px', + threshold: 0.01, + }, + ); + + observer.observe(element); + return () => observer.disconnect(); + }, [isVisible, rootNode, resetKey]); + + return { ref: targetRef, isVisible }; +}; + const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt, maxSize = LIST_ICON_SIZE, + scrollRootRef = null, }) => { + const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, document?.id); const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1)); const thumbnailAsset = useMemo( () => getAssetFromVersion(document?.current_version, 'thumbnail'), @@ -45,14 +94,15 @@ const DocumentThumbnailImage = ({ () => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }), [dimensions.height, dimensions.width], ); - const url = useMemo( - () => - resolveDocumentAssetUrl(document, 'thumbnail', { - ensureAssetUrl, - getAsset: getDocumentAsset, - }), - [document, ensureAssetUrl, getDocumentAsset], - ); + const url = useMemo(() => { + if (!isVisible) { + return null; + } + return resolveDocumentAssetUrl(document, 'thumbnail', { + ensureAssetUrl, + getAsset: getDocumentAsset, + }); + }, [document, ensureAssetUrl, getDocumentAsset, isVisible]); const pageCount = getPageCount(document); const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1; @@ -62,13 +112,15 @@ const DocumentThumbnailImage = ({ } return ( -
+
{url ? ( {alt event.preventDefault()} /> @@ -142,6 +194,18 @@ const DocumentsTable = ({ [draggingDocumentIds], ); const scrollRef = useRef(null); + const [, forceVisibilityTick] = useState(0); + const lastScrollNodeRef = useRef(null); + const assignScrollRef = useCallback((node) => { + if (lastScrollNodeRef.current === node) { + return; + } + lastScrollNodeRef.current = node; + scrollRef.current = node; + if (node) { + forceVisibilityTick((value) => value + 1); + } + }, []); const isGridView = viewMode === 'grid'; const gridIconSize = DEFAULT_GRID_ICON_SIZE; const handleSetViewMode = useCallback( @@ -329,7 +393,7 @@ const DocumentsTable = ({ )}
{ if (event.target === scrollRef.current) { @@ -454,6 +518,7 @@ const DocumentsTable = ({ getDocumentAsset={getDocumentAsset} alt={`Thumbnail for ${doc.title || doc.original_name}`} maxSize={gridIconSize} + scrollRootRef={scrollRef} />