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.
259 lines (216 loc) • 8.17 kB
JSX
import Openseadragon from 'openseadragon';
import PropTypes from 'prop-types';
import { useEffect, useId, useRef, useReducer, useState, useCallback } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { useTranslation } from 'react-i18next';
import OpenSeadragonViewerContext from '../contexts/OpenSeadragonViewerContext';
/** Handle setting up OSD for use in mirador + react */
function OpenSeadragonComponent({
children = undefined,
Container = 'div',
osdConfig = {},
viewerConfig = {},
onUpdateViewport = () => {},
setViewer = () => {},
style = {},
...passThruProps
}) {
const id = useId();
const ref = useRef();
const [grabbing, setGrabbing] = useState(false);
const viewerRef = useRef(undefined);
const initialViewportSet = useRef(false);
const lastAppliedBounds = useRef(null);
const isResettingViewport = useRef(false);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const moveHandler = useDebouncedCallback(
useCallback(
(event) => {
/** Shim to provide a mouse-move event coming from the viewer */
viewerRef.current?.raiseEvent('mouse-move', event);
},
[viewerRef],
),
10,
);
const onViewportChange = useCallback(
(event) => {
const { viewport } = event.eventSource;
if (!initialViewportSet.current) return;
// Don't save viewport changes during automatic recentering
if (isResettingViewport.current) return;
onUpdateViewport({
bounds: viewport.getBounds(),
flip: viewport.getFlip(),
rotation: viewport.getRotation(),
x: Math.round(viewport.centerSpringX.target.value),
y: Math.round(viewport.centerSpringY.target.value),
zoom: viewport.zoomSpring.target.value,
});
},
[onUpdateViewport, initialViewportSet],
);
const setInitialBounds = useCallback(
({ viewport }) => {
if (initialViewportSet.current) return;
initialViewportSet.current = true;
if (viewerConfig.x != null && viewerConfig.y != null) {
viewport.panTo(new Openseadragon.Point(viewerConfig.x, viewerConfig.y), true);
}
if (viewerConfig.zoom != null) {
viewport.zoomTo(viewerConfig.zoom, new Openseadragon.Point(viewerConfig.x, viewerConfig.y), true);
}
if (viewerConfig.rotation != null && viewerConfig.rotation !== viewport.getRotation()) {
viewport.setRotation(viewerConfig.rotation);
}
if (viewerConfig.flip != null && (viewerConfig.flip || false) !== viewport.getFlip()) {
viewport.setFlip(viewerConfig.flip);
}
if (!viewerConfig.x && !viewerConfig.y && !viewerConfig.zoom) {
if (viewerConfig.bounds) {
viewport.fitBounds(new Openseadragon.Rect(...viewerConfig.bounds), true);
lastAppliedBounds.current = viewerConfig.bounds;
} else {
viewport.goHome(true);
}
}
},
[initialViewportSet, viewerConfig],
);
useEffect(() => {
const viewer = viewerRef.current;
if (!viewer) return;
const { viewport } = viewer;
if (!initialViewportSet.current) {
setInitialBounds(viewer);
return;
}
// Check if bounds changed - always recenter when bounds change)
if (viewerConfig.bounds) {
const boundsChanged =
!lastAppliedBounds.current ||
viewerConfig.bounds.length !== lastAppliedBounds.current.length ||
viewerConfig.bounds.some((val, idx) => val !== lastAppliedBounds.current[idx]);
// Bounds changed - recenter regardless of whether x/y/zoom exist
if (boundsChanged) {
isResettingViewport.current = true;
lastAppliedBounds.current = viewerConfig.bounds;
// Wait for the tiles to be fully loaded before recentering
const handleTilesLoaded = () => {
const rect = new Openseadragon.Rect(...viewerConfig.bounds);
viewport.fitBoundsWithConstraints(rect, true);
isResettingViewport.current = false;
};
viewer.addOnceHandler('tile-loaded', handleTilesLoaded);
return;
}
}
// Apply preserved viewport only if bounds haven't changed
// Don't apply x/y/zoom if we don't have them (rely on bounds instead)
if (!viewerConfig.x || !viewerConfig.y || !viewerConfig.zoom) {
return;
}
// @ts-expect-error
if (
viewerConfig.x != null &&
viewerConfig.y != null &&
(Math.round(viewerConfig.x) !== Math.round(viewport.centerSpringX.target.value) ||
// @ts-expect-error
Math.round(viewerConfig.y) !== Math.round(viewport.centerSpringY.target.value))
) {
viewport.panTo(new Openseadragon.Point(viewerConfig.x, viewerConfig.y), false);
}
// @ts-expect-error
if (viewerConfig.zoom != null && viewerConfig.zoom !== viewport.zoomSpring.target.value) {
viewport.zoomTo(viewerConfig.zoom, new Openseadragon.Point(viewerConfig.x, viewerConfig.y), false);
}
if (viewerConfig.rotation != null && viewerConfig.rotation !== viewport.getRotation()) {
viewport.setRotation(viewerConfig.rotation);
}
if (viewerConfig.flip != null && (viewerConfig.flip || false) !== viewport.getFlip()) {
viewport.setFlip(viewerConfig.flip);
}
}, [initialViewportSet, setInitialBounds, viewerConfig, viewerRef]);
// initialize OSD stuff when this component is mounted
useEffect(() => {
const viewer = Openseadragon({
element: ref.current,
...osdConfig,
});
viewer.addHandler('canvas-drag', () => {
setGrabbing(true);
});
viewer.addHandler('canvas-drag-end', () => {
setGrabbing(false);
});
viewer.addHandler('canvas-double-click', ({ position, shift }) => {
if (!osdConfig.zoomPerDoubleClick) return;
const currentZoom = viewer.viewport.getZoom();
const zoomRatio = shift ? 1.0 / osdConfig.zoomPerDoubleClick : osdConfig.zoomPerDoubleClick;
viewer.viewport.zoomTo(currentZoom * zoomRatio, viewer.viewport.pointFromPixel(position), false);
});
viewer.addHandler('animation-finish', onViewportChange);
// @ts-expect-error
viewer.innerTracker.moveHandler = moveHandler;
viewerRef.current = viewer;
setViewer(viewer);
viewer.addOnceHandler('tile-loaded', () => {
initialViewportSet.current = false;
setInitialBounds(viewer);
});
forceUpdate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref]);
// cleanup OSD viewer cruft when this component is unmounted
useEffect(
() => () => {
const viewer = viewerRef.current;
if (!viewer) return;
// @ts-expect-error
if (viewer.innerTracker?.moveHandler === moveHandler) {
// @ts-expect-error
viewer.innerTracker.moveHandler = () => {};
}
// @ts-expect-error
viewer.removeAllHandlers();
viewer.destroy();
viewerRef.current = undefined;
setViewer(null);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const { t } = useTranslation();
useEffect(() => {
const canvas = viewerRef?.current?.canvas?.firstElementChild;
if (canvas) {
canvas.setAttribute('role', 'img');
canvas.setAttribute('aria-label', t('digitizedView'));
canvas.setAttribute('aria-describedby', id);
}
}, [viewerRef?.current?.canvas?.firstElementChild, id, t]);
return (
<OpenSeadragonViewerContext.Provider value={viewerRef}>
<Container id={id} ref={ref} style={{ ...style, cursor: grabbing ? 'grabbing' : 'grab' }} {...passThruProps}>
{children}
</Container>
</OpenSeadragonViewerContext.Provider>
);
}
OpenSeadragonComponent.propTypes = {
children: PropTypes.node,
Container: PropTypes.elementType,
onUpdateViewport: PropTypes.func,
osdConfig: PropTypes.object,
setViewer: PropTypes.func,
style: PropTypes.object,
viewerConfig: PropTypes.shape({
bounds: PropTypes.arrayOf(PropTypes.number),
flip: PropTypes.bool,
rotation: PropTypes.number,
x: PropTypes.number,
y: PropTypes.number,
zoom: PropTypes.number,
}),
};
export default OpenSeadragonComponent;