queue = array()` at * `PHP_INT_MAX`) so third-party front-end scripts don't load outside the layout * frame. No `wp_enqueue_scripts` priority can win that — it runs last by * definition. Beaver Builder's answer is the `fl_builder_ui_enqueue_scripts` * action it fires immediately afterwards for exactly this purpose, so the bundle * is re-queued there. Registration and the localized data survive the wipe (only * the queue is cleared), so re-queuing by handle is enough. * * Styles are not wiped, and `wp_enqueue_media()` is deliberately called in the * ordinary pass: `FLBuilderUIIFrame::enqueue_scripts()` re-runs it only when it * has already fired, which is what keeps the media library available to the * drawer's social-image picker after the queue is emptied. */ class Beaver_Metabox { /** * Script/style handle for the builder bundle. */ private const HANDLE = 'thinkrank-beaver'; /** * Shared metabox manager (data builder + supported post types). * * @var Metabox_Manager */ private Metabox_Manager $metabox; /** * Memoized result of builder_post_id(). * * Resolved once per request and reused by the enqueue, the toolbar filter * and the mount node, so the three cannot disagree — printing the root div * for a post the bundle then declines to load for would leave an orphan * element in the builder footer, and registering a toolbar button with no * drawer behind it would leave a dead launcher. * * @var int|null */ private ?int $builder_post = null; /** * Constructor. * * @param Metabox_Manager $metabox Shared metabox manager instance. */ public function __construct(Metabox_Manager $metabox) { $this->metabox = $metabox; } /** * Register the Beaver Builder hooks. * * Every callback gates on the builder detector, so registering * unconditionally is harmless when Beaver Builder isn't installed: the * detector returns false and nothing runs. * * The enqueue runs at 9999 so it lands after any front-end asset juggling, * and is repeated on `fl_builder_ui_enqueue_scripts` because the top-level * iframe UI empties the script queue at `PHP_INT_MAX` (see the class * docblock). * * @return void */ public function init(): void { add_action('wp_enqueue_scripts', [$this, 'enqueue_editor_assets'], 9999); add_action('fl_builder_ui_enqueue_scripts', [$this, 'requeue_editor_script']); add_action('wp_footer', [$this, 'render_root']); add_filter('fl_builder_ui_bar_buttons', [$this, 'add_bar_button']); } /** * Whether Beaver Builder's editor is open on this request. * * True for both halves of the modern UI (the top-level shell and the layout * iframe) and for the single legacy-UI document. * * @return bool */ private function is_builder(): bool { return class_exists('\FLBuilderModel') && (bool) \FLBuilderModel::is_builder_active(); } /** * Whether this request is the layout iframe of the modern UI. * * @return bool */ private function is_layout_iframe(): bool { return class_exists('\FLBuilderUIIFrame') && \FLBuilderUIIFrame::is_enabled() && \FLBuilderUIIFrame::is_iframe_request(); } /** * Resolve the post currently open in the builder. * * `FLBuilderModel::get_post_id()` is Beaver Builder's own resolver and is * what its own callbacks use, so it stays correct for the cases the queried * object gets wrong (theme-builder layouts, AJAX). The queried object is the * fallback for a release that ever stops setting it. * * @return int Post ID, or 0 if it cannot be determined. */ private function get_post_id(): int { if (class_exists('\FLBuilderModel')) { $post_id = (int) \FLBuilderModel::get_post_id(); if ($post_id) { return $post_id; } } return (int) get_queried_object_id(); } /** * The post being edited, when ThinkRank may act on this builder request. * * Covers BOTH halves of the modern UI, because the toolbar button has to be * registered on the layout iframe request — that is the document whose * `#tmpl-fl-toolbar` the builder actually renders from. * * @return int Post ID, or 0 when this isn't an editable builder request. */ private function builder_post_id(): int { if (null !== $this->builder_post) { return $this->builder_post; } $this->builder_post = 0; if (!$this->is_builder()) { return 0; } $post_id = $this->get_post_id(); if (!$post_id) { return 0; } // Editing SEO from the builder must respect the same capability the // classic metabox save enforces. if (!current_user_can('edit_post', $post_id)) { return 0; } $post = get_post($post_id); if (!$post || !in_array($post->post_type, $this->metabox->get_supported_post_types(), true)) { return 0; } $this->builder_post = $post_id; return $post_id; } /** * The post this request may mount the drawer for, if any. * * Narrower than `builder_post_id()`: the drawer mounts in the window that * owns the toolbar and the viewport, never in the layout iframe. Mounting in * both would give the builder two drawers, one of them sealed inside the * canvas where nothing can reach it. * * @return int Post ID, or 0 when the drawer must not mount here. */ private function ui_post_id(): int { return $this->is_layout_iframe() ? 0 : $this->builder_post_id(); } /** * Enqueue the Beaver Builder metabox bundle and localize its data. * * @return void */ public function enqueue_editor_assets(): void { $post_id = $this->ui_post_id(); if (!$post_id) { return; } $post = get_post($post_id); // wp.media powers the social-image picker inside the drawer. Calling it // here rather than on `fl_builder_ui_enqueue_scripts` is deliberate: the // iframe UI only restores the media scripts it wiped when // `wp_enqueue_media` has ALREADY fired by the time it empties the queue. wp_enqueue_media(); $asset_file = THINKRANK_PLUGIN_DIR . 'assets/beaver.asset.php'; $asset = file_exists($asset_file) ? include $asset_file : [ 'dependencies' => ['react', 'react-dom', 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-components'], 'version' => THINKRANK_VERSION, ]; wp_enqueue_script( self::HANDLE, THINKRANK_PLUGIN_URL . 'assets/beaver.js', $asset['dependencies'], $asset['version'], true ); // Reuse the exact metabox config, then add the data the classic editor // would normally hand the React app through hidden inputs. $data = $this->metabox->get_localized_data($post_id); $data['context'] = 'beaver'; $data['existingMetadata'] = $this->metabox->get_post_metadata($post_id); $data['contentPreview'] = $this->metabox->get_content_preview($post); $data['postTitle'] = get_the_title($post_id); // AJAX route used to persist all fields (no #post form in the builder). $data['saveAction'] = 'thinkrank_save_metabox'; wp_localize_script(self::HANDLE, 'thinkrankMetabox', $data); // Depend on wp-components so the @wordpress/components controls inside // the drawer keep their styling against the builder's global resets. wp_enqueue_style( self::HANDLE, THINKRANK_PLUGIN_URL . 'assets/beaver.css', ['wp-components'], THINKRANK_VERSION ); } /** * Put the bundle back in the queue after the iframe UI empties it. * * `FLBuilderUIIFrame::enqueue_scripts()` clears `$wp_scripts->queue` * wholesale at `PHP_INT_MAX` and then fires this action so third parties can * opt back in. The script is still registered and still carries its * localized data — only the queue was cleared — so re-queuing by handle is * all that is needed. Enqueuing an unregistered handle is a no-op, which * covers the case where `enqueue_editor_assets()` declined to run. * * @return void */ public function requeue_editor_script(): void { if (!$this->ui_post_id()) { return; } wp_enqueue_script(self::HANDLE); } /** * Register the ThinkRank launcher in the builder's top bar. * * Runs on `builder_post_id()`, not `ui_post_id()`: the button has to exist * in the layout iframe's `#tmpl-fl-toolbar`, because that is the copy * `wp.template( 'fl-toolbar' )` reads. The toolbar Beaver Builder renders * from it is then prepended to the top window, where the bundle picks the * button up. * * Beaver Builder renders each entry as * `