@wordpress/editor
Version:
Enhanced block editor for WordPress posts.
312 lines (280 loc) • 9.88 kB
JavaScript
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>
);
}