| 1 |
<?php |
| 2 |
/** |
| 3 |
* Beaver Builder integration for the ThinkRank SEO metabox. |
| 4 |
* |
| 5 |
* Beaver Builder's editor is a front-end editing overlay, not a standalone SPA: |
| 6 |
* it loads the post on its own permalink with `?fl_builder` and paints its own |
| 7 |
* chrome (top bar, panels, settings modals) over the rendered page. It does NOT |
| 8 |
* render WordPress metaboxes and does not submit the #post form, so the |
| 9 |
* classic/block metabox never appears or saves there. |
| 10 |
* |
| 11 |
* That makes this the Divi/Bricks shape — the ordinary `wp_enqueue_scripts` / |
| 12 |
* `wp_footer` flow runs inside the builder, so the SAME React metabox app used |
| 13 |
* everywhere else is reused: |
| 14 |
* - enqueues a dedicated `beaver` bundle and localizes the same |
| 15 |
* `thinkrankMetabox` data (via Metabox_Manager::get_localized_data()), |
| 16 |
* augmented with the metadata + content preview the classic editor exposes |
| 17 |
* through hidden inputs; |
| 18 |
* - outputs a mount node in the footer; |
| 19 |
* - saves through the `thinkrank_save_metabox` AJAX route (Beaver Builder keeps |
| 20 |
* its own layout in the `_fl_builder_data` / `_fl_builder_draft` postmeta and |
| 21 |
* persists it through its own Publish button — we never touch it). |
| 22 |
* |
| 23 |
* ## The launcher is registered, not injected |
| 24 |
* |
| 25 |
* `fl_builder_ui_bar_buttons` is a public filter for adding a button to the top |
| 26 |
* bar, so the launcher is REGISTERED rather than injected into private toolbar |
| 27 |
* markup from JS. That is why there is no DOM-injection watcher here the way |
| 28 |
* there is in `src/divi/index.js` and `src/bricks/index.js`, and no |
| 29 |
* `.et-vb-page-bar-tools`-style private selector to go stale on an update. |
| 30 |
* |
| 31 |
* Detection is `FLBuilderModel::is_builder_active()`, Beaver Builder's own |
| 32 |
* authoritative check, so none of the query-string fallbacks the Divi class |
| 33 |
* carries are needed either. |
| 34 |
* |
| 35 |
* ## Why the hooks are split across two requests |
| 36 |
* |
| 37 |
* Beaver Builder 2.4+ defaults to an iframe UI (`FLBuilderUIIFrame`, switchable |
| 38 |
* via `fl_builder_iframe_ui_enabled`) which splits the builder over TWO requests |
| 39 |
* for the same post: |
| 40 |
* - the top-level UI (`?fl_builder&fl_builder_ui`), a shell that embeds the |
| 41 |
* page in `#fl-builder-ui-iframe`; |
| 42 |
* - the layout iframe (`?fl_builder&fl_builder_ui_iframe`), which renders the |
| 43 |
* page itself. |
| 44 |
* |
| 45 |
* The division of labour between them is counter-intuitive, and getting it |
| 46 |
* backwards produces an integration that looks right and does nothing: |
| 47 |
* |
| 48 |
* - Beaver Builder's entire builder UI *runs* in the LAYOUT IFRAME. |
| 49 |
* `FLBuilder::enqueue_ui_layout_styles_scripts()` — "scripts for the builder |
| 50 |
* UI that need to run in the layout iframe" — is what loads |
| 51 |
* `fl-builder-ui.js`, and `FLBuilder::render_ui()` prints the toolbar's |
| 52 |
* underscore template (`#tmpl-fl-toolbar`, which is where this filter's |
| 53 |
* output is baked in) into that document. So `add_bar_button()` MUST run on |
| 54 |
* the layout iframe request; gating it to the top-level UI would filter the |
| 55 |
* button out of the only document that reads it. |
| 56 |
* - The rendered toolbar is then handed to the TOP WINDOW: `Toolbar.render()` |
| 57 |
* does `$( 'body', window.parent.document ).prepend( $html )`. So the button |
| 58 |
* element, and everything that has to interact with it, lives up there. |
| 59 |
* |
| 60 |
* The drawer therefore belongs in the top-level UI, alongside the toolbar it |
| 61 |
* launches from and outside the canvas that would otherwise clip it — which is |
| 62 |
* why `enqueue_editor_assets()` and `render_root()` gate on `ui_post_id()` |
| 63 |
* (top-level UI only) while `add_bar_button()` gates on `builder_post_id()` |
| 64 |
* (either request). In the legacy UI the two collapse onto one document and the |
| 65 |
* split is a no-op. |
| 66 |
* |
| 67 |
* The remaining catch is that the top-level UI empties the entire script queue |
| 68 |
* (`FLBuilderUIIFrame::enqueue_scripts()` sets `$wp_scripts->queue = array()` at |
| 69 |
* `PHP_INT_MAX`) so third-party front-end scripts don't load outside the layout |
| 70 |
* frame. No `wp_enqueue_scripts` priority can win that — it runs last by |
| 71 |
* definition. Beaver Builder's answer is the `fl_builder_ui_enqueue_scripts` |
| 72 |
* action it fires immediately afterwards for exactly this purpose, so the bundle |
| 73 |
* is re-queued there. Registration and the localized data survive the wipe (only |
| 74 |
* the queue is cleared), so re-queuing by handle is enough. |
| 75 |
* |
| 76 |
* Styles are not wiped, and `wp_enqueue_media()` is deliberately called in the |
| 77 |
* ordinary pass: `FLBuilderUIIFrame::enqueue_scripts()` re-runs it only when it |
| 78 |
* has already fired, which is what keeps the media library available to the |
| 79 |
* drawer's social-image picker after the queue is emptied. |
| 80 |
* |
| 81 |
* @package ThinkRank |
| 82 |
* @since 2.2.2 |
| 83 |
*/ |
| 84 |
|
| 85 |
declare(strict_types=1); |
| 86 |
|
| 87 |
namespace ThinkRank\Admin; |
| 88 |
|
| 89 |
if (!defined('ABSPATH')) { |
| 90 |
exit; |
| 91 |
} |
| 92 |
|
| 93 |
/** |
| 94 |
* Wires the React metabox into the Beaver Builder editor. |
| 95 |
*/ |
| 96 |
class Beaver_Metabox { |
| 97 |
|
| 98 |
/** |
| 99 |
* Script/style handle for the builder bundle. |
| 100 |
*/ |
| 101 |
private const HANDLE = 'thinkrank-beaver'; |
| 102 |
|
| 103 |
/** |
| 104 |
* Shared metabox manager (data builder + supported post types). |
| 105 |
* |
| 106 |
* @var Metabox_Manager |
| 107 |
*/ |
| 108 |
private Metabox_Manager $metabox; |
| 109 |
|
| 110 |
/** |
| 111 |
* Memoized result of builder_post_id(). |
| 112 |
* |
| 113 |
* Resolved once per request and reused by the enqueue, the toolbar filter |
| 114 |
* and the mount node, so the three cannot disagree — printing the root div |
| 115 |
* for a post the bundle then declines to load for would leave an orphan |
| 116 |
* element in the builder footer, and registering a toolbar button with no |
| 117 |
* drawer behind it would leave a dead launcher. |
| 118 |
* |
| 119 |
* @var int|null |
| 120 |
*/ |
| 121 |
private ?int $builder_post = null; |
| 122 |
|
| 123 |
/** |
| 124 |
* Constructor. |
| 125 |
* |
| 126 |
* @param Metabox_Manager $metabox Shared metabox manager instance. |
| 127 |
*/ |
| 128 |
public function __construct(Metabox_Manager $metabox) { |
| 129 |
$this->metabox = $metabox; |
| 130 |
} |
| 131 |
|
| 132 |
/** |
| 133 |
* Register the Beaver Builder hooks. |
| 134 |
* |
| 135 |
* Every callback gates on the builder detector, so registering |
| 136 |
* unconditionally is harmless when Beaver Builder isn't installed: the |
| 137 |
* detector returns false and nothing runs. |
| 138 |
* |
| 139 |
* The enqueue runs at 9999 so it lands after any front-end asset juggling, |
| 140 |
* and is repeated on `fl_builder_ui_enqueue_scripts` because the top-level |
| 141 |
* iframe UI empties the script queue at `PHP_INT_MAX` (see the class |
| 142 |
* docblock). |
| 143 |
* |
| 144 |
* @return void |
| 145 |
*/ |
| 146 |
public function init(): void { |
| 147 |
add_action('wp_enqueue_scripts', [$this, 'enqueue_editor_assets'], 9999); |
| 148 |
add_action('fl_builder_ui_enqueue_scripts', [$this, 'requeue_editor_script']); |
| 149 |
add_action('wp_footer', [$this, 'render_root']); |
| 150 |
add_filter('fl_builder_ui_bar_buttons', [$this, 'add_bar_button']); |
| 151 |
} |
| 152 |
|
| 153 |
/** |
| 154 |
* Whether Beaver Builder's editor is open on this request. |
| 155 |
* |
| 156 |
* True for both halves of the modern UI (the top-level shell and the layout |
| 157 |
* iframe) and for the single legacy-UI document. |
| 158 |
* |
| 159 |
* @return bool |
| 160 |
*/ |
| 161 |
private function is_builder(): bool { |
| 162 |
return class_exists('\FLBuilderModel') && (bool) \FLBuilderModel::is_builder_active(); |
| 163 |
} |
| 164 |
|
| 165 |
/** |
| 166 |
* Whether this request is the layout iframe of the modern UI. |
| 167 |
* |
| 168 |
* @return bool |
| 169 |
*/ |
| 170 |
private function is_layout_iframe(): bool { |
| 171 |
return class_exists('\FLBuilderUIIFrame') |
| 172 |
&& \FLBuilderUIIFrame::is_enabled() |
| 173 |
&& \FLBuilderUIIFrame::is_iframe_request(); |
| 174 |
} |
| 175 |
|
| 176 |
/** |
| 177 |
* Resolve the post currently open in the builder. |
| 178 |
* |
| 179 |
* `FLBuilderModel::get_post_id()` is Beaver Builder's own resolver and is |
| 180 |
* what its own callbacks use, so it stays correct for the cases the queried |
| 181 |
* object gets wrong (theme-builder layouts, AJAX). The queried object is the |
| 182 |
* fallback for a release that ever stops setting it. |
| 183 |
* |
| 184 |
* @return int Post ID, or 0 if it cannot be determined. |
| 185 |
*/ |
| 186 |
private function get_post_id(): int { |
| 187 |
if (class_exists('\FLBuilderModel')) { |
| 188 |
$post_id = (int) \FLBuilderModel::get_post_id(); |
| 189 |
if ($post_id) { |
| 190 |
return $post_id; |
| 191 |
} |
| 192 |
} |
| 193 |
|
| 194 |
return (int) get_queried_object_id(); |
| 195 |
} |
| 196 |
|
| 197 |
/** |
| 198 |
* The post being edited, when ThinkRank may act on this builder request. |
| 199 |
* |
| 200 |
* Covers BOTH halves of the modern UI, because the toolbar button has to be |
| 201 |
* registered on the layout iframe request — that is the document whose |
| 202 |
* `#tmpl-fl-toolbar` the builder actually renders from. |
| 203 |
* |
| 204 |
* @return int Post ID, or 0 when this isn't an editable builder request. |
| 205 |
*/ |
| 206 |
private function builder_post_id(): int { |
| 207 |
if (null !== $this->builder_post) { |
| 208 |
return $this->builder_post; |
| 209 |
} |
| 210 |
|
| 211 |
$this->builder_post = 0; |
| 212 |
|
| 213 |
if (!$this->is_builder()) { |
| 214 |
return 0; |
| 215 |
} |
| 216 |
|
| 217 |
$post_id = $this->get_post_id(); |
| 218 |
if (!$post_id) { |
| 219 |
return 0; |
| 220 |
} |
| 221 |
|
| 222 |
// Editing SEO from the builder must respect the same capability the |
| 223 |
// classic metabox save enforces. |
| 224 |
if (!current_user_can('edit_post', $post_id)) { |
| 225 |
return 0; |
| 226 |
} |
| 227 |
|
| 228 |
$post = get_post($post_id); |
| 229 |
if (!$post || !in_array($post->post_type, $this->metabox->get_supported_post_types(), true)) { |
| 230 |
return 0; |
| 231 |
} |
| 232 |
|
| 233 |
$this->builder_post = $post_id; |
| 234 |
|
| 235 |
return $post_id; |
| 236 |
} |
| 237 |
|
| 238 |
/** |
| 239 |
* The post this request may mount the drawer for, if any. |
| 240 |
* |
| 241 |
* Narrower than `builder_post_id()`: the drawer mounts in the window that |
| 242 |
* owns the toolbar and the viewport, never in the layout iframe. Mounting in |
| 243 |
* both would give the builder two drawers, one of them sealed inside the |
| 244 |
* canvas where nothing can reach it. |
| 245 |
* |
| 246 |
* @return int Post ID, or 0 when the drawer must not mount here. |
| 247 |
*/ |
| 248 |
private function ui_post_id(): int { |
| 249 |
return $this->is_layout_iframe() ? 0 : $this->builder_post_id(); |
| 250 |
} |
| 251 |
|
| 252 |
/** |
| 253 |
* Enqueue the Beaver Builder metabox bundle and localize its data. |
| 254 |
* |
| 255 |
* @return void |
| 256 |
*/ |
| 257 |
public function enqueue_editor_assets(): void { |
| 258 |
$post_id = $this->ui_post_id(); |
| 259 |
if (!$post_id) { |
| 260 |
return; |
| 261 |
} |
| 262 |
|
| 263 |
$post = get_post($post_id); |
| 264 |
|
| 265 |
// wp.media powers the social-image picker inside the drawer. Calling it |
| 266 |
// here rather than on `fl_builder_ui_enqueue_scripts` is deliberate: the |
| 267 |
// iframe UI only restores the media scripts it wiped when |
| 268 |
// `wp_enqueue_media` has ALREADY fired by the time it empties the queue. |
| 269 |
wp_enqueue_media(); |
| 270 |
|
| 271 |
$asset_file = THINKRANK_PLUGIN_DIR . 'assets/beaver.asset.php'; |
| 272 |
$asset = file_exists($asset_file) ? include $asset_file : [ |
| 273 |
'dependencies' => ['react', 'react-dom', 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-components'], |
| 274 |
'version' => THINKRANK_VERSION, |
| 275 |
]; |
| 276 |
|
| 277 |
wp_enqueue_script( |
| 278 |
self::HANDLE, |
| 279 |
THINKRANK_PLUGIN_URL . 'assets/beaver.js', |
| 280 |
$asset['dependencies'], |
| 281 |
$asset['version'], |
| 282 |
true |
| 283 |
); |
| 284 |
|
| 285 |
// Reuse the exact metabox config, then add the data the classic editor |
| 286 |
// would normally hand the React app through hidden inputs. |
| 287 |
$data = $this->metabox->get_localized_data($post_id); |
| 288 |
$data['context'] = 'beaver'; |
| 289 |
$data['existingMetadata'] = $this->metabox->get_post_metadata($post_id); |
| 290 |
$data['contentPreview'] = $this->metabox->get_content_preview($post); |
| 291 |
$data['postTitle'] = get_the_title($post_id); |
| 292 |
// AJAX route used to persist all fields (no #post form in the builder). |
| 293 |
$data['saveAction'] = 'thinkrank_save_metabox'; |
| 294 |
|
| 295 |
wp_localize_script(self::HANDLE, 'thinkrankMetabox', $data); |
| 296 |
|
| 297 |
// Depend on wp-components so the @wordpress/components controls inside |
| 298 |
// the drawer keep their styling against the builder's global resets. |
| 299 |
wp_enqueue_style( |
| 300 |
self::HANDLE, |
| 301 |
THINKRANK_PLUGIN_URL . 'assets/beaver.css', |
| 302 |
['wp-components'], |
| 303 |
THINKRANK_VERSION |
| 304 |
); |
| 305 |
} |
| 306 |
|
| 307 |
/** |
| 308 |
* Put the bundle back in the queue after the iframe UI empties it. |
| 309 |
* |
| 310 |
* `FLBuilderUIIFrame::enqueue_scripts()` clears `$wp_scripts->queue` |
| 311 |
* wholesale at `PHP_INT_MAX` and then fires this action so third parties can |
| 312 |
* opt back in. The script is still registered and still carries its |
| 313 |
* localized data — only the queue was cleared — so re-queuing by handle is |
| 314 |
* all that is needed. Enqueuing an unregistered handle is a no-op, which |
| 315 |
* covers the case where `enqueue_editor_assets()` declined to run. |
| 316 |
* |
| 317 |
* @return void |
| 318 |
*/ |
| 319 |
public function requeue_editor_script(): void { |
| 320 |
if (!$this->ui_post_id()) { |
| 321 |
return; |
| 322 |
} |
| 323 |
|
| 324 |
wp_enqueue_script(self::HANDLE); |
| 325 |
} |
| 326 |
|
| 327 |
/** |
| 328 |
* Register the ThinkRank launcher in the builder's top bar. |
| 329 |
* |
| 330 |
* Runs on `builder_post_id()`, not `ui_post_id()`: the button has to exist |
| 331 |
* in the layout iframe's `#tmpl-fl-toolbar`, because that is the copy |
| 332 |
* `wp.template( 'fl-toolbar' )` reads. The toolbar Beaver Builder renders |
| 333 |
* from it is then prepended to the top window, where the bundle picks the |
| 334 |
* button up. |
| 335 |
* |
| 336 |
* Beaver Builder renders each entry as |
| 337 |
* `<button class="fl-builder-{slug}-button fl-builder-button {class}" …>` |
| 338 |
* and echoes `label`, `title` and `id` WITHOUT escaping them, so every value |
| 339 |
* handed over here is escaped at the point it is built. |
| 340 |
* |
| 341 |
* `.fl-builder-bar-actions` is `flex-direction: row-reverse`, so array order |
| 342 |
* runs right-to-left on screen: inserting immediately after `done` puts the |
| 343 |
* launcher just to the LEFT of the Publish/Done button, which is where the |
| 344 |
* Elementor, Oxygen, Divi and Bricks launchers sit. Appending is the |
| 345 |
* fallback for a release that ever renames or drops that key. |
| 346 |
* |
| 347 |
* The button carries no `onclick`: the bundle binds a delegated listener |
| 348 |
* instead, so the launcher keeps working across the re-renders that rebuild |
| 349 |
* the toolbar from its template. |
| 350 |
* |
| 351 |
* @param array<string, array<string, mixed>> $buttons Registered bar buttons. |
| 352 |
* @return array<string, array<string, mixed>> Filtered bar buttons. |
| 353 |
*/ |
| 354 |
public function add_bar_button(array $buttons): array { |
| 355 |
if (!$this->builder_post_id()) { |
| 356 |
return $buttons; |
| 357 |
} |
| 358 |
|
| 359 |
$button = [ |
| 360 |
'thinkrank' => [ |
| 361 |
// Empty mount node: the bundle renders the React `LauncherMark` |
| 362 |
// (logo + live SEO score badge) into it, the same mark the |
| 363 |
// Bricks toolbar and the block editor's pinned launcher show. |
| 364 |
// Duplicating that gradient SVG here would fork the brand mark |
| 365 |
// and collide on the `<filter>`/`<linearGradient>` ids it |
| 366 |
// references, which is why nothing is drawn server-side. |
| 367 |
'label' => '<span class="thinkrank-fl-launcher-mark"></span>', |
| 368 |
// `fl-builder-button-silent` is Beaver Builder's own icon-button |
| 369 |
// treatment (the one the + content-panel button uses), so the |
| 370 |
// launcher inherits its hover and focus states instead of |
| 371 |
// fighting them with inline styles. The bundle adds |
| 372 |
// `thinkrank-is-ready` once the mark is mounted; until then the |
| 373 |
// stylesheet keeps the button hidden so no empty 40px gap ever |
| 374 |
// shows in the toolbar. |
| 375 |
'class' => 'fl-builder-button-silent thinkrank-fl-launcher', |
| 376 |
'title' => esc_attr__('ThinkRank SEO', 'thinkrank'), |
| 377 |
], |
| 378 |
]; |
| 379 |
|
| 380 |
if (!array_key_exists('done', $buttons)) { |
| 381 |
return array_merge($buttons, $button); |
| 382 |
} |
| 383 |
|
| 384 |
$position = array_search('done', array_keys($buttons), true) + 1; |
| 385 |
|
| 386 |
return array_merge( |
| 387 |
array_slice($buttons, 0, $position, true), |
| 388 |
$button, |
| 389 |
array_slice($buttons, $position, null, true) |
| 390 |
); |
| 391 |
} |
| 392 |
|
| 393 |
/** |
| 394 |
* Output the React mount node into the builder footer. |
| 395 |
* |
| 396 |
* `wp_footer` fires in the top-level UI too: `includes/ui-iframe.php` calls |
| 397 |
* `do_action( 'wp_footer' )` explicitly, which is also what prints the |
| 398 |
* bundle's own script tag there. |
| 399 |
* |
| 400 |
* @return void |
| 401 |
*/ |
| 402 |
public function render_root(): void { |
| 403 |
if (!$this->ui_post_id()) { |
| 404 |
return; |
| 405 |
} |
| 406 |
|
| 407 |
echo '<div id="thinkrank-beaver-root" class="thinkrank-metabox"></div>'; |
| 408 |
} |
| 409 |
} |
| 410 |
|