/* globals window performance */ const defaultOptions = { // How long should it take for the bar to animate to a new // point after receiving it catchupTime: 100, // How quickly should the bar be moving before it has any progress // info from a new source in %/ms initialRate: 0.03, // What is the minimum amount of time the bar should be on the // screen. Irrespective of this number, the bar will always be on screen for // 33 * (100 / maxProgressPerFrame) + ghostTime ms. minTime: 250, // What is the minimum amount of time the bar should sit after the last // update before disappearing ghostTime: 100, // Its easy for a bunch of the bar to be eaten in the first few frames // before we know how much there is to load. This limits how much of // the bar can be used per frame maxProgressPerFrame: 20, // This tweaks the animation easing easeFactor: 1.25, // Should pace automatically start when the page is loaded, or should it wait for `start` to // be called? Always false if pace is loaded with AMD or CommonJS. startOnPageLoad: true, // Should we restart the browser when pushState or replaceState is called? (Generally // means ajax navigation has occured) restartOnPushState: true, // Should we show the progress bar for every ajax request (not just regular or ajax-y page // navigation)? Set to false to disable. // // If so, how many ms does the request have to be running for before we show the progress? restartOnRequestAfter: 500, // What element should the pace element be appended to on the page? target: 'body', elements: { // How frequently in ms should we check for the elements being tested for // using the element monitor? checkInterval: 100, // What elements should we wait for before deciding the page is fully loaded (not required) selectors: ['body'] }, eventLag: { // When we first start measuring event lag, not much is going on in the browser yet, so it's // not uncommon for the numbers to be abnormally low for the first few samples. This configures // how many samples we need before we consider a low number to mean completion. minSamples: 10, // How many samples should we average to decide what the current lag is? sampleCount: 3, // Above how many ms of lag is the CPU considered busy? lagThreshold: 3 }, ajax: { // Which HTTP methods should we track? trackMethods: ['GET'], // Should we track web socket connections? trackWebSockets: true, // A list of regular expressions or substrings of URLS we should ignore (for both tracking and restarting) ignoreURLs: [] } }; function now() { if (typeof performance !== 'undefined' && typeof performance.now !== 'undefined') { return performance.now(); } return +new Date(); } let requestAnimationFrame; let cancelAnimationFrame; if (typeof window !== 'undefined') { requestAnimationFrame = ( window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || window.msRequestAnimationFrame ); cancelAnimationFrame = ( window.cancelAnimationFrame || window.mozCancelAnimationFrame ); } if (typeof requestAnimationFrame === 'undefined') { requestAnimationFrame = (fn) => setTimeout(fn, 50); cancelAnimationFrame = (id) => clearTimeout(id); } const runAnimation = (fn) => { let last = now(); const tick = () => { const diff = now() - last; if (diff >= 33) { last = now(); // Don't run faster than 30 fps return fn(diff, () => requestAnimationFrame(tick)); } return setTimeout(tick, (33 - diff)); }; return tick(); }; function result(obj, key, ...args) { if (typeof obj[key] === 'function') { return obj[key](...args); } return obj[key]; } function extend(out, ...sources) { sources.forEach(source => { if (source) { for (let key in source) { if ({}.hasOwnProperty.call(source, key)) { const val = source[key]; // NOTE: Checking for object may be sufficient... if (typeof out[key] !== 'undefined' && typeof out[key] === 'object' && typeof val !== 'undefined' && typeof val === 'object') { extend(out[key], val); } else { out[key] = val; } } } } }); return out; } function extendNative(to, from) { for (let key in from.prototype) { try { if (typeof to[key] === 'undefined' && typeof from[key] !== 'function') { if (typeof Object.defineProperty === 'function') { Object.defineProperty(to, key, { get() { return from.prototype[key]; }, configurable: true, enumerable: true }); } else { to[key] = from.prototype[key]; } } } catch (e) {} } } function average(arr) { let sum = 0; let count = 0; arr.forEach(v => { sum += Math.abs(v); ++count; }); return sum / count; } function getFromDOM(key='options', json=true) { const el = document.querySelector("[data-pace-#{ key }]"); if (!el) { return; } const data = el.getAttribute("data-pace-#{ key }"); if (!json) { return data; } try { return JSON.parse(data); } catch (e) { if (typeof console !== 'undefined') { console.error("Error parsing inline pace options", e); } } } class Evented { on(event, handler, ctx, once=false) { if (typeof this.bindings === 'undefined') { this.bindings = {}; } if (typeof this.bindings[event] === 'undefined') { this.bindings[event] = []; } this.bindings[event].push({handler, ctx, once}); } once(event, handler, ctx) { this.on(event, handler, ctx, true); } off(event, handler) { if (typeof this.bindings === 'undefined' && typeof this.bindings[event] === 'undefined') { return; } if (typeof handler === 'undefined') { delete this.bindings[event]; } else { let i = 0; while (i < this.bindings[event].length) { if (this.bindings[event][i].handler === handler) { this.bindings[event].splice(i, 1); } else { ++i; } } } } trigger(event, ...args) { if (typeof this.bindings !== 'undefined' && this.bindings[event]) { let i = 0; while (i < this.bindings[event].length) { const {handler, ctx, once} = this.bindings[event][i]; const context = typeof ctx !== 'undefined' ? ctx : this; handler.apply(context, args); if (once) { this.bindings[event].splice(i, 1); } else { ++i; } } } } } const Pace = window.Pace || {}; window.Pace = Pace; extend(Pace, Evented.prototype); const options = Pace.options = extend({}, defaultOptions, window.paceOptions, getFromDOM()); ['ajax', 'document', 'eventLag', 'elements'].forEach(source => { // true enables them without configuration, so we grab the config from the defaults if (options[source] === true) { options[source] = defaultOptions[source]; } }); class NoTargetError extends Error {} class Bar { constructor(_options) { this.options = _options; this.progress = 0; } getElement() { if (typeof this.el === 'undefined') { const targetElement = document.querySelector(this.options.target); if (!targetElement) { throw new NoTargetError(); } this.el = document.createElement('div'); this.el.className = "pace pace-active"; document.body.className = document.body.className.replace(/pace-done/g, ''); document.body.className += ' pace-running'; this.el.innerHTML = [ '
', '
', '
', '
' ].join(''); if (typeof targetElement.firstChild !== 'undefined') { targetElement.insertBefore(this.el, targetElement.firstChild); } else { targetElement.appendChild(this.el); } } return this.el; } finish() { const el = this.getElement(); el.className = el.className.replace('pace-active', ''); el.className += ' pace-inactive'; document.body.className = document.body.className.replace('pace-running', ''); document.body.className += ' pace-done'; } update(progress) { this.progress = progress; this.render(); } destroy() { try { this.getElement().parentNode.removeChild(this.getElement()); } catch (e) { if (e instanceof NoTargetError) { this.el = undefined; } } } render() { const target = document.querySelector(this.options.target); if (!target) { return false; } const el = this.getElement(); const transform = `translate3d(${this.progress}%, 0, 0)`; ['webkitTransform', 'msTransform', 'transform'].forEach(key => { el.children[0].style[key] = transform; }); if (!this.lastRenderedProgress || (this.lastRenderedProgress | 0) !== (this.progress | 0)) { // The whole-part of the number has changed el.children[0].setAttribute('data-progress-text', `${this.progress | 0}%`); let progressStr; if (this.progress >= 100) { // We cap it at 99 so we can use prefix-based attribute selectors progressStr = '99'; } else { progressStr = this.progress < 10 ? '0' : ''; progressStr += this.progress | 0; } el.children[0].setAttribute('data-progress', `${progressStr}`); } this.lastRenderedProgress = this.progress; } done() { return this.progress >= 100; } } class Events { constructor() { this.bindings = {}; } trigger(name, val) { if (typeof this.bindings[name] !== 'undefined') { this.bindings[name].forEach(binding => { binding.call(this, val); }); } } on(name, fn) { if (typeof this.bindings[name] === 'undefined') { this.bindings[name] = []; } this.bindings[name].push(fn); } } const { XMLHttpRequest: _XMLHttpRequest, XDomainRequest: _XDomainRequest, WebSocket: _WebSocket } = window; const ignoreStack = []; Pace.ignore = (fn, ...args) => { ignoreStack.unshift('ignore'); const ret = fn(...args); ignoreStack.shift(); return ret; }; Pace.track = (fn, ...args) => { ignoreStack.unshift('track'); const ret = fn(...args); ignoreStack.shift(); return ret; }; const shouldTrack = (method='GET') => { if (ignoreStack[0] === 'track') { return 'force'; } if (!ignoreStack.length && options.ajax) { if (method === 'socket' && options.ajax.trackWebSockets) { return true; } else if (method.toUpperCase() === options.ajax.trackMethods) { return true; } } return false; }; // We should only ever instantiate one of these class RequestIntercept extends Events { constructor() { super(); const {trackWebSockets} = options.ajax; const monitorXHR = (req) => { const {open: _open} = req; req.open = (type, url, async) => { if (shouldTrack(type)) { this.trigger('request', {type, url, request: req}); } _open.apply(req, arguments); }; }; // XMLHttpRequest window.XMLHttpRequest = (flags) => { const req = new _XMLHttpRequest(flags); monitorXHR(req); return req; }; try { extendNative(window.XMLHttpRequest, _XMLHttpRequest); } catch(e) {} // XDomainRequest if (typeof _XDomainRequest !== 'undefined') { window.XDomainRequest = () => { const req = new _XDomainRequest; monitorXHR(req); return req; }; try { extendNative(window.XDomainRequest, _XDomainRequest); } catch(e) {} } // WebSocket if (typeof _WebSocket !== 'undefined' && trackWebSockets) { window.WebSocket = (url, protocols) => { let req; if (typeof protocols !== 'undefined') { req = new _WebSocket(url, protocols); } else { req = new _WebSocket(url); } if (shouldTrack('socket')) { this.trigger('request', { type: 'socket', url: url, protocols: protocols, request: req }); } return req; }; try { extendNative(window.WebSocket, _WebSocket); } catch(e) {} } } } let _intercept = null; const getIntercept = () => { if (!_intercept) { _intercept = new RequestIntercept(); } return _intercept; }; const shouldIgnoreURL = (url) => { options.ajax.ignoreURLs.forEach(pattern => { if (typeof pattern === 'string') { if (url.indexOf(pattern) !== -1) { return true; } } else { if (pattern.test(url)) { return true; } } }); return false; }; // If we want to start the progress bar // on every request, we need to hear the request // and then inject it into the new ajax monitor // start will have created. getIntercept().on('request', ({type, request, url}) => { if (shouldIgnoreURL(url)) { return; } if (!Pace.running && (options.restartOnRequestAfter !== false || shouldTrack(type) === 'force')) { const args = arguments; let after = options.restartOnRequestAfter || 0; if (typeof after === 'boolean') { after = 0; } setTimeout(() => { let stillActive; if (type === 'socket') { stillActive = request.readyState < 2; } else { stillActive = request.readyState > 0 && request.readyState < 4; } if (stillActive) { Pace.restart(); for (let i = 0; i < Pace.sources.length; ++i) { const source = Pace.sources[i]; if (source instanceof AjaxMonitor) { source.watch(...args); break; } } } }, after); } }); class AjaxMonitor { constructor() { this.elements = []; getIntercept().on('request', () => this.watch(...arguments)); } watch({type, request, url}) { if (shouldIgnoreURL(url)) { return; } let tracker; if (type === 'socket') { tracker = new SocketRequestTracker(request); } else { tracker = new XHRRequestTracker(request); } this.elements.push(tracker); } } class XHRRequestTracker { constructor(request) { this.progress = 0; if (typeof window.ProgressEvent !== 'undefined') { // We're dealing with a modern browser with progress event support request.addEventListener('progress', (evt) => { if (evt.lengthComputable) { this.progress = 100 * evt.loaded / evt.total; } else { // If it's chunked encoding, we have no way of knowing the total length of the // response, all we can do is increment the progress with backoff such that we // never hit 100% until it's done. this.progress = this.progress + (100 - this.progress) / 2; } }, false); ['load', 'abort', 'timeout', 'error'].forEach(event => { request.addEventListener(event, () => { this.progress = 100; }, false); }); } else { const _onreadystatechange = request.onreadystatechange; request.onreadystatechange = () => { if (request.readyState === 0 || request.readyState === 4) { this.progress = 100; } else if (request.readyState === 3) { this.progress = 50; } if (typeof _onreadystatechange !== 'undefined') { _onreadystatechange(...arguments); } }; } } } class SocketRequestTracker { constructor(request) { this.progress = 0; ['error', 'open'].forEach(event => { request.addEventListener(event, () => { this.progress = 100; }, false); }); } } class ElementMonitor { constructor(_options={}) { this.elements = []; if (typeof _options.selectors === 'undefined') { _options.selectors = []; } _options.selectors.forEach(selector => { this.elements.push(new ElementTracker(selector)); }); } } class ElementTracker { constructor(selector) { this.selector = selector; this.progress = 0; this.check(); } check() { if (document.querySelector(this.selector)) { this.done(); } else { setTimeout(() => { this.check(); }, options.elements.checkInterval); } } done() { this.progress = 100; } } class DocumentMonitor { constructor() { const states = { loading: 0, interactive: 50, complete: 100 }; this.progress = states[document.readyState] || 100; const _onreadystatechange = document.onreadystatechange; document.onreadystatechange = () => { if (typeof states[document.readyState] !== 'undefined') { this.progress = states[document.readyState]; } if (typeof _onreadystatechange !== 'undefined') { _onreadystatechange(...arguments); } }; } } class EventLagMonitor { constructor() { this.progress = 0; let avg = 0; let points = 0; const samples = []; let last = now(); const interval = setInterval(() => { const diff = now() - last - 50; last = now(); samples.push(diff); if (samples.length > options.eventLag.sampleCount) { samples.shift(); } avg = average(samples); if (++points >= options.eventLag.minSamples && avg < options.eventLag.lagThreshold) { this.progress = 100; clearInterval(interval); } else { this.progress = 100 * (3 / (avg + 3)); } }, 50); } } class Scaler { constructor(source) { this.source = source; this.last = this.sinceLastUpdate = 0; this.rate = options.initialRate; this.catchup = 0; this.progress = this.lastProgress = 0; if (typeof this.source !== 'undefined') { this.progress = result(this.source, 'progress'); } } tick(frameTime, val) { if (typeof val === 'undefined') { val = result(this.source, 'progress'); } if (val >= 100) { this.done = true; } if (val === this.last) { this.sinceLastUpdate += frameTime; } else { if (this.sinceLastUpdate) { this.rate = (val - this.last) / this.sinceLastUpdate; } this.catchup = (val - this.progress) / options.catchupTime; this.sinceLastUpdate = 0; this.last = val; } if (val > this.progress) { // After we've got a datapoint, we have catchupTime to // get the progress bar to reflect that new data this.progress += this.catchup * frameTime; } const scaling = (1 - Math.pow(this.progress / 100, options.easeFactor)); // Based on the rate of the last update, we preemptively update // the progress bar, scaling it so it can never hit 100% until we // know it's done. this.progress += scaling * this.rate * frameTime; this.progress = Math.min(this.lastProgress + options.maxProgressPerFrame, this.progress); this.progress = Math.max(0, this.progress); this.progress = Math.min(100, this.progress); this.lastProgress = this.progress; return this.progress; } } let sources = null; let scalers = null; let bar = null; let uniScaler = null; let animation = null; let cancelAnimation = null; Pace.running = false; const handlePushState = () => { if (options.restartOnPushState) { Pace.restart(); } }; // We reset the bar whenever it looks like an ajax navigation has occured. if (typeof window.history.pushState !== 'undefined') { const _pushState = window.history.pushState; window.history.pushState = () => { handlePushState(); _pushState.apply(window.history, arguments); }; } if (typeof window.history.replaceState !== 'undefined') { const _replaceState = window.history.replaceState; window.history.replaceState = () => { handlePushState(); _replaceState.apply(window.history, arguments); }; } const SOURCE_KEYS = { ajax: AjaxMonitor, elements: ElementMonitor, document: DocumentMonitor, eventLag: EventLagMonitor }; const init = () => { Pace.sources = sources = []; ['ajax', 'elements', 'document', 'eventLag'].forEach(type => { if (options[type] !== false) { sources.push(new SOURCE_KEYS[type](options[type])); } }); let extraSources = options.extraSources; if (typeof extraSources === 'undefined') { extraSources = []; } extraSources.forEach(source => { sources.push(new source(options)); }); Pace.bar = bar = new Bar; // Each source of progress data has it's own scaler to smooth its output scalers = []; // We have an extra scaler for the final output to keep things looking nice as we add and // remove sources uniScaler = new Scaler; }; init(); Pace.stop = () => { Pace.trigger('stop'); Pace.running = false; bar.destroy(); // Not all browsers support cancelAnimationFrame cancelAnimation = true; if (typeof animation !== 'undefined') { if (typeof cancelAnimationFrame !== 'undefined') { cancelAnimationFrame(animation); } animation = null; } init(); }; Pace.restart = () => { Pace.trigger('restart'); Pace.stop(); Pace.start(); }; Pace.go = () => { Pace.running = true; bar.render(); const start = now(); cancelAnimation = false; animation = runAnimation((frameTime, enqueueNextFrame) => { // Every source gives us a progress number from 0 - 100 // It's up to us to figure out how to turn that into a smoothly moving bar // // Their progress numbers can only increment. We try to interpolate // between the numbers. let sum = 0; let count = sum; let done = true; // A source is composed of a bunch of elements, each with a raw, unscaled progress for (let i = 0; i < sources.length; ++i) { const source = sources[i]; if (typeof scalers[i] === 'undefined') { scalers[i] = []; } const scalerList = scalers[i]; const elements = typeof source.elements !== 'undefined' ? source.elements : [source]; // Each element is given it's own scaler, which turns its value into something // smoothed for display for (let j = 0; j < elements.length; ++j) { const element = elements[j]; if (typeof scalerList[j] === 'undefined') { scalerList[j] = new Scaler(element); } const scaler = scalerList[j]; done &= scaler.done; if (scaler.done) { continue; } count++; sum += scaler.tick(frameTime); } } const avg = sum / count; bar.update(uniScaler.tick(frameTime, avg)); if (bar.done() || done || cancelAnimation) { bar.update(100); Pace.trigger('done'); setTimeout(() => { bar.finish(); Pace.running = false; Pace.trigger('hide'); }, Math.max(options.ghostTime, Math.max(options.minTime - (now() - start), 0))); } else { enqueueNextFrame(); } }); }; Pace.start = (_options) => { extend(options, _options); Pace.running = true; try { bar.render(); } catch (e) { if (e instanceof NoTargetError) { // Handle error... } } // It's usually possible to render a bit before the document declares itself ready if (!document.querySelector('.pace')) { setTimeout(Pace.start, 50); } else { Pace.trigger('start'); Pace.go(); } }; if (options.startOnPageLoad) { Pace.start(); }