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 ( -