PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.5.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.5.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / admin / class-beaver-metabox.php

class-beaver-metabox.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.5.0, at includes/admin/class-beaver-metabox.php

410 lines 16.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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