From 2c8d607b18bc819a40e3d2db2815151bf9beb93a Mon Sep 17 00:00:00 2001 From: John Berlin Date: Wed, 5 Dec 2018 21:47:10 -0500 Subject: [PATCH] Ensured that the banner does not become stuck displaying Loading... on non-html content fixes #417 (#418) Changes: Reworked ContentFrame and the default banner to be ES5 classes. Introduced an optional relationship between ContentFrame and banners. If a banner is exposed then ContentFrame controls the initialization of the banner and routes any messages received from the replay iframe to the banner. When the replay iframe is navigated to a page and the replay iframe loads, the ContentFrame waits 2 seconds before checking to see if the banner still indicates it a loading state and if so updates the displayed information using the URL and timestamp the replay iframe was navigated to. --- pywb/static/default_banner.js | 233 +++++++++++++-------- pywb/static/wb_frame.js | 371 +++++++++++++++++++++------------- 2 files changed, 379 insertions(+), 225 deletions(-) diff --git a/pywb/static/default_banner.js b/pywb/static/default_banner.js index 640c6d70..5def7ff2 100644 --- a/pywb/static/default_banner.js +++ b/pywb/static/default_banner.js @@ -20,8 +20,134 @@ This file is part of pywb, https://github.com/webrecorder/pywb // Creates the default pywb banner. -(function() { - function ts_to_date(ts, is_gmt) { +(function () { + if (window.top !== window) { + return; + } + + /** + * The default banner class + */ + function DefaultBanner() { + if (!(this instanceof DefaultBanner)) return new DefaultBanner(); + this.banner = null; + this.captureInfo = null; + this.last_state = {}; + this.state = null; + this.title = ""; + this.loadingId = 'bannerLoading'; + this.onMessage = this.onMessage.bind(this); + } + + // Functions required to be exposed by all banners + + /** + * @desc Initialize (display) the banner + */ + DefaultBanner.prototype.init = function () { + if (window.wbinfo) { + this.createBanner('_wb_plain_banner'); + this.set_banner( + window.wbinfo.url, + window.wbinfo.timestamp, + window.wbinfo.is_live, + window.wbinfo.is_framed ? "" : document.title + ); + } else { + this.createBanner('_wb_frame_top_banner'); + } + }; + + /** + * @desc Called by ContentFrame to detect if the banner is still showing + * that the page is loading + * @returns {boolean} + */ + DefaultBanner.prototype.stillIndicatesLoading = function () { + return document.getElementById(this.loadingId) != null; + }; + + /** + * @param {string} url - The URL of the replayed page + * @param {?string} ts - The timestamp of the replayed page. + * If we are in live mode this is undefined/empty string + * @param {boolean} is_live - A bool indicating if we are operating in live mode + */ + DefaultBanner.prototype.updateCaptureInfo = function (url, ts, is_live) { + if (is_live && !ts) { + ts = new Date().toISOString().replace(/[-T:.Z]/g, '') + } + this.set_banner(url, ts, is_live, null); + }; + + /** + * @desc Called by ContentFrame when a message is received from the replay iframe + * @param {MessageEvent} event - The message event containing the message received + * from the replayed page + */ + DefaultBanner.prototype.onMessage = function (event) { + var type = event.data.wb_type; + + if (type === "load" || type === "replace-url") { + this.state = event.data; + this.last_state = this.state; + this.title = event.data.title || this.title; + } else if (type === "title") { + this.state = this.last_state; + this.title = event.data.title; + } else { + return; + } + + // favicon update + if (type === 'load') { + var head = document.querySelector('head'); + var oldLink = document.querySelectorAll("link[rel*='icon']"); + var i = 0; + for (; i < oldLink.length; i++) { + head.removeChild(oldLink[i]); + } + + if (this.state.icons) { + for (i = 0; i < this.state.icons.length; i++) { + var icon = this.state.icons[i]; + var link = document.createElement('link'); + link.rel = icon.rel; + link.href = icon.href; + head.appendChild(link); + } + } + } + + this.set_banner(this.state.url, this.state.ts, this.state.is_live, this.title); + }; + + // Functions internal to the default banner + + /** + * @desc Creates the underlying HTML elements comprising the banner + * @param {string} bid - The id for the banner + */ + DefaultBanner.prototype.createBanner = function (bid) { + this.banner = document.createElement("wb_div", true); + this.banner.setAttribute("id", bid); + this.banner.setAttribute("lang", "en"); + this.captureInfo = document.createElement('span'); + this.captureInfo.innerHTML = 'Loading...'; + this.captureInfo.id = '_wb_capture_info'; + this.banner.appendChild(this.captureInfo); + document.body.insertBefore(this.banner, document.body.firstChild); + }; + + /** + * @desc Converts a timestamp to a date string. If is_gmt is truthy then + * the returned data string will be the results of date.toGMTString otherwise + * its date.toLocaleString() + * @param {?string} ts - The timestamp to receive the correct date string for + * @param {boolean} is_gmt - Is the returned date string to be in GMT time + * @returns {string} + */ + DefaultBanner.prototype.ts_to_date = function (ts, is_gmt) { if (!ts) { return ""; } @@ -31,11 +157,11 @@ This file is part of pywb, https://github.com/webrecorder/pywb } var datestr = (ts.substring(0, 4) + "-" + - ts.substring(4, 6) + "-" + - ts.substring(6, 8) + "T" + - ts.substring(8, 10) + ":" + - ts.substring(10, 12) + ":" + - ts.substring(12, 14) + "-00:00"); + ts.substring(4, 6) + "-" + + ts.substring(6, 8) + "T" + + ts.substring(8, 10) + ":" + + ts.substring(10, 12) + ":" + + ts.substring(12, 14) + "-00:00"); var date = new Date(datestr); @@ -44,22 +170,16 @@ This file is part of pywb, https://github.com/webrecorder/pywb } else { return date.toLocaleString(); } - } + }; - function init(bid) { - var banner = document.createElement("wb_div", true); - - banner.setAttribute("id", bid); - banner.setAttribute("lang", "en"); - - var text = ""; - text += "Loading..."; - - banner.innerHTML = text; - document.body.insertBefore(banner, document.body.firstChild); - } - - function set_banner(url, ts, is_live, title) { + /** + * @desc Updates the contents displayed by the banner + * @param {?string} url - The URL of the replayed page to be displayed in the banner + * @param {?string} ts - A timestamp to be displayed in the banner + * @param {boolean} is_live - Are we in live mode + * @param {?string} title - The title of the replayed page to be displayed in the banner + */ + DefaultBanner.prototype.set_banner = function (url, ts, is_live, title) { var capture_str; var title_str; @@ -67,11 +187,11 @@ This file is part of pywb, https://github.com/webrecorder/pywb return; } - var date_str = ts_to_date(ts, true); + var date_str = this.ts_to_date(ts, true); if (title) { capture_str = title; - } else { + } else { capture_str = url; } @@ -88,69 +208,12 @@ This file is part of pywb, https://github.com/webrecorder/pywb title_str += " (" + date_str + ")"; capture_str += date_str; - - document.querySelector("#_wb_capture_info").innerHTML = capture_str; + this.captureInfo.innerHTML = capture_str; window.document.title = title_str; - } - - if (window.top != window) { - return; - } - - var last_state = {}; - - window.addEventListener("load", function() { - if (window.wbinfo) { - init("_wb_plain_banner"); - - set_banner(window.wbinfo.url, - window.wbinfo.timestamp, - window.wbinfo.is_live, - window.wbinfo.is_framed ? "" : document.title); - } else { - init("_wb_frame_top_banner"); - - var state; - var title = ""; - - window.addEventListener("message", function(event) { - var type = event.data.wb_type; - - if (type == "load" || type == "replace-url") { - state = event.data; - last_state = state; - title = event.data.title || title; - } else if (type == "title") { - state = last_state; - title = event.data.title; - } else { - return; - } - - // favicon update - if (type === 'load') { - var head = document.querySelector('head'); - var oldLink = document.querySelectorAll("link[rel*='icon']"); - for (var i = 0; i < oldLink.length; i++) { - head.removeChild(oldLink[i]); - } - - if (state.icons) { - for (var i = 0; i < state.icons.length; i++) { - var icon = state.icons[i]; - var link = document.createElement('link'); - link.rel = icon.rel; - link.href = icon.href; - head.appendChild(link); - } - } - } - - set_banner(state.url, state.ts, state.is_live, title); - }); - } - }); + }; + // all banners will expose themselves by adding themselves as WBBanner on window + window.WBBanner = new DefaultBanner(); })(); diff --git a/pywb/static/wb_frame.js b/pywb/static/wb_frame.js index d743e658..e72f2c26 100644 --- a/pywb/static/wb_frame.js +++ b/pywb/static/wb_frame.js @@ -18,159 +18,250 @@ This file is part of pywb, https://github.com/webrecorder/pywb */ +/** + * @param {Object} content_info - Information about the contents to be replayed + */ function ContentFrame(content_info) { + if (!(this instanceof ContentFrame)) return new ContentFrame(content_info); this.last_inner_hash = window.location.hash; this.last_url = content_info.url; this.last_ts = content_info.request_ts; - - this.init_iframe = function() { - if (typeof(content_info.iframe) === "string") { - this.iframe = document.querySelector(content_info.iframe); - } else { - this.iframe = content_info.iframe; - } - - if (!this.iframe) { - console.warn("no iframe found " + content_info.iframe + " found"); - return; - } - - this.extract_prefix(); - - this.load_url(content_info.url, content_info.request_ts); - } - - this.extract_prefix = function() { - this.app_prefix = content_info.app_prefix || content_info.prefix; - this.content_prefix = content_info.content_prefix || content_info.prefix; - - if (this.app_prefix && this.content_prefix) { - return; - } - - var inx = window.location.href.indexOf(content_info.url); - - if (inx < 0) { - inx = window.location.href.indexOf("/http") + 1; - if (inx <= 0) { - inx = window.location.href.indexOf("///") + 1; - if (inx <= 0) { - console.warn("No Prefix Found!"); - } - } - } - - this.prefix = window.location.href.substr(0, inx); - - this.app_prefix = this.app_prefix || this.prefix; - this.content_prefix = this.content_prefix || this.prefix; - } - - - this.make_url = function(url, ts, content_url) { - var mod, prefix; - - if (content_url) { - mod = "mp_"; - prefix = this.content_prefix; - } else { - mod = ""; - prefix = this.app_prefix; - } - - if (ts || mod) { - mod += "/"; - } - - if (ts) { - return prefix + ts + mod + url; - } else { - return prefix + mod + url; - } - } - - this.handle_event = function(event) { - var frame_win = this.iframe.contentWindow; - if (event.source == window.parent) { - // Pass to replay frame - frame_win.postMessage(event.data, "*"); - } else if (event.source == frame_win) { - - // Check if iframe url change message - if (typeof(event.data) == "object" && event.data["wb_type"]) { - this.handle_message(event.data); - - } else { - // Pass to parent - window.parent.postMessage(event.data, "*"); - } - } - } - - this.handle_message = function(state) { - var type = state.wb_type; - - if (type == "load" || type == "replace-url") { - this.set_url(state); - } else if (type == "hashchange") { - this.inner_hash_changed(state); - } - } - - this.set_url = function(state) { - if (state.url && (state.url != this.last_url || state.request_ts != this.last_ts)) { - var new_url = this.make_url(state.url, state.request_ts, false); - - window.history.replaceState(state, "", new_url); - - this.last_url = state.url; - this.last_ts = state.request_ts; - } - } - - this.load_url = function(newUrl, newTs) { - this.iframe.src = this.make_url(newUrl, newTs, true); - } - - this.inner_hash_changed = function(state) { - if (window.location.hash != state.hash) { - window.location.hash = state.hash; - } - this.last_inner_hash = state.hash; - } - - this.outer_hash_changed = function(event) { - if (window.location.hash == this.last_inner_hash) { - return; - } - - if (this.iframe) { - var message = {"wb_type": "outer_hashchange", "hash": window.location.hash} - - this.iframe.contentWindow.postMessage(message, "*", undefined, true); - } - } - - this.close = function() { - window.removeEventListener("hashchange", this.outer_hash_changed); - window.removeEventListener("message", this.handle_event); - } - + this.content_info = content_info; // bind event callbacks this.outer_hash_changed = this.outer_hash_changed.bind(this); this.handle_event = this.handle_event.bind(this); + this.wbBanner = null; + this.checkBannerToId = null; - window.addEventListener("hashchange", this.outer_hash_changed, false); - window.addEventListener("message", this.handle_event); + window.addEventListener('hashchange', this.outer_hash_changed, false); + window.addEventListener('message', this.handle_event); - if (document.readyState === "complete") { + if (document.readyState === 'complete') { this.init_iframe(); } else { - document.addEventListener("DOMContentLoaded", this.init_iframe.bind(this), { once: true }); + document.addEventListener('DOMContentLoaded', this.init_iframe.bind(this), {once: true}); } - window.__WB_pmw = function(win) { + window.__WB_pmw = function (win) { this.pm_source = win; return this; - } + }; } + +/** + * @desc Initializes the replay iframe. If a banner exists (exposed on window as WBBanner) + * then the init function of the banner is called. + */ +ContentFrame.prototype.init_iframe = function () { + if (typeof (this.content_info.iframe) === 'string') { + this.iframe = document.querySelector(this.content_info.iframe); + } else { + this.iframe = this.content_info.iframe; + } + + if (!this.iframe) { + console.warn('no iframe found ' + this.content_info.iframe + ' found'); + return; + } + + this.extract_prefix(); + if (window.WBBanner) { + this.wbBanner = window.WBBanner; + this.wbBanner.init(); + } + this.load_url(this.content_info.url, this.content_info.request_ts); +}; + +/** + * @desc Initializes the prefixes used to load the pages to be replayed + */ +ContentFrame.prototype.extract_prefix = function () { + this.app_prefix = this.content_info.app_prefix || this.content_info.prefix; + this.content_prefix = this.content_info.content_prefix || this.content_info.prefix; + + if (this.app_prefix && this.content_prefix) { + return; + } + + var inx = window.location.href.indexOf(this.content_info.url); + + if (inx < 0) { + inx = window.location.href.indexOf('/http') + 1; + if (inx <= 0) { + inx = window.location.href.indexOf('///') + 1; + if (inx <= 0) { + console.warn('No Prefix Found!'); + } + } + } + + this.prefix = window.location.href.substr(0, inx); + + this.app_prefix = this.app_prefix || this.prefix; + this.content_prefix = this.content_prefix || this.prefix; +}; + +/** + * @desc Returns an absolute URL (with correct prefix and replay modifier) given + * the replayed pages URL and optional timestamp and content_url + * @param {string} url - The URL of the replayed page + * @param {?string} ts - The timestamp of the replayed page + * @param {?boolean} content_url - Is the abs URL to be constructed using the content_prefix or app_prefix + * @returns {string} + */ +ContentFrame.prototype.make_url = function (url, ts, content_url) { + var mod, prefix; + + if (content_url) { + mod = 'mp_'; + prefix = this.content_prefix; + } else { + mod = ''; + prefix = this.app_prefix; + } + + if (ts || mod) { + mod += '/'; + } + + if (ts) { + return prefix + ts + mod + url; + } else { + return prefix + mod + url; + } +}; + +/** + * @desc Handles and routes all messages received from the replay iframe. + * @param {MessageEvent} event - A message event potentially containing a message from the replay iframe + */ +ContentFrame.prototype.handle_event = function (event) { + var frame_win = this.iframe.contentWindow; + if (event.source === window.parent) { + // Pass to replay frame + frame_win.postMessage(event.data, '*'); + } else if (event.source === frame_win) { + // Check if iframe url change message + if (typeof (event.data) === 'object' && event.data['wb_type']) { + this.handle_message(event); + } else { + // Pass to parent + window.parent.postMessage(event.data, '*'); + } + } +}; + +/** + * @desc Handles messages intended for the content frame (indicated by data.wb_type). If a banner + * is exposed, calls the onMessage function of the exposed banner. + * @param {MessageEvent} event - The message event containing a message from the replay iframe + */ +ContentFrame.prototype.handle_message = function (event) { + if (this.wbBanner) { + this.wbBanner.onMessage(event); + } + var state = event.data; + var type = state.wb_type; + + if (type === 'load' || type === 'replace-url') { + this.set_url(state); + } else if (type === 'hashchange') { + this.inner_hash_changed(state); + } +}; + +/** + * @desc Updates the URL of the top frame + * @param {Object} state - The contents of a message rreceived from the replay iframe + */ +ContentFrame.prototype.set_url = function (state) { + if (state.url && (state.url !== this.last_url || state.request_ts !== this.last_ts)) { + var new_url = this.make_url(state.url, state.request_ts, false); + + window.history.replaceState(state, '', new_url); + + this.last_url = state.url; + this.last_ts = state.request_ts; + } +}; + +/** + * @desc Checks to see if the banner is still indicating the replay iframe is still loading + * 2 seconds after the load event is fired by the replay iframe. If the banner is still + * indicating the replayed page is loading. Updates the displayed information using + * newURL and newTS + * @param {string} newUrl - The new URL of the replay iframe + * @param {?string} newTs - The new timestamp of the replay iframe. Is falsy if + * operating in live mode + */ +ContentFrame.prototype.initBannerUpdateCheck = function (newUrl, newTs) { + if (!this.wbBanner) return; + var contentFrame = this; + var replayIframeLoaded = function () { + contentFrame.iframe.removeEventListener('load', replayIframeLoaded); + contentFrame.checkBannerToId = setTimeout(function () { + contentFrame.checkBannerToId = null; + if (contentFrame.wbBanner.stillIndicatesLoading()) { + contentFrame.wbBanner.updateCaptureInfo( + newUrl, + newTs, + contentFrame.content_prefix.indexOf('/live') !== -1 + ); + } + }, 2000); + }; + if (this.checkBannerToId) { + clearTimeout(this.checkBannerToId); + } + this.iframe.addEventListener('load', replayIframeLoaded); +}; + + +/** + * @desc Navigates the replay iframe to a newURL and if a banner is exposed + * the initBannerUpdateCheck function is called. + * @param {string} newUrl - The new URL of the replay iframe + * @param {?string} newTs - The new timestamp of the replay iframe. Is falsy if + * operating in live mode + */ +ContentFrame.prototype.load_url = function (newUrl, newTs) { + this.iframe.src = this.make_url(newUrl, newTs, true); + if (this.wbBanner) { + this.initBannerUpdateCheck(newUrl, newTs); + } +}; + +/** + * @desc Updates this frames hash to the one inside the replay iframe + * @param {Object} state - The contents of message received from the replay iframe + */ +ContentFrame.prototype.inner_hash_changed = function (state) { + if (window.location.hash !== state.hash) { + window.location.hash = state.hash; + } + this.last_inner_hash = state.hash; +}; + +/** + * @desc Updates the hash of the replay iframe on a hash change in this frame + * @param event + */ +ContentFrame.prototype.outer_hash_changed = function (event) { + if (window.location.hash === this.last_inner_hash) { + return; + } + + if (this.iframe) { + var message = {'wb_type': 'outer_hashchange', 'hash': window.location.hash}; + + this.iframe.contentWindow.postMessage(message, '*', undefined, true); + } +}; + +/** + * @desc Cleans up any event listeners added by the content frame + */ +ContentFrame.prototype.close = function () { + window.removeEventListener('hashchange', this.outer_hash_changed); + window.removeEventListener('message', this.handle_event); +};