diff --git a/assets/src/admin/paired-browsing/app.js b/assets/src/admin/paired-browsing/app.js index 8503576ef99..62c35a08800 100644 --- a/assets/src/admin/paired-browsing/app.js +++ b/assets/src/admin/paired-browsing/app.js @@ -6,9 +6,15 @@ import { addQueryArgs, hasQueryArg, removeQueryArgs } from '@wordpress/url'; * Internal dependencies */ import './app.css'; +import { isNonAmpWindow, isAmpWindow } from './utils'; -const { app, history } = window; -const { noampQueryVar, noampMobile, ampPairedBrowsingQueryVar, documentTitlePrefix } = app; +const { ampPairedBrowsingAppData, history } = window; +const { + noampQueryVar, + noampMobile, + ampPairedBrowsingQueryVar, + documentTitlePrefix, +} = ampPairedBrowsingAppData; class PairedBrowsingApp { /** @@ -25,6 +31,13 @@ class PairedBrowsingApp { */ ampIframe; + /** + * Whether the AMP window is loading. + * + * @type {boolean} + */ + ampWindowLoading = false; + /** * Non-AMP IFrame * @@ -32,6 +45,27 @@ class PairedBrowsingApp { */ nonAmpIframe; + /** + * Whether the non-AMP window is loading. + * + * @type {boolean} + */ + nonAmpWindowLoading = false; + + /** + * Current AMP URL. + * + * @type {string} + */ + currentAmpUrl; + + /** + * Current non-AMP URL. + * + * @type {string} + */ + currentNonAmpUrl; + /** * Non-AMP Link * @@ -46,13 +80,23 @@ class PairedBrowsingApp { */ ampLink; + /** + * Active iframe. + * + * @type {HTMLIFrameElement|null} + */ + activeIframe; + /** * Constructor. */ constructor() { this.nonAmpIframe = document.querySelector( '#non-amp iframe' ); this.ampIframe = document.querySelector( '#amp iframe' ); - this.ampPageHasErrors = false; + this.ampPageHasErrors = false; // @todo This is obsolete. Remove this and invalid-amp span. + + this.currentNonAmpUrl = this.nonAmpIframe.src; + this.currentAmpUrl = this.ampIframe.src; // Link to exit paired browsing. this.nonAmpLink = /** @type {HTMLAnchorElement} */ document.getElementById( 'non-amp-link' ); @@ -70,12 +114,71 @@ class PairedBrowsingApp { }; this.addDisconnectButtonListeners(); + window.addEventListener( 'message', ( event ) => { + this.receiveMessage( event ); + } ); + + // Set the active iframe based on which got the last mouseenter. + // Note that setting activeIframe may get set by receiveScroll if the user starts scrolling + // before moving the mouse. + document.getElementById( 'non-amp' ).addEventListener( 'mouseenter', () => { + this.activeIframe = this.nonAmpIframe; + } ); + document.getElementById( 'amp' ).addEventListener( 'mouseenter', () => { + this.activeIframe = this.ampIframe; + } ); + // Load clients. - Promise.all( this.getIframeLoadedPromises() ); + this.getIframeLoadedPromises(); + } + + /** + * Send message to app. + * + * @param {Window} win Window. + * @param {string} type Type. + * @param {Object} data Data. + */ + sendMessage( win, type, data = {} ) { + win.postMessage( + { + type, + ...data, + ampPairedBrowsing: true, + }, + isAmpWindow( win ) ? this.currentAmpUrl : this.currentNonAmpUrl, + ); + } + + /** + * Receive message. + * + * @param {MessageEvent} event + */ + receiveMessage( event ) { + if ( ! event.data || ! event.data.type || ! event.data.ampPairedBrowsing || ! event.source ) { + return; + } + + if ( ! isAmpWindow( event.source ) && ! isNonAmpWindow( event.source ) ) { + return; + } + + switch ( event.data.type ) { + case 'heartbeat': + this.receiveHeartbeat( event.data, event.source ); + break; + case 'scroll': + this.receiveScroll( event.data, event.source ); + break; + default: + } } /** * Add event listeners for buttons on disconnect overlay. + * + * @todo Revisit. */ addDisconnectButtonListeners() { // The 'Exit' button navigates the parent window to the URL of the disconnected client. @@ -98,33 +201,24 @@ class PairedBrowsingApp { return [ new Promise( ( resolve ) => { this.nonAmpIframe.addEventListener( 'load', () => { - this.toggleDisconnectOverlay( this.nonAmpIframe ); + //this.toggleDisconnectOverlay( this.nonAmpIframe ); resolve(); } ); } ), new Promise( ( resolve ) => { this.ampIframe.addEventListener( 'load', () => { - this.toggleDisconnectOverlay( this.ampIframe ); + //this.toggleDisconnectOverlay( this.ampIframe ); resolve(); } ); } ), ]; } - /** - * Validates whether or not the window document is AMP compatible. - * - * @param {Document} doc Window document. - * @return {boolean} True if AMP compatible, false if not. - */ - documentIsAmp( doc ) { - return doc.querySelector( 'head > script[src="https://cdn.ampproject.org/v0.js"]' ); - } - /** * Toggles the 'disconnected' overlay for the supplied iframe. * + * @todo Revisit. * @param {HTMLIFrameElement} iframe The iframe that hosts the paired browsing client. */ toggleDisconnectOverlay( iframe ) { @@ -162,6 +256,7 @@ class PairedBrowsingApp { /** * Determines the status of the paired browsing client in an iframe. * + * @todo Revisit. * @param {HTMLIFrameElement} iframe The iframe. */ isClientConnected( iframe ) { @@ -222,43 +317,76 @@ class PairedBrowsingApp { } /** - * Get non-AMP location from a window. + * Replace location. * - * @param {Window} win Window. - * @return {string|null} Non-AMP location. + * @param {HTMLIFrameElement} iframe IFrame Element. + * @param {string} url URL. */ - getWindowNonAmpLocation( win ) { - if ( this.documentIsAmp( win.document ) ) { - const canonicalLink = win.document.querySelector( 'head > link[rel=canonical]' ); - return canonicalLink ? canonicalLink.href : null; + replaceLocation( iframe, url ) { + // @todo If disconneted we canot send the replaceLocation message. + + if ( iframe === this.ampIframe ) { + this.ampWindowLoading = true; + } else { + this.nonAmpWindowLoading = true; } - return win.location.href; + + this.sendMessage( + iframe.contentWindow, + 'replaceLocation', + { href: url }, + ); } /** - * Registers the provided client window with its parent, so that it can be managed by it. + * Receive scroll. * - * @param {Window} win Document window. + * @param {Object} data Data. + * @param {boolean} data.x X position. + * @param {string|null} data.y Y position. + * @param {Window} sourceWindow The source window. */ - registerClientWindow( win ) { - let oppositeWindow; - const isAmp = this.documentIsAmp( win.document ); - - const amphtmlLink = win.document.querySelector( 'head > link[rel=amphtml]' ); - const canonicalLink = win.document.querySelector( 'head > link[rel=canonical]' ); + receiveScroll( { x, y }, sourceWindow ) { + // Rely on scroll event to determine initially-active iframe before mouse first moves. + if ( ! this.activeIframe ) { + this.activeIframe = isAmpWindow( sourceWindow ) + ? this.ampIframe + : this.nonAmpIframe; + } - let ampUrl, nonAmpUrl; - if ( isAmp ) { - ampUrl = win.location.href; - nonAmpUrl = canonicalLink ? canonicalLink.href : null; - } else { - nonAmpUrl = win.location.href; - ampUrl = amphtmlLink ? amphtmlLink.href : null; + // Ignore scroll events from the non-active iframe. + if ( ! this.activeIframe || sourceWindow !== this.activeIframe.contentWindow ) { + return; } - if ( win === this.ampIframe.contentWindow ) { - if ( ! isAmp ) { - if ( this.urlHasValidationErrorQueryVar( win.location.href ) ) { + const otherWindow = isAmpWindow( sourceWindow ) + ? this.nonAmpIframe.contentWindow + : this.ampIframe.contentWindow; + this.sendMessage( otherWindow, 'scroll', { x, y } ); + } + + /** + * Receive heartbeat. + * + * @param {Object} data Data. + * @param {boolean} data.isAmpDocument Whether the document is actually an AMP page. + * @param {string|null} data.ampUrl The AMP URL. + * @param {string|null} data.nonAmpUrl The non-AMP URL. + * @param {string} data.documentTitle The canonical link URL if present. + * @param {Window} sourceWindow The source window. + */ + receiveHeartbeat( { isAmpDocument, ampUrl, nonAmpUrl, documentTitle }, sourceWindow ) { + const isAmpSource = isAmpWindow( sourceWindow ); + const sourceIframe = isAmpSource ? this.ampIframe : this.nonAmpIframe; + + if ( isAmpSource ) { + // Stop if the URL has not changed. + if ( this.currentAmpUrl === ampUrl ) { + return; + } + + if ( ! isAmpDocument ) { + if ( this.urlHasValidationErrorQueryVar( ampUrl ) ) { /* * If the AMP page has validation errors, mark the page as invalid so that the * 'disconnected' overlay can be shown. @@ -268,7 +396,7 @@ class PairedBrowsingApp { return; } else if ( ampUrl ) { // Force the AMP iframe to always have an AMP URL, if an AMP version is available. - win.location.replace( ampUrl ); + this.replaceLocation( sourceIframe, ampUrl ); return; } @@ -278,69 +406,68 @@ class PairedBrowsingApp { * overlay. */ this.ampPageHasErrors = true; - this.toggleDisconnectOverlay( this.ampIframe ); + // this.toggleDisconnectOverlay( this.ampIframe ); return; } + this.currentAmpUrl = ampUrl; + // Update the AMP link above the iframe used for exiting paired browsing. - this.ampLink.href = removeQueryArgs( this.ampIframe.contentWindow.location.href, noampQueryVar ); + this.ampLink.href = removeQueryArgs( ampUrl, noampQueryVar ); this.ampPageHasErrors = false; - oppositeWindow = this.nonAmpIframe.contentWindow; } else { + // Stop if the URL has not changed. + if ( this.currentNonAmpUrl === nonAmpUrl ) { + return; + } + // Force the non-AMP iframe to always have a non-AMP URL. - if ( isAmp ) { - win.location.replace( this.purgeRemovableQueryVars( nonAmpUrl ) ); + if ( isAmpDocument ) { + this.replaceLocation( sourceIframe, nonAmpUrl ); return; } + this.currentNonAmpUrl = nonAmpUrl; + // Update the non-AMP link above the iframe used for exiting paired browsing. this.nonAmpLink.href = addQueryArgs( - this.nonAmpIframe.contentWindow.location.href, + nonAmpUrl, { [ noampQueryVar ]: noampMobile }, ); - - oppositeWindow = this.ampIframe.contentWindow; } - // Synchronize scrolling from current window to its opposite. - win.addEventListener( - 'scroll', - () => { - if ( oppositeWindow && oppositeWindow.ampPairedBrowsingClient && oppositeWindow.scrollTo ) { - oppositeWindow.scrollTo( win.scrollX, win.scrollY ); - } - }, - { passive: true }, - ); - - // Scrolling is not synchronized if `scroll-behavior` is set to `smooth`. - win.document.documentElement.style.setProperty( 'scroll-behavior', 'auto', 'important' ); - // Make sure the opposite iframe is set to match. + const thisCurrentUrl = isAmpSource ? nonAmpUrl : ampUrl; + const otherCurrentUrl = isAmpSource ? this.currentNonAmpUrl : this.currentAmpUrl; + if ( - oppositeWindow && - oppositeWindow.location && - ( - this.purgeRemovableQueryVars( this.removeUrlHash( this.getWindowNonAmpLocation( oppositeWindow ) ) ) !== - this.purgeRemovableQueryVars( this.removeUrlHash( this.getWindowNonAmpLocation( win ) ) ) - ) + this.purgeRemovableQueryVars( this.removeUrlHash( thisCurrentUrl ) ) !== + this.purgeRemovableQueryVars( this.removeUrlHash( otherCurrentUrl ) ) ) { - const url = oppositeWindow === this.ampIframe.contentWindow - ? ampUrl - : nonAmpUrl; - - oppositeWindow.location.replace( url ); + const url = isAmpSource + ? nonAmpUrl + : ampUrl; + this.replaceLocation( + isAmpSource ? this.nonAmpIframe : this.ampIframe, + this.purgeRemovableQueryVars( url ), + ); return; } - document.title = documentTitlePrefix + ' ' + win.document.title; + if ( isAmpSource ) { + this.ampWindowLoading = false; + } else { + this.nonAmpWindowLoading = false; + } + + document.title = documentTitlePrefix + ' ' + documentTitle; history.replaceState( {}, '', - this.addPairedBrowsingQueryVar( this.purgeRemovableQueryVars( win.location.href ) ), + this.addPairedBrowsingQueryVar( this.purgeRemovableQueryVars( nonAmpUrl ) ), ); } } diff --git a/assets/src/admin/paired-browsing/client.js b/assets/src/admin/paired-browsing/client.js index c54c8779f0f..646a155c33f 100644 --- a/assets/src/admin/paired-browsing/client.js +++ b/assets/src/admin/paired-browsing/client.js @@ -3,33 +3,168 @@ */ import domReady from '@wordpress/dom-ready'; -const { parent } = window; +/** + * Internal dependencies + */ +import { isNonAmpWindow, isAmpWindow } from './utils'; -if ( parent.pairedBrowsingApp ) { - window.ampPairedBrowsingClient = true; - const app = parent.pairedBrowsingApp; +const { parent, ampPairedBrowsingClientData } = window; +const { ampUrl, nonAmpUrl } = ampPairedBrowsingClientData; - app.registerClientWindow( window ); +/** + * Validates whether or not the window document is AMP compatible. + * + * @return {boolean} True if AMP compatible, false if not. + */ +function documentIsAmp() { + return Boolean( document.querySelector( 'head > script[src="https://cdn.ampproject.org/v0.js"]' ) ); +} - domReady( () => { - if ( app.documentIsAmp( document ) ) { - // Hide the paired browsing menu item. - const pairedBrowsingMenuItem = document.getElementById( 'wp-admin-bar-amp-paired-browsing' ); - if ( pairedBrowsingMenuItem ) { - pairedBrowsingMenuItem.remove(); - } - - // Hide menu item to view non-AMP version. - const ampViewBrowsingItem = document.getElementById( 'wp-admin-bar-amp-view' ); - if ( ampViewBrowsingItem ) { - ampViewBrowsingItem.remove(); - } - } else { - /** - * No need to show the AMP menu in the Non-AMP window. - */ - const ampMenuItem = document.getElementById( 'wp-admin-bar-amp' ); - ampMenuItem.remove(); +// /** +// * Get amphtml link URL. +// * +// * @return {string|null} URL or null if link not present. +// */ +// function getAmphtmlLinkHref() { +// const link = document.querySelector( 'head > link[rel=amphtml]' ); +// return link ? link.href : null; +// } +// +// /** +// * Get canonical link URL. +// * +// * @return {string|null} URL or null if link not present. +// */ +// function getCanonicalLinkHref() { +// const link = document.querySelector( 'head > link[rel=canonical]' ); +// return link ? link.href : null; +// } + +/** + * Modify document for paired browsing. + */ +function modifyDocumentForPairedBrowsing() { + // Scrolling is not synchronized if `scroll-behavior` is set to `smooth`. + document.documentElement.style.setProperty( 'scroll-behavior', 'auto', 'important' ); + + if ( documentIsAmp() ) { + // Hide the paired browsing menu item. + const pairedBrowsingMenuItem = document.getElementById( 'wp-admin-bar-amp-paired-browsing' ); + if ( pairedBrowsingMenuItem ) { + pairedBrowsingMenuItem.remove(); } + + // Hide menu item to view non-AMP version. + const ampViewBrowsingItem = document.getElementById( 'wp-admin-bar-amp-view' ); + if ( ampViewBrowsingItem ) { + ampViewBrowsingItem.remove(); + } + } else { + // No need to show the AMP menu in the Non-AMP window. + const ampMenuItem = document.getElementById( 'wp-admin-bar-amp' ); + ampMenuItem.remove(); + } +} + +/** + * Send message to app. + * + * @param {Window} win Window. + * @param {string} type Type. + * @param {Object} data Data. + */ +function sendMessage( win, type, data ) { + win.postMessage( + { + type, + ...data, + ampPairedBrowsing: true, + }, + nonAmpUrl, // Because the paired browsing app is accessed via the canonical URL. + ); +} + +/** + * Receive message. + * + * @param {MessageEvent} event + */ +function receiveMessage( event ) { + if ( ! event.data || ! event.data.ampPairedBrowsing || ! event.data.type || ! event.source ) { + return; + } + switch ( event.data.type ) { + case 'scroll': + receiveScroll( event.data ); + break; + case 'replaceLocation': + receiveReplaceLocation( event.data ); + break; + default: + } +} + +/** + * Send scroll. + */ +function sendScroll() { + sendMessage( + parent, + 'scroll', + { + x: window.scrollX, + y: window.scrollY, + }, + ); +} + +/** + * Receive scroll. + * + * @param {Object} data + * @param {number} data.x + * @param {number} data.y + */ +function receiveScroll( { x, y } ) { + window.scrollTo( x, y ); +} + +/** + * Receive replace location. + * + * @param {string} href + */ +function receiveReplaceLocation( { href } ) { + window.location.replace( href ); +} + +/** + * Send heartbeat. + * + * @see https://github.com/WordPress/wordpress-develop/blob/7a16c4d5809507bbfa9eb0f95178092492b04727/src/js/_enqueues/wp/customize/controls.js#L6679-L6727 + */ +function sendHeartbeat() { + sendMessage( + parent, + 'heartbeat', + { + isAmpDocument: documentIsAmp(), + //locationHref: window.location.href, + ampUrl, + nonAmpUrl, + documentTitle: document.title, + }, + ); +} + +if ( isNonAmpWindow( window ) || isAmpWindow( window ) ) { + domReady( () => { + modifyDocumentForPairedBrowsing(); + + window.addEventListener( 'message', receiveMessage ); + window.addEventListener( 'scroll', sendScroll, { passive: true } ); + + sendHeartbeat(); + setInterval( sendHeartbeat, 1000 ); } ); } diff --git a/assets/src/admin/paired-browsing/utils.js b/assets/src/admin/paired-browsing/utils.js new file mode 100644 index 00000000000..87cba9e8658 --- /dev/null +++ b/assets/src/admin/paired-browsing/utils.js @@ -0,0 +1,20 @@ + +/** + * Return whether the window is for the non-AMP page. + * + * @param {Window} win Window. + * @return {boolean} Whether non-AMP window. + */ +export function isNonAmpWindow( win ) { + return win.name === 'paired-browsing-non-amp'; +} + +/** + * Return whether the window is for the AMP page. + * + * @param {Window} win Window. + * @return {boolean} Whether AMP window. + */ +export function isAmpWindow( win ) { + return win.name === 'paired-browsing-amp'; +} diff --git a/includes/amp-helper-functions.php b/includes/amp-helper-functions.php index cabde0e6f7f..d3b03c08aed 100644 --- a/includes/amp-helper-functions.php +++ b/includes/amp-helper-functions.php @@ -1432,6 +1432,9 @@ function amp_get_content_sanitizers( $post = null ) { */ $dev_mode_xpaths = (array) apply_filters( 'amp_dev_mode_element_xpaths', [] ); + // Paired browsing paths. + $dev_mode_xpaths[] = '//script[ contains( text(), "ampPairedBrowsingClient" ) ]'; + if ( is_admin_bar_showing() ) { $dev_mode_xpaths[] = '//*[ @id = "wpadminbar" ]'; $dev_mode_xpaths[] = '//*[ @id = "wpadminbar" ]//*'; diff --git a/includes/class-amp-theme-support.php b/includes/class-amp-theme-support.php index 33ef8ae76b5..bf251a2e0ea 100644 --- a/includes/class-amp-theme-support.php +++ b/includes/class-amp-theme-support.php @@ -2361,6 +2361,20 @@ public static function setup_paired_browsing_client() { true ); + $is_amp_request = amp_is_request(); + $current_url = amp_get_current_url(); + $amp_url = $is_amp_request ? $current_url : amp_add_paired_endpoint( $current_url ); + $non_amp_url = ! $is_amp_request ? $current_url : amp_remove_paired_endpoint( $current_url ); + + wp_localize_script( + 'amp-paired-browsing-client', + 'ampPairedBrowsingClientData', + [ + 'ampUrl' => $amp_url, + 'nonAmpUrl' => $non_amp_url, + ] + ); + // Mark enqueued script for AMP dev mode so that it is not removed. // @todo Revisit with . add_filter( @@ -2465,7 +2479,7 @@ public static function serve_paired_browsing_experience( $template ) { wp_localize_script( 'amp-paired-browsing-app', - 'app', + 'ampPairedBrowsingAppData', [ 'ampPairedBrowsingQueryVar' => self::PAIRED_BROWSING_QUERY_VAR, 'noampQueryVar' => QueryVar::NOAMP, diff --git a/includes/templates/amp-paired-browsing.php b/includes/templates/amp-paired-browsing.php index fdcd590f4e2..f3807ec6e28 100644 --- a/includes/templates/amp-paired-browsing.php +++ b/includes/templates/amp-paired-browsing.php @@ -64,7 +64,7 @@ @@ -73,7 +73,7 @@