import { useEffect, useMemo, useRef, useState } from 'react'; import type { CSSProperties, JSX, MutableRefObject } from 'react'; import { getAssetFromVersion, resolveDocumentAssetUrl, resolveAssetUrl, } from '../asset_manager'; import type { DocumentLike as AssetManagerDocumentLike, AssetLike as AssetManagerAssetLike, EnsureAssetUrl as AssetManagerEnsureAssetUrl, GetAsset as AssetManagerGetAsset, } from '../asset_manager'; const DEFAULT_THUMBNAIL_SIZE = 48; // Detect when an element becomes visible within a scroll container so we can delay loading. const useLazyVisibility = ( rootRef: MutableRefObject | null, resetKey?: string | number | null, ) => { 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 (!window.IntersectionObserver) { setIsVisible(true); return undefined; } const observer = new window.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 getPageCount = (doc?: DocumentLike | null) => { const count = doc?.current_version?.metadata?.page_count; return Number.isFinite(count) ? Number(count) : null; }; type DocumentLike = AssetManagerDocumentLike; type AssetLike = AssetManagerAssetLike; type EnsureAssetUrl = AssetManagerEnsureAssetUrl; type GetDocumentAsset = AssetManagerGetAsset; interface DocumentThumbnailImageProps { document?: DocumentLike | null; ensureAssetUrl?: EnsureAssetUrl; getDocumentAsset?: GetDocumentAsset; alt?: string; maxSize?: number; scrollRootRef?: MutableRefObject | null; } const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt = '', maxSize = DEFAULT_THUMBNAIL_SIZE, scrollRootRef = null, }: DocumentThumbnailImageProps): JSX.Element => { const documentId = document?.id; const { ref: visibilityRef, isVisible } = useLazyVisibility(scrollRootRef, documentId); const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1)); const thumbnailAsset = useMemo( () => getAssetFromVersion(document?.current_version, 'thumbnail'), [document?.current_version], ); const thumbnailMetadata = (thumbnailAsset?.metadata as { width?: number; height?: number } | null) || null; const assetWidth = thumbnailMetadata?.width; const assetHeight = thumbnailMetadata?.height; const dimensions = useMemo(() => { const hasDimensions = typeof assetWidth === 'number' && assetWidth > 0 && typeof assetHeight === 'number' && assetHeight > 0; if (!hasDimensions) { return { width: resolvedMaxSize, height: resolvedMaxSize }; } const scale = Math.min(1, resolvedMaxSize / assetWidth, resolvedMaxSize / assetHeight); return { width: Math.max(1, Math.round(assetWidth * scale)), height: Math.max(1, Math.round(assetHeight * scale)), }; }, [assetWidth, assetHeight, resolvedMaxSize]); const innerStyle = useMemo( () => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }), [dimensions.height, dimensions.width], ); const url = useMemo(() => { if (!isVisible) { return null; } const options: { ensureAssetUrl?: EnsureAssetUrl; getAsset?: GetDocumentAsset; } = {}; if (ensureAssetUrl) { options.ensureAssetUrl = ensureAssetUrl; } if (getDocumentAsset) { options.getAsset = getDocumentAsset; } return resolveDocumentAssetUrl(document, 'thumbnail', options) || resolveAssetUrl(thumbnailAsset); }, [document, ensureAssetUrl, getDocumentAsset, isVisible, thumbnailAsset]); const pageCount = getPageCount(document); const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1; const innerClasses = ['document-thumbnail-inner']; if (showMultiPageBadge) { innerClasses.push('document-thumbnail-inner--multipage'); } const aspectRatio = useMemo(() => { if (dimensions.width > 0 && dimensions.height > 0) { return dimensions.width / dimensions.height; } return null; }, [dimensions.height, dimensions.width]); useEffect(() => { const node = visibilityRef.current; if (!node) { return; } if (aspectRatio) { node.dataset.thumbnailAspect = String(aspectRatio); } else { delete node.dataset.thumbnailAspect; } }, [aspectRatio, visibilityRef]); return (
{url ? ( {alt} event.preventDefault()} /> ) : (
DOC
)}
); }; export default DocumentThumbnailImage; export { DEFAULT_THUMBNAIL_SIZE };