diff --git a/src/lib/Preview.js b/src/lib/Preview.js index 4304a6304..bfa966cc3 100644 --- a/src/lib/Preview.js +++ b/src/lib/Preview.js @@ -934,6 +934,7 @@ class Preview extends EventEmitter { this.options.fileOptions = options.fileOptions || {}; // Option to enable use of thumbnails sidebar for document types + // this.options.enableThumbnailsSidebar = !!options.enableThumbnailsSidebar; this.options.enableThumbnailsSidebar = !!options.enableThumbnailsSidebar; // Prefix any user created loaders before our default ones diff --git a/src/lib/Preview.scss b/src/lib/Preview.scss index 8812952b6..6df3fe019 100644 --- a/src/lib/Preview.scss +++ b/src/lib/Preview.scss @@ -3,3 +3,4 @@ @import 'navigation'; @import './Controls'; @import './ProgressBar'; +@import './VirtualScroller'; diff --git a/src/lib/VirtualScroller.js b/src/lib/VirtualScroller.js new file mode 100644 index 000000000..27dcbc130 --- /dev/null +++ b/src/lib/VirtualScroller.js @@ -0,0 +1,239 @@ +import isFinite from 'lodash/isFinite'; +import isFunction from 'lodash/isFunction'; +import throttle from 'lodash/throttle'; + +const BUFFERED_ITEM_MULTIPLIER = 3; + +class VirtualScroller { + /** @property {HTMLElement} - The anchor element for this Virtual Scroller */ + anchorEl; + + /** @property {HTMLElement} - The reference to the scrolling element container */ + scrollingEl; + + /** @property {number} - The height of the scrolling container */ + containerHeight; + + /** @property {number} - The height of a single list item */ + itemHeight; + + /** @property {HTMLElement} - The reference to the list element */ + listEl; + + /** @property {number} - The margin at the top of the list and below every list item */ + margin; + + /** @property {number} - The height of the buffer before virtual scroll renders the next set */ + maxBufferHeight; + + /** @property {number} - The max number of items to render at any one given time */ + maxRenderedItems; + + /** @property {Function} - The callback function that to allow users generate the item */ + renderItemFn; + + /** @property {number} - The total number items to be scrolled */ + totalItems; + + /** @property {number} - The number of items that can fit in the visible scrolling element */ + totalViewItems; + + /** + * [constructor] + * + * @param {HTMLElement} anchor - The HTMLElement that will anchor the virtual scroller + * @return {VirtualScroller} Instance of VirtualScroller + */ + constructor(anchor) { + this.anchorEl = anchor; + + this.previousScrollTop = 0; + + this.createListElement = this.createListElement.bind(this); + this.onScrollHandler = this.onScrollHandler.bind(this); + this.throttledOnScrollHandler = throttle(this.onScrollHandler, 50); + this.renderItems = this.renderItems.bind(this); + } + + /** + * Destroys the virtual scroller + * + * @return {void} + */ + destroy() { + if (this.scrollingEl) { + this.scrollingEl.remove(); + } + + this.scrollingEl = null; + this.listEl = null; + } + + /** + * Initializes the virtual scroller + * + * @param {Object} config - The config + * @return {void} + */ + init(config) { + this.validateRequiredConfig(config); + + this.totalItems = config.totalItems; + this.itemHeight = config.itemHeight; + this.containerHeight = config.containerHeight; + this.renderItemFn = config.renderItemFn; + this.margin = config.margin || 0; + this.totalViewItems = Math.floor(this.containerHeight / (this.itemHeight + this.margin)); + this.maxBufferHeight = this.totalViewItems * this.itemHeight; + this.maxRenderedItems = (this.totalViewItems + 1) * BUFFERED_ITEM_MULTIPLIER; + + // Create the scrolling container element + this.scrollingEl = document.createElement('div'); + this.scrollingEl.className = 'bp-vs'; + + // Create the true height content container + this.listEl = this.createListElement(); + + this.scrollingEl.appendChild(this.listEl); + this.anchorEl.appendChild(this.scrollingEl); + + this.renderItems(); + + this.bindDOMListeners(); + } + + /** + * Utility function to validate the required config is present + * + * @param {Object} config - the config object + * @return {void} + * @throws Error + */ + validateRequiredConfig(config) { + if (!config.totalItems || !isFinite(config.totalItems)) { + throw new Error('totalItems is required'); + } + + if (!config.itemHeight || !isFinite(config.itemHeight)) { + throw new Error('itemHeight is required'); + } + + if (!config.renderItemFn || !isFunction(config.renderItemFn)) { + throw new Error('renderItemFn is required'); + } + + if (!config.containerHeight || !isFinite(config.containerHeight)) { + throw new Error('containerHeight is required'); + } + } + + /** + * Binds DOM listeners + * + * @return {void} + */ + bindDOMListeners() { + this.scrollingEl.addEventListener('scroll', this.throttledOnScrollHandler, { passive: true }); + } + + /** + * Unbinds DOM listeners + * + * @return {void} + */ + unbindDOMListeners() { + if (this.scrollingEl) { + this.scrollingEl.removeEventListener('scroll', this.throttledOnScrollHandler); + } + } + + /** + * Handler for 'scroll' event + * + * @param {Event} e - The scroll event + * @return {void} + */ + onScrollHandler(e) { + const { scrollTop } = e.target; + + if (Math.abs(scrollTop - this.previousScrollTop) > this.maxBufferHeight) { + // The first item to be re-rendered will be a totalViewItems height up from the + // item at the current location + const firstIndex = Math.floor(scrollTop / (this.itemHeight + this.margin)) - this.totalViewItems; + this.renderItems(Math.max(firstIndex, 0)); + + this.previousScrollTop = scrollTop; + } + } + + /** + * Render a set of items, starting from the offset index + * + * @param {number} offset - The offset to start rendering items + * @return {void} + */ + renderItems(offset = 0) { + let count = this.maxRenderedItems; + // If the default count of items to render exceeds the totalItems count + // then just render the difference + if (count + offset > this.totalItems) { + count = this.totalItems - offset; + } + + let numItemsRendered = 0; + + // Create a new list element to be swapped out for the existing one + const newListEl = this.createListElement(); + while (numItemsRendered < count) { + const rowEl = this.renderItem(offset + numItemsRendered); + newListEl.appendChild(rowEl); + numItemsRendered += 1; + } + + this.scrollingEl.replaceChild(newListEl, this.listEl); + this.listEl = newListEl; + } + + /** + * Render a single item + * + * @param {number} rowIndex - The index of the item to be rendered + * @return {HTMLElement} The newly created row item + */ + renderItem(rowIndex) { + const rowEl = document.createElement('li'); + const topPosition = (this.itemHeight + this.margin) * rowIndex + this.margin; + + let renderedThumbnail; + try { + renderedThumbnail = this.renderItemFn.call(this, rowIndex); + } catch (err) { + // eslint-disable-next-line + console.error(`Error rendering thumbnail - ${err}`); + } + + rowEl.style.top = `${topPosition}px`; + rowEl.style.height = `${this.itemHeight}px`; + rowEl.classList.add('bp-vs-list-item'); + + if (renderedThumbnail) { + rowEl.appendChild(renderedThumbnail); + } + + return rowEl; + } + + /** + * Utility to create the list element + * + * @return {HTMLElement} The list element + */ + createListElement() { + const newListEl = document.createElement('ol'); + newListEl.className = 'bp-vs-list'; + newListEl.style.height = `${this.totalItems * (this.itemHeight + this.margin) + this.margin}px`; + return newListEl; + } +} + +export default VirtualScroller; diff --git a/src/lib/VirtualScroller.scss b/src/lib/VirtualScroller.scss new file mode 100644 index 000000000..ca31b1ddf --- /dev/null +++ b/src/lib/VirtualScroller.scss @@ -0,0 +1,18 @@ +.bp-vs { + flex: 1 0 auto; + overflow-y: auto; + + .bp-vs-list { + margin: 0; + padding: 0; + position: relative; + } + + .bp-vs-list-item { + box-sizing: border-box; + display: flex; + left: 0; + position: absolute; + right: 0; + } +} diff --git a/src/lib/viewers/doc/DocBaseViewer.js b/src/lib/viewers/doc/DocBaseViewer.js index 927787af3..9d6d2eed1 100644 --- a/src/lib/viewers/doc/DocBaseViewer.js +++ b/src/lib/viewers/doc/DocBaseViewer.js @@ -7,6 +7,7 @@ import DocFindBar from './DocFindBar'; import Popup from '../../Popup'; import RepStatus from '../../RepStatus'; import PreviewError from '../../PreviewError'; +import VirtualScroller from '../../VirtualScroller'; import { CLASS_BOX_PREVIEW_FIND_BAR, CLASS_CRAWLER, @@ -61,6 +62,7 @@ const MOBILE_MAX_CANVAS_SIZE = 2949120; // ~3MP 1920x1536 const PINCH_PAGE_CLASS = 'pinch-page'; const PINCHING_CLASS = 'pinching'; const PAGES_UNIT_NAME = 'pages'; +const DEFAULT_THUMBNAILS_SIDEBAR_WIDTH = 150; class DocBaseViewer extends BaseViewer { //-------------------------------------------------------------------------- @@ -177,6 +179,10 @@ class DocBaseViewer extends BaseViewer { this.printPopup.destroy(); } + if (this.thumbnailsSidebar) { + this.thumbnailsSidebar.destroy(); + } + super.destroy(); } @@ -990,6 +996,36 @@ class DocBaseViewer extends BaseViewer { // Add page IDs to each page after page structure is available this.setupPageIds(); } + + if (this.options.enableThumbnailsSidebar) { + this.initThumbnails(); + } + } + + initThumbnails() { + this.thumbnailsSidebar = new VirtualScroller(this.thumbnailsSidebarEl); + + // Get the first page of the document, and use its dimensions + // to set the thumbnails size of the thumbnails sidebar + this.pdfViewer.pdfDocument.getPage(1).then((page) => { + const desiredWidth = DEFAULT_THUMBNAILS_SIDEBAR_WIDTH; + const viewport = page.getViewport(1); + const scale = desiredWidth / viewport.width; + const scaledViewport = page.getViewport(scale); + + this.thumbnailsSidebar.init({ + totalItems: this.pdfViewer.pagesCount, + itemHeight: scaledViewport.height, + containerHeight: this.docEl.clientHeight, + margin: 15, + renderItemFn: (itemIndex) => { + const thumbnail = document.createElement('button'); + thumbnail.className = 'bp-thumbnail'; + thumbnail.textContent = `${itemIndex}`; + return thumbnail; + } + }); + }); } /** diff --git a/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js b/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js index 129eabd48..92f3352ed 100644 --- a/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js +++ b/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js @@ -1465,6 +1465,7 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { stubs.getCachedPage = sandbox.stub(docBase, 'getCachedPage'); stubs.emit = sandbox.stub(docBase, 'emit'); stubs.setupPages = sandbox.stub(docBase, 'setupPageIds'); + stubs.initThumbnails = sandbox.stub(docBase, 'initThumbnails'); }); it('should load UI, check the pagination buttons, set the page, and make document scrollable', () => { @@ -1477,6 +1478,49 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { expect(stubs.setPage).to.be.called; expect(docBase.docEl).to.have.class('bp-is-scrollable'); expect(stubs.setupPages).to.be.called; + expect(stubs.initThumbnails).to.be.called; + }); + + it('should not init thumbnails if not enabled', () => { + docBase = new DocBaseViewer({ + cache: { + set: () => {}, + has: () => {}, + get: () => {}, + unset: () => {} + }, + container: containerEl, + representation: { + content: { + url_template: 'foo' + } + }, + file: { + id: '0', + extension: 'ppt' + }, + enableThumbnailsSidebar: false + }); + Object.defineProperty(BaseViewer.prototype, 'setup', { value: sandbox.stub() }); + docBase.containerEl = containerEl; + docBase.setup(); + stubs.loadUI = sandbox.stub(docBase, 'loadUI'); + stubs.setPage = sandbox.stub(docBase, 'setPage'); + stubs.getCachedPage = sandbox.stub(docBase, 'getCachedPage'); + stubs.emit = sandbox.stub(docBase, 'emit'); + stubs.setupPages = sandbox.stub(docBase, 'setupPageIds'); + stubs.initThumbnails = sandbox.stub(docBase, 'initThumbnails'); + + docBase.pdfViewer = { + currentScale: 'unknown' + }; + + docBase.pagesinitHandler(); + expect(stubs.loadUI).to.be.called; + expect(stubs.setPage).to.be.called; + expect(docBase.docEl).to.have.class('bp-is-scrollable'); + expect(stubs.setupPages).to.be.called; + expect(stubs.initThumbnails).not.to.be.called; }); it('should broadcast that the preview is loaded if it hasn\'t already', () => { diff --git a/src/lib/viewers/doc/_docBase.scss b/src/lib/viewers/doc/_docBase.scss index ba9e64577..fcffd6c2f 100644 --- a/src/lib/viewers/doc/_docBase.scss +++ b/src/lib/viewers/doc/_docBase.scss @@ -3,8 +3,20 @@ .bp { .bp-thumbnails-container { border-right: solid 1px $seesee; + display: flex; flex: 0 0 180px; } + + .bp-thumbnail { + align-items: center; + background-color: rgba(0, 0, 255, .5); + border-radius: 4px; + display: flex; + flex: 1 1 auto; + justify-content: center; + margin: 0 15px; + padding: 0; + } } .bp-theme-dark {