PluginProbe
User Profile Builder – Beautiful User Registration Forms, User Profiles & User Role Editor / 4.0.3
User Profile Builder – Beautiful User Registration Forms, User Profiles & User Role Editor v4.0.3
4.0.3 4.0.2 4.0.1 4.0.0 3.16.6 3.16.5 3.16.4 3.16.3 3.16.2 3.16.1 3.16.0 3.15.9 3.9.9 3.9.5 3.9.6 3.9.7 3.9.8 1.1.7 1.1.8 1.1.9 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 All 341 releases
profile-builder / form-builder / blocks / src / components / RepeaterScopeGuard.js

RepeaterScopeGuard.js in User Profile Builder – Beautiful User Registration Forms, User Profiles & User Role Editor 4.0.3, at form-builder/blocks/src/components/RepeaterScopeGuard.js

185 lines 7.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Keep Repeater sub-fields inside their parent (CSS drag gate + drop reconciler).
3 * Reconciler is suspended during drag so it does not fight the live preview.
4 */
5
6 import { registerPlugin } from '@wordpress/plugins';
7 import { useSelect, useDispatch, select as dataSelect } from '@wordpress/data';
8 import { useEffect, useRef } from '@wordpress/element';
9
10 import { REPEATER_BLOCK_NAME as REPEATER_BLOCK } from '../lib/repeaterBlockName';
11
12 /**
13 * Build a clientId → { parent, index } position map by walking the entire
14 * top-level block tree (one level deep is enough — Repeater InnerBlocks are
15 * direct children; we don't currently model nested containers below that).
16 */
17 function buildPositionMap( blocks ) {
18 const map = {};
19 const walk = ( list, parentClientId ) => {
20 for ( let i = 0; i < list.length; i++ ) {
21 const b = list[ i ];
22 map[ b.clientId ] = { parent: parentClientId, index: i, name: b.name };
23 if ( b.innerBlocks && b.innerBlocks.length ) {
24 walk( b.innerBlocks, b.clientId );
25 }
26 }
27 };
28 walk( blocks, '' );
29 return map;
30 }
31
32 /**
33 * Locate the root element that should carry the drag-origin CSS classes.
34 * In modern WP the editor canvas lives in an iframe; in legacy / non-iframe
35 * Gutenberg it's in the main document. Try the iframe first.
36 */
37 function findCanvasRoot() {
38 const iframe = document.querySelector( 'iframe[name="editor-canvas"]' );
39 const doc = iframe && iframe.contentDocument;
40 if ( doc && doc.body ) return doc.body;
41 return document.querySelector( '.editor-styles-wrapper' );
42 }
43
44 function useRepeaterScopeGuard() {
45 const { positionMap, isDragging, dragOrigin, allowedParentId } = useSelect(
46 ( select ) => {
47 const ed = select( 'core/block-editor' );
48 const dragged = ed.getDraggedBlockClientIds ? ed.getDraggedBlockClientIds() : [];
49 const dragging = !! ( dragged && dragged.length );
50
51 let origin = null;
52 let parentId = null;
53 if ( dragging ) {
54 // Walk the first dragged block's ancestor chain. If any
55 // ancestor is a Repeater, the drag originated inside one.
56 const firstId = dragged[ 0 ];
57 const parents = ed.getBlockParents( firstId ) || [];
58 for ( let i = parents.length - 1; i >= 0; i-- ) {
59 const p = ed.getBlock( parents[ i ] );
60 if ( p && p.name === REPEATER_BLOCK ) {
61 origin = 'inside';
62 parentId = parents[ i ];
63 break;
64 }
65 }
66 if ( ! origin ) origin = 'outside';
67 }
68
69 return {
70 positionMap: buildPositionMap( ed.getBlocks() ),
71 isDragging: dragging,
72 dragOrigin: origin,
73 allowedParentId: parentId,
74 };
75 },
76 []
77 );
78
79 const lastMapRef = useRef( null );
80 const { moveBlocksToPosition } = useDispatch( 'core/block-editor' );
81
82 // Effect 1 — CSS overlay. Only the class flip lives here; the `pointer-events`
83 // suppression and why it must be anchored at the SENIOR wrapper are documented
84 // in style/_repeater-scope.scss. Depends only on dragOrigin + allowedParentId,
85 // so it runs at drag start/end and origin transitions — not per frame.
86 useEffect( () => {
87 const root = findCanvasRoot();
88 if ( ! root ) return;
89
90 root.classList.remove( 'wppb-fb-drag-from-outside', 'wppb-fb-drag-from-repeater' );
91 const stale = root.querySelectorAll( '.wppb-fb-drag-allowed-parent' );
92 for ( const el of stale ) el.classList.remove( 'wppb-fb-drag-allowed-parent' );
93
94 if ( dragOrigin === 'outside' ) {
95 root.classList.add( 'wppb-fb-drag-from-outside' );
96 } else if ( dragOrigin === 'inside' ) {
97 root.classList.add( 'wppb-fb-drag-from-repeater' );
98 if ( allowedParentId ) {
99 const parentEl = root.querySelector( '[data-block="' + allowedParentId + '"]' );
100 if ( parentEl ) parentEl.classList.add( 'wppb-fb-drag-allowed-parent' );
101 }
102 }
103 }, [ dragOrigin, allowedParentId ] );
104
105 // Effect 2 — drop-time reconciler. Skipped during an active drag (the
106 // CSS overlay handles that). On every tree change while NOT dragging,
107 // diffs against the last stable snapshot and snaps back any cross-
108 // boundary moves. Uses a two-pass `expectedMap` pattern and fires at
109 // most once per drag (after release).
110 useEffect( () => {
111 if ( isDragging ) {
112 // Freeze the snapshot during the drag. Don't update lastMapRef
113 // here — we want the post-drag diff to compare against the
114 // pre-drag state.
115 return;
116 }
117
118 const prev = lastMapRef.current;
119 if ( ! prev ) {
120 lastMapRef.current = positionMap;
121 return;
122 }
123
124 const editor = dataSelect( 'core/block-editor' );
125 const isRepeaterContainer = ( clientId ) => {
126 if ( ! clientId ) return false;
127 const b = editor.getBlock( clientId );
128 return !! ( b && b.name === REPEATER_BLOCK );
129 };
130
131 // Two-pass to avoid an infinite update loop — the ping-pong between the
132 // snap-back dispatch and the tree change it triggers. Predict the
133 // post-snap state, write it to lastMapRef BEFORE dispatching, then dispatch.
134 const expectedMap = { ...positionMap };
135 const snapBacks = [];
136
137 for ( const clientId in positionMap ) {
138 if ( ! ( clientId in prev ) ) continue;
139 const prevPos = prev[ clientId ];
140 const currPos = positionMap[ clientId ];
141 if ( prevPos.parent === currPos.parent ) continue;
142
143 const prevWasInRepeater = isRepeaterContainer( prevPos.parent );
144 const currIsInRepeater = isRepeaterContainer( currPos.parent );
145 if ( ! prevWasInRepeater && ! currIsInRepeater ) continue;
146
147 snapBacks.push( {
148 clientId,
149 fromParent: currPos.parent || '',
150 toParent: prevPos.parent || '',
151 toIndex: prevPos.index,
152 } );
153 expectedMap[ clientId ] = prevPos;
154 }
155
156 lastMapRef.current = expectedMap;
157
158 for ( const sb of snapBacks ) {
159 moveBlocksToPosition( [ sb.clientId ], sb.fromParent, sb.toParent, sb.toIndex );
160 }
161 }, [ positionMap, isDragging, moveBlocksToPosition ] );
162 // moveBlocksToPosition is stable across renders via Gutenberg's
163 // useDispatch memoization, so including it in deps doesn't change
164 // re-run frequency — it just keeps the dep array honest and lets the
165 // exhaustive-deps lint catch real omissions in future edits.
166 }
167
168 // Inner component runs the hook. Conditionally rendered so the hook (and
169 // its full block-tree useSelect subscription) doesn't fire on non-PB CPTs.
170 const RepeaterScopeGuardInner = () => {
171 useRepeaterScopeGuard();
172 return null;
173 };
174
175 // Outer component gates on post type. Calling the hook on every WP editor
176 // (posts, pages, every CPT) just to no-op on tree changes is wasted work —
177 // the hook subscribes to the full block tree.
178 const RepeaterScopeGuard = () => {
179 const postType = useSelect( ( select ) => select( 'core/editor' ).getCurrentPostType(), [] );
180 if ( ! [ 'wppb-rf-cpt', 'wppb-epf-cpt' ].includes( postType ) ) return null;
181 return <RepeaterScopeGuardInner />;
182 };
183
184 registerPlugin( 'wppb-repeater-scope-guard', { render: RepeaterScopeGuard } );
185