Skip to content

Commit 0828f5c

Browse files
Suggestions: Capture and apply block-move suggestions
Moves in Suggest mode now flow through the apply-and-tag pipeline: the block stays at its new position, the metadata.suggestion = pending-move marker carries the from-parent + from-anchor + from- index, and auto-save persists a block-move operation. The detection heuristic combines two signals: any block whose parent changed is a cross-parent move; within the same parent, blocks NOT in the LCS of old vs new sibling order are reordered moves. The LCS step prevents tagging blocks whose index just shifted as a side-effect of another block moving past them — moving B from index 1 to 3 in [A,B,C,D] tags only B, not the C+D that filled the gap. Apply clears the marker (the block is already at its proposed location). Reject clears the marker and dispatches moveBlockToPosition to revert to the from-parent + from-index. Both paths run with bypass so the interceptor doesn't fight them. Refs #77434.
1 parent 37984e4 commit 0828f5c

3 files changed

Lines changed: 331 additions & 17 deletions

File tree

packages/editor/src/components/suggestion-mode/provider.js

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ export function useSuggestionsProvider() {
389389

390390
const { saveEntityRecord } = useDispatch( coreStore );
391391
const { createNotice } = useDispatch( noticesStore );
392-
const { updateBlockAttributes, removeBlock } =
392+
const { updateBlockAttributes, removeBlock, moveBlockToPosition } =
393393
useDispatch( blockEditorStore );
394394
const {
395395
getBlockAttributes: selectBlockAttributes,
@@ -661,23 +661,27 @@ export function useSuggestionsProvider() {
661661
requestInterceptorBypass( targetClientId );
662662
clearOverlay( targetClientId );
663663
removeBlock( targetClientId );
664-
} else if ( structuralOp.type === 'block-insert-after' ) {
665-
// The block is already in the live tree (the user
666-
// inserted it during Suggest mode); apply commits
667-
// the captured edits onto the live block AND clears
668-
// the pending-insert marker so the block loses its
669-
// dimmed treatment.
664+
} else if (
665+
structuralOp.type === 'block-insert-after' ||
666+
structuralOp.type === 'block-move'
667+
) {
668+
// The block is already at its proposed location
669+
// (the user inserted or moved it during Suggest
670+
// mode); apply commits the captured edits onto the
671+
// live block AND clears the pending marker so the
672+
// block loses its dimmed/outlined treatment.
670673
//
671674
// Attribute-set ops in the same payload represent
672-
// edits the user made between insertion and auto-
673-
// save. They never reach the live block on the
674-
// suggester's side — the interceptor reverts them
675-
// into the overlay — so collaborators (and the
676-
// suggester after a reload) see the live block in
677-
// the captured shape (typically empty content for
678-
// a fresh paragraph). Apply must materialize those
679-
// edits on the live block, otherwise the inserted
680-
// block ends up empty after acceptance.
675+
// edits the user made between the structural
676+
// change and auto-save. They never reach the live
677+
// block on the suggester's side — the interceptor
678+
// reverts them into the overlay — so collaborators
679+
// (and the suggester after a reload) see the live
680+
// block in the captured shape (typically empty
681+
// content for a fresh paragraph). Apply must
682+
// materialize those edits on the live block,
683+
// otherwise the inserted/moved block ends up in
684+
// the wrong shape after acceptance.
681685
const currentAttributes =
682686
selectBlockAttributes( targetClientId );
683687
const withOpsApplied = applyOperations(
@@ -829,13 +833,34 @@ export function useSuggestionsProvider() {
829833
// - block-insert-after: dispatch removeBlock to undo the
830834
// suggested insertion. The marker on the live block goes
831835
// away with the block itself.
836+
// - block-move: clear the marker, then dispatch
837+
// moveBlockToPosition to put the block back at its
838+
// pre-move parent + index.
832839
// - attribute-set (no structural op): no live-block change.
833840
const structuralOp = findStructuralOp( payload?.operations );
834841
if ( structuralOp && clientId ) {
835842
if ( structuralOp.type === 'block-insert-after' ) {
836843
requestInterceptorBypass( clientId );
837844
clearOverlay( clientId );
838845
removeBlock( clientId );
846+
} else if ( structuralOp.type === 'block-move' ) {
847+
const clearAttrs = clearSuggestionMarkerAttributes(
848+
selectBlockAttributes( clientId )
849+
);
850+
if ( clearAttrs ) {
851+
requestInterceptorBypass( clientId );
852+
updateBlockAttributes( clientId, clearAttrs );
853+
}
854+
requestInterceptorBypass( clientId );
855+
clearOverlay( clientId );
856+
moveBlockToPosition(
857+
clientId,
858+
// `moveBlockToPosition` expects '' (not null) for
859+
// the root.
860+
structuralOp.fromParentClientId ?? '',
861+
structuralOp.fromParentClientId ?? '',
862+
structuralOp.fromIndex ?? 0
863+
);
839864
} else {
840865
const clearAttrs = clearSuggestionMarkerAttributes(
841866
selectBlockAttributes( clientId )
@@ -878,6 +903,7 @@ export function useSuggestionsProvider() {
878903
selectBlockAttributes,
879904
updateBlockAttributes,
880905
removeBlock,
906+
moveBlockToPosition,
881907
requestInterceptorBypass,
882908
clearOverlay,
883909
]

packages/editor/src/components/suggestion-mode/store-interceptor.js

Lines changed: 213 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@
3333
* prior parent + index, then tagged `metadata.suggestion = pending-remove`.
3434
* - New blocks → tagged `metadata.suggestion = pending-insert` (the block
3535
* stays in the tree; the marker drives the dimmed visual treatment).
36-
* - Move detection ships in a follow-up.
36+
* - Moved blocks → tagged `metadata.suggestion = pending-move` with the
37+
* pre-move parent + anchor; an LCS-based heuristic isolates the moved
38+
* block from siblings whose index just shifted as a side-effect.
3739
*
3840
* In every case the live block carries the marker and a corresponding
3941
* structural op is written to the overlay so auto-save persists it.
@@ -432,6 +434,171 @@ function topLevelRemoved( removedIds, parentByClientId ) {
432434
} );
433435
}
434436

437+
/**
438+
* Length of the longest common subsequence of two arrays of clientIds (the
439+
* elements of the result appear in the same relative order in both inputs).
440+
* Used by the move-detection heuristic: blocks NOT in the LCS of their
441+
* parent's old vs new sibling order are the ones that actually moved;
442+
* blocks in the LCS just had their index shift as a side-effect.
443+
*
444+
* @param {string[]} a First array.
445+
* @param {string[]} b Second array.
446+
* @return {Set<string>} The LCS as a set for O(1) membership checks.
447+
*/
448+
function lcsClientIds( a, b ) {
449+
const m = a.length;
450+
const n = b.length;
451+
if ( m === 0 || n === 0 ) {
452+
return new Set();
453+
}
454+
const dp = Array.from( { length: m + 1 }, () =>
455+
new Array( n + 1 ).fill( 0 )
456+
);
457+
for ( let i = 1; i <= m; i++ ) {
458+
for ( let j = 1; j <= n; j++ ) {
459+
dp[ i ][ j ] =
460+
a[ i - 1 ] === b[ j - 1 ]
461+
? dp[ i - 1 ][ j - 1 ] + 1
462+
: Math.max( dp[ i - 1 ][ j ], dp[ i ][ j - 1 ] );
463+
}
464+
}
465+
const result = new Set();
466+
let i = m;
467+
let j = n;
468+
while ( i > 0 && j > 0 ) {
469+
if ( a[ i - 1 ] === b[ j - 1 ] ) {
470+
result.add( a[ i - 1 ] );
471+
i--;
472+
j--;
473+
} else if ( dp[ i - 1 ][ j ] > dp[ i ][ j - 1 ] ) {
474+
i--;
475+
} else {
476+
j--;
477+
}
478+
}
479+
return result;
480+
}
481+
482+
/**
483+
* Detect blocks that moved between two ticks of the live tree. A block has
484+
* "moved" when its parent changed (cross-parent move) or, within the same
485+
* parent, its position falls outside the LCS of the parent's old vs new
486+
* sibling order. The LCS heuristic prevents tagging blocks whose index
487+
* just shifted as a side-effect of another block moving past them.
488+
*
489+
* Blocks that are new (not in the previous-tick tree) or removed (in the
490+
* previous-tick tree but not live) are handled by the insertion / removal
491+
* branches; this function ignores both.
492+
*
493+
* @param {string[]} liveClientIds Live tree client ids.
494+
* @param {Object} tree Previous-tick tree snapshot from
495+
* `captureTreeSnapshot`.
496+
* @param {Object} blockEditor Block-editor selectors.
497+
* @return {Array<Object>} One entry per moved block, with from/to anchors.
498+
*/
499+
function detectMovedBlocks( liveClientIds, tree, blockEditor ) {
500+
const movedRaw = [];
501+
502+
const candidatesByNewParent = new Map();
503+
for ( const clientId of liveClientIds ) {
504+
if ( ! tree.parentByClientId.has( clientId ) ) {
505+
continue; // new block — handled elsewhere
506+
}
507+
const oldParent = tree.parentByClientId.get( clientId );
508+
const newParent =
509+
blockEditor.getBlockRootClientId?.( clientId ) || null;
510+
if ( oldParent !== newParent ) {
511+
movedRaw.push( { clientId, oldParent, newParent } );
512+
continue;
513+
}
514+
if ( ! candidatesByNewParent.has( newParent ) ) {
515+
candidatesByNewParent.set( newParent, [] );
516+
}
517+
candidatesByNewParent.get( newParent ).push( clientId );
518+
}
519+
520+
for ( const [ parent, candidates ] of candidatesByNewParent ) {
521+
const oldSiblings = candidates
522+
.slice()
523+
.sort(
524+
( a, b ) =>
525+
( tree.indexByClientId.get( a ) ?? 0 ) -
526+
( tree.indexByClientId.get( b ) ?? 0 )
527+
);
528+
const newSiblingOrder =
529+
blockEditor.getBlockOrder?.( parent ?? undefined ) ?? [];
530+
const newSiblings = candidates
531+
.slice()
532+
.sort(
533+
( a, b ) =>
534+
newSiblingOrder.indexOf( a ) - newSiblingOrder.indexOf( b )
535+
);
536+
const samePosition =
537+
oldSiblings.length === newSiblings.length &&
538+
oldSiblings.every( ( id, i ) => id === newSiblings[ i ] );
539+
if ( samePosition ) {
540+
continue;
541+
}
542+
const stable = lcsClientIds( oldSiblings, newSiblings );
543+
for ( const clientId of newSiblings ) {
544+
if ( ! stable.has( clientId ) ) {
545+
movedRaw.push( {
546+
clientId,
547+
oldParent: parent,
548+
newParent: parent,
549+
} );
550+
}
551+
}
552+
}
553+
554+
if ( movedRaw.length === 0 ) {
555+
return [];
556+
}
557+
558+
// Reconstruct the previous-tick sibling order per parent so we can
559+
// compute fromAnchorClientId. Building this lazily keeps the no-move
560+
// path zero-cost.
561+
const oldOrderByParent = new Map();
562+
const oldOrderFor = ( oldParent ) => {
563+
if ( oldOrderByParent.has( oldParent ) ) {
564+
return oldOrderByParent.get( oldParent );
565+
}
566+
const ids = [];
567+
for ( const [ id, parent ] of tree.parentByClientId ) {
568+
if ( parent === oldParent ) {
569+
ids.push( id );
570+
}
571+
}
572+
ids.sort(
573+
( a, b ) =>
574+
( tree.indexByClientId.get( a ) ?? 0 ) -
575+
( tree.indexByClientId.get( b ) ?? 0 )
576+
);
577+
oldOrderByParent.set( oldParent, ids );
578+
return ids;
579+
};
580+
581+
return movedRaw.map( ( { clientId, oldParent, newParent } ) => {
582+
const oldIndex = tree.indexByClientId.get( clientId ) ?? 0;
583+
const oldSiblingOrder = oldOrderFor( oldParent );
584+
const fromAnchorClientId =
585+
oldIndex > 0 ? oldSiblingOrder[ oldIndex - 1 ] : null;
586+
const newSiblingOrder =
587+
blockEditor.getBlockOrder?.( newParent ?? undefined ) ?? [];
588+
const newIndex = newSiblingOrder.indexOf( clientId );
589+
const toAnchorClientId =
590+
newIndex > 0 ? newSiblingOrder[ newIndex - 1 ] : null;
591+
return {
592+
clientId,
593+
fromParentClientId: oldParent,
594+
fromAnchorClientId,
595+
fromIndex: oldIndex,
596+
toParentClientId: newParent,
597+
toAnchorClientId,
598+
};
599+
} );
600+
}
601+
435602
/**
436603
* Invisible component that catches block-attribute mutations dispatched
437604
* directly to the block-editor store while the editor is in Suggest intent.
@@ -685,6 +852,49 @@ export default function SuggestionStoreInterceptor() {
685852
// block; do NOT update it to `current` here.
686853
}
687854

855+
// Detect blocks that moved (different parent or out-of-place
856+
// within the same parent). The block stays at its new
857+
// position; tag it with `metadata.suggestion = pending-move`
858+
// and capture the from/to anchors for the structural op.
859+
// Apply leaves the block where it is; Reject dispatches
860+
// `moveBlockToPosition` with the from-position.
861+
const moves = detectMovedBlocks( liveClientIds, tree, blockEditor );
862+
for ( const move of moves ) {
863+
const currentAttrs = blockEditor.getBlockAttributes?.(
864+
move.clientId
865+
);
866+
if ( ! currentAttrs ) {
867+
continue;
868+
}
869+
const block = blockEditor.getBlock?.( move.clientId );
870+
if ( ! block ) {
871+
continue;
872+
}
873+
isReverting = true;
874+
try {
875+
blockEditorDispatch.updateBlockAttributes( move.clientId, {
876+
metadata: withSuggestionMarker( currentAttrs.metadata, {
877+
type: 'pending-move',
878+
fromAnchorClientId: move.fromAnchorClientId,
879+
fromParentClientId: move.fromParentClientId,
880+
fromIndex: move.fromIndex,
881+
} ),
882+
} );
883+
} finally {
884+
isReverting = false;
885+
}
886+
setStructuralOpRef.current?.( move.clientId, block.name, {
887+
type: 'block-move',
888+
clientId: move.clientId,
889+
blockName: block.name,
890+
fromAnchorClientId: move.fromAnchorClientId,
891+
fromParentClientId: move.fromParentClientId,
892+
fromIndex: move.fromIndex,
893+
toAnchorClientId: move.toAnchorClientId,
894+
toParentClientId: move.toParentClientId,
895+
} );
896+
}
897+
688898
// Detect blocks that disappeared from the live tree and route
689899
// them through the Suggest-mode "apply-and-tag" flow: re-insert
690900
// the subtree from the previous-tick snapshot at its previous
@@ -838,4 +1048,6 @@ export {
8381048
captureTreeSnapshot,
8391049
topLevelRemoved,
8401050
withSuggestionMarker,
1051+
lcsClientIds,
1052+
detectMovedBlocks,
8411053
};

0 commit comments

Comments
 (0)