diff --git a/__tests__/integration/mirador/index.html b/__tests__/integration/mirador/index.html index c6027129a15b8cc10c50c3aa243521f00d49b318..2bd6b0ed85350f9f19378b220e719f8fed308d01 100644 --- a/__tests__/integration/mirador/index.html +++ b/__tests__/integration/mirador/index.html @@ -12,6 +12,9 @@ <script type="text/javascript"> var miradorInstance = Mirador.viewer({ id: 'mirador', + workspace: { + type: 'elastic' + }, windows: [{ loadedManifest: 'https://iiif.harvardartmuseums.org/manifests/object/299843', canvasIndex: 2, diff --git a/__tests__/src/reducers/workspace.test.js b/__tests__/src/reducers/workspace.test.js index 9873e434d0160319f421233d64e25bcfc374382f..f4f863bc428e2b5f23c54bc01498b404edf08483 100644 --- a/__tests__/src/reducers/workspace.test.js +++ b/__tests__/src/reducers/workspace.test.js @@ -46,13 +46,11 @@ describe('workspace reducer', () => { expect(workspaceReducer([], { type: ActionTypes.SET_WORKSPACE_VIEWPORT_POSITION, payload: { - position: { - x: 50, - y: 50, - }, + x: 50, + y: 50, }, })).toEqual({ - viewportPosition: { + viewport: { x: 50, y: 50, }, diff --git a/package.json b/package.json index 96013af403b7eb39db645b75c96c5aceb2a7fe68..29c6b41fce690c2c3abc7f3b25d0579bf0972602 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "react-mosaic-component": "^2.1.0", "react-placeholder": "^3.0.1", "react-redux": "^6.0.0", + "react-resize-observer": "1.1.1", "react-rnd": "^9.1.1", "react-virtualized": "^9.21.0", "redux": "4.0.1", diff --git a/src/components/ElasticMinimap.js b/src/components/ElasticMinimap.js new file mode 100644 index 0000000000000000000000000000000000000000..f1eb376f248c06ca6c1eeb26503ab085b91d638f --- /dev/null +++ b/src/components/ElasticMinimap.js @@ -0,0 +1,158 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Rnd } from 'react-rnd'; +import ns from '../config/css-ns'; +import { getWorkspaceBoundingBox } from '../state/selectors'; + +const minimapContainer = { + width: 200, + height: 200, + innerPadding: 10, +}; + +// There is a minimum bounding box based on the viewport dimensions, +// and the bounding box is always square + +/** + * ElasticMinimap + */ +const scaledViewport = (workspaceViewport, boundingBox) => { + const viewportAspectRatio = workspaceViewport.width / workspaceViewport.height; + const viewportScaleFactor = workspaceViewport.width / boundingBox.width; + + const scaledViewportWidth = viewportScaleFactor * minimapContainer.width; + const scaledViewportHeight = scaledViewportWidth / viewportAspectRatio; + const scaledViewportX = (-workspaceViewport.x / boundingBox.width) * minimapContainer.width; + const scaledViewportY = (-workspaceViewport.y / boundingBox.height) * minimapContainer.height; + + return { + x: scaledViewportX, + y: scaledViewportY, + width: scaledViewportWidth, + height: scaledViewportHeight, + }; +}; + +/** + * ElasticMinimap + */ +const minimapToWorkspaceCoordinates = (x, y, boundingBox) => { + const newX = -x * boundingBox.width / minimapContainer.width; + const newY = -y * boundingBox.height / minimapContainer.height; + return { + x: newX, + y: newY, + }; +}; + +/** + * ElasticMinimap + */ +const windowStyle = (window, boundingBox) => { + const windowAspectRatio = window.width / window.height; + const windowScaleFactor = window.width / boundingBox.width; + + const scaledWindowWidth = windowScaleFactor * 100; + const scaledWindowX = (window.x / boundingBox.width) * 100; + const scaledWindowY = (window.y / boundingBox.height) * 100; + const scaledWindowHeight = scaledWindowWidth / windowAspectRatio; + + return { + top: `${scaledWindowY}%`, + left: `${scaledWindowX}%`, + height: `${scaledWindowHeight}%`, + width: `${scaledWindowWidth}%`, + }; +}; + +/** + * ElasticMinimap + */ +const boundingBoxStyle = (windows, boundingBox) => { + const windowBoundingBox = getWorkspaceBoundingBox(windows); + const windowBBAspectRatio = windowBoundingBox.width / windowBoundingBox.height; + const windowBBScaleFactor = windowBoundingBox.width / boundingBox.width; + + const scaledWindowBBWidth = windowBBScaleFactor * 100; + const scaledWindowBBX = (windowBoundingBox.x / boundingBox.width) * 100; + const scaledWindowBBY = (windowBoundingBox.y / boundingBox.height) * 100; + const scaledWindowBBHeight = scaledWindowBBWidth / windowBBAspectRatio; + + return { + top: `${scaledWindowBBY}%`, + left: `${scaledWindowBBX}%`, + height: `${scaledWindowBBHeight}%`, + width: `${scaledWindowBBWidth}%`, + }; +}; + +/** + * ElasticMinimap + */ +export class ElasticMinimap extends Component { + /** + * render + * @return + */ + render() { + const boundingBox = { + width: 5000, + height: 5000, + }; + + const { + windows, + workspaceViewport, + setWorkspaceViewportPosition, + } = this.props; + + const viewport = scaledViewport( + workspaceViewport, + boundingBox, + ); + + return ( + <div className={ns('elastic-minimap')}> + { + Object.values(windows).map(window => ( + <div + key={window.id} + className="minimap-window" + style={windowStyle(window, boundingBox)} + /> + )) + } + <div + className="window-bounding-box" + style={boundingBoxStyle(windows, boundingBox)} + /> + <Rnd + position={{ x: viewport.x, y: viewport.y }} + size={{ width: viewport.width, height: viewport.height }} + enableResizing={{ + top: false, + right: false, + bottom: false, + left: false, + topRight: false, + bottomRight: false, + bottomLeft: false, + topLeft: false, + }} + onDragStart={(e, d) => null} + onDrag={(e, d) => { + const newPosition = minimapToWorkspaceCoordinates(d.x, d.y, boundingBox); + setWorkspaceViewportPosition(newPosition.x, newPosition.y); + }} + className="minimap-viewport" + /> + </div> + ); + } +} + +ElasticMinimap.propTypes = { + windows: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + setWorkspaceViewportPosition: PropTypes.func.isRequired, + workspaceViewport: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types +}; diff --git a/src/components/Workspace.js b/src/components/Workspace.js index 5f975288249e5ed8544acaaa110d6ed76b127277..d716d67523ffc015e1ff41050fb8abcb772cb3de 100644 --- a/src/components/Workspace.js +++ b/src/components/Workspace.js @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import Typography from '@material-ui/core/Typography'; +import ResizeObserver from 'react-resize-observer'; import Window from '../containers/Window'; import WorkspaceMosaic from '../containers/WorkspaceMosaic'; import WorkspaceElastic from '../containers/WorkspaceElastic'; @@ -61,7 +62,7 @@ export class Workspace extends React.Component { * render */ render() { - const { isWorkspaceControlPanelVisible, t } = this.props; + const { isWorkspaceControlPanelVisible, setWorkspaceViewportDimensions, t } = this.props; return ( <div @@ -74,6 +75,12 @@ export class Workspace extends React.Component { > <Typography variant="srOnly" component="h1">{t('miradorViewer')}</Typography> {this.workspaceByType()} + + <ResizeObserver + onResize={(rect) => { + setWorkspaceViewportDimensions(rect.width, rect.height); + }} + /> </div> ); } @@ -81,6 +88,7 @@ export class Workspace extends React.Component { Workspace.propTypes = { isWorkspaceControlPanelVisible: PropTypes.bool.isRequired, + setWorkspaceViewportDimensions: PropTypes.func.isRequired, windows: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types workspaceType: PropTypes.string.isRequired, // eslint-disable-line react/forbid-prop-types t: PropTypes.func.isRequired, diff --git a/src/components/WorkspaceElastic.js b/src/components/WorkspaceElastic.js index 65de11c19693ac3567cfcba6aaed850c3d346357..07312ce37d4f0c7e58a1912c4a9b41184f7dc81f 100644 --- a/src/components/WorkspaceElastic.js +++ b/src/components/WorkspaceElastic.js @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Rnd } from 'react-rnd'; import Window from '../containers/Window'; +import ElasticMinimap from '../containers/ElasticMinimap'; import ns from '../config/css-ns'; /** @@ -21,54 +22,57 @@ class WorkspaceElastic extends React.Component { setWindowSize, } = this.props; return ( - <Rnd - default={{ - width: 5000, - height: 5000, - }} - position={{ x: workspace.viewportPosition.x, y: workspace.viewportPosition.y }} - enableResizing={{ - top: false, - right: false, - bottom: false, - left: false, - topRight: false, - bottomRight: false, - bottomLeft: false, - topLeft: false, - }} - onDragStop={(e, d) => { - setWorkspaceViewportPosition({ x: d.x, y: d.y }); - }} - cancel={`.${ns('window')}`} - className={ns('workspace')} - > - { - Object.values(windows).map(window => ( - <Rnd - key={window.id} - size={{ width: window.width, height: window.height }} - position={{ x: window.x, y: window.y }} - bounds="parent" - onDragStop={(e, d) => { - updateWindowPosition(window.id, { x: d.x, y: d.y }); - }} - onResize={(e, direction, ref, delta, position) => { - setWindowSize(window.id, { - width: ref.style.width, - height: ref.style.height, - ...position, - }); - }} - dragHandleClassName={ns('window-top-bar')} - > - <Window - window={window} - /> - </Rnd> - )) - } - </Rnd> + <> + <Rnd + default={{ + width: 5000, + height: 5000, + }} + position={{ x: workspace.viewport.x, y: workspace.viewport.y }} + enableResizing={{ + top: false, + right: false, + bottom: false, + left: false, + topRight: false, + bottomRight: false, + bottomLeft: false, + topLeft: false, + }} + onDrag={(e, d) => { + setWorkspaceViewportPosition(d.x, d.y); + }} + cancel={`.${ns('window')}`} + className={ns('workspace')} + > + { + Object.values(windows).map(window => ( + <Rnd + key={window.id} + size={{ width: window.width, height: window.height }} + position={{ x: window.x, y: window.y }} + bounds="parent" + onDrag={(e, d) => { + updateWindowPosition(window.id, { x: d.x, y: d.y }); + }} + onResize={(e, direction, ref, delta, position) => { + setWindowSize(window.id, { + width: parseInt(ref.style.width, 10), + height: parseInt(ref.style.height, 10), + ...position, + }); + }} + dragHandleClassName={ns('window-top-bar')} + > + <Window + window={window} + /> + </Rnd> + )) + } + </Rnd> + <ElasticMinimap /> + </> ); } } diff --git a/src/containers/ElasticMinimap.js b/src/containers/ElasticMinimap.js new file mode 100644 index 0000000000000000000000000000000000000000..e3fc6efacf4d299d12d26f1114f85ab5463caf14 --- /dev/null +++ b/src/containers/ElasticMinimap.js @@ -0,0 +1,39 @@ +import { compose } from 'redux'; +import { connect } from 'react-redux'; +import { withTranslation } from 'react-i18next'; +import miradorWithPlugins from '../lib/miradorWithPlugins'; +import * as actions from '../state/actions'; +import { ElasticMinimap } from '../components/ElasticMinimap'; + +/** + * mapDispatchToProps - used to hook up connect to action creators + * @memberof ManifestListItem + * @private + */ +const mapDispatchToProps = (dispatch, props) => ({ + setWorkspaceViewportPosition: (x, y) => { + dispatch( + actions.setWorkspaceViewportPosition(x, y), + ); + }, +}); + +/** + * mapStateToProps - to hook up connect + * @memberof WindowViewer + * @private + */ +const mapStateToProps = state => ({ + // viewport: state.workspace.viewport, + // workspaceBoundingBox: state.workspace.viewport, + workspaceViewport: state.workspace.viewport, + windows: state.windows, +}); + +const enhance = compose( + withTranslation(), + connect(mapStateToProps, mapDispatchToProps), + miradorWithPlugins, +); + +export default enhance(ElasticMinimap); diff --git a/src/containers/Workspace.js b/src/containers/Workspace.js index 235dec8d17602fffbb963843d7085eccf8a95342..f06f6b80fc2c1c34a566dcec029ac4f4c80951c1 100644 --- a/src/containers/Workspace.js +++ b/src/containers/Workspace.js @@ -1,8 +1,23 @@ import { compose } from 'redux'; import { connect } from 'react-redux'; import { withTranslation } from 'react-i18next'; +import * as actions from '../state/actions'; import { Workspace } from '../components/Workspace'; + +/** + * mapDispatchToProps - used to hook up connect to action creators + * @memberof ManifestListItem + * @private + */ +const mapDispatchToProps = (dispatch, props) => ({ + setWorkspaceViewportDimensions: (width, height) => { + dispatch( + actions.setWorkspaceViewportDimensions(width, height), + ); + }, +}); + /** * mapStateToProps - to hook up connect * @memberof Workspace @@ -18,7 +33,7 @@ const mapStateToProps = state => ( const enhance = compose( withTranslation(), - connect(mapStateToProps), + connect(mapStateToProps, mapDispatchToProps), // further HOC go here ); diff --git a/src/containers/WorkspaceElastic.js b/src/containers/WorkspaceElastic.js index 8c0b8d714bb1d69c700178fd79aefac3612b1288..8eecedf343fff4d010a55eccfa219ce5c00544e0 100644 --- a/src/containers/WorkspaceElastic.js +++ b/src/containers/WorkspaceElastic.js @@ -22,9 +22,9 @@ const mapStateToProps = state => ( * @private */ const mapDispatchToProps = (dispatch, props) => ({ - setWorkspaceViewportPosition: (position) => { + setWorkspaceViewportPosition: (x, y) => { dispatch( - actions.setWorkspaceViewportPosition(position), + actions.setWorkspaceViewportPosition(x, y), ); }, toggleWorkspaceExposeMode: size => dispatch( diff --git a/src/state/actions/action-types.js b/src/state/actions/action-types.js index 43b6433b04e90c32967155429c7795dd2fa28f46..2a0fcce0fe0676c6cbad7770759c9b1266fe9368 100644 --- a/src/state/actions/action-types.js +++ b/src/state/actions/action-types.js @@ -10,6 +10,7 @@ const ActionTypes = { FOCUS_WINDOW: 'FOCUS_WINDOW', SET_WORKSPACE_FULLSCREEN: 'SET_WORKSPACE_FULLSCREEN', + SET_WORKSPACE_VIEWPORT_DIMENSIONS: 'SET_WORKSPACE_VIEWPORT_DIMENSIONS', SET_WORKSPACE_VIEWPORT_POSITION: 'SET_WORKSPACE_VIEWPORT_POSITION', TOGGLE_WORKSPACE_EXPOSE_MODE: 'TOGGLE_WORKSPACE_EXPOSE_MODE', ADD_MANIFEST: 'ADD_MANIFEST', diff --git a/src/state/actions/workspace.js b/src/state/actions/workspace.js index 6da0e4898d127da4000cf334164bc1f670a71f1d..d09cf4938563c87efa24444dcf472a20e71869e4 100644 --- a/src/state/actions/workspace.js +++ b/src/state/actions/workspace.js @@ -45,14 +45,29 @@ export function setWorkspaceAddVisibility(isWorkspaceAddVisible) { * @param {Object} position * @memberof ActionCreators */ -export function setWorkspaceViewportPosition(position) { +export function setWorkspaceViewportPosition(x, y) { + console.log('x:', x, 'y', y); return { type: ActionTypes.SET_WORKSPACE_VIEWPORT_POSITION, payload: { - position: { - x: position.x, - y: position.y, - }, + x, + y, + }, + }; +} + +/** + * setWorkspaceViewportPosition - action creator + * + * @param {Object} position + * @memberof ActionCreators + */ +export function setWorkspaceViewportDimensions(width, height) { + return { + type: ActionTypes.SET_WORKSPACE_VIEWPORT_DIMENSIONS, + payload: { + width, + height, }, }; } diff --git a/src/state/reducers/workspace.js b/src/state/reducers/workspace.js index 6abaeb93abc2395bb2cf99343aa8cdaf5b67a3c2..86c12855351b8aa807aaea1cfb494495c6f31984 100644 --- a/src/state/reducers/workspace.js +++ b/src/state/reducers/workspace.js @@ -5,9 +5,11 @@ import ActionTypes from '../actions/action-types'; */ export const workspaceReducer = ( state = { // we'll need to abstract this more, methinks. - viewportPosition: { + viewport: { x: -2500, y: -2500, + width: 800, + height: 600, }, exposeModeOn: false, }, @@ -25,7 +27,23 @@ export const workspaceReducer = ( case ActionTypes.SET_WORKSPACE_ADD_VISIBILITY: return { ...state, isWorkspaceAddVisible: action.isWorkspaceAddVisible }; case ActionTypes.SET_WORKSPACE_VIEWPORT_POSITION: - return { ...state, viewportPosition: action.payload.position }; + return { + ...state, + viewport: { + ...state.viewport, + x: action.payload.x, + y: action.payload.y, + }, + }; + case ActionTypes.SET_WORKSPACE_VIEWPORT_DIMENSIONS: + return { + ...state, + viewport: { + ...state.viewport, + width: action.payload.width, + height: action.payload.height, + }, + }; case ActionTypes.TOGGLE_WORKSPACE_EXPOSE_MODE: return { ...state, exposeModeOn: !state.exposeModeOn }; default: diff --git a/src/state/selectors/index.js b/src/state/selectors/index.js index f2940ef1181b82bf26602e6f08275a257bd05432..dccefea72df2eaaa4e2a625c1c54982f17415120 100644 --- a/src/state/selectors/index.js +++ b/src/state/selectors/index.js @@ -272,3 +272,45 @@ export function getLanguagesFromConfigWithCurrent(state) { current: key === language, })); } + +/** + * Return the bounding box for all open windows in the elastic workspace + * in workspace coordinates + * @param {object} state + * @return {object} + */ +export function getWorkspaceBoundingBox(windows) { + const windowObjects = Object.values(windows); + const minX = Math.min(...windowObjects.map(win => win.x)); + const minY = Math.min(...windowObjects.map(win => win.y)); + const maxX = Math.max(...windowObjects.map((win) => { + console.log('win width: ', win.width); + console.log(win.x + win.width); + return (win.x + win.width); + })); + const maxY = Math.max(...windowObjects.map(win => (win.y + win.height))); + console.log('min X: ', minX); + console.log('min Y: ', minY); + console.log('max X: ', maxX); + console.log('max Y: ', maxY); + return { + x: minX, + y: minY, + width: (maxX - minX), + height: maxY - minY, + }; + + // Potential solution to overflow problems. + // const minX = Math.min(...windowObjects.map( + // win => Number.parseFloat(win.x).toPrecision(4), + // )); + // const minY = Math.min(...windowObjects.map( + // win => Number.parseFloat(win.y).toPrecision(4), + // )); + // const maxX = Math.max(...windowObjects.map( + // win => Number.parseFloat((win.x + win.width)).toPrecision(4), + // )); + // const maxY = Math.max(...windowObjects.map( + // win => Number.parseFloat((win.y + win.height)).toPrecision(4), + // )); +} diff --git a/src/styles/index.scss b/src/styles/index.scss index 62687b6c8f64bc93f6c57a4a976e382c41489e80..0a568005dbc10a9ec2953c6181d65725bc4320bc 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -43,7 +43,7 @@ } &-workspace-with-control-panel { - padding-top: 74px; // The height of the control panel + margin-top: 74px; // The height of the control panel } &-workspace-maximized-window { @@ -56,20 +56,57 @@ &-workspace-add { height: 100%; overflow: auto; - padding-left: 6px; - padding-right: 6px; - padding-top: 92px; + margin-left: 6px; + margin-right: 6px; + margin-top: 92px; + } + + &-elastic-minimap { + opacity: 0.8; + position: absolute; + bottom: 0; + right: 0; + margin: 20px; + width: 200px; + height: 200px; + border: 2px solid lightgray; + background: white; + box-sizing: border-box; + + .minimap-window { + position: absolute; + background: rgba(deepskyblue, .3); + border: 1px solid lightgray; + box-sizing: border-box; + } + + .minimap-viewport { + position: absolute; + border: 1px solid orangered; + box-sizing: border-box; + transition: 0.2s box-shadow ease-out; + &:hover { + transition: 0.2s box-shadow ease-out; + box-shadow: 0 0 3px gray; + } + } + + .window-bounding-box { + box-sizing: border-box; + position: absolute; + border: 1px dotted deeppink; + } } @media (min-width: 600px) { &-workspace-with-control-panel { - padding-left: 100px; - padding-top: 0; + margin-left: 100px; + margin-top: 0; } &-workspace-add { - padding-left: 100px; - padding-top: 18px; + margin-left: 100px; + margin-top: 18px; } }