PluginProbe ʕ •ᴥ•ʔ
Secure Custom Fields / 6.8.8
Secure Custom Fields v6.8.8
6.9.5 6.9.4 6.9.3 6.9.2 6.9.1 6.9.0 6.8.9 6.8.7 6.8.8 6.8.6 6.8.4 6.8.5 trunk 6.4.0-beta1 6.4.0-beta2 6.4.1 6.4.1-beta3 6.4.1-beta4 6.4.1-beta5 6.4.1-beta6 6.4.1-beta7 6.4.2 6.5.0 6.5.1 6.5.2 6.5.3 6.5.4 6.5.5 6.5.6 6.5.7 6.6.0 6.7.0 6.7.1 6.8.0 6.8.1 6.8.2 6.8.3
secure-custom-fields / assets / src / js / pro / blocks-v3 / components / block-edit.js
secure-custom-fields / assets / src / js / pro / blocks-v3 / components Last commit date
block-edit.js 2 months ago block-form.js 3 months ago block-placeholder.js 8 months ago block-preview.js 8 months ago block-toolbar-fields.js 3 months ago error-boundary.js 3 months ago inline-editing-toolbar.js 8 months ago jsx-parser.js 3 months ago popover-wrapper.js 3 months ago
block-edit.js
1337 lines
1 /**
2 * BlockEdit Component
3 * Main component for editing ACF blocks in the Gutenberg editor
4 * Handles form fetching, validation, preview rendering, and user interactions
5 */
6 import md5 from 'md5';
7
8 import {
9 useState,
10 useEffect,
11 useRef,
12 createPortal,
13 useMemo,
14 } from '@wordpress/element';
15
16 import {
17 InspectorControls,
18 useBlockProps,
19 useBlockEditContext,
20 } from '@wordpress/block-editor';
21 import {
22 Button,
23 Placeholder,
24 Spinner,
25 Modal,
26 PanelBody,
27 } from '@wordpress/components';
28 import { BlockPlaceholder } from './block-placeholder';
29 import { BlockForm } from './block-form';
30 import { BlockPreview } from './block-preview';
31 import { ErrorBoundary, BlockPreviewErrorFallback } from './error-boundary';
32 import { BlockToolbarFields } from './block-toolbar-fields';
33 import { InlineEditingToolbar } from './inline-editing-toolbar';
34 import { PopoverWrapper } from './popover-wrapper';
35 import {
36 lockPostSaving,
37 unlockPostSaving,
38 sortObjectKeys,
39 lockPostSavingByName,
40 unlockPostSavingByName,
41 } from '../utils/post-locking';
42
43 /**
44 * InspectorBlockFormContainer
45 * Small helper component that manages the inspector panel container ref
46 * Sets the current form container when the inspector panel is available
47 *
48 * @param {Object} props
49 * @param {React.RefObject} props.inspectorBlockFormRef - Ref to inspector container
50 * @param {Function} props.setCurrentBlockFormContainer - Setter for current container
51 */
52 const InspectorBlockFormContainer = ( {
53 inspectorBlockFormRef,
54 setCurrentBlockFormContainer,
55 } ) => {
56 useEffect( () => {
57 setCurrentBlockFormContainer( inspectorBlockFormRef.current );
58 }, [] );
59
60 return <div ref={ inspectorBlockFormRef } />;
61 };
62
63 const isBlockEditorInspectorSidebarOpen = () => {
64 const interfaceStore = wp.data.select( 'core/interface' );
65
66 return (
67 typeof interfaceStore?.getActiveComplementaryArea === 'function' &&
68 interfaceStore.getActiveComplementaryArea( 'core' ) ===
69 'edit-post/block'
70 );
71 };
72
73 const useBlockEditorInspectorSidebarOpen = () => {
74 const [ isOpen, setIsOpen ] = useState( () =>
75 isBlockEditorInspectorSidebarOpen()
76 );
77
78 useEffect( () => {
79 if ( typeof wp.data.subscribe !== 'function' ) {
80 return;
81 }
82
83 const unsubscribe = wp.data.subscribe( () => {
84 setIsOpen( isBlockEditorInspectorSidebarOpen() );
85 } );
86
87 return () => {
88 unsubscribe();
89 };
90 }, [] );
91
92 return isOpen;
93 };
94
95 /**
96 * Main BlockEdit component wrapper
97 * Manages block data fetching and initial setup
98 *
99 * @param {Object} props - Component props
100 * @param {Object} props.attributes - Block attributes
101 * @param {Function} props.setAttributes - Function to update block attributes
102 * @param {Object} props.context - Block context
103 * @param {boolean} props.isSelected - Whether block is currently selected
104 * @param {jQuery} props.$ - jQuery instance
105 * @param {Object} props.blockType - ACF block type configuration
106 * @returns {JSX.Element} - Rendered block editor
107 */
108 export const BlockEdit = ( props ) => {
109 const { attributes, setAttributes, context, isSelected, $, blockType } =
110 props;
111
112 const shouldValidate = blockType.validate;
113 const { clientId } = useBlockEditContext();
114
115 const preloadedData = useMemo( () => {
116 return checkPreloadedData(
117 generateAttributesHash( attributes, context ),
118 clientId,
119 isSelected
120 );
121 }, [] );
122
123 const [ validationErrors, setValidationErrors ] = useState( () => {
124 return preloadedData?.validation?.errors ?? null;
125 } );
126
127 const [ showValidationErrors, setShowValidationErrors ] = useState( null );
128 const [ theSerializedAcfData, setTheSerializedAcfData ] = useState( null );
129 const [ blockFormHtml, setBlockFormHtml ] = useState(
130 () => preloadedData?.form ?? ''
131 );
132 const [ blockPreviewHtml, setBlockPreviewHtml ] = useState( () => {
133 if ( preloadedData?.html ) {
134 return acf.applyFilters(
135 'blocks/preview/render',
136 preloadedData.html,
137 true
138 );
139 }
140 return 'acf-block-preview-loading';
141 } );
142 const [ userHasInteractedWithForm, setUserHasInteractedWithForm ] =
143 useState( false );
144 const [ hasFetchedOnce, setHasFetchedOnce ] = useState( false );
145 const [ ajaxRequest, setAjaxRequest ] = useState();
146 const [ isFetchingBlock, setIsFetchingBlock ] = useState( false );
147
148 // New state for inline editing features
149 const [ blockToolbarFields, setBlockToolbarFields ] = useState( [] );
150 const [ blockFieldInfo, setBlockFieldInfo ] = useState(
151 () => preloadedData?.fields ?? null
152 );
153 const [ gutenbergIframeOrDocument, setGutenbergIframeOrDocument ] =
154 useState( () => {
155 const iframe = document.querySelector( '[name="editor-canvas"]' );
156 return iframe
157 ? iframe.contentDocument || iframe.contentWindow.document
158 : document;
159 } );
160 const [ currentInlineEditingElement, setCurrentInlineEditingElement ] =
161 useState( null );
162 const [
163 currentInlineEditingElementUid,
164 setCurrentInlineEditingElementUid,
165 ] = useState( null );
166 const [ currentContentEditableElement, setCurrentContentEditableElement ] =
167 useState( null );
168 const [ inlineEditingToolbarHasFocus, setInlineEditingToolbarHasFocus ] =
169 useState( false );
170 const [
171 contentEditableChangeInProgress,
172 setContentEditableChangeInProgress,
173 ] = useState( false );
174 const [ acfDynamicStylesElement, setAcfDynamicStylesElement ] =
175 useState( null );
176 const [
177 freezeInlineToolbarDuringReRender,
178 setFreezeInlineToolbarDuringReRender,
179 ] = useState( '' );
180 const blockEditorInspectorSidebarOpen =
181 useBlockEditorInspectorSidebarOpen();
182
183 const acfFormRef = useRef( null );
184 const previewRef = useRef( null );
185 const debounceRef = useRef( null );
186
187 // Initialize acf.blockEdit namespace for jsx-parser to use
188 if ( ! acf.blockEdit ) {
189 acf.blockEdit = {};
190 }
191 acf.blockEdit.setCurrentInlineEditingElementUid =
192 setCurrentInlineEditingElementUid;
193 acf.blockEdit.setCurrentInlineEditingElement =
194 setCurrentInlineEditingElement;
195 acf.blockEdit.setCurrentContentEditableElement =
196 setCurrentContentEditableElement;
197 acf.blockEdit.getBlockFieldInfo = () => blockFieldInfo;
198
199 const attributesWithoutError = useMemo( () => {
200 const { hasAcfError, ...rest } = attributes;
201 return rest;
202 }, [ attributes ] );
203
204 /**
205 * Fetches block data from server (form HTML, preview HTML, validation)
206 *
207 * @param {Object} params - Fetch parameters
208 * @param {Object} params.theAttributes - Block attributes to fetch for
209 * @param {string} params.theClientId - Block client ID
210 * @param {Object} params.theContext - Block context
211 * @param {boolean} params.isSelected - Whether block is selected
212 */
213 function fetchBlockData( {
214 theAttributes,
215 theClientId,
216 theContext,
217 isSelected,
218 } ) {
219 if ( ! theAttributes ) return;
220
221 // NEW: Abort any pending request
222 if ( ajaxRequest ) {
223 ajaxRequest.abort();
224 }
225
226 // Generate hash of attributes for preload cache lookup
227 const attributesHash = generateAttributesHash( theAttributes, context );
228
229 // Check for preloaded block data
230 const preloadedData = checkPreloadedData(
231 attributesHash,
232 theClientId,
233 isSelected
234 );
235
236 if ( preloadedData ) {
237 handlePreloadedData( preloadedData );
238 unlockPostSavingByName( 'acf-fetching-block' );
239 setIsFetchingBlock( false );
240 return;
241 }
242
243 // Prepare query options
244 const queryOptions = { preview: true, form: true, validate: true };
245 if ( ! blockFormHtml ) {
246 queryOptions.validate = false;
247 }
248 if ( ! shouldValidate ) {
249 queryOptions.validate = false;
250 }
251
252 const blockData = { ...theAttributes };
253
254 lockPostSavingByName( 'acf-fetching-block' );
255 setIsFetchingBlock( true );
256
257 // Fetch block data via AJAX
258 const request = $.ajax( {
259 url: acf.get( 'ajaxurl' ),
260 dataType: 'json',
261 type: 'post',
262 cache: false,
263 data: acf.prepareForAjax( {
264 action: 'acf/ajax/fetch-block',
265 block: JSON.stringify( blockData ),
266 clientId: theClientId,
267 context: JSON.stringify( theContext ),
268 query: queryOptions,
269 } ),
270 } )
271 .done( ( response ) => {
272 unlockPostSavingByName( 'acf-fetching-block' );
273 setIsFetchingBlock( false );
274
275 setBlockFormHtml( response.data.form );
276
277 // Handle new field metadata for inline editing
278 if ( response.data.fields ) {
279 setBlockFieldInfo( response.data.fields );
280 }
281
282 // Handle block toolbar fields configuration
283 if ( response.data.blockToolbarFields ) {
284 setBlockToolbarFields( response.data.blockToolbarFields );
285 }
286
287 if ( response.data.preview ) {
288 setBlockPreviewHtml(
289 acf.applyFilters(
290 'blocks/preview/render',
291 response.data.preview,
292 false
293 )
294 );
295 } else {
296 setBlockPreviewHtml(
297 acf.applyFilters(
298 'blocks/preview/render',
299 'acf-block-preview-no-html',
300 false
301 )
302 );
303 }
304
305 if (
306 response.data?.validation &&
307 ! response.data.validation.valid &&
308 response.data.validation.errors
309 ) {
310 setValidationErrors( response.data.validation.errors );
311 } else {
312 setValidationErrors( null );
313 }
314
315 setHasFetchedOnce( true );
316 } )
317 .fail( function () {
318 setHasFetchedOnce( true );
319 unlockPostSavingByName( 'acf-fetching-block' );
320 setIsFetchingBlock( false );
321 } );
322 setAjaxRequest( request );
323 }
324
325 /**
326 * Generates a hash of block attributes for caching
327 *
328 * @param {Object} attrs - Block attributes
329 * @param {Object} ctx - Block context
330 * @returns {string} - MD5 hash of serialized attributes
331 */
332 function generateAttributesHash( attrs, ctx ) {
333 delete attrs.hasAcfError;
334 attrs._acf_context = sortObjectKeys( ctx );
335 return md5( JSON.stringify( sortObjectKeys( attrs ) ) );
336 }
337
338 /**
339 * Checks if block data was preloaded and returns it
340 *
341 * @param {string} hash - Attributes hash
342 * @param {string} clientId - Block client ID
343 * @param {boolean} selected - Whether block is selected
344 * @returns {Object|boolean} - Preloaded data or false
345 */
346 function checkPreloadedData( hash, clientId, selected ) {
347 if ( selected ) return false;
348
349 acf.debug( 'Preload check', hash, clientId );
350
351 // Don't preload blocks inside Query Loop blocks
352 if ( isInQueryLoop( clientId ) ) {
353 return false;
354 }
355
356 const data = getPreloadedBlockData(
357 hash,
358 clientId,
359 acf.get( 'preloadedBlocks' )
360 );
361
362 if ( ! data ) {
363 acf.debug( 'Preload failed: not preloaded.' );
364 return false;
365 }
366
367 acf.debug( 'Preload successful', data );
368 return data;
369 }
370
371 /**
372 * Returns a copy of a preloaded block entry with the placeholder hash
373 * replaced by the actual client ID.
374 *
375 * Works on a deep clone so the shared preloaded entry is never mutated —
376 * duplicating a block with identical attributes reuses the same hash, and
377 * an in-place replacement would corrupt the entry for the duplicate.
378 *
379 * @param {string} hash - Attributes hash
380 * @param {string} blockClientId - Block client ID
381 * @param {Object} preloadedBlocks - The preloaded blocks store
382 * @return {Object|boolean} - Preloaded data or false
383 */
384 function getPreloadedBlockData( hash, blockClientId, preloadedBlocks ) {
385 if ( ! preloadedBlocks || ! preloadedBlocks[ hash ] ) {
386 return false;
387 }
388
389 const data = JSON.parse( JSON.stringify( preloadedBlocks[ hash ] ) );
390
391 // Replace placeholder client ID with actual client ID
392 data.html = data.html.replaceAll( hash, blockClientId );
393 data.form = data.form.replaceAll( hash, blockClientId );
394
395 if ( data?.validation?.errors ) {
396 data.validation.errors = data.validation.errors.map( ( error ) => {
397 error.input = error.input.replaceAll( hash, blockClientId );
398 return error;
399 } );
400 }
401
402 return data;
403 }
404
405 /**
406 * Checks if block is inside a Query Loop block
407 *
408 * @param {string} clientId - Block client ID
409 * @returns {boolean} - True if inside Query Loop
410 */
411 function isInQueryLoop( clientId ) {
412 const parentIds = wp.data
413 .select( 'core/block-editor' )
414 .getBlockParents( clientId );
415
416 return (
417 wp.data
418 .select( 'core/block-editor' )
419 .getBlocksByClientId( parentIds )
420 .filter( ( block ) => block.name === 'core/query' ).length > 0
421 );
422 }
423
424 /**
425 * Handles preloaded block data
426 *
427 * @param {Object} data - Preloaded data
428 */
429 function handlePreloadedData( data ) {
430 if ( data.form ) {
431 setBlockFormHtml( data.form );
432 }
433
434 if ( data.html ) {
435 setBlockPreviewHtml(
436 acf.applyFilters( 'blocks/preview/render', data.html, true )
437 );
438 } else {
439 setBlockPreviewHtml(
440 acf.applyFilters(
441 'blocks/preview/render',
442 'acf-block-preview-no-html',
443 true
444 )
445 );
446 }
447
448 // Handle block toolbar fields from preloaded data
449 if ( data?.blockToolbarFields ) {
450 setBlockToolbarFields( data.blockToolbarFields );
451 }
452
453 // Handle field info from preloaded data
454 if ( data?.fields ) {
455 setBlockFieldInfo( data.fields );
456 }
457
458 if (
459 data?.validation &&
460 ! data.validation.valid &&
461 data.validation.errors
462 ) {
463 setValidationErrors( data.validation.errors );
464 } else {
465 setValidationErrors( null );
466 }
467 }
468
469 // Initial fetch on mount and when selection changes
470 useEffect( () => {
471 function trackUserInteraction() {
472 setUserHasInteractedWithForm( true );
473 window.removeEventListener( 'click', trackUserInteraction );
474 window.removeEventListener( 'keydown', trackUserInteraction );
475 }
476
477 window.addEventListener( 'click', trackUserInteraction );
478 window.addEventListener( 'keydown', trackUserInteraction );
479
480 return () => {
481 window.removeEventListener( 'click', trackUserInteraction );
482 window.removeEventListener( 'keydown', trackUserInteraction );
483 };
484 }, [] );
485
486 useEffect( () => {
487 if ( isSelected ) {
488 return;
489 }
490
491 setCurrentInlineEditingElementUid( null );
492 setCurrentInlineEditingElement( null );
493 setCurrentContentEditableElement( null );
494 }, [ isSelected ] );
495
496 // Update hasAcfError attribute based on validation errors
497 useEffect( () => {
498 setAttributes(
499 validationErrors ? { hasAcfError: true } : { hasAcfError: false }
500 );
501 }, [ validationErrors, setAttributes ] );
502
503 // Listen for validation error events from other blocks
504 useEffect( () => {
505 const handleErrorEvent = ( event ) => {
506 // Only handle if this event is for this specific block
507 if ( clientId === event.detail.acfBlocksWithValidationErrors ) {
508 lockPostSaving( clientId );
509 setShowValidationErrors( true );
510 setCurrentInlineEditingElementUid( null );
511 }
512 };
513
514 document.addEventListener( 'acf/block/has-error', handleErrorEvent );
515
516 return () => {
517 document.removeEventListener(
518 'acf/block/has-error',
519 handleErrorEvent
520 );
521 };
522 }, [] );
523
524 // Cleanup: unlock post saving on unmount
525 useEffect(
526 () => () => {
527 unlockPostSaving( props.clientId );
528 },
529 []
530 );
531
532 // Handle form data changes with debouncing
533 useEffect( () => {
534 clearTimeout( debounceRef.current );
535 lockPostSavingByName( 'acf-fetching-block' );
536 setIsFetchingBlock( true );
537
538 debounceRef.current = setTimeout( () => {
539 const parsedData = JSON.parse( theSerializedAcfData );
540
541 if ( ! parsedData ) {
542 return void fetchBlockData( {
543 theAttributes: attributesWithoutError,
544 theClientId: clientId,
545 theContext: context,
546 isSelected: isSelected,
547 } );
548 }
549
550 if (
551 theSerializedAcfData ===
552 JSON.stringify( attributesWithoutError.data )
553 ) {
554 return void fetchBlockData( {
555 theAttributes: attributesWithoutError,
556 theClientId: clientId,
557 theContext: context,
558 isSelected: isSelected,
559 } );
560 }
561
562 // Use original attributes (with hasAcfError) when updating
563 const updatedAttributes = {
564 ...attributes,
565 data: { ...parsedData },
566 };
567 setAttributes( updatedAttributes );
568 }, 200 );
569
570 // Cleanup function to unlock post saving
571 return () => {
572 clearTimeout( debounceRef.current );
573 unlockPostSavingByName( 'acf-fetching-block' );
574 setIsFetchingBlock( false );
575 };
576 }, [ theSerializedAcfData, attributesWithoutError ] );
577
578 // Trigger ACF actions when preview is rendered
579 useEffect( () => {
580 if ( previewRef.current && blockPreviewHtml ) {
581 const blockName = attributes.name.replace( 'acf/', '' );
582 const $preview = $( previewRef.current );
583
584 acf.doAction( 'render_block_preview', $preview, attributes );
585 acf.doAction(
586 `render_block_preview/type=${ blockName }`,
587 $preview,
588 attributes
589 );
590
591 // If there's an active inline editing element, re-initialize it after preview renders
592 if ( currentInlineEditingElementUid ) {
593 const inlineElement = previewRef?.current.querySelector(
594 `[data-acf-inline-fields-uid="${ currentInlineEditingElementUid }"]`
595 );
596 setCurrentInlineEditingElement( inlineElement );
597 }
598 }
599 }, [ blockPreviewHtml ] );
600
601 useEffect( () => {
602 if (
603 ( currentContentEditableElement &&
604 ! inlineEditingToolbarHasFocus ) ||
605 ! currentInlineEditingElement
606 ) {
607 return;
608 }
609
610 const toolbar = document.querySelector( '.acf-inline-editing-toolbar' );
611
612 if ( toolbar?.style?.cssText ) {
613 setFreezeInlineToolbarDuringReRender(
614 toolbar.style.cssText.replaceAll( ';', '!important;' )
615 );
616 }
617
618 setTimeout( () => {
619 setCurrentInlineEditingElement( currentInlineEditingElement );
620 setTimeout( () => {
621 setFreezeInlineToolbarDuringReRender( null );
622 }, 0 );
623 }, 0 );
624 }, [ currentInlineEditingElement ] );
625
626 return (
627 <BlockEditInner
628 { ...props }
629 validationErrors={ validationErrors }
630 showValidationErrors={ showValidationErrors }
631 theSerializedAcfData={ theSerializedAcfData }
632 setTheSerializedAcfData={ setTheSerializedAcfData }
633 acfFormRef={ acfFormRef }
634 blockFormHtml={ blockFormHtml }
635 blockPreviewHtml={ blockPreviewHtml }
636 blockFetcher={ fetchBlockData }
637 userHasInteractedWithForm={ userHasInteractedWithForm }
638 setUserHasInteractedWithForm={ setUserHasInteractedWithForm }
639 previewRef={ previewRef }
640 hasFetchedOnce={ hasFetchedOnce }
641 blockToolbarFields={ blockToolbarFields }
642 blockFieldInfo={ blockFieldInfo }
643 gutenbergIframeOrDocument={ gutenbergIframeOrDocument }
644 setGutenbergIframeOrDocument={ setGutenbergIframeOrDocument }
645 currentInlineEditingElement={ currentInlineEditingElement }
646 setCurrentInlineEditingElement={ setCurrentInlineEditingElement }
647 currentInlineEditingElementUid={ currentInlineEditingElementUid }
648 setCurrentInlineEditingElementUid={
649 setCurrentInlineEditingElementUid
650 }
651 currentContentEditableElement={ currentContentEditableElement }
652 setCurrentContentEditableElement={
653 setCurrentContentEditableElement
654 }
655 inlineEditingToolbarHasFocus={ inlineEditingToolbarHasFocus }
656 setInlineEditingToolbarHasFocus={ setInlineEditingToolbarHasFocus }
657 contentEditableChangeInProgress={ contentEditableChangeInProgress }
658 setContentEditableChangeInProgress={
659 setContentEditableChangeInProgress
660 }
661 acfDynamicStylesElement={ acfDynamicStylesElement }
662 setAcfDynamicStylesElement={ setAcfDynamicStylesElement }
663 blockEditorInspectorSidebarOpen={ blockEditorInspectorSidebarOpen }
664 freezeInlineToolbarDuringReRender={
665 freezeInlineToolbarDuringReRender
666 }
667 isFetchingBlock={ isFetchingBlock }
668 />
669 );
670 };
671
672 /**
673 * Inner component that handles rendering and portals
674 * Separated to manage refs and portal targets properly
675 */
676 function BlockEditInner( props ) {
677 const {
678 blockType,
679 $,
680 isSelected,
681 attributes,
682 context,
683 validationErrors,
684 showValidationErrors,
685 theSerializedAcfData,
686 setTheSerializedAcfData,
687 acfFormRef,
688 blockFormHtml,
689 blockPreviewHtml,
690 blockFetcher,
691 userHasInteractedWithForm,
692 setUserHasInteractedWithForm,
693 previewRef,
694 hasFetchedOnce,
695 blockToolbarFields,
696 blockFieldInfo,
697 gutenbergIframeOrDocument,
698 setGutenbergIframeOrDocument,
699 currentInlineEditingElement,
700 setCurrentInlineEditingElement,
701 currentInlineEditingElementUid,
702 setCurrentInlineEditingElementUid,
703 currentContentEditableElement,
704 setCurrentContentEditableElement,
705 inlineEditingToolbarHasFocus,
706 setInlineEditingToolbarHasFocus,
707 contentEditableChangeInProgress,
708 setContentEditableChangeInProgress,
709 acfDynamicStylesElement,
710 setAcfDynamicStylesElement,
711 blockEditorInspectorSidebarOpen,
712 freezeInlineToolbarDuringReRender,
713 isFetchingBlock,
714 } = props;
715
716 const { clientId } = useBlockEditContext();
717 const inspectorControlsRef = useRef();
718 const [ blockFormModalOpen, setBlockFormModalOpen ] = useState( false );
719 const modalFormContainerRef = useRef();
720 const [ currentFormContainer, setCurrentFormContainer ] = useState();
721 const [ canRenderForm, setCanRenderForm ] = useState( false );
722 const [ invisibleBlockFormContainer, setInvisibleBlockFormContainer ] =
723 useState();
724
725 // Render counter for debugging
726 const renderCount = useRef( 0 );
727 renderCount.current++;
728
729 // Detect Gutenberg iframe or document
730 useEffect( () => {
731 const gutenbergIframe = document.querySelector(
732 'iframe[name="editor-canvas"]'
733 );
734 if ( gutenbergIframe?.contentDocument ) {
735 setGutenbergIframeOrDocument( gutenbergIframe.contentDocument );
736 } else {
737 setGutenbergIframeOrDocument( document );
738 }
739 }, [] );
740
741 // Create/get dynamic styles element for inline field highlighting
742 useEffect( () => {
743 if ( ! gutenbergIframeOrDocument ) return;
744
745 let styleElement =
746 gutenbergIframeOrDocument.getElementById( 'acf-dynamic-styles' );
747 if ( ! styleElement ) {
748 styleElement = document.createElement( 'style' );
749 styleElement.id = 'acf-dynamic-styles';
750 gutenbergIframeOrDocument.head.appendChild( styleElement );
751 }
752 setAcfDynamicStylesElement( styleElement );
753 }, [ gutenbergIframeOrDocument ] );
754
755 useEffect( () => {
756 let invisibleContainer = document.getElementById(
757 'invisible-acf-form-element'
758 );
759
760 if ( ! invisibleContainer ) {
761 invisibleContainer = document.createElement( 'div' );
762 invisibleContainer.id = 'invisible-acf-form-element';
763 invisibleContainer.style.display = 'none';
764 document.body.appendChild( invisibleContainer );
765 }
766
767 setInvisibleBlockFormContainer( invisibleContainer );
768 }, [ blockEditorInspectorSidebarOpen ] );
769
770 useEffect( () => {
771 if ( blockFormModalOpen && modalFormContainerRef?.current ) {
772 setCurrentFormContainer( modalFormContainerRef.current );
773 return;
774 }
775
776 setCurrentFormContainer(
777 blockEditorInspectorSidebarOpen
778 ? inspectorControlsRef.current
779 : invisibleBlockFormContainer
780 );
781 }, [ blockFormModalOpen, modalFormContainerRef ] );
782
783 useEffect( () => {
784 if ( blockEditorInspectorSidebarOpen ) {
785 if ( ! blockFormModalOpen ) {
786 setCurrentFormContainer( inspectorControlsRef.current );
787 }
788 return;
789 }
790
791 setCurrentFormContainer( invisibleBlockFormContainer );
792 }, [
793 blockEditorInspectorSidebarOpen,
794 invisibleBlockFormContainer,
795 blockFormModalOpen,
796 ] );
797
798 useEffect( () => {
799 if ( isSelected && inspectorControlsRef?.current ) {
800 setCanRenderForm( true );
801 } else if ( isSelected && ! inspectorControlsRef?.current ) {
802 setTimeout( () => {
803 setCanRenderForm( true );
804 }, 1 );
805 }
806 }, [ isSelected, inspectorControlsRef, inspectorControlsRef.current ] );
807
808 useEffect( () => {
809 if (
810 isSelected &&
811 validationErrors &&
812 showValidationErrors &&
813 blockType?.hide_fields_in_sidebar
814 ) {
815 setBlockFormModalOpen( true );
816 }
817 }, [ isSelected, showValidationErrors, validationErrors, blockType ] );
818
819 // Build block CSS classes
820 let blockClasses = 'acf-block-component acf-block-body';
821 blockClasses += ' acf-block-preview';
822
823 if ( validationErrors && showValidationErrors ) {
824 blockClasses += ' acf-block-has-validation-error';
825 }
826
827 const blockProps = {
828 ...useBlockProps( { className: blockClasses, ref: previewRef } ),
829 };
830
831 // Update field value from contentEditable changes (matches 6.7.0.2)
832 const updateFieldValueFromContentEditable = ( content, fieldSlug ) => {
833 if ( ! acfFormRef?.current || ! fieldSlug ) return;
834
835 const fieldWrapper = acfFormRef.current.querySelector(
836 `[data-name=${ fieldSlug }]`
837 );
838 if ( ! fieldWrapper ) return;
839
840 const fieldKey =
841 fieldWrapper.attributes.getNamedItem( 'data-key' )?.value;
842 if ( ! fieldKey ) return;
843
844 const fieldInput = acfFormRef.current.querySelector(
845 `[name="acf-block_${ clientId }[${ fieldKey }]"`
846 );
847 if ( ! fieldInput ) return;
848
849 // Update field value and trigger serialization (debouncing happens in useEffect)
850 if ( content ) {
851 setUserHasInteractedWithForm( true );
852 }
853 setContentEditableChangeInProgress( false );
854 fieldInput.value = content;
855
856 const $form = $( acfFormRef?.current );
857 const serializedData = acf.serialize(
858 $form,
859 `acf-block_${ clientId }`
860 );
861 if ( serializedData ) {
862 setTheSerializedAcfData( JSON.stringify( serializedData ) );
863 } else {
864 setUserHasInteractedWithForm( false );
865 }
866 };
867
868 // Watch for changes in contentEditable fields using MutationObserver
869 useEffect( () => {
870 if ( ! gutenbergIframeOrDocument || ! blockPreviewHtml ) return;
871
872 const observer = new MutationObserver( ( mutations ) => {
873 for ( const mutation of mutations ) {
874 // Handle text content changes
875 if ( mutation.type === 'characterData' ) {
876 let element = mutation.target.parentNode;
877 const blockElement = element?.closest( '[data-block]' );
878 const blockId = blockElement?.getAttribute( 'data-block' );
879
880 if ( ! element || ! blockElement || blockId !== clientId )
881 return;
882
883 // Find the contentEditable element
884 if (
885 element &&
886 ! element.hasAttribute(
887 'data-acf-inline-contenteditable'
888 )
889 ) {
890 element = element.closest(
891 '[data-acf-inline-contenteditable]'
892 );
893 }
894
895 if (
896 element &&
897 element.hasAttribute(
898 'data-acf-inline-contenteditable'
899 )
900 ) {
901 const fieldSlug = element.attributes.getNamedItem(
902 'data-acf-inline-contenteditable-field-slug'
903 ).value;
904 let content = element.innerHTML.trim();
905 if ( ! content ) content = '';
906 updateFieldValueFromContentEditable(
907 content,
908 fieldSlug
909 );
910 }
911 }
912 // Handle attribute or child list changes
913 else {
914 const element = mutation.target.closest(
915 '[data-acf-inline-contenteditable]'
916 );
917 const blockElement = element?.closest( '[data-block]' );
918
919 if (
920 ! element ||
921 ! blockElement ||
922 blockElement.getAttribute( 'data-block' ) !== clientId
923 )
924 return;
925
926 if ( element ) {
927 const fieldSlug = element.attributes.getNamedItem(
928 'data-acf-inline-contenteditable-field-slug'
929 ).value;
930 let content = element.innerHTML.trim();
931
932 // Handle empty content - remove empty BR tags
933 if (
934 ! content ||
935 ( element.textContent.trim().length === 0 &&
936 element.children.length === 1 &&
937 element.firstElementChild &&
938 element.firstElementChild.nodeName === 'BR' )
939 ) {
940 element.innerHTML = '';
941 content = '';
942 }
943
944 updateFieldValueFromContentEditable(
945 content,
946 fieldSlug
947 );
948 }
949 }
950 }
951 } );
952
953 // Observe the gutenberg iframe/document for changes
954 observer.observe( gutenbergIframeOrDocument, {
955 attributes: true,
956 childList: true,
957 subtree: true,
958 characterData: true,
959 attributeFilter: [ 'data-acf-inline-contenteditable' ],
960 } );
961
962 // Cleanup
963 return () => {
964 observer.disconnect();
965 };
966 }, [ blockPreviewHtml, gutenbergIframeOrDocument ] );
967
968 // Callback when a new inline editing element is selected
969 const handleNewInlineEditingElementSelected = ( uid ) => {
970 setTimeout( () => {
971 setCurrentInlineEditingElementUid( uid );
972 const element = previewRef?.current.querySelector(
973 `[data-acf-inline-fields-uid="${ uid }"]`
974 );
975 setCurrentInlineEditingElement( element );
976 if ( element ) {
977 element.scrollIntoView( {
978 behavior: 'smooth',
979 block: 'nearest',
980 } );
981 }
982 }, 1 );
983 };
984
985 // Callback when a new contentEditable element is selected
986 const handleNewContentEditableElementSelected = ( fieldSlug ) => {
987 if ( fieldSlug ) {
988 const element = previewRef?.current.querySelector(
989 `[data-acf-inline-contenteditable-field-slug="${ fieldSlug }"]`
990 );
991 setCurrentContentEditableElement( element );
992 } else {
993 setCurrentContentEditableElement( null );
994 }
995 };
996
997 let portalTarget =
998 blockEditorInspectorSidebarOpen && inspectorControlsRef?.current
999 ? inspectorControlsRef.current
1000 : invisibleBlockFormContainer;
1001
1002 if ( currentFormContainer ) {
1003 portalTarget = currentFormContainer;
1004 }
1005
1006 // Determine inline editing toolbar anchor (matches 6.7.0.2 logic)
1007 let inlineEditingToolbarAnchor = null;
1008 if ( currentInlineEditingElement && currentContentEditableElement ) {
1009 inlineEditingToolbarAnchor = currentInlineEditingElement;
1010 } else if (
1011 currentInlineEditingElement &&
1012 ! currentContentEditableElement
1013 ) {
1014 inlineEditingToolbarAnchor = currentInlineEditingElement;
1015 } else if (
1016 ! currentInlineEditingElement &&
1017 currentContentEditableElement
1018 ) {
1019 inlineEditingToolbarAnchor = currentContentEditableElement;
1020 }
1021 // Ensure anchor is connected to DOM
1022 if (
1023 inlineEditingToolbarAnchor &&
1024 ! inlineEditingToolbarAnchor.isConnected
1025 ) {
1026 inlineEditingToolbarAnchor = null;
1027 }
1028
1029 return (
1030 <>
1031 { /* Block toolbar controls with inline editing support */ }
1032 <BlockToolbarFields
1033 blockToolbarFields={ blockToolbarFields }
1034 blockFieldInfo={ blockFieldInfo }
1035 setCurrentBlockFormContainer={ setCurrentFormContainer }
1036 gutenbergIframeOrDocument={ gutenbergIframeOrDocument }
1037 setBlockFormModalOpen={ setBlockFormModalOpen }
1038 blockFormModalOpen={ blockFormModalOpen }
1039 invisibleBlockFormContainer={ invisibleBlockFormContainer }
1040 currentInlineEditingElement={ currentInlineEditingElement }
1041 setCurrentInlineEditingElement={
1042 setCurrentInlineEditingElement
1043 }
1044 currentInlineEditingElementUid={
1045 currentInlineEditingElementUid
1046 }
1047 hideExpandedEditorBtnInToolbar={
1048 blockType?.expanded_editor_buttons === false ||
1049 ( Array.isArray( blockType?.expanded_editor_buttons ) &&
1050 ! blockType?.expanded_editor_buttons.includes(
1051 'toolbar'
1052 ) ) ||
1053 ! blockFieldInfo ||
1054 blockFieldInfo?.length === 0
1055 }
1056 onNewInlineEditingElementSelected={
1057 handleNewInlineEditingElementSelected
1058 }
1059 />
1060
1061 { /* Inspector panel container */ }
1062 <InspectorControls>
1063 { blockFieldInfo?.length > 0 &&
1064 ( blockType?.expanded_editor_buttons === true ||
1065 ( Array.isArray( blockType?.expanded_editor_buttons ) &&
1066 blockType?.expanded_editor_buttons.includes(
1067 'sidebar'
1068 ) ) ) && (
1069 <PanelBody>
1070 <Button
1071 className="acf-blocks-open-expanded-editor-btn"
1072 variant="secondary"
1073 onClick={ () => {
1074 setBlockFormModalOpen( true );
1075 } }
1076 icon="edit"
1077 >
1078 { blockType?.expanded_editor_button_text ||
1079 acf.__( 'Open Expanded Editor' ) }
1080 </Button>
1081 </PanelBody>
1082 ) }
1083 <InspectorBlockFormContainer
1084 inspectorBlockFormRef={ inspectorControlsRef }
1085 setCurrentBlockFormContainer={ setCurrentFormContainer }
1086 />
1087 </InspectorControls>
1088
1089 { /* Render form via portal when container is available */ }
1090 { portalTarget &&
1091 canRenderForm &&
1092 createPortal(
1093 <>
1094 <BlockForm
1095 $={ $ }
1096 clientId={ clientId }
1097 blockFormHtml={ blockFormHtml }
1098 onChange={ function ( $form ) {
1099 const serializedData = acf.serialize(
1100 $form,
1101 `acf-block_${ clientId }`
1102 );
1103 if ( serializedData ) {
1104 setTheSerializedAcfData(
1105 JSON.stringify( serializedData )
1106 );
1107 }
1108 } }
1109 validationErrors={ validationErrors }
1110 showValidationErrors={ showValidationErrors }
1111 acfFormRef={ acfFormRef }
1112 userHasInteractedWithForm={
1113 userHasInteractedWithForm
1114 }
1115 attributes={ attributes }
1116 hideFieldsInSidebar={
1117 ( blockType?.auto_inline_editing &&
1118 blockType?.hide_fields_in_sidebar ===
1119 undefined &&
1120 currentFormContainer ===
1121 inspectorControlsRef.current ) ||
1122 ( blockType?.hide_fields_in_sidebar &&
1123 currentFormContainer ===
1124 inspectorControlsRef.current )
1125 }
1126 />
1127 { freezeInlineToolbarDuringReRender && (
1128 <style>
1129 { `.acf-inline-editing-toolbar{${ freezeInlineToolbarDuringReRender }}` }
1130 </style>
1131 ) }
1132 </>,
1133 portalTarget
1134 ) }
1135 <>
1136 { /* Modal for editing block fields */ }
1137 { blockFormModalOpen && (
1138 <Modal
1139 className="acf-block-form-modal"
1140 overlayClassName={ acf.applyFilters(
1141 'blocks/expanded_editor_overlay_class',
1142 'acf-expanded-editor-panel-overlay'
1143 ) }
1144 isFullScreen={ true }
1145 title={ blockType.title }
1146 onRequestClose={ () => {
1147 if ( ! isFetchingBlock || validationErrors ) {
1148 setBlockFormModalOpen( false );
1149 }
1150 } }
1151 shouldCloseOnEsc={
1152 ! isFetchingBlock || validationErrors
1153 }
1154 isDismissible={ false }
1155 headerActions={ [
1156 <Button
1157 key="done"
1158 variant="primary"
1159 disabled={
1160 isFetchingBlock && ! validationErrors
1161 }
1162 isBusy={ isFetchingBlock }
1163 onClick={ () => {
1164 setCurrentFormContainer( null );
1165 setBlockFormModalOpen( false );
1166 } }
1167 >
1168 { acf.__( 'Done' ) }
1169 </Button>,
1170 ] }
1171 >
1172 <div
1173 className="acf-modal-block-form-container"
1174 ref={ modalFormContainerRef }
1175 />
1176 </Modal>
1177 ) }
1178 </>
1179
1180 { /* Inline Editing Toolbar */ }
1181 { inlineEditingToolbarAnchor && (
1182 <PopoverWrapper
1183 key={ currentContentEditableElement }
1184 focusOnMount={ ( () => {
1185 const activeElement = document.activeElement;
1186 return (
1187 activeElement && activeElement.isContentEditable,
1188 false
1189 );
1190 } )() }
1191 variant="unstyled"
1192 anchor={ inlineEditingToolbarAnchor }
1193 className="acf-inline-editing-toolbar block-editor-block-list__block-popover"
1194 placement="top-start"
1195 onClose={ ( event ) => {
1196 // Don't close if clicking toolbar button
1197 if (
1198 event.key !== 'Escape' &&
1199 event.target.closest( '.acf-toolbar-button' )
1200 ) {
1201 return false;
1202 }
1203
1204 // Handle Escape key
1205 if ( event.key === 'Escape' ) {
1206 return (
1207 ! document.querySelector(
1208 '.acf-inline-fields-popover-inner'
1209 ) &&
1210 ! currentContentEditableElement &&
1211 ( currentInlineEditingElement &&
1212 currentInlineEditingElement.focus(),
1213 setCurrentInlineEditingElementUid( null ),
1214 setCurrentContentEditableElement( null ),
1215 true )
1216 );
1217 }
1218
1219 // Don't close if clicking on contenteditable element
1220 if (
1221 event.target.getAttribute(
1222 'data-acf-inline-contenteditable'
1223 )
1224 ) {
1225 return false;
1226 }
1227
1228 // Don't close if clicking inside popover or modal
1229 const inlineFieldsPopover = event.target.closest(
1230 '.acf-inline-fields-popover-inner'
1231 );
1232 const modal = event.target.closest(
1233 '.components-modal__content'
1234 );
1235
1236 return (
1237 inlineFieldsPopover ||
1238 modal ||
1239 ( setCurrentInlineEditingElementUid( null ),
1240 setCurrentContentEditableElement( null ) ),
1241 true
1242 );
1243 } }
1244 gutenbergIframeOrDocument={ gutenbergIframeOrDocument }
1245 hidePrimaryBlockToolbar={ true }
1246 >
1247 <InlineEditingToolbar
1248 key={ currentInlineEditingElementUid }
1249 blockIcon={ blockType.icon }
1250 blockFieldInfo={ blockFieldInfo }
1251 acfFormRef={ acfFormRef }
1252 setInlineEditingToolbarHasFocus={
1253 setInlineEditingToolbarHasFocus
1254 }
1255 currentContentEditableElement={
1256 currentContentEditableElement
1257 }
1258 currentInlineEditingElement={
1259 currentInlineEditingElement
1260 }
1261 currentInlineEditingElementUid={
1262 currentInlineEditingElementUid
1263 }
1264 gutenbergIframeOrDocument={ gutenbergIframeOrDocument }
1265 setCurrentBlockFormContainer={ setCurrentFormContainer }
1266 contentEditableChangeInProgress={
1267 contentEditableChangeInProgress
1268 }
1269 />
1270 </PopoverWrapper>
1271 ) }
1272
1273 { /* Dynamic styles for inline field highlighting */ }
1274 { currentInlineEditingElementUid &&
1275 acfDynamicStylesElement &&
1276 createPortal(
1277 <style>
1278 { `
1279 [data-acf-inline-fields-uid="${ currentInlineEditingElementUid }"]{
1280 outline: 2px solid var( --wp-admin-theme-color );
1281 outline-offset: 2px;
1282 }
1283 ` }
1284 </style>,
1285 acfDynamicStylesElement
1286 ) }
1287
1288 { /* Block preview */ }
1289 <>
1290 <BlockPreview
1291 blockPreviewHtml={ blockPreviewHtml }
1292 blockProps={ blockProps }
1293 >
1294 <ErrorBoundary
1295 fallbackRender={ ( { error } ) => (
1296 <BlockPreviewErrorFallback
1297 blockLabel={
1298 blockType?.title || acf.__( 'ACF Block' )
1299 }
1300 setBlockFormModalOpen={ setBlockFormModalOpen }
1301 error={ error }
1302 />
1303 ) }
1304 >
1305 { blockPreviewHtml === 'acf-block-preview-no-html' ? (
1306 <BlockPlaceholder
1307 setBlockFormModalOpen={ setBlockFormModalOpen }
1308 blockLabel={ blockType.title }
1309 />
1310 ) : null }
1311
1312 { /* Show spinner while loading */ }
1313 { blockPreviewHtml === 'acf-block-preview-loading' && (
1314 <Placeholder>
1315 <Spinner />
1316 </Placeholder>
1317 ) }
1318
1319 { /* Render actual preview HTML */ }
1320 { blockPreviewHtml !== 'acf-block-preview-loading' &&
1321 blockPreviewHtml !== 'acf-block-preview-no-html' &&
1322 blockPreviewHtml &&
1323 acf.parseJSX(
1324 blockPreviewHtml,
1325 handleNewInlineEditingElementSelected,
1326 updateFieldValueFromContentEditable,
1327 handleNewContentEditableElementSelected,
1328 blockFieldInfo,
1329 $
1330 ) }
1331 </ErrorBoundary>
1332 </BlockPreview>
1333 </>
1334 </>
1335 );
1336 }
1337