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.
328 lines (301 loc) • 10.8 kB
JSX
import { useCallback, useId } from 'react';
import PropTypes from 'prop-types';
import { styled } from '@mui/material/styles';
import Input from '@mui/material/Input';
import InputAdornment from '@mui/material/InputAdornment';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import Slider from '@mui/material/Slider';
import Tooltip from '@mui/material/Tooltip';
import DragHandleIcon from '@mui/icons-material/DragHandleSharp';
import VerticalAlignTopSharp from '@mui/icons-material/VerticalAlignTopSharp';
import VerticalAlignBottomSharp from '@mui/icons-material/VerticalAlignBottomSharp';
import VisibilityIcon from '@mui/icons-material/VisibilitySharp';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOffSharp';
import OpacityIcon from '@mui/icons-material/OpacitySharp';
import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
import MiradorMenuButton from '../containers/MiradorMenuButton';
import IIIFThumbnail from '../containers/IIIFThumbnail';
import { IIIFResourceLabel } from './IIIFResourceLabel';
const StyledDragHandle = styled('div')(({ theme }) => ({
alignItems: 'center',
borderRight: `0.5px solid ${theme.palette.divider}`,
display: 'flex',
flex: 1,
flexDirection: 'row',
marginBottom: theme.spacing(-2),
marginRight: theme.spacing(1),
marginTop: theme.spacing(-2),
maxWidth: theme.spacing(3),
width: theme.spacing(3),
}));
/** */
const reorder = (list, startIndex, endIndex) => {
const result = Array.from(list);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
};
/** @private */
function Layer({
resource, layerMetadata = {}, index, handleOpacityChange, setLayerVisibility, moveToBackground, moveToFront,
}) {
const { t } = useTranslation();
const { width, height } = { height: undefined, width: 40 };
const layer = {
opacity: 1,
visibility: !!resource.preferred,
...(layerMetadata || {}),
};
return (
<div style={{ flex: 1 }}>
<div style={{ alignItems: 'flex-start', display: 'flex' }}>
<IIIFThumbnail
maxHeight={height}
maxWidth={width}
resource={resource}
border
/>
<Typography
sx={{
paddingLeft: 1,
}}
component="div"
variant="body1"
>
<IIIFResourceLabel resource={resource} fallback={index + 1} />
<div>
<MiradorMenuButton aria-label={t(layer.visibility ? 'layer_hide' : 'layer_show')} edge="start" size="small" onClick={() => { setLayerVisibility(resource.id, !layer.visibility); }}>
{ layer.visibility ? <VisibilityIcon /> : <VisibilityOffIcon /> }
</MiradorMenuButton>
{ layer.index !== 0 && (
<MiradorMenuButton aria-label={t('layer_moveToBackground')} size="small" onClick={() => { moveToBackground(resource.id); }}>
<VerticalAlignTopSharp />
</MiradorMenuButton>
)}
{ layer.index !== layerMetadata && (
<MiradorMenuButton aria-label={t('layer_moveToFront')} size="small" onClick={() => { moveToFront(resource.id); }}>
<VerticalAlignBottomSharp />
</MiradorMenuButton>
)}
</div>
</Typography>
</div>
<div style={{ alignItems: 'center', display: 'flex' }}>
<Tooltip title={t('layer_opacity')}>
<OpacityIcon sx={{ marginRight: 0.5 }} color={layer.visibility ? 'inherit' : 'disabled'} fontSize="small" />
</Tooltip>
<Input
sx={{
'MuiInput-input': {
'&::-webkit-outer-spin-button,&::-webkit-inner-spin-button': {
margin: 0,
WebkitAppearance: 'none',
},
MozAppearance: 'textfield',
textAlign: 'right',
typography: 'caption',
width: '3ch',
},
}}
disabled={!layer.visibility}
value={Math.round(layer.opacity * 100)}
type="number"
min={0}
max={100}
onChange={e => handleOpacityChange(resource.id, e.target.value)}
endAdornment={<InputAdornment disableTypography position="end"><Typography variant="caption">%</Typography></InputAdornment>}
inputProps={{
'aria-label': t('layer_opacity'),
}}
/>
<Slider
sx={{
marginLeft: 2,
marginRight: 2,
maxWidth: 150,
}}
disabled={!layer.visibility}
value={layer.opacity * 100}
onChange={(e, value) => handleOpacityChange(resource.id, value)}
/>
</div>
</div>
);
}
Layer.propTypes = {
handleOpacityChange: PropTypes.func.isRequired,
index: PropTypes.number.isRequired,
layerMetadata: PropTypes.objectOf(PropTypes.shape({
opacity: PropTypes.number,
visibility: PropTypes.bool,
})), // eslint-disable-line react/forbid-prop-types
moveToBackground: PropTypes.func.isRequired,
moveToFront: PropTypes.func.isRequired,
resource: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
setLayerVisibility: PropTypes.func.isRequired,
};
/** @private */
function DraggableLayer({
children, resource, index,
}) {
const { t } = useTranslation();
return (
<Draggable draggableId={resource.id} index={index}>
{(provided, snapshot) => (
<ListItem
ref={provided.innerRef}
{...provided.draggableProps}
component="li"
divider
sx={{
alignItems: 'stretch',
cursor: 'pointer',
paddingBottom: 2,
paddingRight: 2,
paddingTop: 2,
...(snapshot.isDragging && {
backgroundColor: 'action.hover',
}),
}}
disableGutters
key={resource.id}
>
<StyledDragHandle
{...provided.dragHandleProps}
sx={{
'&:hover': {
backgroundColor: snapshot.isDragging ? 'action.selected' : 'action.hover',
},
backgroundColor: snapshot.isDragging ? 'action.selected' : 'shades.light',
}}
>
<Tooltip title={t('layer_move')}>
<DragHandleIcon />
</Tooltip>
</StyledDragHandle>
{ children }
</ListItem>
)}
</Draggable>
);
}
DraggableLayer.propTypes = {
children: PropTypes.node.isRequired,
index: PropTypes.number.isRequired,
resource: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
};
/** */
export function CanvasLayers({
canvasId, index, label, layers, layerMetadata = {}, totalSize, updateLayers, windowId,
}) {
const { t } = useTranslation();
const droppableId = useId();
const handleOpacityChange = useCallback((layerId, value) => {
const payload = {
[layerId]: { opacity: value / 100.0 },
};
updateLayers(windowId, canvasId, payload);
}, [canvasId, updateLayers, windowId]);
/** */
const onDragEnd = useCallback((result) => {
if (!result.destination) return;
if (result.destination.droppableId !== droppableId) return;
if (result.source.droppableId !== droppableId) return;
const sortedLayers = reorder(
layers.map(l => l.id),
result.source.index,
result.destination.index,
);
const payload = layers.reduce((acc, layer) => {
acc[layer.id] = { index: sortedLayers.indexOf(layer.id) };
return acc;
}, {});
updateLayers(windowId, canvasId, payload);
}, [canvasId, droppableId, layers, updateLayers, windowId]);
/** */
const setLayerVisibility = useCallback((layerId, value) => {
const payload = {
[layerId]: { visibility: value },
};
updateLayers(windowId, canvasId, payload);
}, [canvasId, updateLayers, windowId]);
/** */
const moveToBackground = useCallback((layerId) => {
const sortedLayers = reorder(layers.map(l => l.id), layers.findIndex(l => l.id === layerId), 0);
const payload = layers.reduce((acc, layer) => {
acc[layer.id] = { index: sortedLayers.indexOf(layer.id) };
return acc;
}, {});
updateLayers(windowId, canvasId, payload);
}, [canvasId, layers, updateLayers, windowId]);
const moveToFront = useCallback((layerId) => {
const sortedLayers = reorder(layers.map(l => l.id), layers.findIndex(l => l.id === layerId), layers.length - 1);
const payload = layers.reduce((acc, layer) => {
acc[layer.id] = { index: sortedLayers.indexOf(layer.id) };
return acc;
}, {});
updateLayers(windowId, canvasId, payload);
}, [canvasId, layers, updateLayers, windowId]);
return (
<>
{ totalSize > 1 && (
<Typography
sx={{
paddingLeft: 1,
paddingRight: 1,
paddingTop: 2,
}}
variant="overline"
>
{t('annotationCanvasLabel', { context: `${index + 1}/${totalSize}`, label })}
</Typography>
)}
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId={droppableId}>
{(provided, snapshot) => (
<List
sx={{
paddingTop: 0,
}}
{...provided.droppableProps}
ref={provided.innerRef}
>
{
layers && layers.map((r, i) => (
<DraggableLayer key={r.id} resource={r} index={i}>
<Layer
resource={r}
index={i}
layerMetadata={(layerMetadata || {})[r.id] || {}}
handleOpacityChange={handleOpacityChange}
setLayerVisibility={setLayerVisibility}
moveToBackground={moveToBackground}
moveToFront={moveToFront}
/>
</DraggableLayer>
))
}
{provided.placeholder}
</List>
)}
</Droppable>
</DragDropContext>
</>
);
}
CanvasLayers.propTypes = {
canvasId: PropTypes.string.isRequired,
index: PropTypes.number.isRequired,
label: PropTypes.string.isRequired,
layerMetadata: PropTypes.objectOf(PropTypes.shape({
opacity: PropTypes.number,
})),
layers: PropTypes.arrayOf(PropTypes.shape({
})).isRequired,
totalSize: PropTypes.number.isRequired,
updateLayers: PropTypes.func.isRequired,
windowId: PropTypes.string.isRequired,
};