diff --git a/src/i18n/en-US.properties b/src/i18n/en-US.properties index ac3e3af3b..1604c32f7 100644 --- a/src/i18n/en-US.properties +++ b/src/i18n/en-US.properties @@ -1,5 +1,7 @@ # Button tooltip for rotating a preview to the left rotate_left=Rotate left +# Tooltip for current zoom level +zoom_current_scale=Current zoom level # Button tooltip for zooming into a preview zoom_in=Zoom in # Button tooltip for zooming out of a preview diff --git a/src/lib/Controls.js b/src/lib/Controls.js index d83082939..f499011a5 100644 --- a/src/lib/Controls.js +++ b/src/lib/Controls.js @@ -183,40 +183,48 @@ class Controls { }; /** - * Adds buttons to controls + * Adds element to controls * * @public - * @param {string} text - button text - * @param {Function} handler - button handler + * @param {string} text - text + * @param {Function} handler - on click handler * @param {string} [classList] - optional class list - * @param {string} [buttonContent] - Optional button content HTML - * @return {void} + * @param {string} [content] - Optional content HTML + * @param {string} [tag] - Optional html tag, defaults to 'button' + * @return {HTMLElement} The created HTMLElement inserted into the control */ - add(text, handler, classList = '', buttonContent = '') { + add(text, handler, classList = '', content = '', tag = 'button') { const cell = document.createElement('div'); cell.className = 'bp-controls-cell'; - const button = document.createElement('button'); - button.setAttribute('aria-label', text); - button.setAttribute('type', 'button'); - button.setAttribute('title', text); - button.className = `${CONTROLS_BUTTON_CLASS} ${classList}`; - button.addEventListener('click', handler); + const element = document.createElement(tag); + element.setAttribute('aria-label', text); + element.setAttribute('title', text); + + if (tag === 'button') { + element.setAttribute('type', 'button'); + element.className = `${CONTROLS_BUTTON_CLASS} ${classList}`; + element.addEventListener('click', handler); + } else { + element.className = `${classList}`; + } - if (buttonContent) { - button.innerHTML = buttonContent; + if (content) { + element.innerHTML = content; } - cell.appendChild(button); + cell.appendChild(element); this.controlsEl.appendChild(cell); - // Maintain a reference for cleanup - this.buttonRefs.push({ - button, - handler, - }); + if (handler) { + // Maintain a reference for cleanup + this.buttonRefs.push({ + button: element, + handler, + }); + } - return button; + return element; } /** diff --git a/src/lib/Controls.scss b/src/lib/Controls.scss index 31407228a..81835a76f 100644 --- a/src/lib/Controls.scss +++ b/src/lib/Controls.scss @@ -153,3 +153,8 @@ display: block; } } + +.bp-zoom-current-scale { + color: $white; + font-size: 14px; +} diff --git a/src/lib/ZoomControls.js b/src/lib/ZoomControls.js new file mode 100644 index 000000000..eb85505d2 --- /dev/null +++ b/src/lib/ZoomControls.js @@ -0,0 +1,131 @@ +import isFinite from 'lodash/isFinite'; +import noop from 'lodash/noop'; +import { ICON_ZOOM_IN, ICON_ZOOM_OUT } from './icons/icons'; +import Controls from './Controls'; + +const CLASS_ZOOM_CURRENT_SCALE = 'bp-zoom-current-scale'; +const CLASS_ZOOM_IN_BUTTON = 'bp-zoom-in-btn'; +const CLASS_ZOOM_OUT_BUTTON = 'bp-zoom-out-btn'; + +class ZoomControls { + /** @property {Controls} - Controls object */ + controls; + + /** @property {HTMLElement} - Controls element */ + controlsElement; + + /** @property {number} - Current zoom scale */ + currentScale; + + /** @property {HTMLElement} - Current scale element */ + currentScaleElement; + + /** @property {number} - Max zoom scale */ + maxZoom; + + /** @property {number} - Min zoom scale */ + minZoom; + + /** + * [constructor] + * + * @param {Controls} controls - Viewer controls + * @return {ZoomControls} Instance of ZoomControls + */ + constructor(controls) { + if (!controls || !(controls instanceof Controls)) { + throw Error('controls must be an instance of Controls'); + } + + this.controls = controls; + this.controlsElement = controls.controlsEl; + } + + /** + * Initialize the zoom controls with the initial scale and options. + * + * @param {number} currentScale - Initial scale value, assumes range on the scale of 0-1 + * @param {number} [options.maxZoom] - Maximum zoom, on the scale of 0-1, though the max could be upwards of 1 + * @param {number} [options.minZoom] - Minimum zoom, on the scale of 0-1 + * @param {String} [options.zoomInClassName] - Class name for zoom in button + * @param {String} [options.zoomOutClassName] - Class name for zoom out button + * @param {Function} [options.onZoomIn] - Callback when zoom in is triggered + * @param {Function} [options.onZoomOut] - Callback when zoom out is triggered + * @return {void} + */ + init( + currentScale, + { + zoomOutClassName = '', + zoomInClassName = '', + minZoom = 0, + maxZoom = Number.POSITIVE_INFINITY, + onZoomIn = noop, + onZoomOut = noop, + } = {}, + ) { + this.maxZoom = Math.round(this.validateZoom(maxZoom, Number.POSITIVE_INFINITY) * 100); + this.minZoom = Math.round(Math.max(this.validateZoom(minZoom, 0), 0) * 100); + + this.controls.add(__('zoom_out'), onZoomOut, `${CLASS_ZOOM_OUT_BUTTON} ${zoomOutClassName}`, ICON_ZOOM_OUT); + this.controls.add( + __('zoom_current_scale'), + undefined, + undefined, + `100%`, + 'div', + ); + this.controls.add(__('zoom_in'), onZoomIn, `${CLASS_ZOOM_IN_BUTTON} ${zoomInClassName}`, ICON_ZOOM_IN); + + this.currentScaleElement = this.controlsElement.querySelector(`.${CLASS_ZOOM_CURRENT_SCALE}`); + this.setCurrentScale(currentScale); + } + + /** + * Validates the zoom valid to ensure it is a number + * + * @param {number} zoomValue - Zoom value to validate + * @param {number} defaultZoomValue - Default zoom value + * @returns {number} The validated zoom value or the default value + */ + validateZoom(zoomValue, defaultZoomValue = 0) { + return isFinite(zoomValue) ? zoomValue : defaultZoomValue; + } + + /** + * Sets the current scale + * + * @param {number} scale - New scale to be set as current, range 0-1 + * @return {void} + */ + setCurrentScale(scale) { + if (!isFinite(scale)) { + return; + } + + this.currentScale = Math.round(scale * 100); + this.currentScaleElement.textContent = `${this.currentScale}%`; + + this.checkButtonEnablement(); + } + + /** + * Checks the zoom in and zoom out button enablement + * + * @return {void} + */ + checkButtonEnablement() { + const zoomOutElement = this.controlsElement.querySelector(`.${CLASS_ZOOM_OUT_BUTTON}`); + const zoomInElement = this.controlsElement.querySelector(`.${CLASS_ZOOM_IN_BUTTON}`); + + if (zoomOutElement) { + zoomOutElement.disabled = this.currentScale <= this.minZoom; + } + + if (zoomInElement) { + zoomInElement.disabled = this.currentScale >= this.maxZoom; + } + } +} + +export default ZoomControls; diff --git a/src/lib/__tests__/Controls-test.js b/src/lib/__tests__/Controls-test.js index f63d18953..1695e2c10 100644 --- a/src/lib/__tests__/Controls-test.js +++ b/src/lib/__tests__/Controls-test.js @@ -260,6 +260,10 @@ describe('lib/Controls', () => { }); describe('add()', () => { + beforeEach(() => { + sandbox.stub(controls.buttonRefs, 'push'); + }); + it('should create a button with the right attributes', () => { const btn = controls.add('test button', sandbox.stub(), 'test1', 'test content'); expect(btn.attributes.title.value).to.equal('test button'); @@ -267,6 +271,18 @@ describe('lib/Controls', () => { expect(btn.classList.contains('test1')).to.be.true; expect(btn.innerHTML).to.equal('test content'); expect(btn.parentNode.parentNode).to.equal(controls.controlsEl); + expect(controls.buttonRefs.push).to.be.called; + }); + + it('should create a span if specified', () => { + const span = controls.add('test span', null, 'span1', 'test content', 'span'); + expect(span.attributes.title.value).to.equal('test span'); + expect(span.attributes['aria-label'].value).to.equal('test span'); + expect(span.classList.contains('span1')).to.be.true; + expect(span.classList.contains('bp-controls-btn')).to.be.false; + expect(span.innerHTML).to.equal('test content'); + expect(span.parentNode.parentNode).to.equal(controls.controlsEl); + expect(controls.buttonRefs.push).not.to.be.called; }); }); diff --git a/src/lib/__tests__/ZoomControls-test.html b/src/lib/__tests__/ZoomControls-test.html new file mode 100644 index 000000000..e68e7ca1e --- /dev/null +++ b/src/lib/__tests__/ZoomControls-test.html @@ -0,0 +1 @@ +
diff --git a/src/lib/__tests__/ZoomControls-test.js b/src/lib/__tests__/ZoomControls-test.js new file mode 100644 index 000000000..53411002c --- /dev/null +++ b/src/lib/__tests__/ZoomControls-test.js @@ -0,0 +1,181 @@ +/* eslint-disable no-unused-expressions */ +import ZoomControls from '../ZoomControls'; +import Controls from '../Controls'; +import { ICON_ZOOM_OUT, ICON_ZOOM_IN } from '../icons/icons'; + +let zoomControls; +let stubs = {}; + +const sandbox = sinon.sandbox.create(); + +describe('lib/ZoomControls', () => { + before(() => { + fixture.setBase('src/lib'); + }); + + beforeEach(() => { + fixture.load('__tests__/ZoomControls-test.html'); + const controls = new Controls(document.getElementById('test-zoom-controls-container')); + zoomControls = new ZoomControls(controls); + }); + + afterEach(() => { + fixture.cleanup(); + sandbox.verifyAndRestore(); + + zoomControls = null; + stubs = {}; + }); + + describe('constructor()', () => { + it('should create the correct DOM structure', () => { + expect(zoomControls.controlsElement).not.to.be.undefined; + }); + + it('should throw an exception if controls is not provided', () => { + expect(() => new ZoomControls()).to.throw(Error, 'controls must be an instance of Controls'); + }); + }); + + describe('init()', () => { + beforeEach(() => { + stubs.add = sandbox.stub(zoomControls.controls, 'add'); + stubs.setCurrentScale = sandbox.stub(zoomControls, 'setCurrentScale'); + stubs.onZoomIn = sandbox.stub(); + stubs.onZoomOut = sandbox.stub(); + }); + + it('should add the controls', () => { + zoomControls.init(0.5, { onZoomIn: stubs.onZoomIn, onZoomOut: stubs.onZoomOut }); + + expect(stubs.add).to.be.calledWith(__('zoom_out'), stubs.onZoomOut, 'bp-zoom-out-btn ', ICON_ZOOM_OUT); + expect(stubs.add).to.be.calledWith( + __('zoom_current_scale'), + undefined, + undefined, + sinon.match.string, + 'div', + ); + expect(stubs.add).to.be.calledWith(__('zoom_in'), stubs.onZoomIn, 'bp-zoom-in-btn ', ICON_ZOOM_IN); + expect(zoomControls.currentScaleElement).not.to.be.undefined; + expect(stubs.setCurrentScale).to.be.calledWith(0.5); + expect(zoomControls.maxZoom).to.be.equal(Number.POSITIVE_INFINITY); + expect(zoomControls.minZoom).to.be.equal(0); + }); + + it('should set the min and max zooms if specified', () => { + zoomControls.init(0.5, { minZoom: 0.5, maxZoom: 5 }); + + expect(zoomControls.maxZoom).to.be.equal(500); + expect(zoomControls.minZoom).to.be.equal(50); + }); + + it('should set the min zoom to 0 if negative is provided', () => { + zoomControls.init(0.5, { minZoom: -0.1, maxZoom: 5 }); + + expect(zoomControls.maxZoom).to.be.equal(500); + expect(zoomControls.minZoom).to.be.equal(0); + }); + + it('should set the min zoom to 0 if number is not provided', () => { + zoomControls.init(0.5, { minZoom: '0.1', maxZoom: 5 }); + + expect(zoomControls.maxZoom).to.be.equal(500); + expect(zoomControls.minZoom).to.be.equal(0); + }); + + it('should set the max zoom to Number.POSITIVE_INFINITY if number is not provided', () => { + zoomControls.init(0.5, { minZoom: 0.5, maxZoom: '100' }); + + expect(zoomControls.maxZoom).to.be.equal(Number.POSITIVE_INFINITY); + expect(zoomControls.minZoom).to.be.equal(50); + }); + + it('should set optional classnames if specified', () => { + zoomControls.init(0.5, { + zoomInClassName: 'zoom-in-classname', + zoomOutClassName: 'zoom-out-classname', + onZoomIn: stubs.onZoomIn, + onZoomOut: stubs.onZoomOut, + }); + + expect(stubs.add).to.be.calledWith( + __('zoom_out'), + stubs.onZoomOut, + 'bp-zoom-out-btn zoom-out-classname', + ICON_ZOOM_OUT, + ); + expect(stubs.add).to.be.calledWith( + __('zoom_current_scale'), + undefined, + undefined, + sinon.match.string, + 'div', + ); + expect(stubs.add).to.be.calledWith( + __('zoom_in'), + stubs.onZoomIn, + 'bp-zoom-in-btn zoom-in-classname', + ICON_ZOOM_IN, + ); + }); + }); + + describe('setCurrentScale()', () => { + beforeEach(() => { + stubs.checkButtonEnablement = sandbox.stub(zoomControls, 'checkButtonEnablement'); + zoomControls.currentScaleElement = document.createElement('span'); + zoomControls.currentScaleElement.textContent = '100%'; + }); + + it('should not do anything if scale is not provided', () => { + zoomControls.setCurrentScale(); + + expect(zoomControls.currentScale).to.be.undefined; + expect(zoomControls.currentScaleElement.textContent).to.be.equal('100%'); + expect(stubs.checkButtonEnablement).not.to.be.called; + }); + + it('should not do anything if scale is not a number', () => { + zoomControls.setCurrentScale('100'); + + expect(zoomControls.currentScale).to.be.undefined; + expect(zoomControls.currentScaleElement.textContent).to.be.equal('100%'); + expect(stubs.checkButtonEnablement).not.to.be.called; + }); + + it('should set the scale and update the text', () => { + zoomControls.setCurrentScale(0.5); + + expect(zoomControls.currentScale).to.be.equal(50); + expect(zoomControls.currentScaleElement.textContent).to.be.equal('50%'); + expect(stubs.checkButtonEnablement).to.be.called; + }); + }); + + describe('checkButtonEnablement()', () => { + it('should do nothing if currentScale is not at the limits', () => { + zoomControls.init(0.5, { maxZoom: 5, minZoom: 0.3 }); + zoomControls.checkButtonEnablement(); + + expect(zoomControls.controlsElement.querySelector('.bp-zoom-out-btn').disabled).to.be.false; + expect(zoomControls.controlsElement.querySelector('.bp-zoom-in-btn').disabled).to.be.false; + }); + + it('should disable zoom out if currentScale is at the minZoom limit', () => { + zoomControls.init(0.3, { maxZoom: 5, minZoom: 0.3 }); + zoomControls.checkButtonEnablement(); + + expect(zoomControls.controlsElement.querySelector('.bp-zoom-out-btn').disabled).to.be.true; + expect(zoomControls.controlsElement.querySelector('.bp-zoom-in-btn').disabled).to.be.false; + }); + + it('should disable zoom in if currentScale is at the maxZoom limit', () => { + zoomControls.init(5, { maxZoom: 5, minZoom: 0.3 }); + zoomControls.checkButtonEnablement(); + + expect(zoomControls.controlsElement.querySelector('.bp-zoom-out-btn').disabled).to.be.false; + expect(zoomControls.controlsElement.querySelector('.bp-zoom-in-btn').disabled).to.be.true; + }); + }); +}); diff --git a/src/lib/icons/icons.js b/src/lib/icons/icons.js index 7792dba39..bf38b4528 100644 --- a/src/lib/icons/icons.js +++ b/src/lib/icons/icons.js @@ -4,8 +4,8 @@ import DELETE from './delete_24px.svg'; import FULLSCREEN_IN from './full_screen_in_24px.svg'; import FULLSCREEN_OUT from './full_screen_out_24px.svg'; import ROTATE_LEFT from './rotate_left_24px.svg'; -import ZOOM_IN from './zoom_in_24px.svg'; -import ZOOM_OUT from './zoom_out_24px.svg'; +import ZOOM_IN from './zoom_in.svg'; +import ZOOM_OUT from './zoom_out.svg'; import ARROW_LEFT from './arrow_left_24px.svg'; import ARROW_RIGHT from './arrow_right_24px.svg'; import CHECK_MARK from './checkmark_24px.svg'; diff --git a/src/lib/icons/zoom_in.svg b/src/lib/icons/zoom_in.svg new file mode 100644 index 000000000..8a4f5f460 --- /dev/null +++ b/src/lib/icons/zoom_in.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/lib/icons/zoom_in_24px.svg b/src/lib/icons/zoom_in_24px.svg deleted file mode 100755 index 639522ab7..000000000 --- a/src/lib/icons/zoom_in_24px.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/lib/icons/zoom_out.svg b/src/lib/icons/zoom_out.svg new file mode 100644 index 000000000..4f04ceccd --- /dev/null +++ b/src/lib/icons/zoom_out.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/lib/icons/zoom_out_24px.svg b/src/lib/icons/zoom_out_24px.svg deleted file mode 100755 index 699c9b87f..000000000 --- a/src/lib/icons/zoom_out_24px.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/lib/viewers/doc/DocBaseViewer.js b/src/lib/viewers/doc/DocBaseViewer.js index cbb16578e..42b7cee2d 100644 --- a/src/lib/viewers/doc/DocBaseViewer.js +++ b/src/lib/viewers/doc/DocBaseViewer.js @@ -3,6 +3,7 @@ import BaseViewer from '../BaseViewer'; import Browser from '../../Browser'; import Controls from '../../Controls'; import PageControls from '../../PageControls'; +import ZoomControls from '../../ZoomControls'; import DocFindBar from './DocFindBar'; import Popup from '../../Popup'; import RepStatus from '../../RepStatus'; @@ -30,8 +31,6 @@ import { checkPermission, getRepresentation } from '../../file'; import { appendQueryParams, createAssetUrlCreator, getMidpoint, getDistance, getClosestPageToPinch } from '../../util'; import { ICON_PRINT_CHECKMARK, - ICON_ZOOM_OUT, - ICON_ZOOM_IN, ICON_FULLSCREEN_IN, ICON_FULLSCREEN_OUT, ICON_THUMBNAILS_TOGGLE, @@ -41,7 +40,7 @@ import { ERROR_CODE, VIEWER_EVENT, LOAD_METRIC, USER_DOCUMENT_THUMBNAIL_EVENTS } import Timer from '../../Timer'; const CURRENT_PAGE_MAP_KEY = 'doc-current-page-map'; -const DEFAULT_SCALE_DELTA = 1.1; +const DEFAULT_SCALE_DELTA = 0.1; const IS_SAFARI_CLASS = 'is-safari'; const LOAD_TIMEOUT_MS = 180000; // 3 min timeout const MAX_PINCH_SCALE_VALUE = 3; @@ -523,19 +522,12 @@ class DocBaseViewer extends BaseViewer { let numTicks = ticks; let newScale = this.pdfViewer.currentScale; do { - newScale = (newScale * DEFAULT_SCALE_DELTA).toFixed(3); - newScale = Math.min(MAX_SCALE, newScale); + newScale += DEFAULT_SCALE_DELTA; + newScale = Math.min(MAX_SCALE, newScale.toFixed(3)); numTicks -= 1; } while (numTicks > 0 && newScale < MAX_SCALE); - if (this.pdfViewer.currentScale !== newScale) { - this.emit('zoom', { - zoom: newScale, - canZoomOut: true, - canZoomIn: newScale < MAX_SCALE, - }); - } - this.pdfViewer.currentScaleValue = newScale; + this.updateScale(newScale); } /** @@ -548,19 +540,30 @@ class DocBaseViewer extends BaseViewer { let numTicks = ticks; let newScale = this.pdfViewer.currentScale; do { - newScale = (newScale / DEFAULT_SCALE_DELTA).toFixed(3); - newScale = Math.max(MIN_SCALE, newScale); + newScale -= DEFAULT_SCALE_DELTA; + newScale = Math.max(MIN_SCALE, newScale.toFixed(3)); numTicks -= 1; } while (numTicks > 0 && newScale > MIN_SCALE); + this.updateScale(newScale); + } + + /** + * Updates the new scale to the pdfViewer as well as the zoom controls and emits an event + * @param {number} newScale - New zoom scale + * @emits zoom + * @returns {void} + */ + updateScale(newScale) { if (this.pdfViewer.currentScale !== newScale) { this.emit('zoom', { zoom: newScale, canZoomOut: newScale > MIN_SCALE, - canZoomIn: true, + canZoomIn: newScale < MAX_SCALE, }); } this.pdfViewer.currentScaleValue = newScale; + this.zoomControls.setCurrentScale(newScale); } /** @@ -1006,6 +1009,7 @@ class DocBaseViewer extends BaseViewer { loadUI() { this.controls = new Controls(this.containerEl); this.pageControls = new PageControls(this.controls, this.docEl); + this.zoomControls = new ZoomControls(this.controls); this.pageControls.addListener('pagechange', this.setPage); this.bindControlListeners(); } @@ -1065,8 +1069,14 @@ class DocBaseViewer extends BaseViewer { ); } - 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.zoomControls.init(this.pdfViewer.currentScale, { + maxZoom: MAX_SCALE, + minZoom: MIN_SCALE, + zoomInClassName: 'bp-doc-zoom-in-icon', + zoomOutClassName: 'bp-doc-zoom-out-icon', + onZoomIn: this.zoomIn, + onZoomOut: this.zoomOut, + }); this.pageControls.add(this.pdfViewer.currentPageNumber, this.pdfViewer.pagesCount); diff --git a/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js b/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js index 9755694d8..5b84992eb 100644 --- a/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js +++ b/src/lib/viewers/doc/__tests__/DocBaseViewer-test.js @@ -6,6 +6,7 @@ import Browser from '../../../Browser'; import BaseViewer from '../../BaseViewer'; import Controls from '../../../Controls'; import PageControls from '../../../PageControls'; +import ZoomControls from '../../../ZoomControls'; import fullscreen from '../../../Fullscreen'; import DocPreloader from '../DocPreloader'; import * as file from '../../../file'; @@ -28,8 +29,6 @@ import { import { ICON_PRINT_CHECKMARK, ICON_THUMBNAILS_TOGGLE, - ICON_ZOOM_OUT, - ICON_ZOOM_IN, ICON_FULLSCREEN_IN, ICON_FULLSCREEN_OUT, } from '../../../icons/icons'; @@ -919,7 +918,11 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { describe('zoom methods', () => { beforeEach(() => { docBase.pdfViewer = { - currentScale: 5, + currentScale: 8.9, + }; + docBase.zoomControls = { + setCurrentScale: sandbox.stub(), + removeListener: sandbox.stub(), }; stubs.emit = sandbox.stub(docBase, 'emit'); }); @@ -936,6 +939,7 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { docBase.pdfViewer.currentScale = 1; docBase.zoomIn(1); expect(docBase.pdfViewer.currentScaleValue).to.equal(DEFAULT_SCALE_DELTA); + expect(docBase.zoomControls.setCurrentScale).to.have.been.calledWith(DEFAULT_SCALE_DELTA); }); it('should emit the zoom event', () => { @@ -1652,6 +1656,7 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { expect(bindControlListenersStub).to.be.called; expect(docBase.controls instanceof Controls).to.be.true; expect(docBase.pageControls instanceof PageControls).to.be.true; + expect(docBase.zoomControls instanceof ZoomControls).to.be.true; expect(docBase.pageControls.contentEl).to.equal(docBase.docEl); }); }); @@ -2235,6 +2240,7 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { docBase.pdfViewer = { pagesCount: 4, currentPageNumber: 1, + currentScale: 0.9, cleanup: sandbox.stub(), }; @@ -2243,6 +2249,10 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { removeListener: sandbox.stub(), }; + docBase.zoomControls = { + init: sandbox.stub(), + }; + docBase.pageControls = { add: sandbox.stub(), removeListener: sandbox.stub(), @@ -2259,18 +2269,14 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { 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.zoomControls.init).to.be.calledWith(0.9, { + maxZoom: 10, + minZoom: 0.1, + zoomInClassName: 'bp-doc-zoom-in-icon', + zoomOutClassName: 'bp-doc-zoom-out-icon', + onZoomIn: docBase.zoomIn, + onZoomOut: docBase.zoomOut, + }); expect(docBase.pageControls.add).to.be.calledWith(1, 4); @@ -2290,43 +2296,7 @@ describe('src/lib/viewers/doc/DocBaseViewer', () => { 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(), - }; + docBase.options.enableThumbnailsSidebar = false; // Invoke the method to test docBase.bindControlListeners(); diff --git a/src/lib/viewers/image/ImageBaseViewer.js b/src/lib/viewers/image/ImageBaseViewer.js index 667a58922..fc2a96f11 100644 --- a/src/lib/viewers/image/ImageBaseViewer.js +++ b/src/lib/viewers/image/ImageBaseViewer.js @@ -1,8 +1,8 @@ -import Controls from '../../Controls'; import BaseViewer from '../BaseViewer'; import Browser from '../../Browser'; +import Controls from '../../Controls'; import PreviewError from '../../PreviewError'; -import { ICON_ZOOM_IN, ICON_ZOOM_OUT } from '../../icons/icons'; +import ZoomControls from '../../ZoomControls'; import { BROWSERS, CLASS_INVISIBLE } from '../../constants'; import { ERROR_CODE, VIEWER_EVENT } from '../../events'; @@ -77,8 +77,8 @@ class ImageBaseViewer extends BaseViewer { const loadOriginalDimensions = this.setOriginalImageSize(this.imageEl); loadOriginalDimensions.then(() => { - this.loadUI(); this.zoom(); + this.loadUI(); this.imageEl.classList.remove(CLASS_INVISIBLE); this.loaded = true; @@ -199,7 +199,8 @@ class ImageBaseViewer extends BaseViewer { */ loadUI() { this.controls = new Controls(this.containerEl); - this.bindControlListeners(); + this.zoomControls = new ZoomControls(this.controls); + this.zoomControls.init(this.scale, { onZoomIn: this.zoomIn, onZoomOut: this.zoomOut }); } /** @@ -254,17 +255,6 @@ class ImageBaseViewer extends BaseViewer { // 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); - } - /** * Binds DOM listeners for image viewers. * diff --git a/src/lib/viewers/image/ImageViewer.js b/src/lib/viewers/image/ImageViewer.js index 8a2d58b66..263a3310f 100644 --- a/src/lib/viewers/image/ImageViewer.js +++ b/src/lib/viewers/image/ImageViewer.js @@ -1,6 +1,7 @@ import ImageBaseViewer from './ImageBaseViewer'; import { ICON_FULLSCREEN_IN, ICON_FULLSCREEN_OUT, ICON_ROTATE_LEFT } from '../../icons/icons'; import { CLASS_INVISIBLE } from '../../constants'; + import './Image.scss'; const CSS_CLASS_IMAGE = 'bp-image'; @@ -258,6 +259,9 @@ class ImageViewer extends ImageBaseViewer { ? width / this.imageEl.getAttribute('originalWidth') : height / this.imageEl.getAttribute('originalHeight'); this.rotationAngle = (this.currentRotationAngle % 3600) % 360; + if (this.zoomControls) { + this.zoomControls.setCurrentScale(this.scale); + } this.emit('scale', { scale: this.scale, rotationAngle: this.rotationAngle, diff --git a/src/lib/viewers/image/MultiImageViewer.js b/src/lib/viewers/image/MultiImageViewer.js index 7c5eef5ac..078ec48f1 100644 --- a/src/lib/viewers/image/MultiImageViewer.js +++ b/src/lib/viewers/image/MultiImageViewer.js @@ -1,10 +1,11 @@ import ImageBaseViewer from './ImageBaseViewer'; import PageControls from '../../PageControls'; -import './MultiImage.scss'; import { ICON_FULLSCREEN_IN, ICON_FULLSCREEN_OUT } from '../../icons/icons'; import { CLASS_INVISIBLE, CLASS_MULTI_IMAGE_PAGE, CLASS_IS_SCROLLABLE } from '../../constants'; import { pageNumberFromScroll } from '../../util'; +import './MultiImage.scss'; + const PADDING_BUFFER = 100; const CSS_CLASS_IMAGE = 'bp-images'; const CSS_CLASS_IMAGE_WRAPPER = 'bp-images-wrapper'; @@ -245,6 +246,9 @@ class MultiImageViewer extends ImageBaseViewer { // Grab the first page image dimensions const imageEl = this.singleImageEls[0]; this.scale = width ? width / imageEl.naturalWidth : height / imageEl.naturalHeight; + if (this.zoomControls) { + this.zoomControls.setCurrentScale(this.scale); + } this.emit('scale', { scale: this.scale }); } @@ -256,6 +260,7 @@ class MultiImageViewer extends ImageBaseViewer { */ loadUI() { super.loadUI(); + this.pageControls = new PageControls(this.controls, this.wrapperEl); this.bindPageControlListeners(); } @@ -373,7 +378,10 @@ class MultiImageViewer extends ImageBaseViewer { } this.currentPageNumber = pageNumber; - this.pageControls.updateCurrentPage(pageNumber); + + if (this.pageControls) { + this.pageControls.updateCurrentPage(pageNumber); + } this.emit('pagefocus', { pageNumber, diff --git a/src/lib/viewers/image/__tests__/ImageBaseViewer-test.js b/src/lib/viewers/image/__tests__/ImageBaseViewer-test.js index bcc794a32..07847bd01 100644 --- a/src/lib/viewers/image/__tests__/ImageBaseViewer-test.js +++ b/src/lib/viewers/image/__tests__/ImageBaseViewer-test.js @@ -5,7 +5,6 @@ import BaseViewer from '../../BaseViewer'; import Browser from '../../../Browser'; import fullscreen from '../../../Fullscreen'; import PreviewError from '../../../PreviewError'; -import { ICON_ZOOM_IN, ICON_ZOOM_OUT } from '../../../icons/icons'; import { VIEWER_EVENT } from '../../../events'; import * as util from '../../../util'; @@ -213,11 +212,10 @@ 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.bindControlListeners).to.be.called; + expect(imageBase.zoomControls).to.not.be.undefined; }); }); @@ -275,28 +273,6 @@ describe('lib/viewers/image/ImageBaseViewer', () => { }); }); - 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, - ); - }); - }); - describe('handleMouseDown()', () => { beforeEach(() => { stubs.pan = sandbox.stub(imageBase, 'startPanning'); diff --git a/src/lib/viewers/image/__tests__/ImageViewer-test.js b/src/lib/viewers/image/__tests__/ImageViewer-test.js index ee7441649..f26979386 100644 --- a/src/lib/viewers/image/__tests__/ImageViewer-test.js +++ b/src/lib/viewers/image/__tests__/ImageViewer-test.js @@ -326,6 +326,10 @@ describe('lib/viewers/image/ImageViewer', () => { describe('setScale()', () => { it('should emit a scale event with current scale and rotationAngle', () => { sandbox.stub(image, 'emit'); + image.zoomControls = { + setCurrentScale: sandbox.stub(), + removeListener: sandbox.stub(), + }; image.currentRotationAngle = -90; const [width, height] = [100, 100]; @@ -334,15 +338,19 @@ describe('lib/viewers/image/ImageViewer', () => { scale: sinon.match.any, rotationAngle: sinon.match.number, }); + expect(image.zoomControls.setCurrentScale).to.be.calledWith(sinon.match.number); }); }); describe('loadUI()', () => { it('should load UI & controls for zoom', () => { + image.scale = 0.5; + image.loadUI(); expect(image.controls).to.not.be.undefined; expect(image.controls.buttonRefs.length).to.equal(5); + expect(image.zoomControls.currentScale).to.equal(50); }); }); diff --git a/src/lib/viewers/image/__tests__/MultiImageViewer-test.js b/src/lib/viewers/image/__tests__/MultiImageViewer-test.js index 5b6c8c944..f787413e3 100644 --- a/src/lib/viewers/image/__tests__/MultiImageViewer-test.js +++ b/src/lib/viewers/image/__tests__/MultiImageViewer-test.js @@ -8,6 +8,7 @@ import ImageBaseViewer from '../ImageBaseViewer'; import Browser from '../../../Browser'; import * as util from '../../../util'; import { ICON_FULLSCREEN_IN, ICON_FULLSCREEN_OUT } from '../../../icons/icons'; +import ZoomControls from '../../../ZoomControls'; const CLASS_INVISIBLE = 'bp-is-invisible'; @@ -323,6 +324,11 @@ describe('lib/viewers/image/MultiImageViewer', () => { describe('setScale()', () => { it('should set the scale relative to the size of the first image dimensions', () => { + multiImage.zoomControls = { + setCurrentScale: sandbox.stub(), + removeListener: sandbox.stub(), + }; + multiImage.singleImageEls = [ { naturalWidth: 1024, @@ -336,17 +342,30 @@ describe('lib/viewers/image/MultiImageViewer', () => { multiImage.setScale(512, 512); expect(multiImage.emit).to.be.calledWith('scale', { scale: 0.5 }); + expect(multiImage.zoomControls.setCurrentScale).to.be.calledWith(0.5); }); }); describe('loadUI()', () => { + const zoomInitFunc = ZoomControls.prototype.init; + + beforeEach(() => { + Object.defineProperty(ZoomControls.prototype, 'init', { value: sandbox.stub() }); + }); + + afterEach(() => { + Object.defineProperty(ZoomControls.prototype, 'init', { value: zoomInitFunc }); + }); + it('should create page controls and bind the page control listeners', () => { stubs.bindPageControlListeners = sandbox.stub(multiImage, 'bindPageControlListeners'); multiImage.loadUI(); expect(multiImage.pageControls instanceof PageControls).to.be.true; expect(multiImage.pageControls.contentEl).to.equal(multiImage.wrapperEl); + expect(multiImage.zoomControls instanceof ZoomControls).to.be.true; expect(stubs.bindPageControlListeners).to.be.called; + expect(ZoomControls.prototype.init).to.be.called; }); }); diff --git a/src/lib/viewers/text/TextBaseViewer.js b/src/lib/viewers/text/TextBaseViewer.js index c5c9632a1..db7824288 100644 --- a/src/lib/viewers/text/TextBaseViewer.js +++ b/src/lib/viewers/text/TextBaseViewer.js @@ -1,9 +1,10 @@ -import Controls from '../../Controls'; import BaseViewer from '../BaseViewer'; +import Controls from '../../Controls'; +import ZoomControls from '../../ZoomControls'; import { checkPermission } from '../../file'; import { CLASS_IS_PRINTABLE, CLASS_IS_SELECTABLE, PERMISSION_DOWNLOAD } from '../../constants'; -import { ICON_ZOOM_IN, ICON_ZOOM_OUT, ICON_FULLSCREEN_IN, ICON_FULLSCREEN_OUT } from '../../icons/icons'; +import { ICON_FULLSCREEN_IN, ICON_FULLSCREEN_OUT } from '../../icons/icons'; class TextBaseViewer extends BaseViewer { /** @@ -51,21 +52,31 @@ class TextBaseViewer extends BaseViewer { */ zoom(inOrOut) { const el = this.containerEl.querySelector('.bp-text'); - const size = parseInt(el.style.fontSize, 10) || 100; + const size = this.getFontSize(); let newFontSize = 0; if (inOrOut === 'in') { - newFontSize = `${size + 10}%`; + newFontSize = size + 10; } else if (inOrOut === 'out') { - newFontSize = `${size - 10}%`; + newFontSize = size - 10; } - el.style.fontSize = newFontSize; + el.style.fontSize = `${newFontSize}%`; this.emit('zoom', { zoom: newFontSize, canZoomIn: true, canZoomOut: true, }); + this.zoomControls.setCurrentScale(newFontSize / 100); + } + + /** + * Gets the font size applied to the text + * @returns {number} The font size as a number + */ + getFontSize() { + const el = this.containerEl.querySelector('.bp-text'); + return parseInt(el.style.fontSize, 10) || 100; } /** @@ -112,8 +123,14 @@ class TextBaseViewer extends BaseViewer { */ loadUI() { this.controls = new Controls(this.containerEl); - this.controls.add(__('zoom_out'), this.zoomOut, 'bp-text-zoom-out-icon', ICON_ZOOM_OUT); - this.controls.add(__('zoom_in'), this.zoomIn, 'bp-text-zoom-in-icon', ICON_ZOOM_IN); + this.zoomControls = new ZoomControls(this.controls); + this.zoomControls.init(this.getFontSize() / 100, { + zoomInClassName: 'bp-text-zoom-in-icon', + zoomOutClassName: 'bp-text-zoom-out-icon', + onZoomIn: this.zoomIn, + onZoomOut: this.zoomOut, + }); + this.controls.add( __('enter_fullscreen'), this.toggleFullscreen, diff --git a/src/lib/viewers/text/__tests__/TextBaseViewer-test.js b/src/lib/viewers/text/__tests__/TextBaseViewer-test.js index ce69dc8ef..5ab24dd1f 100644 --- a/src/lib/viewers/text/__tests__/TextBaseViewer-test.js +++ b/src/lib/viewers/text/__tests__/TextBaseViewer-test.js @@ -1,7 +1,8 @@ /* eslint-disable no-unused-expressions */ +import BaseViewer from '../../BaseViewer'; import Controls from '../../../Controls'; import TextBaseViewer from '../TextBaseViewer'; -import BaseViewer from '../../BaseViewer'; +import ZoomControls from '../../../ZoomControls'; import * as file from '../../../file'; import { PERMISSION_DOWNLOAD } from '../../../constants'; @@ -59,6 +60,10 @@ describe('lib/viewers/text/TextBaseViewer', () => { textEl = document.createElement('div'); textEl.className = 'bp-text'; textBase.containerEl.appendChild(textEl); + textBase.zoomControls = { + setCurrentScale: sandbox.stub(), + removeListener: sandbox.stub(), + }; }); afterEach(() => { @@ -69,16 +74,19 @@ describe('lib/viewers/text/TextBaseViewer', () => { sandbox.stub(textBase, 'emit'); textBase.zoom(); expect(textBase.emit).to.be.calledWith('zoom'); + expect(textBase.zoomControls.setCurrentScale).to.be.calledWith(0); }); it('should increase font size when zooming in', () => { textBase.zoom('in'); expect(textEl.style.fontSize).to.equal('110%'); + expect(textBase.zoomControls.setCurrentScale).to.be.calledWith(1.1); }); it('should decrease font size when zooming out', () => { textBase.zoom('out'); expect(textEl.style.fontSize).to.equal('90%'); + expect(textBase.zoomControls.setCurrentScale).to.be.calledWith(0.9); }); }); @@ -136,35 +144,39 @@ describe('lib/viewers/text/TextBaseViewer', () => { describe('loadUI()', () => { const addFunc = Controls.prototype.add; + const zoomInitFunc = ZoomControls.prototype.init; + + beforeEach(() => { + sandbox.stub(textBase, 'getFontSize'); + }); afterEach(() => { Object.defineProperty(Controls.prototype, 'add', { value: addFunc }); + Object.defineProperty(ZoomControls.prototype, 'init', { value: zoomInitFunc }); }); it('should setup controls and add click handlers', () => { Object.defineProperty(Controls.prototype, 'add', { value: sandbox.stub() }); + Object.defineProperty(ZoomControls.prototype, 'init', { value: sandbox.stub() }); + textBase.getFontSize.returns(100); textBase.loadUI(); expect(textBase.controls instanceof Controls).to.be.true; - expect(Controls.prototype.add.callCount).to.equal(4); - expect(Controls.prototype.add).to.be.calledWith( - sinon.match.string, - textBase.zoomOut, - sinon.match.string, - sinon.match.string, - ); - expect(Controls.prototype.add).to.be.calledWith( - sinon.match.string, - textBase.zoomIn, - sinon.match.string, - sinon.match.string, - ); + expect(Controls.prototype.add.callCount).to.equal(2); expect(Controls.prototype.add).to.be.calledWith( sinon.match.string, textBase.toggleFullscreen, sinon.match.string, sinon.match.string, ); + + expect(textBase.zoomControls instanceof ZoomControls).to.be.true; + expect(ZoomControls.prototype.init).to.be.calledWith(1, { + zoomInClassName: 'bp-text-zoom-in-icon', + zoomOutClassName: 'bp-text-zoom-out-icon', + onZoomIn: textBase.zoomIn, + onZoomOut: textBase.zoomOut, + }); }); });