diff --git a/__tests__/integration/mirador/thumbnail-navigation.test.js b/__tests__/integration/mirador/thumbnail-navigation.test.js index 1baaa01d0b4d9b2e0e6a8329a2f52052c7e19c8e..840559c3503819e50db0d58e332b8fdbdaaeef0c 100644 --- a/__tests__/integration/mirador/thumbnail-navigation.test.js +++ b/__tests__/integration/mirador/thumbnail-navigation.test.js @@ -12,7 +12,7 @@ describe('Thumbnail navigation', () => { )); expect(Object.values(windows)[0].canvasIndex).toBe(2); // test harness in index.html starts at 2 await page.waitFor(1000); - await expect(page).toClick('.mirador-thumbnail-nav-canvas-1'); + await expect(page).toClick('.mirador-thumbnail-nav-canvas-1 img'); await expect(page).toMatchElement('.mirador-thumbnail-nav-canvas-1.mirador-current-canvas', { timeout: 1500 }); windows = await page.evaluate(() => ( miradorInstance.store.getState().windows diff --git a/__tests__/src/components/CanvasThumbnail.test.js b/__tests__/src/components/CanvasThumbnail.test.js index b18d87ae3b2b51b69e2ba80226a0ad046239a9ff..afc3798f437e962d88a378765f20845352145434 100644 --- a/__tests__/src/components/CanvasThumbnail.test.js +++ b/__tests__/src/components/CanvasThumbnail.test.js @@ -16,11 +16,11 @@ describe('CanvasThumbnail', () => { it('renders properly', () => { expect(wrapper.matchesElement( - <div> + <> <IntersectionObserver onChange={wrapper.instance().handleIntersection}> <img alt="" /> </IntersectionObserver> - </div>, + </>, )).toBe(true); }); diff --git a/__tests__/src/components/ThumbnailNavigation.test.js b/__tests__/src/components/ThumbnailNavigation.test.js index f493672e5b48b5aff9ccbc18c96be9c0a69551a7..f479471c387e4184b4ffbd53137d970777a30253 100644 --- a/__tests__/src/components/ThumbnailNavigation.test.js +++ b/__tests__/src/components/ThumbnailNavigation.test.js @@ -3,8 +3,27 @@ import { shallow } from 'enzyme'; import Grid from 'react-virtualized/dist/commonjs/Grid'; import manifesto from 'manifesto.js'; import ThumbnailNavigation from '../../../src/components/ThumbnailNavigation'; +import CanvasGroupings from '../../../src/lib/CanvasGroupings'; import manifestJson from '../../fixtures/version-2/019.json'; +/** create wrapper */ +function createWrapper(props) { + return shallow( + <ThumbnailNavigation + canvasGroupings={ + new CanvasGroupings(manifesto.create(manifestJson).getSequences()[0].getCanvases()) + } + window={{ + id: 'foobar', + canvasIndex: 1, + thumbnailNavigationPosition: 'bottom', + }} + config={{ thumbnailNavigation: { height: 150 } }} + {...props} + />, + ); +} + describe('ThumbnailNavigation', () => { let wrapper; let setCanvas; @@ -17,20 +36,7 @@ describe('ThumbnailNavigation', () => { Grid.prototype._scrollingContainer = jest.fn( // eslint-disable-line no-underscore-dangle () => ({ scrollLeft: 0 }), ); - wrapper = shallow( - <ThumbnailNavigation - canvases={ - manifesto.create(manifestJson).getSequences()[0].getCanvases() - } - window={{ - id: 'foobar', - canvasIndex: 1, - thumbnailNavigationPosition: 'bottom', - }} - config={{ thumbnailNavigation: { height: 150 } }} - setCanvas={setCanvas} - />, - ); + wrapper = createWrapper({ setCanvas }); grid = wrapper.find('AutoSizer') .dive() .find('Grid'); @@ -39,7 +45,7 @@ describe('ThumbnailNavigation', () => { it('renders the component', () => { expect(wrapper.find('.mirador-thumb-navigation').length).toBe(1); }); - it('renders li elements based off of number of canvases', () => { + it('renders containers based off of number of canvases', () => { expect(renderedGrid.find('.mirador-thumbnail-nav-canvas').length).toBe(3); }); it('sets a mirador-current-canvas class on current canvas', () => { @@ -59,6 +65,34 @@ describe('ThumbnailNavigation', () => { it('Grid is set with expected props for scrolling alignment', () => { expect(grid.props().scrollToAlignment).toBe('center'); expect(grid.props().scrollToColumn).toBe(1); - expect(grid.props().columnIndex).toBe(1); + }); + it('has a ref set used to reset on view change', () => { + expect(wrapper.instance().gridRef).not.toBe(null); + }); + it('renders containers based off of canvas groupings ', () => { + wrapper = createWrapper({ + setCanvas, + canvasGroupings: new CanvasGroupings(manifesto.create(manifestJson).getSequences()[0].getCanvases(), 'book'), + }); + grid = wrapper.find('AutoSizer') + .dive() + .find('Grid'); + renderedGrid = grid.dive(); + expect(renderedGrid.find('.mirador-thumbnail-nav-canvas').length).toBe(2); + expect(renderedGrid.find('CanvasThumbnail').length).toBe(3); + expect(wrapper.instance().scrollToColumn()).toBe(1); + }); + it('triggers a recomputeGridSize on view change', () => { + const mockRecompute = jest.fn(); + wrapper.instance().gridRef = { current: { recomputeGridSize: mockRecompute } }; + wrapper.setProps({ + window: { + id: 'foobar', + canvasIndex: 1, + thumbnailNavigationPosition: 'bottom', + view: 'book', + }, + }); + expect(mockRecompute).toHaveBeenCalled(); }); }); diff --git a/__tests__/src/components/WindowViewer.test.js b/__tests__/src/components/WindowViewer.test.js index 14932afdd6fb191c99d6421d18ce7d7e0c5aae8b..0d0e21f59a45f7e7762dbc25b1fd0c436f0757b9 100644 --- a/__tests__/src/components/WindowViewer.test.js +++ b/__tests__/src/components/WindowViewer.test.js @@ -111,4 +111,14 @@ describe('WindowViewer', () => { }); }); }); + it('when view type changes', () => { + expect(wrapper.instance().canvasGroupings.groupings().length).toEqual(3); + wrapper.setProps({ + window: { + canvasIndex: 0, + view: 'book', + }, + }); + expect(wrapper.instance().canvasGroupings.groupings().length).toEqual(2); + }); }); diff --git a/__tests__/src/lib/CanvasGroupings.test.js b/__tests__/src/lib/CanvasGroupings.test.js new file mode 100644 index 0000000000000000000000000000000000000000..e9cc6912535b669c7ff19b1dd52ab8cda1e29037 --- /dev/null +++ b/__tests__/src/lib/CanvasGroupings.test.js @@ -0,0 +1,56 @@ +import CanvasGroupings from '../../../src/lib/CanvasGroupings'; + +describe('CanvasGroupings', () => { + describe('constructor', () => { + it('sets canvases and viewType', () => { + const subject = new CanvasGroupings([null, null], 'book'); + expect(subject.viewType).toEqual('book'); + expect(subject.canvases.length).toEqual(2); + }); + it('viewType default is single', () => { + const subject = new CanvasGroupings([null, null]); + expect(subject.viewType).toEqual('single'); + }); + }); + describe('groupings', () => { + describe('single', () => { + it('creates an array of arrays of the canvases', () => { + const subject = new CanvasGroupings([null, null, null, null]); + expect(subject.groupings().length).toEqual(4); + expect(subject.groupings()[0]).toEqual(expect.arrayContaining([null])); + }); + }); + describe('book', () => { + let subject; + beforeEach(() => { + subject = new CanvasGroupings([0, 1, 2, 3], 'book'); + }); + it('creates an array of groupings of the canvases', () => { + expect(subject.groupings().length).toEqual(3); + }); + it('first grouping has only 1 canvas', () => { + expect(subject.groupings()[0]).toEqual([0]); + }); + it('second grouping has 2 canvases', () => { + expect(subject.groupings()[1]).toEqual([1, 2]); + }); + }); + }); + describe('getCanvases', () => { + describe('single', () => { + it('selects by index', () => { + const subject = new CanvasGroupings([0, 1, 2, 3]); + expect(subject.getCanvases(2)).toEqual([2]); + }); + }); + describe('book', () => { + let subject; + beforeEach(() => { + subject = new CanvasGroupings([0, 1, 2, 3], 'book'); + }); + it('selects by index / 2', () => { + expect(subject.getCanvases(2)).toEqual([1, 2]); + }); + }); + }); +}); diff --git a/src/components/CanvasThumbnail.js b/src/components/CanvasThumbnail.js index b8a97d13ae96a521c5a4ee4ab25b0efde023ea2e..d60f95594be115934d99b9aac0222456c85294f6 100644 --- a/src/components/CanvasThumbnail.js +++ b/src/components/CanvasThumbnail.js @@ -40,7 +40,7 @@ export default class CanvasThumbnail extends Component { const { loaded, image } = this.state; const imgStyle = { height, width: '100%', ...style }; return ( - <div> + <> <IntersectionObserver onChange={this.handleIntersection}> <img alt="" @@ -51,7 +51,7 @@ export default class CanvasThumbnail extends Component { style={imgStyle} /> </IntersectionObserver> - </div> + </> ); } } diff --git a/src/components/ThumbnailNavigation.js b/src/components/ThumbnailNavigation.js index 686f38fa4c9c10981e0f05b4b5933204ec73f28f..468d8da8114bbb3b7e481fc39a6a04d2e4a9661c 100644 --- a/src/components/ThumbnailNavigation.js +++ b/src/components/ThumbnailNavigation.js @@ -17,21 +17,33 @@ class ThumbnailNavigation extends Component { this.cellRenderer = this.cellRenderer.bind(this); this.calculateScaledWidth = this.calculateScaledWidth.bind(this); + this.gridRef = React.createRef(); + } + + /** + * If the view has changed and the thumbnailNavigation is open, recompute all + * of the grids + */ + componentDidUpdate(prevProps) { + const { window } = this.props; + if (prevProps.window.view !== window.view && window.thumbnailNavigationPosition !== 'off') { + this.gridRef.current.recomputeGridSize(); + } } /** * Determines whether the current index is the rendered canvas, providing * a useful class. */ - currentCanvasClass(canvasIndex) { + currentCanvasClass(canvasIndices) { const { window } = this.props; - if (window.canvasIndex === canvasIndex) return 'current-canvas'; + if (canvasIndices.includes(window.canvasIndex)) return 'current-canvas'; return ''; } /** - * Renders a given "cell" for a react-virtualized Grid. Right now this is a - * "canvas" but in the future for paged items, would be connected canvases. + * Renders a given "cell" for a react-virtualized Grid. This is a grouping of + * canvases. * https://github.com/bvaughn/react-virtualized/blob/master/docs/Grid.md */ cellRenderer(options) { @@ -39,9 +51,9 @@ class ThumbnailNavigation extends Component { columnIndex, key, style, } = options; const { - window, setCanvas, config, canvases, + window, setCanvas, config, canvasGroupings, } = this.props; - const canvas = canvases[columnIndex]; + const currentGroupings = canvasGroupings.groupings()[columnIndex]; return ( <div key={key} @@ -52,13 +64,20 @@ class ThumbnailNavigation extends Component { style={{ width: style.width - 8, }} - className={ns(['thumbnail-nav-canvas', `thumbnail-nav-canvas-${canvas.index}`, this.currentCanvasClass(canvas.index)])} + className={ns(['thumbnail-nav-canvas', `thumbnail-nav-canvas-${columnIndex}`, this.currentCanvasClass(currentGroupings.map(canvas => canvas.index))])} > - <CanvasThumbnail - onClick={() => setCanvas(window.id, canvas.index)} - imageUrl={new ManifestoCanvas(canvas).thumbnail(config.thumbnailNavigation.height)} - height={config.thumbnailNavigation.height} - /> + {currentGroupings.map((canvas, i) => ( + <div + key={canvas.index} + style={{ position: 'absolute', left: (style.width - 8) * i / 2, top: 2 }} + > + <CanvasThumbnail + onClick={() => setCanvas(window.id, currentGroupings[0].index)} + imageUrl={new ManifestoCanvas(canvas).thumbnail(config.thumbnailNavigation.height)} + height={config.thumbnailNavigation.height} + /> + </div> + ))} </div> </div> ); @@ -66,19 +85,36 @@ class ThumbnailNavigation extends Component { /** * calculateScaledWidth - calculates the scaled width of a column for a Grid - * in this simple case, a column == canvas. + * in a simple case, a column == canvas. In a book view, a group (or two) + * canvases */ calculateScaledWidth(options) { - const { config, canvases } = this.props; - const canvas = new ManifestoCanvas(canvases[options.index]); - return Math.floor(config.thumbnailNavigation.height * canvas.aspectRatio) + 8; + const { config, canvasGroupings } = this.props; + return canvasGroupings + .getCanvases(options.index) + .map(canvas => new ManifestoCanvas(canvas).aspectRatio) + .reduce((acc, current) => acc + Math.floor(config.thumbnailNavigation.height * current), 8); + } + + /** + * In book view, this is halved to represent the proxy between the "canvasIndex" + * and the columnIndex (in this case the index of grouped canvases) + */ + scrollToColumn() { + const { window } = this.props; + switch (window.view) { + case 'book': + return Math.ceil(window.canvasIndex / 2); + default: + return window.canvasIndex; + } } /** * Renders things */ render() { - const { config, window, canvases } = this.props; + const { config, window, canvasGroupings } = this.props; if (window.thumbnailNavigationPosition === 'off') { return <></>; } @@ -94,15 +130,15 @@ class ThumbnailNavigation extends Component { {({ height, width }) => ( <Grid cellRenderer={this.cellRenderer} - columnCount={canvases.length} - columnIndex={window.canvasIndex} + columnCount={canvasGroupings.groupings().length} columnWidth={this.calculateScaledWidth} height={config.thumbnailNavigation.height} rowCount={1} rowHeight={config.thumbnailNavigation.height} scrollToAlignment="center" - scrollToColumn={window.canvasIndex} + scrollToColumn={this.scrollToColumn()} width={width} + ref={this.gridRef} /> )} </AutoSizer> @@ -113,7 +149,7 @@ class ThumbnailNavigation extends Component { ThumbnailNavigation.propTypes = { config: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types - canvases: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types + canvasGroupings: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types setCanvas: PropTypes.func.isRequired, window: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types }; diff --git a/src/components/WindowSideBarCanvasPanel.js b/src/components/WindowSideBarCanvasPanel.js index 2e752032eef19ead37dcef8b4aa16264bb090d81..e0e241f5c9a009d7b37039675fa7ec5b0b6f1288 100644 --- a/src/components/WindowSideBarCanvasPanel.js +++ b/src/components/WindowSideBarCanvasPanel.js @@ -44,17 +44,19 @@ class WindowSideBarCanvasPanel extends Component { <ListItem key={canvas.id} > - <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', - }} - /> + <div> + <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', + }} + /> + </div> <Typography className={classNames(classes.clickable, classes.label)} onClick={onClick} diff --git a/src/components/WindowViewer.js b/src/components/WindowViewer.js index 4f8264cbcef8501166a5ae4e937b8cfc1b6c27a7..b833a101f80a7816e4c388f049388036a91b07a8 100644 --- a/src/components/WindowViewer.js +++ b/src/components/WindowViewer.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import OSDViewer from '../containers/OpenSeadragonViewer'; import ViewerNavigation from '../containers/ViewerNavigation'; import ManifestoCanvas from '../lib/ManifestoCanvas'; +import CanvasGroupings from '../lib/CanvasGroupings'; /** * Represents a WindowViewer in the mirador workspace. Responsible for mounting @@ -15,8 +16,9 @@ class WindowViewer extends Component { constructor(props) { super(props); - const { manifest } = this.props; + const { manifest, window } = this.props; this.canvases = manifest.manifestation.getSequences()[0].getCanvases(); + this.canvasGroupings = new CanvasGroupings(this.canvases, window.view); } /** @@ -46,6 +48,10 @@ class WindowViewer extends Component { fetchInfoResponse(new ManifestoCanvas(canvas).imageInformationUri); }); } + // If the view changes, create a new instance + if (prevProps.window.view !== window.view) { + this.canvasGroupings = new CanvasGroupings(this.canvases, window.view); + } } /** @@ -62,28 +68,12 @@ class WindowViewer extends Component { } /** - * Figures out how many and what canvases to present to a user based off of - * the view, number of canvases, and canvasIndex. + * Uses CanvasGroupings to figure out how many and what canvases to present to + * a user based off of the view, number of canvases, and canvasIndex. */ currentCanvases() { const { window } = this.props; - switch (window.view) { - case 'book': - if ( // FIXME: There is probably better logic floating around out there to determine this. - this.canvases.length > 0 // when there are canvases present - && window.canvasIndex !== 0 // when the first canvas is not selected - && window.canvasIndex + 1 !== this.canvases.length // when the last canvas is not selected - ) { - // For an even canvas - if (window.canvasIndex % 2 === 0) { - return [this.canvases[window.canvasIndex - 1], this.canvases[window.canvasIndex]]; - } - return [this.canvases[window.canvasIndex], this.canvases[window.canvasIndex + 1]]; - } - return [this.canvases[window.canvasIndex]]; - default: - return [this.canvases[window.canvasIndex]]; - } + return this.canvasGroupings.getCanvases(window.canvasIndex); } /** diff --git a/src/containers/ThumbnailNavigation.js b/src/containers/ThumbnailNavigation.js index 0f7b5658f41d6a193347fe3cc898b18d54668eb4..d0e33828d6f268dffd4363b66835f0df73212bff 100644 --- a/src/containers/ThumbnailNavigation.js +++ b/src/containers/ThumbnailNavigation.js @@ -1,6 +1,7 @@ import { compose } from 'redux'; import { connect } from 'react-redux'; import miradorWithPlugins from '../lib/miradorWithPlugins'; +import CanvasGroupings from '../lib/CanvasGroupings'; import * as actions from '../state/actions'; import ThumbnailNavigation from '../components/ThumbnailNavigation'; import { getManifestCanvases } from '../state/selectors'; @@ -9,8 +10,8 @@ import { getManifestCanvases } from '../state/selectors'; * @memberof ThumbnailNavigation * @private */ -const mapStateToProps = ({ config }, { manifest }) => ({ - canvases: getManifestCanvases(manifest), +const mapStateToProps = ({ config }, { manifest, window }) => ({ + canvasGroupings: new CanvasGroupings(getManifestCanvases(manifest), window.view), config, }); diff --git a/src/lib/CanvasGroupings.js b/src/lib/CanvasGroupings.js new file mode 100644 index 0000000000000000000000000000000000000000..45438867bcc4d1f569808f5a22322a16d3346329 --- /dev/null +++ b/src/lib/CanvasGroupings.js @@ -0,0 +1,52 @@ +/** + * + */ +export default class CanvasGroupings { + /** + */ + constructor(canvases, viewType = 'single') { + this.canvases = canvases; + this.viewType = viewType; + this._groupings = null; // eslint-disable-line no-underscore-dangle + } + + /** + */ + getCanvases(index) { + switch (this.viewType) { + case 'book': + return this.groupings()[Math.ceil(index / 2)]; + default: + return this.groupings()[index]; + } + } + + /** + * Groups a set of canvases based on the view type. Single, is just an array + * of canvases, while book view creates pairs. + */ + groupings() { + if (this._groupings) { // eslint-disable-line no-underscore-dangle + return this._groupings; // eslint-disable-line no-underscore-dangle + } + if (this.viewType === 'single') { + return this.canvases.map(canvas => [canvas]); + } + const groupings = []; + this.canvases.forEach((canvas, i) => { + if (i === 0) { + groupings.push([canvas]); + return; + } + // Odd page + if (i % 2 !== 0) { + groupings.push([canvas]); + } else { + // Even page + groupings[Math.ceil(i / 2)].push(canvas); + } + }); + this._groupings = groupings; // eslint-disable-line no-underscore-dangle + return groupings; + } +}