diff --git a/__tests__/src/components/OpenSeadragonViewer.test.js b/__tests__/src/components/OpenSeadragonViewer.test.js index 6368e2e050d1ea528cf051d5649d4c9359955943..8113ca8e9bfbda73b9526d85e757ff14710bd62f 100644 --- a/__tests__/src/components/OpenSeadragonViewer.test.js +++ b/__tests__/src/components/OpenSeadragonViewer.test.js @@ -2,15 +2,20 @@ import React from 'react'; import { shallow } from 'enzyme'; import OpenSeadragon from 'openseadragon'; import { OpenSeadragonViewer } from '../../../src/components/OpenSeadragonViewer'; +import OpenSeadragonCanvasOverlay from '../../../src/lib/OpenSeadragonCanvasOverlay'; +import Annotation from '../../../src/lib/Annotation'; import ZoomControls from '../../../src/containers/ZoomControls'; jest.mock('openseadragon'); +jest.mock('../../../src/lib/OpenSeadragonCanvasOverlay'); + describe('OpenSeadragonViewer', () => { let wrapper; let updateViewport; beforeEach(() => { OpenSeadragon.mockClear(); + OpenSeadragonCanvasOverlay.mockClear(); updateViewport = jest.fn(); @@ -126,6 +131,16 @@ describe('OpenSeadragonViewer', () => { 0.5, { x: 1, y: 0, zoom: 0.5 }, false, ); }); + + it('sets up a OpenSeadragonCanvasOverlay', () => { + wrapper.instance().componentDidMount(); + expect(OpenSeadragonCanvasOverlay).toHaveBeenCalledTimes(1); + }); + + it('sets up a listener on update-viewport', () => { + wrapper.instance().componentDidMount(); + expect(addHandler).toHaveBeenCalledWith('update-viewport', expect.anything()); + }); }); describe('componentDidUpdate', () => { @@ -153,6 +168,49 @@ describe('OpenSeadragonViewer', () => { 0.5, { x: 1, y: 0, zoom: 0.5 }, false, ); }); + + it('sets up canvasUpdate to add annotations to the canvas', () => { + const clear = jest.fn(); + const resize = jest.fn(); + const canvasUpdate = jest.fn(); + wrapper.instance().osdCanvasOverlay = { + clear, + resize, + canvasUpdate, + }; + + wrapper.setProps( + { + annotations: [ + new Annotation( + { '@id': 'foo', resources: [{ foo: 'bar' }] }, + ), + ], + }, + ); + wrapper.setProps( + { + annotations: [ + new Annotation( + { '@id': 'foo', resources: [{ foo: 'bar' }] }, + ), + ], + }, + ); + wrapper.setProps( + { + annotations: [ + new Annotation( + { '@id': 'bar', resources: [{ foo: 'bar' }] }, + ), + ], + }, + ); + wrapper.instance().updateCanvas(); + expect(clear).toHaveBeenCalledTimes(1); + expect(resize).toHaveBeenCalledTimes(1); + expect(canvasUpdate).toHaveBeenCalledTimes(1); + }); }); describe('onViewportChange', () => { @@ -173,4 +231,35 @@ describe('OpenSeadragonViewer', () => { ); }); }); + + describe('onUpdateViewport', () => { + it('fires updateCanvas', () => { + const updateCanvas = jest.fn(); + wrapper.instance().updateCanvas = updateCanvas; + wrapper.instance().onUpdateViewport(); + expect(updateCanvas).toHaveBeenCalledTimes(1); + }); + }); + + describe('annotationsToContext', () => { + it('converts the annotations to canvas', () => { + const strokeRect = jest.fn(); + wrapper.instance().osdCanvasOverlay = { + context2d: { + strokeRect, + }, + }; + + const annotations = [ + new Annotation( + { '@id': 'foo', resources: [{ on: 'www.example.com/#xywh=10,10,100,200' }] }, + ), + ]; + wrapper.instance().annotationsToContext(annotations); + const context = wrapper.instance().osdCanvasOverlay.context2d; + expect(context.strokeStyle).toEqual('yellow'); + expect(context.lineWidth).toEqual(10); + expect(strokeRect).toHaveBeenCalledWith(10, 10, 100, 200); + }); + }); }); diff --git a/__tests__/src/lib/OpenSeadragonCanvasOverlay.test.js b/__tests__/src/lib/OpenSeadragonCanvasOverlay.test.js new file mode 100644 index 0000000000000000000000000000000000000000..8663b652be436724cd7a52bb9191aa530e688a4e --- /dev/null +++ b/__tests__/src/lib/OpenSeadragonCanvasOverlay.test.js @@ -0,0 +1,122 @@ +import OpenSeadragon from 'openseadragon'; +import OpenSeadragonCanvasOverlay from '../../../src/lib/OpenSeadragonCanvasOverlay'; + +jest.mock('openseadragon'); + +describe('OpenSeadragonCanvasOverlay', () => { + let canvasOverlay; + beforeEach(() => { + document.body.innerHTML = '<div id="canvas"></div>'; + OpenSeadragon.mockClear(); + OpenSeadragon.mockImplementation(() => ({ + canvas: document.getElementById('canvas'), + container: { + clientHeight: 100, + clientWidth: 200, + }, + viewport: { + getBounds: jest.fn(() => ({ + x: 40, y: 80, width: 200, height: 300, + })), + getZoom: jest.fn(() => (0.75)), + }, + world: { + getItemAt: jest.fn(() => ({ + source: { + dimensions: { + x: 1000, + y: 2000, + }, + }, + viewportToImageZoom: jest.fn(() => (0.075)), + })), + }, + })); + canvasOverlay = new OpenSeadragonCanvasOverlay(new OpenSeadragon()); + }); + describe('constructor', () => { + it('sets up initial values and canvas', () => { + expect(canvasOverlay.containerHeight).toEqual(0); + expect(canvasOverlay.containerWidth).toEqual(0); + expect(canvasOverlay.canvasDiv.outerHTML).toEqual( + '<div style="position: absolute; left: 0px; top: 0px; width: 100%; height: 100%;"><canvas></canvas></div>', + ); + }); + }); + describe('context2d', () => { + it('calls getContext on canvas', () => { + const contextMock = jest.fn(); + canvasOverlay.canvas = { + getContext: contextMock, + }; + canvasOverlay.context2d; // eslint-disable-line no-unused-expressions + expect(contextMock).toHaveBeenCalledTimes(1); + }); + }); + describe('clear', () => { + it('calls getContext and clearRect on canvas', () => { + const clearRect = jest.fn(); + const contextMock = jest.fn(() => ({ + clearRect, + })); + canvasOverlay.canvas = { + getContext: contextMock, + }; + canvasOverlay.clear(); + expect(contextMock).toHaveBeenCalledTimes(1); + expect(clearRect).toHaveBeenCalledTimes(1); + }); + }); + describe('resize', () => { + it('sets various values based off of image and container sizes', () => { + canvasOverlay.resize(); + expect(canvasOverlay.containerHeight).toEqual(100); + expect(canvasOverlay.containerWidth).toEqual(200); + expect(canvasOverlay.imgAspectRatio).toEqual(0.5); + }); + it('when image is undefined returns early', () => { + OpenSeadragon.mockClear(); + OpenSeadragon.mockImplementation(() => ({ + canvas: document.getElementById('canvas'), + container: { + clientHeight: 100, + clientWidth: 200, + }, + viewport: { + getBounds: jest.fn(() => (new OpenSeadragon.Rect(0, 0, 200, 200))), + }, + world: { + getItemAt: jest.fn(), + }, + })); + canvasOverlay = new OpenSeadragonCanvasOverlay(new OpenSeadragon()); + canvasOverlay.resize(); + expect(canvasOverlay.imgHeight).toEqual(undefined); + expect(canvasOverlay.imgWidth).toEqual(undefined); + }); + }); + describe('canvasUpdate', () => { + it('sets appropriate sizes and calls update argument', () => { + const scale = jest.fn(); + const setAttribute = jest.fn(); + const setTransform = jest.fn(); + const translate = jest.fn(); + const contextMock = jest.fn(() => ({ + scale, + setTransform, + translate, + })); + canvasOverlay.canvas = { + getContext: contextMock, + setAttribute, + }; + const update = jest.fn(); + canvasOverlay.resize(); + canvasOverlay.canvasUpdate(update); + expect(update).toHaveBeenCalledTimes(1); + expect(scale).toHaveBeenCalledWith(0.075, 0.075); + expect(translate).toHaveBeenCalledWith(-39.96, -26.65333333333333); + expect(setTransform).toHaveBeenCalledWith(1, 0, 0, 1, 0, 0); + }); + }); +}); diff --git a/src/components/OpenSeadragonViewer.js b/src/components/OpenSeadragonViewer.js index 6428c77e0f492988c24d5741f01eefdd8c6ae9a1..1a265470570e3504242f542c8256d30c32c7c710 100644 --- a/src/components/OpenSeadragonViewer.js +++ b/src/components/OpenSeadragonViewer.js @@ -4,6 +4,7 @@ import OpenSeadragon from 'openseadragon'; import debounce from 'lodash/debounce'; import ns from '../config/css-ns'; import ZoomControls from '../containers/ZoomControls'; +import OpenSeadragonCanvasOverlay from '../lib/OpenSeadragonCanvasOverlay'; /** * Represents a OpenSeadragonViewer in the mirador workspace. Responsible for mounting @@ -17,7 +18,11 @@ export class OpenSeadragonViewer extends Component { super(props); this.viewer = null; + this.osdCanvasOverlay = null; + // An initial value for the updateCanvas method + this.updateCanvas = () => {}; this.ref = React.createRef(); + this.onUpdateViewport = this.onUpdateViewport.bind(this); this.onViewportChange = this.onViewportChange.bind(this); } @@ -37,6 +42,9 @@ export class OpenSeadragonViewer extends Component { showNavigationControl: false, preserveImageSizeOnResize: true, }); + + this.osdCanvasOverlay = new OpenSeadragonCanvasOverlay(this.viewer); + this.viewer.addHandler('update-viewport', this.onUpdateViewport); this.viewer.addHandler('viewport-change', debounce(this.onViewportChange, 300)); if (viewer) { @@ -49,10 +57,21 @@ export class OpenSeadragonViewer extends Component { /** * When the tileSources change, make sure to close the OSD viewer. + * When the annotations change, reset the updateCanvas method to make sure + * they are added. * When the viewport state changes, pan or zoom the OSD viewer as appropriate */ componentDidUpdate(prevProps) { - const { tileSources, viewer } = this.props; + const { tileSources, viewer, annotations } = this.props; + if (!this.annotationsMatch(prevProps.annotations)) { + this.updateCanvas = () => { + this.osdCanvasOverlay.clear(); + this.osdCanvasOverlay.resize(); + this.osdCanvasOverlay.canvasUpdate(() => { + this.annotationsToContext(annotations); + }); + }; + } if (!this.tileSourcesMatch(prevProps.tileSources)) { this.viewer.close(); Promise.all( @@ -82,6 +101,13 @@ export class OpenSeadragonViewer extends Component { this.viewer.removeAllHandlers(); } + /** + * onUpdateViewport - fires during OpenSeadragon render method. + */ + onUpdateViewport(event) { + this.updateCanvas(); + } + /** * Forward OSD state to redux */ @@ -97,6 +123,20 @@ export class OpenSeadragonViewer extends Component { }); } + /** + * annotationsToContext - converts anontations to a canvas context + */ + annotationsToContext(annotations) { + const context = this.osdCanvasOverlay.context2d; + annotations.forEach((annotation) => { + annotation.resources.forEach((resource) => { + context.strokeStyle = 'yellow'; + context.lineWidth = 10; + context.strokeRect(...resource.fragmentSelector); + }); + }); + } + /** * boundsFromTileSources - calculates the overall width/height * based on 0 -> n tileSources @@ -196,6 +236,25 @@ export class OpenSeadragonViewer extends Component { }); } + /** + * annotationsMatch - compares previous annotations to current to determine + * whether to add a new updateCanvas method to draw annotations + * @param {Array} prevAnnotations + * @return {Boolean} + */ + annotationsMatch(prevAnnotations) { + const { annotations } = this.props; + return annotations.some((annotation, index) => { + if (!prevAnnotations[index]) { + return false; + } + if (annotation.id === prevAnnotations[index].id) { + return true; + } + return false; + }); + } + /** * Renders things */ @@ -221,6 +280,7 @@ export class OpenSeadragonViewer extends Component { } OpenSeadragonViewer.defaultProps = { + annotations: [], children: null, tileSources: [], viewer: null, @@ -228,6 +288,7 @@ OpenSeadragonViewer.defaultProps = { }; OpenSeadragonViewer.propTypes = { + annotations: PropTypes.arrayOf(PropTypes.object), children: PropTypes.element, tileSources: PropTypes.arrayOf(PropTypes.object), viewer: PropTypes.object, // eslint-disable-line react/forbid-prop-types diff --git a/src/components/WindowViewer.js b/src/components/WindowViewer.js index 9f103823ffff31dfbae01d24b8a8d01267eb0d4b..78b5354560789a897ae934acd703cdb380deb9db 100644 --- a/src/components/WindowViewer.js +++ b/src/components/WindowViewer.js @@ -126,6 +126,7 @@ export class WindowViewer extends Component { <> <OSDViewer tileSources={this.tileInfoFetchedFromStore()} + currentCanvases={this.currentCanvases()} windowId={window.id} > <ViewerNavigation window={window} canvases={this.canvases} /> diff --git a/src/containers/OpenSeadragonViewer.js b/src/containers/OpenSeadragonViewer.js index eb0e1422d496870e33c228ab101a5bd0ffdc238e..7099f30dc6460276366607398747635cf8499a66 100644 --- a/src/containers/OpenSeadragonViewer.js +++ b/src/containers/OpenSeadragonViewer.js @@ -7,6 +7,7 @@ import * as actions from '../state/actions'; import { getCanvasLabel, getSelectedCanvas, + getSelectedCanvasAnnotations, } from '../state/selectors'; /** @@ -14,12 +15,18 @@ import { * @memberof Window * @private */ -const mapStateToProps = ({ viewers, windows, manifests }, { windowId }) => ({ +const mapStateToProps = ({ + viewers, windows, manifests, annotations, +}, { windowId, currentCanvases }) => ({ viewer: viewers[windowId], label: getCanvasLabel( getSelectedCanvas({ windows, manifests }, windowId), windows[windowId].canvasIndex, ), + annotations: getSelectedCanvasAnnotations( + { annotations }, + currentCanvases.map(canvas => canvas.id), + ), }); /** diff --git a/src/lib/OpenSeadragonCanvasOverlay.js b/src/lib/OpenSeadragonCanvasOverlay.js new file mode 100644 index 0000000000000000000000000000000000000000..e306f4579b84557503324fb6d9c33676ca56c5b1 --- /dev/null +++ b/src/lib/OpenSeadragonCanvasOverlay.js @@ -0,0 +1,100 @@ +import OpenSeadragon from 'openseadragon'; + +/** + * OpenSeadragonCanvasOverlay - adapted from https://github.com/altert/OpenSeadragonCanvasOverlay + * used rather than an "onRedraw" function we tap into our own method. Existing + * repository is not published as an npm package. + * Code ported from https://github.com/altert/OpenSeadragonCanvasOverlay + * carries a BSD 3-Clause license originally authored by @altert from + * https://github.com/altert/OpenseadragonFabricjsOverlay + */ +export default class OpenSeadragonCanvasOverlay { + /** + * constructor - sets up the Canvas overlay container + */ + constructor(viewer) { + this.viewer = viewer; + + this.containerWidth = 0; + this.containerHeight = 0; + + this.canvasDiv = document.createElement('div'); + this.canvasDiv.style.position = 'absolute'; + this.canvasDiv.style.left = 0; + this.canvasDiv.style.top = 0; + this.canvasDiv.style.width = '100%'; + this.canvasDiv.style.height = '100%'; + this.viewer.canvas.appendChild(this.canvasDiv); + + this.canvas = document.createElement('canvas'); + this.canvasDiv.appendChild(this.canvas); + this.imgAspectRatio = 1; + } + + /** */ + get context2d() { + return this.canvas.getContext('2d'); + } + + /** */ + clear() { + this.canvas.getContext('2d').clearRect(0, 0, this.containerWidth, this.containerHeight); + } + + /** + * resize - resizes the added Canvas overlay. + */ + resize() { + if (this.containerWidth !== this.viewer.container.clientWidth) { + this.containerWidth = this.viewer.container.clientWidth; + this.canvasDiv.setAttribute('width', this.containerWidth); + this.canvas.setAttribute('width', this.containerWidth); + } + + if (this.containerHeight !== this.viewer.container.clientHeight) { + this.containerHeight = this.viewer.container.clientHeight; + this.canvasDiv.setAttribute('height', this.containerHeight); + this.canvas.setAttribute('height', this.containerHeight); + } + + this.viewportOrigin = new OpenSeadragon.Point(0, 0); + const boundsRect = this.viewer.viewport.getBounds(true); + this.viewportOrigin.x = boundsRect.x; + this.viewportOrigin.y = boundsRect.y * this.imgAspectRatio; + + this.viewportWidth = boundsRect.width; + this.viewportHeight = boundsRect.height * this.imgAspectRatio; + const image1 = this.viewer.world.getItemAt(0); + if (!image1) return; + this.imgWidth = image1.source.dimensions.x; + this.imgHeight = image1.source.dimensions.y; + this.imgAspectRatio = this.imgWidth / this.imgHeight; + } + + /** + * canvasUpdate - sets up the dimensions for the canvas update to mimick image + * 0 dimensions. Then call provided update function. + * @param {Function} update + */ + canvasUpdate(update) { + const viewportZoom = this.viewer.viewport.getZoom(true); + const image1 = this.viewer.world.getItemAt(0); + if (!image1) return; + const zoom = image1.viewportToImageZoom(viewportZoom); + + const x = ( + (this.viewportOrigin.x / this.imgWidth - this.viewportOrigin.x) / this.viewportWidth + ) * this.containerWidth; + const y = ( + (this.viewportOrigin.y / this.imgHeight - this.viewportOrigin.y) / this.viewportHeight + ) * this.containerHeight; + + if (this.clearBeforeRedraw) this.clear(); + this.canvas.getContext('2d').translate(x, y); + this.canvas.getContext('2d').scale(zoom, zoom); + + update(); + + this.canvas.getContext('2d').setTransform(1, 0, 0, 1, 0, 0); + } +}