From 4d3ecc31f4970072c4f442b053c7d6734d41db5d Mon Sep 17 00:00:00 2001 From: Conrad Chan Date: Tue, 19 Feb 2019 13:17:54 -0800 Subject: [PATCH] New: Thumbnails Sidebar (#932) --- UPGRADING.md | 36 + src/i18n/en-US.properties | 2 + src/index.html | 8 +- src/lib/BoundedCache.js | 62 ++ src/lib/Preview.js | 3 + src/lib/Preview.scss | 1 + src/lib/ThumbnailsSidebar.js | 458 +++++++++++++ src/lib/VirtualScroller.js | 470 +++++++++++++ src/lib/VirtualScroller.scss | 18 + src/lib/__tests__/BoundedCache-test.js | 59 ++ src/lib/__tests__/Preview-test.js | 8 +- src/lib/__tests__/PreviewUI-test.html | 1 + src/lib/__tests__/ThumbnailsSidebar-test.html | 1 + src/lib/__tests__/ThumbnailsSidebar-test.js | 514 ++++++++++++++ src/lib/__tests__/VirtualScroller-test.html | 1 + src/lib/__tests__/VirtualScroller-test.js | 648 ++++++++++++++++++ src/lib/_boxuiVariables.scss | 1 + src/lib/_common.scss | 16 +- src/lib/_loading.scss | 7 +- src/lib/_navigation.scss | 1 + src/lib/constants.js | 4 + src/lib/events.js | 7 + src/lib/icons/icons.js | 2 + src/lib/icons/thumbnails-toggle-icon.svg | 1 + src/lib/shell.html | 50 +- src/lib/viewers/BaseViewer.js | 73 +- .../viewers/__tests__/BaseViewer-test.html | 4 +- src/lib/viewers/__tests__/BaseViewer-test.js | 69 +- src/lib/viewers/box3d/Box3DViewer.js | 2 +- .../box3d/__tests__/Box3DViewer-test.html | 8 +- .../box3d/__tests__/Box3DViewer-test.js | 23 +- .../__tests__/Image360Viewer-test.html | 8 +- .../image360/__tests__/Image360Viewer-test.js | 3 +- .../model3d/__tests__/Model3DViewer-test.html | 8 +- .../__tests__/Video360Viewer-test.html | 8 +- src/lib/viewers/doc/DocBaseViewer.js | 149 +++- src/lib/viewers/doc/DocumentViewer.js | 24 - src/lib/viewers/doc/PresentationViewer.js | 25 - .../doc/__tests__/DocBaseViewer-test.html | 9 +- .../doc/__tests__/DocBaseViewer-test.js | 279 +++++++- .../doc/__tests__/DocumentViewer-test.html | 8 +- .../doc/__tests__/DocumentViewer-test.js | 41 -- .../__tests__/PresentationViewer-test.html | 8 +- .../doc/__tests__/PresentationViewer-test.js | 48 -- .../doc/__tests__/SinglePageViewer-test.html | 8 +- src/lib/viewers/doc/_docBase.scss | 74 ++ src/lib/viewers/error/PreviewErrorViewer.js | 2 +- .../__tests__/PreviewErrorViewer-test.html | 8 +- src/lib/viewers/iframe/IFrameViewer.js | 2 +- .../iframe/__tests__/IFrameViewer-test.html | 8 +- src/lib/viewers/image/ImageViewer.js | 2 +- src/lib/viewers/image/MultiImageViewer.js | 2 +- .../image/__tests__/ImageViewer-test.html | 10 +- .../__tests__/MultiImageViewer-test.html | 11 +- src/lib/viewers/media/MediaBaseViewer.js | 2 +- src/lib/viewers/media/VideoBaseViewer.js | 4 +- .../media/__tests__/DashViewer-test.html | 6 +- .../media/__tests__/MP3Viewer-test.html | 8 +- .../media/__tests__/MP4Viewer-test.html | 8 +- .../media/__tests__/MediaBaseViewer-test.html | 8 +- .../media/__tests__/VideoBaseViewer-test.html | 8 +- .../media/__tests__/VideoBaseViewer-test.js | 5 +- src/lib/viewers/office/OfficeViewer.js | 2 +- .../office/__tests__/OfficeViewer-test.html | 12 +- src/lib/viewers/swf/SWFViewer.js | 2 +- .../viewers/swf/__tests__/SWFViewer-test.html | 12 +- src/lib/viewers/text/CSVViewer.js | 2 +- src/lib/viewers/text/PlainTextViewer.js | 2 +- .../text/__tests__/CSVViewer-test.html | 8 +- .../text/__tests__/MarkdownViewer-test.html | 8 +- .../text/__tests__/PlainTextViewer-test.html | 8 +- .../integration/document/Controls.e2e.test.js | 13 +- .../document/Thumbnails.e2e.test.js | 99 +++ test/integration/sanity/Sanity.e2e.test.js | 3 +- test/support/commands.js | 16 +- 75 files changed, 3285 insertions(+), 254 deletions(-) create mode 100644 UPGRADING.md create mode 100644 src/lib/BoundedCache.js create mode 100644 src/lib/ThumbnailsSidebar.js create mode 100644 src/lib/VirtualScroller.js create mode 100644 src/lib/VirtualScroller.scss create mode 100644 src/lib/__tests__/BoundedCache-test.js create mode 100644 src/lib/__tests__/ThumbnailsSidebar-test.html create mode 100644 src/lib/__tests__/ThumbnailsSidebar-test.js create mode 100644 src/lib/__tests__/VirtualScroller-test.html create mode 100644 src/lib/__tests__/VirtualScroller-test.js create mode 100644 src/lib/icons/thumbnails-toggle-icon.svg create mode 100644 test/integration/document/Thumbnails.e2e.test.js diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 000000000..f709ac161 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,36 @@ +Upgrading Guide +========================= + +Upgrading from 1.x to 2.x +------------------------- +Version 2 includes a breaking change to the DOM structure of the Preview element. + +In version 1, the `.bp-navigate` buttons were siblings with the `.bp` container div +``` +
+ ... +
+ + +``` +But in version 2, the buttons are now inside a new container div `.bp-content`. +``` +
+
+ + +
+
+``` + +`.bp-content` is also the new point in which the various viewers will be dynamically inserted as children, i.e. `.bp-doc`, `.bp-image`, etc... + +This change in structure is to account for the new thumbnails sidebar which will appear to the left of the viewer content. diff --git a/src/i18n/en-US.properties b/src/i18n/en-US.properties index 43c7a421d..4f6dae52c 100644 --- a/src/i18n/en-US.properties +++ b/src/i18n/en-US.properties @@ -34,6 +34,8 @@ loading_preview=Loading Preview... download_file=Download File # Text shown when a text file has been truncated due to size limits. text_truncated=This file has been truncated due to size limits. Please download to view the whole file. +# Button tooltip to toggle Thumbnails Sidebar +toggle_thumbnails=Toggle thumbnails # Error messages # Default preview error message diff --git a/src/index.html b/src/index.html index b1f912fe3..85eb465ff 100644 --- a/src/index.html +++ b/src/index.html @@ -57,6 +57,10 @@ + +
+ +
@@ -77,9 +81,6 @@ // Cache it in local storage localStorage.setItem(selector, value) - - // Attempt to load Preview - loadPreview(); } function loadPreview(options) { @@ -102,6 +103,7 @@ // Try to load all properties from storage on page load setProperty('token'); setProperty('fileid'); + loadPreview(); diff --git a/src/lib/BoundedCache.js b/src/lib/BoundedCache.js new file mode 100644 index 000000000..3d66d7c4a --- /dev/null +++ b/src/lib/BoundedCache.js @@ -0,0 +1,62 @@ +import Cache from './Cache'; + +class BoundedCache extends Cache { + /** @property {Array} - Maintains the list of cache keys in order in which they were added to the cache */ + cacheQueue; + + /** @property {number} - The maximum number of entries in the cache */ + maxEntries; + + /** + * [constructor] + * + * @param {number} [maxEntries] - Override the maximum number of cache entries + */ + constructor(maxEntries) { + super(); + + this.maxEntries = maxEntries || 500; + this.cache = {}; + this.cacheQueue = []; + } + + /** + * Destroys the bounded cache + * + * @return {void} + */ + destroy() { + this.cache = null; + this.cacheQueue = null; + } + + /** + * Caches a simple object in memory. If the number of cache entries + * then exceeds the maxEntries value, then the earliest key in cacheQueue + * will be removed from the cache. + * + * @param {string} key - The cache key + * @param {*} value - The cache value + * @return {void} + */ + set(key, value) { + // If this key is not already in the cache, then add it + // to the cacheQueue. This avoids adding the same key to + // the cacheQueue multiple times if the cache entry gets updated + if (!this.inCache(key)) { + this.cacheQueue.push(key); + } + + super.set(key, value); + + // If the cacheQueue exceeds the maxEntries then remove the first + // key from the front of the cacheQueue and unset that entry + // from the cache + if (this.cacheQueue.length > this.maxEntries) { + const deleteKey = this.cacheQueue.shift(); + this.unset(deleteKey); + } + } +} + +export default BoundedCache; diff --git a/src/lib/Preview.js b/src/lib/Preview.js index c523cb852..aa37acde4 100644 --- a/src/lib/Preview.js +++ b/src/lib/Preview.js @@ -937,6 +937,9 @@ class Preview extends EventEmitter { // Options that are applicable to certain file ids this.options.fileOptions = options.fileOptions || {}; + // Option to enable use of thumbnails sidebar for document types + this.options.enableThumbnailsSidebar = !!options.enableThumbnailsSidebar; + // Prefix any user created loaders before our default ones this.loaders = (options.loaders || []).concat(loaderList); 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/ThumbnailsSidebar.js b/src/lib/ThumbnailsSidebar.js new file mode 100644 index 000000000..8ef30e887 --- /dev/null +++ b/src/lib/ThumbnailsSidebar.js @@ -0,0 +1,458 @@ +import isFinite from 'lodash/isFinite'; +import VirtualScroller from './VirtualScroller'; +import { CLASS_HIDDEN } from './constants'; +import BoundedCache from './BoundedCache'; + +const CLASS_BOX_PREVIEW_THUMBNAIL = 'bp-thumbnail'; +const CLASS_BOX_PREVIEW_THUMBNAIL_IMAGE = 'bp-thumbnail-image'; +const CLASS_BOX_PREVIEW_THUMBNAIL_IMAGE_LOADED = 'bp-thumbnail-image-loaded'; +const CLASS_BOX_PREVIEW_THUMBNAIL_IS_SELECTED = 'bp-thumbnail-is-selected'; +const CLASS_BOX_PREVIEW_THUMBNAIL_PAGE_NUMBER = 'bp-thumbnail-page-number'; +const DEFAULT_THUMBNAILS_SIDEBAR_WIDTH = 150; +const THUMBNAIL_MARGIN = 15; + +class ThumbnailsSidebar { + /** @property {HTMLElement} - The anchor element for this ThumbnailsSidebar */ + anchorEl; + + /** @property {number} - The width : height ratio of the pages of the document */ + pageRatio; + + /** @property {number} - The currently viewed page */ + currentPage; + + /** @property {Array} - The list of currently rendered thumbnail elements */ + currentThumbnails; + + /** @property {PDfViewer} - The PDFJS viewer instance */ + pdfViewer; + + /** @property {number} - The percentage (0-1) to scale down from the full page to thumbnail size */ + scale; + + /** @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.currentThumbnails = []; + this.pdfViewer = pdfViewer; + this.thumbnailImageCache = new BoundedCache(); + + this.createImageEl = this.createImageEl.bind(this); + this.createPlaceholderThumbnail = this.createPlaceholderThumbnail.bind(this); + this.createThumbnailImage = this.createThumbnailImage.bind(this); + this.generateThumbnailImages = this.generateThumbnailImages.bind(this); + this.getThumbnailDataURL = this.getThumbnailDataURL.bind(this); + this.renderNextThumbnailImage = this.renderNextThumbnailImage.bind(this); + this.requestThumbnailImage = this.requestThumbnailImage.bind(this); + this.thumbnailClickHandler = this.thumbnailClickHandler.bind(this); + + this.anchorEl.addEventListener('click', this.thumbnailClickHandler); + } + + /** + * Method to handle the click events in the Thumbnails Sidebar + * + * @param {Event} evt - Mouse click event + * @return {void} + */ + thumbnailClickHandler(evt) { + const { target } = evt; + + // Only care about clicks on the thumbnail element itself. + // The image and page number have pointer-events: none so + // any click should be the thumbnail element itself. + if (target.classList.contains(CLASS_BOX_PREVIEW_THUMBNAIL)) { + // Get the page number + const { bpPageNum: pageNumStr } = target.dataset; + const pageNum = parseInt(pageNumStr, 10); + + if (this.onClickHandler) { + this.onClickHandler(pageNum); + } + } + + evt.preventDefault(); + evt.stopImmediatePropagation(); + } + + /** + * Destroys the thumbnails sidebar + * + * @return {void} + */ + destroy() { + if (this.virtualScroller) { + this.virtualScroller.destroy(); + this.virtualScroller = null; + } + + if (this.thumbnailImageCache) { + this.thumbnailImageCache.destroy(); + this.thumbnailImageCache = null; + } + + this.pdfViewer = null; + this.currentThumbnails = []; + this.currentPage = null; + + this.anchorEl.removeEventListener('click', this.thumbnailClickHandler); + } + + /** + * Initializes the Thumbnails Sidebar + * + * @param {Object} [options] - options for the Thumbnails Sidebar + * @return {void} + */ + init(options) { + this.virtualScroller = new VirtualScroller(this.anchorEl); + + if (options) { + // Click handler for when a thumbnail is clicked + this.onClickHandler = options.onClick; + + // Specify the current page to be selected + this.currentPage = options.currentPage || 1; + } + + // 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)) { + // eslint-disable-next-line + 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.thumbnailHeight = Math.ceil(scaledViewport.height); + + this.virtualScroller.init({ + initialRowIndex: this.currentPage - 1, + totalItems: this.pdfViewer.pagesCount, + itemHeight: this.thumbnailHeight, + containerHeight: this.getContainerHeight(), + 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 }) { + this.currentThumbnails = items; + + // Serially renders the thumbnails one by one as needed + this.renderNextThumbnailImage(); + } + + /** + * Requests the next thumbnail image that needs rendering + * + * @return {void} + */ + renderNextThumbnailImage() { + // Iterates over the current thumbnails and requests rendering of the first + // thumbnail it encounters that does not have an image loaded, starting with + // the visible thumbnails first. + const visibleThumbnails = this.virtualScroller.getVisibleItems(); + const nextThumbnailEl = visibleThumbnails + .concat(this.currentThumbnails) + .find((thumbnailEl) => !thumbnailEl.classList.contains(CLASS_BOX_PREVIEW_THUMBNAIL_IMAGE_LOADED)); + + if (nextThumbnailEl) { + const parsedPageNum = parseInt(nextThumbnailEl.dataset.bpPageNum, 10); + this.requestThumbnailImage(parsedPageNum - 1, nextThumbnailEl); + } + } + + /** + * 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'); + const pageNum = itemIndex + 1; + + thumbnailEl.className = CLASS_BOX_PREVIEW_THUMBNAIL; + thumbnailEl.setAttribute('type', 'button'); + thumbnailEl.dataset.bpPageNum = pageNum; + thumbnailEl.appendChild(this.createPageNumber(pageNum)); + + if (pageNum === this.currentPage) { + thumbnailEl.classList.add(CLASS_BOX_PREVIEW_THUMBNAIL_IS_SELECTED); + } + + // If image is already in cache, then use it instead of waiting for + // the second render image pass + const cachedImage = this.thumbnailImageCache.get(itemIndex); + if (cachedImage && !cachedImage.inProgress) { + thumbnailEl.appendChild(cachedImage.image); + thumbnailEl.classList.add(CLASS_BOX_PREVIEW_THUMBNAIL_IMAGE_LOADED); + } + + 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) => { + // Promise will resolve with null if create image request was already in progress + if (imageEl) { + thumbnailEl.appendChild(imageEl); + thumbnailEl.classList.add(CLASS_BOX_PREVIEW_THUMBNAIL_IMAGE_LOADED); + } + + // After generating the thumbnail image, render the next one + this.renderNextThumbnailImage(); + }); + }); + } + + /** + * 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 or null if generation is in progress + */ + createThumbnailImage(itemIndex) { + const cacheEntry = this.thumbnailImageCache.get(itemIndex); + + // If this thumbnail has already been cached, use it + if (cacheEntry && cacheEntry.image) { + return Promise.resolve(cacheEntry.image); + } + + // If this thumbnail has already been requested, resolve with null + if (cacheEntry && cacheEntry.inProgress) { + return Promise.resolve(null); + } + + // Update the cache entry to be in progress + this.thumbnailImageCache.set(itemIndex, { ...cacheEntry, inProgress: true }); + + return this.getThumbnailDataURL(itemIndex + 1) + .then(this.createImageEl) + .then((imageEl) => { + // Cache this image element for future use + this.thumbnailImageCache.set(itemIndex, { inProgress: false, image: imageEl }); + + return imageEl; + }); + } + + /** + * Given a page number, generates the image data URL for the image of the page + * @param {number} pageNum - The page number of the document + * @return {string} The data URL of the page image + */ + getThumbnailDataURL(pageNum) { + const canvas = document.createElement('canvas'); + + return this.pdfViewer.pdfDocument + .getPage(pageNum) + .then((page) => { + const { width, height } = page.getViewport(1); + // Get the current page w:h ratio in case it differs from the first page + const curPageRatio = width / height; + + // Handle the case where the current page's w:h ratio is less than the + // `pageRatio` which means that this page is probably more portrait than + // landscape + if (curPageRatio < this.pageRatio) { + // Set the canvas height to that of the thumbnail max height + canvas.height = Math.ceil(DEFAULT_THUMBNAILS_SIDEBAR_WIDTH / this.pageRatio); + // Find the canvas width based on the curent page ratio + canvas.width = canvas.height * curPageRatio; + } else { + // In case the current page ratio is same as the first page + // or in case it's larger (which means that it's wider), keep + // the width at the max thumbnail width + canvas.width = DEFAULT_THUMBNAILS_SIDEBAR_WIDTH; + // Find the height based on the current page ratio + canvas.height = Math.ceil(DEFAULT_THUMBNAILS_SIDEBAR_WIDTH / curPageRatio); + } + + // The amount for which to scale down the current page + const { width: canvasWidth } = canvas; + const scale = canvasWidth / width; + return page.render({ + canvasContext: canvas.getContext('2d'), + viewport: page.getViewport(scale) + }); + }) + .then(() => canvas.toDataURL()); + } + + /** + * Creates the image element + * @param {string} dataUrl - The image data URL for the thumbnail + * @return {HTMLElement} - The image element + */ + createImageEl(dataUrl) { + const imageEl = document.createElement('div'); + imageEl.classList.add(CLASS_BOX_PREVIEW_THUMBNAIL_IMAGE); + imageEl.style.backgroundImage = `url('${dataUrl}')`; + + // Add the height and width to the image to be the same as the thumbnail + // so that the css `background-image` rules will work + imageEl.style.width = `${DEFAULT_THUMBNAILS_SIDEBAR_WIDTH}px`; + imageEl.style.height = `${this.thumbnailHeight}px`; + + 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 = CLASS_BOX_PREVIEW_THUMBNAIL_PAGE_NUMBER; + pageNumberEl.textContent = `${pageNumber}`; + return pageNumberEl; + } + + /** + * Sets the currently selected page + * + * @param {number} pageNumber - The page number to set to selected + * @return {void} + */ + setCurrentPage(pageNumber) { + const parsedPageNumber = parseInt(pageNumber, 10); + + if (parsedPageNumber >= 1 && parsedPageNumber <= this.pdfViewer.pagesCount) { + this.currentPage = parsedPageNumber; + this.applyCurrentPageSelection(); + this.virtualScroller.scrollIntoView(parsedPageNumber - 1); + } + } + + /** + * Based on current page selection, checks the currently + * visible thumbnails to toggle the appropriate class + * + * @return {void} + */ + applyCurrentPageSelection() { + this.currentThumbnails.forEach((thumbnailEl) => { + const parsedPageNum = parseInt(thumbnailEl.dataset.bpPageNum, 10); + if (parsedPageNum === this.currentPage) { + thumbnailEl.classList.add(CLASS_BOX_PREVIEW_THUMBNAIL_IS_SELECTED); + } else { + thumbnailEl.classList.remove(CLASS_BOX_PREVIEW_THUMBNAIL_IS_SELECTED); + } + }); + } + + /** + * 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); + } + + /** + * Resizes the thumbnails sidebar + * @return {void} + */ + resize() { + if (!this.virtualScroller) { + return; + } + + this.virtualScroller.resize(this.getContainerHeight()); + } + + /** + * Gets the available container height + * @return {number|null} - The height in pixels of the container or null if the anchorEl does not exist + */ + getContainerHeight() { + if (!this.anchorEl) { + return null; + } + + return this.anchorEl.parentNode.clientHeight; + } +} + +export default ThumbnailsSidebar; diff --git a/src/lib/VirtualScroller.js b/src/lib/VirtualScroller.js new file mode 100644 index 000000000..0d06fec25 --- /dev/null +++ b/src/lib/VirtualScroller.js @@ -0,0 +1,470 @@ +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 THROTTLE_SCROLL_THRESHOLD = 150; +const DEBOUNCE_SCROLL_THRESHOLD = 151; + +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 {number} - The previously recorded scrollTop value */ + previousScrollTop; + + /** @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.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); + } + + /** + * 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.onScrollEnd = config.onScrollEnd; + this.onScrollStart = config.onScrollStart; + + // 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.resize(this.containerHeight); + // 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(); + + if (config.onInit) { + const listInfo = this.getCurrentListInfo(); + config.onInit(listInfo); + } + } + + /** + * Given the container height, calculate the virtual window properties + * @param {number} containerHeight - the available container height of the virtual scroller + * @return {void} + */ + resize(containerHeight) { + if (!containerHeight || !isFinite(containerHeight)) { + return; + } + + this.containerHeight = containerHeight; + + this.totalViewItems = Math.floor(this.containerHeight / (this.itemHeight + this.margin)); + this.maxBufferHeight = this.totalViewItems * this.itemHeight; + this.maxRenderedItems = (this.totalViewItems + 1) * BUFFERED_ITEM_MULTIPLIER; + } + + /** + * 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 }); + this.scrollingEl.addEventListener('scroll', this.debouncedOnScrollEndHandler, { passive: true }); + } + + /** + * Unbinds DOM listeners + * + * @return {void} + */ + unbindDOMListeners() { + if (this.scrollingEl) { + this.scrollingEl.removeEventListener('scroll', this.throttledOnScrollHandler); + this.scrollingEl.removeEventListener('scroll', this.debouncedOnScrollEndHandler); + } + } + + /** + * 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; + } + } + + /** + * 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, children } = this.listEl; + + // Parse the row index from the data-attribute + let curStartOffset = firstElementChild ? parseInt(firstElementChild.dataset.bpVsRowIndex, 10) : -1; + let curEndOffset = lastElementChild ? parseInt(lastElementChild.dataset.bpVsRowIndex, 10) : -1; + + // If the data-attribute value is not present default to invalid -1 + curStartOffset = isFinite(curStartOffset) ? curStartOffset : -1; + curEndOffset = isFinite(curEndOffset) ? curEndOffset : -1; + + let items = []; + + if (children) { + // Extract an array of the user's created HTMLElements + items = this.getListItems().map( + (listItemEl) => (listItemEl && listItemEl.children ? listItemEl.children[0] : null) + ); + } + + return { + startOffset: curStartOffset, + endOffset: curEndOffset, + items + }; + } + + /** + * Render a set of items, starting from the offset index + * + * @param {number} offset - The offset to start rendering items + * @return {void} + */ + renderItems(offset = 0) { + // 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; + } + + // If specified offset is in the last window into the list then + // render that last window instead of starting at that offset + const lastWindowOffset = Math.max(0, 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 + if (newEndOffset >= this.totalItems) { + newEndOffset = this.totalItems - 1; + } + + // Creates a document fragment for the new list items to be appended into the list + const fragment = document.createDocumentFragment(); + + 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; + // Create elements from curEnd + 1 to newEndOffset + this.createItems(fragment, newStartOffset, newEndOffset); + // Delete the elements from curStartOffset to newStartOffset + this.deleteItems(this.listEl, curStartOffset - curStartOffset, offset - curStartOffset); + // Append the document fragment to the listEl + this.listEl.appendChild(fragment); + } 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(fragment, offset, curStartOffset - 1); + // Delete the elements from newEndOffset to the end + this.deleteItems(this.listEl, newEndOffset - curStartOffset + 1); + // Insert before the firstElementChild of the listEl + this.listEl.insertBefore(fragment, this.listEl.firstElementChild); + } else { + // Scenario #3: New range has no overlap with current range of items + // |--------------------| + // curStartOffset curEndOffset + // |--------------------| + // newStartOffset newEndOffset + this.createItems(fragment, newStartOffset, newEndOffset); + // Delete all the current elements (if any) + this.deleteItems(this.listEl); + this.listEl.appendChild(fragment); + } + } + + /** + * Creates new HTMLElements appended to the newList + * + * @param {HTMLElement} newListEl - the new `ol` element + * @param {number} start - start index + * @param {number} end - end index + * @return {void} + */ + createItems(newListEl, start, end) { + if (!newListEl || start < 0 || end < 0) { + return; + } + + for (let i = start; i <= end; i++) { + const newEl = this.renderItem(i); + newListEl.appendChild(newEl); + } + } + + /** + * Deletes elements of the 'ol' + * + * @param {HTMLElement} listEl - the `ol` element + * @param {number} [start] - start index + * @param {number} [end] - end index + * @return {void} + */ + deleteItems(listEl, start = 0, end) { + if (!listEl || start < 0 || end < 0) { + return; + } + + const listItems = Array.prototype.slice.call(listEl.children, start, end); + listItems.forEach((listItem) => listEl.removeChild(listItem)); + } + + /** + * 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'); + rowEl.dataset.bpVsRowIndex = rowIndex; + + 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; + } + + /** + * 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 = this.getListItems().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; + } + + /** + * Gets the currently visible list items + * @return {Array} - the list of current visible list items + */ + getVisibleItems() { + if (!this.listEl) { + return []; + } + + return this.getListItems() + .filter((itemEl) => this.isVisible(itemEl)) + .map((itemEl) => itemEl && itemEl.children && itemEl.children[0]); + } + + /** + * Gets the list items of this.listEl as an array + * @return {Array} - the list items + */ + getListItems() { + if (!this.listEl) { + return []; + } + + return Array.prototype.slice.call(this.listEl.children); + } +} + +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/__tests__/BoundedCache-test.js b/src/lib/__tests__/BoundedCache-test.js new file mode 100644 index 000000000..67900fa2e --- /dev/null +++ b/src/lib/__tests__/BoundedCache-test.js @@ -0,0 +1,59 @@ +/* eslint-disable no-unused-expressions */ +import BoundedCache from '../BoundedCache'; + +const sandbox = sinon.sandbox.create(); + +describe('BoundedCache', () => { + let cache; + + beforeEach(() => { + cache = new BoundedCache(2); + }); + + afterEach(() => { + sandbox.verifyAndRestore(); + + cache = null; + }); + + describe('constructor()', () => { + it('should initialize properties', () => { + cache = new BoundedCache(); + + expect(cache.maxEntries).to.be.equal(500); + expect(cache.cache).to.be.empty; + expect(cache.cacheQueue.length).to.be.equal(0); + }); + + it('should handle maxEntries', () => { + expect(cache.maxEntries).to.be.equal(2); + }); + }); + + describe('set()', () => { + it('should add the entry to the cache', () => { + cache.set('foo', 'bar'); + + expect(cache.inCache('foo')).to.be.true; + expect(cache.cacheQueue).to.be.eql(['foo']); + }); + + it('should not update the cacheQueue if key already exists', () => { + cache.set('foo', 'bar'); + cache.set('foo', 'bar2'); + + expect(cache.inCache('foo')).to.be.true; + expect(cache.get('foo')).to.be.equal('bar2'); + expect(cache.cacheQueue).to.be.eql(['foo']); + }); + + it('should remove the earliest added entry when entries exceed maxEntries', () => { + cache.set('foo', 'bar'); + cache.set('hello', 'world'); + cache.set('goodnight', 'moon'); + + expect(cache.inCache('foo')).to.be.false; + expect(cache.cacheQueue).to.be.eql(['hello', 'goodnight']); + }); + }); +}); diff --git a/src/lib/__tests__/Preview-test.js b/src/lib/__tests__/Preview-test.js index 0da224f5e..43f4b039e 100644 --- a/src/lib/__tests__/Preview-test.js +++ b/src/lib/__tests__/Preview-test.js @@ -1197,7 +1197,8 @@ describe('lib/Preview', () => { showAnnotations: true, fixDependencies: true, collection: stubs.collection, - loaders: stubs.loaders + loaders: stubs.loaders, + enableThumbnailsSidebar: true }; stubs.assign = sandbox.spy(Object, 'assign'); @@ -1310,6 +1311,11 @@ describe('lib/Preview', () => { expect(stubs.disableViewers).to.be.calledWith('Office'); expect(stubs.enableViewers).to.be.calledWith('text'); }); + + it('should set whether to enable thumbnails sidebar', () => { + preview.parseOptions(preview.previewOptions); + expect(preview.options.enableThumbnailsSidebar).to.be.true; + }); }); describe('createViewerOptions()', () => { diff --git a/src/lib/__tests__/PreviewUI-test.html b/src/lib/__tests__/PreviewUI-test.html index ff06f11c4..af105a43c 100644 --- a/src/lib/__tests__/PreviewUI-test.html +++ b/src/lib/__tests__/PreviewUI-test.html @@ -5,4 +5,5 @@ +
diff --git a/src/lib/__tests__/ThumbnailsSidebar-test.html b/src/lib/__tests__/ThumbnailsSidebar-test.html new file mode 100644 index 000000000..40d85cb37 --- /dev/null +++ b/src/lib/__tests__/ThumbnailsSidebar-test.html @@ -0,0 +1 @@ +
diff --git a/src/lib/__tests__/ThumbnailsSidebar-test.js b/src/lib/__tests__/ThumbnailsSidebar-test.js new file mode 100644 index 000000000..95f0f0fed --- /dev/null +++ b/src/lib/__tests__/ThumbnailsSidebar-test.js @@ -0,0 +1,514 @@ +/* eslint-disable no-unused-expressions */ +import ThumbnailsSidebar from '../ThumbnailsSidebar'; +import VirtualScroller from '../VirtualScroller'; +import { CLASS_HIDDEN } from '../constants'; + +const sandbox = sinon.sandbox.create(); + +describe('ThumbnailsSidebar', () => { + let thumbnailsSidebar; + let stubs = {}; + let pdfViewer = {}; + let page; + let virtualScroller; + let pagePromise; + let anchorEl; + + before(() => fixture.setBase('src/lib')); + + beforeEach(() => { + fixture.load('__tests__/ThumbnailsSidebar-test.html'); + + stubs.raf = sandbox.stub(window, 'requestAnimationFrame').callsFake((callback) => callback()); + + stubs.getViewport = sandbox.stub(); + stubs.render = sandbox.stub(); + + page = { + getViewport: stubs.getViewport, + render: stubs.render + }; + pagePromise = Promise.resolve(page); + + 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'); + stubs.vsGetVisibleItems = sandbox.stub(VirtualScroller.prototype, 'getVisibleItems'); + + virtualScroller = { + destroy: stubs.vsDestroy, + getVisibleItems: stubs.vsGetVisibleItems, + scrollIntoView: stubs.vsScrollIntoView + }; + + pdfViewer = { + pdfDocument: { + getPage: stubs.getPage + } + }; + + anchorEl = document.getElementById('test-thumbnails-sidebar'); + + thumbnailsSidebar = new ThumbnailsSidebar(anchorEl, pdfViewer); + }); + + afterEach(() => { + fixture.cleanup(); + sandbox.verifyAndRestore(); + + if (thumbnailsSidebar && typeof thumbnailsSidebar.destroy === 'function') { + thumbnailsSidebar.thumbnailImageCache = null; + thumbnailsSidebar.destroy(); + } + + thumbnailsSidebar = null; + stubs = {}; + }); + + describe('constructor()', () => { + it('should initialize properties', () => { + expect(thumbnailsSidebar.anchorEl.id).to.be.equal('test-thumbnails-sidebar'); + expect(thumbnailsSidebar.pdfViewer).to.be.equal(pdfViewer); + expect(thumbnailsSidebar.thumbnailImageCache.cache).to.be.empty; + expect(thumbnailsSidebar.scale).to.be.undefined; + expect(thumbnailsSidebar.pageRatio).to.be.undefined; + }); + }); + + describe('destroy()', () => { + it('should clean up the instance properties', () => { + thumbnailsSidebar.destroy(); + + expect(thumbnailsSidebar.thumbnailImageCache).to.be.null; + expect(thumbnailsSidebar.pdfViewer).to.be.null; + }); + + it('should destroy virtualScroller if it exists', () => { + thumbnailsSidebar.virtualScroller = virtualScroller; + thumbnailsSidebar.destroy(); + + expect(stubs.vsDestroy).to.be.called; + expect(thumbnailsSidebar.virtualScroller).to.be.null; + expect(thumbnailsSidebar.thumbnailImageCache).to.be.null; + expect(thumbnailsSidebar.pdfViewer).to.be.null; + }); + }); + + describe('init()', () => { + it('should initialize the render properties', () => { + stubs.getViewport.returns({ width: 10, height: 10 }); + + thumbnailsSidebar.init(); + + return pagePromise.then(() => { + expect(stubs.getViewport).to.be.called; + expect(thumbnailsSidebar.scale).to.be.equal(15); // DEFAULT_THUMBNAILS_SIDEBAR_WIDTH / width + expect(thumbnailsSidebar.pageRatio).to.be.equal(1); + expect(stubs.vsInit).to.be.called; + }); + }); + + it('should not initialize the render properties if viewport does not return width', () => { + stubs.getViewport.returns({ width: undefined, height: 10 }); + + thumbnailsSidebar.init(); + + return pagePromise.then(() => { + expect(stubs.getViewport).to.be.called; + expect(thumbnailsSidebar.scale).to.be.undefined; + expect(thumbnailsSidebar.pageRatio).to.be.undefined; + expect(stubs.vsInit).not.to.be.called; + }); + }); + + it('should not initialize the render properties if viewport does not return height', () => { + stubs.getViewport.returns({ width: 10, height: undefined }); + + thumbnailsSidebar.init(); + + return pagePromise.then(() => { + expect(stubs.getViewport).to.be.called; + expect(thumbnailsSidebar.scale).to.be.undefined; + expect(thumbnailsSidebar.pageRatio).to.be.undefined; + expect(stubs.vsInit).not.to.be.called; + }); + }); + + it('should not initialize the render properties if viewport does not return non zero width & height', () => { + stubs.getViewport.returns({ width: 0, height: 0 }); + + thumbnailsSidebar.init(); + + return pagePromise.then(() => { + expect(stubs.getViewport).to.be.called; + expect(thumbnailsSidebar.scale).to.be.undefined; + expect(thumbnailsSidebar.pageRatio).to.be.undefined; + expect(stubs.vsInit).not.to.be.called; + }); + }); + }); + + describe('renderNextThumbnailImage()', () => { + beforeEach(() => { + stubs.requestThumbnailImage = sandbox.stub(thumbnailsSidebar, 'requestThumbnailImage'); + thumbnailsSidebar.virtualScroller = virtualScroller; + stubs.vsGetVisibleItems.returns([]); + }); + + // eslint-disable-next-line + const createThumbnailEl = (pageNum, contains) => { + return { + classList: { + contains: () => contains + }, + dataset: { + bpPageNum: pageNum + } + }; + }; + + it('should do nothing there are no current thumbnails', () => { + thumbnailsSidebar.currentThumbnails = []; + thumbnailsSidebar.renderNextThumbnailImage(); + + expect(stubs.requestThumbnailImage).not.to.be.called; + }); + + it('should not request thumbnail images if thumbnail already contains image loaded class', () => { + const items = [createThumbnailEl(1, true)]; + thumbnailsSidebar.currentThumbnails = items; + + thumbnailsSidebar.renderNextThumbnailImage(); + + expect(stubs.requestThumbnailImage).not.to.be.called; + }); + + it('should request thumbnail images if thumbnail does not already contains image loaded class', () => { + const items = [createThumbnailEl(1, false)]; + thumbnailsSidebar.currentThumbnails = items; + thumbnailsSidebar.renderNextThumbnailImage(); + + expect(stubs.requestThumbnailImage).to.be.calledOnce; + }); + + it('should only request the first thumbnail that does not already contain an image loaded class', () => { + const items = [createThumbnailEl(1, true), createThumbnailEl(2, false), createThumbnailEl(3, false)]; + thumbnailsSidebar.currentThumbnails = items; + thumbnailsSidebar.renderNextThumbnailImage(); + + expect(stubs.requestThumbnailImage).to.be.calledOnce; + }); + }); + + describe('requestThumbnailImage()', () => { + it('should add the image to the thumbnail element', () => { + const imageEl = {}; + const createImagePromise = Promise.resolve(imageEl); + stubs.createThumbnailImage = sandbox + .stub(thumbnailsSidebar, 'createThumbnailImage') + .returns(createImagePromise); + stubs.appendChild = sandbox.stub(); + stubs.addClass = sandbox.stub(); + + const thumbnailEl = { + appendChild: stubs.appendChild, + classList: { add: stubs.addClass } + }; + + thumbnailsSidebar.requestThumbnailImage(0, thumbnailEl); + + return createImagePromise.then(() => { + expect(stubs.appendChild).to.be.called; + expect(stubs.addClass).to.be.called; + }); + }); + }); + + describe('createThumbnailImage', () => { + beforeEach(() => { + stubs.getThumbnailDataURL = sandbox + .stub(thumbnailsSidebar, 'getThumbnailDataURL') + .returns(Promise.resolve()); + stubs.createImageEl = sandbox.stub(thumbnailsSidebar, 'createImageEl'); + stubs.getCacheEntry = sandbox.stub(thumbnailsSidebar.thumbnailImageCache, 'get'); + stubs.setCacheEntry = sandbox.stub(thumbnailsSidebar.thumbnailImageCache, 'set'); + }); + + it('should resolve immediately if the image is in cache', () => { + const cachedImage = {}; + stubs.getCacheEntry.withArgs(1).returns({ image: cachedImage }); + + return thumbnailsSidebar.createThumbnailImage(1).then(() => { + expect(stubs.createImageEl).not.to.be.called; + }); + }); + + it('should create an image element if not in cache', () => { + const cachedImage = {}; + stubs.createImageEl.returns(cachedImage); + + return thumbnailsSidebar.createThumbnailImage(0).then((imageEl) => { + expect(stubs.createImageEl).to.be.called; + expect(stubs.setCacheEntry).to.be.calledWith(0, { inProgress: false, image: imageEl }); + }); + }); + + it('should resolve with null if cache entry inProgress is true', () => { + const cachedImage = {}; + stubs.getCacheEntry.withArgs(0).returns({ inProgress: true }); + stubs.createImageEl.returns(cachedImage); + + return thumbnailsSidebar.createThumbnailImage(0).then((imageEl) => { + expect(stubs.createImageEl).not.to.be.called; + expect(imageEl).to.be.null; + }); + }); + }); + + describe('getThumbnailDataURL()', () => { + beforeEach(() => { + stubs.getCacheEntry = sandbox.stub(thumbnailsSidebar.thumbnailImageCache, 'get'); + stubs.setCacheEntry = sandbox.stub(thumbnailsSidebar.thumbnailImageCache, 'set'); + thumbnailsSidebar.thumbnailImageCache = { get: stubs.getCacheEntry, set: stubs.setCacheEntry }; + }); + + it('should scale canvas the same as the first page if page ratio is the same', () => { + const cachedImage = {}; + stubs.getCacheEntry.withArgs(1).returns(cachedImage); + thumbnailsSidebar.pageRatio = 1; + + // Current page has same ratio + stubs.getViewport.withArgs(1).returns({ width: 10, height: 10 }); + stubs.render.returns(Promise.resolve()); + + const expScale = 15; // Should be DEFAULT_THUMBNAILS_SIDEBAR_WIDTH(150) / 10 + + return thumbnailsSidebar.getThumbnailDataURL(1).then(() => { + expect(stubs.getPage).to.be.called; + expect(stubs.getViewport.withArgs(expScale)).to.be.called; + }); + }); + + it('should handle non-uniform page ratios', () => { + const cachedImage = {}; + stubs.getCacheEntry.withArgs(1).returns(cachedImage); + thumbnailsSidebar.pageRatio = 1; + + // Current page has ratio of 0.5 instead of 1 + stubs.getViewport.withArgs(1).returns({ width: 10, height: 20 }); + stubs.render.returns(Promise.resolve()); + + const expScale = 7.5; // Should be 7.5 instead of 15 because the viewport ratio above is 0.5 instead of 1 + + return thumbnailsSidebar.createThumbnailImage(0).then(() => { + expect(stubs.getPage).to.be.called; + expect(stubs.getViewport.withArgs(expScale)).to.be.called; + }); + }); + }); + + describe('thumbnailClickHandler()', () => { + let targetEl; + let evt; + + beforeEach(() => { + stubs.onClickHandler = sandbox.stub(); + stubs.preventDefault = sandbox.stub(); + stubs.stopImmediatePropagation = sandbox.stub(); + + targetEl = document.createElement('div'); + targetEl.classList.add('bp-thumbnail'); + targetEl.dataset.bpPageNum = '3'; + + evt = { + target: targetEl, + preventDefault: stubs.preventDefault, + stopImmediatePropagation: stubs.stopImmediatePropagation + }; + + thumbnailsSidebar.onClickHandler = stubs.onClickHandler; + }); + + it('should call the onClickHandler if target is a thumbnail element', () => { + thumbnailsSidebar.thumbnailClickHandler(evt); + + expect(stubs.onClickHandler).to.be.calledWith(3); + expect(stubs.preventDefault).to.be.called; + expect(stubs.stopImmediatePropagation).to.be.called; + }); + + it('should not call the onClickHandler if target is not thumbnail element', () => { + targetEl.classList.remove('bp-thumbnail'); + thumbnailsSidebar.thumbnailClickHandler(evt); + + expect(stubs.onClickHandler).not.to.be.called; + expect(stubs.preventDefault).to.be.called; + expect(stubs.stopImmediatePropagation).to.be.called; + }); + }); + + describe('setCurrentPage()', () => { + beforeEach(() => { + stubs.applyCurrentPageSelection = sandbox.stub(thumbnailsSidebar, 'applyCurrentPageSelection'); + thumbnailsSidebar.pdfViewer = { pagesCount: 10 }; + thumbnailsSidebar.virtualScroller = virtualScroller; + }); + + const paramaterizedTests = [ + { name: 'pageNumber is undefined', pageNumber: undefined }, + { name: 'pageNumber is less than 1', pageNumber: 0 }, + { name: 'pageNumber is greater than last page', pageNumber: 11 } + ]; + + paramaterizedTests.forEach(({ name, pageNumber }) => { + it(`should do nothing if ${name}`, () => { + thumbnailsSidebar.setCurrentPage(pageNumber); + + expect(thumbnailsSidebar.currentPage).to.be.undefined; + expect(stubs.applyCurrentPageSelection).not.to.be.called; + }); + }); + + it('should set the currentPage and apply current page selection', () => { + thumbnailsSidebar.setCurrentPage(3); + + expect(thumbnailsSidebar.currentPage).to.be.equal(3); + expect(stubs.applyCurrentPageSelection).to.be.called; + expect(stubs.vsScrollIntoView).to.be.calledWith(2); + }); + }); + + describe('applyCurrentPageSelection()', () => { + let thumbnails; + + beforeEach(() => { + stubs.addClass = sandbox.stub(); + stubs.removeClass = sandbox.stub(); + + // eslint-disable-next-line + const createTestThumbnail = (pageNum) => { + const thumbnail = document.createElement('div'); + thumbnail.dataset.bpPageNum = pageNum; + thumbnail.classList.add = stubs.addClass; + thumbnail.classList.remove = stubs.removeClass; + return thumbnail; + }; + + const thumbnail1 = createTestThumbnail('1'); + const thumbnail2 = createTestThumbnail('2'); + const thumbnail3 = createTestThumbnail('3'); + + thumbnails = [thumbnail1, thumbnail2, thumbnail3]; + + thumbnailsSidebar.currentThumbnails = thumbnails; + }); + + it('should remove the is selected class from all thumbnails', () => { + thumbnailsSidebar.currentPage = 10; + + thumbnailsSidebar.applyCurrentPageSelection(); + + expect(stubs.removeClass).to.be.calledThrice; + expect(stubs.addClass).not.to.be.called; + }); + + it('should remove the is selected class from all thumbnails', () => { + thumbnailsSidebar.currentPage = 2; + + thumbnailsSidebar.applyCurrentPageSelection(); + + expect(stubs.removeClass).to.be.calledTwice; + 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.html b/src/lib/__tests__/VirtualScroller-test.html new file mode 100644 index 000000000..03a74a801 --- /dev/null +++ b/src/lib/__tests__/VirtualScroller-test.html @@ -0,0 +1 @@ +
diff --git a/src/lib/__tests__/VirtualScroller-test.js b/src/lib/__tests__/VirtualScroller-test.js new file mode 100644 index 000000000..bc5cefb8c --- /dev/null +++ b/src/lib/__tests__/VirtualScroller-test.js @@ -0,0 +1,648 @@ +/* eslint-disable no-unused-expressions */ +import VirtualScroller from '../VirtualScroller'; + +let virtualScroller; +let stubs = {}; + +const sandbox = sinon.sandbox.create(); + +describe('VirtualScroller', () => { + before(() => fixture.setBase('src/lib')); + + beforeEach(() => { + fixture.load('__tests__/VirtualScroller-test.html'); + virtualScroller = new VirtualScroller(document.getElementById('test-virtual-scroller')); + }); + + afterEach(() => { + fixture.cleanup(); + sandbox.verifyAndRestore(); + + if (virtualScroller && typeof virtualScroller.destroy === 'function') { + virtualScroller.destroy(); + } + + virtualScroller = null; + stubs = {}; + }); + + describe('constructor()', () => { + it('should initialize anchorEl and previousScrollTop', () => { + expect(virtualScroller.anchorEl.id).to.be.equal('test-virtual-scroller'); + expect(virtualScroller.previousScrollTop).to.be.equal(0); + }); + }); + + describe('destroy()', () => { + it('should remove the HTML element references', () => { + const scrollingEl = { remove: () => {} }; + sandbox.stub(scrollingEl, 'remove'); + + virtualScroller.scrollingEl = scrollingEl; + virtualScroller.listEl = {}; + + virtualScroller.destroy(); + + expect(scrollingEl.remove).to.be.called; + expect(virtualScroller.scrollingEl).to.be.null; + expect(virtualScroller.listEl).to.be.null; + }); + }); + + describe('init()', () => { + beforeEach(() => { + stubs.validateRequiredConfig = sandbox.stub(virtualScroller, 'validateRequiredConfig'); + }); + + it('should parse the config object', () => { + stubs.renderItemFn = sandbox.stub(); + stubs.renderItems = sandbox.stub(virtualScroller, 'renderItems'); + stubs.bindDOMListeners = sandbox.stub(virtualScroller, 'bindDOMListeners'); + + virtualScroller.init({ + totalItems: 10, + itemHeight: 100, + containerHeight: 500, + renderItemFn: stubs.renderItemFn + }); + + expect(virtualScroller.totalItems).to.be.equal(10); + expect(virtualScroller.itemHeight).to.be.equal(100); + expect(virtualScroller.containerHeight).to.be.equal(500); + expect(virtualScroller.renderItemFn).to.be.equal(stubs.renderItemFn); + expect(virtualScroller.margin).to.be.equal(0); + expect(virtualScroller.totalViewItems).to.be.equal(5); + expect(virtualScroller.maxBufferHeight).to.be.equal(500); + expect(virtualScroller.maxRenderedItems).to.be.equal(18); + + expect(virtualScroller.scrollingEl.classList.contains('bp-vs')).to.be.true; + expect(virtualScroller.listEl.classList.contains('bp-vs-list')).to.be.true; + + expect(stubs.renderItems).to.be.called; + expect(stubs.bindDOMListeners).to.be.called; + }); + + it('should call onInit if provided', () => { + const mockListInfo = {}; + stubs.getCurrentListInfo = sandbox.stub(virtualScroller, 'getCurrentListInfo').returns(mockListInfo); + stubs.onInitHandler = sandbox.stub(); + + virtualScroller.init({ + totalItems: 10, + itemHeight: 100, + containerHeight: 500, + renderItemFn: stubs.renderItemFn, + onInit: stubs.onInitHandler + }); + + 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()', () => { + it('should not throw an error if config is good', () => { + expect(() => + virtualScroller.validateRequiredConfig({ + totalItems: 10, + itemHeight: 100, + renderItemFn: () => {}, + containerHeight: 500 + }) + ).to.not.throw(); + }); + + [ + { name: 'totalItems falsy', config: {} }, + { name: 'totalItems not finite', config: { totalItems: '10' } }, + { name: 'itemHeight falsy', config: { totalItems: 10 } }, + { name: 'itemHeight not finite', config: { totalItems: 10, itemHeight: '100' } }, + { name: 'renderItemFn falsy', config: { totalItems: 10, itemHeight: 100 } }, + { name: 'renderItemFn not a function', config: { totalItems: 10, itemHeight: 100, renderItemFn: 'hi' } }, + { name: 'containerHeight falsy', config: { totalItems: 10, itemHeight: 100, renderItemFn: () => {} } }, + { + name: 'containerHeight not finite', + config: { totalItems: 10, itemHeight: 100, renderItemFn: () => {}, containerHeight: '500' } + } + ].forEach((data) => { + it(`should throw an error if config is bad: ${data.name}`, () => { + expect(() => virtualScroller.validateRequiredConfig(data.config)).to.throw(); + }); + }); + }); + + describe('onScrollHandler()', () => { + beforeEach(() => { + stubs.renderItems = sandbox.stub(virtualScroller, 'renderItems'); + virtualScroller.maxBufferHeight = 100; + }); + + it('should not proceed if the scroll movement < maxBufferHeight', () => { + virtualScroller.previousScrollTop = 0; + virtualScroller.onScrollHandler({ target: { scrollTop: 10 } }); + + expect(stubs.renderItems).to.not.be.called; + }); + + it('should proceed if positive scroll movement > maxBufferHeight', () => { + virtualScroller.previousScrollTop = 0; + virtualScroller.onScrollHandler({ target: { scrollTop: 101 } }); + + expect(stubs.renderItems).to.be.called; + expect(virtualScroller.previousScrollTop).to.be.equal(101); + }); + + it('should proceed if negative scroll movement > maxBufferHeight', () => { + virtualScroller.previousScrollTop = 102; + virtualScroller.onScrollHandler({ target: { scrollTop: 1 } }); + + expect(stubs.renderItems).to.be.called; + expect(virtualScroller.previousScrollTop).to.be.equal(1); + }); + }); + + describe('renderItems()', () => { + let newListEl; + let curListEl; + + beforeEach(() => { + stubs.appendChild = sandbox.stub(); + stubs.insertBefore = sandbox.stub(); + 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'); + stubs.createItems = sandbox.stub(virtualScroller, 'createItems'); + stubs.deleteItems = sandbox.stub(virtualScroller, 'deleteItems'); + stubs.createDocumentFragment = sandbox.stub(document, 'createDocumentFragment').returns(newListEl); + }); + + it('should render the whole range of items (no reuse)', () => { + stubs.getCurrentListInfo.returns({ + startOffset: -1, + endOffset: -1 + }); + virtualScroller.renderItems(); + + expect(stubs.deleteItems).to.be.calledWith(curListEl); + expect(stubs.createItems).to.be.calledWith(newListEl, 0, 10); + expect(stubs.appendChild).to.be.called; + expect(stubs.insertBefore).not.to.be.called; + }); + + it('should render the last window into the list', () => { + stubs.getCurrentListInfo.returns({ + startOffset: -1, + endOffset: -1 + }); + virtualScroller.renderItems(95); + + expect(stubs.deleteItems).to.be.calledWith(curListEl); + 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', () => { + stubs.getCurrentListInfo.returns({ + startOffset: 20, + endOffset: 30 + }); + virtualScroller.renderItems(15); + + expect(stubs.deleteItems).to.be.called; + expect(stubs.createItems).to.be.calledWith(newListEl, 15, 19); + expect(stubs.appendChild).not.to.be.called; + expect(stubs.insertBefore).to.be.called; + }); + }); + + describe('renderItem()', () => { + it('should render an item absolutely positioned with arbitrary content', () => { + const renderedThumbnail = document.createElement('button'); + renderedThumbnail.className = 'rendered-thumbnail'; + stubs.renderItemFn = sandbox.stub().returns(renderedThumbnail); + + virtualScroller.itemHeight = 100; + virtualScroller.margin = 0; + virtualScroller.renderItemFn = stubs.renderItemFn; + + const item = virtualScroller.renderItem(0); + expect(stubs.renderItemFn).to.be.called; + expect(item.classList.contains('bp-vs-list-item')).to.be.true; + expect(item.firstChild.classList.contains('rendered-thumbnail')).to.be.true; + }); + + it('should still render the item even if renderItemFn throws an error', () => { + const renderedThumbnail = document.createElement('button'); + renderedThumbnail.className = 'rendered-thumbnail'; + stubs.renderItemFn = sandbox.stub().throws(); + + virtualScroller.itemHeight = 100; + virtualScroller.margin = 0; + virtualScroller.renderItemFn = stubs.renderItemFn; + + const item = virtualScroller.renderItem(0); + expect(stubs.renderItemFn).to.be.called; + expect(item.classList.contains('bp-vs-list-item')).to.be.true; + expect(item.firstChild).to.be.null; + }); + }); + + describe('createListElement()', () => { + it('should return the list element', () => { + virtualScroller.totalItems = 10; + virtualScroller.itemHeight = 100; + virtualScroller.margin = 0; + + expect(virtualScroller.createListElement().classList.contains('bp-vs-list')).to.be.true; + }); + }); + + describe('onScrollEndHandler()', () => { + beforeEach(() => { + stubs.getCurrentListInfo = sandbox.stub(virtualScroller, 'getCurrentListInfo'); + }); + + it('should do nothing if onScrollEnd is not set', () => { + virtualScroller.onScrollEndHandler(); + + expect(stubs.getCurrentListInfo).not.to.be.called; + }); + + it('should call onScrollEnd with listInfo object', () => { + stubs.onScrollEnd = sandbox.stub(); + virtualScroller.onScrollEnd = stubs.onScrollEnd; + + virtualScroller.onScrollEndHandler(); + + expect(stubs.getCurrentListInfo).to.be.called; + expect(stubs.onScrollEnd).to.be.called; + }); + }); + + describe('getCurrentListInfo()', () => { + let item1; + let item2; + + beforeEach(() => { + item1 = { data: 'hello' }; + item2 = { data: 'bye' }; + }); + + it('should return -1 for offsets if elements do not exist', () => { + virtualScroller.listEl = { + children: [{ children: [item1] }, { children: [item2] }] + }; + + const retObj = virtualScroller.getCurrentListInfo(); + expect(retObj.startOffset).to.be.equal(-1); + expect(retObj.endOffset).to.be.equal(-1); + expect(retObj.items).to.be.eql([item1, item2]); + }); + + it('should return -1 for offsets if data attribute is not a number', () => { + virtualScroller.listEl = { + firstElementChild: { children: [item1], dataset: {} }, + lastElementChild: { children: [item2], dataset: {} }, + children: [{ children: [item1] }, { children: [item2] }] + }; + + const retObj = virtualScroller.getCurrentListInfo(); + expect(retObj.startOffset).to.be.equal(-1); + expect(retObj.endOffset).to.be.equal(-1); + expect(retObj.items).to.be.eql([item1, item2]); + }); + + it('should retrieve the correct data attributes for start and end offsets', () => { + virtualScroller.listEl = { + firstElementChild: { children: [item1], dataset: { bpVsRowIndex: '0' } }, + lastElementChild: { children: [item2], dataset: { bpVsRowIndex: '10' } }, + children: [{ children: [item1] }, { children: [item2] }] + }; + + const retObj = virtualScroller.getCurrentListInfo(); + expect(retObj.startOffset).to.be.equal(0); + expect(retObj.endOffset).to.be.equal(10); + expect(retObj.items).to.be.eql([item1, item2]); + }); + + it('should return [] for items if no children', () => { + virtualScroller.listEl = { + firstElementChild: { children: [item1], dataset: {} }, + lastElementChild: { children: [item2], dataset: {} } + }; + + const retObj = virtualScroller.getCurrentListInfo(); + expect(retObj.startOffset).to.be.equal(-1); + expect(retObj.endOffset).to.be.equal(-1); + expect(retObj.items).to.be.empty; + }); + }); + + describe('deleteItems()', () => { + let listEl; + + beforeEach(() => { + stubs.removeChild = sandbox.stub(); + listEl = { removeChild: stubs.removeChild }; + }); + + const paramaterizedTests = [ + { name: 'no listEl provided', listEl: undefined, start: 1, end: 2 }, + { name: 'no start provided', listEl, start: undefined, end: 2 }, + { name: 'no end provided', listEl, start: 1, end: undefined }, + { name: 'start is < 0 provided', listEl, start: -1, end: 2 }, + { name: 'end is < 0 provided', listEl, start: 1, end: -1 } + ]; + + paramaterizedTests.forEach((testData) => { + it(`should do nothing if ${testData.name}`, () => { + const { listEl: list, start, end } = testData; + + virtualScroller.deleteItems(list, start, end); + + expect(stubs.removeChild).not.to.be.called; + }); + }); + + it('should remove the items specified', () => { + const list = { + children: [{}, {}, {}, {}], + removeChild: stubs.removeChild + }; + + virtualScroller.deleteItems(list, 0, 1); + + expect(stubs.removeChild).to.be.calledOnce; + }); + + it('should remove the items specified from start to the end when end is not provided', () => { + const list = { + children: [{}, {}, {}, {}], + removeChild: stubs.removeChild + }; + + virtualScroller.deleteItems(list, 2); + + expect(stubs.removeChild).to.be.calledTwice; + }); + }); + + describe('createItems()', () => { + let newListEl; + + beforeEach(() => { + stubs.appendChild = sandbox.stub(); + stubs.renderItem = sandbox.stub(virtualScroller, 'renderItem'); + newListEl = { appendChild: stubs.appendChild }; + }); + + const paramaterizedTests = [ + { name: 'no newListEl provided', newListEl: undefined, oldListEl: {}, start: 1, end: 2 }, + { name: 'no start provided', newListEl, oldListEl: {}, start: undefined, end: 2 }, + { name: 'no end provided', newListEl, oldListEl: {}, start: 1, end: undefined }, + { name: 'start is < 0 provided', newListEl, oldListEl: {}, start: -1, end: 2 }, + { name: 'end is < 0 provided', newListEl, oldListEl: {}, start: 1, end: -1 } + ]; + + paramaterizedTests.forEach((testData) => { + it(`should do nothing if ${testData.name}`, () => { + const { newListEl: newList, start, end } = testData; + + virtualScroller.createItems(newList, start, end); + + expect(stubs.appendChild).not.to.be.called; + }); + }); + + it('should create the new items specified', () => { + virtualScroller.createItems(newListEl, 0, 2); + + expect(stubs.renderItem).to.be.calledThrice; + 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()', () => { + beforeEach(() => { + const 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; + }); + }); + + describe('getVisibleItems()', () => { + it('should return empty list if listEl is falsy', () => { + virtualScroller.listEl = false; + + expect(virtualScroller.getVisibleItems()).to.be.empty; + }); + + it('should return only visible list items', () => { + const listEl = { + children: [{ children: [{ val: 1 }] }, { children: [{ val: 2 }] }, { children: [{ val: 3 }] }] + }; + + const expectedItems = [{ val: 1 }, { val: 3 }]; + + stubs.isVisible = sandbox.stub(virtualScroller, 'isVisible'); + // Only the first and third children are visible + stubs.isVisible.onFirstCall().returns(true); + stubs.isVisible.onSecondCall().returns(false); + stubs.isVisible.onThirdCall().returns(true); + + virtualScroller.listEl = listEl; + + expect(virtualScroller.getVisibleItems()).to.be.eql(expectedItems); + }); + }); + + describe('resize()', () => { + it('should do nothing if containerHeight is not provided', () => { + virtualScroller.containerHeight = 1; + virtualScroller.totalViewItems = 2; + virtualScroller.maxBufferHeight = 3; + virtualScroller.maxRenderedItems = 4; + + virtualScroller.resize(); + + expect(virtualScroller.containerHeight).to.be.equal(1); + expect(virtualScroller.totalViewItems).to.be.equal(2); + expect(virtualScroller.maxBufferHeight).to.be.equal(3); + expect(virtualScroller.maxRenderedItems).to.be.equal(4); + }); + + it('should do nothing if containerHeight is not a number', () => { + virtualScroller.containerHeight = 1; + virtualScroller.totalViewItems = 2; + virtualScroller.maxBufferHeight = 3; + virtualScroller.maxRenderedItems = 4; + + virtualScroller.resize('123'); + + expect(virtualScroller.containerHeight).to.be.equal(1); + expect(virtualScroller.totalViewItems).to.be.equal(2); + expect(virtualScroller.maxBufferHeight).to.be.equal(3); + expect(virtualScroller.maxRenderedItems).to.be.equal(4); + }); + + it('should update the virtual window properties', () => { + virtualScroller.itemHeight = 10; + virtualScroller.margin = 0; + virtualScroller.containerHeight = 1; + virtualScroller.totalViewItems = 2; + virtualScroller.maxBufferHeight = 3; + virtualScroller.maxRenderedItems = 4; + + virtualScroller.resize(100); + + expect(virtualScroller.containerHeight).to.be.equal(100); + expect(virtualScroller.totalViewItems).to.be.equal(10); + expect(virtualScroller.maxBufferHeight).to.be.equal(100); + expect(virtualScroller.maxRenderedItems).to.be.equal(33); + }); + }); +}); diff --git a/src/lib/_boxuiVariables.scss b/src/lib/_boxuiVariables.scss index 551927a7e..cf79026c7 100644 --- a/src/lib/_boxuiVariables.scss +++ b/src/lib/_boxuiVariables.scss @@ -48,3 +48,4 @@ $tendemob-grey: #64686d !default; $sunset-grey: #464a4f !default; $seventy-sixers: #767676 !default; $approx-mischka-grey: #a3adb9 !default; +$d-eight: #d8d8d8 !default; diff --git a/src/lib/_common.scss b/src/lib/_common.scss index 14c9946dd..5f8951712 100644 --- a/src/lib/_common.scss +++ b/src/lib/_common.scss @@ -118,13 +118,11 @@ $header-height: 48px; } .bp { - align-items: center; background-color: $ffive; border: 0 none; bottom: 0; display: flex; - flex-direction: column; - justify-content: center; + flex: 1 1 100%; left: 0; margin: 0; outline: none; @@ -146,6 +144,18 @@ $header-height: 48px; &.bp-dark { background-color: $black; } + + .bp-content { + align-items: center; + display: flex; + flex: 1 1 auto; + position: relative; + } + + .bp-content.bp-is-fullscreen { + background-color: inherit; // Safari needs some reminder of what to do for flex items in a flex container when in fullscreen + width: 100%; + } } .accessibility-hidden { diff --git a/src/lib/_loading.scss b/src/lib/_loading.scss index ae9189306..a88292d0c 100644 --- a/src/lib/_loading.scss +++ b/src/lib/_loading.scss @@ -29,8 +29,14 @@ animation-duration: 1s; animation-iteration-count: 1; animation-name: fadeIn; + bottom: 0; display: flex; flex-direction: column; + justify-content: center; + left: 0; + position: absolute; + right: 0; + top: 0; transition: .25s opacity, .25s transform; .bp-loaded & { @@ -51,7 +57,6 @@ color: $twos; position: relative; text-align: center; - z-index: 1; } .bp-loading-btn-container { diff --git a/src/lib/_navigation.scss b/src/lib/_navigation.scss index 587432875..5700929c6 100644 --- a/src/lib/_navigation.scss +++ b/src/lib/_navigation.scss @@ -13,6 +13,7 @@ $navigationBtnWidth: 50px; margin: 0; opacity: 0; overflow: hidden; + pointer-events: all; position: absolute; text-decoration: none; top: 50%; diff --git a/src/lib/constants.js b/src/lib/constants.js index 8248a0280..b847368cc 100644 --- a/src/lib/constants.js +++ b/src/lib/constants.js @@ -5,6 +5,7 @@ export const CLASS_PREVIEW_LOADED = 'bp-loaded'; export const CLASS_BOX_PREVIEW = 'bp'; export const CLASS_BOX_PREVIEW_BUTTON = 'bp-btn'; export const CLASS_BOX_PREVIEW_CONTAINER = 'bp-container'; +export const CLASS_BOX_PREVIEW_CONTENT = 'bp-content'; export const CLASS_BOX_PREVIEW_FIND_BAR = 'bp-find-bar'; export const CLASS_BOX_PREVIEW_HAS_HEADER = 'bp-has-header'; export const CLASS_BOX_PREVIEW_HAS_NAVIGATION = 'bp-has-navigation'; @@ -33,6 +34,7 @@ export const CLASS_BOX_PREVIEW_NOTIFICATION = 'bp-notification'; export const CLASS_BOX_PREVIEW_NOTIFICATION_WRAPPER = 'bp-notifications-wrapper'; export const CLASS_BOX_PREVIEW_TOGGLE_OVERLAY = 'bp-toggle-overlay'; export const CLASS_BOX_PREVIEW_THEME_DARK = 'bp-theme-dark'; +export const CLASS_BOX_PREVIEW_THUMBNAILS_CONTAINER = 'bp-thumbnails-container'; export const CLASS_ELEM_KEYBOARD_FOCUS = 'bp-has-keyboard-focus'; export const CLASS_FULLSCREEN = 'bp-is-fullscreen'; export const CLASS_FULLSCREEN_UNSUPPORTED = 'bp-fullscreen-unsupported'; @@ -49,6 +51,7 @@ export const CLASS_SPINNER = 'bp-spinner'; export const SELECTOR_BOX_PREVIEW_CONTAINER = `.${CLASS_BOX_PREVIEW_CONTAINER}`; export const SELECTOR_BOX_PREVIEW = `.${CLASS_BOX_PREVIEW}`; +export const SELECTOR_BOX_PREVIEW_CONTENT = `.${CLASS_BOX_PREVIEW_CONTENT}`; export const SELECTOR_BOX_PREVIEW_CRAWLER_WRAPPER = '.bp-crawler-wrapper'; export const SELECTOR_BOX_PREVIEW_HEADER_BTNS = `.${CLASS_BOX_PREVIEW_HEADER_BTNS}`; export const SELECTOR_NAVIGATION_LEFT = '.bp-navigate-left'; @@ -67,6 +70,7 @@ export const SELECTOR_BOX_PREVIEW_LOGO_CUSTOM = `.${CLASS_BOX_PREVIEW_LOGO_CUSTO export const SELECTOR_BOX_PREVIEW_LOGO_DEFAULT = `.${CLASS_BOX_PREVIEW_LOGO_DEFAULT}`; export const SELECTOR_BOX_PREVIEW_PROGRESS_BAR = `.${CLASS_BOX_PREVIEW_PROGRESS_BAR}`; export const SELECTOR_BOX_PREVIEW_NOTIFICATION = `.${CLASS_BOX_PREVIEW_NOTIFICATION}`; +export const SELECTOR_BOX_PREVIEW_THUMBNAILS_CONTAINER = `.${CLASS_BOX_PREVIEW_THUMBNAILS_CONTAINER}`; export const PERMISSION_DOWNLOAD = 'can_download'; export const PERMISSION_PREVIEW = 'can_preview'; diff --git a/src/lib/events.js b/src/lib/events.js index 93353c71b..288d60bda 100644 --- a/src/lib/events.js +++ b/src/lib/events.js @@ -76,3 +76,10 @@ export const USER_DOCUMENT_FIND_EVENTS = { OPEN: 'user_document_find_open', // The user opens the find bar PREVIOUS: 'user_document_find_previous' // The user navigates to the previous find entry }; + +// Events fired when using thumbnail sidebar +export const USER_DOCUMENT_THUMBNAIL_EVENTS = { + CLOSE: 'user_document_thumbnails_close', + NAVIGATE: 'user_document_thumbnails_navigate', + OPEN: 'user_document_thumbnails_open' +}; diff --git a/src/lib/icons/icons.js b/src/lib/icons/icons.js index 95243ec1f..cfc7665b2 100644 --- a/src/lib/icons/icons.js +++ b/src/lib/icons/icons.js @@ -44,6 +44,7 @@ import FIND_DROP_UP from './arrow_drop_up.svg'; import CLOSE from './close.svg'; import SEARCH from './search.svg'; import PRINT_CHECKMARK from './print_checkmark.svg'; +import THUMBNAILS_TOGGLE from './thumbnails-toggle-icon.svg'; export const ICON_DROP_DOWN = DROP_DOWN; export const ICON_DROP_UP = DROP_UP; @@ -68,6 +69,7 @@ export const ICON_FIND_DROP_UP = FIND_DROP_UP; export const ICON_CLOSE = CLOSE; export const ICON_SEARCH = SEARCH; export const ICON_PRINT_CHECKMARK = PRINT_CHECKMARK; +export const ICON_THUMBNAILS_TOGGLE = THUMBNAILS_TOGGLE; const FILE_LOADING_ICONS = { FILE_AUDIO, diff --git a/src/lib/icons/thumbnails-toggle-icon.svg b/src/lib/icons/thumbnails-toggle-icon.svg new file mode 100644 index 000000000..f7644db48 --- /dev/null +++ b/src/lib/icons/thumbnails-toggle-icon.svg @@ -0,0 +1 @@ + diff --git a/src/lib/shell.html b/src/lib/shell.html index 773554553..91a42bd73 100644 --- a/src/lib/shell.html +++ b/src/lib/shell.html @@ -34,33 +34,35 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ +
+
+
- -
-
-
+ +
- -
diff --git a/src/lib/viewers/BaseViewer.js b/src/lib/viewers/BaseViewer.js index 7e56232b7..b7a93561b 100644 --- a/src/lib/viewers/BaseViewer.js +++ b/src/lib/viewers/BaseViewer.js @@ -18,16 +18,17 @@ import { replacePlaceholders } from '../util'; import { - CLASS_HIDDEN, CLASS_BOX_PREVIEW_MOBILE, + CLASS_HIDDEN, FILE_OPTION_START, - SELECTOR_BOX_PREVIEW, - SELECTOR_BOX_PREVIEW_BTN_ANNOTATE_POINT, SELECTOR_BOX_PREVIEW_BTN_ANNOTATE_DRAW, + SELECTOR_BOX_PREVIEW_BTN_ANNOTATE_POINT, + SELECTOR_BOX_PREVIEW_CONTENT, SELECTOR_BOX_PREVIEW_CRAWLER_WRAPPER, SELECTOR_BOX_PREVIEW_ICON, STATUS_SUCCESS, - STATUS_VIEWABLE + STATUS_VIEWABLE, + SELECTOR_BOX_PREVIEW } from '../constants'; import { getIconFromExtension, getIconFromName } from '../icons/icons'; import { VIEWER_EVENT, ERROR_CODE, LOAD_METRIC, DOWNLOAD_REACHABILITY_METRICS } from '../events'; @@ -110,6 +111,15 @@ class BaseViewer extends EventEmitter { /** @property {boolean} - Has the viewer retried downloading the content */ hasRetriedContentDownload = false; + /** @property {Object} - Keeps track of which metrics have been emitted already */ + emittedMetrics; + + /** @property {HTMLElement} - The root element (.bp) of the viewer (includes the loading wrapper as well as content) */ + rootEl; + + /** @property {HTMLElement} - The .bp-content which is the container for the viewer's content */ + containerEl; + /** * [constructor] * @@ -125,6 +135,8 @@ class BaseViewer extends EventEmitter { this.isMobile = Browser.isMobile(); this.hasTouch = Browser.hasTouch(); + this.emittedMetrics = {}; + // Bind context for callbacks this.resetLoadTimeout = this.resetLoadTimeout.bind(this); this.preventDefault = this.preventDefault.bind(this); @@ -141,6 +153,7 @@ class BaseViewer extends EventEmitter { this.viewerLoadHandler = this.viewerLoadHandler.bind(this); this.initAnnotations = this.initAnnotations.bind(this); this.loadBoxAnnotations = this.loadBoxAnnotations.bind(this); + this.createViewer = this.createViewer.bind(this); } /** @@ -163,8 +176,10 @@ class BaseViewer extends EventEmitter { container = document.querySelector(container); } - // From the perspective of viewers bp holds everything - this.containerEl = container.querySelector(SELECTOR_BOX_PREVIEW); + this.rootEl = container.querySelector(SELECTOR_BOX_PREVIEW); + + // From the perspective of viewers bp-content holds everything + this.containerEl = container.querySelector(SELECTOR_BOX_PREVIEW_CONTENT); // Attach event listeners this.addCommonListeners(); @@ -174,7 +189,7 @@ class BaseViewer extends EventEmitter { // For mobile browsers add mobile class just in case viewers need it if (this.isMobile) { - this.containerEl.classList.add(CLASS_BOX_PREVIEW_MOBILE); + this.rootEl.classList.add(CLASS_BOX_PREVIEW_MOBILE); } // Creates a promise that the annotator will be constructed if annotations are @@ -236,6 +251,7 @@ class BaseViewer extends EventEmitter { this.destroyed = true; this.annotatorPromise = null; this.annotatorPromiseResolver = null; + this.emittedMetrics = null; this.emit('destroy'); } @@ -587,12 +603,29 @@ class BaseViewer extends EventEmitter { * @return {void} */ emitMetric(event, data) { + // If this metric has been emitted already and is on the whitelist of metrics + // to be emitted only once per session, then do nothing + if (this.emittedMetrics[event] && this.getMetricsWhitelist().includes(event)) { + return; + } + + // Mark that this metric has been emitted + this.emittedMetrics[event] = true; + super.emit(VIEWER_EVENT.metric, { event, data }); } + /** + * Method which returns the list of metrics to be emitted only once + * @return {Array} - the array of metric names to be emitted only once + */ + getMetricsWhitelist() { + return []; + } + /** * Handles the beginning of a pinch to zoom event on mobile. * Although W3 strongly discourages the prevention of pinch to zoom, @@ -1099,6 +1132,32 @@ class BaseViewer extends EventEmitter { handleAssetAndRepLoad() { this.loadBoxAnnotations().then(this.createAnnotator); } + + /** + * Method to insert the viewer wrapper + * + * @param {HTMLElement} element Element to be inserted into the DOM + * @return {HTMLElement} inserted element + */ + createViewer(element) { + if (!element) { + return null; + } + + const firstChildEl = this.containerEl.firstChild; + let addedElement; + + if (!firstChildEl) { + addedElement = this.containerEl.appendChild(element); + } else { + // Need to insert the viewer wrapper as the first element in the container + // so that we can perserve the natural stacking context to have the prev/next + // file buttons on top of the previewed content + addedElement = this.containerEl.insertBefore(element, firstChildEl); + } + + return addedElement; + } } export default BaseViewer; diff --git a/src/lib/viewers/__tests__/BaseViewer-test.html b/src/lib/viewers/__tests__/BaseViewer-test.html index 5b6c68a9e..d26c2c122 100644 --- a/src/lib/viewers/__tests__/BaseViewer-test.html +++ b/src/lib/viewers/__tests__/BaseViewer-test.html @@ -1,3 +1,5 @@
-
+
+
+
diff --git a/src/lib/viewers/__tests__/BaseViewer-test.js b/src/lib/viewers/__tests__/BaseViewer-test.js index 624a1755d..9a6b5e8ca 100644 --- a/src/lib/viewers/__tests__/BaseViewer-test.js +++ b/src/lib/viewers/__tests__/BaseViewer-test.js @@ -8,6 +8,7 @@ import DownloadReachability from '../../DownloadReachability'; import fullscreen from '../../Fullscreen'; import * as util from '../../util'; import * as icons from '../../icons/icons'; +import * as constants from '../../constants'; import { VIEWER_EVENT, LOAD_METRIC, ERROR_CODE } from '../../events'; import Timer from '../../Timer'; @@ -81,7 +82,7 @@ describe('lib/viewers/BaseViewer', () => { showAnnotations: true }); - expect(base.containerEl).to.have.class('bp'); + expect(base.containerEl).to.have.class(constants.CLASS_BOX_PREVIEW_CONTENT); expect(base.addCommonListeners).to.be.called; expect(getIconFromExtensionStub).to.be.called; expect(base.loadTimeout).to.be.a('number'); @@ -97,7 +98,7 @@ describe('lib/viewers/BaseViewer', () => { base.setup(); - const container = document.querySelector('.bp'); + const container = document.querySelector(constants.SELECTOR_BOX_PREVIEW); expect(container).to.have.class('bp-is-mobile'); }); @@ -1385,4 +1386,68 @@ describe('lib/viewers/BaseViewer', () => { expect(base.createAnnotator).to.be.called; }); }); + + describe('createViewer()', () => { + it('should return null if no element is provided', () => { + expect(base.createViewer()).to.be.null; + }); + + it('should append the element if containerEl has no first child', () => { + base.containerEl = document.querySelector(constants.SELECTOR_BOX_PREVIEW_CONTENT); + const newDiv = document.createElement('div'); + sandbox.stub(base.containerEl, 'appendChild'); + base.createViewer(newDiv); + expect(base.containerEl.appendChild).to.be.called; + }); + + it('should insert the provided element before the other children', () => { + base.containerEl = document.querySelector(constants.SELECTOR_BOX_PREVIEW_CONTENT); + const existingChild = document.createElement('div'); + existingChild.className = 'existing-child'; + base.containerEl.appendChild(existingChild); + + sandbox.stub(base.containerEl, 'insertBefore'); + const newDiv = document.createElement('div'); + newDiv.className = 'new-div'; + base.createViewer(newDiv); + expect(base.containerEl.insertBefore).to.be.called; + }); + }); + + describe('emitMetric()', () => { + beforeEach(() => { + stubs.emit = sandbox.stub(EventEmitter.prototype, 'emit'); + stubs.getMetricsWhitelist = sandbox.stub(base, 'getMetricsWhitelist'); + }); + + it('should update the emittedMetrics object when called the first time', () => { + base.emittedMetrics = {}; + stubs.getMetricsWhitelist.returns([]); + + base.emitMetric('foo', 'bar'); + + expect(base.emittedMetrics.foo).to.be.true; + expect(stubs.emit).to.be.called; + }); + + it('should be emitted even if not the first time and not whitelisted', () => { + base.emittedMetrics = { foo: true }; + stubs.getMetricsWhitelist.returns([]); + + base.emitMetric('foo', 'bar'); + + expect(base.emittedMetrics.foo).to.be.true; + expect(stubs.emit).to.be.called; + }); + + it('should not do anything if it has been emitted before and is whitelisted', () => { + base.emittedMetrics = { foo: true }; + stubs.getMetricsWhitelist.returns(['foo']); + + base.emitMetric('foo', 'bar'); + + expect(base.emittedMetrics.foo).to.be.true; + expect(stubs.emit).not.to.be.called; + }); + }); }); diff --git a/src/lib/viewers/box3d/Box3DViewer.js b/src/lib/viewers/box3d/Box3DViewer.js index ba4bb5581..ffbc45864 100644 --- a/src/lib/viewers/box3d/Box3DViewer.js +++ b/src/lib/viewers/box3d/Box3DViewer.js @@ -67,7 +67,7 @@ class Box3DViewer extends BaseViewer { this.renderer = null; - this.wrapperEl = this.containerEl.appendChild(document.createElement('div')); + this.wrapperEl = this.createViewer(document.createElement('div')); this.wrapperEl.className = CSS_CLASS_BOX3D; this.contextNotification = new Notification(this.wrapperEl); diff --git a/src/lib/viewers/box3d/__tests__/Box3DViewer-test.html b/src/lib/viewers/box3d/__tests__/Box3DViewer-test.html index 7dd9073c1..58be94f4d 100644 --- a/src/lib/viewers/box3d/__tests__/Box3DViewer-test.html +++ b/src/lib/viewers/box3d/__tests__/Box3DViewer-test.html @@ -1 +1,7 @@ -
+
+
+
+
+
+
+
diff --git a/src/lib/viewers/box3d/__tests__/Box3DViewer-test.js b/src/lib/viewers/box3d/__tests__/Box3DViewer-test.js index 4f2bf9bef..6a91aacb9 100644 --- a/src/lib/viewers/box3d/__tests__/Box3DViewer-test.js +++ b/src/lib/viewers/box3d/__tests__/Box3DViewer-test.js @@ -17,6 +17,7 @@ import { EVENT_WEBGL_CONTEXT_RESTORED } from '../box3DConstants'; import { VIEWER_EVENT } from '../../../events'; +import { SELECTOR_BOX_PREVIEW_CONTENT } from '../../../constants'; const sandbox = sinon.sandbox.create(); @@ -54,7 +55,7 @@ describe('lib/viewers/box3d/Box3DViewer', () => { }); Object.defineProperty(BaseViewer.prototype, 'setup', { value: sandbox.mock() }); - box3d.containerEl = containerEl; + box3d.containerEl = document.querySelector(SELECTOR_BOX_PREVIEW_CONTENT); box3d.setup(); sandbox.stub(box3d, 'createSubModules'); @@ -104,7 +105,7 @@ describe('lib/viewers/box3d/Box3DViewer', () => { } }); Object.defineProperty(BaseViewer.prototype, 'setup', { value: sandbox.mock() }); - box3d.containerEl = containerEl; + box3d.containerEl = document.querySelector(SELECTOR_BOX_PREVIEW_CONTENT); box3d.setup(); box3d.createSubModules(); @@ -358,7 +359,7 @@ describe('lib/viewers/box3d/Box3DViewer', () => { it('should call renderer.load()', () => { Object.defineProperty(BaseViewer.prototype, 'setup', { value: sandbox.mock() }); - box3d.containerEl = containerEl; + box3d.containerEl = document.querySelector(SELECTOR_BOX_PREVIEW_CONTENT); Object.defineProperty(BaseViewer.prototype, 'load', { value: sandbox.mock() }); sandbox.stub(box3d, 'loadAssets').returns(Promise.resolve()); sandbox.stub(box3d, 'getRepStatus').returns({ getPromise: () => Promise.resolve() }); @@ -383,7 +384,11 @@ describe('lib/viewers/box3d/Box3DViewer', () => { it('should call renderer.load() with the entities.json file and options', () => { const contentUrl = 'someEntitiesJsonUrl'; sandbox.stub(box3d, 'createContentUrl').returns(contentUrl); - sandbox.mock(box3d.renderer).expects('load').withArgs(contentUrl, box3d.options).returns(Promise.resolve()); + sandbox + .mock(box3d.renderer) + .expects('load') + .withArgs(contentUrl, box3d.options) + .returns(Promise.resolve()); box3d.postLoad(); }); @@ -409,14 +414,20 @@ describe('lib/viewers/box3d/Box3DViewer', () => { sandbox.stub(box3d, 'createContentUrl').returns(contentUrl); sandbox.stub(box3d, 'appendAuthHeader').returns(headers); sandbox.stub(box3d, 'isRepresentationReady').returns(true); - sandbox.mock(util).expects('get').withArgs(contentUrl, headers, 'any'); + sandbox + .mock(util) + .expects('get') + .withArgs(contentUrl, headers, 'any'); box3d.prefetch({ assets: false, content: true }); }); it('should not prefetch content if content is true but representation is not ready', () => { sandbox.stub(box3d, 'isRepresentationReady').returns(false); - sandbox.mock(util).expects('get').never(); + sandbox + .mock(util) + .expects('get') + .never(); box3d.prefetch({ assets: false, content: true }); }); }); diff --git a/src/lib/viewers/box3d/image360/__tests__/Image360Viewer-test.html b/src/lib/viewers/box3d/image360/__tests__/Image360Viewer-test.html index 7dd9073c1..58be94f4d 100644 --- a/src/lib/viewers/box3d/image360/__tests__/Image360Viewer-test.html +++ b/src/lib/viewers/box3d/image360/__tests__/Image360Viewer-test.html @@ -1 +1,7 @@ -
+
+
+
+
+
+
+
diff --git a/src/lib/viewers/box3d/image360/__tests__/Image360Viewer-test.js b/src/lib/viewers/box3d/image360/__tests__/Image360Viewer-test.js index 67f357744..676fecb8a 100644 --- a/src/lib/viewers/box3d/image360/__tests__/Image360Viewer-test.js +++ b/src/lib/viewers/box3d/image360/__tests__/Image360Viewer-test.js @@ -3,6 +3,7 @@ import Image360Viewer from '../Image360Viewer'; import BaseViewer from '../../../BaseViewer'; import Box3DControls from '../../Box3DControls'; import Image360Renderer from '../Image360Renderer'; +import { SELECTOR_BOX_PREVIEW_CONTENT } from '../../../../constants'; const sandbox = sinon.sandbox.create(); const CSS_CLASS_IMAGE_360 = 'bp-image-360'; @@ -27,7 +28,7 @@ describe('lib/viewers/box3d/image360/Image360Viewer', () => { } }); Object.defineProperty(BaseViewer.prototype, 'setup', { value: sandbox.mock() }); - viewer.containerEl = containerEl; + viewer.containerEl = document.querySelector(SELECTOR_BOX_PREVIEW_CONTENT); }); afterEach(() => { diff --git a/src/lib/viewers/box3d/model3d/__tests__/Model3DViewer-test.html b/src/lib/viewers/box3d/model3d/__tests__/Model3DViewer-test.html index 7dd9073c1..58be94f4d 100644 --- a/src/lib/viewers/box3d/model3d/__tests__/Model3DViewer-test.html +++ b/src/lib/viewers/box3d/model3d/__tests__/Model3DViewer-test.html @@ -1 +1,7 @@ -
+
+
+
+
+
+
+
diff --git a/src/lib/viewers/box3d/video360/__tests__/Video360Viewer-test.html b/src/lib/viewers/box3d/video360/__tests__/Video360Viewer-test.html index 7dd9073c1..58be94f4d 100644 --- a/src/lib/viewers/box3d/video360/__tests__/Video360Viewer-test.html +++ b/src/lib/viewers/box3d/video360/__tests__/Video360Viewer-test.html @@ -1 +1,7 @@ -
+
+
+
+
+
+
+
diff --git a/src/lib/viewers/doc/DocBaseViewer.js b/src/lib/viewers/doc/DocBaseViewer.js index 58d402b9f..73dfd376b 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 ThumbnailsSidebar from '../../ThumbnailsSidebar'; import { CLASS_BOX_PREVIEW_FIND_BAR, CLASS_CRAWLER, @@ -18,7 +19,8 @@ import { PRELOAD_REP_NAME, STATUS_SUCCESS, QUERY_PARAM_ENCODING, - ENCODING_TYPES + ENCODING_TYPES, + CLASS_BOX_PREVIEW_THUMBNAILS_CONTAINER } from '../../constants'; import { checkPermission, getRepresentation } from '../../file'; import { @@ -29,9 +31,16 @@ import { getDistance, getClosestPageToPinch } from '../../util'; -import { ICON_PRINT_CHECKMARK } from '../../icons/icons'; +import { + ICON_PRINT_CHECKMARK, + ICON_ZOOM_OUT, + ICON_ZOOM_IN, + ICON_FULLSCREEN_IN, + ICON_FULLSCREEN_OUT, + ICON_THUMBNAILS_TOGGLE +} from '../../icons/icons'; import { JS, PRELOAD_JS, CSS } from './docAssets'; -import { ERROR_CODE, VIEWER_EVENT, LOAD_METRIC } from '../../events'; +import { ERROR_CODE, VIEWER_EVENT, LOAD_METRIC, USER_DOCUMENT_THUMBNAIL_EVENTS } from '../../events'; import Timer from '../../Timer'; const CURRENT_PAGE_MAP_KEY = 'doc-current-page-map'; @@ -54,6 +63,12 @@ const MOBILE_MAX_CANVAS_SIZE = 2949120; // ~3MP 1920x1536 const PINCH_PAGE_CLASS = 'pinch-page'; const PINCHING_CLASS = 'pinching'; const PAGES_UNIT_NAME = 'pages'; +// List of metrics to be emitted only once per session +const METRICS_WHITELIST = [ + USER_DOCUMENT_THUMBNAIL_EVENTS.CLOSE, + USER_DOCUMENT_THUMBNAIL_EVENTS.NAVIGATE, + USER_DOCUMENT_THUMBNAIL_EVENTS.OPEN +]; class DocBaseViewer extends BaseViewer { //-------------------------------------------------------------------------- @@ -83,6 +98,8 @@ class DocBaseViewer extends BaseViewer { this.pinchToZoomChangeHandler = this.pinchToZoomChangeHandler.bind(this); this.pinchToZoomEndHandler = this.pinchToZoomEndHandler.bind(this); this.emitMetric = this.emitMetric.bind(this); + this.toggleThumbnails = this.toggleThumbnails.bind(this); + this.onThumbnailClickHandler = this.onThumbnailClickHandler.bind(this); } /** @@ -92,7 +109,7 @@ class DocBaseViewer extends BaseViewer { // Call super() to set up common layout super.setup(); - this.docEl = this.containerEl.appendChild(document.createElement('div')); + this.docEl = this.createViewer(document.createElement('div')); this.docEl.classList.add('bp-doc'); if (Browser.getName() === 'Safari') { @@ -113,6 +130,13 @@ class DocBaseViewer extends BaseViewer { this.loadTimeout = LOAD_TIMEOUT_MS; this.startPageNum = this.getStartPage(this.startAt); + + if (this.options.enableThumbnailsSidebar) { + this.thumbnailsSidebarEl = document.createElement('div'); + this.thumbnailsSidebarEl.className = `${CLASS_BOX_PREVIEW_THUMBNAILS_CONTAINER} ${CLASS_HIDDEN}`; + this.thumbnailsSidebarEl.setAttribute('data-testid', 'thumbnails-sidebar'); + this.containerEl.parentNode.insertBefore(this.thumbnailsSidebarEl, this.containerEl); + } } /** @@ -166,6 +190,10 @@ class DocBaseViewer extends BaseViewer { this.printPopup.destroy(); } + if (this.thumbnailsSidebar) { + this.thumbnailsSidebar.destroy(); + } + super.destroy(); } @@ -426,6 +454,10 @@ class DocBaseViewer extends BaseViewer { this.pdfViewer.currentPageNumber = parsedPageNumber; this.cachePage(this.pdfViewer.currentPageNumber); + + if (this.thumbnailsSidebar) { + this.thumbnailsSidebar.setCurrentPage(parsedPageNumber); + } } /** @@ -550,8 +582,8 @@ class DocBaseViewer extends BaseViewer { * @param {Object} event - Event object * @return {void} */ - emitMetric(event) { - super.emitMetric(event.name, event.data); + emitMetric({ name, data }) { + super.emitMetric(name, data); } //-------------------------------------------------------------------------- @@ -667,13 +699,17 @@ class DocBaseViewer extends BaseViewer { } // Save page and return after resize - const { currentPageNumber } = this.pdfViewer.currentPageNumber; + const { currentPageNumber } = this.pdfViewer; this.pdfViewer.currentScaleValue = this.pdfViewer.currentScaleValue || 'auto'; this.pdfViewer.update(); this.setPage(currentPageNumber); + if (this.thumbnailsSidebar) { + this.thumbnailsSidebar.resize(); + } + super.resize(); } @@ -971,12 +1007,34 @@ class DocBaseViewer extends BaseViewer { } /** - * Binds listeners for document controls. Overridden. + * Binds listeners for document controls * * @protected * @return {void} */ - bindControlListeners() {} + bindControlListeners() { + if (this.options.enableThumbnailsSidebar) { + this.controls.add( + __('toggle_thumbnails'), + this.toggleThumbnails, + 'bp-toggle-thumbnails-icon', + ICON_THUMBNAILS_TOGGLE + ); + } + + this.controls.add(__('zoom_out'), this.zoomOut, 'bp-doc-zoom-out-icon', ICON_ZOOM_OUT); + this.controls.add(__('zoom_in'), this.zoomIn, 'bp-doc-zoom-in-icon', ICON_ZOOM_IN); + + this.pageControls.add(this.pdfViewer.currentPageNumber, this.pdfViewer.pagesCount); + + this.controls.add( + __('enter_fullscreen'), + this.toggleFullscreen, + 'bp-enter-fullscreen-icon', + ICON_FULLSCREEN_IN + ); + this.controls.add(__('exit_fullscreen'), this.toggleFullscreen, 'bp-exit-fullscreen-icon', ICON_FULLSCREEN_OUT); + } /** * Handler for 'pagesinit' event. @@ -1011,6 +1069,34 @@ class DocBaseViewer extends BaseViewer { // Add page IDs to each page after page structure is available this.setupPageIds(); } + + if (this.options.enableThumbnailsSidebar) { + this.initThumbnails(); + } + } + + /** + * Initialize the Thumbnails Sidebar + * + * @return {void} + */ + initThumbnails() { + this.thumbnailsSidebar = new ThumbnailsSidebar(this.thumbnailsSidebarEl, this.pdfViewer); + this.thumbnailsSidebar.init({ + onClick: this.onThumbnailClickHandler, + currentPage: this.pdfViewer.currentPageNumber + }); + } + + /** + * Handles the click of a thumbnail for navigation + * + * @param {number} pageNum - the page number + * @return {void} + */ + onThumbnailClickHandler(pageNum) { + this.emitMetric({ name: USER_DOCUMENT_THUMBNAIL_EVENTS.NAVIGATE, data: pageNum }); + this.setPage(pageNum); } /** @@ -1053,6 +1139,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 @@ -1237,6 +1327,47 @@ class DocBaseViewer extends BaseViewer { this.pinchScale = 1; this.pinchPage = null; } + + /** + * Callback when the toggle thumbnail sidebar button is clicked. + * + * @protected + * @return {void} + */ + toggleThumbnails() { + if (!this.thumbnailsSidebar) { + return; + } + + this.thumbnailsSidebar.toggle(); + + const { pagesCount } = this.pdfViewer; + + let metricName; + let eventName; + if (!this.thumbnailsSidebar.isOpen()) { + metricName = USER_DOCUMENT_THUMBNAIL_EVENTS.CLOSE; + eventName = 'thumbnailsClose'; + } else { + metricName = USER_DOCUMENT_THUMBNAIL_EVENTS.OPEN; + eventName = 'thumbnailsOpen'; + } + + this.emitMetric({ name: metricName, data: pagesCount }); + this.emit(eventName); + + this.resize(); + } + + /** + * Overrides the base method + * + * @override + * @return {Array} - the array of metric names to be emitted only once + */ + getMetricsWhitelist() { + return METRICS_WHITELIST; + } } export default DocBaseViewer; diff --git a/src/lib/viewers/doc/DocumentViewer.js b/src/lib/viewers/doc/DocumentViewer.js index f76a55cfd..a6e74e290 100644 --- a/src/lib/viewers/doc/DocumentViewer.js +++ b/src/lib/viewers/doc/DocumentViewer.js @@ -1,7 +1,6 @@ import DocBaseViewer from './DocBaseViewer'; import DocPreloader from './DocPreloader'; import fullscreen from '../../Fullscreen'; -import { ICON_FULLSCREEN_IN, ICON_FULLSCREEN_OUT, ICON_ZOOM_IN, ICON_ZOOM_OUT } from '../../icons/icons'; import './Document.scss'; class DocumentViewer extends DocBaseViewer { @@ -59,29 +58,6 @@ class DocumentViewer extends DocBaseViewer { //-------------------------------------------------------------------------- // Event Listeners //-------------------------------------------------------------------------- - - /** - * Bind event listeners for document controls - * - * @private - * @return {void} - */ - bindControlListeners() { - super.bindControlListeners(); - - this.controls.add(__('zoom_out'), this.zoomOut, 'bp-doc-zoom-out-icon', ICON_ZOOM_OUT); - this.controls.add(__('zoom_in'), this.zoomIn, 'bp-doc-zoom-in-icon', ICON_ZOOM_IN); - - this.pageControls.add(this.pdfViewer.currentPageNumber, this.pdfViewer.pagesCount); - - this.controls.add( - __('enter_fullscreen'), - this.toggleFullscreen, - 'bp-enter-fullscreen-icon', - ICON_FULLSCREEN_IN - ); - this.controls.add(__('exit_fullscreen'), this.toggleFullscreen, 'bp-exit-fullscreen-icon', ICON_FULLSCREEN_OUT); - } } export default DocumentViewer; diff --git a/src/lib/viewers/doc/PresentationViewer.js b/src/lib/viewers/doc/PresentationViewer.js index c355c1516..f7189257f 100644 --- a/src/lib/viewers/doc/PresentationViewer.js +++ b/src/lib/viewers/doc/PresentationViewer.js @@ -2,7 +2,6 @@ import throttle from 'lodash/throttle'; import DocBaseViewer from './DocBaseViewer'; import PresentationPreloader from './PresentationPreloader'; import { CLASS_INVISIBLE } from '../../constants'; -import { ICON_FULLSCREEN_IN, ICON_FULLSCREEN_OUT, ICON_ZOOM_IN, ICON_ZOOM_OUT } from '../../icons/icons'; import './Presentation.scss'; const WHEEL_THROTTLE = 200; @@ -177,30 +176,6 @@ class PresentationViewer extends DocBaseViewer { } } - /** - * Adds event listeners for presentation controls - * - * @override - * @return {void} - * @protected - */ - bindControlListeners() { - super.bindControlListeners(); - - this.controls.add(__('zoom_out'), this.zoomOut, 'bp-exit-zoom-out-icon', ICON_ZOOM_OUT); - this.controls.add(__('zoom_in'), this.zoomIn, 'bp-enter-zoom-in-icon', ICON_ZOOM_IN); - - this.pageControls.add(this.pdfViewer.currentPageNumber, this.pdfViewer.pagesCount); - - this.controls.add( - __('enter_fullscreen'), - this.toggleFullscreen, - 'bp-enter-fullscreen-icon', - ICON_FULLSCREEN_IN - ); - this.controls.add(__('exit_fullscreen'), this.toggleFullscreen, 'bp-exit-fullscreen-icon', ICON_FULLSCREEN_OUT); - } - /** * Handler for mobile scroll events. * diff --git a/src/lib/viewers/doc/__tests__/DocBaseViewer-test.html b/src/lib/viewers/doc/__tests__/DocBaseViewer-test.html index 7dd9073c1..c51f940ec 100644 --- a/src/lib/viewers/doc/__tests__/DocBaseViewer-test.html +++ b/src/lib/viewers/doc/__tests__/DocBaseViewer-test.html @@ -1 +1,8 @@ -
+
+
+
+
+
+
+
+
diff --git a/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js b/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js index 5dfa3d472..ef84d25d1 100644 --- a/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js +++ b/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js @@ -18,10 +18,19 @@ import { STATUS_PENDING, STATUS_SUCCESS, QUERY_PARAM_ENCODING, - ENCODING_TYPES + ENCODING_TYPES, + SELECTOR_BOX_PREVIEW_CONTENT, + CLASS_BOX_PREVIEW_THUMBNAILS_CONTAINER } from '../../../constants'; -import { ICON_PRINT_CHECKMARK } from '../../../icons/icons'; -import { VIEWER_EVENT, LOAD_METRIC } from '../../../events'; +import { + ICON_PRINT_CHECKMARK, + ICON_THUMBNAILS_TOGGLE, + ICON_ZOOM_OUT, + ICON_ZOOM_IN, + ICON_FULLSCREEN_IN, + ICON_FULLSCREEN_OUT +} from '../../../icons/icons'; +import { VIEWER_EVENT, LOAD_METRIC, USER_DOCUMENT_THUMBNAIL_EVENTS } from '../../../events'; import Timer from '../../../Timer'; const LOAD_TIMEOUT_MS = 180000; // 3 min timeout @@ -61,7 +70,7 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { beforeEach(() => { fixture.load('viewers/doc/__tests__/DocBaseViewer-test.html'); - containerEl = document.querySelector('.container'); + containerEl = document.querySelector(SELECTOR_BOX_PREVIEW_CONTENT); docBase = new DocBaseViewer({ cache: { set: () => {}, @@ -78,7 +87,8 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { file: { id: '0', extension: 'ppt' - } + }, + enableThumbnailsSidebar: true }); Object.defineProperty(BaseViewer.prototype, 'setup', { value: sandbox.stub() }); docBase.containerEl = containerEl; @@ -101,15 +111,44 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { }); describe('setup()', () => { - it('should correctly set a doc element, viewer element, and a timeout', () => { + it('should correctly set a doc element, viewer element, thumbnails sidebar element, and a timeout', () => { expect(docBase.docEl.classList.contains('bp-doc')).to.be.true; expect(docBase.docEl.parentNode).to.deep.equal(docBase.containerEl); expect(docBase.viewerEl.classList.contains('pdfViewer')).to.be.true; expect(docBase.viewerEl.parentNode).to.equal(docBase.docEl); + expect(docBase.thumbnailsSidebarEl.classList.contains(CLASS_BOX_PREVIEW_THUMBNAILS_CONTAINER)).to.be.true; + expect(docBase.thumbnailsSidebarEl.parentNode).to.equal(docBase.containerEl.parentNode); + expect(docBase.loadTimeout).to.equal(LOAD_TIMEOUT_MS); }); + + it('should not set a thumbnails sidebar element if the option is not enabled', () => { + docBase = new DocBaseViewer({ + cache: { + set: () => {}, + has: () => {}, + get: () => {}, + unset: () => {} + }, + container: containerEl, + representation: { + content: { + url_template: 'foo' + } + }, + file: { + id: '0', + extension: 'ppt' + }, + enableThumbnailsSidebar: false + }); + docBase.containerEl = containerEl; + docBase.setup(); + + expect(docBase.thumbnailsSidebarEl).to.be.undefined; + }); }); describe('destroy()', () => { @@ -1076,6 +1115,8 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { Object.defineProperty(Object.getPrototypeOf(DocBaseViewer.prototype), 'resize', { value: sandbox.stub() }); + stubs.thumbnailsResize = sandbox.stub(); + docBase.thumbnailsSidebar = { resize: stubs.thumbnailsResize, destroy: () => {} }; }); afterEach(() => { @@ -1088,12 +1129,14 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { docBase.pdfViewer = null; docBase.resize(); expect(BaseViewer.prototype.resize).to.not.be.called; + expect(stubs.thumbnailsResize).not.to.be.called; }); it('should do nothing if the page views are not ready', () => { docBase.pdfViewer.pageViewsReady = false; docBase.resize(); expect(BaseViewer.prototype.resize).to.not.be.called; + expect(stubs.thumbnailsResize).not.to.be.called; }); it('should resize the preload', () => { @@ -1104,6 +1147,7 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { docBase.resize(); expect(docBase.preloader.resize).to.be.called; expect(BaseViewer.prototype.resize).to.not.be.called; + expect(stubs.thumbnailsResize).not.to.be.called; }); it('should update the pdfViewer and reset the page', () => { @@ -1111,6 +1155,7 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { expect(docBase.pdfViewer.update).to.be.called; expect(stubs.setPage).to.be.called; expect(BaseViewer.prototype.resize).to.be.called; + expect(stubs.thumbnailsResize).to.be.called; }); }); @@ -1549,6 +1594,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', () => { @@ -1561,6 +1607,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', () => { @@ -2013,4 +2102,182 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { expect(docBase.getStartPage(startAt)).to.be.undefined; }); }); + + describe('bindControlListeners()', () => { + beforeEach(() => { + docBase.pdfViewer = { + pagesCount: 4, + currentPageNumber: 1, + cleanup: sandbox.stub() + }; + + docBase.controls = { + add: sandbox.stub(), + removeListener: sandbox.stub() + }; + + docBase.pageControls = { + add: sandbox.stub(), + removeListener: sandbox.stub() + }; + }); + + it('should add the correct controls', () => { + docBase.bindControlListeners(); + + expect(docBase.controls.add).to.be.calledWith( + __('toggle_thumbnails'), + docBase.toggleThumbnails, + 'bp-toggle-thumbnails-icon', + ICON_THUMBNAILS_TOGGLE + ); + + expect(docBase.controls.add).to.be.calledWith( + __('zoom_out'), + docBase.zoomOut, + 'bp-doc-zoom-out-icon', + ICON_ZOOM_OUT + ); + expect(docBase.controls.add).to.be.calledWith( + __('zoom_in'), + docBase.zoomIn, + 'bp-doc-zoom-in-icon', + ICON_ZOOM_IN + ); + + expect(docBase.pageControls.add).to.be.calledWith(1, 4); + + expect(docBase.controls.add).to.be.calledWith( + __('enter_fullscreen'), + docBase.toggleFullscreen, + 'bp-enter-fullscreen-icon', + ICON_FULLSCREEN_IN + ); + expect(docBase.controls.add).to.be.calledWith( + __('exit_fullscreen'), + docBase.toggleFullscreen, + 'bp-exit-fullscreen-icon', + ICON_FULLSCREEN_OUT + ); + }); + + it('should not add the toggle thumbnails control if the option is not enabled', () => { + // Create a new instance that has enableThumbnailsSidebar as false + docBase = new DocBaseViewer({ + cache: { + set: () => {}, + has: () => {}, + get: () => {}, + unset: () => {} + }, + container: containerEl, + representation: { + content: { + url_template: 'foo' + } + }, + file: { + id: '0', + extension: 'ppt' + }, + enableThumbnailsSidebar: false + }); + docBase.containerEl = containerEl; + docBase.setup(); + + docBase.controls = { + add: sandbox.stub(), + removeListener: sandbox.stub() + }; + + docBase.pageControls = { + add: sandbox.stub(), + removeListener: sandbox.stub() + }; + + docBase.pdfViewer = { + pagesCount: 4, + currentPageNumber: 1, + cleanup: sandbox.stub() + }; + + // Invoke the method to test + docBase.bindControlListeners(); + + // Check expectations + expect(docBase.controls.add).to.not.be.calledWith( + __('toggle_thumbnails'), + docBase.toggleThumbnails, + 'bp-toggle-thumbnails-icon', + ICON_THUMBNAILS_TOGGLE + ); + }); + }); + + 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.thumbnailsSidebar = undefined; + + docBase.toggleThumbnails(); + + expect(docBase.resize).not.to.be.called; + }); + + it('should toggle open and resize the viewer', () => { + docBase.thumbnailsSidebar = thumbnailsSidebar; + docBase.pdfViewer = { pagesCount: 10 }; + stubs.isSidebarOpen.returns(true); + + docBase.toggleThumbnails(); + + 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', () => { + docBase.thumbnailsSidebar = thumbnailsSidebar; + docBase.pdfViewer = { pagesCount: 10 }; + stubs.isSidebarOpen.returns(false); + + docBase.toggleThumbnails(); + + 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'); + }); + }); + + describe('getMetricsWhitelist()', () => { + it('should return the thumbnail sidebar events', () => { + const expWhitelist = [ + USER_DOCUMENT_THUMBNAIL_EVENTS.CLOSE, + USER_DOCUMENT_THUMBNAIL_EVENTS.NAVIGATE, + USER_DOCUMENT_THUMBNAIL_EVENTS.OPEN + ]; + + expect(docBase.getMetricsWhitelist()).to.be.eql(expWhitelist); + }); + }); }); diff --git a/src/lib/viewers/doc/__tests__/DocumentViewer-test.html b/src/lib/viewers/doc/__tests__/DocumentViewer-test.html index 7dd9073c1..58be94f4d 100644 --- a/src/lib/viewers/doc/__tests__/DocumentViewer-test.html +++ b/src/lib/viewers/doc/__tests__/DocumentViewer-test.html @@ -1 +1,7 @@ -
+
+
+
+
+
+
+
diff --git a/src/lib/viewers/doc/__tests__/DocumentViewer-test.js b/src/lib/viewers/doc/__tests__/DocumentViewer-test.js index 1d6d4b989..53635bcc2 100644 --- a/src/lib/viewers/doc/__tests__/DocumentViewer-test.js +++ b/src/lib/viewers/doc/__tests__/DocumentViewer-test.js @@ -4,7 +4,6 @@ import DocBaseViewer from '../DocBaseViewer'; import BaseViewer from '../../BaseViewer'; import DocPreloader from '../DocPreloader'; import fullscreen from '../../../Fullscreen'; -import { ICON_FULLSCREEN_IN, ICON_FULLSCREEN_OUT, ICON_ZOOM_IN, ICON_ZOOM_OUT } from '../../../icons/icons'; const sandbox = sinon.sandbox.create(); @@ -147,44 +146,4 @@ describe('lib/viewers/doc/DocumentViewer', () => { expect(docbaseStub).to.have.been.calledTwice; }); }); - - describe('bindControlListeners()', () => { - beforeEach(() => { - doc.pdfViewer = { - pagesCount: 4, - cleanup: sandbox.stub() - }; - - doc.pageControls = { - add: sandbox.stub(), - removeListener: sandbox.stub() - }; - }); - - it('should add the correct controls', () => { - doc.bindControlListeners(); - expect(doc.controls.add).to.be.calledWith( - __('zoom_out'), - doc.zoomOut, - 'bp-doc-zoom-out-icon', - ICON_ZOOM_OUT - ); - expect(doc.controls.add).to.be.calledWith(__('zoom_in'), doc.zoomIn, 'bp-doc-zoom-in-icon', ICON_ZOOM_IN); - - expect(doc.pageControls.add).to.be.called; - - expect(doc.controls.add).to.be.calledWith( - __('enter_fullscreen'), - doc.toggleFullscreen, - 'bp-enter-fullscreen-icon', - ICON_FULLSCREEN_IN - ); - expect(doc.controls.add).to.be.calledWith( - __('exit_fullscreen'), - doc.toggleFullscreen, - 'bp-exit-fullscreen-icon', - ICON_FULLSCREEN_OUT - ); - }); - }); }); diff --git a/src/lib/viewers/doc/__tests__/PresentationViewer-test.html b/src/lib/viewers/doc/__tests__/PresentationViewer-test.html index 7dd9073c1..58be94f4d 100644 --- a/src/lib/viewers/doc/__tests__/PresentationViewer-test.html +++ b/src/lib/viewers/doc/__tests__/PresentationViewer-test.html @@ -1 +1,7 @@ -
+
+
+
+
+
+
+
diff --git a/src/lib/viewers/doc/__tests__/PresentationViewer-test.js b/src/lib/viewers/doc/__tests__/PresentationViewer-test.js index 4823e600d..5bd7b6cfa 100644 --- a/src/lib/viewers/doc/__tests__/PresentationViewer-test.js +++ b/src/lib/viewers/doc/__tests__/PresentationViewer-test.js @@ -5,8 +5,6 @@ import DocBaseViewer from '../DocBaseViewer'; import PresentationPreloader from '../PresentationPreloader'; import { CLASS_INVISIBLE } from '../../../constants'; -import { ICON_ZOOM_OUT, ICON_ZOOM_IN, ICON_FULLSCREEN_IN, ICON_FULLSCREEN_OUT } from '../../../icons/icons'; - const sandbox = sinon.sandbox.create(); let containerEl; @@ -293,52 +291,6 @@ describe('lib/viewers/doc/PresentationViewer', () => { }); }); - describe('bindControlListeners()', () => { - beforeEach(() => { - presentation.pdfViewer = { - pagesCount: 4, - currentPageNumber: 1, - cleanup: sandbox.stub() - }; - - presentation.pageControls = { - add: sandbox.stub(), - removeListener: sandbox.stub() - }; - }); - - it('should add the correct controls', () => { - presentation.bindControlListeners(); - expect(presentation.controls.add).to.be.calledWith( - __('zoom_out'), - presentation.zoomOut, - 'bp-exit-zoom-out-icon', - ICON_ZOOM_OUT - ); - expect(presentation.controls.add).to.be.calledWith( - __('zoom_in'), - presentation.zoomIn, - 'bp-enter-zoom-in-icon', - ICON_ZOOM_IN - ); - - expect(presentation.pageControls.add).to.be.calledWith(1, 4); - - expect(presentation.controls.add).to.be.calledWith( - __('enter_fullscreen'), - presentation.toggleFullscreen, - 'bp-enter-fullscreen-icon', - ICON_FULLSCREEN_IN - ); - expect(presentation.controls.add).to.be.calledWith( - __('exit_fullscreen'), - presentation.toggleFullscreen, - 'bp-exit-fullscreen-icon', - ICON_FULLSCREEN_OUT - ); - }); - }); - describe('mobileScrollHandler()', () => { beforeEach(() => { stubs.checkOverflow = sandbox.stub(presentation, 'checkOverflow').returns(false); diff --git a/src/lib/viewers/doc/__tests__/SinglePageViewer-test.html b/src/lib/viewers/doc/__tests__/SinglePageViewer-test.html index 7dd9073c1..58be94f4d 100644 --- a/src/lib/viewers/doc/__tests__/SinglePageViewer-test.html +++ b/src/lib/viewers/doc/__tests__/SinglePageViewer-test.html @@ -1 +1,7 @@ -
+
+
+
+
+
+
+
diff --git a/src/lib/viewers/doc/_docBase.scss b/src/lib/viewers/doc/_docBase.scss index e9f2a5a2c..8b768836e 100644 --- a/src/lib/viewers/doc/_docBase.scss +++ b/src/lib/viewers/doc/_docBase.scss @@ -1,5 +1,74 @@ @import '../../boxuiVariables'; +$thumbnail-border-radius: 4px; + +.bp { + .bp-thumbnails-container { + border-right: solid 1px $seesee; + display: flex; + flex: 0 0 201px; // Accounts for the 1px border on the right so the remaining width is actually 180 + } + + .bp-thumbnail { + align-items: center; + background-color: $d-eight; + border: 0; + border-radius: $thumbnail-border-radius; + display: flex; + flex: 1 0 auto; + justify-content: center; + margin: 0 25px; + padding: 0; + position: relative; + width: 150px; + } + + .bp-thumbnail-page-number { + background-color: $black; + border-radius: $thumbnail-border-radius; + bottom: 10px; + color: $white; + font-size: 11px; + left: 50%; + line-height: 16px; + opacity: .7; + padding: 0 5px; + pointer-events: none; + position: absolute; + transform: translateX(-50%); + } + + .bp-thumbnail-image { + background-position: center; + background-repeat: no-repeat; + background-size: contain; + border-radius: $thumbnail-border-radius; + pointer-events: none; + } + + // Applies a border *outside* the thumbnail button itself rather than + // contributing to the width of the button and shrinking the thumbnail + // image + .bp-thumbnail.bp-thumbnail-is-selected::after { + border: 4px solid $fours; + border-radius: $thumbnail-border-radius; + bottom: -2px; + content: ''; + left: -2px; + position: absolute; + right: -2px; + top: -2px; + } +} + +.bp-theme-dark { + .bp { + .bp-thumbnails-container { + border-right-color: $twos; + } + } +} + .bp-doc { bottom: 0; height: 100%; @@ -109,3 +178,8 @@ display: block; } } + +// Hide the toggle thumbnails button when in fullscreen +.bp-content.bp-is-fullscreen .bp-toggle-thumbnails-icon { + display: none; +} diff --git a/src/lib/viewers/error/PreviewErrorViewer.js b/src/lib/viewers/error/PreviewErrorViewer.js index dfb639029..a303f30d6 100644 --- a/src/lib/viewers/error/PreviewErrorViewer.js +++ b/src/lib/viewers/error/PreviewErrorViewer.js @@ -24,7 +24,7 @@ class PreviewErrorViewer extends BaseViewer { // Call super() first to set up common layout super.setup(); - this.infoEl = this.containerEl.appendChild(document.createElement('div')); + this.infoEl = this.createViewer(document.createElement('div')); this.infoEl.className = 'bp-error'; this.iconEl = this.infoEl.appendChild(document.createElement('div')); diff --git a/src/lib/viewers/error/__tests__/PreviewErrorViewer-test.html b/src/lib/viewers/error/__tests__/PreviewErrorViewer-test.html index 7dd9073c1..58be94f4d 100644 --- a/src/lib/viewers/error/__tests__/PreviewErrorViewer-test.html +++ b/src/lib/viewers/error/__tests__/PreviewErrorViewer-test.html @@ -1 +1,7 @@ -
+
+
+
+
+
+
+
diff --git a/src/lib/viewers/iframe/IFrameViewer.js b/src/lib/viewers/iframe/IFrameViewer.js index 8e97f9252..19fe75747 100644 --- a/src/lib/viewers/iframe/IFrameViewer.js +++ b/src/lib/viewers/iframe/IFrameViewer.js @@ -9,7 +9,7 @@ class IFrameViewer extends BaseViewer { // Call super() to set up common layout super.setup(); - this.iframeEl = this.containerEl.appendChild(document.createElement('iframe')); + this.iframeEl = this.createViewer(document.createElement('iframe')); this.iframeEl.setAttribute('width', '100%'); this.iframeEl.setAttribute('height', '100%'); this.iframeEl.setAttribute('frameborder', 0); diff --git a/src/lib/viewers/iframe/__tests__/IFrameViewer-test.html b/src/lib/viewers/iframe/__tests__/IFrameViewer-test.html index e1e597132..dd4e7e124 100644 --- a/src/lib/viewers/iframe/__tests__/IFrameViewer-test.html +++ b/src/lib/viewers/iframe/__tests__/IFrameViewer-test.html @@ -10,4 +10,10 @@ background-color: #eee; } -
+
+
+
+
+
+
+
diff --git a/src/lib/viewers/image/ImageViewer.js b/src/lib/viewers/image/ImageViewer.js index 1895b80e9..a2edb6bc8 100644 --- a/src/lib/viewers/image/ImageViewer.js +++ b/src/lib/viewers/image/ImageViewer.js @@ -31,7 +31,7 @@ class ImageViewer extends ImageBaseViewer { // Call super() to set up common layout super.setup(); - this.wrapperEl = this.containerEl.appendChild(document.createElement('div')); + this.wrapperEl = this.createViewer(document.createElement('div')); this.wrapperEl.classList.add(CSS_CLASS_IMAGE); this.imageEl = this.wrapperEl.appendChild(document.createElement('img')); diff --git a/src/lib/viewers/image/MultiImageViewer.js b/src/lib/viewers/image/MultiImageViewer.js index 7a539790b..5fdb3e6ea 100644 --- a/src/lib/viewers/image/MultiImageViewer.js +++ b/src/lib/viewers/image/MultiImageViewer.js @@ -32,7 +32,7 @@ class MultiImageViewer extends ImageBaseViewer { // Call super() to set up common layout super.setup(); - this.wrapperEl = this.containerEl.appendChild(document.createElement('div')); + this.wrapperEl = this.createViewer(document.createElement('div')); this.wrapperEl.classList.add(CSS_CLASS_IMAGE_WRAPPER); this.imageEl = this.wrapperEl.appendChild(document.createElement('div')); diff --git a/src/lib/viewers/image/__tests__/ImageViewer-test.html b/src/lib/viewers/image/__tests__/ImageViewer-test.html index 2ac756dad..6c239e49b 100644 --- a/src/lib/viewers/image/__tests__/ImageViewer-test.html +++ b/src/lib/viewers/image/__tests__/ImageViewer-test.html @@ -10,4 +10,12 @@ background-color: #eee; } -
+
+
+
+
+ +
+
+
+
diff --git a/src/lib/viewers/image/__tests__/MultiImageViewer-test.html b/src/lib/viewers/image/__tests__/MultiImageViewer-test.html index c8a751ef9..35bfca794 100644 --- a/src/lib/viewers/image/__tests__/MultiImageViewer-test.html +++ b/src/lib/viewers/image/__tests__/MultiImageViewer-test.html @@ -1,8 +1,10 @@ -
+
+
+
+
+
+
diff --git a/src/lib/viewers/media/MediaBaseViewer.js b/src/lib/viewers/media/MediaBaseViewer.js index 276ee5539..c94a4a2ca 100644 --- a/src/lib/viewers/media/MediaBaseViewer.js +++ b/src/lib/viewers/media/MediaBaseViewer.js @@ -54,7 +54,7 @@ class MediaBaseViewer extends BaseViewer { super.setup(); // Media Wrapper - this.wrapperEl = this.containerEl.appendChild(document.createElement('div')); + this.wrapperEl = this.createViewer(document.createElement('div')); this.wrapperEl.className = CSS_CLASS_MEDIA; // Media Container diff --git a/src/lib/viewers/media/VideoBaseViewer.js b/src/lib/viewers/media/VideoBaseViewer.js index 89628ce7d..17a23736d 100644 --- a/src/lib/viewers/media/VideoBaseViewer.js +++ b/src/lib/viewers/media/VideoBaseViewer.js @@ -219,8 +219,8 @@ class VideoBaseViewer extends MediaBaseViewer { * @return {void} */ lowerLights() { - if (this.containerEl) { - this.containerEl.classList.add(CLASS_DARK); + if (this.rootEl) { + this.rootEl.classList.add(CLASS_DARK); } } diff --git a/src/lib/viewers/media/__tests__/DashViewer-test.html b/src/lib/viewers/media/__tests__/DashViewer-test.html index af8d6055b..325864e03 100644 --- a/src/lib/viewers/media/__tests__/DashViewer-test.html +++ b/src/lib/viewers/media/__tests__/DashViewer-test.html @@ -1 +1,5 @@ -
>
+
+
+
+
> +
diff --git a/src/lib/viewers/media/__tests__/MP3Viewer-test.html b/src/lib/viewers/media/__tests__/MP3Viewer-test.html index 7dd9073c1..58be94f4d 100644 --- a/src/lib/viewers/media/__tests__/MP3Viewer-test.html +++ b/src/lib/viewers/media/__tests__/MP3Viewer-test.html @@ -1 +1,7 @@ -
+
+
+
+
+
+
+
diff --git a/src/lib/viewers/media/__tests__/MP4Viewer-test.html b/src/lib/viewers/media/__tests__/MP4Viewer-test.html index 7dd9073c1..58be94f4d 100644 --- a/src/lib/viewers/media/__tests__/MP4Viewer-test.html +++ b/src/lib/viewers/media/__tests__/MP4Viewer-test.html @@ -1 +1,7 @@ -
+
+
+
+
+
+
+
diff --git a/src/lib/viewers/media/__tests__/MediaBaseViewer-test.html b/src/lib/viewers/media/__tests__/MediaBaseViewer-test.html index 7dd9073c1..58be94f4d 100644 --- a/src/lib/viewers/media/__tests__/MediaBaseViewer-test.html +++ b/src/lib/viewers/media/__tests__/MediaBaseViewer-test.html @@ -1 +1,7 @@ -
+
+
+
+
+
+
+
diff --git a/src/lib/viewers/media/__tests__/VideoBaseViewer-test.html b/src/lib/viewers/media/__tests__/VideoBaseViewer-test.html index 7dd9073c1..58be94f4d 100644 --- a/src/lib/viewers/media/__tests__/VideoBaseViewer-test.html +++ b/src/lib/viewers/media/__tests__/VideoBaseViewer-test.html @@ -1 +1,7 @@ -
+
+
+
+
+
+
+
diff --git a/src/lib/viewers/media/__tests__/VideoBaseViewer-test.js b/src/lib/viewers/media/__tests__/VideoBaseViewer-test.js index fa5df1f63..8a037217b 100644 --- a/src/lib/viewers/media/__tests__/VideoBaseViewer-test.js +++ b/src/lib/viewers/media/__tests__/VideoBaseViewer-test.js @@ -4,6 +4,7 @@ import MediaBaseViewer from '../MediaBaseViewer'; import BaseViewer from '../../BaseViewer'; let containerEl; +let rootEl; let videoBase; const sandbox = sinon.sandbox.create(); @@ -17,6 +18,7 @@ describe('lib/viewers/media/VideoBaseViewer', () => { beforeEach(() => { fixture.load('viewers/media/__tests__/VideoBaseViewer-test.html'); containerEl = document.querySelector('.container'); + rootEl = document.querySelector('.bp'); videoBase = new VideoBaseViewer({ cache: { set: () => {}, @@ -44,6 +46,7 @@ describe('lib/viewers/media/VideoBaseViewer', () => { Object.defineProperty(BaseViewer.prototype, 'setup', { value: sandbox.stub() }); videoBase.containerEl = containerEl; + videoBase.rootEl = rootEl; videoBase.setup(); }); @@ -238,7 +241,7 @@ describe('lib/viewers/media/VideoBaseViewer', () => { describe('lowerLights', () => { it('should add dark class to container', () => { videoBase.lowerLights(); - expect(videoBase.containerEl.classList.contains('bp-dark')).to.be.true; + expect(videoBase.rootEl.classList.contains('bp-dark')).to.be.true; }); }); }); diff --git a/src/lib/viewers/office/OfficeViewer.js b/src/lib/viewers/office/OfficeViewer.js index d2e093b9f..9feecf8b6 100644 --- a/src/lib/viewers/office/OfficeViewer.js +++ b/src/lib/viewers/office/OfficeViewer.js @@ -171,7 +171,7 @@ class OfficeViewer extends BaseViewer { setupIframe() { const { appHost, apiHost, file, sharedLink, location: { locale } } = this.options; const iframeEl = this.createIframeElement(); - this.containerEl.appendChild(iframeEl); + this.createViewer(iframeEl); if (this.platformSetup) { const formEl = this.createFormElement(apiHost, file.id, sharedLink, locale); diff --git a/src/lib/viewers/office/__tests__/OfficeViewer-test.html b/src/lib/viewers/office/__tests__/OfficeViewer-test.html index e1e597132..35bfca794 100644 --- a/src/lib/viewers/office/__tests__/OfficeViewer-test.html +++ b/src/lib/viewers/office/__tests__/OfficeViewer-test.html @@ -1,8 +1,10 @@ -
+
+
+
+
+
+
+
diff --git a/src/lib/viewers/swf/SWFViewer.js b/src/lib/viewers/swf/SWFViewer.js index 462aa8e16..4459bb017 100644 --- a/src/lib/viewers/swf/SWFViewer.js +++ b/src/lib/viewers/swf/SWFViewer.js @@ -21,7 +21,7 @@ class SWFViewer extends BaseViewer { setup() { // Call super() to set up common layout super.setup(); - this.playerEl = this.containerEl.appendChild(document.createElement('div')); + this.playerEl = this.createViewer(document.createElement('div')); this.playerEl.id = 'flash-player'; } diff --git a/src/lib/viewers/swf/__tests__/SWFViewer-test.html b/src/lib/viewers/swf/__tests__/SWFViewer-test.html index e1e597132..35bfca794 100644 --- a/src/lib/viewers/swf/__tests__/SWFViewer-test.html +++ b/src/lib/viewers/swf/__tests__/SWFViewer-test.html @@ -1,8 +1,10 @@ -
+
+
+
+
+
+
+
diff --git a/src/lib/viewers/text/CSVViewer.js b/src/lib/viewers/text/CSVViewer.js index 17f6af65a..9935d7e06 100644 --- a/src/lib/viewers/text/CSVViewer.js +++ b/src/lib/viewers/text/CSVViewer.js @@ -15,7 +15,7 @@ class CSVViewer extends TextBaseViewer { // Call super() first to set up common layout super.setup(); - this.csvEl = this.containerEl.appendChild(document.createElement('div')); + this.csvEl = this.createViewer(document.createElement('div')); this.csvEl.className = 'bp-text bp-text-csv'; } diff --git a/src/lib/viewers/text/PlainTextViewer.js b/src/lib/viewers/text/PlainTextViewer.js index 9c1724a26..82d209263 100644 --- a/src/lib/viewers/text/PlainTextViewer.js +++ b/src/lib/viewers/text/PlainTextViewer.js @@ -108,7 +108,7 @@ class PlainTextViewer extends TextBaseViewer { // Call super() first to set up common layout super.setup(); - this.textEl = this.containerEl.appendChild(document.createElement('pre')); + this.textEl = this.createViewer(document.createElement('pre')); this.textEl.className = 'bp-text bp-text-plain hljs'; this.textEl.classList.add(CLASS_HIDDEN); diff --git a/src/lib/viewers/text/__tests__/CSVViewer-test.html b/src/lib/viewers/text/__tests__/CSVViewer-test.html index 7dd9073c1..58be94f4d 100644 --- a/src/lib/viewers/text/__tests__/CSVViewer-test.html +++ b/src/lib/viewers/text/__tests__/CSVViewer-test.html @@ -1 +1,7 @@ -
+
+
+
+
+
+
+
diff --git a/src/lib/viewers/text/__tests__/MarkdownViewer-test.html b/src/lib/viewers/text/__tests__/MarkdownViewer-test.html index 7dd9073c1..58be94f4d 100644 --- a/src/lib/viewers/text/__tests__/MarkdownViewer-test.html +++ b/src/lib/viewers/text/__tests__/MarkdownViewer-test.html @@ -1 +1,7 @@ -
+
+
+
+
+
+
+
diff --git a/src/lib/viewers/text/__tests__/PlainTextViewer-test.html b/src/lib/viewers/text/__tests__/PlainTextViewer-test.html index 7dd9073c1..58be94f4d 100644 --- a/src/lib/viewers/text/__tests__/PlainTextViewer-test.html +++ b/src/lib/viewers/text/__tests__/PlainTextViewer-test.html @@ -1 +1,7 @@ -
+
+
+
+
+
+
+
diff --git a/test/integration/document/Controls.e2e.test.js b/test/integration/document/Controls.e2e.test.js index 9d3c282b2..23814c81f 100644 --- a/test/integration/document/Controls.e2e.test.js +++ b/test/integration/document/Controls.e2e.test.js @@ -7,6 +7,7 @@ describe('Preview Document Controls', () => { cy.visit('/'); cy.showPreview(token, fileId); cy.getByTestId('current-page').as('currentPage'); + cy.getPreviewPage(1); }); it('Should zoom in and out', () => { @@ -18,7 +19,7 @@ describe('Preview Document Controls', () => { cy.wrap($page[0].scrollHeight).as('originalHeight'); }); - cy.showControls(); + cy.showDocumentControls(); cy.getByTitle('Zoom out').click(); @@ -33,7 +34,7 @@ describe('Preview Document Controls', () => { cy.wrap(zoomedOutHeight).as('zoomedOutHeight'); }); - cy.showControls(); + cy.showDocumentControls(); cy.getByTitle('Zoom in').click(); @@ -51,14 +52,14 @@ describe('Preview Document Controls', () => { cy.contains('The Content Platform for Your Apps'); cy.get('@currentPage').invoke('text').should('equal', '1'); - cy.showControls(); + cy.showDocumentControls(); cy.getByTitle('Next page').click(); cy.getPreviewPage(2).should('be.visible'); cy.contains('Discover how your business can use Box Platform'); cy.get('@currentPage').invoke('text').should('equal', '2'); - cy.showControls(); + cy.showDocumentControls(); cy.getByTitle('Previous page').click(); cy.getPreviewPage(1).should('be.visible'); @@ -71,7 +72,7 @@ describe('Preview Document Controls', () => { cy.contains('The Content Platform for Your Apps'); cy.get('@currentPage').invoke('text').should('equal', '1'); - cy.showControls(); + cy.showDocumentControls(); cy.getByTitle('Click to enter page number').click(); cy.getByTestId('page-num-input').should('be.visible').type('2').blur(); @@ -87,7 +88,7 @@ describe('Preview Document Controls', () => { // it('Should handle going fullscreen', () => { // cy.getPreviewPage(1).should('be.visible'); // cy.contains('The Content Platform for Your Apps'); - // cy.showControls(); + // cy.showDocumentControls(); // cy.getByTitle('Enter fullscreen').should('be.visible').click(); // cy.getByTitle('Exit fullscreen').should('be.visible'); // }); diff --git a/test/integration/document/Thumbnails.e2e.test.js b/test/integration/document/Thumbnails.e2e.test.js new file mode 100644 index 000000000..eb130dcb4 --- /dev/null +++ b/test/integration/document/Thumbnails.e2e.test.js @@ -0,0 +1,99 @@ +// +describe('Preview Document Thumbnails', () => { + const token = Cypress.env('ACCESS_TOKEN'); + const fileId = Cypress.env('FILE_ID_DOC'); + const THUMBNAIL_SELECTED_CLASS = 'bp-thumbnail-is-selected'; + + /* eslint-disable */ + const getThumbnail = (pageNum) => cy.get(`.bp-thumbnail[data-bp-page-num=${pageNum}]`); + const showDocumentPreview = ({ enableThumbnailsSidebar } = {}) => { + cy.showPreview(token, fileId, { enableThumbnailsSidebar }); + cy.getPreviewPage(1); + cy.contains('The Content Platform for Your Apps'); + }; + const toggleThumbnails = () => { + cy.showDocumentControls(); + + cy + .getByTitle('Toggle thumbnails') + .should('be.visible') + .click(); + + return cy.getByTestId('thumbnails-sidebar'); + }; + /* eslint-enable */ + + beforeEach(() => { + cy.visit('/'); + }); + + it('Should not see the sidebar button if disabled', () => { + showDocumentPreview({ enableThumbnailsSidebar: false }); + + cy.showDocumentControls(); + cy.getByTitle('Toggle thumbnails').should('not.be.visible'); + }); + + it('Should see the sidebar button if enabled', () => { + showDocumentPreview({ enableThumbnailsSidebar: true }); + + cy.showDocumentControls(); + cy.getByTitle('Toggle thumbnails').should('be.visible'); + }); + + it('Should render thumbnails when toggled', () => { + showDocumentPreview({ enableThumbnailsSidebar: true }); + + toggleThumbnails().should('be.visible'); + + toggleThumbnails().should('not.be.visible'); + }); + + it('Should be able to change page by clicking on the thumbnail', () => { + showDocumentPreview({ enableThumbnailsSidebar: true }); + + // Verify we're on page 1 + cy.getByTestId('current-page').as('currentPage') + .invoke('text') + .should('equal', '1'); + + toggleThumbnails().should('be.visible'); + + // Verify which thumbnail is selected + getThumbnail(1) + .should('have.class', THUMBNAIL_SELECTED_CLASS) + .as('thumbOne'); + getThumbnail(2) + .click() + .should('have.class', THUMBNAIL_SELECTED_CLASS); + cy.get('@thumbOne').should('not.have.class', THUMBNAIL_SELECTED_CLASS); + cy + .get('@currentPage') + .invoke('text') + .should('equal', '2'); + }); + + it('Should reflect the selected page when page is changed', () => { + showDocumentPreview({ enableThumbnailsSidebar: true }); + cy + .getByTestId('current-page') + .as('currentPage') + .invoke('text') + .should('equal', '1'); + + toggleThumbnails().should('be.visible'); + + getThumbnail(1) + .should('have.class', THUMBNAIL_SELECTED_CLASS) + .as('thumbOne'); + + cy.getByTitle('Next page').click(); + + getThumbnail(2).should('have.class', THUMBNAIL_SELECTED_CLASS); + cy.get('@thumbOne').should('not.have.class', THUMBNAIL_SELECTED_CLASS); + cy + .get('@currentPage') + .invoke('text') + .should('equal', '2'); + }); +}); diff --git a/test/integration/sanity/Sanity.e2e.test.js b/test/integration/sanity/Sanity.e2e.test.js index 2c7d095ed..519bda252 100644 --- a/test/integration/sanity/Sanity.e2e.test.js +++ b/test/integration/sanity/Sanity.e2e.test.js @@ -8,9 +8,8 @@ describe('Preview Sanity', () => { }); it('Should load a document preview', () => { - // Show the preview cy.showPreview(token, fileId); - // Assert document content is present + cy.getPreviewPage(1); cy.contains('The Content Platform for Your Apps'); }); }); diff --git a/test/support/commands.js b/test/support/commands.js index 6e9f5b920..59bc5dc86 100644 --- a/test/support/commands.js +++ b/test/support/commands.js @@ -4,22 +4,26 @@ Cypress.Commands.add('getPreviewPage', (pageNum) => { cy .get(`.page[data-page-number=${pageNum}]`) .as('previewPage') - .find('[data-testid="page-loading-indicator"]') + // Adding timeout here because sometimes it takes more than the Cypress + // default timeout to render the preview + .find('[data-testid="page-loading-indicator"]', { timeout: 15000 }) .should('not.exist'); return cy.get('@previewPage'); }); +Cypress.Commands.add('showDocumentControls', () => { + cy.getByTestId('bp').trigger('mousemove'); + return cy.getByTestId('controls-wrapper').should('be.visible'); +}); Cypress.Commands.add('showPreview', (token, fileId, options) => { cy.getByTestId('token').type(token); cy.getByTestId('token-set').click(); cy.getByTestId('fileid').type(fileId); cy.getByTestId('fileid-set').click(); - if (options) { - cy.window().then((win) => { - win.loadPreview(options); - }); - } + cy.window().then((win) => { + win.loadPreview(options); + }); // Wait for .bp to load viewer return cy.getByTestId('bp').should('have.class', 'bp-loaded');