diff --git a/__tests__/src/actions/workspace.test.js b/__tests__/src/actions/workspace.test.js index e9de722b9d23d88d97ec051510ea6abc0f83fc8f..5dcc6d6819925c1a3e06e81933b43d9c664cf2c8 100644 --- a/__tests__/src/actions/workspace.test.js +++ b/__tests__/src/actions/workspace.test.js @@ -31,4 +31,13 @@ describe('workspace actions', () => { expect(actions.updateWorkspaceMosaicLayout(options)).toEqual(expectedAction); }); }); + describe('toggleZoomControls', () => { + it('should set the zoom control visibility', () => { + const expectedAction = { + type: ActionTypes.TOGGLE_ZOOM_CONTROLS, + showZoomControls: true, + }; + expect(actions.toggleZoomControls(true)).toEqual(expectedAction); + }); + }); }); diff --git a/__tests__/src/components/WorkspaceMenu.test.js b/__tests__/src/components/WorkspaceMenu.test.js index 27c11729c9568368cfb7881ae3c51cbfebe2745c..c7b76b66149c0670d51043329cf8adcab09e7af7 100644 --- a/__tests__/src/components/WorkspaceMenu.test.js +++ b/__tests__/src/components/WorkspaceMenu.test.js @@ -6,9 +6,19 @@ import WindowList from '../../../src/containers/WindowList'; describe('WorkspaceMenu', () => { let wrapper; let handleClose; + const showZoomControls = false; + let toggleZoomControls; + beforeEach(() => { handleClose = jest.fn(); - wrapper = shallow(<WorkspaceMenu handleClose={handleClose} />); + toggleZoomControls = jest.fn(); + wrapper = shallow( + <WorkspaceMenu + handleClose={handleClose} + showZoomControls={showZoomControls} + toggleZoomControls={toggleZoomControls} + />, + ); }); it('renders without an error', () => { @@ -34,4 +44,11 @@ describe('WorkspaceMenu', () => { expect(wrapper.find(WindowList).props().open).toBe(false); }); }); + + describe('handleZoomToggleClick', () => { + it('resets the anchor state', () => { + wrapper.instance().handleZoomToggleClick(); + expect(toggleZoomControls).toBeCalledWith(true); + }); + }); }); diff --git a/__tests__/src/components/ZoomControls.test.js b/__tests__/src/components/ZoomControls.test.js new file mode 100644 index 0000000000000000000000000000000000000000..ad3cc991019370031cdc0b9cc418b56f3d126631 --- /dev/null +++ b/__tests__/src/components/ZoomControls.test.js @@ -0,0 +1,75 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import ZoomControls from '../../../src/components/ZoomControls'; + +describe('ZoomControls', () => { + let wrapper; + const viewer = { x: 100, y: 100, zoom: 1 }; + const showZoomControls = false; + let updateViewport; + + beforeEach(() => { + updateViewport = jest.fn(); + wrapper = shallow( + <ZoomControls + windowId="xyz" + viewer={viewer} + showZoomControls={showZoomControls} + updateViewport={updateViewport} + />, + ).dive(); + }); + + describe('with showZoomControls=false', () => { + it('renders nothing unless asked', () => { + expect(wrapper.find('WithStyles(List)').length).toBe(0); + }); + }); + + + describe('with showZoomControls=true', () => { + beforeEach(() => { + updateViewport = jest.fn(); + wrapper = shallow( + <ZoomControls + windowId="xyz" + viewer={viewer} + showZoomControls + updateViewport={updateViewport} + />, + ).dive(); + }); + + it('renders a couple buttons', () => { + expect(wrapper.find('WithStyles(List)').length).toBe(1); + }); + + it('has a zoom-in button', () => { + const button = wrapper.find('WithStyles(IconButton)[aria-label="zoomIn"]'); + expect(button.simulate('click')); + expect(updateViewport).toHaveBeenCalledTimes(1); + expect(updateViewport).toHaveBeenCalledWith('xyz', { x: 100, y: 100, zoom: 2 }); + }); + + it('has a zoom-out button', () => { + const button = wrapper.find('WithStyles(IconButton)[aria-label="zoomOut"]'); + expect(button.simulate('click')); + expect(updateViewport).toHaveBeenCalledTimes(1); + expect(updateViewport).toHaveBeenCalledWith('xyz', { x: 100, y: 100, zoom: 0.5 }); + }); + + it('has a zoom reseet button', () => { + const button = wrapper.find('WithStyles(IconButton)[aria-label="zoomReset"]'); + expect(button.simulate('click')); + expect(updateViewport).toHaveBeenCalledTimes(1); + expect(updateViewport).toHaveBeenCalledWith('xyz', { x: 100, y: 100, zoom: 1 }); + }); + }); + + describe('handleZoomInClick', () => { + it('increases the zoom value on Zoom-In', () => { + wrapper.instance().handleZoomInClick(); + expect(updateViewport).toHaveBeenCalled(); + }); + }); +}); diff --git a/__tests__/src/reducers/workspace.test.js b/__tests__/src/reducers/workspace.test.js index dd5ebddcb99bc540ffd0ae87f4d99a5dbd8568ab..8b5732259ad8837af4d16d991d40308af4fcfbc7 100644 --- a/__tests__/src/reducers/workspace.test.js +++ b/__tests__/src/reducers/workspace.test.js @@ -18,6 +18,14 @@ describe('workspace reducer', () => { isFullscreenEnabled: true, }); }); + it('should handle TOGGLE_ZOOM_CONTROLS', () => { + expect(reducer([], { + type: ActionTypes.TOGGLE_ZOOM_CONTROLS, + showZoomControls: true, + })).toEqual({ + showZoomControls: true, + }); + }); it('should handle UPDATE_WORKSPACE_MOSAIC_LAYOUT', () => { expect(reducer([], { type: ActionTypes.UPDATE_WORKSPACE_MOSAIC_LAYOUT, diff --git a/locales/en/translation.json b/locales/en/translation.json index 3ca92e21328345b5afbeff4ef4d8c37ddc80a007..a5e657c224f8a77e7a64b9fef968aeb3af4ba36d 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -23,6 +23,9 @@ "theme": "Theme", "thumbnails": "Thumbnails", "toggleWindowSideBar": "Toggle window sidebar", - "untitled": "[Untitled]" + "untitled": "[Untitled]", + "zoomIn": "Zoom in", + "zoomOut": "Zoom out", + "zoomReset": "Reset zoom" } } diff --git a/src/components/OpenSeadragonViewer.js b/src/components/OpenSeadragonViewer.js index e28b87182dd4a77c31d216921901f3541356ffd8..6477a27df66e52888ef5a52aea569c98c5933a29 100644 --- a/src/components/OpenSeadragonViewer.js +++ b/src/components/OpenSeadragonViewer.js @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import OpenSeadragon from 'openseadragon'; import ns from '../config/css-ns'; +import ZoomControls from '../containers/ZoomControls'; /** * Represents a OpenSeadragonViewer in the mirador workspace. Responsible for mounting @@ -154,6 +155,7 @@ class OpenSeadragonViewer extends Component { > { children } </div> + <ZoomControls windowId={window.id} /> </> ); } diff --git a/src/components/WorkspaceMenu.js b/src/components/WorkspaceMenu.js index 9f37eff027313e9c5279c2fa5ea040a1cf5fc2d9..d7a0020463503a0710788f8e25571b9231bcf90e 100644 --- a/src/components/WorkspaceMenu.js +++ b/src/components/WorkspaceMenu.js @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import Menu from '@material-ui/core/Menu'; import Divider from '@material-ui/core/Divider'; import ListItemIcon from '@material-ui/core/ListItemIcon'; +import LoupeIcon from '@material-ui/icons/Loupe'; import MenuItem from '@material-ui/core/MenuItem'; import Typography from '@material-ui/core/Typography'; import SaveAltIcon from '@material-ui/icons/SaveAlt'; @@ -22,6 +23,7 @@ class WorkspaceMenu extends Component { super(props); this.state = { windowList: {}, + toggleZoom: {}, settings: {}, exportWorkspace: {}, }; @@ -53,13 +55,29 @@ class WorkspaceMenu extends Component { }; } + /** + * @private + */ + handleZoomToggleClick() { + const { toggleZoomControls, showZoomControls } = this.props; + toggleZoomControls(!showZoomControls); + } + /** * render * @return */ render() { - const { handleClose, anchorEl, t } = this.props; - const { windowList, settings, exportWorkspace } = this.state; + const { + handleClose, anchorEl, t, showZoomControls, + } = this.props; + + const { + windowList, + toggleZoom, + settings, + exportWorkspace, + } = this.state; return ( <> @@ -74,6 +92,16 @@ class WorkspaceMenu extends Component { </ListItemIcon> <Typography varient="inherit">{t('listAllOpenWindows')}</Typography> </MenuItem> + <MenuItem + aria-haspopup="true" + onClick={(e) => { this.handleZoomToggleClick(e); handleClose(e); }} + aria-owns={toggleZoom.anchorEl ? 'toggle-zoom-menu' : undefined} + > + <ListItemIcon> + <LoupeIcon /> + </ListItemIcon> + <Typography varient="inherit">{ showZoomControls ? 'Hide zoom controls' : 'Show Zoom Controls' }</Typography> + </MenuItem> <Divider /> <MenuItem aria-haspopup="true" @@ -101,6 +129,10 @@ class WorkspaceMenu extends Component { open={Boolean(windowList.anchorEl)} handleClose={this.handleMenuItemClose('windowList')} /> + <WorkspaceSettings + open={Boolean(toggleZoom.open)} + handleClose={this.handleMenuItemClose('toggleZoom')} + /> <WorkspaceSettings open={Boolean(settings.open)} handleClose={this.handleMenuItemClose('settings')} @@ -116,6 +148,8 @@ class WorkspaceMenu extends Component { WorkspaceMenu.propTypes = { handleClose: PropTypes.func.isRequired, + toggleZoomControls: PropTypes.func, + showZoomControls: PropTypes.bool, anchorEl: PropTypes.object, // eslint-disable-line react/forbid-prop-types t: PropTypes.func, }; @@ -123,6 +157,8 @@ WorkspaceMenu.propTypes = { WorkspaceMenu.defaultProps = { anchorEl: null, t: key => key, + showZoomControls: false, + toggleZoomControls: () => {}, }; export default WorkspaceMenu; diff --git a/src/components/ZoomControls.js b/src/components/ZoomControls.js new file mode 100644 index 0000000000000000000000000000000000000000..77903229042d79f1454bb191801da8ac29bd0fc6 --- /dev/null +++ b/src/components/ZoomControls.js @@ -0,0 +1,134 @@ +import React, { Component } from 'react'; +import { withStyles } from '@material-ui/core/styles'; +import IconButton from '@material-ui/core/IconButton'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import AddCircleIcon from '@material-ui/icons/AddCircle'; +import RemoveCircleIcon from '@material-ui/icons/RemoveCircle'; +import RefreshIcon from '@material-ui/icons/Refresh'; +import PropTypes from 'prop-types'; + +/** + */ +class ZoomControls extends Component { + /** + * constructor - + */ + constructor(props) { + super(props); + + this.handleZoomInClick = this.handleZoomInClick.bind(this); + this.handleZoomOutClick = this.handleZoomOutClick.bind(this); + this.handleZoomResetClick = this.handleZoomResetClick.bind(this); + } + + /** + * @private + */ + handleZoomInClick() { + const { windowId, updateViewport, viewer } = this.props; + + updateViewport(windowId, { + x: viewer.x, + y: viewer.y, + zoom: viewer.zoom * 2, + }); + } + + /** + * @private + */ + handleZoomOutClick() { + const { windowId, updateViewport, viewer } = this.props; + + updateViewport(windowId, { + x: viewer.x, + y: viewer.y, + zoom: viewer.zoom / 2, + }); + } + + /** + * @private + */ + handleZoomResetClick() { + const { windowId, updateViewport, viewer } = this.props; + + updateViewport(windowId, { + x: viewer.x, + y: viewer.y, + zoom: 1, + }); + } + + /** + * render + * @return + */ + render() { + const { showZoomControls, classes, t } = this.props; + + if (!showZoomControls) { + return ( + <> + </> + ); + } + return ( + <List className={classes.zoom_controls}> + <ListItem> + <IconButton aria-label={t('zoomIn')} onClick={this.handleZoomInClick}> + <AddCircleIcon /> + </IconButton> + </ListItem> + <ListItem> + <IconButton aria-label={t('zoomOut')} onClick={this.handleZoomOutClick}> + <RemoveCircleIcon /> + </IconButton> + </ListItem> + <ListItem> + <IconButton aria-label={t('zoomReset')} onClick={this.handleZoomResetClick}> + <RefreshIcon /> + </IconButton> + </ListItem> + </List> + ); + } +} + +ZoomControls.propTypes = { + windowId: PropTypes.string, + showZoomControls: PropTypes.bool, + viewer: PropTypes.shape({ + x: PropTypes.number, + y: PropTypes.number, + zoom: PropTypes.number, + }), + updateViewport: PropTypes.func, + classes: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + t: PropTypes.func, +}; + +ZoomControls.defaultProps = { + windowId: '', + showZoomControls: false, + viewer: {}, + updateViewport: () => {}, + t: key => key, +}; + +/** + * @private + */ +const styles = theme => ({ + zoom_controls: { + position: 'absolute', + right: 0, + }, + ListItem: { + paddingTop: 0, + paddingBottom: 0, + }, +}); + +export default withStyles(styles)(ZoomControls); diff --git a/src/containers/WorkspaceMenu.js b/src/containers/WorkspaceMenu.js index 866358d4fede4ed20468237fbf47e8cb0288c2b6..aa1bdd2e34d4b48d2c46818ff79d0551fafc1a73 100644 --- a/src/containers/WorkspaceMenu.js +++ b/src/containers/WorkspaceMenu.js @@ -1,9 +1,28 @@ import { compose } from 'redux'; +import { connect } from 'react-redux'; import { withNamespaces } from 'react-i18next'; import miradorWithPlugins from '../lib/miradorWithPlugins'; +import * as actions from '../state/actions'; import WorkspaceMenu from '../components/WorkspaceMenu'; +/** + * mapDispatchToProps - used to hook up connect to action creators + * @memberof ManifestListItem + * @private + */ +const mapDispatchToProps = { toggleZoomControls: actions.toggleZoomControls }; + +/** + * mapStateToProps - to hook up connect + * @memberof WindowViewer + * @private + */ +const mapStateToProps = state => ( + { showZoomControls: state.workspace.showZoomControls } +); + const enhance = compose( + connect(mapStateToProps, mapDispatchToProps), withNamespaces(), miradorWithPlugins, // further HOC diff --git a/src/containers/ZoomControls.js b/src/containers/ZoomControls.js new file mode 100644 index 0000000000000000000000000000000000000000..9c99d6cb906280e0c429f97fb2da449eb59bf7ba --- /dev/null +++ b/src/containers/ZoomControls.js @@ -0,0 +1,33 @@ +import { compose } from 'redux'; +import { connect } from 'react-redux'; +import { withNamespaces } from 'react-i18next'; +import * as actions from '../state/actions'; +import ZoomControls from '../components/ZoomControls'; + +/** + * mapStateToProps - to hook up connect + * @memberof Workspace + * @private + */ +const mapStateToProps = (state, props) => ( + { + showZoomControls: state.workspace.showZoomControls, + viewer: state.windows[props.windowId].viewer, + } +); + + +/** + * mapDispatchToProps - used to hook up connect to action creators + * @memberof Workspace + * @private + */ +const mapDispatchToProps = { updateViewport: actions.updateViewport }; + +const enhance = compose( + connect(mapStateToProps, mapDispatchToProps), + withNamespaces(), + // further HOC go here +); + +export default enhance(ZoomControls); diff --git a/src/state/actions/action-types.js b/src/state/actions/action-types.js index 946d7ec8df813a86d04a53452c175f1e50015444..bd11ddcb9324722bc910e6fbfa1933522d952e8b 100644 --- a/src/state/actions/action-types.js +++ b/src/state/actions/action-types.js @@ -15,6 +15,7 @@ const ActionTypes = { SET_WINDOW_THUMBNAIL_POSITION: 'SET_WINDOW_THUMBNAIL_POSITION', TOGGLE_WINDOW_SIDE_BAR: 'TOGGLE_WINDOW_SIDE_BAR', TOGGLE_WINDOW_SIDE_BAR_PANEL: 'TOGGLE_WINDOW_SIDE_BAR_PANEL', + TOGGLE_ZOOM_CONTROLS: 'TOGGLE_ZOOM_CONTROLS', UPDATE_CONFIG: 'UPDATE_CONFIG', REMOVE_MANIFEST: 'REMOVE_MANIFEST', REQUEST_INFO_RESPONSE: 'REQUEST_INFO_RESPONSE', diff --git a/src/state/actions/workspace.js b/src/state/actions/workspace.js index 5bda3ff7df4303e90799ecce67194cd74ea4f3bf..a5aeb61a668b814de123ce13b9fff7ef50bc3ef0 100644 --- a/src/state/actions/workspace.js +++ b/src/state/actions/workspace.js @@ -11,6 +11,15 @@ export function setWorkspaceFullscreen(isFullscreenEnabled) { return { type: ActionTypes.SET_WORKSPACE_FULLSCREEN, isFullscreenEnabled }; } +/** + * toggleZoomControls - action creator + * @param {Boolean} showZoomControls + * @memberof ActionCreators +*/ +export function toggleZoomControls(showZoomControls) { + return { type: ActionTypes.TOGGLE_ZOOM_CONTROLS, showZoomControls }; +} + /** * updateWorkspaceMosaicLayout - action creator * diff --git a/src/state/reducers/workspace.js b/src/state/reducers/workspace.js index 1bfa069ad2d15d1448033771ee55c1c8e2da122a..47ccdf7de83f3e17b1c1063abbf5d1e7f45284bd 100644 --- a/src/state/reducers/workspace.js +++ b/src/state/reducers/workspace.js @@ -9,6 +9,8 @@ const workspaceReducer = (state = {}, action) => { return { ...state, focusedWindowId: action.windowId }; case ActionTypes.SET_WORKSPACE_FULLSCREEN: return { ...state, isFullscreenEnabled: action.isFullscreenEnabled }; + case ActionTypes.TOGGLE_ZOOM_CONTROLS: + return { ...state, showZoomControls: action.showZoomControls }; case ActionTypes.UPDATE_WORKSPACE_MOSAIC_LAYOUT: return { ...state, layout: action.layout }; default: