UNPKG

@wordpress/block-editor

Version:
398 lines (361 loc) 11.4 kB
/** * External dependencies */ import clsx from 'clsx'; /** * WordPress dependencies */ import { useState, createPortal, forwardRef, useMemo, useEffect, } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { useMergeRefs, useRefEffect, useDisabled } from '@wordpress/compose'; import { __experimentalStyleProvider as StyleProvider } from '@wordpress/components'; import { useSelect } from '@wordpress/data'; /** * Internal dependencies */ import { useBlockSelectionClearer } from '../block-selection-clearer'; import { useWritingFlow } from '../writing-flow'; import { getCompatibilityStyles } from './get-compatibility-styles'; import { useScaleCanvas } from './use-scale-canvas'; import { store as blockEditorStore } from '../../store'; function bubbleEvent( event, Constructor, frame ) { const init = {}; for ( const key in event ) { init[ key ] = event[ key ]; } // Check if the event is a MouseEvent generated within the iframe. // If so, adjust the coordinates to be relative to the position of // the iframe. This ensures that components such as Draggable // receive coordinates relative to the window, instead of relative // to the iframe. Without this, the Draggable event handler would // result in components "jumping" position as soon as the user // drags over the iframe. if ( event instanceof frame.contentDocument.defaultView.MouseEvent ) { const rect = frame.getBoundingClientRect(); init.clientX += rect.left; init.clientY += rect.top; } const newEvent = new Constructor( event.type, init ); if ( init.defaultPrevented ) { newEvent.preventDefault(); } const cancelled = ! frame.dispatchEvent( newEvent ); if ( cancelled ) { event.preventDefault(); } } /** * Bubbles some event types (keydown, keypress, and dragover) to parent document * document to ensure that the keyboard shortcuts and drag and drop work. * * Ideally, we should remove event bubbling in the future. Keyboard shortcuts * should be context dependent, e.g. actions on blocks like Cmd+A should not * work globally outside the block editor. * * @param {Document} iframeDocument Document to attach listeners to. */ function useBubbleEvents( iframeDocument ) { return useRefEffect( () => { const { defaultView } = iframeDocument; if ( ! defaultView ) { return; } const { frameElement } = defaultView; const html = iframeDocument.documentElement; const eventTypes = [ 'dragover', 'mousemove' ]; const handlers = {}; for ( const name of eventTypes ) { handlers[ name ] = ( event ) => { const prototype = Object.getPrototypeOf( event ); const constructorName = prototype.constructor.name; const Constructor = window[ constructorName ]; bubbleEvent( event, Constructor, frameElement ); }; html.addEventListener( name, handlers[ name ] ); } return () => { for ( const name of eventTypes ) { html.removeEventListener( name, handlers[ name ] ); } }; } ); } function Iframe( { contentRef, children, tabIndex = 0, scale = 1, frameSize = 0, readonly, forwardedRef: ref, title = __( 'Editor canvas' ), ...props } ) { const { resolvedAssets, isPreviewMode } = useSelect( ( select ) => { const { getSettings } = select( blockEditorStore ); const settings = getSettings(); return { resolvedAssets: settings.__unstableResolvedAssets, isPreviewMode: settings.isPreviewMode, }; }, [] ); const { styles = '', scripts = '' } = resolvedAssets; /** @type {[Document, import('react').Dispatch<Document>]} */ const [ iframeDocument, setIframeDocument ] = useState(); const [ bodyClasses, setBodyClasses ] = useState( [] ); const clearerRef = useBlockSelectionClearer(); const [ before, writingFlowRef, after ] = useWritingFlow(); const setRef = useRefEffect( ( node ) => { node._load = () => { setIframeDocument( node.contentDocument ); }; let iFrameDocument; // Prevent the default browser action for files dropped outside of dropzones. function preventFileDropDefault( event ) { event.preventDefault(); } const { ownerDocument } = node; // Ideally ALL classes that are added through get_body_class should // be added in the editor too, which we'll somehow have to get from // the server in the future (which will run the PHP filters). setBodyClasses( Array.from( ownerDocument.body.classList ).filter( ( name ) => name.startsWith( 'admin-color-' ) || name.startsWith( 'post-type-' ) || name === 'wp-embed-responsive' ) ); function onLoad() { const { contentDocument } = node; const { documentElement } = contentDocument; iFrameDocument = contentDocument; documentElement.classList.add( 'block-editor-iframe__html' ); clearerRef( documentElement ); contentDocument.dir = ownerDocument.dir; for ( const compatStyle of getCompatibilityStyles() ) { if ( contentDocument.getElementById( compatStyle.id ) ) { continue; } contentDocument.head.appendChild( compatStyle.cloneNode( true ) ); if ( ! isPreviewMode ) { // eslint-disable-next-line no-console console.warn( `${ compatStyle.id } was added to the iframe incorrectly. Please use block.json or enqueue_block_assets to add styles to the iframe.`, compatStyle ); } } iFrameDocument.addEventListener( 'dragover', preventFileDropDefault, false ); iFrameDocument.addEventListener( 'drop', preventFileDropDefault, false ); // Prevent clicks on links from navigating away. Note that links // inside `contenteditable` are already disabled by the browser, so // this is for links in blocks outside of `contenteditable`. iFrameDocument.addEventListener( 'click', ( event ) => { if ( event.target.tagName === 'A' ) { event.preventDefault(); // Appending a hash to the current URL will not reload the // page. This is useful for e.g. footnotes. const href = event.target.getAttribute( 'href' ); if ( href?.startsWith( '#' ) ) { iFrameDocument.defaultView.location.hash = href.slice( 1 ); } } } ); } node.addEventListener( 'load', onLoad ); return () => { delete node._load; node.removeEventListener( 'load', onLoad ); iFrameDocument?.removeEventListener( 'dragover', preventFileDropDefault ); iFrameDocument?.removeEventListener( 'drop', preventFileDropDefault ); }; }, [] ); const { contentResizeListener, containerResizeListener, isZoomedOut, scaleContainerWidth, } = useScaleCanvas( { scale, frameSize: parseInt( frameSize ), iframeDocument, } ); const disabledRef = useDisabled( { isDisabled: ! readonly } ); const bodyRef = useMergeRefs( [ useBubbleEvents( iframeDocument ), contentRef, clearerRef, writingFlowRef, disabledRef, ] ); // Correct doctype is required to enable rendering in standards // mode. Also preload the styles to avoid a flash of unstyled // content. const html = `<!doctype html> <html> <head> <meta charset="utf-8"> <base href="${ window.location.origin }"> <script>window.frameElement._load()</script> <style> html{ height: auto !important; min-height: 100%; } /* Lowest specificity to not override global styles */ :where(body) { margin: 0; /* Default background color in case zoom out mode background colors the html element */ background-color: white; } </style> ${ styles } ${ scripts } </head> <body> <script>document.currentScript.parentElement.remove()</script> </body> </html>`; const [ src, cleanup ] = useMemo( () => { const _src = URL.createObjectURL( new window.Blob( [ html ], { type: 'text/html' } ) ); return [ _src, () => URL.revokeObjectURL( _src ) ]; }, [ html ] ); useEffect( () => cleanup, [ cleanup ] ); // Make sure to not render the before and after focusable div elements in view // mode. They're only needed to capture focus in edit mode. const shouldRenderFocusCaptureElements = tabIndex >= 0 && ! isPreviewMode; const iframe = ( <> { shouldRenderFocusCaptureElements && before } { /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */ } <iframe { ...props } style={ { ...props.style, height: props.style?.height, border: 0, } } ref={ useMergeRefs( [ ref, setRef ] ) } tabIndex={ tabIndex } // Correct doctype is required to enable rendering in standards // mode. Also preload the styles to avoid a flash of unstyled // content. src={ src } title={ title } onKeyDown={ ( event ) => { if ( props.onKeyDown ) { props.onKeyDown( event ); } // If the event originates from inside the iframe, it means // it bubbled through the portal, but only with React // events. We need to to bubble native events as well, // though by doing so we also trigger another React event, // so we need to stop the propagation of this event to avoid // duplication. if ( event.currentTarget.ownerDocument !== event.target.ownerDocument ) { // We should only stop propagation of the React event, // the native event should further bubble inside the // iframe to the document and window. // Alternatively, we could consider redispatching the // native event in the iframe. const { stopPropagation } = event.nativeEvent; event.nativeEvent.stopPropagation = () => {}; event.stopPropagation(); event.nativeEvent.stopPropagation = stopPropagation; bubbleEvent( event, window.KeyboardEvent, event.currentTarget ); } } } > { iframeDocument && createPortal( // We want to prevent React events from bubbling through the iframe // we bubble these manually. /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */ <body ref={ bodyRef } className={ clsx( 'block-editor-iframe__body', 'editor-styles-wrapper', ...bodyClasses ) } > { contentResizeListener } <StyleProvider document={ iframeDocument }> { children } </StyleProvider> </body>, iframeDocument.documentElement ) } </iframe> { shouldRenderFocusCaptureElements && after } </> ); return ( <div className="block-editor-iframe__container"> { containerResizeListener } <div className={ clsx( 'block-editor-iframe__scale-container', isZoomedOut && 'is-zoomed-out' ) } style={ { '--wp-block-editor-iframe-zoom-out-scale-container-width': isZoomedOut && `${ scaleContainerWidth }px`, } } > { iframe } </div> </div> ); } function IframeIfReady( props, ref ) { const isInitialised = useSelect( ( select ) => select( blockEditorStore ).getSettings().__internalIsInitialized, [] ); // We shouldn't render the iframe until the editor settings are initialised. // The initial settings are needed to get the styles for the srcDoc, which // cannot be changed after the iframe is mounted. srcDoc is used to to set // the initial iframe HTML, which is required to avoid a flash of unstyled // content. if ( ! isInitialised ) { return null; } return <Iframe { ...props } forwardedRef={ ref } />; } export default forwardRef( IframeIfReady );