204 lines
5.8 KiB
TypeScript
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 };
|