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
JSX
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']),
};