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/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, + }, } ) ); 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() ); 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/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 } + + ); +}; 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/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..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; @@ -322,4 +345,52 @@ .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-tab-wrap { + display: none; +} + +.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.scss b/assets/src/sass/pro/acf-styles-in-iframe-for-blocks.scss new file mode 100644 index 00000000..ef866e92 --- /dev/null +++ b/assets/src/sass/pro/acf-styles-in-iframe-for-blocks.scss @@ -0,0 +1,18 @@ +[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; +} + +.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 baea5c0d..9744daaf 100644 --- a/includes/blocks.php +++ b/includes/blocks.php @@ -8,6 +8,9 @@ // Exit if accessed directly. defined( 'ABSPATH' ) || exit; +require_once dirname( __DIR__ ) . '/pro/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,14 @@ 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 +820,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' ) . '

' ) ); } @@ -868,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' ), ) @@ -905,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, @@ -913,6 +940,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 +1111,8 @@ function acf_ajax_fetch_block() { acf_prefix_fields( $fields, "acf-{$block['id']}" ); if ( $fields ) { + $response['fields'] = $fields; + // Start Capture. ob_start(); @@ -1092,8 +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 ) ); + } // Send response. wp_send_json_success( $response ); } @@ -1588,3 +1634,254 @@ 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. + * @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 { + * 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. + * } + * + * @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, + ); + + $args = wp_parse_args( $args, $default_args ); + + $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(); + + /** + * 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 ) ) { + $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 { + $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; + } + } + + 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. + * } + * + * @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 ); + $args['render'] = acf_get_data( 'acf_doing_block_preview' ); + + if ( ! $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/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/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() ); +} 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/' ),