diff --git a/__tests__/src/components/CanvasThumbnail.test.js b/__tests__/src/components/CanvasThumbnail.test.js index f3fa0904adba3805658180cf95f5ec4df2130b45..9590dd3fa66ed87cdb55da6850c68cbada1f5a97 100644 --- a/__tests__/src/components/CanvasThumbnail.test.js +++ b/__tests__/src/components/CanvasThumbnail.test.js @@ -3,15 +3,22 @@ import { shallow } from 'enzyme'; import IntersectionObserver from '@researchgate/react-intersection-observer'; import { CanvasThumbnail } from '../../../src/components/CanvasThumbnail'; +/** + * Helper function to create a shallow wrapper around CanvasThumbnail + */ +function createWrapper(props) { + return shallow( + <CanvasThumbnail + imageUrl="https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/full/193,/0/default.jpg" + {...props} + />, + ); +} + describe('CanvasThumbnail', () => { let wrapper; beforeEach(() => { - wrapper = shallow( - <CanvasThumbnail - imageUrl="https://stacks.stanford.edu/image/iiif/sn904cj3429%2F12027000/full/193,/0/default.jpg" - onClick={() => {}} - />, - ); + wrapper = createWrapper(); }); it('renders properly', () => { @@ -32,4 +39,30 @@ describe('CanvasThumbnail', () => { wrapper.instance().handleIntersection({ isIntersecting: true }); expect(wrapper.find('img').props().src).toMatch(/stacks/); }); + + it('can be constrained by maxHeight', () => { + wrapper = createWrapper({ maxHeight: 500 }); + + expect(wrapper.find('img').props().style).toMatchObject({ height: 500, width: 'auto' }); + }); + + it('can be constrained by maxWidth', () => { + wrapper = createWrapper({ maxWidth: 500 }); + + expect(wrapper.find('img').props().style).toMatchObject({ height: 'auto', width: 500 }); + }); + + it('can be constrained by maxWidth and maxHeight', () => { + wrapper = createWrapper({ maxHeight: 400, maxWidth: 500 }); + + expect(wrapper.find('img').props().style).toMatchObject({ height: 400, width: 500 }); + }); + + it('can be constrained by maxWidth and maxHeight and a desired aspect ratio', () => { + wrapper = createWrapper({ maxHeight: 400, maxWidth: 500, aspectRatio: 2 }); + expect(wrapper.find('img').props().style).toMatchObject({ height: 250, width: 500 }); + + wrapper = createWrapper({ maxHeight: 400, maxWidth: 500, aspectRatio: 1 }); + expect(wrapper.find('img').props().style).toMatchObject({ height: 400, width: 400 }); + }); }); diff --git a/__tests__/src/components/ThumbnailNavigation.test.js b/__tests__/src/components/ThumbnailNavigation.test.js index 133465a26840f422cbc32593703b37b4e72e1949..19ca09079c501dde70a1f51d1fbd0d6f90adb66c 100644 --- a/__tests__/src/components/ThumbnailNavigation.test.js +++ b/__tests__/src/components/ThumbnailNavigation.test.js @@ -52,7 +52,7 @@ describe('ThumbnailNavigation', () => { expect(wrapper.find('.mirador-thumbnail-nav-canvas-1.mirador-current-canvas')); }); it('when clicked, updates the current canvas', () => { - renderedGrid.find('.mirador-thumbnail-nav-canvas-0 CanvasThumbnail').simulate('click'); + renderedGrid.find('.mirador-thumbnail-nav-canvas-0 WithStyles(GridListTile)').simulate('click'); expect(setCanvas).toHaveBeenCalledWith('foobar', 0); }); it('sets up calculated width based off of height of area and dimensions of canvas', () => { diff --git a/__tests__/src/components/WindowSideBarCanvasPanel.test.js b/__tests__/src/components/WindowSideBarCanvasPanel.test.js index 14bbf84443cf53ab80b467971eee0b4537565fae..a5937e1c9e6df98d804722df62c372b1c7780d50 100644 --- a/__tests__/src/components/WindowSideBarCanvasPanel.test.js +++ b/__tests__/src/components/WindowSideBarCanvasPanel.test.js @@ -4,7 +4,6 @@ import List from '@material-ui/core/List'; import ListItem from '@material-ui/core/ListItem'; import Typography from '@material-ui/core/Typography'; import manifesto from 'manifesto.js'; -import { CanvasThumbnail } from '../../../src/components/CanvasThumbnail'; import { WindowSideBarCanvasPanel } from '../../../src/components/WindowSideBarCanvasPanel'; import manifestJson from '../../fixtures/version-2/019.json'; import { getIdAndLabelOfCanvases } from '../../../src/state/selectors'; @@ -54,13 +53,8 @@ describe('WindowSideBarCanvasPanel', () => { .text()).toBe(idsAndLabels[1].label); }); - it('should call the onClick handler of a navigation item\'s label', () => { - wrapper.find(Typography).at(1).simulate('click'); - expect(setCanvas).toHaveBeenCalledTimes(1); - }); - - it('should call the onClick handler of a navigation item\'s thumbnail', () => { - wrapper.find(CanvasThumbnail).at(0).simulate('click'); + it('should call the onClick handler of a list item', () => { + wrapper.find(ListItem).at(1).simulate('click'); expect(setCanvas).toHaveBeenCalledTimes(1); }); }); diff --git a/__tests__/src/lib/ManifestoCanvas.test.js b/__tests__/src/lib/ManifestoCanvas.test.js index 7a3c8db79d83cac6371700a15b1290bf9ab686a7..ef43ad1c765850821ea4f25bea4eff436a64b91a 100644 --- a/__tests__/src/lib/ManifestoCanvas.test.js +++ b/__tests__/src/lib/ManifestoCanvas.test.js @@ -43,14 +43,28 @@ describe('ManifestoCanvas', () => { }); }); describe('thumbnail', () => { - it('calculates a thumbnail image API request based off of height', () => { + it('calculates a thumbnail image API request based off of width, height and aspect ratio', () => { + expect(instance.thumbnail(100, 100)).toEqual( + 'https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44/full/,100/0/default.jpg', + ); + + expect(instance.thumbnail(100, 1000)).toEqual( + 'https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44/full/100,/0/default.jpg', + ); + }); + it('calculates a thumbnail image API request based off of width', () => { expect(instance.thumbnail(100)).toEqual( - 'https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44/full/66,/0/default.jpg', + 'https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44/full/100,/0/default.jpg', + ); + }); + it('calculates a thumbnail image API request based off of height', () => { + expect(instance.thumbnail(null, 100)).toEqual( + 'https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44/full/,100/0/default.jpg', ); }); it('defaults to using 150 as a height', () => { expect(instance.thumbnail()).toEqual( - 'https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44/full/100,/0/default.jpg', + 'https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44/full/,150/0/default.jpg', ); }); diff --git a/src/components/CanvasThumbnail.js b/src/components/CanvasThumbnail.js index 87a03bbc15557e3b202903a2131371f2e3496a94..9e1fd254928ac7b106745dcdff9235d20b5fefda 100644 --- a/src/components/CanvasThumbnail.js +++ b/src/components/CanvasThumbnail.js @@ -45,16 +45,63 @@ export class CanvasThumbnail extends Component { return CanvasThumbnail.defaultImgPlaceholder; } + /** */ + imageConstraints() { + const { + maxHeight, maxWidth, aspectRatio, + } = this.props; + + if (maxHeight && maxWidth && aspectRatio) return 'sizeByConfinedWh'; + if (maxHeight && maxWidth) return 'sizeByDistortedWh'; + if (maxHeight && !maxWidth) return 'sizeByH'; + if (!maxHeight && maxWidth) return 'sizeByW'; + + return undefined; + } + /** * */ imageStyles() { - const { height, style } = this.props; - const { image } = this.state; + const { + maxHeight, maxWidth, aspectRatio, style, + } = this.props; + + let height; + let width; + + switch (this.imageConstraints()) { + case 'sizeByConfinedWh': + // size to width + if ((maxWidth / maxHeight) < aspectRatio) { + height = maxWidth / aspectRatio; + width = maxWidth; + } else { + height = maxHeight; + width = maxHeight * aspectRatio; + } + + break; + case 'sizeByDistortedWh': + height = maxHeight; + width = maxWidth; + break; + case 'sizeByH': + height = maxHeight; + width = 'auto'; + break; + case 'sizeByW': + height = 'auto'; + width = maxWidth; + break; + default: + height = 'auto'; + width = 'auto'; + } return { height, - width: (image && image.src) ? '100%' : '110px', + width, ...style, }; } @@ -62,14 +109,11 @@ export class CanvasThumbnail extends Component { /** */ render() { - const { onClick } = this.props; return ( <> <IntersectionObserver onChange={this.handleIntersection}> <img alt="" - onClick={onClick} - onKeyPress={onClick} role="presentation" src={this.imageSrc()} style={this.imageStyles()} @@ -86,14 +130,17 @@ CanvasThumbnail.defaultImgPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANS CanvasThumbnail.propTypes = { imageUrl: PropTypes.string, isValid: PropTypes.bool, - height: PropTypes.number, - onClick: PropTypes.func.isRequired, + maxHeight: PropTypes.number, + maxWidth: PropTypes.number, + aspectRatio: PropTypes.number, style: PropTypes.object, // eslint-disable-line react/forbid-prop-types, }; CanvasThumbnail.defaultProps = { imageUrl: null, isValid: true, - height: 150, + maxHeight: null, + maxWidth: null, + aspectRatio: null, style: {}, }; diff --git a/src/components/ThumbnailNavigation.js b/src/components/ThumbnailNavigation.js index b79404d53926f62588a9ac732151838e2867959b..33674bdcde9cbd4d714da4b6963816052f2250e5 100644 --- a/src/components/ThumbnailNavigation.js +++ b/src/components/ThumbnailNavigation.js @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import AutoSizer from 'react-virtualized/dist/commonjs/AutoSizer'; import Grid from 'react-virtualized/dist/commonjs/Grid'; +import GridListTile from '@material-ui/core/GridListTile'; import { CanvasThumbnail } from './CanvasThumbnail'; import ManifestoCanvas from '../lib/ManifestoCanvas'; import ns from '../config/css-ns'; @@ -66,18 +67,28 @@ export class ThumbnailNavigation extends Component { }} className={ns(['thumbnail-nav-canvas', `thumbnail-nav-canvas-${columnIndex}`, this.currentCanvasClass(currentGroupings.map(canvas => canvas.index))])} > - {currentGroupings.map((canvas, i) => ( - <div - key={canvas.index} - style={{ position: 'absolute', left: (style.width - 8) * i / 2, top: 2 }} - > - <CanvasThumbnail + {currentGroupings.map((canvas, i) => { + const { height } = config.thumbnailNavigation; + const manifestoCanvas = new ManifestoCanvas(canvas); + + return ( + <GridListTile + component="div" + key={canvas.index} onClick={() => setCanvas(window.id, currentGroupings[0].index)} - imageUrl={new ManifestoCanvas(canvas).thumbnail(config.thumbnailNavigation.height)} - height={config.thumbnailNavigation.height} - /> - </div> - ))} + style={{ + position: 'absolute', left: (style.width - 8) * i / 2, top: 2, + }} + > + <CanvasThumbnail + imageUrl={manifestoCanvas.thumbnail(null, height)} + maxHeight={config.thumbnailNavigation.height} + maxWidth={style.width} + aspectRatio={manifestoCanvas.aspectRatio} + /> + </GridListTile> + ); + })} </div> </div> ); diff --git a/src/components/ValidationCanvas.js b/src/components/ValidationCanvas.js deleted file mode 100644 index dc3ed584ef790770d9f7887f516fa0abddad129c..0000000000000000000000000000000000000000 --- a/src/components/ValidationCanvas.js +++ /dev/null @@ -1,35 +0,0 @@ -import ManifestoCanvas from '../lib/ManifestoCanvas'; - -/** - */ -export class ValidationCanvas extends ManifestoCanvas { - /** - * checks whether the canvas has a valid height - */ - get hasValidHeight() { - return ( - typeof this.canvas.getHeight() === 'number' - && this.canvas.getHeight() > 0 - ); - } - - /** - * checks whether the canvas has a valid height - */ - get hasValidWidth() { - return ( - typeof this.canvas.getHeight() === 'number' - && this.canvas.getHeight() > 0 - ); - } - - /** - * checks whether the canvas has valid dimensions - */ - get hasValidDimensions() { - return ( - this.hasValidHeight - && this.hasValidWidth - ); - } -} diff --git a/src/components/WindowSideBarCanvasPanel.js b/src/components/WindowSideBarCanvasPanel.js index ed05c9d4a2b2ffe6c1820088d08d318dd70d51b0..f97cb053655e1deab8649869a59d0b57c2e3f103 100644 --- a/src/components/WindowSideBarCanvasPanel.js +++ b/src/components/WindowSideBarCanvasPanel.js @@ -5,20 +5,13 @@ import Typography from '@material-ui/core/Typography'; import List from '@material-ui/core/List'; import ListItem from '@material-ui/core/ListItem'; import { CanvasThumbnail } from './CanvasThumbnail'; -import { ValidationCanvas } from './ValidationCanvas'; +import ManifestoCanvas from '../lib/ManifestoCanvas'; import { getIdAndLabelOfCanvases } from '../state/selectors'; /** * a panel showing the canvases for a given manifest */ export class WindowSideBarCanvasPanel extends Component { - /** - * calculateScaledWidth - calculates the scaled width according to the given width and aspectRatio - */ - static calculateScaledWidth(height, aspectRatio) { - return Math.floor(height * aspectRatio); - } - /** * render */ @@ -35,32 +28,31 @@ export class WindowSideBarCanvasPanel extends Component { <List> { canvasesIdAndLabel.map((canvas, canvasIndex) => { - const validationCanvas = new ValidationCanvas(canvases[canvasIndex]); - const isValid = validationCanvas.hasValidDimensions; + const { width, height } = config.canvasNavigation; + const manifestoCanvas = new ManifestoCanvas(canvases[canvasIndex]); + const isValid = manifestoCanvas.hasValidDimensions; const onClick = () => { setCanvas(windowId, canvasIndex); }; // eslint-disable-line require-jsdoc, max-len return ( <ListItem key={canvas.id} + alignItems="flex-start" + onClick={onClick} + button > - <div> + <div style={{ minWidth: 50 }}> <CanvasThumbnail className={classNames(classes.clickable)} isValid={isValid} - imageUrl={validationCanvas.thumbnail(config.canvasNavigation.height)} - onClick={onClick} - style={{ - cursor: 'pointer', - height: config.canvasNavigation.height, - width: isValid ? WindowSideBarCanvasPanel.calculateScaledWidth(config.canvasNavigation.height, validationCanvas.aspectRatio) : 'auto', - }} + imageUrl={manifestoCanvas.thumbnail(width, height)} + maxHeight={config.canvasNavigation.height} + maxWidth={config.canvasNavigation.width} + aspectRatio={manifestoCanvas.aspectRatio} /> </div> <Typography - className={classNames(classes.clickable, classes.label)} - onClick={onClick} + className={classNames(classes.label)} variant="body2" - color="secondary" > {canvas.label} </Typography> diff --git a/src/components/index.js b/src/components/index.js index e624b5872b0f962af411342fe7ee3d2ecc50a4cb..72c49d7c4f66d2a1cb5f034b3f017f86e117c5b1 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -10,7 +10,6 @@ export * from './NestedMenu'; export * from './OpenSeadragonViewer'; export * from './SanitizedHtml'; export * from './ThumbnailNavigation'; -export * from './ValidationCanvas'; export * from './ViewerNavigation'; export * from './Window'; export * from './WindowList'; diff --git a/src/config/settings.js b/src/config/settings.js index 24e7359328bd1a68dd5fc4d0b3d00a0d75fa6935..5cb87c448822c6eaa14ce75caa9c31eb786ae7a1 100644 --- a/src/config/settings.js +++ b/src/config/settings.js @@ -1,6 +1,7 @@ export default { canvasNavigation: { - height: 100, + height: 50, + width: 50, }, theme: { // Sets up a MaterialUI theme. See https://material-ui.com/customization/default-theme/ palette: { diff --git a/src/containers/WindowSideBarCanvasPanel.js b/src/containers/WindowSideBarCanvasPanel.js index 7c821cc77aa2795f38a95c2cc7c21f82b313fc53..27432454fdceb75435187354de7eb35117fc377d 100644 --- a/src/containers/WindowSideBarCanvasPanel.js +++ b/src/containers/WindowSideBarCanvasPanel.js @@ -27,17 +27,12 @@ const mapDispatchToProps = { setCanvas: actions.setCanvas }; /** * * @param theme - * @returns {{clickable: {cursor: string}, - * label: {fontSize: string, paddingLeft: number}, windowSideBarH2: *}} + * @returns {label: {paddingLeft: number}, windowSideBarH2: *}} */ const styles = theme => ({ windowSideBarH2: theme.typography.h5, - clickable: { - cursor: 'pointer', - }, label: { - fontSize: '8pt', - paddingLeft: 8, + paddingLeft: theme.spacing.unit, }, }); diff --git a/src/lib/ManifestoCanvas.js b/src/lib/ManifestoCanvas.js index 7952804d241b32b22dc11975ef9566b1c3ba56bd..885e080dfc3ed1ccae09f3c1a8258776d98ef51b 100644 --- a/src/lib/ManifestoCanvas.js +++ b/src/lib/ManifestoCanvas.js @@ -43,13 +43,70 @@ export default class ManifestoCanvas { * Creates a canonical image request for a thumb * @param {Number} height */ - thumbnail(height = 150) { - const width = Math.floor(height * this.aspectRatio); + thumbnail(maxWidth = undefined, maxHeight = undefined) { + let width; + let height; if (!this.imageInformationUri) { return undefined; } - return this.canonicalImageUri.replace(/\/full\/.*\/0\//, `/full/${width},/0/`); + switch (this.thumbnailConstraints(maxWidth, maxHeight)) { + case 'sizeByH': + height = maxHeight; + break; + case 'sizeByW': + width = maxWidth; + break; + default: + height = '150'; + } + + // note that, although the IIIF server may support sizeByConfinedWh (e.g. !w,h) + // this is a IIIF level 2 feature, so we're instead providing w, or h,-style requests + // which are only level 1. + return this.canonicalImageUri.replace(/\/full\/.*\/0\//, `/full/${width || ''},${height || ''}/0/`); + } + + /** @private */ + thumbnailConstraints(maxWidth, maxHeight) { + if (!maxHeight && !maxWidth) return undefined; + if (maxHeight && !maxWidth) return 'sizeByH'; + if (!maxHeight && maxWidth) return 'sizeByW'; + + const { aspectRatio } = this; + const desiredAspectRatio = maxWidth / maxHeight; + + return desiredAspectRatio < aspectRatio ? 'sizeByW' : 'sizeByH'; + } + + /** + * checks whether the canvas has a valid height + */ + get hasValidHeight() { + return ( + typeof this.canvas.getHeight() === 'number' + && this.canvas.getHeight() > 0 + ); + } + + /** + * checks whether the canvas has a valid height + */ + get hasValidWidth() { + return ( + typeof this.canvas.getHeight() === 'number' + && this.canvas.getHeight() > 0 + ); + } + + /** + * checks whether the canvas has valid dimensions + */ + get hasValidDimensions() { + return ( + this.hasValidHeight + && this.hasValidWidth + ); } }