diff --git a/src/lib/Preview.js b/src/lib/Preview.js index f06ac5277..9ecba6aba 100644 --- a/src/lib/Preview.js +++ b/src/lib/Preview.js @@ -931,7 +931,6 @@ 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/ThumbnailsSidebar.js b/src/lib/ThumbnailsSidebar.js new file mode 100644 index 000000000..7be6c956f --- /dev/null +++ b/src/lib/ThumbnailsSidebar.js @@ -0,0 +1,186 @@ +import isFinite from 'lodash/isFinite'; +import VirtualScroller from './VirtualScroller'; + +const DEFAULT_THUMBNAILS_SIDEBAR_WIDTH = 150; +const THUMBNAIL_WIDTH_MAX = 210; +const THUMBNAIL_MARGIN = 15; + +class ThumbnailsSidebar { + /** @property {HTMLElement} - The anchor element for this ThumbnailsSidebar */ + anchorEl; + + /** @property {PDfViewer} - The PDFJS viewer instance */ + pdfViewer; + + /** @property {Object} - Cache for the thumbnail image elements */ + thumbnailImageCache; + + /** + * [constructor] + * + * @param {HTMLElement} element - the HTMLElement that will anchor the thumbnail sidebar + * @param {PDFViewer} pdfViewer - the PDFJS viewer + */ + constructor(element, pdfViewer) { + this.anchorEl = element; + this.pdfViewer = pdfViewer; + this.thumbnailImageCache = {}; + + this.createPlaceholderThumbnail = this.createPlaceholderThumbnail.bind(this); + this.requestThumbnailImage = this.requestThumbnailImage.bind(this); + this.createThumbnailImage = this.createThumbnailImage.bind(this); + this.generateThumbnailImages = this.generateThumbnailImages.bind(this); + } + + /** + * Destroys the thumbnails sidebar + * + * @return {void} + */ + destroy() { + if (this.virtualScroller) { + this.virtualScroller.destroy(); + this.virtualScroller = null; + } + + this.thumbnailImageCache = null; + this.pdfViewer = null; + } + + /** + * Initializes the Thumbnails Sidebar + * + * @return {void} + */ + init() { + this.virtualScroller = new VirtualScroller(this.anchorEl); + + // 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 { width, height } = page.getViewport(1); + + // If the dimensions of the page are invalid then don't proceed further + if (!(isFinite(width) && width > 0 && isFinite(height) && height > 0)) { + console.error('Page dimensions invalid when initializing the thumbnails sidebar'); + return; + } + + // Amount to scale down from fullsize to thumbnail size + this.scale = DEFAULT_THUMBNAILS_SIDEBAR_WIDTH / width; + // Width : Height ratio of the page + this.pageRatio = width / height; + const scaledViewport = page.getViewport(this.scale); + + this.virtualScroller.init({ + totalItems: this.pdfViewer.pagesCount, + itemHeight: scaledViewport.height, + containerHeight: this.anchorEl.parentNode.clientHeight, + margin: THUMBNAIL_MARGIN, + renderItemFn: this.createPlaceholderThumbnail, + onScrollEnd: this.generateThumbnailImages, + onInit: this.generateThumbnailImages + }); + }); + } + + /** + * Generates the thumbnail images that are not yet created + * + * @param {Object} currentListInfo - VirtualScroller info object which contains startOffset, endOffset, and the thumbnail elements + * @return {void} + */ + generateThumbnailImages({ items, startOffset }) { + items.forEach((thumbnailEl, index) => { + if (thumbnailEl.classList.contains('bp-thumbnail-image-loaded')) { + return; + } + + this.requestThumbnailImage(index + startOffset, thumbnailEl); + }); + } + + /** + * Creates the placeholder thumbnail with page indication. This element will + * not yet have the image of the page + * + * @param {number} itemIndex - The item index into the overall list (0 indexed) + * @return {HTMLElement} - thumbnail button element + */ + createPlaceholderThumbnail(itemIndex) { + const thumbnailEl = document.createElement('button'); + thumbnailEl.className = 'bp-thumbnail'; + thumbnailEl.setAttribute('type', 'button'); + thumbnailEl.appendChild(this.createPageNumber(itemIndex + 1)); + return thumbnailEl; + } + + /** + * Request the thumbnail image to be made + * + * @param {number} itemIndex - the item index in the overall list (0 indexed) + * @param {HTMLElement} thumbnailEl - the thumbnail button element + * @return {void} + */ + requestThumbnailImage(itemIndex, thumbnailEl) { + requestAnimationFrame(() => { + this.createThumbnailImage(itemIndex).then((imageEl) => { + thumbnailEl.appendChild(imageEl); + thumbnailEl.classList.add('bp-thumbnail-image-loaded'); + }); + }); + } + + /** + * Make a thumbnail image element + * + * @param {number} itemIndex - the item index for the overall list (0 indexed) + * @return {Promise} - promise reolves with the image HTMLElement + */ + createThumbnailImage(itemIndex) { + // If this page has already been cached, use it + if (this.thumbnailImageCache[itemIndex]) { + return Promise.resolve(this.thumbnailImageCache[itemIndex]); + } + + const canvas = document.createElement('canvas'); + + return this.pdfViewer.pdfDocument + .getPage(itemIndex + 1) + .then((page) => { + const viewport = page.getViewport(1); + canvas.width = THUMBNAIL_WIDTH_MAX; + canvas.height = THUMBNAIL_WIDTH_MAX / this.pageRatio; + const scale = THUMBNAIL_WIDTH_MAX / viewport.width; + return page.render({ + canvasContext: canvas.getContext('2d'), + viewport: page.getViewport(scale) + }); + }) + .then(() => { + const imageEl = document.createElement('img'); + imageEl.src = canvas.toDataURL(); + imageEl.style.maxWidth = '100%'; + + // Cache this image element for future use + this.thumbnailImageCache[itemIndex] = imageEl; + + return imageEl; + }); + } + + /** + * Creates a page number element + * + * @param {number} pageNumber - Page number of the document + * @return {HTMLElement} - A div containing the page number + */ + createPageNumber(pageNumber) { + const pageNumberEl = document.createElement('div'); + pageNumberEl.className = 'bp-thumbnail-page-number'; + pageNumberEl.textContent = `${pageNumber}`; + return pageNumberEl; + } +} + +export default ThumbnailsSidebar; diff --git a/src/lib/VirtualScroller.js b/src/lib/VirtualScroller.js index d99b36415..3ab884151 100644 --- a/src/lib/VirtualScroller.js +++ b/src/lib/VirtualScroller.js @@ -1,9 +1,11 @@ import isFinite from 'lodash/isFinite'; import isFunction from 'lodash/isFunction'; import throttle from 'lodash/throttle'; +import debounce from 'lodash/debounce'; const BUFFERED_ITEM_MULTIPLIER = 3; -const SCROLL_THROTTLE_MS = 50; +const THROTTLE_SCROLL_THRESHOLD = 150; +const DEBOUNCE_SCROLL_THRESHOLD = 151; class VirtualScroller { /** @property {HTMLElement} - The anchor element for this Virtual Scroller */ @@ -30,6 +32,9 @@ class VirtualScroller { /** @property {number} - The max number of items to render at any one given time */ maxRenderedItems; + /** @property {number} - The previously recorded scrollTop value */ + previousScrollTop; + /** @property {Function} - The callback function that to allow users generate the item */ renderItemFn; @@ -51,8 +56,13 @@ class VirtualScroller { this.previousScrollTop = 0; this.createListElement = this.createListElement.bind(this); - this.onScrollHandler = throttle(this.onScrollHandler.bind(this), SCROLL_THROTTLE_MS); + 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.debouncedOnScrollEndHandler = debounce(this.onScrollEndHandler, DEBOUNCE_SCROLL_THRESHOLD); + this.throttledOnScrollHandler = throttle(this.onScrollHandler, THROTTLE_SCROLL_THRESHOLD); } /** @@ -83,6 +93,9 @@ class VirtualScroller { this.containerHeight = config.containerHeight; this.renderItemFn = config.renderItemFn; this.margin = config.margin || 0; + this.onScrollEnd = config.onScrollEnd; + this.onScrollStart = config.onScrollStart; + this.totalViewItems = Math.floor(this.containerHeight / (this.itemHeight + this.margin)); this.maxBufferHeight = this.totalViewItems * this.itemHeight; this.maxRenderedItems = (this.totalViewItems + 1) * BUFFERED_ITEM_MULTIPLIER; @@ -100,6 +113,11 @@ class VirtualScroller { this.renderItems(); this.bindDOMListeners(); + + if (config.onInit) { + const listInfo = this.getCurrentListInfo(); + config.onInit(listInfo); + } } /** @@ -134,6 +152,7 @@ class VirtualScroller { */ bindDOMListeners() { this.scrollingEl.addEventListener('scroll', this.throttledOnScrollHandler, { passive: true }); + this.scrollingEl.addEventListener('scroll', this.debouncedOnScrollEndHandler, { passive: true }); } /** @@ -144,6 +163,7 @@ class VirtualScroller { unbindDOMListeners() { if (this.scrollingEl) { this.scrollingEl.removeEventListener('scroll', this.throttledOnScrollHandler); + this.scrollingEl.removeEventListener('scroll', this.debouncedOnScrollEndHandler); } } @@ -166,6 +186,38 @@ class VirtualScroller { } } + /** + * Debounced scroll handler to signal when scrolling has stopped + * + * @return {void} + */ + onScrollEndHandler() { + if (this.onScrollEnd) { + const listInfo = this.getCurrentListInfo(); + this.onScrollEnd(listInfo); + } + } + + /** + * Gets information about what the current start offset, end offset and rendered items array + * + * @return {Object} - info object + */ + getCurrentListInfo() { + const { firstElementChild, lastElementChild } = this.listEl; + + const curStartOffset = firstElementChild ? Number.parseInt(firstElementChild.dataset.bpVsRowIndex, 10) : -1; + const curEndOffset = lastElementChild ? Number.parseInt(lastElementChild.dataset.bpVsRowIndex, 10) : -1; + + const items = Array.prototype.slice.call(this.listEl.children).map((listItemEl) => listItemEl.children[0]); + + return { + startOffset: curStartOffset, + endOffset: curEndOffset, + items + }; + } + /** * Render a set of items, starting from the offset index * @@ -173,27 +225,101 @@ class VirtualScroller { * @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; + // calculate the diff between what is already rendered + // and what needs to be rendered + const { startOffset: curStartOffset, endOffset: curEndOffset } = this.getCurrentListInfo(); + + if (curStartOffset === offset) { + return; } - let numItemsRendered = 0; + let newStartOffset = 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 + if (newEndOffset >= this.totalItems) { + newEndOffset = this.totalItems - 1; + } // 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; + + if (curStartOffset <= offset && offset <= curEndOffset) { + // Scenario #1: New start offset falls within the current range of items rendered + // |--------------------| + // curStartOffset curEndOffset + // |--------------------| + // newStartOffset newEndOffset + newStartOffset = curEndOffset + 1; + // clone elements from newStartOffset to curEndOffset + this.cloneItems(newListEl, this.listEl, offset - curStartOffset, curEndOffset - curStartOffset); + // then create elements from curEnd + 1 to newEndOffset + this.createItems(newListEl, newStartOffset, newEndOffset); + } else if (curStartOffset <= newEndOffset && newEndOffset <= curEndOffset) { + // Scenario #2: New end offset falls within the current range of items rendered + // |--------------------| + // curStartOffset curEndOffset + // |--------------------| + // newStartOffset newEndOffset + + // create elements from newStartOffset to curStart - 1 + this.createItems(newListEl, offset, curStartOffset - 1); + // then clone elements from curStartOffset to newEndOffset + this.cloneItems(newListEl, this.listEl, 0, newEndOffset - curStartOffset); + } else { + // Scenario #3: New range has no overlap with current range of items + // |--------------------| + // curStartOffset curEndOffset + // |--------------------| + // newStartOffset newEndOffset + this.createItems(newListEl, newStartOffset, newEndOffset); } this.scrollingEl.replaceChild(newListEl, this.listEl); this.listEl = newListEl; } + /** + * Clones a subset of the HTMLElements from the oldList to the newList. + * The newList element is modified. + * + * @param {HTMLElement} newListEl - the new `ol` element + * @param {HTMLElement} oldListEl - the old `ol` element + * @param {number} start - start index + * @param {number} end - end index + * @return {void} + */ + cloneItems(newListEl, oldListEl, start, end) { + if (!newListEl || !oldListEl || start < 0 || end < 0) { + return; + } + + const { children } = oldListEl; + + for (let i = start; i <= end; i++) { + newListEl.appendChild(children[i].cloneNode(true)); + } + } + + /** + * Creates new HTMLElements appended to the newList + * + * @param {HTMLElement} newList - the new `li` element + * @param {number} start - start index + * @param {number} end - end index + * @return {void} + */ + createItems(newList, start, end) { + if (!newList || start < 0 || end < 0) { + return; + } + + for (let i = start; i <= end; i++) { + const newEl = this.renderItem(i); + newList.appendChild(newEl); + } + } + /** * Render a single item * @@ -215,6 +341,7 @@ class VirtualScroller { rowEl.style.top = `${topPosition}px`; rowEl.style.height = `${this.itemHeight}px`; rowEl.classList.add('bp-vs-list-item'); + rowEl.dataset.bpVsRowIndex = rowIndex; if (renderedThumbnail) { rowEl.appendChild(renderedThumbnail); diff --git a/src/lib/__tests__/VirtualScroller-test.js b/src/lib/__tests__/VirtualScroller-test.js index d5a6d8694..e3e82af4d 100644 --- a/src/lib/__tests__/VirtualScroller-test.js +++ b/src/lib/__tests__/VirtualScroller-test.js @@ -146,35 +146,50 @@ describe('VirtualScroller', () => { describe('renderItems()', () => { let newListEl; + const curListEl = {}; beforeEach(() => { virtualScroller.scrollingEl = { replaceChild: () => {} }; + virtualScroller.listEl = curListEl; newListEl = { appendChild: () => {} }; stubs.createListElement = sandbox.stub(virtualScroller, 'createListElement').returns(newListEl); stubs.renderItem = sandbox.stub(virtualScroller, 'renderItem'); stubs.replaceChild = sandbox.stub(virtualScroller.scrollingEl, 'replaceChild'); stubs.appendChild = sandbox.stub(newListEl, 'appendChild'); + stubs.getCurrentListInfo = sandbox.stub(virtualScroller, 'getCurrentListInfo'); + stubs.cloneItems = sandbox.stub(virtualScroller, 'cloneItems'); + stubs.createItems = sandbox.stub(virtualScroller, 'createItems'); }); afterEach(() => { virtualScroller.scrollingEl = null; }); - it('should render maxRenderedItems', () => { + it('should render the whole range of items (no reuse)', () => { virtualScroller.maxRenderedItems = 10; virtualScroller.totalItems = 100; + stubs.getCurrentListInfo.returns({ + startOffset: -1, + endOffset: -1 + }); virtualScroller.renderItems(); - expect(newListEl.appendChild.callCount).to.be.equal(10); + expect(stubs.cloneItems).not.to.be.called; + expect(stubs.createItems).to.be.calledWith(newListEl, 0, 10); expect(virtualScroller.scrollingEl.replaceChild).to.be.called; }); it('should render the remaining items up to totalItems', () => { virtualScroller.maxRenderedItems = 10; virtualScroller.totalItems = 100; + stubs.getCurrentListInfo.returns({ + startOffset: -1, + endOffset: -1 + }); virtualScroller.renderItems(95); - expect(newListEl.appendChild.callCount).to.be.equal(5); + expect(stubs.cloneItems).not.to.be.called; + expect(stubs.createItems).to.be.calledWith(newListEl, 95, 99); expect(virtualScroller.scrollingEl.replaceChild).to.be.called; }); }); diff --git a/src/lib/viewers/doc/DocBaseViewer.js b/src/lib/viewers/doc/DocBaseViewer.js index e139a8e93..e03f38e3c 100644 --- a/src/lib/viewers/doc/DocBaseViewer.js +++ b/src/lib/viewers/doc/DocBaseViewer.js @@ -8,7 +8,7 @@ import fullscreen from '../../Fullscreen'; import Popup from '../../Popup'; import RepStatus from '../../RepStatus'; import PreviewError from '../../PreviewError'; -import VirtualScroller from '../../VirtualScroller'; +import ThumbnailsSidebar from '../../ThumbnailsSidebar'; import { CLASS_BOX_PREVIEW_FIND_BAR, CLASS_CRAWLER, @@ -63,7 +63,6 @@ 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 { //-------------------------------------------------------------------------- @@ -1013,29 +1012,8 @@ class DocBaseViewer extends BaseViewer { } 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; - } - }); - }); + this.thumbnailsSidebar = new ThumbnailsSidebar(this.thumbnailsSidebarEl, this.pdfViewer); + this.thumbnailsSidebar.init(); } /** diff --git a/src/lib/viewers/doc/_docBase.scss b/src/lib/viewers/doc/_docBase.scss index fcffd6c2f..97d058578 100644 --- a/src/lib/viewers/doc/_docBase.scss +++ b/src/lib/viewers/doc/_docBase.scss @@ -9,13 +9,28 @@ .bp-thumbnail { align-items: center; - background-color: rgba(0, 0, 255, .5); + background-color: #d8d8d8; + border: 0; border-radius: 4px; display: flex; flex: 1 1 auto; justify-content: center; margin: 0 15px; + overflow: hidden; padding: 0; + position: relative; + } + + .bp-thumbnail-page-number { + background-color: $black; + border-radius: 4px; + bottom: 10px; + color: $white; + font-size: 11px; + line-height: 16px; + opacity: .7; + padding: 0 5px; + position: absolute; } }