UNPKG

mirador

Version:

An open-source, web-based 'multi-up' viewer that supports zoom-pan-rotate functionality, ability to display/compare simple images, and images with annotations.

215 lines (190 loc) 6.3 kB
import { useMemo, useEffect, useState, useContext } from 'react'; import PropTypes from 'prop-types'; import { styled } from '@mui/material/styles'; import { useInView } from 'react-intersection-observer'; import { IIIFResourceLabel } from './IIIFResourceLabel'; import { useThumbnailService } from '../hooks'; import FailedImageContext from '../contexts/FailedImageContext'; const Root = styled('div', { name: 'IIIFThumbnail', slot: 'root' })({}); const Label = styled('span', { name: 'IIIFThumbnail', slot: 'label' })(({ theme }) => ({ ...theme.typography.caption, })); const Image = styled('img', { name: 'IIIFThumbnail', slot: 'image' })(() => ({ height: 'auto', width: 'auto', })); /** * A lazy-loaded image that uses IntersectionObserver to determine when to * try to load the image (or even calculate that the image url/height/width are) */ const LazyLoadedImage = ({ border = false, placeholder, style = {}, thumbnail = null, resource, maxHeight = null, maxWidth = null, ...props }) => { const { ref, inView } = useInView(); const [loaded, setLoaded] = useState(false); const [failed, setFailed] = useState(false); const thumbnailService = useThumbnailService(maxHeight, maxWidth); const { notifyFailure, fallbackImage } = useContext(FailedImageContext); /** * Handles the intersection (visibility) of a given thumbnail, by requesting * the image and then updating the state. */ useEffect(() => { if (loaded || !inView) return; setLoaded(true); }, [inView, loaded]); const image = useMemo(() => { if (thumbnail) return thumbnail; const i = thumbnailService.get(resource); if (i && i.url) return i; return undefined; }, [resource, thumbnail, thumbnailService]); const imageStyles = useMemo(() => { const styleProps = { height: undefined, maxHeight: undefined, maxWidth: undefined, width: undefined, }; // If we're using a fallback image due to failure, use object-fit to preserve aspect ratio if (failed && fallbackImage) { return { ...styleProps, ...style, maxWidth, maxHeight, objectFit: 'contain', }; } if (!image) return { ...style, height: maxHeight, width: maxWidth }; const { height: thumbHeight, width: thumbWidth } = image; if (thumbHeight && thumbWidth) { if ((maxHeight && thumbHeight > maxHeight) || (maxWidth && thumbWidth > maxWidth)) { const aspectRatio = thumbWidth / thumbHeight; if (maxHeight && maxWidth) { if (maxWidth / maxHeight < aspectRatio) { styleProps.height = Math.round(maxWidth / aspectRatio); styleProps.width = maxWidth; } else { styleProps.height = maxHeight; styleProps.width = Math.round(maxHeight * aspectRatio); } } else if (maxHeight) { styleProps.height = maxHeight; styleProps.maxWidth = Math.round(maxHeight * aspectRatio); } else if (maxWidth) { styleProps.width = maxWidth; styleProps.maxHeight = Math.round(maxWidth / aspectRatio); } } else { styleProps.width = thumbWidth; styleProps.height = thumbHeight; } } else if (thumbHeight && !thumbWidth) { styleProps.height = maxHeight; } else if (!thumbHeight && thumbWidth) { styleProps.width = maxWidth; } else { // The thumbnail wasn't retrieved via an Image API service, // and its dimensions are not specified in the JSON-LD // (note that this may result in a blurry image) styleProps.width = maxWidth; styleProps.height = maxHeight; } return { ...styleProps, ...style, }; }, [image, maxWidth, maxHeight, style, failed, fallbackImage]); const { url: src = placeholder } = (loaded && (thumbnail || image)) || {}; // Decide final image source: normal, failed fallback, or placeholder const finalSrc = failed ? fallbackImage || placeholder : src; return ( <Image ownerState={{ border }} ref={ref} alt={failed ? 'Thumbnail image unavailable' : ''} role={failed ? undefined : 'presentation'} src={finalSrc} style={imageStyles} onError={() => { setFailed(true); if (finalSrc) notifyFailure(finalSrc); }} {...props} /> ); }; LazyLoadedImage.propTypes = { border: PropTypes.bool, maxHeight: PropTypes.number, maxWidth: PropTypes.number, placeholder: PropTypes.string.isRequired, resource: PropTypes.object.isRequired, style: PropTypes.object, thumbnail: PropTypes.shape({ height: PropTypes.number, url: PropTypes.string.isRequired, width: PropTypes.number, }), }; const defaultPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mMMDQmtBwADgwF/Op8FmAAAAABJRU5ErkJggg=='; /** * Uses InteractionObserver to "lazy" load canvas thumbnails that are in view. */ export function IIIFThumbnail({ border = false, children = null, imagePlaceholder = defaultPlaceholder, label = undefined, labelled = false, maxHeight = null, maxWidth = null, resource, style = {}, thumbnail = null, }) { // eslint-disable-next-line prefer-rest-params const ownerState = arguments[0]; return ( <Root ownerState={ownerState}> <LazyLoadedImage placeholder={imagePlaceholder} thumbnail={thumbnail} resource={resource} maxHeight={maxHeight} maxWidth={maxWidth} style={style} border={border} /> {labelled && <Label ownerState={ownerState}>{label || <IIIFResourceLabel resource={resource} />}</Label>} {children} </Root> ); } IIIFThumbnail.propTypes = { border: PropTypes.bool, children: PropTypes.node, imagePlaceholder: PropTypes.string, label: PropTypes.string, labelled: PropTypes.bool, maxHeight: PropTypes.number, maxWidth: PropTypes.number, resource: PropTypes.object.isRequired, style: PropTypes.object, thumbnail: PropTypes.shape({ height: PropTypes.number, url: PropTypes.string.isRequired, width: PropTypes.number, }), // eslint-disable-next-line react/no-unused-prop-types variant: PropTypes.oneOf(['inside', 'outside']), };