Skip to content

Commit

Permalink
New: Virtual scroll for the thumbnails sidebar (#880)
Browse files Browse the repository at this point in the history
  • Loading branch information
Conrad Chan authored and Conrad Chan committed Feb 1, 2019
1 parent a686532 commit bfb99af
Show file tree
Hide file tree
Showing 7 changed files with 351 additions and 0 deletions.
1 change: 1 addition & 0 deletions src/lib/Preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/lib/Preview.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
@import 'navigation';
@import './Controls';
@import './ProgressBar';
@import './VirtualScroller';
239 changes: 239 additions & 0 deletions src/lib/VirtualScroller.js
Original file line number Diff line number Diff line change
@@ -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;
18 changes: 18 additions & 0 deletions src/lib/VirtualScroller.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
36 changes: 36 additions & 0 deletions src/lib/viewers/doc/DocBaseViewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
//--------------------------------------------------------------------------
Expand Down Expand Up @@ -177,6 +179,10 @@ class DocBaseViewer extends BaseViewer {
this.printPopup.destroy();
}

if (this.thumbnailsSidebar) {
this.thumbnailsSidebar.destroy();
}

super.destroy();
}

Expand Down Expand Up @@ -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;
}
});
});
}

/**
Expand Down
44 changes: 44 additions & 0 deletions src/lib/viewers/doc/__tests__/DocBaseViewer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
Loading

0 comments on commit bfb99af

Please sign in to comment.