UNPKG

@wordpress/editor

Version:
312 lines (280 loc) 9.88 kB
import { renderToString } from '@wordpress/element'; import { Button, MenuItem, Path, SVG } from '@wordpress/components'; import { __, _x } from '@wordpress/i18n'; import { useSelect, useDispatch } from '@wordpress/data'; import { applyFilters } from '@wordpress/hooks'; import { store as coreStore } from '@wordpress/core-data'; import { external } from '@wordpress/icons'; import { VisuallyHidden } from '@wordpress/ui'; import { store as editorStore } from '../../store'; function buildInterstitialMarkup() { let markup = renderToString( <div className="editor-post-preview-button__interstitial-message"> <SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"> <Path className="outer" d="M48 12c19.9 0 36 16.1 36 36S67.9 84 48 84 12 67.9 12 48s16.1-36 36-36" fill="none" /> <Path className="inner" d="M69.5 46.4c0-3.9-1.4-6.7-2.6-8.8-1.6-2.6-3.1-4.9-3.1-7.5 0-2.9 2.2-5.7 5.4-5.7h.4C63.9 19.2 56.4 16 48 16c-11.2 0-21 5.7-26.7 14.4h2.1c3.3 0 8.5-.4 8.5-.4 1.7-.1 1.9 2.4.2 2.6 0 0-1.7.2-3.7.3L40 67.5l7-20.9L42 33c-1.7-.1-3.3-.3-3.3-.3-1.7-.1-1.5-2.7.2-2.6 0 0 5.3.4 8.4.4 3.3 0 8.5-.4 8.5-.4 1.7-.1 1.9 2.4.2 2.6 0 0-1.7.2-3.7.3l11.5 34.3 3.3-10.4c1.6-4.5 2.4-7.8 2.4-10.5zM16.1 48c0 12.6 7.3 23.5 18 28.7L18.8 35c-1.7 4-2.7 8.4-2.7 13zm32.5 2.8L39 78.6c2.9.8 5.9 1.3 9 1.3 3.7 0 7.3-.6 10.6-1.8-.1-.1-.2-.3-.2-.4l-9.8-26.9zM76.2 36c0 3.2-.6 6.9-2.4 11.4L64 75.6c9.5-5.5 15.9-15.8 15.9-27.6 0-5.5-1.4-10.8-3.9-15.3.1 1 .2 2.1.2 3.3z" fill="none" /> </SVG> <p>{ __( 'Generating preview…' ) }</p> </div> ); markup += ` <style> body { margin: 0; } .editor-post-preview-button__interstitial-message { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; width: 100vw; } @-webkit-keyframes paint { 0% { stroke-dashoffset: 0; } } @-moz-keyframes paint { 0% { stroke-dashoffset: 0; } } @-o-keyframes paint { 0% { stroke-dashoffset: 0; } } @keyframes paint { 0% { stroke-dashoffset: 0; } } .editor-post-preview-button__interstitial-message svg { width: 192px; height: 192px; stroke: #555d66; stroke-width: 0.75; } .editor-post-preview-button__interstitial-message svg .outer, .editor-post-preview-button__interstitial-message svg .inner { stroke-dasharray: 280; stroke-dashoffset: 280; -webkit-animation: paint 1.5s ease infinite alternate; -moz-animation: paint 1.5s ease infinite alternate; -o-animation: paint 1.5s ease infinite alternate; animation: paint 1.5s ease infinite alternate; } p { text-align: center; font-family: -apple-system, system-ui, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; } </style> `; /** * Filters the interstitial message shown when generating previews. * * @param {string} markup The preview interstitial markup. */ markup = applyFilters( 'editor.PostPreview.interstitialMarkup', markup ); return markup; } function writeInterstitialMessage( targetDocument, markup ) { targetDocument.write( markup ); targetDocument.title = __( 'Generating preview…' ); targetDocument.close(); } /** * Resolves the preview window's `document`, working around * `Document-Isolation-Policy` (DIP) isolation. * * The editor screen is served with `Document-Isolation-Policy: * isolate-and-credentialless` to enable cross-origin isolation. This places the * editor tab and an already-open preview tab in separate agent clusters, so * synchronous access to a reused preview tab's `document` throws a * `SecurityError`. Navigating the reused tab back to `about:blank` returns it to * the opener's agent cluster and restores access. That navigation is * asynchronous and we can't attach a cross-isolation `load` listener, so poll * the `document` access (the operation that throws) until it succeeds, up to a * short timeout. * * @param {Window} previewWindow The preview window/tab. * * @return {?Document} The reachable preview document, or `null` if it never * becomes reachable within the timeout. */ async function getPreviewDocument( previewWindow ) { // A freshly opened tab is already on `about:blank` and accessible, so this // succeeds on the first preview without any reset. try { return previewWindow.document; } catch { // The reused preview tab is isolated from the editor; reset it below. } previewWindow.location = 'about:blank'; const timeoutMs = 1000; const intervalMs = 50; const deadline = Date.now() + timeoutMs; do { await new Promise( ( resolve ) => setTimeout( resolve, intervalMs ) ); try { return previewWindow.document; } catch { // Navigation to `about:blank` hasn't completed yet; keep polling. } } while ( Date.now() < deadline ); return null; } /** * Writes the preview interstitial into the preview window, working around * `Document-Isolation-Policy` (DIP) isolation. * * The interstitial is a progressive enhancement: if the document never becomes * reachable we simply skip it, and the caller still navigates the preview to the * real content. * * @param {Window} previewWindow The preview window/tab. */ async function writeInterstitialIntoPreviewWindow( previewWindow ) { const previewDocument = await getPreviewDocument( previewWindow ); if ( previewDocument ) { writeInterstitialMessage( previewDocument, buildInterstitialMarkup() ); } } function usePostPreviewProps( { forceIsAutosaveable, onPreview } ) { const { postId, currentPostLink, previewLink, isSaveable, isViewable } = useSelect( ( select ) => { const editor = select( editorStore ); const core = select( coreStore ); const postType = core.getPostType( editor.getCurrentPostType( 'type' ) ); const canView = postType?.viewable ?? false; if ( ! canView ) { return { isViewable: canView }; } return { postId: editor.getCurrentPostId(), currentPostLink: editor.getCurrentPostAttribute( 'link' ), previewLink: editor.getEditedPostPreviewLink(), isSaveable: editor.isEditedPostSaveable(), isViewable: canView, }; }, [] ); const { __unstableSaveForPreview } = useDispatch( editorStore ); if ( ! isViewable ) { return null; } const target = `wp-preview-${ postId }`; const handlePreviewClick = async ( event ) => { // Preserve native link semantics with `href` and `target`, but intercept the // click because the final preview URL may depend on an asynchronous save. // Opening the named window synchronously also prevents popup blocking and // ensures subsequent previews reuse the same tab. // https://github.com/WordPress/gutenberg/pull/8330 event.preventDefault(); // Open up a Preview tab if needed. This is where we'll show the preview. const previewWindow = window.open( '', target ); // Focus the Preview tab. This might not do anything, depending on the browser's // and user's preferences. // https://html.spec.whatwg.org/multipage/interaction.html#dom-window-focus previewWindow.focus(); await writeInterstitialIntoPreviewWindow( previewWindow ); const link = await __unstableSaveForPreview( { forceIsAutosaveable } ); previewWindow.location = link; onPreview?.(); }; // Link to the `?preview=true` URL if we have it, since this lets us see // changes that were autosaved since the post was last published. Otherwise, // just link to the post's URL. const href = previewLink || currentPostLink; return { href, target, disabled: ! isSaveable, onClick: handlePreviewClick, }; } /** * Renders the post preview action as a menu item. * * @param {Object} props The component props. * @param {boolean} props.forceIsAutosaveable Whether to force autosave. * @param {Function} props.onPreview The callback function for the preview event. * * @return {React.ReactNode} The rendered menu item. */ export function PostPreviewMenuItem( { forceIsAutosaveable, onPreview } ) { const previewProps = usePostPreviewProps( { forceIsAutosaveable, onPreview, } ); if ( ! previewProps ) { return null; } return ( <MenuItem icon={ external } { ...previewProps }> { __( 'Preview in new tab' ) } </MenuItem> ); } /** * Renders a button that opens a new window or tab for the preview, * writes the interstitial message to this window, and then navigates * to the actual preview link. The button is not rendered if the post * is not viewable and disabled if the post is not saveable. * * @param {Object} props The component props. * @param {string} props.className The class name for the button. * @param {string} props.textContent The text content for the button. * @param {boolean} props.forceIsAutosaveable Whether to force autosave. * @param {string} props.role The role attribute for the button. * @param {Function} props.onPreview The callback function for preview event. * * @return {React.ReactNode} The rendered button component. */ export default function PostPreviewButton( { className, textContent, forceIsAutosaveable, role, onPreview, } ) { const previewProps = usePostPreviewProps( { forceIsAutosaveable, onPreview, } ); if ( ! previewProps ) { return null; } return ( <Button variant={ ! className ? 'tertiary' : undefined } className={ className || 'editor-post-preview' } role={ role } size="compact" accessibleWhenDisabled { ...previewProps } > { textContent || ( <> { _x( 'Preview', 'imperative verb' ) } <VisuallyHidden render={ <span /> }> { /* translators: accessibility text */ __( '(opens in a new tab)' ) } </VisuallyHidden> </> ) } </Button> ); }