typescript
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { CSSProperties, JSX, MutableRefObject } from 'react';
|
||||
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } 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;
|
||||
}
|
||||
|
||||
interface DocumentLike {
|
||||
id?: Identifier;
|
||||
current_version?: DocumentVersionLike;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AssetLike {
|
||||
id?: Identifier;
|
||||
url?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type EnsureAssetUrl = (
|
||||
documentId: Identifier,
|
||||
asset: AssetLike,
|
||||
options?: { start?: number; limit?: number; [key: string]: unknown },
|
||||
) => Promise<unknown> | void;
|
||||
|
||||
type GetDocumentAsset = (document: DocumentLike | null | undefined, assetType: string) => AssetLike | null | undefined;
|
||||
|
||||
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 };
|
||||
Reference in New Issue
Block a user