diff --git a/src/lib/ThumbnailsSidebar.js b/src/lib/ThumbnailsSidebar.js index 9d02a3c14..7ddf32d7b 100644 --- a/src/lib/ThumbnailsSidebar.js +++ b/src/lib/ThumbnailsSidebar.js @@ -1,5 +1,6 @@ import isFinite from 'lodash/isFinite'; import VirtualScroller from './VirtualScroller'; +import { CLASS_HIDDEN } from './constants'; const CLASS_BOX_PREVIEW_THUMBNAIL = 'bp-thumbnail'; const CLASS_BOX_PREVIEW_THUMBNAIL_IMAGE = 'bp-thumbnail-image'; @@ -137,6 +138,7 @@ class ThumbnailsSidebar { const scaledViewport = page.getViewport(this.scale); this.virtualScroller.init({ + initialRowIndex: this.currentPage - 1, totalItems: this.pdfViewer.pagesCount, itemHeight: scaledViewport.height, containerHeight: this.anchorEl.parentNode.clientHeight, @@ -342,6 +344,7 @@ class ThumbnailsSidebar { if (parsedPageNumber >= 1 && parsedPageNumber <= this.pdfViewer.pagesCount) { this.currentPage = parsedPageNumber; this.applyCurrentPageSelection(); + this.virtualScroller.scrollIntoView(parsedPageNumber - 1); } } @@ -361,6 +364,56 @@ class ThumbnailsSidebar { } }); } + + /** + * Toggles the thumbnails sidebar + * @return {void} + */ + toggle() { + if (!this.anchorEl) { + return; + } + + if (!this.isOpen()) { + this.toggleOpen(); + } else { + this.toggleClose(); + } + } + + /** + * Returns whether the sidebar is open or not + * @return {boolean} true if the sidebar is open, false if not + */ + isOpen() { + return this.anchorEl && !this.anchorEl.classList.contains(CLASS_HIDDEN); + } + + /** + * Toggles the sidebar open. This will scroll the current page into view + * @return {void} + */ + toggleOpen() { + if (!this.anchorEl) { + return; + } + + this.anchorEl.classList.remove(CLASS_HIDDEN); + + this.virtualScroller.scrollIntoView(this.currentPage - 1); + } + + /** + * Toggles the sidebar closed + * @return {void} + */ + toggleClose() { + if (!this.anchorEl) { + return; + } + + this.anchorEl.classList.add(CLASS_HIDDEN); + } } export default ThumbnailsSidebar; diff --git a/src/lib/VirtualScroller.js b/src/lib/VirtualScroller.js index 5b152765b..60a67b013 100644 --- a/src/lib/VirtualScroller.js +++ b/src/lib/VirtualScroller.js @@ -56,10 +56,12 @@ class VirtualScroller { this.previousScrollTop = 0; this.createListElement = this.createListElement.bind(this); + this.isVisible = this.isVisible.bind(this); this.onScrollEndHandler = this.onScrollEndHandler.bind(this); this.onScrollHandler = this.onScrollHandler.bind(this); this.getCurrentListInfo = this.getCurrentListInfo.bind(this); this.renderItems = this.renderItems.bind(this); + this.scrollIntoView = this.scrollIntoView.bind(this); this.debouncedOnScrollEndHandler = debounce(this.onScrollEndHandler, DEBOUNCE_SCROLL_THRESHOLD); this.throttledOnScrollHandler = throttle(this.onScrollHandler, THROTTLE_SCROLL_THRESHOLD); @@ -110,7 +112,8 @@ class VirtualScroller { this.scrollingEl.appendChild(this.listEl); this.anchorEl.appendChild(this.scrollingEl); - this.renderItems(); + // If initialRowIndex is < the first window into the list, then just render from the first item + this.renderItems(config.initialRowIndex < this.maxRenderedItems ? 0 : config.initialRowIndex); this.bindDOMListeners(); @@ -245,7 +248,10 @@ class VirtualScroller { return; } - let newStartOffset = offset; + // If specified offset is in the last window into the list then + // render that last window instead of starting at that offset + const lastWindowOffset = this.totalItems - this.maxRenderedItems; + let newStartOffset = offset > lastWindowOffset ? lastWindowOffset : offset; let newEndOffset = offset + this.maxRenderedItems; // If the default count of items to render exceeds the totalItems count // then just render up to the end @@ -372,6 +378,53 @@ class VirtualScroller { newListEl.style.height = `${this.totalItems * (this.itemHeight + this.margin) + this.margin}px`; return newListEl; } + + /** + * Scrolls the provided row index into view. + * @param {number} rowIndex - the index of the row in the overall list + * @return {void} + */ + scrollIntoView(rowIndex) { + if (!this.scrollingEl || rowIndex < 0 || rowIndex >= this.totalItems) { + return; + } + + // See if the list item indexed by `rowIndex` is already present + const foundItem = Array.prototype.slice.call(this.listEl.children).find((listItem) => { + const { bpVsRowIndex } = listItem.dataset; + const parsedRowIndex = parseInt(bpVsRowIndex, 10); + return parsedRowIndex === rowIndex; + }); + + if (foundItem) { + // If it is already present and visible, do nothing, but if not visible + // then scroll it into view + if (!this.isVisible(foundItem)) { + foundItem.scrollIntoView(); + } + } else { + // If it is not present, then adjust the scrollTop so that the list item + // will get rendered. + const topPosition = (this.itemHeight + this.margin) * rowIndex; + this.scrollingEl.scrollTop = topPosition; + } + } + + /** + * Checks to see whether the provided list item element is currently visible + * @param {HTMLElement} listItemEl - the list item elment + * @return {boolean} Returns true if the list item is visible, false otherwise + */ + isVisible(listItemEl) { + if (!this.scrollingEl || !listItemEl) { + return false; + } + + const { scrollTop } = this.scrollingEl; + const { offsetTop } = listItemEl; + + return scrollTop <= offsetTop && offsetTop <= scrollTop + this.containerHeight; + } } export default VirtualScroller; diff --git a/src/lib/__tests__/ThumbnailsSidebar-test.js b/src/lib/__tests__/ThumbnailsSidebar-test.js index c7535643c..7c464a413 100644 --- a/src/lib/__tests__/ThumbnailsSidebar-test.js +++ b/src/lib/__tests__/ThumbnailsSidebar-test.js @@ -1,6 +1,7 @@ /* eslint-disable no-unused-expressions */ import ThumbnailsSidebar from '../ThumbnailsSidebar'; import VirtualScroller from '../VirtualScroller'; +import { CLASS_HIDDEN } from '../constants'; const sandbox = sinon.sandbox.create(); @@ -11,6 +12,7 @@ describe('ThumbnailsSidebar', () => { let page; let virtualScroller; let pagePromise; + let anchorEl; before(() => fixture.setBase('src/lib')); @@ -31,9 +33,11 @@ describe('ThumbnailsSidebar', () => { stubs.getPage = sandbox.stub().returns(pagePromise); stubs.vsInit = sandbox.stub(VirtualScroller.prototype, 'init'); stubs.vsDestroy = sandbox.stub(VirtualScroller.prototype, 'destroy'); + stubs.vsScrollIntoView = sandbox.stub(VirtualScroller.prototype, 'scrollIntoView'); virtualScroller = { - destroy: stubs.vsDestroy + destroy: stubs.vsDestroy, + scrollIntoView: stubs.vsScrollIntoView }; pdfViewer = { @@ -42,7 +46,9 @@ describe('ThumbnailsSidebar', () => { } }; - thumbnailsSidebar = new ThumbnailsSidebar(document.getElementById('test-thumbnails-sidebar'), pdfViewer); + anchorEl = document.getElementById('test-thumbnails-sidebar'); + + thumbnailsSidebar = new ThumbnailsSidebar(anchorEl, pdfViewer); }); afterEach(() => { @@ -335,6 +341,7 @@ describe('ThumbnailsSidebar', () => { beforeEach(() => { stubs.applyCurrentPageSelection = sandbox.stub(thumbnailsSidebar, 'applyCurrentPageSelection'); thumbnailsSidebar.pdfViewer = { pagesCount: 10 }; + thumbnailsSidebar.virtualScroller = virtualScroller; }); const paramaterizedTests = [ @@ -357,6 +364,7 @@ describe('ThumbnailsSidebar', () => { expect(thumbnailsSidebar.currentPage).to.be.equal(3); expect(stubs.applyCurrentPageSelection).to.be.called; + expect(stubs.vsScrollIntoView).to.be.calledWith(2); }); }); @@ -403,4 +411,93 @@ describe('ThumbnailsSidebar', () => { expect(stubs.addClass).to.be.calledOnce; }); }); + + describe('toggle()', () => { + beforeEach(() => { + stubs.isOpen = sandbox.stub(thumbnailsSidebar, 'isOpen'); + stubs.toggleOpen = sandbox.stub(thumbnailsSidebar, 'toggleOpen'); + stubs.toggleClose = sandbox.stub(thumbnailsSidebar, 'toggleClose'); + }); + + it('should do nothing if there is no anchorEl', () => { + thumbnailsSidebar.anchorEl = null; + + thumbnailsSidebar.toggle(); + + expect(stubs.isOpen).not.to.be.called; + expect(stubs.toggleOpen).not.to.be.called; + expect(stubs.toggleClose).not.to.be.called; + + thumbnailsSidebar.anchorEl = anchorEl; + }); + + it('should toggle open if it was closed', () => { + stubs.isOpen.returns(false); + + thumbnailsSidebar.toggle(); + + expect(stubs.isOpen).to.be.called; + expect(stubs.toggleOpen).to.be.called; + expect(stubs.toggleClose).not.to.be.called; + }); + + it('should toggle closed if it was open', () => { + stubs.isOpen.returns(true); + + thumbnailsSidebar.toggle(); + + expect(stubs.isOpen).to.be.called; + expect(stubs.toggleOpen).not.to.be.called; + expect(stubs.toggleClose).to.be.called; + }); + }); + + describe('toggleOpen()', () => { + beforeEach(() => { + stubs.removeClass = sandbox.stub(thumbnailsSidebar.anchorEl.classList, 'remove'); + thumbnailsSidebar.virtualScroller = virtualScroller; + }); + + it('should do nothing if there is no anchorEl', () => { + thumbnailsSidebar.anchorEl = null; + + thumbnailsSidebar.toggleOpen(); + + expect(stubs.removeClass).not.to.be.called; + expect(stubs.vsScrollIntoView).not.to.be.called; + + thumbnailsSidebar.anchorEl = anchorEl; + }); + + it('should remove the hidden class and scroll the page into view', () => { + thumbnailsSidebar.currentPage = 3; + + thumbnailsSidebar.toggleOpen(); + + expect(stubs.removeClass).to.be.calledWith(CLASS_HIDDEN); + expect(stubs.vsScrollIntoView).to.be.calledWith(2); + }); + }); + + describe('toggleClose()', () => { + beforeEach(() => { + stubs.addClass = sandbox.stub(thumbnailsSidebar.anchorEl.classList, 'add'); + }); + + it('should do nothing if there is no anchorEl', () => { + thumbnailsSidebar.anchorEl = null; + + thumbnailsSidebar.toggleClose(); + + expect(stubs.addClass).not.to.be.called; + + thumbnailsSidebar.anchorEl = anchorEl; + }); + + it('should add the hidden class', () => { + thumbnailsSidebar.toggleClose(); + + expect(stubs.addClass).to.be.calledWith(CLASS_HIDDEN); + }); + }); }); diff --git a/src/lib/__tests__/VirtualScroller-test.js b/src/lib/__tests__/VirtualScroller-test.js index 509f73f36..aec3973d4 100644 --- a/src/lib/__tests__/VirtualScroller-test.js +++ b/src/lib/__tests__/VirtualScroller-test.js @@ -97,6 +97,36 @@ describe('VirtualScroller', () => { expect(stubs.onInitHandler).to.be.calledWith(mockListInfo); }); + + it('should call renderItems with the provided initialRowIndex', () => { + stubs.renderItemFn = sandbox.stub(); + stubs.renderItems = sandbox.stub(virtualScroller, 'renderItems'); + + virtualScroller.init({ + totalItems: 10, + itemHeight: 100, + containerHeight: 500, + renderItemFn: stubs.renderItemFn, + initialRowIndex: 50 + }); + + expect(stubs.renderItems).to.be.calledWith(50); + }); + + it('should call renderItems with 0 if initialRowIndex falls within first window', () => { + stubs.renderItemFn = sandbox.stub(); + stubs.renderItems = sandbox.stub(virtualScroller, 'renderItems'); + + virtualScroller.init({ + totalItems: 10, + itemHeight: 100, + containerHeight: 500, + renderItemFn: stubs.renderItemFn, + initialRowIndex: 2 + }); + + expect(stubs.renderItems).to.be.calledWith(0); + }); }); describe('validateRequiredConfig()', () => { @@ -170,6 +200,8 @@ describe('VirtualScroller', () => { curListEl = { appendChild: stubs.appendChild, insertBefore: stubs.insertBefore }; newListEl = {}; virtualScroller.listEl = curListEl; + virtualScroller.maxRenderedItems = 10; + virtualScroller.totalItems = 100; stubs.renderItem = sandbox.stub(virtualScroller, 'renderItem'); stubs.getCurrentListInfo = sandbox.stub(virtualScroller, 'getCurrentListInfo'); @@ -179,8 +211,6 @@ describe('VirtualScroller', () => { }); it('should render the whole range of items (no reuse)', () => { - virtualScroller.maxRenderedItems = 10; - virtualScroller.totalItems = 100; stubs.getCurrentListInfo.returns({ startOffset: -1, endOffset: -1 @@ -193,9 +223,7 @@ describe('VirtualScroller', () => { expect(stubs.insertBefore).not.to.be.called; }); - it('should render the remaining items up to totalItems', () => { - virtualScroller.maxRenderedItems = 10; - virtualScroller.totalItems = 100; + it('should render the last window into the list', () => { stubs.getCurrentListInfo.returns({ startOffset: -1, endOffset: -1 @@ -203,14 +231,12 @@ describe('VirtualScroller', () => { virtualScroller.renderItems(95); expect(stubs.deleteItems).to.be.calledWith(curListEl); - expect(stubs.createItems).to.be.calledWith(newListEl, 95, 99); + expect(stubs.createItems).to.be.calledWith(newListEl, 90, 99); expect(stubs.appendChild).to.be.called; expect(stubs.insertBefore).not.to.be.called; }); it('should render items above the current list', () => { - virtualScroller.maxRenderedItems = 10; - virtualScroller.totalItems = 100; stubs.getCurrentListInfo.returns({ startOffset: 20, endOffset: 30 @@ -430,4 +456,123 @@ describe('VirtualScroller', () => { expect(stubs.appendChild).to.be.calledThrice; }); }); + + describe('scrollIntoView()', () => { + let scrollingEl; + let listEl; + + beforeEach(() => { + scrollingEl = { remove: () => {} }; + + virtualScroller.totalItems = 10; + virtualScroller.itemHeight = 10; + virtualScroller.margin = 0; + virtualScroller.scrollingEl = scrollingEl; + + stubs.isVisible = sandbox.stub(virtualScroller, 'isVisible'); + stubs.scrollIntoView = sandbox.stub(); + + listEl = { + children: [ + { dataset: { bpVsRowIndex: 0 }, scrollIntoView: stubs.scrollIntoView }, + { dataset: { bpVsRowIndex: 1 }, scrollIntoView: stubs.scrollIntoView }, + { dataset: { bpVsRowIndex: 2 }, scrollIntoView: stubs.scrollIntoView } + ] + }; + }); + + it('should do nothing if scrollingEl is falsy', () => { + virtualScroller.scrollingEl = undefined; + + virtualScroller.scrollIntoView(1); + + expect(stubs.isVisible).not.to.be.called; + expect(scrollingEl.scrollTop).to.be.undefined; + }); + + it('should do nothing if rowIndex is < 0', () => { + virtualScroller.scrollIntoView(-1); + + expect(stubs.isVisible).not.to.be.called; + expect(scrollingEl.scrollTop).to.be.undefined; + }); + + it('should do nothing if rowIndex is = totalItems', () => { + virtualScroller.scrollIntoView(10); + + expect(stubs.isVisible).not.to.be.called; + expect(scrollingEl.scrollTop).to.be.undefined; + }); + + it('should do nothing if rowIndex is > totalItems', () => { + virtualScroller.scrollIntoView(11); + + expect(stubs.isVisible).not.to.be.called; + expect(scrollingEl.scrollTop).to.be.undefined; + }); + + it('should set the scroll top if item is not found', () => { + virtualScroller.listEl = listEl; + + virtualScroller.scrollIntoView(8); + + expect(stubs.isVisible).not.to.be.called; + expect(stubs.scrollIntoView).not.to.be.called; + expect(scrollingEl.scrollTop).not.to.be.undefined; + }); + + it('should scroll item into view if found but not visible', () => { + virtualScroller.listEl = listEl; + stubs.isVisible.returns(false); + + virtualScroller.scrollIntoView(1); + + expect(stubs.isVisible).to.be.called; + expect(stubs.scrollIntoView).to.be.called; + expect(scrollingEl.scrollTop).to.be.undefined; + }); + + it('should not scroll if item is found and visible', () => { + virtualScroller.listEl = listEl; + stubs.isVisible.returns(true); + + virtualScroller.scrollIntoView(1); + + expect(stubs.isVisible).to.be.called; + expect(stubs.scrollIntoView).not.to.be.called; + expect(scrollingEl.scrollTop).to.be.undefined; + }); + }); + + describe('isVisible()', () => { + let scrollingEl; + + beforeEach(() => { + scrollingEl = { scrollTop: 100, remove: () => {} }; + virtualScroller.scrollingEl = scrollingEl; + virtualScroller.containerHeight = 100; + }); + + it('should return false if scrollingEl is falsy', () => { + virtualScroller.scrollingEl = false; + + expect(virtualScroller.isVisible({})).to.be.false; + }); + + it('should return false if listItemEl is falsy', () => { + expect(virtualScroller.isVisible()).to.be.false; + }); + + it('should return false if the offsetTop of listItemEl is < scrollTop', () => { + expect(virtualScroller.isVisible({ offsetTop: 50 })).to.be.false; + }); + + it('should return false if the offsetTop of listItemEl is > scrollTop + containerHeight', () => { + expect(virtualScroller.isVisible({ offsetTop: 201 })).to.be.false; + }); + + it('should return true if the offsetTop of listItemEl is >= scrollTop && <= scrollTop + containerHeight', () => { + expect(virtualScroller.isVisible({ offsetTop: 101 })).to.be.true; + }); + }); }); diff --git a/src/lib/viewers/doc/DocBaseViewer.js b/src/lib/viewers/doc/DocBaseViewer.js index af393584e..c31fb654f 100644 --- a/src/lib/viewers/doc/DocBaseViewer.js +++ b/src/lib/viewers/doc/DocBaseViewer.js @@ -1086,6 +1086,10 @@ class DocBaseViewer extends BaseViewer { const { pageNumber } = event; this.pageControls.updateCurrentPage(pageNumber); + if (this.thumbnailsSidebar) { + this.thumbnailsSidebar.setCurrentPage(pageNumber); + } + // We only set cache the current page if 'pagechange' was fired after // preview is loaded - this filters out pagechange events fired by // the viewer's initialization @@ -1290,17 +1294,17 @@ class DocBaseViewer extends BaseViewer { * @return {void} */ toggleThumbnails() { - if (!this.thumbnailsSidebarEl) { + if (!this.thumbnailsSidebar) { return; } - this.thumbnailsSidebarEl.classList.toggle(CLASS_HIDDEN); + this.thumbnailsSidebar.toggle(); const { pagesCount } = this.pdfViewer; let metricName; let eventName; - if (this.thumbnailsSidebarEl.classList.contains(CLASS_HIDDEN)) { + if (!this.thumbnailsSidebar.isOpen()) { metricName = USER_DOCUMENT_THUMBNAIL_EVENTS.CLOSE; eventName = 'thumbnailsClose'; } else { diff --git a/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js b/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js index 85b70cc27..96b4dee50 100644 --- a/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js +++ b/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js @@ -19,7 +19,6 @@ import { STATUS_SUCCESS, QUERY_PARAM_ENCODING, ENCODING_TYPES, - SELECTOR_BOX_PREVIEW_THUMBNAILS_CONTAINER, SELECTOR_BOX_PREVIEW_CONTENT, CLASS_BOX_PREVIEW_THUMBNAILS_CONTAINER } from '../../../constants'; @@ -2093,14 +2092,25 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { }); describe('toggleThumbnails()', () => { + let thumbnailsSidebar; + beforeEach(() => { sandbox.stub(docBase, 'resize'); sandbox.stub(docBase, 'emitMetric'); sandbox.stub(docBase, 'emit'); + + stubs.toggleSidebar = sandbox.stub(); + stubs.isSidebarOpen = sandbox.stub(); + + thumbnailsSidebar = { + toggle: stubs.toggleSidebar, + isOpen: stubs.isSidebarOpen, + destroy: () => {} + }; }); it('should do nothing if thumbnails sidebar does not exist', () => { - docBase.thumbnailsSidebarEl = undefined; + docBase.thumbnailsSidebar = undefined; docBase.toggleThumbnails(); @@ -2108,29 +2118,28 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { }); it('should toggle open and resize the viewer', () => { - const thumbnailsSidebarEl = document.querySelector(SELECTOR_BOX_PREVIEW_THUMBNAILS_CONTAINER); - docBase.thumbnailsSidebarEl = thumbnailsSidebarEl; + docBase.thumbnailsSidebar = thumbnailsSidebar; docBase.pdfViewer = { pagesCount: 10 }; - expect(thumbnailsSidebarEl.classList.contains(CLASS_HIDDEN)).to.be.true; + stubs.isSidebarOpen.returns(true); docBase.toggleThumbnails(); - expect(thumbnailsSidebarEl.classList.contains(CLASS_HIDDEN)).to.be.false; + expect(stubs.toggleSidebar).to.be.called; + expect(stubs.isSidebarOpen).to.be.called; expect(docBase.resize).to.be.called; expect(docBase.emitMetric).to.be.calledWith({ name: USER_DOCUMENT_THUMBNAIL_EVENTS.OPEN, data: 10 }); expect(docBase.emit).to.be.calledWith('thumbnailsOpen'); }); it('should toggle close and resize the viewer', () => { - const thumbnailsSidebarEl = document.querySelector(SELECTOR_BOX_PREVIEW_THUMBNAILS_CONTAINER); - docBase.thumbnailsSidebarEl = thumbnailsSidebarEl; - thumbnailsSidebarEl.classList.remove(CLASS_HIDDEN); + docBase.thumbnailsSidebar = thumbnailsSidebar; docBase.pdfViewer = { pagesCount: 10 }; - expect(thumbnailsSidebarEl.classList.contains(CLASS_HIDDEN)).to.be.false; + stubs.isSidebarOpen.returns(false); docBase.toggleThumbnails(); - expect(thumbnailsSidebarEl.classList.contains(CLASS_HIDDEN)).to.be.true; + expect(stubs.toggleSidebar).to.be.called; + expect(stubs.isSidebarOpen).to.be.called; expect(docBase.resize).to.be.called; expect(docBase.emitMetric).to.be.calledWith({ name: USER_DOCUMENT_THUMBNAIL_EVENTS.CLOSE, data: 10 }); expect(docBase.emit).to.be.calledWith('thumbnailsClose');