From a9a588b126c0dcd6e3f0b651a4ea0da4439c567b Mon Sep 17 00:00:00 2001
From: Carlos Bravo <37012961+cbravobernal@users.noreply.github.com>
Date: Tue, 9 Dec 2025 20:07:44 +0100
Subject: [PATCH 1/6] First backport, v3 blocks pending
---
assets/src/js/_acf-field-accordion.js | 28 +-
assets/src/sass/acf-input.scss | 4 +
assets/src/sass/pro/_blocks.scss | 44 +++
.../acf-styles-in-iframe-for-blocks.min.scss | 14 +
includes/blocks.php | 254 ++++++++++++++++-
pro/blocks-auto-inline-editing.php | 256 ++++++++++++++++++
6 files changed, 596 insertions(+), 4 deletions(-)
create mode 100644 assets/src/sass/pro/acf-styles-in-iframe-for-blocks.min.scss
create mode 100755 pro/blocks-auto-inline-editing.php
diff --git a/assets/src/js/_acf-field-accordion.js b/assets/src/js/_acf-field-accordion.js
index 6d88374b..e61d1b43 100644
--- a/assets/src/js/_acf-field-accordion.js
+++ b/assets/src/js/_acf-field-accordion.js
@@ -94,6 +94,15 @@
accordionManager.iconHtml( { open: this.get( 'open' ) } )
);
+ $label.attr( {
+ tabindex: 0,
+ role: 'button',
+ 'aria-expanded': this.get( 'open' ) ? 'true' : 'false',
+ } );
+ $input.attr( {
+ role: 'region',
+ } );
+
// classes
// - remove 'inside' which is a #poststuff WP class
var $parent = $field.parent();
@@ -131,6 +140,7 @@
events: {
'click .acf-accordion-title': 'onClick',
+ 'keydown .acf-accordion-title': 'onKeydown',
'invalidField .acf-accordion': 'onInvalidField',
},
@@ -174,6 +184,10 @@
this.iconHtml( { open: true } )
);
$el.addClass( '-open' );
+ $el.find( '.acf-accordion-title:first' ).attr(
+ 'aria-expanded',
+ 'true'
+ );
// action
acf.doAction( 'show', $el );
@@ -195,6 +209,10 @@
this.iconHtml( { open: false } )
);
$el.removeClass( '-open' );
+ $el.find( '.acf-accordion-title:first' ).attr(
+ 'aria-expanded',
+ 'false'
+ );
// action
acf.doAction( 'hide', $el );
@@ -207,7 +225,15 @@
// open close
this.toggle( $el.parent() );
},
-
+ onKeydown: function ( e, $el ) {
+ // check for enter or space
+ if ( 13 === e.which ) {
+ // prevent Default
+ e.preventDefault();
+ // open close
+ this.toggle( $el.parent() );
+ }
+ },
onInvalidField: function ( e, $el ) {
// bail early if already focused
if ( this.busy ) {
diff --git a/assets/src/sass/acf-input.scss b/assets/src/sass/acf-input.scss
index 2300b8b4..5537d8e9 100644
--- a/assets/src/sass/acf-input.scss
+++ b/assets/src/sass/acf-input.scss
@@ -2394,6 +2394,10 @@ html[dir=rtl] .acf-range-wrap .acf-prepend {
.acf-accordion .acf-accordion-title:hover {
background: #f3f4f5;
}
+.acf-accordion .acf-accordion-title:focus-visible {
+ box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9));
+ outline-offset: -2px;
+}
.acf-accordion .acf-accordion-title label {
margin: 0;
padding: 0;
diff --git a/assets/src/sass/pro/_blocks.scss b/assets/src/sass/pro/_blocks.scss
index 280acaff..4c86b9d5 100644
--- a/assets/src/sass/pro/_blocks.scss
+++ b/assets/src/sass/pro/_blocks.scss
@@ -322,4 +322,48 @@
.acf-blocks-open-expanded-editor-btn.has-text.has-icon {
width: 100%;
justify-content: center
+}
+
+.acf-blocks-toolbar-icon {
+ display: flex;
+ align-items: center;
+ gap: .3rem;
+ padding-right: .6rem;
+}
+
+.acf-blocks-toolbar-icon i {
+ display: flex;
+}
+
+.acf-inline-fields-popover {
+ z-index: 59899;
+ min-width: 300px;
+}
+
+.acf-inline-fields-popover-inner>.acf-block-panel>.acf-block-fields>.acf-field {
+ display: none;
+ border-top-style: none !important;
+}
+
+.acf-inline-fields-popover-inner .acf-block-fields>.acf-error-message {
+ display: none;
+}
+
+.acf-inline-editing-toolbar .acf-inline-fields-popover-inner .acf-tab-group {
+ display: flex;
+ white-space: nowrap;
+}
+
+.block-editor-block-toolbar .field-type-icon {
+ top: 0;
+ background-color: initial;
+ border: 0;
+}
+
+.block-editor-block-toolbar .field-type-icon:before {
+ background-color: #000;
+}
+
+.block-editor-block-toolbar .is-pressed .field-type-icon:before {
+ background-color: #fff;
}
\ No newline at end of file
diff --git a/assets/src/sass/pro/acf-styles-in-iframe-for-blocks.min.scss b/assets/src/sass/pro/acf-styles-in-iframe-for-blocks.min.scss
new file mode 100644
index 00000000..57f55a5a
--- /dev/null
+++ b/assets/src/sass/pro/acf-styles-in-iframe-for-blocks.min.scss
@@ -0,0 +1,14 @@
+[data-acf-inline-contenteditable="1"][contenteditable=true]:empty::before {
+ content: attr(data-acf-placeholder);
+ opacity: .62;
+}
+
+[data-acf-inline-contenteditable="1"][contenteditable=true]:empty {
+ border: 1px dashed rgba(255, 0, 0, 0);
+}
+
+[data-acf-inline-fields-uid]:hover,
+[data-acf-inline-contenteditable]:hover {
+ outline: 2px solid var(--wp-admin-theme-color);
+ outline-offset: 2px;
+}
\ No newline at end of file
diff --git a/includes/blocks.php b/includes/blocks.php
index baea5c0d..4f12bd23 100644
--- a/includes/blocks.php
+++ b/includes/blocks.php
@@ -8,6 +8,9 @@
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
+require_once 'blocks-auto-inline-editing.php';
+use function SCF\Blocks\AutoInlineEditing\apply_inline_editing_attributes_to_render_template;
+
// Register store.
acf_register_store( 'block-types' );
acf_register_store( 'block-cache' );
@@ -124,6 +127,7 @@ function acf_handle_json_block_registration( $settings, $metadata ) {
'validateOnLoad' => 'validate_on_load',
'usePostMeta' => 'use_post_meta',
'hideFieldsInSidebar' => 'hide_fields_in_sidebar',
+ 'autoInlineEditing' => 'auto_inline_editing',
);
$textdomain = ! empty( $metadata['textdomain'] ) ? $metadata['textdomain'] : 'secure-custom-fields';
$i18n_schema = get_block_metadata_i18n_schema();
@@ -675,8 +679,11 @@ function acf_rendered_block( $attributes, $content = '', $is_preview = false, $p
* If we're in preloaded preview, we need to get the validation state for a preview too.
* Because the block render resets meta once it's finished to not pollute $post_id, we need to redo that process here.
*/
- $block = acf_prepare_block( $attributes );
- $block = acf_add_block_meta_values( $block, $post_id );
+ $block = acf_prepare_block( $attributes );
+ $block = acf_add_block_meta_values( $block, $post_id );
+ $block_toolbar_fields = acf_process_block_toolbar_fields( apply_filters( 'acf/blocks/top_toolbar_fields', array(), $block, $content, $is_preview, $post_id, $wp_block, $context ) );
+ $fields = acf_get_block_fields( $block );
+
acf_setup_meta( $block['data'], $block['id'], true );
if ( ! empty( $block['validate'] ) ) {
$validation = acf_get_block_validation_state( $block, false, false, true );
@@ -716,6 +723,15 @@ function acf_rendered_block( $attributes, $content = '', $is_preview = false, $p
$block_cache['validation'] = $validation;
}
+ if ( isset( $block_toolbar_fields ) ) {
+ $block_cache['blockToolbarFields'] = $block_toolbar_fields;
+ }
+
+ if ( isset( $fields ) ) {
+ $block_cache['fields'] = $fields;
+ }
+
+
// Store in cache for preloading if we're in the backend.
acf_get_store( 'block-cache' )->set(
$attributes['id'],
@@ -805,7 +821,17 @@ function acf_block_render_template( $block, $content, $is_preview, $post_id, $wp
// Include template.
if ( file_exists( $path ) ) {
- include $path;
+ if ( $is_preview && ! empty( $block['auto_inline_editing'] ) ) {
+ $result = apply_inline_editing_attributes_to_render_template( $path, $block, $is_preview );
+
+ // In order to allow block render templates to support any html tags,
+ // we must assume that escaping has already been properly handled by the block render template here.
+ // Typically we'd use something like wp_kses here, but that would limit the HTML tags that can be used.
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ echo $result;
+ } else {
+ include $path;
+ }
} elseif ( $is_preview ) {
echo acf_esc_html( apply_filters( 'acf/blocks/template_not_found_message', '
' . __( 'The render template for this ACF Block was not found', 'secure-custom-fields' ) . '
' ) );
}
@@ -913,6 +939,22 @@ function ( $block ) {
}
}
+
+/**
+ * Enqueues scripts and styles to load inside the block editor iframe.
+ * This allows us to do things like style contenteditable, and other inline editing elements.
+ *
+ * @since ACF 6.7
+ */
+function acf_enqueue_in_iframe_styles() {
+ if ( is_admin() ) {
+ $min = defined( 'ACF_DEVELOPMENT_MODE' ) && ACF_DEVELOPMENT_MODE ? '' : '.min';
+ $css_path = acf_get_url( "assets/build/css/pro/acf-styles-in-iframe-for-blocks{$min}.css" );
+ wp_enqueue_style( 'acf-inline-editing-styles', $css_path, ACF_VERSION, true );
+ }
+}
+add_action( 'enqueue_block_assets', 'acf_enqueue_in_iframe_styles' );
+
/**
* Enqueues scripts and styles for a specific block type.
*
@@ -1068,6 +1110,8 @@ function acf_ajax_fetch_block() {
acf_prefix_fields( $fields, "acf-{$block['id']}" );
if ( $fields ) {
+ $response['fields'] = $fields;
+
// Start Capture.
ob_start();
@@ -1094,6 +1138,8 @@ function acf_ajax_fetch_block() {
$response['preview'] = acf_rendered_block( $block, $content, $is_preview, $post_id, null, $context, true );
}
+ $response['blockToolbarFields'] = acf_process_block_toolbar_fields( apply_filters( 'acf/blocks/top_toolbar_fields', array(), $block, $content, $is_preview, $post_id, null, $context ) );
+
// Send response.
wp_send_json_success( $response );
}
@@ -1588,3 +1634,205 @@ function acf_get_block_meta_values_to_save( $content = '' ) {
return $meta_values;
}
+
+
+/**
+ * Helper function that returns the HTML attributes required for toolbar inline editing as a string, escaped and ready for output.
+ *
+ * @param array $fields Array {
+ * Required. A list of the fields, each of which which will be displayed in the popup toolbar.
+ *
+ * Each field can be passed as:
+ *
+ * - A string (e.g. `'my_field_name'`)
+ * - An associative array with specific keys:
+ * @type string $field_name The name of the field to display in the toolbar.
+ * @type string $field_icon An html tag, can be an svg, to be used as the toolbar icon. If not passed, the icon of the first field will be used.
+ * @type string $field_label A string to use as the label for the button in the toolbar.
+ * }
+ *
+ * @param array $args Array {
+ * Optional. An array of additional args which can control how the toolbar is displayed and used.
+ *
+ * @type string $toolbar_icon Optional. An html tag, can be an svg, to be used as the toolbar icon. If not passed, the icon of the first field will be used.
+ * @type string $toolbar_title Optional. A string to be used as the toolbar title. If not passed, the name of the first field will be used.
+ * @type string $uid Optional. A unique identifier that isn't used by any other inline fields in this block. Pass if you have 2 elements that conflict.
+ * @type string $render Optional. True if it should return the output, false if it should return an empty string.
+ * }
+ *
+ * @return string A string containing the attributes.
+ */
+function acf_inline_toolbar_editing_attrs( $fields, $args = array() ): string {
+
+ if ( empty( $fields ) ) {
+ return '';
+ }
+
+ $default_args = array(
+ 'toolbar_icon' => null,
+ 'toolbar_title' => null,
+ 'uid' => null,
+ 'render' => null,
+ );
+
+ $args = wp_parse_args( $args, $default_args );
+
+ if ( $args['render'] === null ) {
+ $render = acf_get_data( 'acf_doing_block_preview' );
+ }
+
+ if ( ! $render ) {
+ return '';
+ }
+
+ // Get the block id.
+ $meta_instance = acf_get_instance( 'ACF_Local_Meta' );
+ $block_id = $meta_instance->post_id;
+
+ if ( empty( $block_id ) || ! str_starts_with( $block_id, 'block_' ) ) {
+ return '';
+ }
+
+ // Prefix the generated uid with the blockid.
+ $generated_uid = $block_id;
+
+ $fields_processed = array();
+
+ foreach ( $fields as $field ) {
+ if ( is_array( $field ) ) {
+ $generated_uid .= $field['field_name'];
+ $fields_processed[] = array(
+ 'fieldName' => $field['field_name'],
+ // Base64 allows us to embed html in an attribute without causing issues with quotes, etc.
+ 'fieldIcon' => base64_encode( $field['field_icon'] ), // phpcs:ignore
+ 'fieldLabel' => $field['field_label'],
+ );
+ } else {
+ $fields_processed[] = $field;
+ $generated_uid .= $field;
+ }
+ }
+
+ if ( empty( $args['uid'] ) ) {
+ $args['uid'] = $generated_uid;
+ }
+
+ if ( ! empty( $args['toolbar_icon'] ) ) {
+ $args['toolbar_icon'] = 'data-acf-toolbar-icon="' . esc_attr( $args['toolbar_icon'] ) . '" ';
+ }
+
+ if ( ! empty( $args['toolbar_title'] ) ) {
+ $args['toolbar_title'] = 'data-acf-toolbar-title="' . esc_attr( $args['toolbar_title'] ) . '" ';
+ }
+
+ return 'data-acf-inline-fields-uid="' . esc_attr( $args['uid'] ) . '" data-acf-inline-fields="' . esc_attr( wp_json_encode( $fields_processed ) ) . '" ' . $args['toolbar_icon'] . $args['toolbar_title'] . 'role="button" tabindex="0"';
+}
+
+/**
+ * Helper function that returns the HTML attributes required for inline text editing as a string, escaped and ready for output.
+ *
+ * @param string $field_name A string which is the name of the field to update when the user types into the HTML element.
+ *
+ * @param array $args Array {
+ * Optional. An array of additional args which can control how the popover identifier is displayed.
+ *
+ * @type string $toolbar_icon Optional. An html tag, can be an svg, to be used as the toolbar icon. If not passed, the icon of the first field will be used.
+ * @type string $toolbar_title Optional. A string to be used as the toolbar title. If not passed, the name of the first field will be used.
+ * @type string $placeholder Optional. Optional. A string which will be used as the placeholder in the typable text area.
+ * @type string $render Optional. True if it should return the output, false if it should return an empty string.
+ * }
+ *
+ * @return string A string containing the attributes.
+ */
+function acf_inline_text_editing_attrs( $field_name, $args = array() ): string {
+
+ if ( empty( $field_name ) ) {
+ return '';
+ }
+
+ $default_args = array(
+ 'toolbar_icon' => null,
+ 'toolbar_title' => null,
+ 'placeholder' => null,
+ 'render' => null,
+ );
+
+ $args = wp_parse_args( $args, $default_args );
+
+ if ( $args['render'] === null ) {
+ $args['render'] = acf_get_data( 'acf_doing_block_preview' );
+ }
+
+ if ( ! $args['render'] ) {
+ return '';
+ }
+
+ if ( ! empty( $args['toolbar_icon'] ) ) {
+ $args['toolbar_icon'] = 'data-acf-toolbar-icon="' . esc_attr( $args['toolbar_icon'] ) . '" ';
+ }
+
+ if ( ! empty( $args['toolbar_title'] ) ) {
+ $args['toolbar_title'] = 'data-acf-toolbar-title="' . esc_attr( $args['toolbar_title'] ) . '" ';
+ }
+
+ if ( ! $args['placeholder'] ) {
+ $args['placeholder'] = __( 'Type to edit...', 'secure-custom-fields' );
+ }
+
+ return 'data-acf-inline-contenteditable="1" data-acf-inline-contenteditable-field-slug="' . esc_attr( $field_name ) . '" data-acf-placeholder="' . esc_attr( $args['placeholder'] ) . '" ' . $args['toolbar_icon'] . $args['toolbar_title'];
+}
+
+/**
+ * This function prepares a fields array for being localized and used on the frontend as block toolbar fields.
+ *
+ * @param array $fields Array {
+ * Required. A list of the fields, each of which which will be displayed in the popup toolbar.
+ *
+ * Each field can be passed as:
+ *
+ * - A string (e.g. `'my_field_name'`)
+ * - An associative array with specific keys:
+ * @type string $field_name The name of the field to display in the toolbar.
+ * @type string $field_icon An html tag, can be an svg, to be used as the toolbar icon. If not passed, the icon of the first field will be used.
+ * @type string $field_label A string to use as the label for the button in the toolbar.
+ * }
+ *
+ * @return array The array of fields, prepared for JS localization.
+ */
+function acf_process_block_toolbar_fields( $fields ) {
+ $fields_processed = array();
+
+ foreach ( $fields as $index => $field ) {
+ if ( is_array( $field ) ) {
+ $fields_processed[] = array(
+ 'fieldName' => ! empty( $field['field_name'] ) ? $field['field_name'] : $index,
+ // Base64 allows us to embed html in an attribute without causing issues with quotes, etc.
+ 'fieldIcon' => ! empty( $field['field_icon'] ) ? base64_encode( $field['field_icon'] ) : null, // phpcs:ignore
+ 'fieldLabel' => ! empty( $field['field_label'] ) ? $field['field_label'] : null,
+ );
+ } else {
+ $fields_processed[] = $field;
+ }
+ }
+
+ return $fields_processed;
+}
+
+/**
+ * Helper function for block render templates to check if an acf field has a value.
+ * This is relevant when autoInlineEditing is enabled for a block, because empty fields
+ * will have acf_auto_inline_editing_field_name_ + field_name as their value if they are empty.
+ *
+ * @param string $field_name True if the field is empty, false if it has a value.
+ * @return boolean True if the field is empty, false if it has a value.
+ */
+function acf_inline_editing_field_is_empty( $field_name ) {
+ $field_value = get_field( $field_name );
+
+ if ( empty( $field_value ) || $field_value === 'acf_auto_inline_editing_field_name_' . $field_name ) {
+ return true;
+ }
+
+ return false;
+}
+
diff --git a/pro/blocks-auto-inline-editing.php b/pro/blocks-auto-inline-editing.php
new file mode 100755
index 00000000..de98a586
--- /dev/null
+++ b/pro/blocks-auto-inline-editing.php
@@ -0,0 +1,256 @@
+' . ob_get_clean();
+
+ $acf_blocks_doing_auto_inline_editing = false;
+
+ // Load the HTML into DOMDocument
+ $dom = new \DOMDocument();
+ libxml_use_internal_errors( true ); // Suppress warnings for invalid HTML
+ $dom->loadHTML( $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
+ libxml_clear_errors();
+
+ // Get all elements
+ $xpath = new \DOMXPath( $dom );
+ $elements = $xpath->query( '//*' );
+
+ // Iterate over elements and modify based on text content
+ foreach ( $elements as $element ) {
+ $field_names_for_popover = array();
+
+ $top_level_text = '';
+
+ // phpcs:ignore WordPress.NamingConventions.ValidVariableName
+ if ( empty( $element->childNodes ) ) {
+ continue;
+ }
+
+ // Loop through the child nodes of the current element
+ // phpcs:ignore WordPress.NamingConventions.ValidVariableName
+ foreach ( $element->childNodes as $child ) {
+ // Check if the child node is a text node
+ // phpcs:ignore WordPress.NamingConventions.ValidVariableName
+ if ( $child->nodeType === XML_TEXT_NODE ) {
+ // phpcs:ignore WordPress.NamingConventions.ValidVariableName
+ $top_level_text .= $child->nodeValue;
+ }
+ }
+
+ $top_level_text = trim( $top_level_text );
+
+ if ( ! empty( $top_level_text ) ) {
+ $acf_field_found = false;
+
+ // Loop through each field used in this render template.
+ foreach ( $acf_fields_used_in_block_render_template as $key => $field_data ) {
+ if ( ! $field_data['name'] ) {
+ continue;
+ }
+
+ // If the value for this field matches the text in the dom, apply the inline editing attributes.
+ if ( $field_data['value'] === $top_level_text ) {
+ $acf_field_found = true;
+ $field_slug = $field_data['name'];
+ $field_value = $field_data['value'];
+ $field_type = $field_data['type'];
+ $field_placeholder_text = ! empty( $field_data['placeholder'] ) ? $field_data['placeholder'] : __( 'Type to edit...', 'secure-custom-fields' );
+
+ if ( ! in_array( $field_type, $non_auto_inline_editing_fields, true ) ) {
+ if ( in_array( $field_type, $allowed_contenteditable_field_types, true ) ) {
+ // Add the contenteditable things.
+ if ( ! $element->getAttribute( 'data-acf-inline-contenteditable' ) ) {
+ $element->setAttribute( 'role', 'button' );
+ $element->setAttribute( 'data-acf-inline-contenteditable', true );
+
+ $element->setAttribute( 'data-acf-inline-contenteditable-field-slug', str_replace( 'acf_auto_inline_editing_field_name_', '', $field_slug ) );
+ $element->setAttribute( 'data-acf-placeholder', $field_placeholder_text );
+ }
+
+ // phpcs:ignore WordPress.NamingConventions.ValidVariableName
+ if ( $field_value !== 'acf_auto_inline_editing_field_name_' . $field_slug ) {
+ // phpcs:ignore WordPress.NamingConventions.ValidVariableName
+ $element->nodeValue = $field_value;
+ } else {
+ // phpcs:ignore WordPress.NamingConventions.ValidVariableName
+ $element->nodeValue = '';
+ }
+ } else {
+ // Make the field popover instead of contenteditable.
+ $field_names_for_popover[] = str_replace( 'acf_auto_inline_editing_field_name_', '', $field_slug );
+ }
+ }
+
+ // We found a matching field so we can stop looping.
+ break;
+ }
+ }
+
+ if ( ! $acf_field_found ) {
+ foreach ( $acf_fields_used_in_block_render_template as $key => $field_data ) {
+ // Remove the acf_auto_inline_editing_field_name_ placeholder from the text node.
+ if ( strpos( $top_level_text, 'acf_auto_inline_editing_field_name_' . $field_data['name'] ) !== false ) {
+ // phpcs:ignore WordPress.NamingConventions.ValidVariableName
+ $element->nodeValue = str_replace( 'acf_auto_inline_editing_field_name_' . $field_data['name'], '', $top_level_text );
+ }
+ }
+ }
+ }
+
+ // If the value for this field matches the field slug, remove it.
+ // phpcs:ignore WordPress.NamingConventions.ValidVariableName
+ if ( str_starts_with( $top_level_text, 'acf_auto_inline_editing_field_name_' ) ) {
+ // phpcs:ignore WordPress.NamingConventions.ValidVariableName
+ $element->textContent = '';
+ }
+
+ // Loop over each attribute. If an attribute comes from acf, make it popup when parent is selected.
+ foreach ( $element->attributes as $attribute ) {
+ if ( $attribute->name === 'data-acf-inline-contenteditable-field-slug' ) {
+ continue;
+ }
+ $attribute_value = trim( $attribute->value );
+
+ foreach ( $acf_fields_used_in_block_render_template as $field_data ) {
+ if ( empty( $field_data['name'] ) ) {
+ continue;
+ }
+
+ $field_slug = $field_data['name'];
+ $field_value = $field_data['value'];
+
+ if ( ! is_array( $field_value ) && $attribute_value === $field_value ) {
+ $field_names_for_popover[] = str_replace( 'acf_auto_inline_editing_field_name_', '', $field_slug );
+ }
+
+ if ( strpos( $attribute_value, 'acf_auto_inline_editing_field_name_' ) !== false ) {
+ $attribute_value = str_replace( 'acf_auto_inline_editing_field_name_' . $field_slug, '', $attribute_value );
+ $element->setAttribute( $attribute->name, $attribute_value );
+ }
+ }
+ }
+
+ $field_names_for_popover = array_unique( $field_names_for_popover );
+
+ // Don't add popover fields to the top level element unless it has text content (as opposed to html/non-text content, which is what most top level elements contain).
+ // phpcs:ignore WordPress.NamingConventions.ValidVariableName
+ $is_top_level = isset( $element->parentNode->tagName ) && $element->parentNode->tagName === 'body';
+
+ if ( ! $is_top_level && ! empty( $field_names_for_popover ) ) {
+ $preexisting_inline_fields_uid = $element->getAttribute( 'data-acf-inline-fields-uid' );
+ if ( ! $preexisting_inline_fields_uid ) {
+ $element->setAttribute( 'data-acf-inline-fields-uid', implode( '__', $field_names_for_popover ) . '__' . $block['id'] );
+ $element->setAttribute(
+ 'data-acf-inline-fields',
+ wp_json_encode( $field_names_for_popover ),
+ );
+ }
+ }
+ }
+ // phpcs:ignore WordPress.NamingConventions.ValidVariableName
+ $dom->preserveWhiteSpace = true;
+ // phpcs:ignore WordPress.NamingConventions.ValidVariableName
+ $dom->formatOutput = false;
+
+ return str_replace( '', '', $dom->saveHTML() );
+}
From 108c34a6c2047b6ea0f5e645e2eef2bc1d82ff70 Mon Sep 17 00:00:00 2001
From: Carlos Bravo <37012961+cbravobernal@users.noreply.github.com>
Date: Wed, 10 Dec 2025 10:42:08 +0100
Subject: [PATCH 2/6] Not tested commit
---
.../components/inline-editing-toolbar.js | 301 ++++++++++++++++++
.../blocks-v3/components/popover-wrapper.js | 120 +++++++
2 files changed, 421 insertions(+)
create mode 100644 assets/src/js/pro/blocks-v3/components/inline-editing-toolbar.js
create mode 100644 assets/src/js/pro/blocks-v3/components/popover-wrapper.js
diff --git a/assets/src/js/pro/blocks-v3/components/inline-editing-toolbar.js b/assets/src/js/pro/blocks-v3/components/inline-editing-toolbar.js
new file mode 100644
index 00000000..575e4438
--- /dev/null
+++ b/assets/src/js/pro/blocks-v3/components/inline-editing-toolbar.js
@@ -0,0 +1,301 @@
+/**
+ * InlineEditingToolbar Component
+ * Main inline editing toolbar for ACF blocks
+ * Handles field selection and editing for inline editable elements
+ */
+
+import { useState, useEffect, useMemo, createPortal } from '@wordpress/element';
+import { Button, Modal } from '@wordpress/components';
+import { PopoverWrapper } from './popover-wrapper';
+import { BlockForm } from './block-form';
+
+/**
+ * InlineEditingToolbar component
+ * Displays a toolbar with field buttons for inline editing
+ *
+ * @param {Object} props - Component props
+ * @param {Object} props.blockIcon - Block icon configuration
+ * @param {Array} props.blockFieldInfo - Array of field information
+ * @param {React.RefObject} props.acfFormRef - Reference to ACF form
+ * @param {Function} props.setInlineEditingToolbarHasFocus - Setter for toolbar focus state
+ * @param {Element|null} props.currentContentEditableElement - Current content editable element
+ * @param {Element|null} props.currentInlineEditingElement - Current inline editing element
+ * @param {string|null} props.currentInlineEditingElementUid - Current element UID
+ * @param {Document|HTMLIFrameElement} props.gutenbergIframeOrDocument - Document or iframe reference
+ * @param {Function} props.setCurrentBlockFormContainer - Setter for form container
+ * @param {boolean} props.contentEditableChangeInProgress - Whether content change is in progress
+ * @returns {JSX.Element|null} - Rendered toolbar or null
+ */
+export const InlineEditingToolbar = ( {
+ blockIcon,
+ blockFieldInfo,
+ acfFormRef,
+ setInlineEditingToolbarHasFocus,
+ currentContentEditableElement,
+ currentInlineEditingElement,
+ currentInlineEditingElementUid,
+ gutenbergIframeOrDocument,
+ setCurrentBlockFormContainer,
+ contentEditableChangeInProgress,
+} ) => {
+ const [ isFieldPopoverOpen, setIsFieldPopoverOpen ] = useState( false );
+ const [ isFieldModalOpen, setIsFieldModalOpen ] = useState( false );
+ const [ selectedFieldKey, setSelectedFieldKey ] = useState( null );
+ const [ popoverAnchor, setPopoverAnchor ] = useState( null );
+ const fieldPopoverContainerRef = useState( null );
+
+ // Parse inline fields from data attribute
+ const inlineFieldsAttr = currentInlineEditingElement
+ ? currentInlineEditingElement.getAttribute( 'data-acf-inline-fields' )
+ : null;
+ let inlineFields = [];
+ try {
+ inlineFields = JSON.parse( inlineFieldsAttr || '[]' );
+ } catch ( e ) {
+ acf.debug(
+ 'Inline fields were not a properly formatted JSON array',
+ inlineFieldsAttr
+ );
+ }
+
+ /**
+ * Get field type by field name
+ */
+ const getFieldType = ( fieldName ) => {
+ const field = blockFieldInfo.find( ( f ) => f.name === fieldName );
+ return field?.type || null;
+ };
+
+ /**
+ * Get field info by field name
+ */
+ const getFieldInfo = ( fieldName ) => {
+ return blockFieldInfo.find( ( f ) => f.name === fieldName ) || null;
+ };
+
+ /**
+ * Get field label by field name
+ */
+ const getFieldLabel = ( fieldName ) => {
+ const field = getFieldInfo( fieldName );
+ return field?.label || fieldName;
+ };
+
+ /**
+ * Check if field type requires modal
+ */
+ const isComplexFieldType = ( fieldType ) => {
+ return [ 'flexible_content', 'repeater', 'group' ].includes(
+ fieldType
+ );
+ };
+
+ /**
+ * Get toolbar icon from data attribute or field or default
+ */
+ const toolbarIcon = useMemo( () => {
+ // Check for custom toolbar icon in data attribute
+ const customIcon = currentInlineEditingElement?.getAttribute(
+ 'data-acf-toolbar-icon'
+ );
+ if ( customIcon ) {
+ // Decode base64 SVG if present
+ try {
+ if ( customIcon.startsWith( 'data:image/svg+xml;base64,' ) ) {
+ return customIcon;
+ }
+ } catch ( e ) {
+ // Ignore
+ }
+ }
+
+ // Use field icon if available
+ if ( currentContentEditableElement ) {
+ const fieldSlug = currentContentEditableElement.getAttribute(
+ 'data-acf-inline-contenteditable-field-slug'
+ );
+ const field = getFieldInfo( fieldSlug );
+ if ( field?.icon ) {
+ return field.icon;
+ }
+ }
+
+ // Default icon
+ return blockIcon || 'edit';
+ }, [
+ currentInlineEditingElement,
+ currentContentEditableElement,
+ blockIcon,
+ ] );
+
+ /**
+ * Get toolbar title from data attribute or element type or field label
+ */
+ const toolbarTitle = useMemo( () => {
+ // Check for custom toolbar title in data attribute
+ const customTitle = currentInlineEditingElement?.getAttribute(
+ 'data-acf-toolbar-title'
+ );
+ if ( customTitle ) {
+ return customTitle;
+ }
+
+ // Use content editable field label if available
+ if ( currentContentEditableElement ) {
+ const fieldSlug = currentContentEditableElement.getAttribute(
+ 'data-acf-inline-contenteditable-field-slug'
+ );
+ return getFieldLabel( fieldSlug );
+ }
+
+ // Use element type (DIV, P, SPAN, etc.) if no field is selected
+ if ( currentInlineEditingElement ) {
+ const tagName = currentInlineEditingElement.tagName;
+ return tagName.charAt( 0 ) + tagName.slice( 1 ).toLowerCase();
+ }
+
+ return acf.__( 'Edit' );
+ }, [
+ currentInlineEditingElement,
+ currentContentEditableElement,
+ blockFieldInfo,
+ ] );
+
+ /**
+ * Handle field button click
+ */
+ const handleFieldButtonClick = ( fieldName, buttonElement ) => {
+ const fieldType = getFieldType( fieldName );
+
+ // Set selected field
+ setSelectedFieldKey( fieldName );
+
+ // Open modal for complex field types
+ if ( isComplexFieldType( fieldType ) ) {
+ setIsFieldModalOpen( true );
+ setIsFieldPopoverOpen( false );
+ } else {
+ // Open popover for simple fields
+ setPopoverAnchor( buttonElement );
+ setIsFieldPopoverOpen( true );
+ }
+ };
+
+ /**
+ * Close field popover/modal
+ */
+ const closeFieldEditor = () => {
+ setIsFieldPopoverOpen( false );
+ setIsFieldModalOpen( false );
+ setSelectedFieldKey( null );
+ setPopoverAnchor( null );
+ };
+
+ // Clean up when toolbar loses focus
+ useEffect( () => {
+ setInlineEditingToolbarHasFocus( true );
+ return () => {
+ setInlineEditingToolbarHasFocus( false );
+ };
+ }, [] );
+
+ if ( ! currentInlineEditingElement || ! currentInlineEditingElementUid ) {
+ return null;
+ }
+
+ return (
+
+
+
+ { typeof toolbarIcon === 'string' &&
+ toolbarIcon.startsWith( 'data:image' ) ? (
+

+ ) : (
+
+ ) }
+
+
+ { toolbarTitle }
+
+
+
+ { inlineFields.length > 0 && (
+
+ { inlineFields.map( ( fieldName ) => {
+ const field = getFieldInfo( fieldName );
+ if ( ! field ) {
+ return null;
+ }
+
+ return (
+
+ ) }
+
+ { /* Field popover for simple fields */ }
+ { isFieldPopoverOpen && popoverAnchor && selectedFieldKey && (
+
+
+ { acfFormRef?.current &&
+ createPortal(
+
+ { /* Render specific field from form */ }
+
+
,
+ fieldPopoverContainerRef
+ ) }
+
+
+ ) }
+
+ { /* Field modal for complex fields */ }
+ { isFieldModalOpen && selectedFieldKey && (
+
+
+ { acfFormRef?.current && (
+
+ ) }
+
+
+ ) }
+
+ );
+};
diff --git a/assets/src/js/pro/blocks-v3/components/popover-wrapper.js b/assets/src/js/pro/blocks-v3/components/popover-wrapper.js
new file mode 100644
index 00000000..0bebda19
--- /dev/null
+++ b/assets/src/js/pro/blocks-v3/components/popover-wrapper.js
@@ -0,0 +1,120 @@
+/**
+ * PopoverWrapper Component
+ * Custom Popover wrapper that handles inline editing toolbar behavior
+ * - Escape key to close
+ * - Click outside to close
+ * - Focus management
+ * - Hiding primary block toolbar when active
+ */
+
+import { useEffect } from '@wordpress/element';
+import { Popover } from '@wordpress/components';
+
+/**
+ * PopoverWrapper component
+ * Wraps the WordPress Popover component with custom event handlers
+ *
+ * @param {Object} props - Component props
+ * @param {React.ReactNode} props.children - Child elements to render inside popover
+ * @param {string} props.className - CSS class for the popover
+ * @param {Element|null} props.anchor - Anchor element for positioning
+ * @param {string} props.placement - Placement relative to anchor (e.g., 'top-start')
+ * @param {Function} props.onClose - Callback when popover should close
+ * @param {boolean|string} props.focusOnMount - Whether to focus on mount
+ * @param {string} props.variant - Popover variant (e.g., 'unstyled')
+ * @param {boolean} props.animate - Whether to animate popover
+ * @param {Document|HTMLIFrameElement} props.gutenbergIframeOrDocument - Document or iframe reference
+ * @param {boolean} props.hidePrimaryBlockToolbar - Whether to hide the primary block toolbar
+ * @returns {JSX.Element} - Wrapped Popover component
+ */
+export const PopoverWrapper = ( {
+ children,
+ className,
+ anchor,
+ placement,
+ onClose,
+ focusOnMount,
+ variant,
+ animate = false,
+ gutenbergIframeOrDocument,
+ hidePrimaryBlockToolbar = false,
+} ) => {
+ useEffect( () => {
+ // Get the appropriate document (iframe or regular document)
+ const doc = gutenbergIframeOrDocument?.contentDocument
+ ? gutenbergIframeOrDocument.contentDocument
+ : gutenbergIframeOrDocument || document;
+
+ /**
+ * Handle escape key to close popover
+ */
+ const handleEscapeKey = ( event ) => {
+ if ( event.key === 'Escape' ) {
+ event.preventDefault();
+ event.stopPropagation();
+ onClose?.();
+ }
+ };
+
+ /**
+ * Handle click outside popover to close it
+ */
+ const handleClickOutside = ( event ) => {
+ // Check if click is outside the popover
+ const popoverElement = event.target.closest(
+ '.' + className.split( ' ' ).join( '.' )
+ );
+ if ( ! popoverElement ) {
+ onClose?.();
+ }
+ };
+
+ // Add event listeners
+ doc.addEventListener( 'keydown', handleEscapeKey, true );
+ doc.addEventListener( 'mousedown', handleClickOutside, true );
+
+ // Hide primary block toolbar if requested
+ if ( hidePrimaryBlockToolbar ) {
+ const toolbar = doc.querySelector(
+ '.block-editor-block-list__block.is-selected > .block-editor-block-contextual-toolbar'
+ );
+ if ( toolbar ) {
+ toolbar.style.display = 'none';
+ }
+ }
+
+ // Cleanup
+ return () => {
+ doc.removeEventListener( 'keydown', handleEscapeKey, true );
+ doc.removeEventListener( 'mousedown', handleClickOutside, true );
+
+ // Restore primary block toolbar
+ if ( hidePrimaryBlockToolbar ) {
+ const toolbar = doc.querySelector(
+ '.block-editor-block-list__block.is-selected > .block-editor-block-contextual-toolbar'
+ );
+ if ( toolbar ) {
+ toolbar.style.display = '';
+ }
+ }
+ };
+ }, [
+ gutenbergIframeOrDocument,
+ onClose,
+ className,
+ hidePrimaryBlockToolbar,
+ ] );
+
+ return (
+
+ { children }
+
+ );
+};
From ba279bcb7834927753090001232243b4624298fb Mon Sep 17 00:00:00 2001
From: Carlos Bravo <37012961+cbravobernal@users.noreply.github.com>
Date: Thu, 11 Dec 2025 12:33:49 +0100
Subject: [PATCH 3/6] Backport all PHP
---
assets/src/js/pro/_acf-blocks.js | 54 ++++-----
assets/src/sass/acf-global.scss | 7 +-
assets/src/sass/pro/_blocks.scss | 37 +++++-
...s => acf-styles-in-iframe-for-blocks.scss} | 4 +
includes/api/api-helpers.php | 15 ++-
includes/blocks.php | 109 +++++++++++++-----
.../fields/class-acf-field-google-map.php | 9 +-
webpack.config.js | 2 +
8 files changed, 167 insertions(+), 70 deletions(-)
rename assets/src/sass/pro/{acf-styles-in-iframe-for-blocks.min.scss => acf-styles-in-iframe-for-blocks.scss} (85%)
diff --git a/assets/src/js/pro/_acf-blocks.js b/assets/src/js/pro/_acf-blocks.js
index 91748001..dc08b062 100644
--- a/assets/src/js/pro/_acf-blocks.js
+++ b/assets/src/js/pro/_acf-blocks.js
@@ -610,6 +610,33 @@ const md5 = require( 'md5' );
return name;
}
+ /**
+ * A react Component for inline scripts.
+ *
+ * This Component uses a combination of React references and jQuery to append the
+ * inline ` );
+ }
+ componentDidUpdate() {
+ this.setHTML( this.props.children );
+ }
+ componentDidMount() {
+ this.setHTML( this.props.children );
+ }
+ }
+
/**
* Converts the given name into a React friendly name or component.
*
@@ -1009,33 +1036,6 @@ const md5 = require( 'md5' );
}
}
- /**
- * A react Component for inline scripts.
- *
- * This Component uses a combination of React references and jQuery to append the
- * inline ` );
- }
- componentDidUpdate() {
- this.setHTML( this.props.children );
- }
- componentDidMount() {
- this.setHTML( this.props.children );
- }
- }
-
/**
* DynamicHTML Class.
*
diff --git a/assets/src/sass/acf-global.scss b/assets/src/sass/acf-global.scss
index 07c55be9..990dbda7 100644
--- a/assets/src/sass/acf-global.scss
+++ b/assets/src/sass/acf-global.scss
@@ -808,11 +808,14 @@ a.acf-icon.dark.-minus:hover, a.acf-icon.dark.-cancel:hover {
background: #b4b9be;
color: #fff !important;
}
-.acf-icon.grey:hover {
+.acf-icon.grey:hover, .acf-icon.grey:focus {
background: #00a0d2;
color: #fff;
}
-.acf-icon.grey.-minus:hover, .acf-icon.grey.-cancel:hover {
+.acf-icon.grey.-minus:hover,
+.acf-icon.grey.-minus:focus,
+.acf-icon.grey.-cancel:hover,
+.acf-icon.grey.-cancel:focus {
background: #32373c;
}
diff --git a/assets/src/sass/pro/_blocks.scss b/assets/src/sass/pro/_blocks.scss
index 4c86b9d5..5c7bae25 100644
--- a/assets/src/sass/pro/_blocks.scss
+++ b/assets/src/sass/pro/_blocks.scss
@@ -234,6 +234,34 @@
margin: 16px -16px -16px;
}
+.acf-block-form-modal .components-modal__content {
+ padding: 4px 8px 8px !important;
+}
+
+.acf-block-form-modal .components-modal__content .acf-block-panel .acf-block-fields {
+ border: none !important;
+}
+
+.acf-block-form-modal .components-modal__content .acf-fields>.acf-field {
+ border: none !important;
+}
+
+.acf-block-form-modal .components-modal__header {
+ padding: 24px;
+ border-bottom: #ddd solid 1px !important;
+ max-height: 64px !important;
+}
+
+.acf-block-form-modal .components-modal__header h1 {
+ font-weight: 500;
+ font-size: 15px !important;
+}
+
+html[dir=rtl] .acf-block-form-modal .components-modal__header .components-button {
+ left: 0;
+ right: auto;
+}
+
@media(min-width: 600px)and (min-width: 782px) {
.acf-block-form-modal {
@@ -249,11 +277,6 @@
left: 0
}
- html[dir=rtl] .acf-block-form-modal .components-modal__header .components-button {
- left: 0;
- right: auto
- }
-
@keyframes components-modal__appear-animation {
0% {
right: -20px;
@@ -345,6 +368,10 @@
border-top-style: none !important;
}
+.acf-inline-fields-popover-inner .acf-tab-wrap {
+ display: none;
+}
+
.acf-inline-fields-popover-inner .acf-block-fields>.acf-error-message {
display: none;
}
diff --git a/assets/src/sass/pro/acf-styles-in-iframe-for-blocks.min.scss b/assets/src/sass/pro/acf-styles-in-iframe-for-blocks.scss
similarity index 85%
rename from assets/src/sass/pro/acf-styles-in-iframe-for-blocks.min.scss
rename to assets/src/sass/pro/acf-styles-in-iframe-for-blocks.scss
index 57f55a5a..ef866e92 100644
--- a/assets/src/sass/pro/acf-styles-in-iframe-for-blocks.min.scss
+++ b/assets/src/sass/pro/acf-styles-in-iframe-for-blocks.scss
@@ -11,4 +11,8 @@
[data-acf-inline-contenteditable]:hover {
outline: 2px solid var(--wp-admin-theme-color);
outline-offset: 2px;
+}
+
+.acf-block-has-validation-error {
+ border: 2px solid #d94f4f;
}
\ No newline at end of file
diff --git a/includes/api/api-helpers.php b/includes/api/api-helpers.php
index 21880478..cfc43689 100644
--- a/includes/api/api-helpers.php
+++ b/includes/api/api-helpers.php
@@ -1288,8 +1288,19 @@ function acf_get_grouped_posts( $args ) {
// find array of post_type
$post_types = acf_get_array( $args['post_type'] );
- $post_types_labels = acf_get_pretty_post_types( $post_types );
- $is_single_post_type = ( count( $post_types ) == 1 );
+ $is_single_post_type = ( count( $post_types ) === 1 );
+
+ // WordPress 6.8+ sorts post_type arrays for cache key generation
+ // We need to use the same sorted order when processing results
+ if (
+ ! $is_single_post_type &&
+ -1 !== $args['posts_per_page'] &&
+ version_compare( get_bloginfo( 'version' ), '6.8', '>=' )
+ ) {
+ sort( $post_types );
+ }
+
+ $post_types_labels = acf_get_pretty_post_types( $post_types );
// attachment doesn't work if it is the only item in an array
if ( $is_single_post_type ) {
diff --git a/includes/blocks.php b/includes/blocks.php
index 4f12bd23..9744daaf 100644
--- a/includes/blocks.php
+++ b/includes/blocks.php
@@ -8,7 +8,7 @@
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;
-require_once 'blocks-auto-inline-editing.php';
+require_once dirname( __DIR__ ) . '/pro/blocks-auto-inline-editing.php';
use function SCF\Blocks\AutoInlineEditing\apply_inline_editing_attributes_to_render_template;
// Register store.
@@ -731,7 +731,6 @@ function acf_rendered_block( $attributes, $content = '', $is_preview = false, $p
$block_cache['fields'] = $fields;
}
-
// Store in cache for preloading if we're in the backend.
acf_get_store( 'block-cache' )->set(
$attributes['id'],
@@ -894,6 +893,7 @@ function acf_enqueue_block_assets() {
'Open Expanded Editor' => __( 'Open Expanded Editor', 'secure-custom-fields' ),
'Error previewing block v3' => __( 'The preview for this block couldn’t be loaded. Review its content or settings for issues.', 'secure-custom-fields' ),
'ACF Block' => __( 'ACF Block', 'secure-custom-fields' ),
+ 'Done' => __( 'Done', 'secure-custom-fields' ),
/* translators: %s: Block type title */
'%s settings' => __( '%s settings', 'secure-custom-fields' ),
)
@@ -931,6 +931,7 @@ function ( $block ) {
// Retrieve any cached block HTML and include this in the localized data.
if ( acf_get_setting( 'preload_blocks' ) ) {
$preloaded_blocks = acf_get_store( 'block-cache' )->get_data();
+
acf_localize_data(
array(
'preloadedBlocks' => $preloaded_blocks,
@@ -1136,10 +1137,9 @@ function acf_ajax_fetch_block() {
// Render and store HTML.
$response['preview'] = acf_rendered_block( $block, $content, $is_preview, $post_id, null, $context, true );
- }
-
- $response['blockToolbarFields'] = acf_process_block_toolbar_fields( apply_filters( 'acf/blocks/top_toolbar_fields', array(), $block, $content, $is_preview, $post_id, null, $context ) );
+ $response['blockToolbarFields'] = acf_process_block_toolbar_fields( apply_filters( 'acf/blocks/top_toolbar_fields', array(), $block, $content, $is_preview, $post_id, null, $context ) );
+ }
// Send response.
wp_send_json_success( $response );
}
@@ -1646,9 +1646,11 @@ function acf_get_block_meta_values_to_save( $content = '' ) {
*
* - A string (e.g. `'my_field_name'`)
* - An associative array with specific keys:
- * @type string $field_name The name of the field to display in the toolbar.
- * @type string $field_icon An html tag, can be an svg, to be used as the toolbar icon. If not passed, the icon of the first field will be used.
- * @type string $field_label A string to use as the label for the button in the toolbar.
+ * @type string $field_name The name of the field to display in the toolbar.
+ * @type string $field_icon An html tag, can be an svg, to be used as the toolbar icon. If not passed, the icon of the first field will be used.
+ * @type string $field_label A string to use as the label for the button in the toolbar.
+ * @type boolean $use_expanded_editor Default is false, which opens the field in the popover. Set to true to open in the expanded editor.
+ * @type string $popover_min_width Enter the CSS width value to use for the popover. Default is "300px".
* }
*
* @param array $args Array {
@@ -1657,7 +1659,6 @@ function acf_get_block_meta_values_to_save( $content = '' ) {
* @type string $toolbar_icon Optional. An html tag, can be an svg, to be used as the toolbar icon. If not passed, the icon of the first field will be used.
* @type string $toolbar_title Optional. A string to be used as the toolbar title. If not passed, the name of the first field will be used.
* @type string $uid Optional. A unique identifier that isn't used by any other inline fields in this block. Pass if you have 2 elements that conflict.
- * @type string $render Optional. True if it should return the output, false if it should return an empty string.
* }
*
* @return string A string containing the attributes.
@@ -1672,14 +1673,11 @@ function acf_inline_toolbar_editing_attrs( $fields, $args = array() ): string {
'toolbar_icon' => null,
'toolbar_title' => null,
'uid' => null,
- 'render' => null,
);
$args = wp_parse_args( $args, $default_args );
- if ( $args['render'] === null ) {
- $render = acf_get_data( 'acf_doing_block_preview' );
- }
+ $render = acf_get_data( 'acf_doing_block_preview' );
if ( ! $render ) {
return '';
@@ -1698,18 +1696,74 @@ function acf_inline_toolbar_editing_attrs( $fields, $args = array() ): string {
$fields_processed = array();
+ /**
+ * Filters the field types that should open in the expanded editor by default.
+ *
+ * @since ACF 6.7.0
+ *
+ * @param array $field_types An array of field type names.
+ * @return array
+ */
+ $fields_to_open_in_expanded_editor = apply_filters(
+ 'acf/blocks/fields_to_open_in_expanded_editor',
+ array(
+ 'repeater',
+ 'flexible_content',
+ )
+ );
+
+ /**
+ * Filters the field types that require a wider popover for inline editing.
+ *
+ * @since ACF 6.7.0
+ *
+ * @param array $field_types An array of field type names.
+ * @return array
+ */
+ $fields_needing_wide_popover = apply_filters(
+ 'acf/blocks/fields_needing_wide_popover',
+ array(
+ 'gallery',
+ 'relationship',
+ 'wysiwyg',
+ 'google_map',
+ )
+ );
+
+ $popover_min_width_normal = '300px';
+ $popover_min_width_wide = '600px';
+
foreach ( $fields as $field ) {
if ( is_array( $field ) ) {
- $generated_uid .= $field['field_name'];
- $fields_processed[] = array(
- 'fieldName' => $field['field_name'],
- // Base64 allows us to embed html in an attribute without causing issues with quotes, etc.
- 'fieldIcon' => base64_encode( $field['field_icon'] ), // phpcs:ignore
- 'fieldLabel' => $field['field_label'],
- );
+ $full_field_data = acf_get_field( $field['field_name'] );
+ if ( $full_field_data ) {
+ $use_expanded_editor_default = in_array( $full_field_data['type'], $fields_to_open_in_expanded_editor, true ) ? true : false;
+ $popover_min_width_default = in_array( $full_field_data['type'], $fields_needing_wide_popover, true ) ? $popover_min_width_wide : $popover_min_width_normal;
+
+ $generated_uid .= $field['field_name'];
+ $fields_processed[] = array(
+ 'fieldName' => $field['field_name'],
+ // Base64 allows us to embed html in an attribute without causing issues with quotes, etc.
+ 'fieldIcon' => ! empty( $field['field_icon'] ) ? base64_encode( $field['field_icon'] ) : null, // phpcs:ignore
+ 'fieldLabel' => ! empty( $field['field_label'] ) ? $field['field_label'] : $full_field_data['label'],
+ 'useExpandedEditor' => ! empty( $field['use_expanded_editor'] ) ? $field['use_expanded_editor'] : $use_expanded_editor_default,
+ 'popoverMinWidth' => ! empty( $field['popover_min_width'] ) ? $field['popover_min_width'] : $popover_min_width_default,
+ );
+ }
} else {
- $fields_processed[] = $field;
- $generated_uid .= $field;
+ $full_field_data = acf_get_field( $field );
+
+ if ( $full_field_data ) {
+ $fields_processed[] = array(
+ 'fieldName' => $field,
+ 'fieldIcon' => null,
+ 'fieldLabel' => $full_field_data['label'],
+ 'useExpandedEditor' => in_array( $full_field_data['type'], $fields_to_open_in_expanded_editor, true ) ? true : false,
+ 'popoverMinWidth' => in_array( $full_field_data['type'], $fields_needing_wide_popover, true ) ? $popover_min_width_wide : $popover_min_width_normal,
+ );
+ }
+
+ $generated_uid .= $field;
}
}
@@ -1739,7 +1793,6 @@ function acf_inline_toolbar_editing_attrs( $fields, $args = array() ): string {
* @type string $toolbar_icon Optional. An html tag, can be an svg, to be used as the toolbar icon. If not passed, the icon of the first field will be used.
* @type string $toolbar_title Optional. A string to be used as the toolbar title. If not passed, the name of the first field will be used.
* @type string $placeholder Optional. Optional. A string which will be used as the placeholder in the typable text area.
- * @type string $render Optional. True if it should return the output, false if it should return an empty string.
* }
*
* @return string A string containing the attributes.
@@ -1757,13 +1810,10 @@ function acf_inline_text_editing_attrs( $field_name, $args = array() ): string {
'render' => null,
);
- $args = wp_parse_args( $args, $default_args );
+ $args = wp_parse_args( $args, $default_args );
+ $args['render'] = acf_get_data( 'acf_doing_block_preview' );
- if ( $args['render'] === null ) {
- $args['render'] = acf_get_data( 'acf_doing_block_preview' );
- }
-
- if ( ! $args['render'] ) {
+ if ( ! $render ) {
return '';
}
@@ -1835,4 +1885,3 @@ function acf_inline_editing_field_is_empty( $field_name ) {
return false;
}
-
diff --git a/includes/fields/class-acf-field-google-map.php b/includes/fields/class-acf-field-google-map.php
index ea280835..32dc348c 100644
--- a/includes/fields/class-acf-field-google-map.php
+++ b/includes/fields/class-acf-field-google-map.php
@@ -151,13 +151,14 @@ function render_field( $field ) {
diff --git a/webpack.config.js b/webpack.config.js
index b07d3b92..80ab01fb 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -35,6 +35,8 @@ const commonConfig = {
'css/pro/acf-pro-field-group':
'./assets/src/sass/pro/acf-pro-field-group.scss',
'css/pro/acf-pro-input': './assets/src/sass/pro/acf-pro-input.scss',
+ 'css/pro/acf-styles-in-iframe-for-blocks':
+ './assets/src/sass/pro/acf-styles-in-iframe-for-blocks.scss',
},
output: {
path: path.resolve( __dirname, 'assets/build/' ),
From 349046cc46915d1f52aa5ee91b87114841c19573 Mon Sep 17 00:00:00 2001
From: Carlos Bravo <37012961+cbravobernal@users.noreply.github.com>
Date: Thu, 11 Dec 2025 16:15:02 +0100
Subject: [PATCH 4/6] Backport input min js
---
assets/src/js/_acf-field-google-map.js | 30 ++++++++++++++++++++------
assets/src/js/_acf-tinymce.js | 12 ++++++++++-
assets/src/js/_acf-validation.js | 7 +++---
3 files changed, 38 insertions(+), 11 deletions(-)
diff --git a/assets/src/js/_acf-field-google-map.js b/assets/src/js/_acf-field-google-map.js
index ff8a414f..69c56b98 100644
--- a/assets/src/js/_acf-field-google-map.js
+++ b/assets/src/js/_acf-field-google-map.js
@@ -7,9 +7,9 @@
wait: 'load',
events: {
- 'click a[data-name="clear"]': 'onClickClear',
- 'click a[data-name="locate"]': 'onClickLocate',
- 'click a[data-name="search"]': 'onClickSearch',
+ 'click button[data-name="clear"]': 'onClickClear',
+ 'click button[data-name="locate"]': 'onClickLocate',
+ 'click button[data-name="search"]': 'onClickSearch',
'keydown .search': 'onKeydownSearch',
'keyup .search': 'onKeyupSearch',
'focus .search': 'onFocusSearch',
@@ -513,8 +513,13 @@
this.searchAddress( this.$search().val() );
},
- onFocusSearch: function ( e, $el ) {
- this.setState( 'searching' );
+ onFocusSearch: function ( event, searchInput ) {
+ const currentValue = this.val();
+ const currentAddress = currentValue ? currentValue.address : '';
+
+ if ( searchInput.val() !== currentAddress ) {
+ this.setState( 'searching' );
+ }
},
onBlurSearch: function ( e, $el ) {
@@ -529,9 +534,20 @@
},
onKeyupSearch: function ( e, $el ) {
- // Clear empty value.
- if ( ! $el.val() ) {
+ const val = this.val();
+ const address = val ? val.address : '';
+
+ if ( $el.val() ) {
+ // If search input has value
+ if ( $el.val() !== address ) {
+ this.setState( 'searching' );
+ } else {
+ this.setState( 'default' );
+ }
+ } else {
+ // If search input is empty
this.val( false );
+ this.setState( 'default' );
}
},
diff --git a/assets/src/js/_acf-tinymce.js b/assets/src/js/_acf-tinymce.js
index d9e1637e..41276608 100644
--- a/assets/src/js/_acf-tinymce.js
+++ b/assets/src/js/_acf-tinymce.js
@@ -108,12 +108,22 @@
init.setup = function ( ed ) {
ed.on( 'change', function ( e ) {
ed.save(); // save to textarea
+ if ( ! $textarea.closest( '.attachment-info' ).length ) {
+ $textarea.trigger( 'change' );
+ }
$textarea.trigger( 'change' );
} );
+ ed.on( 'blur', function ( e ) {
+ if ( $textarea.closest( '.attachment-info' ).length ) {
+ ed.save();
+ $textarea.trigger( 'change' );
+ }
+ } );
+
// Fix bug where Gutenberg does not hear "mouseup" event and tries to select multiple blocks.
ed.on( 'mouseup', function ( e ) {
- var event = new MouseEvent( 'mouseup' );
+ const event = new MouseEvent( 'mouseup' );
window.dispatchEvent( event );
} );
diff --git a/assets/src/js/_acf-validation.js b/assets/src/js/_acf-validation.js
index 750ef033..87e79b48 100644
--- a/assets/src/js/_acf-validation.js
+++ b/assets/src/js/_acf-validation.js
@@ -1177,9 +1177,10 @@
new CustomEvent(
'acf/block/has-error',
{
- acfBlocksWithValidationErrors: [
- block,
- ],
+ detail: {
+ acfBlocksWithValidationErrors:
+ block.clientId,
+ },
}
)
);
From 5b34feffe5c6a36f1b26835be69cb7598134ff04 Mon Sep 17 00:00:00 2001
From: Carlos Bravo <37012961+cbravobernal@users.noreply.github.com>
Date: Thu, 11 Dec 2025 16:19:55 +0100
Subject: [PATCH 5/6] Backport field group min
---
assets/src/js/_field-group-field.js | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/assets/src/js/_field-group-field.js b/assets/src/js/_field-group-field.js
index a00417e3..38458f50 100644
--- a/assets/src/js/_field-group-field.js
+++ b/assets/src/js/_field-group-field.js
@@ -407,11 +407,21 @@
// update label
$handle.find( '.li-field-label strong a' ).html( label );
+ let shouldConvertToLowercase = name === name.toLowerCase();
+ shouldConvertToLowercase = acf.applyFilters(
+ 'convert_field_name_to_lowercase',
+ shouldConvertToLowercase,
+ this
+ );
// update name
$handle
.find( '.li-field-name' )
- .html( this.makeCopyable( acf.strSanitize( name ) ) );
+ .html(
+ this.makeCopyable(
+ acf.strSanitize( name, shouldConvertToLowercase )
+ )
+ );
// update type
const iconName = acf.strSlugify( this.getType() );
From 065eee620ac6f75900511097afec7f8dcdb526ff Mon Sep 17 00:00:00 2001
From: Carlos Bravo <37012961+cbravobernal@users.noreply.github.com>
Date: Sun, 21 Dec 2025 13:51:16 +0100
Subject: [PATCH 6/6] Dangerous inline editing code
---
assets/src/js/pro/_acf-blocks.js | 113 ++--
.../js/pro/blocks-v3/components/block-edit.js | 495 +++++++++++++++--
.../pro/blocks-v3/components/block-preview.js | 7 +-
.../components/block-toolbar-fields.js | 243 +++++++++
.../components/inline-editing-toolbar.js | 512 ++++++++++--------
.../js/pro/blocks-v3/components/jsx-parser.js | 209 ++++++-
.../blocks-v3/components/popover-wrapper.js | 15 +-
.../js/pro/blocks-v3/utils/post-locking.js | 14 +
webpack.config.js | 2 +-
9 files changed, 1302 insertions(+), 308 deletions(-)
create mode 100644 assets/src/js/pro/blocks-v3/components/block-toolbar-fields.js
diff --git a/assets/src/js/pro/_acf-blocks.js b/assets/src/js/pro/_acf-blocks.js
index dc08b062..7da87973 100644
--- a/assets/src/js/pro/_acf-blocks.js
+++ b/assets/src/js/pro/_acf-blocks.js
@@ -1086,52 +1086,57 @@ const md5 = require( 'md5' );
maybePreload( blockId, clientId, form ) {
acf.debug( 'Preload check', blockId, clientId, form );
- if ( ! isBlockInQueryLoop( this.props.clientId ) ) {
- const preloadedBlocks = acf.get( 'preloadedBlocks' );
- const modeText = form ? 'form' : 'preview';
-
- if ( preloadedBlocks && preloadedBlocks[ blockId ] ) {
- // Ensure we only preload the correct block state (form or preview).
- if (
- ( form && ! preloadedBlocks[ blockId ].form ) ||
- ( ! form && preloadedBlocks[ blockId ].form )
- ) {
- acf.debug( 'Preload failed: state not preloaded.' );
- return false;
- }
- // Set HTML to the preloaded version.
- preloadedBlocks[ blockId ].html = preloadedBlocks[
- blockId
- ].html.replaceAll( blockId, clientId );
-
- // Replace blockId in errors.
- if (
- preloadedBlocks[ blockId ].validation &&
- preloadedBlocks[ blockId ].validation.errors
- ) {
- preloadedBlocks[ blockId ].validation.errors =
- preloadedBlocks[ blockId ].validation.errors.map(
- ( error ) => {
- error.input = error.input.replaceAll(
- blockId,
- clientId
- );
- return error;
- }
- );
- }
+ // Early return if block is in query loop.
+ if ( isBlockInQueryLoop( this.props.clientId ) ) {
+ acf.debug( 'Preload failed: Block is in query loop.' );
+ return false;
+ }
- // Return preloaded object.
- acf.debug(
- 'Preload successful',
- preloadedBlocks[ blockId ]
- );
- return preloadedBlocks[ blockId ];
- }
+ const preloadedBlocks = acf.get( 'preloadedBlocks' );
+
+ // Early return if no preloaded blocks exist.
+ if ( ! preloadedBlocks || ! preloadedBlocks[ blockId ] ) {
+ acf.debug( 'Preload failed: Block not preloaded.' );
+ return false;
}
- acf.debug( 'Preload failed: not preloaded.' );
- return false;
+
+ // Create a copy to avoid mutating the original preloaded data.
+ const preloadedBlock = { ...preloadedBlocks[ blockId ] };
+
+ // Ensure we only preload the correct block state (form or preview).
+ if (
+ ( form && ! preloadedBlock.form ) ||
+ ( ! form && preloadedBlock.form )
+ ) {
+ acf.debug(
+ 'Preload failed: Correct state not preloaded.',
+ form ? 'form' : 'preview'
+ );
+ return false;
+ }
+
+ // Replace blockId with clientId in HTML.
+ preloadedBlock.html = preloadedBlock.html.replaceAll(
+ blockId,
+ clientId
+ );
+
+ // Replace blockId in validation errors.
+ if (
+ preloadedBlock.validation &&
+ preloadedBlock.validation.errors
+ ) {
+ preloadedBlock.validation.errors =
+ preloadedBlock.validation.errors.map( ( error ) => {
+ error.input = error.input.replaceAll( blockId, clientId );
+ return error;
+ } );
+ }
+
+ // Return preloaded object.
+ acf.debug( 'Preload successful', preloadedBlock );
+ return preloadedBlock;
}
loadState() {
@@ -1254,7 +1259,16 @@ const md5 = require( 'md5' );
const $thisParent = $( this.el );
// Move $el into place.
- $thisParent.html( $el );
+ // Skip this in StrictMode unless context is 'append' or component is subscribed.
+ if (
+ ! (
+ acf.get( 'StrictMode' ) &&
+ context !== 'append' &&
+ ! this.subscribed
+ )
+ ) {
+ $thisParent.html( $el );
+ }
// Special case for reusable blocks.
// Multiple instances of the same reusable block share the same block id.
@@ -1870,12 +1884,11 @@ const md5 = require( 'md5' );
// Register block types.
const blockTypes = acf.get( 'blockTypes' );
if ( blockTypes ) {
- // Only register blocks with version < 3 (v3 blocks are registered separately).
- blockTypes
- .filter(
- ( blockType ) => parseInt( blockType.acf_block_version ) < 3
- )
- .map( registerBlockType );
+ // Only register blocks with version <= 2 (v3+ blocks are registered separately).
+ blockTypes.forEach( ( blockType ) => {
+ parseInt( blockType.acf_block_version ) <= 2 &&
+ registerBlockType( blockType );
+ } );
}
}
diff --git a/assets/src/js/pro/blocks-v3/components/block-edit.js b/assets/src/js/pro/blocks-v3/components/block-edit.js
index 0c791fc7..b7e81f53 100644
--- a/assets/src/js/pro/blocks-v3/components/block-edit.js
+++ b/assets/src/js/pro/blocks-v3/components/block-edit.js
@@ -14,23 +14,18 @@ import {
} from '@wordpress/element';
import {
- BlockControls,
InspectorControls,
useBlockProps,
useBlockEditContext,
} from '@wordpress/block-editor';
-import {
- Button,
- ToolbarGroup,
- ToolbarButton,
- Placeholder,
- Spinner,
- Modal,
-} from '@wordpress/components';
+import { Button, Placeholder, Spinner, Modal } from '@wordpress/components';
import { BlockPlaceholder } from './block-placeholder';
import { BlockForm } from './block-form';
import { BlockPreview } from './block-preview';
import { ErrorBoundary, BlockPreviewErrorFallback } from './error-boundary';
+import { BlockToolbarFields } from './block-toolbar-fields';
+import { InlineEditingToolbar } from './inline-editing-toolbar';
+import { PopoverWrapper } from './popover-wrapper';
import {
lockPostSaving,
unlockPostSaving,
@@ -109,6 +104,33 @@ export const BlockEdit = ( props ) => {
const [ hasFetchedOnce, setHasFetchedOnce ] = useState( false );
const [ ajaxRequest, setAjaxRequest ] = useState();
+ // New state for inline editing features
+ const [ blockToolbarFields, setBlockToolbarFields ] = useState( [] );
+ const [ blockFieldInfo, setBlockFieldInfo ] = useState( null );
+ const [ gutenbergIframeOrDocument, setGutenbergIframeOrDocument ] =
+ useState( () => {
+ const iframe = document.querySelector( '[name="editor-canvas"]' );
+ return iframe
+ ? iframe.contentDocument || iframe.contentWindow.document
+ : document;
+ } );
+ const [ currentInlineEditingElement, setCurrentInlineEditingElement ] =
+ useState( null );
+ const [
+ currentInlineEditingElementUid,
+ setCurrentInlineEditingElementUid,
+ ] = useState( null );
+ const [ currentContentEditableElement, setCurrentContentEditableElement ] =
+ useState( null );
+ const [ inlineEditingToolbarHasFocus, setInlineEditingToolbarHasFocus ] =
+ useState( false );
+ const [
+ contentEditableChangeInProgress,
+ setContentEditableChangeInProgress,
+ ] = useState( false );
+ const [ acfDynamicStylesElement, setAcfDynamicStylesElement ] =
+ useState( null );
+
const acfFormRef = useRef( null );
const previewRef = useRef( null );
const debounceRef = useRef( null );
@@ -187,6 +209,16 @@ export const BlockEdit = ( props ) => {
setBlockFormHtml( response.data.form );
+ // Handle new field metadata for inline editing
+ if ( response.data.fields ) {
+ setBlockFieldInfo( response.data.fields );
+ }
+
+ // Handle block toolbar fields configuration
+ if ( response.data.blockToolbarFields ) {
+ setBlockToolbarFields( response.data.blockToolbarFields );
+ }
+
if ( response.data.preview ) {
setBlockPreviewHtml(
acf.applyFilters(
@@ -318,6 +350,16 @@ export const BlockEdit = ( props ) => {
);
}
+ // Handle block toolbar fields from preloaded data
+ if ( data?.blockToolbarFields ) {
+ setBlockToolbarFields( data.blockToolbarFields );
+ }
+
+ // Handle field info from preloaded data
+ if ( data?.fields ) {
+ setBlockFieldInfo( data.fields );
+ }
+
if (
data?.validation &&
! data.validation.valid &&
@@ -362,9 +404,13 @@ export const BlockEdit = ( props ) => {
// Listen for validation error events from other blocks
useEffect( () => {
- const handleErrorEvent = () => {
- lockPostSaving( clientId );
- setShowValidationErrors( true );
+ const handleErrorEvent = ( event ) => {
+ // Only handle if this event is for this specific block
+ if ( clientId === event.detail.acfBlockWithValidationErrors ) {
+ lockPostSaving( clientId );
+ setShowValidationErrors( true );
+ setCurrentInlineEditingElementUid( null );
+ }
};
document.addEventListener( 'acf/block/has-error', handleErrorEvent );
@@ -374,7 +420,6 @@ export const BlockEdit = ( props ) => {
'acf/block/has-error',
handleErrorEvent
);
- unlockPostSaving( clientId );
};
}, [] );
@@ -389,6 +434,7 @@ export const BlockEdit = ( props ) => {
// Handle form data changes with debouncing
useEffect( () => {
clearTimeout( debounceRef.current );
+ lockPostSavingByName( 'acf-fetching-block' );
debounceRef.current = setTimeout( () => {
const parsedData = JSON.parse( theSerializedAcfData );
@@ -421,6 +467,12 @@ export const BlockEdit = ( props ) => {
};
setAttributes( updatedAttributes );
}, 200 );
+
+ // Cleanup function to unlock post saving
+ return () => {
+ clearTimeout( debounceRef.current );
+ unlockPostSavingByName( 'acf-fetching-block' );
+ };
}, [ theSerializedAcfData, attributesWithoutError ] );
// Trigger ACF actions when preview is rendered
@@ -435,6 +487,14 @@ export const BlockEdit = ( props ) => {
$preview,
attributes
);
+
+ // If there's an active inline editing element, re-initialize it after preview renders
+ if ( currentInlineEditingElementUid ) {
+ const inlineElement = previewRef?.current.querySelector(
+ `[data-acf-inline-fields-uid="${ currentInlineEditingElementUid }"]`
+ );
+ setCurrentInlineEditingElement( inlineElement );
+ }
}
}, [ blockPreviewHtml ] );
@@ -453,6 +513,28 @@ export const BlockEdit = ( props ) => {
setUserHasInteractedWithForm={ setUserHasInteractedWithForm }
previewRef={ previewRef }
hasFetchedOnce={ hasFetchedOnce }
+ blockToolbarFields={ blockToolbarFields }
+ blockFieldInfo={ blockFieldInfo }
+ gutenbergIframeOrDocument={ gutenbergIframeOrDocument }
+ setGutenbergIframeOrDocument={ setGutenbergIframeOrDocument }
+ currentInlineEditingElement={ currentInlineEditingElement }
+ setCurrentInlineEditingElement={ setCurrentInlineEditingElement }
+ currentInlineEditingElementUid={ currentInlineEditingElementUid }
+ setCurrentInlineEditingElementUid={
+ setCurrentInlineEditingElementUid
+ }
+ currentContentEditableElement={ currentContentEditableElement }
+ setCurrentContentEditableElement={
+ setCurrentContentEditableElement
+ }
+ inlineEditingToolbarHasFocus={ inlineEditingToolbarHasFocus }
+ setInlineEditingToolbarHasFocus={ setInlineEditingToolbarHasFocus }
+ contentEditableChangeInProgress={ contentEditableChangeInProgress }
+ setContentEditableChangeInProgress={
+ setContentEditableChangeInProgress
+ }
+ acfDynamicStylesElement={ acfDynamicStylesElement }
+ setAcfDynamicStylesElement={ setAcfDynamicStylesElement }
/>
);
};
@@ -477,16 +559,60 @@ function BlockEditInner( props ) {
blockPreviewHtml,
blockFetcher,
userHasInteractedWithForm,
+ setUserHasInteractedWithForm,
previewRef,
hasFetchedOnce,
+ blockToolbarFields,
+ blockFieldInfo,
+ gutenbergIframeOrDocument,
+ setGutenbergIframeOrDocument,
+ currentInlineEditingElement,
+ setCurrentInlineEditingElement,
+ currentInlineEditingElementUid,
+ setCurrentInlineEditingElementUid,
+ currentContentEditableElement,
+ setCurrentContentEditableElement,
+ inlineEditingToolbarHasFocus,
+ setInlineEditingToolbarHasFocus,
+ contentEditableChangeInProgress,
+ setContentEditableChangeInProgress,
+ acfDynamicStylesElement,
+ setAcfDynamicStylesElement,
} = props;
const { clientId } = useBlockEditContext();
const inspectorControlsRef = useRef();
const [ isModalOpen, setIsModalOpen ] = useState( false );
+ const [ blockFormModalOpen, setBlockFormModalOpen ] = useState( false );
const modalFormContainerRef = useRef();
const [ currentFormContainer, setCurrentFormContainer ] = useState();
+ // Detect Gutenberg iframe or document
+ useEffect( () => {
+ const gutenbergIframe = document.querySelector(
+ 'iframe[name="editor-canvas"]'
+ );
+ if ( gutenbergIframe?.contentDocument ) {
+ setGutenbergIframeOrDocument( gutenbergIframe.contentDocument );
+ } else {
+ setGutenbergIframeOrDocument( document );
+ }
+ }, [] );
+
+ // Create/get dynamic styles element for inline field highlighting
+ useEffect( () => {
+ if ( ! gutenbergIframeOrDocument ) return;
+
+ let styleElement =
+ gutenbergIframeOrDocument.getElementById( 'acf-dynamic-styles' );
+ if ( ! styleElement ) {
+ styleElement = document.createElement( 'style' );
+ styleElement.id = 'acf-dynamic-styles';
+ gutenbergIframeOrDocument.head.appendChild( styleElement );
+ }
+ setAcfDynamicStylesElement( styleElement );
+ }, [ gutenbergIframeOrDocument ] );
+
// Set current form container when modal opens
useEffect( () => {
if ( isModalOpen && modalFormContainerRef?.current ) {
@@ -531,6 +657,161 @@ function BlockEditInner( props ) {
...useBlockProps( { className: blockClasses, ref: previewRef } ),
};
+ // Update field value from contentEditable changes (matches 6.7.0.2)
+ const updateFieldValueFromContentEditable = ( content, fieldSlug ) => {
+ if ( ! acfFormRef?.current || ! fieldSlug ) return;
+
+ const fieldWrapper = acfFormRef.current.querySelector(
+ `[data-name=${ fieldSlug }]`
+ );
+ if ( ! fieldWrapper ) return;
+
+ const fieldKey = fieldWrapper.attributes.getNamedItem( 'data-key' )
+ ?.value;
+ if ( ! fieldKey ) return;
+
+ const fieldInput = acfFormRef.current.querySelector(
+ `[name="acf-block_${ clientId }[${ fieldKey }]"`
+ );
+ if ( ! fieldInput ) return;
+
+ // Update field value and trigger serialization (debouncing happens in useEffect)
+ if ( content ) {
+ setUserHasInteractedWithForm( true );
+ }
+ setContentEditableChangeInProgress( false );
+ fieldInput.value = content;
+
+ const $form = $( acfFormRef?.current );
+ const serializedData = acf.serialize( $form, `acf-block_${ clientId }` );
+ if ( serializedData ) {
+ setTheSerializedAcfData( JSON.stringify( serializedData ) );
+ } else {
+ setUserHasInteractedWithForm( false );
+ }
+ };
+
+ // Watch for changes in contentEditable fields using MutationObserver
+ useEffect( () => {
+ if ( ! gutenbergIframeOrDocument || ! blockPreviewHtml ) return;
+
+ const observer = new MutationObserver( ( mutations ) => {
+ for ( const mutation of mutations ) {
+ // Handle text content changes
+ if ( mutation.type === 'characterData' ) {
+ let element = mutation.target.parentNode;
+ const blockElement = element?.closest( '[data-block]' );
+ const blockId = blockElement?.getAttribute( 'data-block' );
+
+ if ( ! element || ! blockElement || blockId !== clientId )
+ return;
+
+ // Find the contentEditable element
+ if (
+ element &&
+ ! element.hasAttribute(
+ 'data-acf-inline-contenteditable'
+ )
+ ) {
+ element = element.closest(
+ '[data-acf-inline-contenteditable]'
+ );
+ }
+
+ if (
+ element &&
+ element.hasAttribute( 'data-acf-inline-contenteditable' )
+ ) {
+ const fieldSlug = element.attributes.getNamedItem(
+ 'data-acf-inline-contenteditable-field-slug'
+ ).value;
+ let content = element.innerHTML.trim();
+ if ( ! content ) content = '';
+ updateFieldValueFromContentEditable( content, fieldSlug );
+ }
+ }
+ // Handle attribute or child list changes
+ else {
+ const element = mutation.target.closest(
+ '[data-acf-inline-contenteditable]'
+ );
+ const blockElement = element?.closest( '[data-block]' );
+
+ if (
+ ! element ||
+ ! blockElement ||
+ blockElement.getAttribute( 'data-block' ) !== clientId
+ )
+ return;
+
+ if ( element ) {
+ const fieldSlug = element.attributes.getNamedItem(
+ 'data-acf-inline-contenteditable-field-slug'
+ ).value;
+ let content = element.innerHTML.trim();
+
+ // Handle empty content - remove empty BR tags
+ if (
+ ! content ||
+ ( element.textContent.trim().length === 0 &&
+ element.children.length === 1 &&
+ element.firstElementChild &&
+ element.firstElementChild.nodeName === 'BR' )
+ ) {
+ element.innerHTML = '';
+ content = '';
+ }
+
+ updateFieldValueFromContentEditable( content, fieldSlug );
+ }
+ }
+ }
+ } );
+
+ // Observe the gutenberg iframe/document for changes
+ observer.observe( gutenbergIframeOrDocument, {
+ attributes: true,
+ childList: true,
+ subtree: true,
+ characterData: true,
+ attributeFilter: [ 'data-acf-inline-contenteditable' ],
+ } );
+
+ // Cleanup
+ return () => {
+ observer.disconnect();
+ };
+ }, [ blockPreviewHtml, gutenbergIframeOrDocument ] );
+
+ // Callback when a new inline editing element is selected
+ const handleNewInlineEditingElementSelected = ( uid ) => {
+ setTimeout( () => {
+ setCurrentInlineEditingElementUid( uid );
+ const element = previewRef?.current.querySelector(
+ `[data-acf-inline-fields-uid="${ uid }"]`
+ );
+ setCurrentInlineEditingElement( element );
+ if ( element ) {
+ element.scrollIntoView( {
+ behavior: 'smooth',
+ block: 'nearest',
+ } );
+ }
+ }, 1 );
+ };
+
+ // Callback when a new contentEditable element is selected
+ const handleNewContentEditableElementSelected = ( fieldSlug ) => {
+ if ( fieldSlug ) {
+ const element = previewRef?.current.querySelector(
+ `[data-acf-inline-contenteditable-field-slug="${ fieldSlug }"]`
+ );
+ setCurrentContentEditableElement( element );
+ } else {
+ setCurrentContentEditableElement( null );
+ }
+ };
+
// Determine portal target
let portalTarget = null;
if ( currentFormContainer ) {
@@ -539,21 +820,37 @@ function BlockEditInner( props ) {
portalTarget = inspectorControlsRef.current;
}
+ // Determine inline editing toolbar anchor (matches 6.7.0.2 logic)
+ let inlineEditingToolbarAnchor = null;
+ if ( currentInlineEditingElement && currentContentEditableElement ) {
+ inlineEditingToolbarAnchor = currentInlineEditingElement;
+ } else if ( currentInlineEditingElement && ! currentContentEditableElement ) {
+ inlineEditingToolbarAnchor = currentInlineEditingElement;
+ } else if ( ! currentInlineEditingElement && currentContentEditableElement ) {
+ inlineEditingToolbarAnchor = currentContentEditableElement;
+ }
+ // Ensure anchor is connected to DOM
+ if ( inlineEditingToolbarAnchor && ! inlineEditingToolbarAnchor.isConnected ) {
+ inlineEditingToolbarAnchor = null;
+ }
+
return (
<>
- { /* Block toolbar controls */ }
-
-
- {
- setIsModalOpen( true );
- } }
- />
-
-
+ { /* Block toolbar controls with inline editing support */ }
+
{ /* Inspector panel container */ }
@@ -637,9 +934,32 @@ function BlockEditInner( props ) {
isFullScreen={ true }
title={ blockType.title }
onRequestClose={ () => {
- setCurrentFormContainer( null );
- setIsModalOpen( false );
+ // Check if block is fetching
+ const isFetching =
+ document.body.classList.contains(
+ 'acf-fetching-block'
+ );
+ if ( ! isFetching ) {
+ setCurrentFormContainer( null );
+ setIsModalOpen( false );
+ }
} }
+ isDismissible={ false }
+ headerActions={ [
+ ,
+ ] }
>
+ { /* Inline Editing Toolbar */ }
+ { inlineEditingToolbarAnchor && (
+ {
+ const activeElement = document.activeElement;
+ return activeElement && activeElement.isContentEditable, false;
+ } )() }
+ variant="unstyled"
+ anchor={ inlineEditingToolbarAnchor }
+ className="acf-inline-editing-toolbar block-editor-block-list__block-popover"
+ placement="top-start"
+ onClose={ ( event ) => {
+ // Don't close if clicking toolbar button
+ if (
+ event.key !== 'Escape' &&
+ event.target.closest( '.acf-toolbar-button' )
+ ) {
+ return false;
+ }
+
+ // Handle Escape key
+ if ( event.key === 'Escape' ) {
+ return (
+ ! document.querySelector(
+ '.acf-inline-fields-popover-inner'
+ ) &&
+ ! inlineEditingToolbarHasFocus &&
+ ( currentInlineEditingElement &&
+ currentInlineEditingElement.focus(),
+ setCurrentInlineEditingElementUid( null ),
+ setCurrentContentEditableElement( null ),
+ true )
+ );
+ }
+
+ // Don't close if clicking on contenteditable element
+ if (
+ event.target.getAttribute(
+ 'data-acf-inline-contenteditable'
+ )
+ ) {
+ return false;
+ }
+
+ // Don't close if clicking inside popover or modal
+ const inlineFieldsPopover = event.target.closest(
+ '.acf-inline-fields-popover-inner'
+ );
+ const modal = event.target.closest(
+ '.components-modal__content'
+ );
+
+ return (
+ inlineFieldsPopover ||
+ modal ||
+ ( setCurrentInlineEditingElementUid( null ),
+ setCurrentContentEditableElement( null ) ),
+ true
+ );
+ } }
+ gutenbergIframeOrDocument={ gutenbergIframeOrDocument }
+ hidePrimaryBlockToolbar={ true }
+ >
+
+
+ ) }
+
+ { /* Dynamic styles for inline field highlighting */ }
+ { currentInlineEditingElementUid &&
+ acfDynamicStylesElement &&
+ createPortal(
+ ,
+ acfDynamicStylesElement
+ ) }
+
{ /* Block preview */ }
<>
+ blockPreviewHtml !== 'acf-block-preview-loading' &&
blockPreviewHtml !== 'acf-block-preview-no-html' &&
- blockPreviewHtml &&
- acf.parseJSX( blockPreviewHtml ) }
+ blockPreviewHtml
+ ? acf.parseJSX(
+ blockPreviewHtml,
+ setCurrentInlineEditingElementUid,
+ handleNewContentEditableElementSelected,
+ blockFieldInfo
+ )
+ : null,
+ [ blockPreviewHtml, blockFieldInfo ]
+ ) }
>
diff --git a/assets/src/js/pro/blocks-v3/components/block-preview.js b/assets/src/js/pro/blocks-v3/components/block-preview.js
index e8257953..505d390b 100644
--- a/assets/src/js/pro/blocks-v3/components/block-preview.js
+++ b/assets/src/js/pro/blocks-v3/components/block-preview.js
@@ -9,12 +9,9 @@
*
* @param {Object} props - Component props
* @param {React.ReactNode} props.children - Child elements to render
- * @param {string} props.blockPreviewHtml - HTML string of the block preview (used as key)
* @param {Object} props.blockProps - Block props from useBlockProps hook
* @returns {JSX.Element} - Rendered preview wrapper
*/
-export const BlockPreview = ( { children, blockPreviewHtml, blockProps } ) => (
-
- { children }
-
+export const BlockPreview = ( { children, blockProps } ) => (
+ { children }
);
diff --git a/assets/src/js/pro/blocks-v3/components/block-toolbar-fields.js b/assets/src/js/pro/blocks-v3/components/block-toolbar-fields.js
new file mode 100644
index 00000000..527401e5
--- /dev/null
+++ b/assets/src/js/pro/blocks-v3/components/block-toolbar-fields.js
@@ -0,0 +1,243 @@
+/**
+ * BlockToolbarFields Component
+ * Renders field buttons in the WordPress block toolbar (top toolbar)
+ * Allows quick access to edit specific fields from the block toolbar
+ */
+
+import { useState, useRef } from '@wordpress/element';
+import { BlockControls } from '@wordpress/blockEditor';
+import { ToolbarGroup, ToolbarButton, Modal } from '@wordpress/components';
+import { PopoverWrapper } from './popover-wrapper';
+
+/**
+ * BlockToolbarFields component
+ * Displays field editing buttons in the WordPress block toolbar
+ *
+ * @param {Object} props - Component props
+ * @param {Array} props.blockToolbarFields - Array of field names or field config objects to show in toolbar
+ * @param {Array} props.blockFieldInfo - Array of all field information
+ * @param {Function} props.setCurrentBlockFormContainer - Setter for form container ref
+ * @param {Document|HTMLIFrameElement} props.gutenbergIframeOrDocument - Document or iframe reference
+ * @param {Function} props.setBlockFormModalOpen - Setter for block form modal state
+ * @param {boolean} props.blockFormModalOpen - Whether block form modal is open
+ * @returns {JSX.Element} - Rendered toolbar controls
+ */
+export const BlockToolbarFields = ( {
+ blockToolbarFields,
+ blockFieldInfo,
+ setCurrentBlockFormContainer,
+ gutenbergIframeOrDocument,
+ setBlockFormModalOpen,
+ blockFormModalOpen,
+} ) => {
+ const [ selectedFieldKey, setSelectedFieldKey ] = useState( null );
+ const [ selectedFieldButtonRef, setSelectedFieldButtonRef ] = useState();
+ const [ usePopover, setUsePopover ] = useState( true );
+ const fieldPopoverContainerRef = useRef();
+
+ /**
+ * Get field type class name from field name
+ */
+ const getFieldTypeClassName = ( fieldName ) => {
+ if ( ! fieldName || ! blockFieldInfo ) return '';
+ const field = blockFieldInfo.find( ( f ) => f.name === fieldName );
+ return field?.type ? field.type.replace( /_/g, '-' ) : '';
+ };
+
+ /**
+ * Get field info object from field name
+ */
+ const getFieldInfo = ( fieldName ) => {
+ return fieldName && blockFieldInfo
+ ? blockFieldInfo.find( ( f ) => f.name === fieldName )
+ : null;
+ };
+
+ /**
+ * Get field label from field name
+ */
+ const getFieldLabel = ( fieldName ) => {
+ if ( ! fieldName || ! blockFieldInfo ) return fieldName || '';
+ const field = blockFieldInfo.find( ( f ) => f.name === fieldName );
+ // Fallback to fieldName when label not found to ensure toolbar shows a title
+ return field?.label || fieldName || '';
+ };
+ return (
+
+ { /* Edit Block button */ }
+
+ {
+ setBlockFormModalOpen( true );
+ } }
+ isPressed={ blockFormModalOpen }
+ />
+
+
+ { /* Field buttons */ }
+ { blockToolbarFields.length > 0 && (
+
+ { /* Style to show selected field in form */ }
+ { ( () => {
+ const styleContent = `[data-name="${ selectedFieldKey }"]{ display: block!important; }`;
+ return ;
+ } )() }
+
+ { /* Render field buttons */ }
+ { blockToolbarFields &&
+ blockToolbarFields.map( ( field ) => {
+ let fieldName = '';
+ let fieldIconSvg = null;
+ let fieldLabel = null;
+
+ // Handle field config object or simple string
+ if ( typeof field === 'object' ) {
+ fieldName = field.fieldName
+ ? field.fieldName
+ : field.index;
+ fieldIconSvg = field.fieldIcon
+ ? window.atob( field.fieldIcon )
+ : null;
+ fieldLabel = field.fieldLabel
+ ? field.fieldLabel
+ : fieldName;
+ } else {
+ fieldName = field;
+ }
+
+ return (
+
+ ) : (
+
+ )
+ }
+ label={
+ fieldLabel || getFieldLabel( fieldName )
+ }
+ isPressed={ fieldName === selectedFieldKey }
+ ref={
+ fieldName === selectedFieldKey
+ ? setSelectedFieldButtonRef
+ : null
+ }
+ onMouseDown={ () => {
+ if ( selectedFieldKey === fieldName ) {
+ setSelectedFieldKey( null );
+ } else {
+ setSelectedFieldKey( null );
+ setTimeout( () => {
+ const fieldInfo =
+ getFieldInfo( fieldName );
+ // Use modal for complex field types
+ if (
+ fieldInfo?.type ===
+ 'flexible_content' ||
+ fieldInfo?.type ===
+ 'repeater'
+ ) {
+ setUsePopover( false );
+ } else {
+ setUsePopover( true );
+ }
+ setSelectedFieldKey(
+ fieldName
+ );
+ } );
+ }
+ } }
+ />
+ );
+ } ) }
+
+ { /* Popover for simple field types */ }
+ { selectedFieldKey &&
+ selectedFieldButtonRef &&
+ usePopover && (
+ {
+ // Don't close on certain events
+ if (
+ event.key !== 'Escape' &&
+ ( event?.target.closest(
+ '.media-modal'
+ ) ||
+ event?.target.closest(
+ '.acf-tooltip'
+ ) ||
+ ( event?.target &&
+ fieldPopoverContainerRef?.current &&
+ fieldPopoverContainerRef?.current.contains(
+ event.target
+ ) ) ||
+ ( selectedFieldButtonRef?.current &&
+ selectedFieldButtonRef?.current.contains(
+ event?.target
+ ) ) )
+ ) {
+ return false;
+ }
+ setSelectedFieldKey( null );
+ return true;
+ } }
+ variant="unstyled"
+ gutenbergIframeOrDocument={
+ gutenbergIframeOrDocument
+ }
+ >
+
+
+ ) }
+
+ { /* Modal for complex field types */ }
+ { selectedFieldKey &&
+ selectedFieldButtonRef &&
+ ! usePopover && (
+ {
+ setSelectedFieldKey( null );
+ } }
+ >
+
+
+ ) }
+
+ ) }
+
+ );
+};
diff --git a/assets/src/js/pro/blocks-v3/components/inline-editing-toolbar.js b/assets/src/js/pro/blocks-v3/components/inline-editing-toolbar.js
index 575e4438..3092f7fa 100644
--- a/assets/src/js/pro/blocks-v3/components/inline-editing-toolbar.js
+++ b/assets/src/js/pro/blocks-v3/components/inline-editing-toolbar.js
@@ -4,10 +4,9 @@
* Handles field selection and editing for inline editable elements
*/
-import { useState, useEffect, useMemo, createPortal } from '@wordpress/element';
-import { Button, Modal } from '@wordpress/components';
+import { useState, useEffect, useMemo, useRef } from '@wordpress/element';
+import { Toolbar, ToolbarGroup, ToolbarButton, Modal } from '@wordpress/components';
import { PopoverWrapper } from './popover-wrapper';
-import { BlockForm } from './block-form';
/**
* InlineEditingToolbar component
@@ -16,7 +15,6 @@ import { BlockForm } from './block-form';
* @param {Object} props - Component props
* @param {Object} props.blockIcon - Block icon configuration
* @param {Array} props.blockFieldInfo - Array of field information
- * @param {React.RefObject} props.acfFormRef - Reference to ACF form
* @param {Function} props.setInlineEditingToolbarHasFocus - Setter for toolbar focus state
* @param {Element|null} props.currentContentEditableElement - Current content editable element
* @param {Element|null} props.currentInlineEditingElement - Current inline editing element
@@ -29,7 +27,6 @@ import { BlockForm } from './block-form';
export const InlineEditingToolbar = ( {
blockIcon,
blockFieldInfo,
- acfFormRef,
setInlineEditingToolbarHasFocus,
currentContentEditableElement,
currentInlineEditingElement,
@@ -38,19 +35,20 @@ export const InlineEditingToolbar = ( {
setCurrentBlockFormContainer,
contentEditableChangeInProgress,
} ) => {
- const [ isFieldPopoverOpen, setIsFieldPopoverOpen ] = useState( false );
- const [ isFieldModalOpen, setIsFieldModalOpen ] = useState( false );
const [ selectedFieldKey, setSelectedFieldKey ] = useState( null );
- const [ popoverAnchor, setPopoverAnchor ] = useState( null );
- const fieldPopoverContainerRef = useState( null );
+ const [ selectedFieldConfig, setSelectedFieldConfig ] = useState();
+ const [ selectedFieldButtonRef, setSelectedFieldButtonRef ] = useState();
+ const [ usePopover, setUsePopover ] = useState( true );
+ const fieldPopoverContainerRef = useRef();
- // Parse inline fields from data attribute
+ // Get inline fields from data attribute
const inlineFieldsAttr = currentInlineEditingElement
? currentInlineEditingElement.getAttribute( 'data-acf-inline-fields' )
: null;
+
let inlineFields = [];
try {
- inlineFields = JSON.parse( inlineFieldsAttr || '[]' );
+ inlineFields = JSON.parse( inlineFieldsAttr );
} catch ( e ) {
acf.debug(
'Inline fields were not a properly formatted JSON array',
@@ -59,243 +57,331 @@ export const InlineEditingToolbar = ( {
}
/**
- * Get field type by field name
+ * Get field type class name from field name
*/
- const getFieldType = ( fieldName ) => {
+ function getFieldTypeClassName( fieldName ) {
+ if ( ! fieldName || ! blockFieldInfo ) return '';
const field = blockFieldInfo.find( ( f ) => f.name === fieldName );
- return field?.type || null;
- };
-
- /**
- * Get field info by field name
- */
- const getFieldInfo = ( fieldName ) => {
- return blockFieldInfo.find( ( f ) => f.name === fieldName ) || null;
- };
+ return field?.type ? field.type.replace( /_/g, '-' ) : '';
+ }
/**
- * Get field label by field name
+ * Get field label from field name
*/
- const getFieldLabel = ( fieldName ) => {
- const field = getFieldInfo( fieldName );
- return field?.label || fieldName;
- };
+ function getFieldLabel( fieldName ) {
+ if ( ! fieldName || ! blockFieldInfo ) return '';
+ const field = blockFieldInfo.find( ( f ) => f.name === fieldName );
+ return field ? field.label : '';
+ }
- /**
- * Check if field type requires modal
- */
- const isComplexFieldType = ( fieldType ) => {
- return [ 'flexible_content', 'repeater', 'group' ].includes(
- fieldType
- );
- };
+ // Clear selected field when content editable changes
+ useEffect( () => {
+ setSelectedFieldKey( null );
+ }, [ contentEditableChangeInProgress ] );
- /**
- * Get toolbar icon from data attribute or field or default
- */
+ // Generate toolbar icon
const toolbarIcon = useMemo( () => {
- // Check for custom toolbar icon in data attribute
- const customIcon = currentInlineEditingElement?.getAttribute(
- 'data-acf-toolbar-icon'
- );
- if ( customIcon ) {
- // Decode base64 SVG if present
- try {
- if ( customIcon.startsWith( 'data:image/svg+xml;base64,' ) ) {
- return customIcon;
- }
- } catch ( e ) {
- // Ignore
- }
- }
+ let icon =
+ currentContentEditableElement && ! currentInlineEditingElement
+ ? currentContentEditableElement.getAttribute(
+ 'data-acf-toolbar-icon'
+ )
+ : null;
- // Use field icon if available
- if ( currentContentEditableElement ) {
- const fieldSlug = currentContentEditableElement.getAttribute(
- 'data-acf-inline-contenteditable-field-slug'
- );
- const field = getFieldInfo( fieldSlug );
- if ( field?.icon ) {
- return field.icon;
- }
+ if ( ! icon && currentInlineEditingElement ) {
+ icon = currentInlineEditingElement
+ ? currentInlineEditingElement.getAttribute(
+ 'data-acf-toolbar-icon'
+ )
+ : null;
}
- // Default icon
- return blockIcon || 'edit';
- }, [
- currentInlineEditingElement,
- currentContentEditableElement,
- blockIcon,
- ] );
-
- /**
- * Get toolbar title from data attribute or element type or field label
- */
- const toolbarTitle = useMemo( () => {
- // Check for custom toolbar title in data attribute
- const customTitle = currentInlineEditingElement?.getAttribute(
- 'data-acf-toolbar-title'
- );
- if ( customTitle ) {
- return customTitle;
+ if ( icon ) {
+ icon = ;
}
- // Use content editable field label if available
- if ( currentContentEditableElement ) {
+ if ( ! icon && currentContentEditableElement && ! currentInlineEditingElement ) {
const fieldSlug = currentContentEditableElement.getAttribute(
'data-acf-inline-contenteditable-field-slug'
);
- return getFieldLabel( fieldSlug );
+ icon = (
+
+ );
}
- // Use element type (DIV, P, SPAN, etc.) if no field is selected
- if ( currentInlineEditingElement ) {
- const tagName = currentInlineEditingElement.tagName;
- return tagName.charAt( 0 ) + tagName.slice( 1 ).toLowerCase();
+ if ( ! icon ) {
+ icon = React.isValidElement( blockIcon ) ? (
+ blockIcon
+ ) : (
+
+ );
}
- return acf.__( 'Edit' );
- }, [
- currentInlineEditingElement,
- currentContentEditableElement,
- blockFieldInfo,
- ] );
+ return icon;
+ }, [ blockFieldInfo, currentContentEditableElement, currentInlineEditingElement ] );
- /**
- * Handle field button click
- */
- const handleFieldButtonClick = ( fieldName, buttonElement ) => {
- const fieldType = getFieldType( fieldName );
+ // Generate toolbar title
+ const toolbarTitle = useMemo( () => {
+ let fieldName;
- // Set selected field
- setSelectedFieldKey( fieldName );
+ if ( currentContentEditableElement && ! currentInlineEditingElement ) {
+ const title = currentContentEditableElement.getAttribute(
+ 'data-acf-toolbar-title'
+ );
+ if ( title ) return title;
- // Open modal for complex field types
- if ( isComplexFieldType( fieldType ) ) {
- setIsFieldModalOpen( true );
- setIsFieldPopoverOpen( false );
- } else {
- // Open popover for simple fields
- setPopoverAnchor( buttonElement );
- setIsFieldPopoverOpen( true );
- }
- };
+ fieldName = currentContentEditableElement.getAttribute(
+ 'data-acf-inline-contenteditable-field-slug'
+ );
+ } else if ( currentInlineEditingElement ) {
+ const title =
+ currentInlineEditingElement.getAttribute( 'data-acf-toolbar-title' );
+ if ( title ) return title;
- /**
- * Close field popover/modal
- */
- const closeFieldEditor = () => {
- setIsFieldPopoverOpen( false );
- setIsFieldModalOpen( false );
- setSelectedFieldKey( null );
- setPopoverAnchor( null );
- };
+ if ( inlineFields.length > 1 ) {
+ const elementTypeLabels = {
+ A: 'Link',
+ DIV: 'Division',
+ P: 'Paragraph',
+ SPAN: 'Span',
+ INPUT: 'Input',
+ BUTTON: 'Button',
+ IMG: 'Image',
+ UL: 'Unordered List',
+ OL: 'Ordered List',
+ LI: 'List Item',
+ H1: 'Heading 1',
+ H2: 'Heading 2',
+ H3: 'Heading 3',
+ H4: 'Heading 4',
+ H5: 'Heading 5',
+ H6: 'Heading 6',
+ TABLE: 'Table',
+ TR: 'Table Row',
+ TD: 'Table Cell',
+ TH: 'Table Header',
+ FORM: 'Form',
+ TEXTAREA: 'Text Area',
+ SELECT: 'Select',
+ OPTION: 'Option',
+ };
+ return elementTypeLabels[ currentInlineEditingElement.tagName ];
+ }
- // Clean up when toolbar loses focus
- useEffect( () => {
- setInlineEditingToolbarHasFocus( true );
- return () => {
- setInlineEditingToolbarHasFocus( false );
- };
- }, [] );
+ fieldName =
+ typeof inlineFields[ 0 ] === 'object'
+ ? inlineFields[ 0 ].fieldName
+ : inlineFields[ 0 ];
+ }
- if ( ! currentInlineEditingElement || ! currentInlineEditingElementUid ) {
- return null;
- }
+ return getFieldLabel( fieldName );
+ }, [ currentInlineEditingElement, currentContentEditableElement, blockFieldInfo ] );
return (
-
-
-
- { typeof toolbarIcon === 'string' &&
- toolbarIcon.startsWith( 'data:image' ) ? (
-

- ) : (
-
- ) }
-
-
- { toolbarTitle }
-
-
+ <>
+ { /* Field popover for simple fields */ }
+ { selectedFieldKey &&
+ selectedFieldButtonRef &&
+ usePopover &&
+ currentInlineEditingElementUid && (
+
{
+ if ( event.key === 'Escape' ) {
+ setSelectedFieldKey( null );
+ return true;
+ }
+ // Don't close if clicking inside the popover or anchor
+ if (
+ ( event?.target &&
+ fieldPopoverContainerRef?.current &&
+ fieldPopoverContainerRef?.current.contains(
+ event.target
+ ) ) ||
+ ( selectedFieldButtonRef?.current &&
+ selectedFieldButtonRef?.current.contains(
+ event?.target
+ ) )
+ ) {
+ return false;
+ }
+ return undefined;
+ } }
+ variant={ usePopover ? 'toolbar' : 'unstyled' }
+ gutenbergIframeOrDocument={ gutenbergIframeOrDocument }
+ hidePrimaryBlockToolbar={ true }
+ animate={ true }
+ >
+ {
+ setInlineEditingToolbarHasFocus( true );
+ } }
+ >
+
+
+
+ ) }
- { inlineFields.length > 0 && (
-
- { inlineFields.map( ( fieldName ) => {
- const field = getFieldInfo( fieldName );
- if ( ! field ) {
- return null;
+ { /* Modal for complex field types */ }
+ { selectedFieldKey &&
+ selectedFieldButtonRef &&
+ ! usePopover &&
+ currentInlineEditingElementUid && (
+
f.name === selectedFieldKey
+ )?.label
+ : ''
}
-
- return (
-
+
+ ) }
- { /* Field popover for simple fields */ }
- { isFieldPopoverOpen && popoverAnchor && selectedFieldKey && (
-
-
- { acfFormRef?.current &&
- createPortal(
-
- { /* Render specific field from form */ }
-
+
+ { /* Toolbar icon and title */ }
+
+
+ { toolbarIcon }
+ { toolbarTitle }
+
+
+
+ { /* Field buttons */ }
+
+ { ( () => {
+ if ( ! inlineFields || inlineFields.length === 0 ) {
+ return null;
+ }
+
+ const buttons = inlineFields.map( ( field, index ) => {
+ let fieldName = '';
+ let fieldIconSvg = null;
+ let fieldLabel = null;
+
+ if ( typeof field === 'object' ) {
+ fieldName = field.fieldName
+ ? field.fieldName
+ : index;
+ fieldIconSvg = field.fieldIcon
+ ? window.atob( field.fieldIcon )
+ : null;
+ fieldLabel = field.fieldLabel
+ ? field.fieldLabel
+ : fieldName;
+ } else {
+ fieldName = field;
+ }
+
+ // Use 'edit' icon if field has useExpandedEditor flag
+ if ( ! fieldIconSvg && field?.useExpandedEditor ) {
+ fieldIconSvg = 'edit';
+ }
+
+ return (
+
+ )
+ ) : (
+
+ )
+ }
+ label={ fieldLabel || getFieldLabel( fieldName ) }
+ isPressed={ fieldName === selectedFieldKey }
+ ref={
+ fieldName === selectedFieldKey
+ ? setSelectedFieldButtonRef
+ : null
+ }
+ onClick={ () => {
+ setInlineEditingToolbarHasFocus( true );
+ if ( selectedFieldKey === fieldName ) {
+ setSelectedFieldKey( null );
+ } else {
+ // Determine if we should use modal or popover
+ setUsePopover( ! field?.useExpandedEditor );
+ setSelectedFieldKey( fieldName );
+ setSelectedFieldConfig( field );
+ }
} }
/>
- ,
- fieldPopoverContainerRef
- ) }
-
-
- ) }
+ );
+ } );
- { /* Field modal for complex fields */ }
- { isFieldModalOpen && selectedFieldKey && (
-
-
- { acfFormRef?.current && (
-
- ) }
-
-
- ) }
-
+ return buttons;
+ } )() }
+
+
+
+
+ { /* Style to show selected field */ }
+ { ( () => {
+ const styleContent = `[data-name="${ selectedFieldKey }"]{ display: block!important; }`;
+ return ;
+ } )() }
+ >
);
};
diff --git a/assets/src/js/pro/blocks-v3/components/jsx-parser.js b/assets/src/js/pro/blocks-v3/components/jsx-parser.js
index a0e60e70..7cb75ba9 100644
--- a/assets/src/js/pro/blocks-v3/components/jsx-parser.js
+++ b/assets/src/js/pro/blocks-v3/components/jsx-parser.js
@@ -154,9 +154,18 @@ function parseAttribute( attribute ) {
*
* @param {Node} node - The DOM node to parse
* @param {number} depth - Current recursion depth (0-based)
+ * @param {Function} setCurrentInlineEditingElementUid - Setter for inline editing UID
+ * @param {Function} setCurrentContentEditableElement - Setter for contentEditable element
+ * @param {Array} blockFieldInfo - Array of field info objects
* @returns {JSX.Element|null} - React element or null if node should be skipped
*/
-function parseNodeToJSX( node, depth = 0 ) {
+function parseNodeToJSX(
+ node,
+ depth = 0,
+ setCurrentInlineEditingElementUid = null,
+ setCurrentContentEditableElement = null,
+ blockFieldInfo = null
+) {
// Determine the component type for this node
const componentType = getComponentType( node.nodeName.toLowerCase() );
@@ -176,6 +185,152 @@ function parseNodeToJSX( node, depth = 0 ) {
props[ name ] = value;
} );
+ // Add inline fields event handlers
+ if ( node.hasAttribute( 'data-acf-inline-fields' ) ) {
+ props.style = {
+ ...parseStyleAttribute( node.getAttribute( 'style' ) || '' ),
+ pointerEvents: 'all',
+ };
+ props.role = 'button';
+ props.tabIndex = 0;
+
+ props.onFocus = ( event ) => {
+ event.stopPropagation();
+ setCurrentInlineEditingElementUid &&
+ setCurrentInlineEditingElementUid(
+ node.attributes.getNamedItem(
+ 'data-acf-inline-fields-uid'
+ ).value
+ );
+ };
+
+ props.onMouseDown = ( event ) => event.stopPropagation();
+
+ props.onClick = ( event ) => {
+ event.stopPropagation();
+ const link = event.target.closest( 'a' );
+ if ( link && link.tagName === 'A' ) {
+ event.preventDefault();
+ acf.debug( `Navigation prevented for ${ link.href }` );
+ }
+ if (
+ ! event.target.hasAttribute( 'data-acf-inline-contenteditable' )
+ ) {
+ setCurrentInlineEditingElementUid &&
+ setCurrentInlineEditingElementUid(
+ node.attributes.getNamedItem(
+ 'data-acf-inline-fields-uid'
+ ).value
+ );
+ }
+ };
+
+ props.onKeyDown = ( event ) => {
+ if ( event.key === 'Tab' && event.shiftKey ) {
+ event.preventDefault();
+ const toolbar = document.querySelector(
+ '.acf-inline-editing-toolbar'
+ );
+ const button = toolbar?.querySelector( 'button' );
+ if ( button ) {
+ button.focus();
+ setCurrentInlineEditingElementUid &&
+ setCurrentInlineEditingElementUid(
+ node.attributes.getNamedItem(
+ 'data-acf-inline-fields-uid'
+ ).value
+ );
+ }
+ }
+ if ( event.key === 'Enter' ) {
+ event.stopPropagation();
+ const link = event.target.closest( 'a' );
+ if ( link && link.tagName === 'A' ) {
+ event.preventDefault();
+ acf.debug( `Navigation prevented for ${ link.href }` );
+ }
+ setCurrentInlineEditingElementUid &&
+ setCurrentInlineEditingElementUid(
+ node.attributes.getNamedItem(
+ 'data-acf-inline-fields-uid'
+ ).value
+ );
+ }
+ };
+ }
+
+ // Add contentEditable handlers
+ if ( node.hasAttribute( 'data-acf-inline-contenteditable' ) ) {
+ const fieldSlug = node.attributes.getNamedItem(
+ 'data-acf-inline-contenteditable-field-slug'
+ ).value;
+ const editableFields = blockFieldInfo
+ ? blockFieldInfo.filter(
+ ( field ) =>
+ field.name === fieldSlug &&
+ ( field.type === 'text' || field.type === 'textarea' )
+ )
+ : [];
+
+ if ( editableFields.length > 0 ) {
+ props.contentEditable = true;
+ props.suppressContentEditableWarning = true;
+ props.role = 'input';
+ props.tabIndex = 0;
+
+ props.onFocus = ( event ) => {
+ const link = event.target.closest( 'a' );
+ if ( link && link.tagName === 'A' ) {
+ event.preventDefault();
+ acf.debug( `Navigation prevented for ${ link.href }` );
+ }
+ event.stopPropagation();
+ setCurrentContentEditableElement &&
+ setCurrentContentEditableElement(
+ node.attributes.getNamedItem(
+ 'data-acf-inline-contenteditable-field-slug'
+ ).value
+ );
+
+ if ( node.hasAttribute( 'data-acf-inline-fields' ) ) {
+ setCurrentInlineEditingElementUid &&
+ setCurrentInlineEditingElementUid(
+ node.attributes.getNamedItem(
+ 'data-acf-inline-fields-uid'
+ ).value
+ );
+ } else {
+ setCurrentInlineEditingElementUid &&
+ setCurrentInlineEditingElementUid( null );
+ }
+ };
+
+ props.onPaste = ( event ) => {
+ event.preventDefault();
+ const text = event.clipboardData.getData( 'text/plain' );
+ event.currentTarget.textContent =
+ event.currentTarget.textContent + text;
+ };
+ } else {
+ // Remove invalid contentEditable attributes
+ delete props[ 'data-acf-inline-contenteditable-field-slug' ];
+ delete props[ 'data-acf-inline-contenteditable' ];
+ }
+ }
+
+ // Add click handler to clear selection if clicking outside inline fields
+ if (
+ ! node.hasAttribute( 'data-acf-inline-fields' ) &&
+ ! node.hasAttribute( 'data-acf-inline-contenteditable' )
+ ) {
+ props.onClick = ( event ) => {
+ if ( event.target === event.currentTarget ) {
+ setCurrentInlineEditingElementUid &&
+ setCurrentInlineEditingElementUid( null );
+ }
+ };
+ }
+
// Handle special ACFInnerBlocks component
if ( componentType === 'ACFInnerBlocks' ) {
return createElement( ACFInnerBlocksComponent, { ...props } );
@@ -192,7 +347,15 @@ function parseNodeToJSX( node, depth = 0 ) {
elementArray.push( textContent );
}
} else {
- elementArray.push( parseNodeToJSX( childNode, depth + 1 ) );
+ elementArray.push(
+ parseNodeToJSX(
+ childNode,
+ depth + 1,
+ setCurrentInlineEditingElementUid,
+ setCurrentContentEditableElement,
+ blockFieldInfo
+ )
+ );
}
} );
@@ -200,14 +363,46 @@ function parseNodeToJSX( node, depth = 0 ) {
return createElement.apply( this, elementArray );
}
+/**
+ * Helper function to parse style attribute string into object
+ *
+ * @param {string} styleString - CSS style string
+ * @returns {Object} - Style object for React
+ */
+function parseStyleAttribute( styleString ) {
+ const styleObj = {};
+ if ( ! styleString ) return styleObj;
+
+ styleString.split( ';' ).forEach( ( rule ) => {
+ const [ property, value ] = rule.split( ':' ).map( ( s ) => s.trim() );
+ if ( property && value ) {
+ // Convert CSS property to camelCase
+ const camelProperty = property.replace( /-([a-z])/g, ( g ) =>
+ g[ 1 ].toUpperCase()
+ );
+ styleObj[ camelProperty ] = value;
+ }
+ } );
+
+ return styleObj;
+}
+
/**
* Main parseJSX function exposed on the acf global object
* Converts HTML string to React elements for use in ACF blocks
*
* @param {string} htmlString - HTML markup to parse
+ * @param {Function} setCurrentInlineEditingElementUid - Setter for inline editing UID
+ * @param {Function} setCurrentContentEditableElement - Setter for contentEditable element
+ * @param {Array} blockFieldInfo - Array of field info objects
* @returns {Array|JSX.Element} - React children from parsed HTML
*/
-export function parseJSX( htmlString ) {
+export function parseJSX(
+ htmlString,
+ setCurrentInlineEditingElementUid = null,
+ setCurrentContentEditableElement = null,
+ blockFieldInfo = null
+) {
// Wrap in div to ensure valid HTML structure
htmlString = '' + htmlString + '
';
@@ -218,7 +413,13 @@ export function parseJSX( htmlString ) {
);
// Parse with jQuery, convert to React, and extract children from wrapper div
- const parsedElement = parseNodeToJSX( jQuery( htmlString )[ 0 ], 0 );
+ const parsedElement = parseNodeToJSX(
+ jQuery( htmlString )[ 0 ],
+ 0,
+ setCurrentInlineEditingElementUid,
+ setCurrentContentEditableElement,
+ blockFieldInfo
+ );
return parsedElement.props.children;
}
diff --git a/assets/src/js/pro/blocks-v3/components/popover-wrapper.js b/assets/src/js/pro/blocks-v3/components/popover-wrapper.js
index 0bebda19..3cb0448e 100644
--- a/assets/src/js/pro/blocks-v3/components/popover-wrapper.js
+++ b/assets/src/js/pro/blocks-v3/components/popover-wrapper.js
@@ -50,9 +50,7 @@ export const PopoverWrapper = ( {
*/
const handleEscapeKey = ( event ) => {
if ( event.key === 'Escape' ) {
- event.preventDefault();
- event.stopPropagation();
- onClose?.();
+ onClose?.( event );
}
};
@@ -65,7 +63,7 @@ export const PopoverWrapper = ( {
'.' + className.split( ' ' ).join( '.' )
);
if ( ! popoverElement ) {
- onClose?.();
+ onClose?.( event );
}
};
@@ -114,6 +112,15 @@ export const PopoverWrapper = ( {
placement={ placement }
animate={ animate }
>
+ { hidePrimaryBlockToolbar && (
+
+ ) }
{ children }
);
diff --git a/assets/src/js/pro/blocks-v3/utils/post-locking.js b/assets/src/js/pro/blocks-v3/utils/post-locking.js
index c0797133..78fe34bb 100644
--- a/assets/src/js/pro/blocks-v3/utils/post-locking.js
+++ b/assets/src/js/pro/blocks-v3/utils/post-locking.js
@@ -29,6 +29,20 @@ export const unlockPostSaving = ( clientId ) => {
}
};
+/**
+ * Checks if post saving is currently locked for a specific block
+ *
+ * @param {string} clientId - The block's client ID
+ * @returns {boolean} - True if post saving is locked for this block
+ */
+export const isPostSavingLocked = ( clientId ) => {
+ const dispatch = wp.data.dispatch( 'core/editor' );
+ if ( ! dispatch ) {
+ return false;
+ }
+ return wp.data.select( 'core/editor' ).isPostSavingLocked( `acf/block/${ clientId }` );
+};
+
/**
* Locks post saving with a custom lock name
* Used for global operations that aren't tied to a specific block
diff --git a/webpack.config.js b/webpack.config.js
index 80ab01fb..721bec5c 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -49,7 +49,7 @@ const commonConfig = {
use: {
loader: 'babel-loader',
options: {
- presets: [ '@babel/preset-react' ],
+ presets: [ [ '@babel/preset-react', { runtime: 'automatic' } ] ],
plugins: process.env.COVERAGE_ENABLED
? [ 'istanbul' ]
: [],