diff --git a/__tests__/src/components/WindowViewSettings.test.js b/__tests__/src/components/WindowViewSettings.test.js index 4a7c06a07d67cff892feadf14afaa1307ef279b0..bd19497b2e988c0282370fead6b29c0e778788d6 100644 --- a/__tests__/src/components/WindowViewSettings.test.js +++ b/__tests__/src/components/WindowViewSettings.test.js @@ -7,11 +7,12 @@ import { WindowViewSettings } from '../../../src/components/WindowViewSettings'; /** create wrapper */ function createWrapper(props) { - return shallow( + return mount( <WindowViewSettings classes={{}} windowId="xyz" setWindowViewType={() => {}} + viewTypes={['single', 'book', 'scroll', 'gallery']} windowViewType="single" {...props} />, @@ -23,10 +24,11 @@ describe('WindowViewSettings', () => { const wrapper = createWrapper(); expect(wrapper.find(ListSubheader).length).toBe(1); const labels = wrapper.find(FormControlLabel); - expect(labels.length).toBe(3); + expect(labels.length).toBe(4); expect(labels.at(0).props().value).toBe('single'); expect(labels.at(1).props().value).toBe('book'); - expect(labels.at(2).props().value).toBe('gallery'); + expect(labels.at(2).props().value).toBe('scroll'); + expect(labels.at(3).props().value).toBe('gallery'); }); it('should set the correct label active (by setting the secondary color)', () => { @@ -37,8 +39,11 @@ describe('WindowViewSettings', () => { wrapper = createWrapper({ windowViewType: 'book' }); expect(wrapper.find(FormControlLabel).at(1).props().control.props.color).toEqual('secondary'); - wrapper = createWrapper({ windowViewType: 'gallery' }); + wrapper = createWrapper({ windowViewType: 'scroll' }); expect(wrapper.find(FormControlLabel).at(2).props().control.props.color).toEqual('secondary'); + + wrapper = createWrapper({ windowViewType: 'gallery' }); + expect(wrapper.find(FormControlLabel).at(3).props().control.props.color).toEqual('secondary'); }); it('updates state when the view config selection changes', () => { @@ -49,6 +54,8 @@ describe('WindowViewSettings', () => { wrapper.find(MenuItem).at(1).simulate('click'); expect(setWindowViewType).toHaveBeenCalledWith('xyz', 'book'); wrapper.find(MenuItem).at(2).simulate('click'); + expect(setWindowViewType).toHaveBeenCalledWith('xyz', 'scroll'); + wrapper.find(MenuItem).at(3).simulate('click'); expect(setWindowViewType).toHaveBeenCalledWith('xyz', 'gallery'); }); @@ -58,6 +65,7 @@ describe('WindowViewSettings', () => { classes={{}} windowId="xyz" setWindowViewType={() => {}} + viewTypes={['single', 'book', 'scroll', 'gallery']} windowViewType="single" />, ); diff --git a/__tests__/src/lib/CanvasGroupings.test.js b/__tests__/src/lib/CanvasGroupings.test.js index 1f222d299a75fe337e4315ac703eca70461ba370..2732be7b629a0af002c1a7535e6a2cc1b775539a 100644 --- a/__tests__/src/lib/CanvasGroupings.test.js +++ b/__tests__/src/lib/CanvasGroupings.test.js @@ -35,6 +35,16 @@ describe('CanvasGroupings', () => { expect(subject.groupings()[1]).toEqual([1, 2]); }); }); + describe('scroll', () => { + let subject; + beforeEach(() => { + subject = new CanvasGroupings([0, 1, 2, 3], 'scroll'); + }); + it('creates an array of all the canvases', () => { + expect(subject.groupings().length).toEqual(1); + expect(subject.groupings()[0]).toEqual([0, 1, 2, 3]); + }); + }); }); describe('getCanvases', () => { describe('single', () => { @@ -58,5 +68,11 @@ describe('CanvasGroupings', () => { expect(subject.getCanvases(2)).toEqual([2]); }); }); + describe('scroll', () => { + it('selects by index', () => { + const subject = new CanvasGroupings([0, 1, 2, 3], 'scroll'); + expect(subject.getCanvases(0)).toEqual([0, 1, 2, 3]); + }); + }); }); }); diff --git a/__tests__/src/lib/CanvasWorld.test.js b/__tests__/src/lib/CanvasWorld.test.js index f30d00ed0184f123f07f70494df83a98afae7768..d010df137176bd2cd6aaa26e7988842ad1f08179 100644 --- a/__tests__/src/lib/CanvasWorld.test.js +++ b/__tests__/src/lib/CanvasWorld.test.js @@ -29,6 +29,14 @@ describe('CanvasWorld', () => { expect(new CanvasWorld(canvasSubset, null, 'right-to-left').contentResourceToWorldCoordinates({ id: 'https://stacks.stanford.edu/image/iiif/rz176rt6531%2FPC0170_s3_Tree_Calendar_20081101_152516_0410/full/full/0/default.jpg' })) .toEqual([0, 0, 2848, 4288]); }); + it('supports TTB orientations', () => { + expect(new CanvasWorld(canvasSubset, null, 'top-to-bottom').contentResourceToWorldCoordinates({ id: 'https://stacks.stanford.edu/image/iiif/rz176rt6531%2FPC0170_s3_Tree_Calendar_20081101_152516_0410/full/full/0/default.jpg' })) + .toEqual([0, 1936, 2848, 4288]); + }); + it('supports BTT orientations', () => { + expect(new CanvasWorld(canvasSubset, null, 'bottom-to-top').contentResourceToWorldCoordinates({ id: 'https://stacks.stanford.edu/image/iiif/rz176rt6531%2FPC0170_s3_Tree_Calendar_20081101_152516_0410/full/full/0/default.jpg' })) + .toEqual([0, 0, 2848, 4288]); + }); it('when placed by a fragment contains the offset', () => { const subject = new CanvasWorld( [Utils.parseManifest(fragmentFixture).getSequences()[0].getCanvases()[0]], diff --git a/__tests__/src/selectors/windows.test.js b/__tests__/src/selectors/windows.test.js index 5bc2699678b8a1b7162cd9e5c10583c73f4d6996..3db842886656c61a4c806408f48da6588de3aefd 100644 --- a/__tests__/src/selectors/windows.test.js +++ b/__tests__/src/selectors/windows.test.js @@ -11,6 +11,7 @@ import { getWindowManifests, getWindows, getMaximizedWindowsIds, + getAllowedWindowViewTypes, } from '../../../src/state/selectors/windows'; describe('getWindows', () => { @@ -104,6 +105,12 @@ describe('getWindowViewType', () => { config: { window: { defaultView: 'default', + views: [ + { behaviors: ['individuals'], key: 'single' }, + { behaviors: ['paged'], key: 'book' }, + { behaviors: ['continuous'], key: 'scroll' }, + { key: 'gallery' }, + ], }, }, manifests: { @@ -150,6 +157,36 @@ describe('getWindowViewType', () => { }); }); +describe('getAllowedWindowViewTypes', () => { + const state = { + config: { + window: { + defaultView: 'single', + views: [ + { behaviors: ['individuals'], key: 'single' }, + { behaviors: ['paged'], key: 'book' }, + { behaviors: ['continuous'], key: 'scroll' }, + { key: 'gallery' }, + ], + }, + }, + manifests: { + x: { json: { ...manifestFixture001 } }, + y: { json: { ...manifestFixture015 } }, + }, + }; + + it('should return unrestricted view types', () => { + const received = getAllowedWindowViewTypes(state, { manifestId: 'x' }); + expect(received).toEqual(['single', 'gallery']); + }); + + it('should return view types where behaviors match', () => { + const received = getAllowedWindowViewTypes(state, { manifestId: 'y' }); + expect(received).toEqual(['single', 'book', 'gallery']); + }); +}); + describe('getViewer', () => { const state = { viewers: { diff --git a/src/components/WindowViewSettings.js b/src/components/WindowViewSettings.js index 1666c7a8a1fa28df0d2100d8971ea4db79d84e92..fdc05946517f5816668556e33c20315cdd6f743e 100644 --- a/src/components/WindowViewSettings.js +++ b/src/components/WindowViewSettings.js @@ -4,6 +4,7 @@ import FormControlLabel from '@material-ui/core/FormControlLabel'; import MenuItem from '@material-ui/core/MenuItem'; import ListSubheader from '@material-ui/core/ListSubheader'; import SingleIcon from '@material-ui/icons/CropOriginalSharp'; +import ScrollViewIcon from '@material-ui/icons/ViewColumn'; import PropTypes from 'prop-types'; import BookViewIcon from './icons/BookViewIcon'; import GalleryViewIcon from './icons/GalleryViewIcon'; @@ -55,44 +56,40 @@ export class WindowViewSettings extends Component { */ render() { const { - classes, handleClose, t, windowViewType, + classes, handleClose, t, windowViewType, viewTypes, } = this.props; + const iconMap = { + book: BookViewIcon, + gallery: GalleryViewIcon, + scroll: ScrollViewIcon, + single: SingleIcon, + }; + + /** Suspiciously similar to a component, yet if it is invoked through JSX + none of the click handlers work? */ + const menuItem = ({ value, Icon }) => ( + <MenuItem + key={value} + className={classes.MenuItem} + ref={windowViewType === value && (ref => this.handleSelectedRef(ref))} + onClick={() => { this.handleChange(value); handleClose(); }} + > + <FormControlLabel + value={value} + classes={{ label: windowViewType === value ? classes.selectedLabel : classes.label }} + control={<Icon color={windowViewType === value ? 'secondary' : undefined} />} + label={t(value)} + labelPlacement="bottom" + /> + </MenuItem> + ); + + if (viewTypes.length === 0) return null; return ( <> <ListSubheader role="presentation" disableSticky tabIndex="-1">{t('view')}</ListSubheader> - - <MenuItem - className={classes.MenuItem} - ref={ref => this.handleSelectedRef(ref)} - onClick={() => { this.handleChange('single'); handleClose(); }} - > - <FormControlLabel - value="single" - classes={{ label: windowViewType === 'single' ? classes.selectedLabel : classes.label }} - control={<SingleIcon color={windowViewType === 'single' ? 'secondary' : undefined} />} - label={t('single')} - labelPlacement="bottom" - /> - </MenuItem> - <MenuItem className={classes.MenuItem} onClick={() => { this.handleChange('book'); handleClose(); }}> - <FormControlLabel - value="book" - classes={{ label: windowViewType === 'book' ? classes.selectedLabel : classes.label }} - control={<BookViewIcon color={windowViewType === 'book' ? 'secondary' : undefined} />} - label={t('book')} - labelPlacement="bottom" - /> - </MenuItem> - <MenuItem className={classes.MenuItem} onClick={() => { this.handleChange('gallery'); handleClose(); }}> - <FormControlLabel - value="gallery" - classes={{ label: windowViewType === 'gallery' ? classes.selectedLabel : classes.label }} - control={<GalleryViewIcon color={windowViewType === 'gallery' ? 'secondary' : undefined} />} - label={t('gallery')} - labelPlacement="bottom" - /> - </MenuItem> + { viewTypes.map(value => menuItem({ Icon: iconMap[value], value })) } </> ); } @@ -103,10 +100,12 @@ WindowViewSettings.propTypes = { handleClose: PropTypes.func, setWindowViewType: PropTypes.func.isRequired, t: PropTypes.func, + viewTypes: PropTypes.arrayOf(PropTypes.string), windowId: PropTypes.string.isRequired, windowViewType: PropTypes.string.isRequired, }; WindowViewSettings.defaultProps = { handleClose: () => {}, t: key => key, + viewTypes: [], }; diff --git a/src/config/settings.js b/src/config/settings.js index a81460c6586aae9a6fdd51a1b2d5fdd9f58e82c2..419a0c62f6145753e59dd7c6a06cee1f53a26125 100644 --- a/src/config/settings.js +++ b/src/config/settings.js @@ -242,7 +242,13 @@ export default { canvas: true, annotations: true, search: true, - } + }, + views: [ + { key: 'single', behaviors: ['individuals'] }, + { key: 'book', behaviors: ['paged'] }, + { key: 'scroll', behaviors: ['continuous'] }, + { key: 'gallery' }, + ], }, windows: [ // Array of windows to be open when mirador initializes (each object should at least provide a manifestId key with the value of the IIIF presentation manifest to load) /** diff --git a/src/containers/WindowViewSettings.js b/src/containers/WindowViewSettings.js index e4b47ae70797500b6eb463d415bbbd138a3914f6..6fc417f284b1f5b585c85ec369ddbd31946ece8a 100644 --- a/src/containers/WindowViewSettings.js +++ b/src/containers/WindowViewSettings.js @@ -4,7 +4,7 @@ import { withTranslation } from 'react-i18next'; import { withStyles } from '@material-ui/core/styles'; import { withPlugins } from '../extend/withPlugins'; import * as actions from '../state/actions'; -import { getWindowViewType } from '../state/selectors'; +import { getAllowedWindowViewTypes, getWindowViewType } from '../state/selectors'; import { WindowViewSettings } from '../components/WindowViewSettings'; /** @@ -21,6 +21,7 @@ const mapDispatchToProps = { setWindowViewType: actions.setWindowViewType }; */ const mapStateToProps = (state, { windowId }) => ( { + viewTypes: getAllowedWindowViewTypes(state, { windowId }), windowViewType: getWindowViewType(state, { windowId }), } ); diff --git a/src/lib/CanvasGroupings.js b/src/lib/CanvasGroupings.js index ab348f30740da53a7985fb45b9873373b3713a82..9939e8622ee089e105b5230c77f2818443cd0c7d 100644 --- a/src/lib/CanvasGroupings.js +++ b/src/lib/CanvasGroupings.js @@ -29,6 +29,9 @@ export default class CanvasGroupings { if (this._groupings) { // eslint-disable-line no-underscore-dangle return this._groupings; // eslint-disable-line no-underscore-dangle } + if (this.viewType === 'scroll') { + return [this.canvases]; + } if (this.viewType !== 'book') { return this.canvases.map(canvas => [canvas]); } diff --git a/src/lib/CanvasWorld.js b/src/lib/CanvasWorld.js index 535f9fac351fdf387eae6e03d0fd079f4df845a4..f15f7d709f3ee655eb6d7fddd308788288a926ed 100644 --- a/src/lib/CanvasWorld.js +++ b/src/lib/CanvasWorld.js @@ -13,6 +13,7 @@ export default class CanvasWorld { this.canvases = canvases.map(c => new MiradorCanvas(c)); this.layers = layers; this.viewingDirection = viewingDirection; + this._canvasDimensions = null; // eslint-disable-line no-underscore-dangle } /** */ @@ -20,65 +21,113 @@ export default class CanvasWorld { return this.canvases.map(canvas => canvas.id); } + /** */ + get canvasDimensions() { + if (this._canvasDimensions) { // eslint-disable-line no-underscore-dangle + return this._canvasDimensions; // eslint-disable-line no-underscore-dangle + } + + const [dirX, dirY] = this.canvasDirection; + const scale = dirY === 0 + ? Math.min(...this.canvases.map(c => c.getHeight())) + : Math.min(...this.canvases.map(c => c.getWidth())); + let incX = 0; + let incY = 0; + + const canvasDims = this.canvases.reduce((acc, canvas) => { + let canvasHeight; + let canvasWidth; + + if (dirY === 0) { + // constant height + canvasHeight = scale; + canvasWidth = Math.floor(scale * canvas.aspectRatio); + } else { + // constant width + canvasWidth = scale; + canvasHeight = Math.floor(scale * (1 / canvas.aspectRatio)); + } + acc.push({ + canvas, + height: canvasHeight, + width: canvasWidth, + x: incX, + y: incY, + }); + + incX += dirX * canvasWidth; + incY += dirY * canvasHeight; + return acc; + }, []); + + const worldHeight = dirY === 0 ? scale : Math.abs(incY); + const worldWidth = dirX === 0 ? scale : Math.abs(incX); + + this._canvasDimensions = canvasDims // eslint-disable-line no-underscore-dangle + .reduce((acc, dims) => { + acc.push({ + ...dims, + x: dirX === -1 ? dims.x + worldWidth - dims.width : dims.x, + y: dirY === -1 ? dims.y + worldHeight - dims.height : dims.y, + }); + + return acc; + }, []); + + return this._canvasDimensions; // eslint-disable-line no-underscore-dangle + } + /** * contentResourceToWorldCoordinates - calculates the contentResource coordinates * respective to the world. */ contentResourceToWorldCoordinates(contentResource) { - const wholeBounds = this.worldBounds(); const miradorCanvasIndex = this.canvases.findIndex(c => ( c.imageResources.find(r => r.id === contentResource.id) )); const canvas = this.canvases[miradorCanvasIndex]; - const scaledWidth = Math.floor(wholeBounds[3] * canvas.aspectRatio); - let x = 0; - if (miradorCanvasIndex === this.secondCanvasIndex) { - x = wholeBounds[2] - scaledWidth; - } + const [x, y, w, h] = this.canvasToWorldCoordinates(canvas.id); + const fragmentOffset = canvas.onFragment(contentResource.id); if (fragmentOffset) { return [ x + fragmentOffset[0], - 0 + fragmentOffset[1], + y + fragmentOffset[1], fragmentOffset[2], fragmentOffset[3], ]; } return [ x, - 0, - scaledWidth, - wholeBounds[3], + y, + w, + h, ]; } /** */ canvasToWorldCoordinates(canvasId) { - const wholeBounds = this.worldBounds(); - const miradorCanvasIndex = this.canvases.findIndex(c => (c.id === canvasId)); - const { aspectRatio } = this.canvases[miradorCanvasIndex]; - const scaledWidth = Math.floor(wholeBounds[3] * aspectRatio); - let x = 0; - if (miradorCanvasIndex === this.secondCanvasIndex) { - x = wholeBounds[2] - scaledWidth; - } + const canvasDimensions = this.canvasDimensions.find(c => c.canvas.id === canvasId); + return [ - x, - 0, - scaledWidth, - wholeBounds[3], + canvasDimensions.x, + canvasDimensions.y, + canvasDimensions.width, + canvasDimensions.height, ]; } - /** - * secondCanvasIndex - index of the second canvas used for determining which - * is first - */ - get secondCanvasIndex() { - return this.viewingDirection === 'right-to-left' ? 0 : 1; + /** */ + get canvasDirection() { + switch (this.viewingDirection) { + case 'left-to-right': return [1, 0]; + case 'right-to-left': return [-1, 0]; + case 'top-to-bottom': return [0, 1]; + case 'bottom-to-top': return [0, -1]; + default: return [1, 0]; + } } - /** Get the IIIF content resource for an image */ contentResource(infoResponseId) { const miradorCanvas = this.canvases.find(c => c.imageServiceIds.some(id => ( @@ -149,26 +198,14 @@ export default class CanvasWorld { * lined up horizontally starting from left to right. */ worldBounds() { - const heights = []; - const dimensions = []; - this.canvases.forEach((canvas) => { - heights.push(canvas.getHeight()); - dimensions.push({ - height: canvas.getHeight(), - width: canvas.getWidth(), - }); - }); - const minHeight = Math.min(...heights); - let scaledWidth = 0; - dimensions.forEach((dim) => { - const aspectRatio = dim.width / dim.height; - scaledWidth += Math.floor(minHeight * aspectRatio); - }); + const worldWidth = Math.max(...this.canvasDimensions.map(c => c.x + c.width)); + const worldHeight = Math.max(...this.canvasDimensions.map(c => c.y + c.height)); + return [ 0, 0, - scaledWidth, - minHeight, + worldWidth, + worldHeight, ]; } } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 08d855641731c9e190e329aea087c5160ffc3ca5..b655cb2d5347b049c8b0e8f3369b6fb335ffcc35 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -104,6 +104,7 @@ "retry": "Retry", "right": "Right", "rights": "License", + "scroll": "Scroll", "searchInputLabel": "search terms", "searchNextResult": "Next result", "searchNoResults": "No results found", diff --git a/src/state/selectors/config.js b/src/state/selectors/config.js index 6612fceabb4b8442ed2b8f7e5d462040c6c5ef78..ed7981d2b42f61729f1b9dbdf0956255366d4d92 100644 --- a/src/state/selectors/config.js +++ b/src/state/selectors/config.js @@ -52,6 +52,11 @@ export const getDefaultView = createSelector( ({ window }) => window && window.defaultView, ); +export const getViewConfigs = createSelector( + [getConfig], + ({ window }) => (window && window.views) || [], +); + export const getThemeDirection = createSelector( [getConfig], ({ theme }) => theme.direction || 'ltr', diff --git a/src/state/selectors/windows.js b/src/state/selectors/windows.js index 4ffbe1422d892edc4cf24a0937c86548f9c5d5cf..c24ca52d8e9677a488879f893d9d8b2d3843723b 100644 --- a/src/state/selectors/windows.js +++ b/src/state/selectors/windows.js @@ -5,7 +5,7 @@ import { getManifestViewingHint, getManifestoInstance, } from './manifests'; -import { getDefaultView } from './config'; +import { getDefaultView, getViewConfigs } from './config'; import { getWorkspaceType } from './workspace'; /** @@ -85,18 +85,41 @@ export const getWindowViewType = createSelector( getManifestViewingHint, getManifestBehaviors, getDefaultView, + getViewConfigs, ], - (window, manifestViewingHint, manifestBehaviors, defaultView) => { - const lookup = { - individuals: 'single', - paged: 'book', - }; - return (window && window.view) - || lookup[manifestBehaviors.find(b => lookup[b]) || manifestViewingHint] - || defaultView; + (window, manifestViewingHint, manifestBehaviors, defaultView, viewConfig) => { + if (window && window.view) return window.view; + + const config = viewConfig.find(view => ( + view.behaviors + && view.behaviors.some(b => manifestViewingHint === b || manifestBehaviors.includes(b)) + )); + + return (config && config.key) || defaultView; }, ); +/** */ +export const getAllowedWindowViewTypes = createSelector( + [ + getManifestViewingHint, + getManifestBehaviors, + getDefaultView, + getViewConfigs, + ], + (manifestViewingHint, manifestBehaviors, defaultView, viewConfig) => ( + viewConfig.reduce((allowedViews, view) => { + if ( + view.key === defaultView + || !view.behaviors + || view.behaviors.some(b => ( + manifestViewingHint === b || manifestBehaviors.includes(b) + ))) allowedViews.push(view.key); + return allowedViews; + }, []) + ), +); + export const getViewer = createSelector( [ state => state.viewers,