diff --git a/src/lib/Controls.js b/src/lib/Controls.js index f84cf238a..e4aa84f45 100644 --- a/src/lib/Controls.js +++ b/src/lib/Controls.js @@ -4,8 +4,8 @@ import { CLASS_HIDDEN } from './constants'; const SHOW_PREVIEW_CONTROLS_CLASS = 'box-show-preview-controls'; const CONTROLS_BUTTON_CLASS = 'bp-controls-btn'; -const CONTROLS_PAGE_NUM_INPUT_CLASS = 'bp-doc-page-num-input'; -const CONTROLS_PAGE_NUM_WRAPPER_CLASS = 'bp-doc-page-num-wrapper'; +const CONTROLS_PAGE_NUM_INPUT_CLASS = 'bp-page-num-input'; +const CONTROLS_PAGE_NUM_WRAPPER_CLASS = 'bp-page-num-wrapper'; const CONTROLS_AUTO_HIDE_TIMEOUT_IN_MILLIS = 2000; class Controls { diff --git a/src/lib/Controls.scss b/src/lib/Controls.scss index 2ea3831c0..5216ce2cc 100644 --- a/src/lib/Controls.scss +++ b/src/lib/Controls.scss @@ -16,6 +16,71 @@ position: relative; table-layout: fixed; transition: opacity .5s; + + // Page num input CSS + .bp-page-num { + min-width: 48px; + width: auto; // Let page num expand as needed + + span { + display: inline; + font-size: 14px; + } + } + + .bp-page-num-wrapper { + background-color: #444; + border-radius: 3px; + margin: 5px; + padding: 7px 5px; + } + + /* stylelint-disable property-no-vendor-prefix */ + // Removes the spinner for number type inputs in Webkit browsers + input::-webkit-outer-spin-button, + input::-webkit-inner-spin-button { + -webkit-appearance: none; + } + + // Removes the spinner for number type inputs in Firefox + input[type=number] { + -moz-appearance: textfield; + } + + /* stylelint-enable property-no-vendor-prefix */ + + .bp-page-num-input { + font-size: 14px; + margin: 0 auto; + position: absolute; + text-align: center; + visibility: hidden; + width: 44px; // hard-coded to solve layout issues + } + + &.show-page-number-input { + .bp-page-num-wrapper { + background-color: transparent; + border: none; + padding: 0; + } + + .bp-page-num { + opacity: 1; + } + + .bp-current-page, + .bp-page-num-divider, + .bp-total-pages { + display: none; + } + + .bp-page-num-input { + display: inline-block; + position: static; + visibility: visible; + } + } } .box-show-preview-controls .bp-controls { diff --git a/src/lib/PageControls.js b/src/lib/PageControls.js new file mode 100644 index 000000000..812e58edc --- /dev/null +++ b/src/lib/PageControls.js @@ -0,0 +1,231 @@ +import EventEmitter from 'events'; +import fullscreen from './Fullscreen'; +import Browser from './Browser'; +import { decodeKeydown } from './util'; +import { ICON_DROP_DOWN, ICON_DROP_UP } from './icons/icons'; + +const SHOW_PAGE_NUM_INPUT_CLASS = 'show-page-number-input'; +const CONTROLS_PAGE_NUM_WRAPPER_CLASS = 'bp-page-num-wrapper'; +const CONTROLS_CURRENT_PAGE = 'bp-current-page'; +const CONTROLS_PAGE_NUM_INPUT_CLASS = 'bp-page-num-input'; +const CONTROLS_TOTAL_PAGES = 'bp-total-pages'; +const PAGE_NUM = 'bp-page-num'; +const PREV_PAGE = 'bp-previous-page'; +const NEXT_PAGE = 'bp-next-page'; + +const pageNumTemplate = ` +
+ 1 + +  /  + 1 +
`.replace(/>\s*<'); + +class PageControls extends EventEmitter { + /** + * [constructor] + * + * @param {HTMLElement} controls - Viewer controls + * @param {Function} previousPage - Previous page handler + * @param {Function} nextPage - Next page handler + * @return {Controls} Instance of controls + */ + constructor(controls, previousPage, nextPage) { + super(); + + this.controls = controls; + this.controlsEl = controls.controlsEl; + this.currentPageEl = controls.currentPageEl; + this.pageNumInputEl = controls.pageNumInputEl; + + this.controls.add(__('previous_page'), previousPage, `bp-previous-page-icon ${PREV_PAGE}`, ICON_DROP_UP); + this.controls.add(__('enter_page_num'), this.showPageNumInput.bind(this), PAGE_NUM, pageNumTemplate); + this.controls.add(__('next_page'), nextPage, `bp-next-page-icon ${NEXT_PAGE}`, ICON_DROP_DOWN); + } + + /** + * Initializes page number selector. + * + * @private + * @param {number} pagesCount - Total number of page + * @return {void} + */ + init(pagesCount) { + const pageNumEl = this.controlsEl.querySelector(`.${PAGE_NUM}`); + this.pagesCount = pagesCount; + + // Update total page number + const totalPageEl = pageNumEl.querySelector(`.${CONTROLS_TOTAL_PAGES}`); + totalPageEl.textContent = pagesCount; + + // Keep reference to page number input and current page elements + this.pageNumInputEl = pageNumEl.querySelector(`.${CONTROLS_PAGE_NUM_INPUT_CLASS}`); + this.pageNumInputEl.setAttribute('max', pagesCount); + + this.currentPageEl = pageNumEl.querySelector(`.${CONTROLS_CURRENT_PAGE}`); + } + + /** + * Replaces the page number display with an input box that allows the user to type in a page number + * + * @private + * @return {void} + */ + showPageNumInput() { + // show the input box with the current page number selected within it + this.controlsEl.classList.add(SHOW_PAGE_NUM_INPUT_CLASS); + + this.pageNumInputEl.value = this.currentPageEl.textContent; + this.pageNumInputEl.focus(); + this.pageNumInputEl.select(); + + // finish input when input is blurred or enter key is pressed + this.pageNumInputEl.addEventListener('blur', this.pageNumInputBlurHandler.bind(this)); + this.pageNumInputEl.addEventListener('keydown', this.pageNumInputKeydownHandler.bind(this)); + } + + /** + * Hide the page number input + * + * @private + * @return {void} + */ + hidePageNumInput() { + this.controlsEl.classList.remove(SHOW_PAGE_NUM_INPUT_CLASS); + this.pageNumInputEl.removeEventListener('blur', this.pageNumInputBlurHandler); + this.pageNumInputEl.removeEventListener('keydown', this.pageNumInputKeydownHandler); + } + + /** + * Disables or enables previous/next pagination buttons depending on + * current page number. + * + * @param {number} currentPageNum - Current page number + * @param {number} pagesCount - Total number of page + * @return {void} + */ + checkPaginationButtons(currentPageNum, pagesCount) { + const pageNumButtonEl = this.controlsEl.querySelector(`.${PAGE_NUM}`); + const previousPageButtonEl = this.controlsEl.querySelector(`.${PREV_PAGE}`); + const nextPageButtonEl = this.controlsEl.querySelector(`.${NEXT_PAGE}`); + + // Safari disables keyboard input in fullscreen before Safari 10.1 + const isSafariFullscreen = Browser.getName() === 'Safari' && fullscreen.isFullscreen(this.controlsEl); + + // Disable page number selector if there is only one page or less + if (pageNumButtonEl) { + if (pagesCount <= 1 || isSafariFullscreen) { + pageNumButtonEl.disabled = true; + } else { + pageNumButtonEl.disabled = false; + } + } + + // Disable previous page if on first page, otherwise enable + if (previousPageButtonEl) { + if (currentPageNum === 1) { + previousPageButtonEl.disabled = true; + } else { + previousPageButtonEl.disabled = false; + } + } + + // Disable next page if on last page, otherwise enable + if (nextPageButtonEl) { + if (currentPageNum === pagesCount) { + nextPageButtonEl.disabled = true; + } else { + nextPageButtonEl.disabled = false; + } + } + } + + /** + * Update page number in page control widget. + * + * @private + * @param {number} pageNum - Number of page to update to + * @return {void} + */ + updateCurrentPage(pageNum) { + let truePageNum = pageNum; + + // refine the page number to fall within bounds + if (pageNum > this.pagesCount) { + truePageNum = this.pagesCount; + } else if (pageNum < 1) { + truePageNum = 1; + } + + if (this.pageNumInputEl) { + this.pageNumInputEl.value = truePageNum; + } + + if (this.currentPageEl) { + this.currentPageEl.textContent = truePageNum; + } + + this.currentPageNumber = truePageNum; + this.checkPaginationButtons(this.currentPageNumber, this.pagesCount); + } + + /** + * Blur handler for page number input. + * + * @private + * @param {Event} event Blur event + * @return {void} + */ + pageNumInputBlurHandler(event) { + const target = event.target; + const pageNum = parseInt(target.value, 10); + + if (!isNaN(pageNum)) { + this.emit('setpage', pageNum); + } + + this.hidePageNumInput(); + } + + /** + * Keydown handler for page number input. + * + * @private + * @param {Event} event - Keydown event + * @return {void} + */ + pageNumInputKeydownHandler(event) { + const key = decodeKeydown(event); + + switch (key) { + case 'Enter': + case 'Tab': + // The keycode of the 'next' key on Android Chrome is 9, which maps to 'Tab'. + // this.docEl.focus(); + // We normally trigger the blur handler by blurring the input + // field, but this doesn't work for IE in fullscreen. For IE, + // we blur the page behind the controls - this unfortunately + // is an IE-only solution that doesn't work with other browsers + if (Browser.getName() !== 'Explorer') { + event.target.blur(); + } + + event.stopPropagation(); + event.preventDefault(); + break; + + case 'Escape': + this.hidePageNumInput(); + // this.docEl.focus(); + + event.stopPropagation(); + event.preventDefault(); + break; + + default: + break; + } + } +} + +export default PageControls; diff --git a/src/lib/__tests__/Controls-test.js b/src/lib/__tests__/Controls-test.js index 5f3bd9799..dbead76fd 100644 --- a/src/lib/__tests__/Controls-test.js +++ b/src/lib/__tests__/Controls-test.js @@ -93,7 +93,7 @@ describe('lib/Controls', () => { element.className = ''; expect(controls.isPreviewControlButton(element)).to.be.false; - parent.className = 'bp-doc-page-num-wrapper'; + parent.className = 'bp-page-num-wrapper'; expect(controls.isPreviewControlButton(element)).to.be.true; }); }); diff --git a/src/lib/__tests__/PageControls-test.html b/src/lib/__tests__/PageControls-test.html new file mode 100644 index 000000000..3d412d36e --- /dev/null +++ b/src/lib/__tests__/PageControls-test.html @@ -0,0 +1 @@ +
diff --git a/src/lib/__tests__/PageControls-test.js b/src/lib/__tests__/PageControls-test.js new file mode 100644 index 000000000..cba7bf7f1 --- /dev/null +++ b/src/lib/__tests__/PageControls-test.js @@ -0,0 +1,232 @@ +/* eslint-disable no-unused-expressions */ +import PageControls from '../PageControls'; +import Controls from '../Controls'; +import { CLASS_HIDDEN } from './../constants'; +import fullscreen from '../Fullscreen'; +import Browser from '../Browser'; +import { decodeKeydown } from '../util'; +import { ICON_DROP_DOWN, ICON_DROP_UP } from '../icons/icons'; + +let pageControls; +let clock; +let stubs = {}; + +const sandbox = sinon.sandbox.create(); + +const SHOW_PREVIEW_CONTROLS_CLASS = 'box-show-preview-controls'; +const RESET_TIMEOUT_CLOCK_TICK = 2001; + +const SHOW_PAGE_NUM_INPUT_CLASS = 'show-page-number-input'; +const CONTROLS_PAGE_NUM_WRAPPER_CLASS = 'bp-page-num-wrapper'; +const CONTROLS_CURRENT_PAGE = 'bp-current-page'; +const CONTROLS_PAGE_NUM_INPUT_CLASS = 'bp-page-num-input'; +const CONTROLS_TOTAL_PAGES = 'bp-total-pages'; +const PAGE_NUM = 'bp-page-num'; +const PREV_PAGE = 'bp-previous-page'; +const NEXT_PAGE = 'bp-next-page'; + +describe('lib/PageControls', () => { + before(() => { + fixture.setBase('src/lib'); + }); + + beforeEach(() => { + fixture.load('__tests__/PageControls-test.html'); + const controls = new Controls(document.getElementById('test-page-controls-container')); + pageControls = new PageControls(controls, sandbox.stub, sandbox.stub); + }); + + afterEach(() => { + fixture.cleanup(); + sandbox.verifyAndRestore(); + + if (pageControls && typeof pageControls.destroy === 'function') { + pageControls.destroy(); + } + + pageControls = null; + stubs = {}; + }); + + describe('constructor()', () => { + it('should create the correct DOM structure', () => { + expect(pageControls.controlsEl).to.not.be.undefined; + expect(pageControls.controls.buttonRefs.length).equals(3); + }); + }); + + describe('init()', () => { + it('should initialize the page number selector', () => { + const pagesCount = '5'; + pageControls.init(pagesCount); + const totalPageEl = pageControls.controlsEl.querySelector(`.${CONTROLS_TOTAL_PAGES}`); + const pageNumInputEl = pageControls.controlsEl.querySelector(`.${CONTROLS_PAGE_NUM_INPUT_CLASS}`); + expect(pageControls.pagesCount).equals(pagesCount); + expect(totalPageEl).to.have.text(pagesCount); + expect(pageNumInputEl).to.have.attr('max', pagesCount); + expect(pageControls.currentPageEl).to.not.be.undefined; + }); + }); + + describe('showPageNumInput()', () => { + it('should set the page number input value, focus, select, and add listeners', () => { + pageControls.currentPageEl = 0; + pageControls.pageNumInputEl = { + value: 0, + focus: sandbox.stub(), + select: sandbox.stub(), + addEventListener: sandbox.stub() + }; + + pageControls.showPageNumInput(); + expect(pageControls.controlsEl).to.have.class(SHOW_PAGE_NUM_INPUT_CLASS); + expect(pageControls.pageNumInputEl.focus).to.be.called; + expect(pageControls.pageNumInputEl.select).to.be.called; + expect(pageControls.pageNumInputEl.addEventListener).to.be.calledWith('blur', sinon.match.func); + expect(pageControls.pageNumInputEl.addEventListener).to.be.calledWith('keydown', sinon.match.func); + }); + }); + + describe('hidePageNumInput()', () => { + it('should hide the input class and remove event listeners', () => { + pageControls.pageNumInputEl = { + removeEventListener: sandbox.stub() + }; + + pageControls.hidePageNumInput(); + expect(pageControls.controlsEl).to.not.have.class(SHOW_PAGE_NUM_INPUT_CLASS); + expect(pageControls.pageNumInputEl.removeEventListener).to.be.calledWith('blur', sinon.match.func); + expect(pageControls.pageNumInputEl.removeEventListener).to.be.calledWith('keydown', sinon.match.func); + }); + }); + + describe('checkPaginationButtons()', () => { + beforeEach(() => { + stubs.pageNumButtonEl = pageControls.controlsEl.querySelector(`.${PAGE_NUM}`); + stubs.previousPageButtonEl = pageControls.controlsEl.querySelector(`.${PREV_PAGE}`); + stubs.nextPageButtonEl = pageControls.controlsEl.querySelector(`.${NEXT_PAGE}`); + + stubs.browser = sandbox.stub(Browser, 'getName').returns('Safari'); + stubs.fullscreen = sandbox.stub(fullscreen, 'isFullscreen').returns(true); + }); + + it('should disable/enable page number button el based on current page and browser type', () => { + pageControls.checkPaginationButtons(); + expect(stubs.pageNumButtonEl.disabled).to.equal(true); + + pageControls.checkPaginationButtons(1, 6); + expect(stubs.pageNumButtonEl.disabled).to.equal(true); + + stubs.fullscreen.returns('false'); + stubs.browser.returns('Chrome'); + pageControls.checkPaginationButtons(1, 6); + expect(stubs.pageNumButtonEl.disabled).to.equal(false); + }); + + it('should disable/enable previous page button el based on current page', () => { + pageControls.checkPaginationButtons(1, 5); + expect(stubs.previousPageButtonEl.disabled).to.equal(true); + + pageControls.checkPaginationButtons(20, 20); + expect(stubs.previousPageButtonEl.disabled).to.equal(false); + }); + + it('should disable/enable next page button el based on current page', () => { + pageControls.checkPaginationButtons(20, 20); + expect(stubs.nextPageButtonEl.disabled).to.equal(true); + + pageControls.checkPaginationButtons(1, 20); + expect(stubs.nextPageButtonEl.disabled).to.equal(false); + }); + }); + + describe('updateCurrentPage()', () => { + it('should only update the page to a valid value', () => { + pageControls.pagesCount = 10; + pageControls.pageNumInputEl = { + value: 1, + textContent: 1 + }; + const checkPaginationButtonsStub = sandbox.stub(pageControls, 'checkPaginationButtons'); + + pageControls.updateCurrentPage(-5); + expect(checkPaginationButtonsStub).to.be.called; + expect(pageControls.pageNumInputEl.value).to.equal(1); + + pageControls.updateCurrentPage(25); + expect(checkPaginationButtonsStub).to.be.called; + expect(pageControls.pageNumInputEl.value).to.equal(10); + + pageControls.updateCurrentPage(7); + expect(checkPaginationButtonsStub).to.be.called; + expect(pageControls.pageNumInputEl.value).to.equal(7); + }); + }); + + describe('pageNumInputBlurHandler()', () => { + beforeEach(() => { + stubs.event = { + target: { + value: 5 + } + }; + stubs.emit = sandbox.stub(pageControls, 'emit'); + stubs.hidePageNumInputStub = sandbox.stub(pageControls, 'hidePageNumInput'); + }); + + it('should hide the page number input and set the page if given valid input', () => { + pageControls.pageNumInputBlurHandler(stubs.event); + expect(stubs.emit).to.be.calledWith('setpage', stubs.event.target.value); + expect(stubs.hidePageNumInputStub).to.be.called; + }); + + it('should hide the page number input but not set the page if given invalid input', () => { + stubs.event.target.value = 'not a number'; + + pageControls.pageNumInputBlurHandler(stubs.event); + expect(stubs.emit).to.be.not.be.called; + expect(stubs.hidePageNumInputStub).to.be.called; + }); + }); + + describe('pageNumInputKeydownHandler()', () => { + beforeEach(() => { + stubs.event = { + key: 'Enter', + stopPropagation: sandbox.stub(), + preventDefault: sandbox.stub(), + target: { + blur: sandbox.stub() + } + }; + stubs.browser = sandbox.stub(Browser, 'getName').returns('Explorer'); + stubs.hidePageNumInput = sandbox.stub(pageControls, 'hidePageNumInput'); + }); + + it('should focus the doc element if IE and stop default actions on \'enter\'', () => { + pageControls.pageNumInputKeydownHandler(stubs.event); + expect(stubs.browser).to.be.called; + expect(stubs.event.stopPropagation).to.be.called; + expect(stubs.event.preventDefault).to.be.called; + }); + + it('should blur if not IE and stop default actions on \'enter\'', () => { + stubs.browser.returns('Chrome'); + + pageControls.pageNumInputKeydownHandler(stubs.event); + expect(stubs.browser).to.be.called; + expect(stubs.event.target.blur).to.be.called; + expect(stubs.event.stopPropagation).to.be.called; + expect(stubs.event.preventDefault).to.be.called; + }); + + it('should hide the page number input, focus the document, and stop default actions on \'Esc\'', () => { + stubs.event.key = 'Esc'; + + pageControls.pageNumInputKeydownHandler(stubs.event); + expect(stubs.hidePageNumInput).to.be.called; + expect(stubs.event.stopPropagation).to.be.called; + expect(stubs.event.preventDefault).to.be.called; + }); + }); +}); diff --git a/src/lib/viewers/doc/DocBaseViewer.js b/src/lib/viewers/doc/DocBaseViewer.js index 17c5cc274..49afda508 100644 --- a/src/lib/viewers/doc/DocBaseViewer.js +++ b/src/lib/viewers/doc/DocBaseViewer.js @@ -3,6 +3,7 @@ import throttle from 'lodash.throttle'; import BaseViewer from '../BaseViewer'; import Browser from '../../Browser'; import Controls from '../../Controls'; +import PageControls from '../../PageControls'; import DocFindBar from './DocFindBar'; import fullscreen from '../../Fullscreen'; import Popup from '../../Popup'; @@ -17,7 +18,7 @@ import { STATUS_ERROR } from '../../constants'; import { checkPermission, getRepresentation } from '../../file'; -import { get, createAssetUrlCreator, decodeKeydown } from '../../util'; +import { get, createAssetUrlCreator } from '../../util'; import { ICON_PRINT_CHECKMARK, ICON_FILE_DOCUMENT } from '../../icons/icons'; import { JS, CSS } from './docAssets'; @@ -28,7 +29,6 @@ const SAFARI_PRINT_TIMEOUT_MS = 1000; // Wait 1s before trying to print const PRINT_DIALOG_TIMEOUT_MS = 500; const MAX_SCALE = 10.0; const MIN_SCALE = 0.1; -const SHOW_PAGE_NUM_INPUT_CLASS = 'show-page-number-input'; const IS_SAFARI_CLASS = 'is-safari'; const SCROLL_EVENT_THROTTLE_INTERVAL = 200; const SCROLL_END_TIMEOUT = this.isMobile ? 500 : 250; @@ -306,7 +306,7 @@ class DocBaseViewer extends BaseViewer { * @return {void} */ setPage(pageNum) { - if (pageNum <= 0 || pageNum > this.pdfViewer.pagesCount) { + if (pageNum < 1 || pageNum > this.pdfViewer.pagesCount) { return; } @@ -347,50 +347,6 @@ class DocBaseViewer extends BaseViewer { this.cache.set(CURRENT_PAGE_MAP_KEY, currentPageMap, true /* useLocalStorage */); } - /** - * Disables or enables previous/next pagination buttons depending on - * current page number. - * - * @return {void} - */ - checkPaginationButtons() { - const pagesCount = this.pdfViewer.pagesCount; - const currentPageNum = this.pdfViewer.currentPageNumber; - const pageNumButtonEl = this.containerEl.querySelector('.bp-doc-page-num'); - const previousPageButtonEl = this.containerEl.querySelector('.bp-previous-page'); - const nextPageButtonEl = this.containerEl.querySelector('.bp-next-page'); - - // Safari disables keyboard input in fullscreen before Safari 10.1 - const isSafariFullscreen = Browser.getName() === 'Safari' && fullscreen.isFullscreen(this.containerEl); - - // Disable page number selector if there is only one page or less - if (pageNumButtonEl) { - if (pagesCount <= 1 || isSafariFullscreen) { - pageNumButtonEl.disabled = true; - } else { - pageNumButtonEl.disabled = false; - } - } - - // Disable previous page if on first page, otherwise enable - if (previousPageButtonEl) { - if (currentPageNum === 1) { - previousPageButtonEl.disabled = true; - } else { - previousPageButtonEl.disabled = false; - } - } - - // Disable next page if on last page, otherwise enable - if (nextPageButtonEl) { - if (currentPageNum === this.pdfViewer.pagesCount) { - nextPageButtonEl.disabled = true; - } else { - nextPageButtonEl.disabled = false; - } - } - } - /** * Zoom into document. * @@ -663,26 +619,6 @@ class DocBaseViewer extends BaseViewer { }); } - /** - * Initializes page number selector. - * - * @private - * @return {void} - */ - initPageNumEl() { - const pageNumEl = this.controls.controlsEl.querySelector('.bp-doc-page-num'); - - // Update total page number - const totalPageEl = pageNumEl.querySelector('.bp-doc-total-pages'); - totalPageEl.textContent = this.pdfViewer.pagesCount; - - // Keep reference to page number input and current page elements - this.pageNumInputEl = pageNumEl.querySelector('.bp-doc-page-num-input'); - this.pageNumInputEl.setAttribute('max', this.pdfViewer.pagesCount); - - this.currentPageEl = pageNumEl.querySelector('.bp-doc-current-page'); - } - /** * Fetches PDF and converts to blob for printing. * @@ -760,68 +696,8 @@ class DocBaseViewer extends BaseViewer { */ loadUI() { this.controls = new Controls(this.containerEl); + this.pageControls = new PageControls(this.controls, this.prevPage, this.nextPage); this.bindControlListeners(); - this.initPageNumEl(); - } - - /** - * Replaces the page number display with an input box that allows the user to type in a page number - * - * @private - * @return {void} - */ - showPageNumInput() { - // show the input box with the current page number selected within it - this.controls.controlsEl.classList.add(SHOW_PAGE_NUM_INPUT_CLASS); - - this.pageNumInputEl.value = this.currentPageEl.textContent; - this.pageNumInputEl.focus(); - this.pageNumInputEl.select(); - - // finish input when input is blurred or enter key is pressed - this.pageNumInputEl.addEventListener('blur', this.pageNumInputBlurHandler); - this.pageNumInputEl.addEventListener('keydown', this.pageNumInputKeydownHandler); - } - - /** - * Hide the page number input - * - * @private - * @return {void} - */ - hidePageNumInput() { - this.controls.controlsEl.classList.remove(SHOW_PAGE_NUM_INPUT_CLASS); - this.pageNumInputEl.removeEventListener('blur', this.pageNumInputBlurHandler); - this.pageNumInputEl.removeEventListener('keydown', this.pageNumInputKeydownHandler); - } - - /** - * Update page number in page control widget. - * - * @private - * @param {number} pageNum - Number of page to update to - * @return {void} - */ - updateCurrentPage(pageNum) { - let truePageNum = pageNum; - const pagesCount = this.pdfViewer.pagesCount; - - // refine the page number to fall within bounds - if (pageNum > pagesCount) { - truePageNum = pagesCount; - } else if (pageNum < 1) { - truePageNum = 1; - } - - if (this.pageNumInputEl) { - this.pageNumInputEl.value = truePageNum; - } - - if (this.currentPageEl) { - this.currentPageEl.textContent = truePageNum; - } - - this.checkPaginationButtons(); } //-------------------------------------------------------------------------- @@ -901,64 +777,6 @@ class DocBaseViewer extends BaseViewer { */ bindControlListeners() {} - /** - * Blur handler for page number input. - * - * @param {Event} event Blur event - * @return {void} - * @private - */ - pageNumInputBlurHandler(event) { - const target = event.target; - const pageNum = parseInt(target.value, 10); - - if (!isNaN(pageNum)) { - this.setPage(pageNum); - } - - this.hidePageNumInput(); - } - - /** - * Keydown handler for page number input. - * - * @private - * @param {Event} event - Keydown event - * @return {void} - */ - pageNumInputKeydownHandler(event) { - const key = decodeKeydown(event); - - switch (key) { - case 'Enter': - case 'Tab': - // The keycode of the 'next' key on Android Chrome is 9, which maps to 'Tab'. - this.docEl.focus(); - // We normally trigger the blur handler by blurring the input - // field, but this doesn't work for IE in fullscreen. For IE, - // we blur the page behind the controls - this unfortunately - // is an IE-only solution that doesn't work with other browsers - if (Browser.getName() !== 'Explorer') { - event.target.blur(); - } - - event.stopPropagation(); - event.preventDefault(); - break; - - case 'Escape': - this.hidePageNumInput(); - this.docEl.focus(); - - event.stopPropagation(); - event.preventDefault(); - break; - - default: - break; - } - } - /** * Handler for 'pagesinit' event. * @@ -969,7 +787,7 @@ class DocBaseViewer extends BaseViewer { this.pdfViewer.currentScaleValue = 'auto'; this.loadUI(); - this.checkPaginationButtons(); + this.pageControls.checkPaginationButtons(this.pdfViewer.currentPageNumber, this.pdfViewer.pagesCount); // Set current page to previously opened page or first page this.setPage(this.getCachedPage()); @@ -1029,7 +847,7 @@ class DocBaseViewer extends BaseViewer { */ pagechangeHandler(event) { const pageNum = event.pageNumber; - this.updateCurrentPage(pageNum); + this.pageControls.updateCurrentPage(pageNum); // We only set cache the current page if 'pagechange' was fired after // preview is loaded - this filters out pagechange events fired by diff --git a/src/lib/viewers/doc/DocumentViewer.js b/src/lib/viewers/doc/DocumentViewer.js index c2cce2bcb..21537f4bb 100644 --- a/src/lib/viewers/doc/DocumentViewer.js +++ b/src/lib/viewers/doc/DocumentViewer.js @@ -1,11 +1,8 @@ import autobind from 'autobind-decorator'; -import pageNumTemplate from './pageNumButtonContent.html'; import DocBaseViewer from './DocBaseViewer'; import DocPreloader from './DocPreloader'; import fullscreen from '../../Fullscreen'; import { - ICON_DROP_DOWN, - ICON_DROP_UP, ICON_FILE_DOCUMENT, ICON_FILE_PDF, ICON_FILE_SPREADSHEET, @@ -103,16 +100,8 @@ class DocumentViewer extends DocBaseViewer { 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.controls.add( - __('previous_page'), - this.previousPage, - 'bp-doc-previous-page-icon bp-previous-page', - ICON_DROP_UP - ); - - const buttonContent = pageNumTemplate.replace(/>\s*<'); // removing new lines - this.controls.add(__('enter_page_num'), this.showPageNumInput, 'bp-doc-page-num', buttonContent); - this.controls.add(__('next_page'), this.nextPage, 'bp-doc-next-page-icon bp-next-page', ICON_DROP_DOWN); + this.pageControls.init(this.pdfViewer.pagesCount); + this.pageControls.addListener('setpage', this.setPage); this.controls.add( __('enter_fullscreen'), diff --git a/src/lib/viewers/doc/PresentationViewer.js b/src/lib/viewers/doc/PresentationViewer.js index 2e014d479..0f6bdfa2c 100644 --- a/src/lib/viewers/doc/PresentationViewer.js +++ b/src/lib/viewers/doc/PresentationViewer.js @@ -1,12 +1,9 @@ import autobind from 'autobind-decorator'; import throttle from 'lodash.throttle'; -import pageNumTemplate from './pageNumButtonContent.html'; import DocBaseViewer from './DocBaseViewer'; import PresentationPreloader from './PresentationPreloader'; import { CLASS_INVISIBLE } from '../../constants'; import { - ICON_DROP_DOWN, - ICON_DROP_UP, ICON_FILE_PRESENTATION, ICON_FULLSCREEN_IN, ICON_FULLSCREEN_OUT, @@ -193,22 +190,8 @@ class PresentationViewer extends DocBaseViewer { 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.controls.add( - __('previous_page'), - this.previousPage, - 'bp-presentation-previous-page-icon bp-previous-page', - ICON_DROP_UP - ); - - const buttonContent = pageNumTemplate.replace(/>\s*<'); // removing new lines - this.controls.add(__('enter_page_num'), this.showPageNumInput, 'bp-doc-page-num', buttonContent); - - this.controls.add( - __('next_page'), - this.nextPage, - 'bp-presentation-next-page-icon bp-next-page', - ICON_DROP_DOWN - ); + this.pageControls.init(this.pdfViewer.pagesCount); + this.pageControls.addListener('setpage', this.setPage); this.controls.add( __('enter_fullscreen'), diff --git a/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js b/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js index e0646732f..3bd2a7cb5 100644 --- a/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js +++ b/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js @@ -3,6 +3,7 @@ import DocBaseViewer from '../DocBaseViewer'; import Browser from '../../../Browser'; import BaseViewer from '../../BaseViewer'; import Controls from '../../../Controls'; +import PageControls from '../../../PageControls'; import fullscreen from '../../../Fullscreen'; import DocPreloader from '../DocPreloader'; import * as file from '../../../file'; @@ -568,76 +569,6 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { }); }); - describe('checkPaginationButtons()', () => { - beforeEach(() => { - const pageNumButtonEl = document.createElement('div'); - pageNumButtonEl.className = 'bp-doc-page-num'; - pageNumButtonEl.disabled = undefined; - docBase.containerEl.appendChild(pageNumButtonEl); - - const previousPageButtonEl = document.createElement('div'); - previousPageButtonEl.className = 'bp-previous-page'; - previousPageButtonEl.disabled = undefined; - docBase.containerEl.appendChild(previousPageButtonEl); - - const nextPageButtonEl = document.createElement('div'); - nextPageButtonEl.className = 'bp-next-page'; - nextPageButtonEl.disabled = undefined; - docBase.containerEl.appendChild(nextPageButtonEl); - - docBase.pdfViewer = { - pagesCount: 0, - currentPageNumber: 1 - }; - - stubs.pageNumButtonEl = pageNumButtonEl; - stubs.previousPageButtonEl = previousPageButtonEl; - stubs.nextPageButtonEl = nextPageButtonEl; - stubs.browser = sandbox.stub(Browser, 'getName').returns('Safari'); - stubs.fullscreen = sandbox.stub(fullscreen, 'isFullscreen').returns(true); - }); - - afterEach(() => { - docBase.containerEl.innerHTML = ''; - docBase.pdfViewer = undefined; - }); - - it('should disable/enable page number button el based on current page and browser type', () => { - docBase.checkPaginationButtons(); - expect(stubs.pageNumButtonEl.disabled).to.equal(true); - - docBase.pdfViewer.pagesCount = 6; - docBase.checkPaginationButtons(); - expect(stubs.pageNumButtonEl.disabled).to.equal(true); - - stubs.fullscreen.returns('false'); - stubs.browser.returns('Chrome'); - docBase.checkPaginationButtons(); - expect(stubs.pageNumButtonEl.disabled).to.equal(false); - }); - - it('should disable/enable previous page button el based on current page', () => { - docBase.checkPaginationButtons(); - expect(stubs.previousPageButtonEl.disabled).to.equal(true); - - docBase.pdfViewer.currentPageNumber = 20; - docBase.checkPaginationButtons(); - expect(stubs.previousPageButtonEl.disabled).to.equal(false); - }); - - it('should disable/enable next page button el based on current page', () => { - docBase.pdfViewer.currentPageNumber = 20; - docBase.pdfViewer.pagesCount = 20; - - docBase.checkPaginationButtons(); - expect(stubs.nextPageButtonEl.disabled).to.equal(true); - - docBase.pdfViewer.currentPageNumber = 1; - docBase.checkPaginationButtons(); - expect(stubs.nextPageButtonEl.disabled).to.equal(false); - }); - }); - describe('zoom methods', () => { beforeEach(() => { docBase.pdfViewer = { @@ -1089,41 +1020,6 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { }); }); - describe('initPageNumEl()', () => { - beforeEach(() => { - docBase.pdfViewer = { - pagesCount: 5 - }; - stubs.totalPageEl = { - textContent: 0, - setAttribute: sandbox.stub() - }; - stubs.querySelector = { - querySelector: sandbox.stub().returns(stubs.totalPageEl) - }; - docBase.controls = { - controlsEl: { - querySelector: sandbox.stub().returns(stubs.querySelector) - } - }; - }); - - it('should set the text content on the total page element', () => { - docBase.initPageNumEl(); - - expect(docBase.controls.controlsEl.querySelector).to.be.called; - expect(stubs.querySelector.querySelector).to.be.called; - expect(stubs.totalPageEl.textContent).to.equal(5); - }); - - it('should keep track of the page number input and current page elements', () => { - docBase.initPageNumEl(); - - expect(docBase.pageNumInputEl).to.equal(stubs.totalPageEl); - expect(docBase.currentPageEl).to.equal(stubs.totalPageEl); - }); - }); - describe('fetchPrintBlob()', () => { beforeEach(() => { stubs.get = sandbox.stub(util, 'get').returns(Promise.resolve('blob')); @@ -1139,83 +1035,13 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { describe('loadUI()', () => { it('should set controls, bind listeners, and init the page number element', () => { const bindControlListenersStub = sandbox.stub(docBase, 'bindControlListeners'); - const initPageNumElStub = sandbox.stub(docBase, 'initPageNumEl'); docBase.loadUI(); expect(bindControlListenersStub).to.be.called; - expect(initPageNumElStub).to.be.called; expect(docBase.controls instanceof Controls).to.be.true; }); }); - describe('showPageNumInput()', () => { - it('should set the page number input value, focus, select, and add listeners', () => { - docBase.controls = { - controlsEl: { - classList: { - add: sandbox.stub() - } - } - }; - docBase.currentPageEl = 0; - docBase.pageNumInputEl = { - value: 0, - focus: sandbox.stub(), - select: sandbox.stub(), - addEventListener: sandbox.stub() - }; - - docBase.showPageNumInput(); - expect(docBase.pageNumInputEl.focus).to.be.called; - expect(docBase.pageNumInputEl.select).to.be.called; - expect(docBase.pageNumInputEl.addEventListener).to.be.called.twice; - }); - }); - - describe('hidePageNumInput()', () => { - it('should hide the input class and remove event listeners', () => { - docBase.controls = { - controlsEl: { - classList: { - remove: sandbox.stub() - } - } - }; - docBase.pageNumInputEl = { - removeEventListener: sandbox.stub() - }; - - docBase.hidePageNumInput(); - expect(docBase.controls.controlsEl.classList.remove).to.be.called; - expect(docBase.pageNumInputEl.removeEventListener).to.be.called; - }); - }); - - describe('updateCurrentPage()', () => { - it('should only update the page to a valid value', () => { - docBase.pdfViewer = { - pagesCount: 10 - }; - docBase.pageNumInputEl = { - value: 1, - textContent: 1 - }; - const checkPaginationButtonsStub = sandbox.stub(docBase, 'checkPaginationButtons'); - - docBase.updateCurrentPage(-5); - expect(checkPaginationButtonsStub).to.be.called; - expect(docBase.pageNumInputEl.value).to.equal(1); - - docBase.updateCurrentPage(25); - expect(checkPaginationButtonsStub).to.be.called; - expect(docBase.pageNumInputEl.value).to.equal(10); - - docBase.updateCurrentPage(7); - expect(checkPaginationButtonsStub).to.be.called; - expect(docBase.pageNumInputEl.value).to.equal(7); - }); - }); - describe('bindDOMListeners()', () => { beforeEach(() => { stubs.addEventListener = sandbox.stub(docBase.docEl, 'addEventListener'); @@ -1309,84 +1135,18 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { }); }); - describe('pageNumInputBlurHandler()', () => { - beforeEach(() => { - docBase.event = { - target: { - value: 5 - } - }; - stubs.setPageStub = sandbox.stub(docBase, 'setPage'); - stubs.hidePageNumInputStub = sandbox.stub(docBase, 'hidePageNumInput'); - }); - - it('should hide the page number input and set the page if given valid input', () => { - docBase.pageNumInputBlurHandler(docBase.event); - expect(stubs.setPageStub).to.be.calledWith(docBase.event.target.value); - expect(stubs.hidePageNumInputStub).to.be.called; - }); - - it('should hide the page number input but not set the page if given invalid input', () => { - docBase.event.target.value = 'not a number'; - - docBase.pageNumInputBlurHandler(docBase.event); - expect(stubs.setPageStub).to.not.be.called; - expect(stubs.hidePageNumInputStub).to.be.called; - }); - }); - - describe('pageNumInputKeydownHandler()', () => { - beforeEach(() => { - docBase.event = { - key: 'Enter', - stopPropagation: sandbox.stub(), - preventDefault: sandbox.stub(), - target: { - blur: sandbox.stub() - } - }; - stubs.browser = sandbox.stub(Browser, 'getName').returns('Explorer'); - stubs.focus = sandbox.stub(docBase.docEl, 'focus'); - stubs.hidePageNumInput = sandbox.stub(docBase, 'hidePageNumInput'); - }); - - it('should focus the doc element if IE and stop default actions on \'enter\'', () => { - docBase.pageNumInputKeydownHandler(docBase.event); - expect(stubs.browser).to.be.called; - expect(stubs.focus).to.be.called; - expect(docBase.event.stopPropagation).to.be.called; - expect(docBase.event.preventDefault).to.be.called; - }); - - it('should blur if not IE and stop default actions on \'enter\'', () => { - stubs.browser.returns('Chrome'); - - docBase.pageNumInputKeydownHandler(docBase.event); - expect(stubs.browser).to.be.called; - expect(docBase.event.target.blur).to.be.called; - expect(docBase.event.stopPropagation).to.be.called; - expect(docBase.event.preventDefault).to.be.called; - }); - - it('should hide the page number input, focus the document, and stop default actions on \'Esc\'', () => { - docBase.event.key = 'Esc'; - - docBase.pageNumInputKeydownHandler(docBase.event); - expect(stubs.hidePageNumInput).to.be.called; - expect(stubs.focus).to.be.called; - expect(docBase.event.stopPropagation).to.be.called; - expect(docBase.event.preventDefault).to.be.called; - }); - }); - describe('pagesinitHandler()', () => { beforeEach(() => { stubs.loadUI = sandbox.stub(docBase, 'loadUI'); - stubs.checkPaginationButtons = sandbox.stub(docBase, 'checkPaginationButtons'); stubs.setPage = sandbox.stub(docBase, 'setPage'); stubs.getCachedPage = sandbox.stub(docBase, 'getCachedPage'); stubs.emit = sandbox.stub(docBase, 'emit'); stubs.setupPages = sandbox.stub(docBase, 'setupPageIds'); + + docBase.pageControls = { + checkPaginationButtons: sandbox.stub() + }; + stubs.checkPaginationButtons = docBase.pageControls.checkPaginationButtons; }); it('should load UI, check the pagination buttons, set the page, and make document scrollable', () => { @@ -1400,6 +1160,7 @@ 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.checkPaginationButtons).to.be.called; }); it('should broadcast that the preview is loaded if it hasn\'t already', () => { @@ -1447,7 +1208,6 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { describe('pagechangeHandler()', () => { beforeEach(() => { - stubs.updateCurrentPage = sandbox.stub(docBase, 'updateCurrentPage'); stubs.cachePage = sandbox.stub(docBase, 'cachePage'); stubs.emit = sandbox.stub(docBase, 'emit'); docBase.event = { @@ -1456,6 +1216,10 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { docBase.pdfViewer = { pageCount: 1 }; + docBase.pageControls = { + updateCurrentPage: sandbox.stub() + }; + stubs.updateCurrentPage = docBase.pageControls.updateCurrentPage; }); it('should emit the pagefocus event', () => { diff --git a/src/lib/viewers/doc/__tests__/DocumentViewer-test.js b/src/lib/viewers/doc/__tests__/DocumentViewer-test.js index d49bb2df3..a457a7ae0 100644 --- a/src/lib/viewers/doc/__tests__/DocumentViewer-test.js +++ b/src/lib/viewers/doc/__tests__/DocumentViewer-test.js @@ -153,6 +153,18 @@ describe('lib/viewers/doc/DocumentViewer', () => { }); describe('bindControlListeners()', () => { + beforeEach(() => { + doc.pdfViewer = { + pagesCount: 4, + cleanup: sandbox.stub() + }; + + doc.pageControls = { + init: sandbox.stub(), + addListener: sandbox.stub() + }; + }); + it('should add the correct controls', () => { doc.bindControlListeners(); expect(doc.controls.add).to.be.calledWith( @@ -162,20 +174,10 @@ describe('lib/viewers/doc/DocumentViewer', () => { ICON_ZOOM_OUT ); expect(doc.controls.add).to.be.calledWith(__('zoom_in'), doc.zoomIn, 'bp-doc-zoom-in-icon', ICON_ZOOM_IN); - expect(doc.controls.add).to.be.calledWith( - __('previous_page'), - doc.previousPage, - 'bp-doc-previous-page-icon bp-previous-page', - ICON_DROP_UP - ); - expect(doc.controls.add).to.be.calledWith(__('enter_page_num'), doc.showPageNumInput, 'bp-doc-page-num'); - expect(doc.controls.add).to.be.calledWith( - __('next_page'), - doc.nextPage, - 'bp-doc-next-page-icon bp-next-page', - ICON_DROP_DOWN - ); + expect(doc.pageControls.init).to.be.called; + expect(doc.pageControls.addListener).to.be.calledWith('setpage', sinon.match.func); + expect(doc.controls.add).to.be.calledWith( __('enter_fullscreen'), doc.toggleFullscreen, diff --git a/src/lib/viewers/doc/__tests__/PresentationViewer-test.js b/src/lib/viewers/doc/__tests__/PresentationViewer-test.js index e90ad2e7f..8a572a2d8 100644 --- a/src/lib/viewers/doc/__tests__/PresentationViewer-test.js +++ b/src/lib/viewers/doc/__tests__/PresentationViewer-test.js @@ -299,7 +299,19 @@ describe('lib/viewers/doc/PresentationViewer', () => { }); describe('bindControlListeners()', () => { - it('should ', () => { + beforeEach(() => { + presentation.pdfViewer = { + pagesCount: 4, + cleanup: sandbox.stub() + }; + + presentation.pageControls = { + init: sandbox.stub(), + addListener: sandbox.stub() + }; + }); + + it('should add the correct controls', () => { presentation.bindControlListeners(); expect(presentation.controls.add).to.be.calledWith( __('zoom_out'), @@ -313,23 +325,10 @@ describe('lib/viewers/doc/PresentationViewer', () => { 'bp-enter-zoom-in-icon', ICON_ZOOM_IN ); - expect(presentation.controls.add).to.be.calledWith( - __('previous_page'), - presentation.previousPage, - 'bp-presentation-previous-page-icon bp-previous-page', - ICON_DROP_UP - ); - expect(presentation.controls.add).to.be.calledWith( - __('enter_page_num'), - presentation.showPageNumInput, - 'bp-doc-page-num' - ); - expect(presentation.controls.add).to.be.calledWith( - __('next_page'), - presentation.nextPage, - 'bp-presentation-next-page-icon bp-next-page', - ICON_DROP_DOWN - ); + + expect(presentation.pageControls.init).to.be.called; + expect(presentation.pageControls.addListener).to.be.calledWith('setpage', sinon.match.func); + expect(presentation.controls.add).to.be.calledWith( __('enter_fullscreen'), presentation.toggleFullscreen, diff --git a/src/lib/viewers/doc/_docBase.scss b/src/lib/viewers/doc/_docBase.scss index 563fda7e5..54c092cdc 100644 --- a/src/lib/viewers/doc/_docBase.scss +++ b/src/lib/viewers/doc/_docBase.scss @@ -80,73 +80,6 @@ } } -.bp-controls { - // Page num input CSS - .bp-doc-page-num { - min-width: 48px; - width: auto; // Let page num expand as needed - - span { - display: inline; - font-size: 14px; - } - } - - .bp-doc-page-num-wrapper { - background-color: #444; - border-radius: 3px; - margin: 5px; - padding: 7px 5px; - } - - /* stylelint-disable property-no-vendor-prefix */ - // Removes the spinner for number type inputs in Webkit browsers - input::-webkit-outer-spin-button, - input::-webkit-inner-spin-button { - -webkit-appearance: none; - } - - // Removes the spinner for number type inputs in Firefox - input[type=number] { - -moz-appearance: textfield; - } - - /* stylelint-enable property-no-vendor-prefix */ - - .bp-doc-page-num-input { - font-size: 14px; - margin: 0 auto; - position: absolute; - text-align: center; - visibility: hidden; - width: 44px; // hard-coded to solve layout issues - } - - &.show-page-number-input { - .bp-doc-page-num-wrapper { - background-color: transparent; - border: none; - padding: 0; - } - - .bp-doc-page-num { - opacity: 1; - } - - .bp-doc-current-page, - .bp-doc-page-num-divider, - .bp-doc-total-pages { - display: none; - } - - .bp-doc-page-num-input { - display: inline-block; - position: static; - visibility: visible; - } - } -} - .bp-print-notification { display: none; font-size: 24px; diff --git a/src/lib/viewers/doc/pageNumButtonContent.html b/src/lib/viewers/doc/pageNumButtonContent.html deleted file mode 100644 index d6ae11d11..000000000 --- a/src/lib/viewers/doc/pageNumButtonContent.html +++ /dev/null @@ -1,6 +0,0 @@ -
- 1 - -  /  - 1 -
diff --git a/src/lib/viewers/image/ImageBaseViewer.js b/src/lib/viewers/image/ImageBaseViewer.js index bd411cd3f..13632bb54 100644 --- a/src/lib/viewers/image/ImageBaseViewer.js +++ b/src/lib/viewers/image/ImageBaseViewer.js @@ -43,8 +43,8 @@ class ImageBaseViewer extends BaseViewer { return; } - this.zoom(); this.loadUI(); + this.zoom(); this.imageEl.classList.remove(CLASS_INVISIBLE); this.loaded = true; @@ -164,6 +164,20 @@ class ImageBaseViewer extends BaseViewer { */ loadUI() { this.controls = new Controls(this.containerEl); + this.bindControlListeners(); + } + + //-------------------------------------------------------------------------- + // Event Listeners + //-------------------------------------------------------------------------- + + /** + * Bind event listeners for document controls + * + * @private + * @return {void} + */ + bindControlListeners() { this.controls.add(__('zoom_out'), this.zoomOut, 'bp-image-zoom-out-icon', ICON_ZOOM_OUT); this.controls.add(__('zoom_in'), this.zoomIn, 'bp-image-zoom-in-icon', ICON_ZOOM_IN); } diff --git a/src/lib/viewers/image/MultiImageViewer.js b/src/lib/viewers/image/MultiImageViewer.js index 33a916d26..741619a09 100644 --- a/src/lib/viewers/image/MultiImageViewer.js +++ b/src/lib/viewers/image/MultiImageViewer.js @@ -1,7 +1,7 @@ import autobind from 'autobind-decorator'; import ImageBaseViewer from './ImageBaseViewer'; +import PageControls from '../../PageControls'; import './MultiImage.scss'; - import { ICON_FILE_IMAGE, ICON_FULLSCREEN_IN, ICON_FULLSCREEN_OUT } from '../../icons/icons'; import { CLASS_INVISIBLE } from '../../constants'; @@ -29,6 +29,9 @@ class MultiImageViewer extends ImageBaseViewer { this.singleImageEls = [this.imageEl.appendChild(document.createElement('img'))]; this.loadTimeout = 60000; + + // Defaults the current page number to 1 + this.currentPageNumber = 1; } /** @@ -68,6 +71,8 @@ class MultiImageViewer extends ImageBaseViewer { this.imageUrls = this.constructImageUrls(template); this.imageUrls.forEach((imageUrl, index) => this.setupImageEls(imageUrl, index)); + + this.wrapperEl.addEventListener('scroll', this.scrollHandler, true); }) .catch(this.handleAssetError); } @@ -82,11 +87,11 @@ class MultiImageViewer extends ImageBaseViewer { const { viewer, representation } = this.options; const metadata = representation.metadata; const asset = viewer.ASSET; + this.pagesCount = metadata.pages; const urlBase = this.createContentUrlWithAuthParams(template, asset); - const urls = []; - for (let pageNum = 1; pageNum <= metadata.pages; pageNum++) { + for (let pageNum = 1; pageNum <= this.pagesCount; pageNum++) { urls.push(urlBase.replace('{page}', pageNum)); } @@ -166,6 +171,9 @@ class MultiImageViewer extends ImageBaseViewer { // Give the browser some time to render before updating pannability setTimeout(this.updatePannability, ZOOM_UPDATE_PAN_DELAY); + + // Set current page to previously opened page or first page + this.setPage(this.currentPageNumber); } /** @@ -186,6 +194,22 @@ class MultiImageViewer extends ImageBaseViewer { */ loadUI() { super.loadUI(); + this.pageControls.checkPaginationButtons(this.currentPageNumber, this.pagesCount); + } + + /** + * Binds listeners for document controls. Overridden. + * + * @protected + * @return {void} + */ + bindControlListeners() { + super.bindControlListeners(); + + this.pageControls = new PageControls(this.controls, this.previousPage, this.nextPage); + this.pageControls.init(this.pagesCount); + this.pageControls.addListener('setpage', this.setPage); + this.controls.add( __('enter_fullscreen'), this.toggleFullscreen, @@ -222,6 +246,130 @@ class MultiImageViewer extends ImageBaseViewer { this.singleImageEls[index].removeEventListener('error', this.errorHandler); } + + /** + * Go to previous page + * + * @return {void} + */ + previousPage() { + this.setPage(this.currentPageNumber - 1); + } + + /** + * Go to next page + * + * @return {void} + */ + nextPage() { + this.setPage(this.currentPageNumber + 1); + } + + /** + * Go to specified page + * + * @param {number} pageNum - Page to navigate to + * @return {void} + */ + setPage(pageNum) { + if (pageNum < 1 || pageNum > this.pagesCount) { + return; + } + + this.currentPageNumber = pageNum; + this.singleImageEls[pageNum - 1].scrollIntoView(); + } + + /** + * Handles scroll event in the wrapper element + * + * @private + * @return {void} + */ + scrollHandler() { + if (this.scrollCheckHandler) { + return; + } + + if (!this.scrollState) { + const currentPageEl = this.singleImageEls[this.currentPageNumber - 1]; + this.scrollState = { + down: false, + lastY: currentPageEl.scrollTop + }; + } + + const imageScrollHandler = this.isSingleImageElScrolled.bind(this); + this.scrollCheckHandler = window.requestAnimationFrame(imageScrollHandler); + } + + /** + * Updates page number if the single image has been scrolled past + * + * @private + * @return {void} + */ + isSingleImageElScrolled() { + this.scrollCheckHandler = null; + const currentY = this.wrapperEl.scrollTop; + const lastY = this.scrollState.lastY; + + if (currentY !== lastY) { + this.scrollState.isScrollingDown = currentY > lastY; + } + this.scrollState.lastY = currentY; + this.updatePageChange(); + } + + /** + * Updates page number in the page controls + * + * @private + * @param {number} pageNum - Page just navigated to + * @return {void} + */ + pagechangeHandler(pageNum) { + this.currentPageNumber = pageNum; + this.pageControls.updateCurrentPage(pageNum); + this.emit('pagefocus', this.currentPageNumber); + } + + /** + * Update the page number based on scroll direction. Only increment if + * wrapper is scrolled down past at least half of the current page element. + * Only decrement page if wrapper is scrolled up past at least half of the + * previous page element + * + * @private + * @return {void} + */ + updatePageChange() { + let pageNum = this.currentPageNumber; + const currentPageEl = this.singleImageEls[this.currentPageNumber - 1]; + const wrapperScrollOffset = this.scrollState.lastY; + const currentPageMiddleY = currentPageEl.offsetTop + currentPageEl.clientHeight / 2; + const isScrolledToBottom = wrapperScrollOffset + this.wrapperEl.clientHeight >= this.wrapperEl.scrollHeight; + + if ( + this.scrollState.isScrollingDown && + currentPageEl.nextSibling && + (wrapperScrollOffset > currentPageMiddleY || isScrolledToBottom) + ) { + // Increment page + const nextPage = currentPageEl.nextSibling; + pageNum = parseInt(nextPage.dataset.pageNumber, 10); + } else if (!this.scrollState.isScrollingDown && currentPageEl.previousSibling) { + const prevPage = currentPageEl.previousSibling; + const prevPageMiddleY = prevPage.offsetTop + prevPage.clientHeight / 2; + + // Decrement page + if (prevPageMiddleY > wrapperScrollOffset) { + pageNum = parseInt(prevPage.dataset.pageNumber, 10); + } + } + + this.pagechangeHandler(pageNum); + } } export default MultiImageViewer; diff --git a/src/lib/viewers/image/README.md b/src/lib/viewers/image/README.md index d6547dedd..b2190b777 100755 --- a/src/lib/viewers/image/README.md +++ b/src/lib/viewers/image/README.md @@ -67,6 +67,7 @@ At the default zoom level, clicking on the image will zoom in once. When zoomed ### Controls: * Zoom In * Zoom Out +* Set Page: either with the up and down arrows, or by clicking the page number and entering text * Fullscreen: can be exited with the escape key ## Supported File Extensions @@ -85,6 +86,7 @@ The image viewer fires the following events | reload | The preview reloads || | resize | The preview resizes | 1. {number} **height**: window height 2. {number} **width**: window width | | zoom | The preview zooms in or out | 1. {number} **zoom**: new zoom value 2. {boolean} **canZoomIn**: true if the viewer can zoom in more 3. {boolean} **canZoomOut**: true if the viewer can zoom out more | +| pagefocus | A page is visible | {number} page number of focused page | | pan | The preview is panning || | panstart | Panning starts || | panend | Panning ends || @@ -97,5 +99,8 @@ The following methods are available for the multi-page image viewer. | Method Name | Explanation | Method Parameters | | --- | --- | --- | | zoom | Zooms the image | {string} 'in', 'out', or 'reset' | +| previousPage | Navigates to the previous page || +| nextPage | Navigates to the next page || +| setPage | Navigates to a given page | {number} page number | | print | Prints the image || | toggleFullscreen | Toggles fullscreen mode || diff --git a/src/lib/viewers/image/__tests__/ImageBaseViewer-test.js b/src/lib/viewers/image/__tests__/ImageBaseViewer-test.js index e0954091e..65968a11e 100644 --- a/src/lib/viewers/image/__tests__/ImageBaseViewer-test.js +++ b/src/lib/viewers/image/__tests__/ImageBaseViewer-test.js @@ -3,6 +3,7 @@ import ImageBaseViewer from '../ImageBaseViewer'; import BaseViewer from '../../BaseViewer'; import Browser from '../../../Browser'; import fullscreen from '../../../Fullscreen'; +import { ICON_ZOOM_IN, ICON_ZOOM_OUT } from '../../../icons/icons'; const CSS_CLASS_PANNING = 'panning'; const CSS_CLASS_ZOOMABLE = 'zoomable'; @@ -209,10 +210,28 @@ describe('lib/viewers/image/ImageBaseViewer', () => { describe('loadUI()', () => { it('should create controls and add control buttons for zoom', () => { + sandbox.stub(imageBase, 'bindControlListeners'); imageBase.loadUI(); expect(imageBase.controls).to.not.be.undefined; - expect(imageBase.controls.buttonRefs.length).to.equal(2); + expect(imageBase.bindControlListeners).to.be.called; + }); + }); + + describe('bindControlListeners()', () => { + it('should add the correct controls', () => { + imageBase.controls = { + add: sandbox.stub() + }; + + imageBase.bindControlListeners(); + expect(imageBase.controls.add).to.be.calledWith( + __('zoom_out'), + imageBase.zoomOut, + 'bp-image-zoom-out-icon', + ICON_ZOOM_OUT + ); + expect(imageBase.controls.add).to.be.calledWith(__('zoom_in'), imageBase.zoomIn, 'bp-image-zoom-in-icon', ICON_ZOOM_IN); }); }); diff --git a/src/lib/viewers/image/__tests__/MultiImageViewer-test.js b/src/lib/viewers/image/__tests__/MultiImageViewer-test.js index ba0d3dfd8..3889e756c 100644 --- a/src/lib/viewers/image/__tests__/MultiImageViewer-test.js +++ b/src/lib/viewers/image/__tests__/MultiImageViewer-test.js @@ -46,6 +46,15 @@ describe('lib/viewers/image/MultiImageViewer', () => { } }; + stubs.singleImageEl = { + src: undefined, + setAttribute: sandbox.stub(), + classList: { + add: sandbox.stub() + }, + scrollIntoView: sandbox.stub() + }; + multiImage = new MultiImageViewer(options); Object.defineProperty(BaseViewer.prototype, 'setup', { value: sandbox.stub() }); @@ -102,6 +111,10 @@ describe('lib/viewers/image/MultiImageViewer', () => { stubs.bindDOMListeners = sandbox.stub(multiImage, 'bindDOMListeners'); stubs.bindImageListeners = sandbox.stub(multiImage, 'bindImageListeners'); stubs.setupImageEls = sandbox.stub(multiImage, 'setupImageEls'); + multiImage.wrapperEl = { + addEventListener: sandbox.stub() + }; + stubs.addWrapperListener = multiImage.wrapperEl.addEventListener; }); it('should create the image urls', () => { @@ -120,6 +133,7 @@ describe('lib/viewers/image/MultiImageViewer', () => { expect(stubs.bindImageListeners).to.be.called; expect(stubs.bindDOMListeners).to.be.called; expect(stubs.constructImageUrls).to.be.called; + expect(stubs.addWrapperListener).to.be.calledWith('scroll', sinon.match.func, 'true'); }) .catch(() => {}); }); @@ -138,13 +152,6 @@ describe('lib/viewers/image/MultiImageViewer', () => { beforeEach(() => { multiImage.setup(); stubs.bindImageListeners = sandbox.stub(multiImage, 'bindImageListeners'); - stubs.singleImageEl = { - src: undefined, - setAttribute: sandbox.stub(), - classList: { - add: sandbox.stub() - } - }; }); it('should set the single image el and error handler if it is not the first image', () => { @@ -252,6 +259,7 @@ describe('lib/viewers/image/MultiImageViewer', () => { beforeEach(() => { stubs.zoomEmit = sandbox.stub(multiImage, 'emit'); stubs.setScale = sandbox.stub(multiImage, 'setScale'); + stubs.scroll = sandbox.stub(multiImage, 'setPage'); stubs.updatePannability = sandbox.stub(multiImage, 'updatePannability'); multiImage.setup(); }); @@ -289,7 +297,7 @@ describe('lib/viewers/image/MultiImageViewer', () => { multiImage.loadUI(); expect(multiImage.controls).to.not.be.undefined; - expect(multiImage.controls.buttonRefs.length).to.equal(4); + expect(multiImage.controls.buttonRefs.length).to.equal(7); }); }); @@ -356,4 +364,23 @@ describe('lib/viewers/image/MultiImageViewer', () => { expect(multiImage.emit).to.be.calledWith('scale', { scale: 0.5 }); }); }); + + describe('setPage()', () => { + it('should scroll to the current page', () => { + multiImage.singleImageEls = { + 1: stubs.singleImageEl, + 2: stubs.singleImageEl, + 3: stubs.singleImageEl + }; + sandbox.stub(multiImage, 'emit'); + + multiImage.setPage(2); + expect(multiImage.currentPageNumber).equals(2); + }); + + it('should not do anything if setting an invalid page', () => { + multiImage.setPage(-1); + expect(multiImage.currentPageNumber).to.be.undefined; + }); + }); });