diff --git a/gutenberg.php b/gutenberg.php index 4dc2ea3e862c23..511310f495de86 100644 --- a/gutenberg.php +++ b/gutenberg.php @@ -159,3 +159,25 @@ function gutenberg_rest_nonce() { exit( wp_create_nonce( 'wp_rest' ) ); } add_action( 'wp_ajax_gutenberg_rest_nonce', 'gutenberg_rest_nonce' ); + +/** + * Prints TinyMCE scripts when we're outside of the block editor. + * Otherwise, use the default TinyMCE script printing behavior. + * + * @since 7.9.1 + */ +function gutenberg_print_tinymce_scripts() { + $current_screen = get_current_screen(); + if ( ! $current_screen->is_block_editor() ) { + // maybe also need to check a setting/feature flag? Put this behind phase 2? + wp_print_tinymce_scripts(); + } else { + echo ""; + echo "\n"; + } +} +remove_action( 'print_tinymce_scripts', 'wp_print_tinymce_scripts' ); +add_action( 'print_tinymce_scripts', 'gutenberg_print_tinymce_scripts' ); diff --git a/lib/class-wp-rest-dependencies-controller.php b/lib/class-wp-rest-dependencies-controller.php new file mode 100755 index 00000000000000..66a5cb41b78ec9 --- /dev/null +++ b/lib/class-wp-rest-dependencies-controller.php @@ -0,0 +1,288 @@ +namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + 'schema' => array( $this, 'get_item_schema' ), + ) + ); + + $get_item_args = array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\w-]+)', + array( + 'args' => array( + 'handle' => array( + 'description' => __( 'Unique identifier for the object.', 'gutenberg' ), + 'type' => 'string', + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => $get_item_args, + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Get list of dependencies. + * + * @param WP_REST_Request $request Request. + * + * @return array|WP_Error|WP_REST_Response + */ + public function get_items( $request ) { + $data = []; + $handle = $request['dependency']; + $filter = []; + if ( $handle ) { + $this->object->all_deps( $handle ); + $filter = $this->object->to_do; + } + + if ( $handle ) { + foreach ( $filter as $dependency_handle ) { + foreach ( $this->object->registered as $dependency ) { + if ( $dependency_handle === $dependency->handle ) { + $item = $this->prepare_item_for_response( $dependency, $request ); + $data[] = $this->prepare_response_for_collection( $item ); + } + } + } + } else { + foreach ( $this->object->registered as $dependency ) { + $item = $this->prepare_item_for_response( $dependency, $request ); + $data[] = $this->prepare_response_for_collection( $item ); + } + } + + return $data; + } + + /** + * Get a single dependency. + * + * @param WP_REST_Request $request Request. + * + * @return array|mixed|WP_Error|WP_REST_Response + */ + public function get_item( $request ) { + if ( ! isset( $this->object->registered[ $request['handle'] ] ) ) { + return []; + } + $dependency = $this->object->registered[ $request['handle'] ]; + $data = $this->prepare_item_for_response( $dependency, $request ); + + return $data; + } + + /** + * Prepare item for response. + * + * @param mixed $dependency Dependency. + * @param WP_REST_Request $request Request. + * + * @return mixed|WP_Error|WP_REST_Response + */ + public function prepare_item_for_response( $dependency, $request ) { + $dependency->url = $this->get_url( $dependency->src, $dependency->ver, $dependency->handle ); + $response = rest_ensure_response( (array) $dependency ); + $dependencies = $this->prepare_links( $dependency ); + $response->add_links( $dependencies ); + + return $response; + } + + /** + * Permission check. + * + * @param WP_REST_Request $request Request. + * + * @return bool|true|WP_Error + */ + public function get_items_permissions_check( $request ) { + if ( $this->check_handle( $request['dependency'] ) ) { + return true; + } + + return current_user_can( 'manage_options' ); + } + + /** + * Permission check. + * + * @param WP_REST_Request $request Request. + * + * @return bool|true|WP_Error + */ + public function get_item_permissions_check( $request ) { + if ( $this->check_handle( $request['handle'] ) ) { + return true; + } + + return current_user_can( 'manage_options' ); + } + + /** + * Prepare links. + * + * @param object $dependency Dependency. + * + * @return array + */ + protected function prepare_links( $dependency ) { + $base = sprintf( '%s/%s', $this->namespace, $this->rest_base ); + // Entity meta. + $links = array( + 'self' => array( + 'href' => rest_url( trailingslashit( $base ) . $dependency->handle ), + ), + 'collection' => array( + 'href' => rest_url( $base ), + ), + 'deps' => array( + 'href' => rest_url( trailingslashit( $base ) . '?dependency=' . $dependency->handle ), + ), + ); + + return $links; + } + + /** + * Get collection params. + * + * @return array + */ + public function get_collection_params() { + $query_params = parent::get_collection_params(); + $query_params['context']['default'] = 'view'; + + $query_params['dependency'] = array( + 'description' => __( 'Dependency.', 'gutenberg' ), + 'type' => 'array', + ); + + return $query_params; + } + + /** + * Check handle exists and is viewable. + * + * @param string $handle script / style handle. + * + * @return bool + */ + protected function check_handle( $handle ) { + + if ( ! $handle ) { + return false; + } + + // All core assets should be public. + if ( in_array( $handle, $this->get_core_assets(), true ) ) { + return true; + } + + // All block public assets should also be public. + if ( in_array( $handle, $this->block_asset( $this->block_dependency ), true ) ) { + return true; + } + + // All block edit assets should check if user is logged in and has the ability to using the editor. + if ( in_array( $handle, $this->block_asset( $this->editor_block_dependency ), true ) ) { + return current_user_can( 'edit_posts' ); + } + + return false; + } + + /** + * Get core assets. + * + * @return array + */ + public function get_core_assets() { + /* translators: %s: Method name. */ + _doing_it_wrong( sprintf( __( "Method '%s' not implemented. Must be overridden in subclass.", 'gutenberg' ), __METHOD__ ), 'x.x' ); + + return array(); + } + + /** + * Block asset. + * + * @param string $field Field to pluck from list of objects. + * + * @return array + */ + protected function block_asset( $field ) { + if ( ! $field ) { + return array(); + } + + $block_registry = WP_Block_Type_Registry::get_instance(); + $blocks = $block_registry->get_all_registered(); + $handles = wp_list_pluck( $blocks, $field ); + $handles = array_values( $handles ); + $handles = array_filter( $handles ); + + return $handles; + } + +} diff --git a/lib/class-wp-rest-scripts-controller.php b/lib/class-wp-rest-scripts-controller.php new file mode 100755 index 00000000000000..c8590a935d0cc4 --- /dev/null +++ b/lib/class-wp-rest-scripts-controller.php @@ -0,0 +1,57 @@ +namespace = 'wp/v2'; + $this->rest_base = 'scripts'; + $this->editor_block_dependency = 'editor_script'; + $this->block_dependency = 'script'; + $this->object = wp_scripts(); + } + + /** + * Helper to get Script URL. + * + * @param string $src Script URL. + * @param string $ver Version URL. + * @param string $handle Handle name. + * + * @return string + */ + public function get_url( $src, $ver, $handle ) { + if ( ! is_bool( $src ) && ! preg_match( '|^(https?:)?//|', $src ) && ! ( $this->object->content_url && 0 === strpos( $src, $this->object->content_url ) ) ) { + $src = $this->object->base_url . $src; + } + if ( ! empty( $ver ) ) { + $src = add_query_arg( 'ver', $ver, $src ); + } + + /** This filter is documented in wp-includes/class.wp-scripts.php */ + $src = esc_url( apply_filters( 'script_loader_src', $src, $handle ) ); + + return esc_url( $src ); + } + + /** + * Get core assets. + * + * @return array + */ + public function get_core_assets() { + $handles = wp_list_pluck( $this->object->registered, 'handle' ); + $handles = array_values( $handles ); + + return $handles; + } +} diff --git a/lib/class-wp-rest-styles-controller.php b/lib/class-wp-rest-styles-controller.php new file mode 100755 index 00000000000000..2f7f518f726b68 --- /dev/null +++ b/lib/class-wp-rest-styles-controller.php @@ -0,0 +1,47 @@ +namespace = 'wp/v2'; + $this->rest_base = 'styles'; + $this->editor_block_dependency = 'editor_style'; + $this->block_dependency = 'style'; + $this->object = wp_styles(); + } + + /** + * Helper to get Style URL. + * + * @param string $src Style URL. + * @param string $ver Version URL. + * @param string $handle Handle name. + * + * @return string + */ + public function get_url( $src, $ver, $handle ) { + return $this->object->_css_href( $src, $ver, $handle ); + } + + /** + * Get core assets. + * + * @return array + */ + public function get_core_assets() { + $handles = wp_list_pluck( $this->object->registered, 'handle' ); + $handles = array_values( $handles ); + + return $handles; + } +} diff --git a/lib/class-wp-rest-tinymce-i18n-controller.php b/lib/class-wp-rest-tinymce-i18n-controller.php new file mode 100755 index 00000000000000..7577085f418ce9 --- /dev/null +++ b/lib/class-wp-rest-tinymce-i18n-controller.php @@ -0,0 +1,76 @@ + WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_translations' ], + 'permission_callback' => [ $this, 'check_permissions' ], + 'args' => [], + ], + 'schema' => array( $this, 'get_item_schema' ), + ] + ); + } + + /** + * Get the translations for the current user's locale. + * + * _WP_Editors is not included by default so we need to require it + * if the class isn't available. We should probably refactor all the + * TinyMCE translation files out of _WP_Editors. This shouldn't be + * too hard considering they're already all static functions. + * + * @param WP_REST_Request $request Request. + * + * @return array + */ + public function get_translations( $request ) { + if ( ! class_exists( '_WP_Editors', false ) ) { + require ABSPATH . WPINC . '/class-wp-editor.php'; + } + + $mce_locale = _WP_Editors::get_mce_locale(); + $json_only = true; + $baseurl = _WP_Editors::get_baseurl(); + + return [ + "translations" => _WP_Editors::wp_mce_translation( $mce_locale, $json_only ), + "locale" => $mce_locale, + "locale_script_handle" => "$baseurl/langs/$mce_locale.js", + ]; + } + + /** + * This endpoint is only mean to be used from the editor so it can be safely + * restricted only to users that can edit posts. + * + * @param WP_REST_Request $request Request. + * + * @return bool|true|WP_Error + */ + public function check_permissions( $request ) { + return current_user_can( 'edit_posts' ); + } +} diff --git a/lib/load.php b/lib/load.php index 7207dbbed184c6..879a80bf0d615a 100644 --- a/lib/load.php +++ b/lib/load.php @@ -48,6 +48,18 @@ function gutenberg_is_experiment_enabled( $name ) { if ( ! class_exists( 'WP_REST_Menu_Locations_Controller' ) ) { require_once dirname( __FILE__ ) . '/class-wp-rest-menu-locations-controller.php'; } + if ( ! class_exists( 'WP_REST_Dependencies_Controller' ) ) { + require_once dirname( __FILE__ ) . '/class-wp-rest-dependencies-controller.php'; + } + if ( ! class_exists( 'WP_REST_Scripts_Controller' ) ) { + require_once dirname( __FILE__ ) . '/class-wp-rest-scripts-controller.php'; + } + if ( ! class_exists( 'WP_REST_Styles_Controller' ) ) { + require_once dirname( __FILE__ ) . '/class-wp-rest-styles-controller.php'; + } + if ( ! class_exists( 'WP_REST_TinyMCE_I81n_Controller' ) ) { + require_once dirname( __FILE__ ) . '/class-wp-rest-tinymce-i18n-controller.php'; + } /** * End: Include for phase 2 */ diff --git a/lib/rest-api.php b/lib/rest-api.php index eba3685e4cdd7a..27193a31aecc89 100644 --- a/lib/rest-api.php +++ b/lib/rest-api.php @@ -140,6 +140,24 @@ function wp_api_nav_menus_taxonomy_args( $args, $taxonomy ) { } add_filter( 'register_taxonomy_args', 'wp_api_nav_menus_taxonomy_args', 10, 2 ); +/** + * Registers the scripts and styles area REST API routes. + */ +function gutenberg_register_script_style() { + // Scripts. + $controller = new WP_REST_Scripts_Controller(); + $controller->register_routes(); + + // Styles. + $controller = new WP_REST_Styles_Controller(); + $controller->register_routes(); + + // TinyMCE translations + $controller = new WP_REST_TinyMCE_I18n_Controller(); + $controller->register_routes(); +} +add_action( 'rest_api_init', 'gutenberg_register_script_style' ); + /** * Shim for get_sample_permalink() to add support for auto-draft status. * diff --git a/packages/block-library/src/classic/edit.js b/packages/block-library/src/classic/edit.js index 7ea8b295c8aefc..7db36295744fca 100644 --- a/packages/block-library/src/classic/edit.js +++ b/packages/block-library/src/classic/edit.js @@ -5,6 +5,12 @@ import { Component } from '@wordpress/element'; import { __, _x } from '@wordpress/i18n'; import { BACKSPACE, DELETE, F10 } from '@wordpress/keycodes'; +/** + * Internal dependencies + */ +import LazyLoad from './lazy'; +import apiFetch from '@wordpress/api-fetch'; + const { wp } = window; function isTmceEmpty( editor ) { @@ -23,7 +29,7 @@ function isTmceEmpty( editor ) { return /^\n?$/.test( body.innerText || body.textContent ); } -export default class ClassicEdit extends Component { +class ClassicEdit extends Component { constructor( props ) { super( props ); this.initialize = this.initialize.bind( this ); @@ -216,3 +222,25 @@ export default class ClassicEdit extends Component { /* eslint-enable jsx-a11y/no-static-element-interactions */ } } + +const LazyClassicEdit = ( props ) => ( + { + const { + translations, + locale, + locale_script_handle: localeScriptHandle, + } = await apiFetch( { path: '/wp/v2/tinymce-i18n' } ); + + const { tinymce } = window; + tinymce.addI18n( locale, JSON.stringify( translations ) ); + tinymce.ScriptLoader.markDone( localeScriptHandle ); + } } + placeholder={
Loading...
} + > + +
+); + +export default LazyClassicEdit; diff --git a/packages/block-library/src/classic/lazy.js b/packages/block-library/src/classic/lazy.js new file mode 100644 index 00000000000000..de80dd403da3c6 --- /dev/null +++ b/packages/block-library/src/classic/lazy.js @@ -0,0 +1,175 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; + +/** + * External dependencies + */ +import { noop, sum } from 'lodash'; + +/** + * @typedef {'scripts' | 'styles'} WPLazyDependencyType + */ + +/** + * Gets the path corresponding to the dependency type and list of dependency + * handles to load. + * + * @see {WP_REST_Styles_Controller} + * + * @param {WPLazyDependencyType} dependencyType The type of dependency for which to build the path. + * @param {string[]} dependencyHandles List of dependency handles for which to build the path. + */ +const getPath = ( dependencyType, dependencyHandles ) => + `/wp/v2/${ dependencyType }/?${ dependencyHandles.map( + ( handle ) => `dependency=${ handle }` + ) }`; + +/** + * + * @param {WPLazyDependencyType} dependencyType The type of dependency + * @param {(dependencyUri: string) => (HTMLScriptElement | HTMLLinkElement)} createElement A function accepting a URI for a dependency and returning an element to append to head + * + * @return {(dependencies: string[]) => Promise} A file loader function acepting a list of dependencies and returning a Promise which resolves when all the files have been loaded + */ +const createDependencyLoader = ( dependencyType, createElement ) => { + const previouslyLoadedDependencies = new Set(); + + return async ( dependencies ) => { + const dependenciesToLoad = dependencies.filter( + ( handle ) => ! previouslyLoadedDependencies.has( handle ) + ); + + if ( dependenciesToLoad.length === 0 ) { + return 0; + } + + const path = getPath( dependencyType, dependenciesToLoad ); + + const orderedDependenciesWithDependencies = await apiFetch( { path } ); + + /** + * Programatically added `script` tags get parsed and executed as though they + * had `async` on them. That means that if we have script A and B, where B + * depends on A and we add both to the DOM at the same time, there's no + * guaranteed order of execution, B could execute before A and break, even if + * A's script element was added before B. + * + * Therefore, we need to block on each script's loading until the next one down + * the list. + * + * The only problem with this is if you have two final dependencies you're trying + * to load and they have completely separate dependency trees, we will _not_ be + * loading those trees in parallel. This isn't currently an issue, so fixing it + * right now would be a premature optimization. It would probably require rethinking + * how the API returns things (i.e., we should probably return things in an + * actual tree format and represent independent trees as duely separate trees if + * we want to be able to load things in parallel). For now, this will have to do. + */ + for ( const dependency of orderedDependenciesWithDependencies ) { + const { src: dependencyUri } = dependency; + + await new Promise( ( resolve, reject ) => { + const element = createElement( dependencyUri ); + element.onload = resolve; + element.onerror = reject; + document.head.appendChild( element ); + } ); + } + + orderedDependenciesWithDependencies.forEach( ( { handle } ) => + previouslyLoadedDependencies.add( handle ) + ); + + return orderedDependenciesWithDependencies.length; + }; +}; + +const loadScripts = createDependencyLoader( 'scripts', ( dependencyUri ) => { + const scriptElement = document.createElement( 'script' ); + scriptElement.type = 'application/javascript'; + scriptElement.src = dependencyUri; + return scriptElement; +} ); + +const loadStyles = createDependencyLoader( 'styles', ( dependencyUri ) => { + const linkElement = document.createElement( 'link' ); + linkElement.rel = 'stylesheet'; + linkElement.href = dependencyUri; + return linkElement; +} ); + +/** + * A component which blocks the rendering of its children until the dependencies + * declared in scripts and styles have been loaded and the optional onLoaded + * function has successfully resolved. Allows asynchronously loading library + * dependencies for blocks (for example TinyMCE for the Classic Block). + * + * @example + * Given a library called `map-library` which loads an object onto window.mapLibrary, we can initialize this dependency using LazyLoad: + * + * const EmbeddedMapBlockEdit = () => { + * window.mapLibrary.drawMap(); + * }; + * + * const LazyEmbeddedMapBlockEdit = ( props ) => ( + * window.mapLibrary.init() } + * > + * + * + * ); + * + * @param {Object} props + * @param {string[]} props.scripts List of script handles to load for children + * @param {string[]} props.styles List of style handles to load for children + * @param {import('@wordpress/element').WPNode} props.children Children to render after loading dependencies + * @param {import('@wordpress/element').WPNode} props.placeholder Placeholder to render while loading dependencies + * @param {() => Promise} props.onLoaded + * @param {(e: Error) => void} props.onError Function to handle errors while loading dependencies + */ +const LazyLoad = ( { + scripts, + styles, + children, + placeholder, + onLoaded, + onError, +} ) => { + const [ loaded, setLoaded ] = useState( false ); + + Promise.all( [ loadScripts( scripts ), loadStyles( styles ) ] ) + .then( ( loadedCounts ) => { + /** + * skip `onLoaded` if no dependencies were loaded, for example, + * if they were previously loaded + */ + if ( sum( loadedCounts ) > 0 ) { + return onLoaded(); + } + return Promise.resolve(); + } ) + .then( () => { + setLoaded( true ); + } ) + .catch( onError ); + + if ( loaded ) { + return children; + } + + return placeholder; +}; + +LazyLoad.defaultProps = { + scripts: [], + styles: [], + placeholder: null, + onLoaded: () => Promise.resolve(), + onError: noop, +}; + +export default LazyLoad; diff --git a/packages/element/src/react.js b/packages/element/src/react.js index 78752a1b5a179a..03674f04d4815c 100644 --- a/packages/element/src/react.js +++ b/packages/element/src/react.js @@ -46,6 +46,12 @@ import { isString } from 'lodash'; * @typedef {import('react').SyntheticEvent} WPSyntheticEvent */ +/** + * Object containing a React node. + * + * @typedef {import('react').ReactNode} WPNode + */ + /** * Object that provides utilities for dealing with React children. */