Files
papercrate/frontend/src/documents/DocumentThumbnailImage.tsx
T
2025-11-13 02:37:16 +01:00

204 lines
5.8 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, JSX, MutableRefObject } from 'react';
import {
getAssetFromVersion,
resolveDocumentAssetUrl,
createAssetView,
} 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<Element | null> | null,
resetKey: string | number | null | undefined,
) => {
const targetRef = useRef<HTMLDivElement | null>(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' || !('IntersectionObserver' in window)) {
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 getPageCount = (doc: DocumentLike | null | undefined) =>
Number.isFinite(doc?.current_version?.metadata?.page_count)
? (doc?.current_version?.metadata?.page_count as number)
: null;
type Identifier = string | number;
interface DocumentVersionLike {
metadata?: {
page_count?: number;
width?: number;
height?: number;
[key: string]: unknown;
};
assets?: unknown;
[key: string]: unknown;
}
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<Element | null> | 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<AssetLike | null>(
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
[document?.current_version],
);
const thumbnailView = useMemo(() => createAssetView(thumbnailAsset), [thumbnailAsset]);
const primaryMetadata = thumbnailView.getPrimaryMetadata() || {};
const assetWidth = Number(primaryMetadata?.width);
const assetHeight = Number(primaryMetadata?.height);
const dimensions = useMemo(() => {
if (!Number.isFinite(assetWidth) || assetWidth <= 0 || !Number.isFinite(assetHeight) || assetHeight <= 0) {
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<CSSProperties>(
() => ({ 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);
}, [document, ensureAssetUrl, getDocumentAsset, isVisible]);
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 (Number.isFinite(assetWidth) && Number.isFinite(assetHeight) && assetWidth > 0 && assetHeight > 0) {
return assetWidth / assetHeight;
}
return null;
}, [assetWidth, assetHeight]);
useEffect(() => {
const node = visibilityRef.current;
if (!node) {
return;
}
if (aspectRatio) {
node.dataset.thumbnailAspect = String(aspectRatio);
} else {
delete node.dataset.thumbnailAspect;
}
}, [aspectRatio, visibilityRef]);
return (
<div className="document-thumbnail-wrapper" ref={visibilityRef}>
<div className={innerClasses.join(' ')} style={innerStyle}>
{url ? (
<img
src={url}
alt={alt}
className="document-thumbnail"
loading="lazy"
decoding="async"
draggable={false}
onDragStart={(event) => event.preventDefault()}
/>
) : (
<div className="thumb-placeholder">DOC</div>
)}
</div>
</div>
);
};
export default DocumentThumbnailImage;
export { DEFAULT_THUMBNAIL_SIZE };