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