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.
204 lines (189 loc) • 6.49 kB
JSX
import { useEffect, useId, useMemo } from 'react';
import { useEffectEvent } from 'use-effect-event';
import PropTypes from 'prop-types';
import Button from '@mui/material/Button';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
import ListItemButton from '@mui/material/ListItemButton';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import { styled } from '@mui/material/styles';
import { useTranslation } from 'react-i18next';
import SanitizedHtml from '../containers/SanitizedHtml';
import TruncatedHit from '../lib/TruncatedHit';
import { ScrollTo } from './ScrollTo';
const Root = styled(ListItem, { name: 'SearchHit', slot: 'root' })(({ ownerState, theme }) => ({
'&.Mui-focused': {
'&:hover': {
...(ownerState.windowSelected && {
backgroundColor: 'inherit',
}),
},
...(ownerState.windowSelected && {
backgroundColor: 'inherit',
}),
},
paddingRight: theme.spacing(1),
}));
const CanvasLabel = styled('h4', { name: 'SearchHit', slot: 'canvasLabel' })(({ theme }) => ({
display: 'inline',
marginBottom: theme.spacing(1.5),
}));
const Counter = styled(Chip, { name: 'SearchHit', slot: 'counter' })(({ ownerState, theme }) => ({
// eslint-disable-next-line no-nested-ternary
backgroundColor: theme.palette.hitCounter.default,
...(ownerState.windowSelected && {
backgroundColor: theme.palette.highlights.primary,
}),
...(ownerState.adjacent && !ownerState.windowSelected && {
backgroundColor: theme.palette.highlights.secondary,
}),
height: 30,
marginRight: theme.spacing(1),
typography: 'subtitle2',
verticalAlign: 'inherit',
}));
/** */
export function SearchHit({
adjacent = false, annotation = undefined, annotationId = undefined, annotationLabel = undefined,
announcer = undefined, canvasLabel = undefined, companionWindowId = undefined, containerRef = undefined,
focused = false, hit = undefined, index = undefined, selectAnnotation = () => {}, selected = false,
showDetails = () => {}, total = undefined, windowId, windowSelected = false,
}) {
const { t } = useTranslation();
useEffect(() => {
if (selected) {
announceHit();
}
}, [selected]); // eslint-disable-line react-hooks/exhaustive-deps
/** */
const handleClick = () => {
if (annotation && annotationId) selectAnnotation(annotationId);
};
const truncatedHit = useMemo(() => (hit && new TruncatedHit(hit, annotation)), [hit, annotation]);
/**
* Pass content describing the hit to the announcer prop (intended for screen readers)
*/
const announceHit = useEffectEvent(() => {
if (!announcer || !truncatedHit) return;
announcer(
[
t('pagination', { current: index + 1, total }),
canvasLabel,
annotationLabel,
truncatedHit.before,
truncatedHit.match,
truncatedHit.after,
].join(' '),
'polite',
);
});
const canvasLabelHtmlId = useId();
if (focused && !selected) return null;
const renderedHit = focused ? hit : hit && truncatedHit;
const truncated = hit && (renderedHit.before !== hit.before || renderedHit.after !== hit.after);
const ownerState = {
adjacent, focused, selected, windowSelected,
};
const header = (
<>
<Counter
component="span"
ownerState={ownerState}
label={index + 1}
/>
<CanvasLabel id={canvasLabelHtmlId}>
{canvasLabel}
{annotationLabel && (
<Typography component="span" sx={{ display: 'block', marginTop: 1 }}>{annotationLabel}</Typography>
)}
</CanvasLabel>
</>
);
return (
<ScrollTo
containerRef={containerRef}
offsetTop={96} // offset for the height of the form above
scrollTo={windowSelected && !focused}
>
<Root
ownerState={ownerState}
className={windowSelected ? 'windowSelected' : ''}
divider
component={selected ? 'li' : ListItemButton}
onClick={handleClick}
selected={selected}
>
<ListItemText
primary={header}
primaryTypographyProps={{ component: 'div', sx: { marginBottom: 1 }, variant: 'subtitle2' }}
secondaryTypographyProps={{ variant: 'body1' }}
secondary={(
<>
{hit && (
<>
<SanitizedHtml ruleSet="iiif" htmlString={renderedHit.before} />
{' '}
<strong>
<SanitizedHtml ruleSet="iiif" htmlString={renderedHit.match} />
</strong>
{' '}
<SanitizedHtml ruleSet="iiif" htmlString={renderedHit.after} />
{' '}
{truncated && !focused && (
<Button
sx={{
'& span': {
lineHeight: '1.5em',
},
margin: 0,
padding: 0,
textTransform: 'none',
}}
onClick={showDetails}
color="secondary"
size="small"
aria-describedby={canvasLabelHtmlId}
>
{t('more')}
</Button>
)}
</>
)}
{!hit && annotation && <SanitizedHtml ruleSet="iiif" htmlString={annotation.chars} />}
</>
)}
/>
</Root>
</ScrollTo>
);
}
SearchHit.propTypes = {
adjacent: PropTypes.bool,
annotation: PropTypes.shape({
chars: PropTypes.string,
targetId: PropTypes.string,
}),
annotationId: PropTypes.string,
annotationLabel: PropTypes.string,
announcer: PropTypes.func,
canvasLabel: PropTypes.string,
companionWindowId: PropTypes.string,
containerRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]),
focused: PropTypes.bool,
hit: PropTypes.shape({
after: PropTypes.string,
before: PropTypes.string,
match: PropTypes.string,
}),
index: PropTypes.number,
selectAnnotation: PropTypes.func,
selected: PropTypes.bool,
showDetails: PropTypes.func,
total: PropTypes.number,
windowId: PropTypes.string.isRequired, // eslint-disable-line react/no-unused-prop-types
windowSelected: PropTypes.bool,
};