pdf.js/web/pdf_history.js
Jonas Jenwald 1fab637247 Prevent if (!state || false) { in the output, in PDFHistory._popState, for e.g. MOZCENTRAL builds
By re-ordering the condition, which includes a `PDFJSDev` check, we can avoid meaningless output in the built files.
2019-12-13 10:38:39 +01:00

700 lines
23 KiB
JavaScript

/* Copyright 2017 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
getGlobalEventBus, isValidRotation, parseQueryString, waitOnEventOrTimeout
} from './ui_utils';
// Heuristic value used when force-resetting `this._blockHashChange`.
const HASH_CHANGE_TIMEOUT = 1000; // milliseconds
// Heuristic value used when adding the current position to the browser history.
const POSITION_UPDATED_THRESHOLD = 50;
// Heuristic value used when adding a temporary position to the browser history.
const UPDATE_VIEWAREA_TIMEOUT = 1000; // milliseconds
/**
* @typedef {Object} PDFHistoryOptions
* @property {IPDFLinkService} linkService - The navigation/linking service.
* @property {EventBus} eventBus - The application event bus.
*/
/**
* @typedef {Object} InitializeParameters
* @property {string} fingerprint - The PDF document's unique fingerprint.
* @property {boolean} [resetHistory] - Reset the browsing history.
* @property {boolean} [updateUrl] - Attempt to update the document URL, with
* the current hash, when pushing/replacing browser history entries.
*/
/**
* @typedef {Object} PushParameters
* @property {string} [namedDest] - The named destination. If absent, a
* stringified version of `explicitDest` is used.
* @property {Array} explicitDest - The explicit destination array.
* @property {number} pageNumber - The page to which the destination points.
*/
function getCurrentHash() {
return document.location.hash;
}
class PDFHistory {
/**
* @param {PDFHistoryOptions} options
*/
constructor({ linkService, eventBus, }) {
this.linkService = linkService;
this.eventBus = eventBus || getGlobalEventBus();
this._initialized = false;
this._fingerprint = '';
this.reset();
this._boundEvents = null;
this._isViewerInPresentationMode = false;
// Ensure that we don't miss either a 'presentationmodechanged' or a
// 'pagesinit' event, by registering the listeners immediately.
this.eventBus.on('presentationmodechanged', (evt) => {
this._isViewerInPresentationMode = evt.active || evt.switchInProgress;
});
this.eventBus.on('pagesinit', () => {
this._isPagesLoaded = false;
const onPagesLoaded = (evt) => {
this.eventBus.off('pagesloaded', onPagesLoaded);
this._isPagesLoaded = !!evt.pagesCount;
};
this.eventBus.on('pagesloaded', onPagesLoaded);
});
}
/**
* Initialize the history for the PDF document, using either the current
* browser history entry or the document hash, whichever is present.
* @param {InitializeParameters} params
*/
initialize({ fingerprint, resetHistory = false, updateUrl = false, }) {
if (!fingerprint || typeof fingerprint !== 'string') {
console.error(
'PDFHistory.initialize: The "fingerprint" must be a non-empty string.');
return;
}
// Ensure that any old state is always reset upon initialization.
if (this._initialized) {
this.reset();
}
const reInitialized = (this._fingerprint !== '' &&
this._fingerprint !== fingerprint);
this._fingerprint = fingerprint;
this._updateUrl = (updateUrl === true);
this._initialized = true;
this._bindEvents();
const state = window.history.state;
this._popStateInProgress = false;
this._blockHashChange = 0;
this._currentHash = getCurrentHash();
this._numPositionUpdates = 0;
this._uid = this._maxUid = 0;
this._destination = null;
this._position = null;
if (!this._isValidState(state, /* checkReload = */ true) || resetHistory) {
const { hash, page, rotation, } = this._parseCurrentHash();
if (!hash || reInitialized || resetHistory) {
// Ensure that the browser history is reset on PDF document load.
this._pushOrReplaceState(null, /* forceReplace = */ true);
return;
}
// Ensure that the browser history is initialized correctly when
// the document hash is present on PDF document load.
this._pushOrReplaceState({ hash, page, rotation, },
/* forceReplace = */ true);
return;
}
// The browser history contains a valid entry, ensure that the history is
// initialized correctly on PDF document load.
let destination = state.destination;
this._updateInternalState(destination, state.uid,
/* removeTemporary = */ true);
if (this._uid > this._maxUid) {
this._maxUid = this._uid;
}
if (destination.rotation !== undefined) {
this._initialRotation = destination.rotation;
}
if (destination.dest) {
this._initialBookmark = JSON.stringify(destination.dest);
// If the history is updated, e.g. through the user changing the hash,
// before the initial destination has become visible, then we do *not*
// want to potentially add `this._position` to the browser history.
this._destination.page = null;
} else if (destination.hash) {
this._initialBookmark = destination.hash;
} else if (destination.page) {
// Fallback case; shouldn't be necessary, but better safe than sorry.
this._initialBookmark = `page=${destination.page}`;
}
}
/**
* Reset the current `PDFHistory` instance, and consequently prevent any
* further updates and/or navigation of the browser history.
*/
reset() {
if (this._initialized) {
this._pageHide(); // Simulate a 'pagehide' event when resetting.
this._initialized = false;
this._unbindEvents();
}
if (this._updateViewareaTimeout) {
clearTimeout(this._updateViewareaTimeout);
this._updateViewareaTimeout = null;
}
this._initialBookmark = null;
this._initialRotation = null;
}
/**
* Push an internal destination to the browser history.
* @param {PushParameters}
*/
push({ namedDest = null, explicitDest, pageNumber, }) {
if (!this._initialized) {
return;
}
if (namedDest && typeof namedDest !== 'string') {
console.error('PDFHistory.push: ' +
`"${namedDest}" is not a valid namedDest parameter.`);
return;
} else if (!Array.isArray(explicitDest)) {
console.error('PDFHistory.push: ' +
`"${explicitDest}" is not a valid explicitDest parameter.`);
return;
} else if (!(Number.isInteger(pageNumber) &&
pageNumber > 0 && pageNumber <= this.linkService.pagesCount)) {
// Allow an unset `pageNumber` if and only if the history is still empty;
// please refer to the `this._destination.page = null;` comment above.
if (pageNumber !== null || this._destination) {
console.error('PDFHistory.push: ' +
`"${pageNumber}" is not a valid pageNumber parameter.`);
return;
}
}
let hash = namedDest || JSON.stringify(explicitDest);
if (!hash) {
// The hash *should* never be undefined, but if that were to occur,
// avoid any possible issues by not updating the browser history.
return;
}
let forceReplace = false;
if (this._destination &&
(isDestHashesEqual(this._destination.hash, hash) ||
isDestArraysEqual(this._destination.dest, explicitDest))) {
// When the new destination is identical to `this._destination`, and
// its `page` is undefined, replace the current browser history entry.
// NOTE: This can only occur if `this._destination` was set either:
// - through the document hash being specified on load.
// - through the user changing the hash of the document.
if (this._destination.page) {
return;
}
forceReplace = true;
}
if (this._popStateInProgress && !forceReplace) {
return;
}
this._pushOrReplaceState({
dest: explicitDest,
hash,
page: pageNumber,
rotation: this.linkService.rotation,
}, forceReplace);
if (!this._popStateInProgress) {
// Prevent the browser history from updating while the new destination is
// being scrolled into view, to avoid potentially inconsistent state.
this._popStateInProgress = true;
// We defer the resetting of `this._popStateInProgress`, to account for
// e.g. zooming occuring when the new destination is being navigated to.
Promise.resolve().then(() => {
this._popStateInProgress = false;
});
}
}
/**
* Push the current position to the browser history.
*/
pushCurrentPosition() {
if (!this._initialized || this._popStateInProgress) {
return;
}
this._tryPushCurrentPosition();
}
/**
* Go back one step in the browser history.
* NOTE: Avoids navigating away from the document, useful for "named actions".
*/
back() {
if (!this._initialized || this._popStateInProgress) {
return;
}
let state = window.history.state;
if (this._isValidState(state) && state.uid > 0) {
window.history.back();
}
}
/**
* Go forward one step in the browser history.
* NOTE: Avoids navigating away from the document, useful for "named actions".
*/
forward() {
if (!this._initialized || this._popStateInProgress) {
return;
}
let state = window.history.state;
if (this._isValidState(state) && state.uid < this._maxUid) {
window.history.forward();
}
}
/**
* @type {boolean} Indicating if the user is currently moving through the
* browser history, useful e.g. for skipping the next 'hashchange' event.
*/
get popStateInProgress() {
return this._initialized &&
(this._popStateInProgress || this._blockHashChange > 0);
}
get initialBookmark() {
return this._initialized ? this._initialBookmark : null;
}
get initialRotation() {
return this._initialized ? this._initialRotation : null;
}
/**
* @private
*/
_pushOrReplaceState(destination, forceReplace = false) {
let shouldReplace = forceReplace || !this._destination;
let newState = {
fingerprint: this._fingerprint,
uid: shouldReplace ? this._uid : (this._uid + 1),
destination,
};
if (typeof PDFJSDev !== 'undefined' && PDFJSDev.test('CHROME') &&
window.history.state && window.history.state.chromecomState) {
// history.state.chromecomState is managed by chromecom.js.
newState.chromecomState = window.history.state.chromecomState;
}
this._updateInternalState(destination, newState.uid);
let newUrl;
if (this._updateUrl && destination && destination.hash) {
const baseUrl = document.location.href.split('#')[0];
if (!baseUrl.startsWith('file://')) { // Prevent errors in Firefox.
newUrl = `${baseUrl}#${destination.hash}`;
}
}
if (shouldReplace) {
window.history.replaceState(newState, '', newUrl);
} else {
this._maxUid = this._uid;
window.history.pushState(newState, '', newUrl);
}
if (typeof PDFJSDev !== 'undefined' && PDFJSDev.test('CHROME') &&
top === window) {
// eslint-disable-next-line no-undef
chrome.runtime.sendMessage('showPageAction');
}
}
/**
* @private
*/
_tryPushCurrentPosition(temporary = false) {
if (!this._position) {
return;
}
let position = this._position;
if (temporary) {
position = Object.assign(Object.create(null), this._position);
position.temporary = true;
}
if (!this._destination) {
this._pushOrReplaceState(position);
return;
}
if (this._destination.temporary) {
// Always replace a previous *temporary* position.
this._pushOrReplaceState(position, /* forceReplace = */ true);
return;
}
if (this._destination.hash === position.hash) {
return; // The current document position has not changed.
}
if (!this._destination.page &&
(POSITION_UPDATED_THRESHOLD <= 0 ||
this._numPositionUpdates <= POSITION_UPDATED_THRESHOLD)) {
// `this._destination` was set through the user changing the hash of
// the document. Do not add `this._position` to the browser history,
// to avoid "flooding" it with lots of (nearly) identical entries,
// since we cannot ensure that the document position has changed.
return;
}
let forceReplace = false;
if (this._destination.page >= position.first &&
this._destination.page <= position.page) {
// When the `page` of `this._destination` is still visible, do not
// update the browsing history when `this._destination` either:
// - contains an internal destination, since in this case we
// cannot ensure that the document position has actually changed.
// - was set through the user changing the hash of the document.
if (this._destination.dest || !this._destination.first) {
return;
}
// To avoid "flooding" the browser history, replace the current entry.
forceReplace = true;
}
this._pushOrReplaceState(position, forceReplace);
}
/**
* @private
*/
_isValidState(state, checkReload = false) {
if (!state) {
return false;
}
if (state.fingerprint !== this._fingerprint) {
if (checkReload) {
// Potentially accept the history entry, even if the fingerprints don't
// match, when the viewer was reloaded (see issue 6847).
if (typeof state.fingerprint !== 'string' ||
state.fingerprint.length !== this._fingerprint.length) {
return false;
}
const [perfEntry] = performance.getEntriesByType('navigation');
if (!perfEntry || perfEntry.type !== 'reload') {
return false;
}
} else {
// This should only occur in viewers with support for opening more than
// one PDF document, e.g. the GENERIC viewer.
return false;
}
}
if (!Number.isInteger(state.uid) || state.uid < 0) {
return false;
}
if (state.destination === null || typeof state.destination !== 'object') {
return false;
}
return true;
}
/**
* @private
*/
_updateInternalState(destination, uid, removeTemporary = false) {
if (this._updateViewareaTimeout) {
// When updating `this._destination`, make sure that we always wait for
// the next 'updateviewarea' event before (potentially) attempting to
// push the current position to the browser history.
clearTimeout(this._updateViewareaTimeout);
this._updateViewareaTimeout = null;
}
if (removeTemporary && destination && destination.temporary) {
// When the `destination` comes from the browser history,
// we no longer treat it as a *temporary* position.
delete destination.temporary;
}
this._destination = destination;
this._uid = uid;
// This should always be reset when `this._destination` is updated.
this._numPositionUpdates = 0;
}
/**
* @private
*/
_parseCurrentHash() {
const hash = unescape(getCurrentHash()).substring(1);
let page = parseQueryString(hash).page | 0;
if (!(Number.isInteger(page) &&
page > 0 && page <= this.linkService.pagesCount)) {
page = null;
}
return { hash, page, rotation: this.linkService.rotation, };
}
/**
* @private
*/
_updateViewarea({ location, }) {
if (this._updateViewareaTimeout) {
clearTimeout(this._updateViewareaTimeout);
this._updateViewareaTimeout = null;
}
this._position = {
hash: this._isViewerInPresentationMode ?
`page=${location.pageNumber}` : location.pdfOpenParams.substring(1),
page: this.linkService.page,
first: location.pageNumber,
rotation: location.rotation,
};
if (this._popStateInProgress) {
return;
}
if (POSITION_UPDATED_THRESHOLD > 0 && this._isPagesLoaded &&
this._destination && !this._destination.page) {
// If the current destination was set through the user changing the hash
// of the document, we will usually not try to push the current position
// to the browser history; see `this._tryPushCurrentPosition()`.
//
// To prevent `this._tryPushCurrentPosition()` from effectively being
// reduced to a no-op in this case, we will assume that the position
// *did* in fact change if the 'updateviewarea' event was dispatched
// more than `POSITION_UPDATED_THRESHOLD` times.
this._numPositionUpdates++;
}
if (UPDATE_VIEWAREA_TIMEOUT > 0) {
// When closing the browser, a 'pagehide' event will be dispatched which
// *should* allow us to push the current position to the browser history.
// In practice, it seems that the event is arriving too late in order for
// the session history to be successfully updated.
// (For additional details, please refer to the discussion in
// https://bugzilla.mozilla.org/show_bug.cgi?id=1153393.)
//
// To workaround this we attempt to *temporarily* add the current position
// to the browser history only when the viewer is *idle*,
// i.e. when scrolling and/or zooming does not occur.
//
// PLEASE NOTE: It's absolutely imperative that the browser history is
// *not* updated too often, since that would render the viewer more or
// less unusable. Hence the use of a timeout to delay the update until
// the viewer has been idle for `UPDATE_VIEWAREA_TIMEOUT` milliseconds.
this._updateViewareaTimeout = setTimeout(() => {
if (!this._popStateInProgress) {
this._tryPushCurrentPosition(/* temporary = */ true);
}
this._updateViewareaTimeout = null;
}, UPDATE_VIEWAREA_TIMEOUT);
}
}
/**
* @private
*/
_popState({ state, }) {
let newHash = getCurrentHash(), hashChanged = this._currentHash !== newHash;
this._currentHash = newHash;
if ((typeof PDFJSDev !== 'undefined' && PDFJSDev.test('CHROME') &&
state && state.chromecomState && !this._isValidState(state)) ||
!state) {
// This case corresponds to the user changing the hash of the document.
this._uid++;
const { hash, page, rotation, } = this._parseCurrentHash();
this._pushOrReplaceState({ hash, page, rotation, },
/* forceReplace = */ true);
return;
}
if (!this._isValidState(state)) {
// This should only occur in viewers with support for opening more than
// one PDF document, e.g. the GENERIC viewer.
return;
}
// Prevent the browser history from updating until the new destination,
// as stored in the browser history, has been scrolled into view.
this._popStateInProgress = true;
if (hashChanged) {
// When the hash changed, implying that the 'popstate' event will be
// followed by a 'hashchange' event, then we do *not* want to update the
// browser history when handling the 'hashchange' event (in web/app.js)
// since that would *overwrite* the new destination navigated to below.
//
// To avoid accidentally disabling all future user-initiated hash changes,
// if there's e.g. another 'hashchange' listener that stops the event
// propagation, we make sure to always force-reset `this._blockHashChange`
// after `HASH_CHANGE_TIMEOUT` milliseconds have passed.
this._blockHashChange++;
waitOnEventOrTimeout({
target: window,
name: 'hashchange',
delay: HASH_CHANGE_TIMEOUT,
}).then(() => {
this._blockHashChange--;
});
}
// Navigate to the new destination.
let destination = state.destination;
this._updateInternalState(destination, state.uid,
/* removeTemporary = */ true);
if (this._uid > this._maxUid) {
this._maxUid = this._uid;
}
if (isValidRotation(destination.rotation)) {
this.linkService.rotation = destination.rotation;
}
if (destination.dest) {
this.linkService.navigateTo(destination.dest);
} else if (destination.hash) {
this.linkService.setHash(destination.hash);
} else if (destination.page) {
// Fallback case; shouldn't be necessary, but better safe than sorry.
this.linkService.page = destination.page;
}
// Since `PDFLinkService.navigateTo` is asynchronous, we thus defer the
// resetting of `this._popStateInProgress` slightly.
Promise.resolve().then(() => {
this._popStateInProgress = false;
});
}
/**
* @private
*/
_pageHide() {
// Attempt to push the `this._position` into the browser history when
// navigating away from the document. This is *only* done if the history
// is empty/temporary, since otherwise an existing browser history entry
// will end up being overwritten (given that new entries cannot be pushed
// into the browser history when the 'unload' event has already fired).
if (!this._destination || this._destination.temporary) {
this._tryPushCurrentPosition();
}
}
/**
* @private
*/
_bindEvents() {
if (this._boundEvents) {
return; // The event listeners were already added.
}
this._boundEvents = {
updateViewarea: this._updateViewarea.bind(this),
popState: this._popState.bind(this),
pageHide: this._pageHide.bind(this),
};
this.eventBus.on('updateviewarea', this._boundEvents.updateViewarea);
window.addEventListener('popstate', this._boundEvents.popState);
window.addEventListener('pagehide', this._boundEvents.pageHide);
}
/**
* @private
*/
_unbindEvents() {
if (!this._boundEvents) {
return; // The event listeners were already removed.
}
this.eventBus.off('updateviewarea', this._boundEvents.updateViewarea);
window.removeEventListener('popstate', this._boundEvents.popState);
window.removeEventListener('pagehide', this._boundEvents.pageHide);
this._boundEvents = null;
}
}
function isDestHashesEqual(destHash, pushHash) {
if (typeof destHash !== 'string' || typeof pushHash !== 'string') {
return false;
}
if (destHash === pushHash) {
return true;
}
let { nameddest, } = parseQueryString(destHash);
if (nameddest === pushHash) {
return true;
}
return false;
}
function isDestArraysEqual(firstDest, secondDest) {
function isEntryEqual(first, second) {
if (typeof first !== typeof second) {
return false;
}
if (Array.isArray(first) || Array.isArray(second)) {
return false;
}
if (first !== null && typeof first === 'object' && second !== null) {
if (Object.keys(first).length !== Object.keys(second).length) {
return false;
}
for (let key in first) {
if (!isEntryEqual(first[key], second[key])) {
return false;
}
}
return true;
}
return first === second || (Number.isNaN(first) && Number.isNaN(second));
}
if (!(Array.isArray(firstDest) && Array.isArray(secondDest))) {
return false;
}
if (firstDest.length !== secondDest.length) {
return false;
}
for (let i = 0, ii = firstDest.length; i < ii; i++) {
if (!isEntryEqual(firstDest[i], secondDest[i])) {
return false;
}
}
return true;
}
export {
PDFHistory,
isDestHashesEqual,
isDestArraysEqual,
};