From de17b59bb12c185c639d3cf3641e9c9e0e3b4ab3 Mon Sep 17 00:00:00 2001
From: Jack Reed <phillipjreed@gmail.com>
Date: Wed, 20 Feb 2019 11:22:42 -0700
Subject: [PATCH] Sets up ThumbnailNavigation for BookView

Fixes #1868 Fixes #1870

This PR introduces CanvasGroupings that can effectively determine
what/which canvases should be grouped together for display. This logic
can be used throughout Mirador.
---
 .../src/components/CanvasThumbnail.test.js    |  4 +-
 .../components/ThumbnailNavigation.test.js    | 66 ++++++++++++----
 __tests__/src/components/WindowViewer.test.js | 10 +++
 __tests__/src/lib/CanvasGroupings.test.js     | 56 +++++++++++++
 src/components/CanvasThumbnail.js             |  4 +-
 src/components/ThumbnailNavigation.js         | 78 ++++++++++++++-----
 src/components/WindowViewer.js                | 30 +++----
 src/containers/ThumbnailNavigation.js         |  5 +-
 src/lib/CanvasGroupings.js                    | 52 +++++++++++++
 9 files changed, 242 insertions(+), 63 deletions(-)
 create mode 100644 __tests__/src/lib/CanvasGroupings.test.js
 create mode 100644 src/lib/CanvasGroupings.js

diff --git a/__tests__/src/components/CanvasThumbnail.test.js b/__tests__/src/components/CanvasThumbnail.test.js
index b18d87ae3..afc3798f4 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 f493672e5..f479471c3 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 14932afdd..0d0e21f59 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 000000000..e9cc69125
--- /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 b8a97d13a..d60f95594 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 686f38fa4..3109bf651 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={i} // eslint-disable-line react/no-array-index-key
+              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/WindowViewer.js b/src/components/WindowViewer.js
index 4f8264cbc..b833a101f 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 0f7b5658f..d0e33828d 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 000000000..45438867b
--- /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;
+  }
+}
-- 
GitLab