and the mount node + script in the
* builder footer by hand (wp_print_styles / wp_print_scripts), since neither
* wp_head nor wp_footer runs;
* - the bundle shows the drawer's own floating launcher and saves through the
* `thinkrank_save_metabox` AJAX route (Oxygen keeps its own content under the
* `_breakdance_data` postmeta via its own Save button — we never touch it).
*
* Scope: Oxygen 6 / Breakdance. The legacy shortcode builder (Oxygen < 6) is
* covered for content *extraction* (see Builder_Content) but not for this
* editor UI; the request detector still recognises its `?ct_builder=true` view
* so a future entry can hook it without touching this contract.
*
* @package ThinkRank
* @since 1.24.0
*/
namespace ThinkRank\Admin;
if (!defined('ABSPATH')) {
exit;
}
/**
* Wires the React metabox into the Oxygen / Breakdance editor.
*/
class Oxygen_Metabox {
/**
* Shared metabox manager (data builder + supported post types).
*
* @var Metabox_Manager
*/
private Metabox_Manager $metabox;
/**
* Constructor.
*
* @param Metabox_Manager $metabox Shared metabox manager instance.
*/
public function __construct(Metabox_Manager $metabox) {
$this->metabox = $metabox;
}
/**
* Handle used for the builder script + style + localized data.
*/
private const HANDLE = 'thinkrank-oxygen';
/**
* Register the builder hooks.
*
* The Oxygen 6 / Breakdance builder template is a standalone HTML document
* that never calls `wp_head()` or `wp_footer()`, so the usual enqueue flow
* prints nothing there. Breakdance instead fires its own actions inside the
* builder chrome — `breakdance_builder_head` (in
) and
* `breakdance_builder_footer` (before ) — and those only fire on a
* genuine builder request. We hook both and print our assets by hand.
*
* Registering unconditionally is harmless when Oxygen isn't installed: the
* actions simply never fire.
*
* @return void
*/
public function init(): void {
add_action('breakdance_builder_head', [$this, 'print_builder_styles']);
add_action('breakdance_builder_footer', [$this, 'print_builder_scripts']);
}
/**
* Resolve the post currently open in the builder.
*
* The builder loads as `?oxygen=builder&id=` (or `?breakdance=...`
* on Breakdance builds); the edited post is always the `id` query var.
*
* @return int Post ID, or 0 if it cannot be determined.
*/
private function get_post_id(): int {
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only context resolution inside the builder request, no state change
foreach (['id', 'page_id', 'post', 'post_id'] as $key) {
if (isset($_GET[$key])) {
$candidate = absint(wp_unslash($_GET[$key]));
if ($candidate) {
return $candidate;
}
}
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$queried = get_queried_object_id();
return $queried ? (int) $queried : 0;
}
/**
* Register the script/style and localize the data, once.
*
* Returns the post being edited when this request should get the panel, or
* null when it should be skipped (no post, wrong type, missing capability).
*
* @return \WP_Post|null Post to render for, or null to skip.
*/
private function register_assets(): ?\WP_Post {
$post_id = $this->get_post_id();
if (!$post_id) {
return null;
}
// Editing SEO from the builder must respect the same capability the
// classic metabox save enforces.
if (!current_user_can('edit_post', $post_id)) {
return null;
}
$post = get_post($post_id);
if (!$post || !in_array($post->post_type, $this->metabox->get_supported_post_types(), true)) {
return null;
}
// Both hooks run per request; register only on the first.
if (wp_script_is(self::HANDLE, 'registered')) {
return $post;
}
$asset_file = THINKRANK_PLUGIN_DIR . 'assets/oxygen.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_register_script(
self::HANDLE,
THINKRANK_PLUGIN_URL . 'assets/oxygen.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'] = 'oxygen';
$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_register_style(
self::HANDLE,
THINKRANK_PLUGIN_URL . 'assets/oxygen.css',
['wp-components'],
THINKRANK_VERSION
);
return $post;
}
/**
* Print the drawer styles into the builder .
*
* `wp_head()` never runs in the builder, so the enqueued style would never
* be emitted — print it (and its dependencies) directly.
*
* @return void
*/
public function print_builder_styles(): void {
if (!$this->register_assets()) {
return;
}
wp_print_styles(self::HANDLE);
}
/**
* Print the mount node, media templates and drawer script into the builder
* footer, right before .
*
* @return void
*/
public function print_builder_scripts(): void {
if (!$this->register_assets()) {
return;
}
echo '';
// wp.media powers the social-image picker inside the drawer. Its
// templates normally print on wp_footer, which the builder never fires,
// so print the scripts and underscore templates by hand. Best-effort:
// the drawer still works without the picker if this is unavailable.
if (function_exists('wp_enqueue_media')) {
wp_enqueue_media();
wp_print_scripts('media-editor');
}
wp_print_scripts(self::HANDLE);
if (function_exists('wp_print_media_templates')) {
wp_print_media_templates();
}
}
}