` tag. */ const JSON_FLAGS = JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP; /** * The hook suffix `add_theme_page()` returned, or null when the page is not registered. * * @var string|null */ private static $hook_suffix = null; /** * Wire the screen up. */ public static function init() { // Registered here rather than on `admin_menu` so the flag exists under WP-CLI, REST // and cron too — `wp companion feature-flag list` reads it from one of those. self::register_feature_flags(); add_action( 'admin_menu', array( __CLASS__, 'add_admin_page' ) ); } /** * Declare the screen's feature flag. */ public static function register_feature_flags() { Feature_Flags::register( self::FEATURE_FLAG, array( 'default' => false, 'description' => 'Edit the newsletter email design in wp-admin, under Appearance.', 'owner' => 'jetpack-newsletter', ) ); } /** * Whether the screen should exist on this site. * * The WordPress.com `email-design-editor` sticker gates the bootstrap endpoint, not this * page: a Jetpack site cannot read stickers without an API call, so the screen carries * its own gate. * * @return bool */ public static function is_enabled() { return Feature_Flags::is_enabled( self::FEATURE_FLAG ); } /** * Add the screen under Appearance. */ public static function add_admin_page() { if ( ! self::is_enabled() ) { return; } self::$hook_suffix = add_theme_page( __( 'Email Design', 'jetpack' ), __( 'Email Design', 'jetpack' ), 'edit_theme_options', self::PAGE_SLUG, array( __CLASS__, 'render' ) ); if ( self::$hook_suffix ) { add_action( 'load-' . self::$hook_suffix, array( __CLASS__, 'on_load' ) ); } } /** * Scope everything else to this one screen. */ public static function on_load() { add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) ); add_filter( 'admin_body_class', array( __CLASS__, 'add_fullscreen_body_class' ) ); } /** * Put the screen in fullscreen mode before the bundle has loaded. * * `wp-editor` hides the admin menu on this class, and the editor sets it from a React * component — so it cannot apply until the bundle has loaded and the bootstrap has answered, * and the page visibly reflows a few seconds in. Setting it here makes fullscreen the layout * from first paint. The admin bar is untouched, which keeps a way out if the editor fails. * * @param string $classes Space-separated admin body classes. * @return string */ public static function add_fullscreen_body_class( $classes ) { return trim( $classes . ' is-fullscreen-mode' ); } /** * Enqueue the editor bundle and the block-editor assets it expects to find. */ public static function enqueue_assets() { $asset_path = JETPACK__PLUGIN_DIR . '_inc/build/email-design-editor.asset.php'; if ( ! file_exists( $asset_path ) ) { return; } $asset = include $asset_path; self::enqueue_block_editor_assets(); // `@woocommerce/email-editor` opts into core's private APIs as `@wordpress/edit-site`, // resolved against the site's `wp-private-apis`. Re-check on a package bump. NL-839 (j). wp_enqueue_script( self::HANDLE, plugins_url( '_inc/build/email-design-editor.js', JETPACK__PLUGIN_FILE ), $asset['dependencies'], $asset['version'], true ); wp_set_script_translations( self::HANDLE, 'jetpack' ); // `wp-editor`, `wp-block-editor` and `wp-preferences` are what lay the editor's frame // out; without them every region stacks into one narrow column. Keep the list to // handles WordPress registers — an unregistered one drops this stylesheet silently, // which is `wp-interface`'s trap. wp_enqueue_style( self::HANDLE, plugins_url( '_inc/build/email-design-editor.css', JETPACK__PLUGIN_FILE ), array( 'wp-components', 'wp-block-editor', 'wp-editor', 'wp-edit-blocks', 'wp-preferences', 'wp-format-library', ), $asset['version'] ); wp_style_add_data( self::HANDLE, 'rtl', 'replace' ); wp_add_inline_style( self::HANDLE, self::get_layout_css() ); wp_add_inline_script( self::HANDLE, 'window.JetpackEmailDesignEditor = ' . wp_json_encode( self::get_screen_data(), self::JSON_FLAGS ) . ';', 'before' ); } /** * Fill the screen with the editor. * * The editor's frame expects a viewport, not the flow of an admin page: inside the usual * wp-admin content column it collapses to a fraction of the height and scrolls its own * regions. Woo avoids this by running on `post.php`, which is already fullscreen. * * @return string */ private static function get_layout_css() { return ' #wpcontent { padding-inline-start: 0; } #wpfooter { display: none; } #' . self::HANDLE . ' { position: fixed; /* Below #wpadminbar (99999) so the editor\'s own popovers and snackbars, which ask for 100000, cannot cover it — this caps everything inside. The admin menu is hidden on this screen, so there is nothing else left to clear. */ z-index: 99990; inset-block: var(--wp-admin--admin-bar--height, 32px) 0; inset-inline: 0; } /* wp-edit-post pins this and the screen does not load it, so without this the snackbar renders in flow beside the header instead of over the canvas. */ #' . self::HANDLE . ' .components-editor-notices__snackbar { position: absolute; inset-block-end: 20px; inset-inline-start: 20px; } '; } /** * Reproduce what the package's `Assets_Manager` does on WooCommerce's own screen. * * None of this happens automatically on a custom admin page: without it the editor * mounts against no block library, no block categories and no server-side block * definitions. See NL-839 (a). * * @todo Firing `enqueue_block_editor_assets` wholesale is the leading suspect for the * second Styles button — it pulls in core's site-editing global styles UI. NL-839 (e). */ private static function enqueue_block_editor_assets() { // Named rather than built from a post: there is no post here, and `get_block_categories()` // hands whatever it gets to filters that type-hint the context. $context = new WP_Block_Editor_Context( array( 'name' => 'jetpack/email-design' ) ); wp_enqueue_media(); do_action( 'enqueue_block_assets' ); do_action( 'enqueue_block_editor_assets' ); wp_enqueue_style( 'wp-edit-blocks' ); wp_enqueue_style( 'wp-format-library' ); wp_add_inline_script( 'wp-blocks', sprintf( 'wp.blocks.setCategories( %s );', wp_json_encode( get_block_categories( $context ), self::JSON_FLAGS ) ), 'after' ); wp_add_inline_script( 'wp-blocks', sprintf( 'wp.blocks.unstable__bootstrapServerSideBlockDefinitions( %s );', wp_json_encode( get_block_editor_server_block_settings(), self::JSON_FLAGS ) ), 'after' ); } /** * What the page hands the bundle, as `window.JetpackEmailDesignEditor`. * * Every WordPress.com id — the template's, the global-styles record's — comes from the * bootstrap response instead, because they are namespaced to the shadow blog's theme and * a locally computed one is right on Simple and wrong everywhere else. See NL-839 (c). * * @return array */ private static function get_screen_data() { return array( 'elementId' => self::HANDLE, 'editorSettings' => self::get_iframe_asset_settings(), // The editor assigns these to `window.location.href` from its header buttons. // Both point at Appearance until the screen has a real entry point (NL-844). 'urls' => array( 'back' => admin_url( 'themes.php' ), 'listings' => admin_url( 'themes.php' ), ), 'userEmail' => wp_get_current_user()->user_email, ); } /** * The two editor settings that describe this installation rather than the design. * * WordPress.com strips both from the bootstrap bundle, because there they would name * WordPress.com's own asset URLs and push them into the site's canvas. * * @return array */ private static function get_iframe_asset_settings() { // Absent before WP 6.3, and private, but it is what core's own block editors call to // resolve the assets an iframed canvas needs. if ( ! function_exists( '_wp_get_iframed_editor_assets' ) ) { return array(); } $handles = self::get_allowed_iframe_style_handles(); return array( '__unstableResolvedAssets' => self::get_resolved_assets( $handles ), 'allowedIframeStyleHandles' => $handles, ); } /** * The stylesheet handles the canvas is allowed to keep. * * Mirrors the package's `Settings_Controller::get_allowed_iframe_style_handles()`. An empty * list is not a no-op: the client strips every stylesheet not named here, so omitting this * leaves the canvas painted in the site's own styles rather than the email's. * * @return string[] */ private static function get_allowed_iframe_style_handles() { $handles = array( 'wp-components-css', 'wp-reset-editor-styles-css', 'wp-block-library-css', 'wp-block-editor-content-css', 'wp-edit-blocks-css', ); // Registration args reach the block type verbatim — `WP_Block_Type::set_props()` normalizes // only `attributes`, and `register_block_type_args` can rewrite the rest — so a block // declaring a bare string here would otherwise fatal the screen inside `array_merge()`. foreach ( WP_Block_Type_Registry::get_instance()->get_all_registered() as $block ) { if ( ! is_array( $block->supports ) || empty( $block->supports['email'] ) ) { continue; } foreach ( array_merge( (array) $block->style_handles, (array) $block->editor_style_handles ) as $handle ) { if ( is_string( $handle ) ) { $handles[] = $handle . '-css'; } } } return $handles; } /** * The iframe assets, trimmed to the allowed handles. * * @param string[] $allowed Handles to keep. * @return array The `_wp_get_iframed_editor_assets()` shape, with `styles` filtered. */ private static function get_resolved_assets( array $allowed ) { $assets = _wp_get_iframed_editor_assets(); $kept = array(); foreach ( explode( "\n", (string) $assets['styles'] ) as $asset ) { foreach ( $allowed as $handle ) { if ( str_contains( $asset, $handle ) ) { $kept[] = $asset; break; } } } $assets['styles'] = implode( "\n", $kept ); return $assets; } /** * Render the container the editor mounts into. */ public static function render() { printf( '
', esc_attr( self::HANDLE ) ); } } Jetpack_Email_Design_Editor::init();