onCardClick( field ) }
onKeyDown={ ( e ) => {
if ( e.key === 'Enter' || e.key === ' ' ) {
e.preventDefault();
onCardClick( field );
}
} }
>
{ isRepeater && subFieldCount > 0 && (
{ subFieldCount }
) }
{ /* Actions live in the title row, revealed on
hover / selection (CSS). Delete (red text)
sits left of the Insert/Remove outlined button. */ }
{ /* Hover tooltips convey what the button
labels don't: Remove is form-scoped and
reversible, Delete is global and permanent,
Insert reuses this field rather than
creating a new one. `text` is the hover
description; the field-specific `aria-label`
stays on the button as the accessible name. */ }
{ /* No Delete in the mini inserter: that
popover is for finding and inserting, and
a global, permanent, un-undoable action
doesn't belong one stray click away from
Insert on a card list this dense. Gated in
JS rather than CSS-hidden so the popover
carries no invisible-but-focusable
destructive button. Deleting a field is
the docked panel's job. */ }
{ ! inMiniInserter && ! MANDATORY_DEFAULT_TYPES.has( field.fieldType ) && (
) }
{ /* In form → Remove (scrubs the block from
this form), except mandatory default types
(Username / E-mail / Password) which show no
button. Not in form → Insert. */ }
{ ! isUsed ? (
) : ( ! MANDATORY_DEFAULT_TYPES.has( field.fieldType ) && (
) ) }
{ sprintf(
/* translators: %s: field title or type */
__( 'Delete the field “%s”? This removes it from every form that uses it and cannot be undone.', 'profile-builder' ),
label
) }
{ usage.loading && (
{ __( 'Checking which other forms use this field…', 'profile-builder' ) }
) }
{ ! usage.loading && otherForms.length > 0 && (
/* Alert-style callout (custom div, not a Gutenberg , for
full control of the amber treatment: cream fill, thick left
rail, bold lead-in, divided list of the OTHER forms the field
lives on). Keeps the `__usage` hook for tests. */
{ sprintf(
/* translators: %d: number of other forms */
_n(
'This field is used on %d other form.',
'This field is used on %d other forms.',
otherForms.length,
'profile-builder'
),
otherForms.length
) }
{ ' ' }
{ __( 'Deleting it will also remove it from:', 'profile-builder' ) }
{ otherForms.map( ( f ) => (
{ f.title }
) ) }
) }
{ ! usage.loading && usage.error && (
{ __( 'Could not check which other forms use this field. It will still be removed from every form on delete.', 'profile-builder' ) }
) }
{ error && (
{ error }
) }
);
};
// Portal Existing Fields into every Media tab (docked + mini inserters).
const ExistingFieldsPanel = () => {
const postType = useSelect(
( select ) => select( 'core/editor' ).getCurrentPostType(),
[]
);
const [ targets, setTargets ] = useState( [] );
const isOurPostType = [ 'wppb-rf-cpt', 'wppb-epf-cpt' ].includes( postType );
// Ref mirrors targets for the observer effect (avoids stale closure).
const targetsRef = useRef( [] );
const applyTargets = useCallback( ( next ) => {
const prev = targetsRef.current;
if ( prev.length === next.length && prev.every( ( el, i ) => el === next[ i ] ) ) return;
targetsRef.current = next;
setTargets( next );
}, [] );
useEffect( () => {
if ( ! isOurPostType ) return undefined;
const sync = () => {
for ( const { selector, label } of TAB_RENAMES ) {
for ( const tab of document.querySelectorAll( selector ) ) {
renameTab( tab, label );
}
}
const next = [];
for ( const mediaTab of document.querySelectorAll( HIJACK_TAB_SELECTOR ) ) {
// Reorder: place Existing Fields (the hijacked Media tab) before
// Examples (Patterns) — within THIS instance's tab strip only.
const patternsTab = mediaTab.parentNode
? mediaTab.parentNode.querySelector( '[role="tab"][id$="-patterns"]' )
: null;
if (
patternsTab &&
patternsTab.parentNode === mediaTab.parentNode &&
( patternsTab.compareDocumentPosition( mediaTab ) & Node.DOCUMENT_POSITION_FOLLOWING )
) {
patternsTab.parentNode.insertBefore( mediaTab, patternsTab );
}
const panelId = mediaTab.getAttribute( 'aria-controls' );
const panel = panelId ? document.getElementById( panelId ) : null;
if ( ! panel ) continue;
// Claim the panel immediately (hijack class + target div) so the
// real Media-tab content is hidden from the first frame...
const injected = ensurePanelTarget( panel );
const popover = panel.closest( '.block-editor-inserter__popover' );
// Mark popovers that can't take existing fields (CSS hides the tab).
if ( popover ) {
const allowed = miniInserterAllowsExistingFields();
popover.classList.toggle( NO_EXISTING_FIELDS_CLASS, ! allowed );
if ( ! allowed ) continue;
}
// Lazy-mount card list in mini inserter (docked always mounts).
if ( popover && panel.hasAttribute( 'hidden' ) ) {
continue;
}
next.push( injected );
}
applyTargets( next );
};
// Coalesce a burst of Gutenberg DOM mutations into a single sync per
// animation frame. Undebounced, the observer ran sync (several
// querySelectors) on every mutation batch on the typing→paint path (INP).
let frame = 0;
const scheduleSync = () => {
if ( frame ) return;
frame = window.requestAnimationFrame( () => {
frame = 0;
sync();
} );
};
sync();
const observer = new MutationObserver( scheduleSync );
// Observe document.body — mini inserter popovers portal outside the skeleton.
observer.observe( document.body, {
attributeFilter: [ 'hidden' ],
attributes: true,
childList: true,
subtree: true,
} );
// Re-sync when destination store changes (may race popover mount).
const unsubscribeTarget = subscribeMiniInserterTarget( scheduleSync );
return () => {
observer.disconnect();
unsubscribeTarget();
if ( frame ) window.cancelAnimationFrame( frame );
};
}, [ isOurPostType, applyTargets ] );
if ( ! isOurPostType || targets.length === 0 ) return null;
return targets.map( ( target, i ) => {
// A target inside an inserter popover is a mini inserter: its Insert
// must land in the container that opened it, and inserting should
// dismiss the popover (the docked sidebar list stays open, as before).
const inPopover = !! target.closest( '.block-editor-inserter__popover' );
// Key off the owning tab panel's id (`tabs-N-media-view`), not the array
// index: the popover instance comes and goes, and an index key would let
// React reuse the docked list's subtree for it (and vice versa).
const key = ( target.parentElement && target.parentElement.id ) || `wppb-fb-existing-fields-${ i }`;
return createPortal(
{ __( 'Select a field from the Existing Fields list or click a field block on the canvas to edit its settings.', 'profile-builder' ) }
);
}
// Not-in-form card: VirtualFieldEdit + per-edit persist.
const cardBlockType = field.blockName ? getBlockType( field.blockName ) : null;
const isSubFieldEdit = !! selectedCardParentMeta && !! parentRepeater;
const hint = isSubFieldEdit
? sprintf(
/* translators: %s: parent Repeater title or meta-name */
__( 'Sub-field of Repeater "%s". Edits here update the global sub-field definition.', 'profile-builder' ),
( parentRepeater.title || parentRepeater.metaName )
)
: __( 'This field is not in the current form. Edits here update the global field definition and apply wherever the field is used.', 'profile-builder' );
return (
<>
{ hint }
>
);
};
// Pin complementary area to Field Settings when a PB block/card is selected;
// otherwise Document sidebar. Never closable.
const FieldSettingsAutoOpener = () => {
const { selectedBlockClientId, selectedBlockName } = useSelect( ( select ) => {
const blocks = select( 'core/block-editor' );
const id = blocks.getSelectedBlockClientId();
const block = id ? blocks.getBlock( id ) : null;
return {
selectedBlockClientId: id,
selectedBlockName: block ? block.name : null,
};
}, [] );
const { id: selectedCardId } = useSelectedCard();
const isPbBlockSelected = !! ( selectedBlockClientId && selectedBlockName && selectedBlockName.startsWith( 'profile-builder/' ) );
const isPbCardSelected = selectedCardId !== null && selectedCardId !== undefined;
const isPbSelected = isPbBlockSelected || isPbCardSelected;
// The complementary area is a pure function of selection.
const desiredSidebar = isPbSelected ? SIDEBAR_TARGET : DOCUMENT_SIDEBAR;
const activeSidebar = useSelect( ( select ) => {
const ed = select( 'core/edit-post' );
return ed && typeof ed.getActiveGeneralSidebarName === 'function'
? ed.getActiveGeneralSidebarName()
: null;
}, [] );
useEffect( () => {
if ( activeSidebar === desiredSidebar ) return;
// Microtask: win race against core's complementary-area reset.
queueMicrotask( () => {
const ed = dataSelect( 'core/edit-post' );
const active = ed && typeof ed.getActiveGeneralSidebarName === 'function'
? ed.getActiveGeneralSidebarName()
: null;
if ( active !== desiredSidebar ) openSidebar( desiredSidebar );
} );
}, [ desiredSidebar, activeSidebar ] );
return null;
};
// Auto-switch to Existing Fields when an in-form canvas block is clicked.
const activateExistingFieldsTab = () => {
const tab = document.querySelector( HIJACK_TAB_SELECTOR );
if ( ! tab ) return;
// Don't fight the user's own tab choice / avoid a redundant click.
if ( tab.getAttribute( 'aria-selected' ) === 'true' ) return;
tab.click();
};
const collectPbClientIds = ( blocks, out ) => {
for ( const b of blocks ) {
if ( b.name && b.name.startsWith( 'profile-builder/' ) ) out.add( b.clientId );
if ( b.innerBlocks && b.innerBlocks.length ) collectPbClientIds( b.innerBlocks, out );
}
return out;
};
const ExistingFieldsTabAutoSwitch = () => {
const selectedPbClientId = useSelect( ( select ) => {
const be = select( 'core/block-editor' );
const id = be.getSelectedBlockClientId();
const block = id ? be.getBlock( id ) : null;
return ( block && block.name && block.name.startsWith( 'profile-builder/' ) ) ? id : null;
}, [] );
const canvasBlocks = useSelect( ( select ) => select( 'core/block-editor' ).getBlocks(), [] );
// PB-block clientIds known as of the last canvas snapshot. `null` until the
// first canvas effect runs, so a selection already present at page load
// never triggers a switch.
const knownClientIdsRef = useRef( null );
// Selection effect — declared BEFORE the canvas-tracking effect so that when
// a block is just inserted (canvas tree + selection change in one dispatch),
// this runs against the PRE-insert set and correctly skips it.
useEffect( () => {
if ( ! selectedPbClientId ) return;
if ( knownClientIdsRef.current && knownClientIdsRef.current.has( selectedPbClientId ) ) {
activateExistingFieldsTab();
}
}, [ selectedPbClientId ] );
// Keep the known-clientId set current. Runs after the selection effect.
useEffect( () => {
knownClientIdsRef.current = collectPbClientIds( canvasBlocks, new Set() );
}, [ canvasBlocks ] );
return null;
};
// Clear card-only selection on empty-canvas click.
const DeselectCardOnCanvasClick = () => {
const { id: selectedCardId } = useSelectedCard();
useEffect( () => {
if ( selectedCardId === null || selectedCardId === undefined ) return undefined;
const clearIfEmptySpace = ( isIframe ) => ( e ) => {
const t = e.target;
if ( ! t || typeof t.closest !== 'function' ) return;
// A click on an actual block selects it — ExistingFieldsList's
// store-driven effect clears the card then. Leave it alone.
if ( t.closest( '[data-block], .wp-block' ) ) return;
// Inside the iframed canvas, everything that isn't a block is the
// form's empty space. In the non-iframed fallback, restrict to the
// editor writing surface so clicks on surrounding chrome don't deselect.
if ( isIframe || t.closest( '.block-editor-writing-flow, .block-editor-block-list__layout, .editor-styles-wrapper' ) ) {
setSelectedCard( null );
}
};
const detachers = [];
// pointerdown: core padding appender stops mousedown.
const attach = ( doc, isIframe ) => {
if ( ! doc ) return;
const handler = clearIfEmptySpace( isIframe );
doc.addEventListener( 'pointerdown', handler, true );
detachers.push( () => doc.removeEventListener( 'pointerdown', handler, true ) );
};
attach( document, false );
const iframe = document.querySelector( 'iframe[name="editor-canvas"]' );
if ( iframe ) {
// contentDocument is same-origin (the editor canvas); guard anyway.
try { attach( iframe.contentDocument, true ); } catch ( err ) { /* ignore */ }
}
return () => detachers.forEach( ( off ) => off() );
}, [ selectedCardId ] );
return null;
};
const FieldSettingsSidebar = () => {
const postType = useSelect(
( select ) => select( 'core/editor' ).getCurrentPostType(),
[]
);
const isOurPostType = [ 'wppb-rf-cpt', 'wppb-epf-cpt' ].includes( postType );
if ( ! isOurPostType ) return null;
return (
<>
>
);
};
registerPlugin( 'wppb-fb-existing-fields-sidebar', { render: FieldSettingsSidebar } );