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..78114e3495c20f90369e08084a17184f0f0163f5 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,87 @@ 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: { 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 ceb3e1936cfbf3b31642ebf4b1d53967f97c9efe..7dae639f12505d884f6a6a7dcd0da1b2b2d49c3c 100644 --- a/__tests__/src/reducers/windows.test.js +++ b/__tests__/src/reducers/windows.test.js @@ -138,4 +138,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 1e95e3083ef0f8ee1961bdc53cb4ef23e2289bbc..f3db606ef55ef3024f7c15c90d603c3d56c24d10 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; } @@ -33,14 +34,22 @@ class OpenSeadragonViewer extends Component { alwaysBlend: false, showNavigationControl: false, }); + 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( @@ -52,6 +61,15 @@ class OpenSeadragonViewer extends Component { this.fitBounds(0, 0, tileSources[0].width, tileSources[0].height); } }); + } else if (window.viewer && prevProps.window.viewer) { + if (window.viewer.x !== prevProps.window.viewer.x + || window.viewer.y !== prevProps.window.viewer.y) { + this.viewer.viewport.panTo(window.viewer, false); + } + + if (window.viewer.zoom !== prevProps.window.viewer.zoom) { + this.viewer.viewport.zoomTo(window.viewer.zoom, window.viewer, false); + } } } @@ -61,6 +79,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) { @@ -132,6 +165,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 302d40515fb883623162514d066515d585fe7a89..70c0f4be18f48feafbf781d2fae3c9e1ce3dff66 100644 --- a/src/state/actions/action-types.js +++ b/src/state/actions/action-types.js @@ -21,6 +21,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 8c851f8249148b26e4906cdc5ecd06c86d2a0a99..2c253881723e919c9b92aa16b85a3f760288d3fb 100644 --- a/src/state/reducers/windows.js +++ b/src/state/reducers/windows.js @@ -36,6 +36,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; }