diff --git a/test/unit/ui_utils_spec.js b/test/unit/ui_utils_spec.js index 44517a91a..58f97e9d1 100644 --- a/test/unit/ui_utils_spec.js +++ b/test/unit/ui_utils_spec.js @@ -14,7 +14,8 @@ */ import { - binarySearchFirstItem, EventBus, getPDFFileNameFromURL + binarySearchFirstItem, EventBus, getPDFFileNameFromURL, waitOnEventOrTimeout, + WaitOnType } from '../../web/ui_utils'; import { createObjectURL, isNodeJS } from '../../src/shared/util'; @@ -259,4 +260,118 @@ describe('ui_utils', function() { expect(count).toEqual(2); }); }); + + describe('waitOnEventOrTimeout', function() { + let eventBus; + + beforeAll(function(done) { + eventBus = new EventBus(); + done(); + }); + + afterAll(function() { + eventBus = null; + }); + + it('should reject invalid parameters', function(done) { + let invalidTarget = waitOnEventOrTimeout({ + target: 'window', + name: 'DOMContentLoaded', + }).then(function() { + throw new Error('Should reject invalid parameters.'); + }, function(reason) { + expect(reason instanceof Error).toEqual(true); + }); + + let invalidName = waitOnEventOrTimeout({ + target: eventBus, + name: '', + }).then(function() { + throw new Error('Should reject invalid parameters.'); + }, function(reason) { + expect(reason instanceof Error).toEqual(true); + }); + + let invalidDelay = waitOnEventOrTimeout({ + target: eventBus, + name: 'pagerendered', + delay: -1000, + }).then(function() { + throw new Error('Should reject invalid parameters.'); + }, function(reason) { + expect(reason instanceof Error).toEqual(true); + }); + + Promise.all([invalidTarget, invalidName, invalidDelay]).then(done, + done.fail); + }); + + it('should resolve on event, using the DOM', function(done) { + if (isNodeJS()) { + pending('Document in not supported in Node.js.'); + } + let button = document.createElement('button'); + + let buttonClicked = waitOnEventOrTimeout({ + target: button, + name: 'click', + delay: 10000, + }); + // Immediately dispatch the expected event. + button.click(); + + buttonClicked.then(function(type) { + expect(type).toEqual(WaitOnType.EVENT); + done(); + }, done.fail); + }); + + it('should resolve on timeout, using the DOM', function(done) { + if (isNodeJS()) { + pending('Document in not supported in Node.js.'); + } + let button = document.createElement('button'); + + let buttonClicked = waitOnEventOrTimeout({ + target: button, + name: 'click', + delay: 10, + }); + // Do *not* dispatch the event, and wait for the timeout. + + buttonClicked.then(function(type) { + expect(type).toEqual(WaitOnType.TIMEOUT); + done(); + }, done.fail); + }); + + it('should resolve on event, using the EventBus', function(done) { + let pageRendered = waitOnEventOrTimeout({ + target: eventBus, + name: 'pagerendered', + delay: 10000, + }); + // Immediately dispatch the expected event. + eventBus.dispatch('pagerendered'); + + pageRendered.then(function(type) { + expect(type).toEqual(WaitOnType.EVENT); + done(); + }, done.fail); + }); + + it('should resolve on timeout, using the EventBus', function(done) { + let pageRendered = waitOnEventOrTimeout({ + target: eventBus, + name: 'pagerendered', + delay: 10, + }); + // Do *not* dispatch the event, and wait for the timeout. + + pageRendered.then(function(type) { + expect(type).toEqual(WaitOnType.TIMEOUT); + done(); + }, done.fail); + }); + }); }); diff --git a/web/ui_utils.js b/web/ui_utils.js index df6702ca4..a670d6702 100644 --- a/web/ui_utils.js +++ b/web/ui_utils.js @@ -13,7 +13,7 @@ * limitations under the License. */ -import { PDFJS } from 'pdfjs-lib'; +import { createPromiseCapability, PDFJS } from 'pdfjs-lib'; const CSS_UNITS = 96.0 / 72.0; const DEFAULT_SCALE_VALUE = 'auto'; @@ -453,6 +453,62 @@ function cloneObj(obj) { return result; } +const WaitOnType = { + EVENT: 'event', + TIMEOUT: 'timeout', +}; + +/** + * @typedef {Object} WaitOnEventOrTimeoutParameters + * @property {Object} target - The event target, can for example be: + * `window`, `document`, a DOM element, or an {EventBus} instance. + * @property {string} name - The name of the event. + * @property {number} delay - The delay, in milliseconds, after which the + * timeout occurs (if the event wasn't already dispatched). + */ + +/** + * Allows waiting for an event or a timeout, whichever occurs first. + * Can be used to ensure that an action always occurs, even when an event + * arrives late or not at all. + * + * @param {WaitOnEventOrTimeoutParameters} + * @returns {Promise} A promise that is resolved with a {WaitOnType} value. + */ +function waitOnEventOrTimeout({ target, name, delay = 0, }) { + if (typeof target !== 'object' || !(name && typeof name === 'string') || + !(Number.isInteger(delay) && delay >= 0)) { + return Promise.reject( + new Error('waitOnEventOrTimeout - invalid paramaters.')); + } + let capability = createPromiseCapability(); + + function handler(type) { + if (target instanceof EventBus) { + target.off(name, eventHandler); + } else { + target.removeEventListener(name, eventHandler); + } + + if (timeout) { + clearTimeout(timeout); + } + capability.resolve(type); + } + + let eventHandler = handler.bind(null, WaitOnType.EVENT); + if (target instanceof EventBus) { + target.on(name, eventHandler); + } else { + target.addEventListener(name, eventHandler); + } + + let timeoutHandler = handler.bind(null, WaitOnType.TIMEOUT); + let timeout = setTimeout(timeoutHandler, delay); + + return capability.promise; +} + /** * Promise that is resolved when DOM window becomes visible. */ @@ -618,4 +674,6 @@ export { normalizeWheelEventDelta, animationStarted, localized, + WaitOnType, + waitOnEventOrTimeout, };