diff --git a/__tests__/src/actions/canvas.test.js b/__tests__/src/actions/canvas.test.js index b00c021700cd456cfebfb60acf9538216756cc90..481ff53f79f8a82a338a491f895d8155181cdcd7 100644 --- a/__tests__/src/actions/canvas.test.js +++ b/__tests__/src/actions/canvas.test.js @@ -33,4 +33,19 @@ describe('canvas actions', () => { expect(actions.setCanvas(id, 100)).toEqual(expectedAction); }); }); + describe('updateViewport', () => { + it('sets viewer state', () => { + const id = 'abc123'; + const expectedAction = { + type: ActionTypes.UPDATE_VIEWPORT, + windowId: id, + payload: { + x: 1, + y: 0, + zoom: 0.5, + }, + }; + expect(actions.updateViewport(id, { x: 1, y: 0, zoom: 0.5 })).toEqual(expectedAction); + }); + }); }); diff --git a/__tests__/src/components/OpenSeadragonViewer.test.js b/__tests__/src/components/OpenSeadragonViewer.test.js index 72d2c8fa0bd87669e2f7d52943b628f9b07a287e..2e8c8347c2fbaf4af8d316d5e0c74eb066ee8b7e 100644 --- a/__tests__/src/components/OpenSeadragonViewer.test.js +++ b/__tests__/src/components/OpenSeadragonViewer.test.js @@ -1,15 +1,24 @@ import React from 'react'; import { shallow } from 'enzyme'; +import OpenSeadragon from 'openseadragon'; import OpenSeadragonViewer from '../../../src/components/OpenSeadragonViewer'; +jest.mock('openseadragon'); + describe('OpenSeadragonViewer', () => { let wrapper; + let updateViewport; beforeEach(() => { + OpenSeadragon.mockClear(); + + updateViewport = jest.fn(); + wrapper = shallow( <OpenSeadragonViewer tileSources={[{ '@id': 'http://foo' }]} window={{ id: 'base' }} config={{}} + updateViewport={updateViewport} > <div className="foo" /> </OpenSeadragonViewer>, @@ -31,9 +40,6 @@ describe('OpenSeadragonViewer', () => { }); describe('addTileSource', () => { it('calls addTiledImage asynchronously on the OSD viewer', async () => { - wrapper.instance().viewer = { - addTiledImage: jest.fn().mockResolvedValue('event'), - }; wrapper.instance().addTileSource({}).then((event) => { expect(event).toBe('event'); }); @@ -59,4 +65,93 @@ describe('OpenSeadragonViewer', () => { ).toHaveBeenCalled(); }); }); + + describe('componentDidMount', () => { + let panTo; + let zoomTo; + let addHandler; + beforeEach(() => { + panTo = jest.fn(); + zoomTo = jest.fn(); + addHandler = jest.fn(); + + wrapper = shallow( + <OpenSeadragonViewer + tileSources={[{ '@id': 'http://foo' }]} + window={{ id: 'base', viewer: { x: 1, y: 0, zoom: 0.5 } }} + config={{}} + updateViewport={updateViewport} + > + <div className="foo" /> + </OpenSeadragonViewer>, + ); + + wrapper.instance().ref = { current: true }; + + OpenSeadragon.mockImplementation(() => ({ + viewport: { panTo, zoomTo }, + addHandler, + addTiledImage: jest.fn().mockResolvedValue('event'), + })); + }); + + it('calls the OSD viewport panTo and zoomTo with the component state', () => { + wrapper.instance().componentDidMount(); + + expect(addHandler).toHaveBeenCalledWith('viewport-change', expect.anything()); + + expect(panTo).toHaveBeenCalledWith( + { x: 1, y: 0, zoom: 0.5 }, false, + ); + expect(zoomTo).toHaveBeenCalledWith( + 0.5, { x: 1, y: 0, zoom: 0.5 }, false, + ); + }); + }); + + describe('componentDidUpdate', () => { + it('calls the OSD viewport panTo and zoomTo with the component state', () => { + const panTo = jest.fn(); + const zoomTo = jest.fn(); + + wrapper.instance().viewer = { + viewport: { + centerSpringX: { target: { value: 10 } }, + centerSpringY: { target: { value: 10 } }, + zoomSpring: { target: { value: 1 } }, + panTo, + zoomTo, + }, + }; + + wrapper.setProps({ window: { id: 'base', viewer: { x: 0.5, y: 0.5, zoom: 0.1 } } }); + wrapper.setProps({ window: { id: 'base', viewer: { x: 1, y: 0, zoom: 0.5 } } }); + + expect(panTo).toHaveBeenCalledWith( + { x: 1, y: 0, zoom: 0.5 }, false, + ); + expect(zoomTo).toHaveBeenCalledWith( + 0.5, { x: 1, y: 0, zoom: 0.5 }, false, + ); + }); + }); + + describe('onViewportChange', () => { + it('translates the OSD viewport data into an update to the component state', () => { + wrapper.instance().onViewportChange({ + eventSource: { + viewport: { + centerSpringX: { target: { value: 1 } }, + centerSpringY: { target: { value: 0 } }, + zoomSpring: { target: { value: 0.5 } }, + }, + }, + }); + + expect(updateViewport).toHaveBeenCalledWith( + 'base', + { x: 1, y: 0, zoom: 0.5 }, + ); + }); + }); }); diff --git a/__tests__/src/reducers/windows.test.js b/__tests__/src/reducers/windows.test.js index f7c09854bbb636c7aabe9650c6d75ed5609e9fc4..a8a75b6718dae4a218c7b2b06968a4e5bb541461 100644 --- a/__tests__/src/reducers/windows.test.js +++ b/__tests__/src/reducers/windows.test.js @@ -176,4 +176,27 @@ describe('windows reducer', () => { }, }); }); + + it('should handle UPDATE_VIEWPORT', () => { + expect(reducer({ + abc123: { + id: 'abc123', + }, + def456: { + id: 'def456', + }, + }, { + type: ActionTypes.UPDATE_VIEWPORT, + windowId: 'abc123', + payload: { x: 0, y: 1, zoom: 0.5 }, + })).toEqual({ + abc123: { + id: 'abc123', + viewer: { x: 0, y: 1, zoom: 0.5 }, + }, + def456: { + id: 'def456', + }, + }); + }); }); diff --git a/src/components/OpenSeadragonViewer.js b/src/components/OpenSeadragonViewer.js index 316fcaae48afa9cb08ee49762e812d857d23f008..e28b87182dd4a77c31d216921901f3541356ffd8 100644 --- a/src/components/OpenSeadragonViewer.js +++ b/src/components/OpenSeadragonViewer.js @@ -16,13 +16,14 @@ class OpenSeadragonViewer extends Component { this.viewer = null; this.ref = React.createRef(); + this.onViewportChange = this.onViewportChange.bind(this); } /** * React lifecycle event */ componentDidMount() { - const { tileSources } = this.props; + const { tileSources, window } = this.props; if (!this.ref.current) { return; } @@ -34,14 +35,22 @@ class OpenSeadragonViewer extends Component { showNavigationControl: false, preserveImageSizeOnResize: true, }); + this.viewer.addHandler('viewport-change', this.onViewportChange); + + if (window.viewer) { + this.viewer.viewport.panTo(window.viewer, false); + this.viewer.viewport.zoomTo(window.viewer.zoom, window.viewer, false); + } + tileSources.forEach(tileSource => this.addTileSource(tileSource)); } /** * When the tileSources change, make sure to close the OSD viewer. + * When the viewport state changes, pan or zoom the OSD viewer as appropriate */ componentDidUpdate(prevProps) { - const { tileSources } = this.props; + const { tileSources, window } = this.props; if (!this.tileSourcesMatch(prevProps.tileSources)) { this.viewer.close(); Promise.all( @@ -53,6 +62,17 @@ class OpenSeadragonViewer extends Component { this.fitBounds(0, 0, tileSources[0].width, tileSources[0].height); } }); + } else if (window.viewer) { + const { viewport } = this.viewer; + + if (window.viewer.x !== viewport.centerSpringX.target.value + || window.viewer.y !== viewport.centerSpringY.target.value) { + this.viewer.viewport.panTo(window.viewer, false); + } + + if (window.viewer.zoom !== viewport.zoomSpring.target.value) { + this.viewer.viewport.zoomTo(window.viewer.zoom, window.viewer, false); + } } } @@ -62,6 +82,21 @@ class OpenSeadragonViewer extends Component { this.viewer.removeAllHandlers(); } + /** + * Forward OSD state to redux + */ + onViewportChange(event) { + const { updateViewport, window } = this.props; + + const { viewport } = event.eventSource; + + updateViewport(window.id, { + x: viewport.centerSpringX.target.value, + y: viewport.centerSpringY.target.value, + zoom: viewport.zoomSpring.target.value, + }); + } + /** */ addTileSource(tileSource) { @@ -133,6 +168,7 @@ OpenSeadragonViewer.propTypes = { children: PropTypes.element, tileSources: PropTypes.arrayOf(PropTypes.object), window: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + updateViewport: PropTypes.func.isRequired, }; export default OpenSeadragonViewer; diff --git a/src/containers/OpenSeadragonViewer.js b/src/containers/OpenSeadragonViewer.js index eecd30d771a508f52f08d4b91e7ac95caed5621a..3584aa3194c43e78148882892e1ca99f9a069255 100644 --- a/src/containers/OpenSeadragonViewer.js +++ b/src/containers/OpenSeadragonViewer.js @@ -1,4 +1,23 @@ +import { compose } from 'redux'; +import { connect } from 'react-redux'; import miradorWithPlugins from '../lib/miradorWithPlugins'; import OpenSeadragonViewer from '../components/OpenSeadragonViewer'; +import * as actions from '../state/actions'; -export default miradorWithPlugins(OpenSeadragonViewer); +/** + * mapDispatchToProps - used to hook up connect to action creators + * @memberof ManifestListItem + * @private + */ +const mapDispatchToProps = { + updateViewport: actions.updateViewport, +}; + +const enhance = compose( + connect(null, mapDispatchToProps), + miradorWithPlugins, + // further HOC go here +); + + +export default enhance(OpenSeadragonViewer); diff --git a/src/containers/Workspace.js b/src/containers/Workspace.js index 0bac3285eb2f9364e1f08423dd0dcad13d6013a9..a86a81739d1cb8099d0ce6e70ced1b4df2e9e302 100644 --- a/src/containers/Workspace.js +++ b/src/containers/Workspace.js @@ -1,6 +1,5 @@ import { compose } from 'redux'; import { connect } from 'react-redux'; -import * as actions from '../state/actions'; import Workspace from '../components/Workspace'; /** diff --git a/src/state/actions/action-types.js b/src/state/actions/action-types.js index d21f58b39f2ea2ba9f63915cbde1c11e636abeb3..50869227446a4af8ac34f903b91d48e2e710df3b 100644 --- a/src/state/actions/action-types.js +++ b/src/state/actions/action-types.js @@ -22,6 +22,7 @@ const ActionTypes = { RECEIVE_INFO_RESPONSE_FAILURE: 'RECEIVE_INFO_RESPONSE_FAILURE', REMOVE_INFO_RESPONSE: 'REMOVE_INFO_RESPONSE', UPDATE_WORKSPACE_MOSAIC_LAYOUT: 'UPDATE_WORKSPACE_MOSAIC_LAYOUT', + UPDATE_VIEWPORT: 'UPDATE_VIEWPORT', }; export default ActionTypes; diff --git a/src/state/actions/canvas.js b/src/state/actions/canvas.js index 9e094aa4d3573f5b9f39c87d9f9d47cbd904915c..c1a5f1ad9b26343317e840e7720eb85a6ae3371c 100644 --- a/src/state/actions/canvas.js +++ b/src/state/actions/canvas.js @@ -30,3 +30,14 @@ export function previousCanvas(windowId) { export function setCanvas(windowId, canvasIndex) { return { type: ActionTypes.SET_CANVAS, windowId, canvasIndex }; } + +/** + * updateViewport - action creator + * + * @param {String} windowId + * @param {Number} canvasIndex + * @memberof ActionCreators + */ +export function updateViewport(windowId, payload) { + return { type: ActionTypes.UPDATE_VIEWPORT, windowId, payload }; +} diff --git a/src/state/reducers/windows.js b/src/state/reducers/windows.js index 26c18f70220ba79a9a7d974e17e52434e7176679..108f77d2209d00d2586d673316b6ee3c64db300d 100644 --- a/src/state/reducers/windows.js +++ b/src/state/reducers/windows.js @@ -49,6 +49,14 @@ const windowsReducer = (state = {}, action) => { return setCanvasIndex(state, action.windowId, currentIndex => currentIndex - 1); case ActionTypes.SET_CANVAS: return setCanvasIndex(state, action.windowId, currentIndex => action.canvasIndex); + case ActionTypes.UPDATE_VIEWPORT: + return { + ...state, + [action.windowId]: { + ...state[action.windowId], + viewer: action.payload, + }, + }; default: return state; }