diff --git a/packages/editor/src/components/suggestion-mode/style.scss b/packages/editor/src/components/suggestion-mode/style.scss
index 198ea4b0941a25..cb0f82704ce3ec 100644
--- a/packages/editor/src/components/suggestion-mode/style.scss
+++ b/packages/editor/src/components/suggestion-mode/style.scss
@@ -38,6 +38,25 @@ $suggestion-color: #007017;
}
}
+// Pending-remove: the block is still real (selection, focus, and toolbar
+// keep working) but the visual treatment communicates "proposed for
+// deletion". A horizontal rule sits above the block; reduced opacity
+// de-emphasizes the content without hiding it. Apply runs the actual
+// removal; Reject clears the marker.
+.block-editor-block-list__block.is-suggestion-pending-remove {
+ position: relative;
+ opacity: 0.5;
+
+ &::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ top: 50%;
+ pointer-events: none;
+ border-top: 2px solid var(--wp-block-synced-color, #cc1818);
+ }
+}
+
.editor-collab-sidebar-panel__suggestion-summary {
em {
font-style: italic;
diff --git a/packages/editor/src/components/suggestion-mode/suggestion-diff.js b/packages/editor/src/components/suggestion-mode/suggestion-diff.js
index 5a5189b1dd2c2d..fdccd3f7f1a45b 100644
--- a/packages/editor/src/components/suggestion-mode/suggestion-diff.js
+++ b/packages/editor/src/components/suggestion-mode/suggestion-diff.js
@@ -138,23 +138,12 @@ export default function SuggestionDiff( { operations } ) {
{ __( 'Suggested change' ) }
{ operations.map( ( op, index ) => {
- const canWordDiff =
- op.type === 'attribute-set' &&
- isTextValue( op.before ) &&
- isTextValue( op.after ) &&
- ( op.before?.length ?? 0 ) <= MAX_DIFF_LENGTH &&
- ( op.after?.length ?? 0 ) <= MAX_DIFF_LENGTH;
- const key = `${ op.type }:${ op.attribute }:${ index }`;
+ const key = `${ op.type }:${
+ op.attribute ?? op.clientId
+ }:${ index }`;
return (
- { canWordDiff ? (
-
- ) : (
-
- ) }
+
);
} ) }
@@ -166,6 +155,34 @@ function isTextValue( value ) {
return value === null || value === undefined || typeof value === 'string';
}
+/**
+ * Pick the diff renderer for an operation. Hoisted out of the parent map
+ * loop so the per-op decision tree is a flat if/else rather than a nested
+ * ternary.
+ *
+ * @param {{ operation: import('./provider').SuggestionOperation }} props
+ */
+function DiffForOperation( { operation } ) {
+ if ( operation.type === 'block-remove' ) {
+ return ;
+ }
+ if (
+ operation.type === 'attribute-set' &&
+ isTextValue( operation.before ) &&
+ isTextValue( operation.after ) &&
+ ( operation.before?.length ?? 0 ) <= MAX_DIFF_LENGTH &&
+ ( operation.after?.length ?? 0 ) <= MAX_DIFF_LENGTH
+ ) {
+ return (
+
+ );
+ }
+ return ;
+}
+
function TextDiff( { before, after } ) {
// The LCS below is O(m·n) in time and space. Memoize so repeated
// sidebar renders don't repay the cost.
@@ -216,3 +233,72 @@ function AttributeDiff( { operation } ) {
);
}
+
+/**
+ * Render a `block-remove` op as a strikethrough preview. The op carries a
+ * snapshot of the removed block (`op.block`) so the sidebar can show what
+ * is proposed to disappear without depending on the live tree.
+ *
+ * Falls back to a label-only "Remove block: X" line when the block snapshot
+ * is missing (older payloads, or block-editor reading edge cases).
+ *
+ * @param {{ operation: { blockName?: string, block?: Object } }} props
+ */
+function BlockRemoveDiff( { operation } ) {
+ const blockName = operation.blockName ?? operation.block?.name ?? '';
+ const innerText = collectBlockText( operation.block );
+ return (
+
+
+ { __( 'Deleted:' ) }
+ { innerText
+ ? innerText
+ : blockName || __( 'Block proposed for removal.' ) }
+
+
+ );
+}
+
+/**
+ * Concatenate the text content of a serialized block snapshot for use in
+ * the sidebar diff preview. Walks `attributes.content` (RichText-backed
+ * blocks) plus innerBlocks recursively. Caps the total length at
+ * `MAX_DIFF_LENGTH` so a giant subtree doesn't bloat the sidebar.
+ *
+ * @param {Object|undefined} block Serialized block snapshot.
+ * @return {string} Concatenated text, possibly empty.
+ */
+function collectBlockText( block ) {
+ if ( ! block ) {
+ return '';
+ }
+ const parts = [];
+ const walk = ( node ) => {
+ if ( ! node ) {
+ return;
+ }
+ const text = node.attributes?.content;
+ if ( typeof text === 'string' && text.length > 0 ) {
+ parts.push( text );
+ } else if ( text && typeof text.toString === 'function' ) {
+ // RichTextData wrappers serialize their content via String().
+ const str = String( text );
+ if ( str.length > 0 ) {
+ parts.push( str );
+ }
+ }
+ for ( const child of node.innerBlocks ?? [] ) {
+ walk( child );
+ }
+ };
+ walk( block );
+ const joined = parts.join( ' ' );
+ return joined.length > MAX_DIFF_LENGTH
+ ? `${ joined.slice( 0, MAX_DIFF_LENGTH ) }…`
+ : joined;
+}
+
+export { collectBlockText };
diff --git a/packages/editor/src/components/suggestion-mode/suggestion-summary.js b/packages/editor/src/components/suggestion-mode/suggestion-summary.js
index 9bc070b5b65c44..336a7b1e0c8cdb 100644
--- a/packages/editor/src/components/suggestion-mode/suggestion-summary.js
+++ b/packages/editor/src/components/suggestion-mode/suggestion-summary.js
@@ -59,6 +59,26 @@ const FORMAT_ATTRIBUTE_LABELS = {
textColor: __( 'text color' ),
};
+/**
+ * Convert a block name like `core/paragraph` to a friendlier label used in
+ * structural-suggestion summaries ("Remove block: paragraph"). Strips the
+ * namespace prefix and falls back to the raw name when the block name is
+ * empty or non-namespaced.
+ *
+ * @param {string|undefined} blockName Block name from the suggestion op.
+ * @return {string} Display label.
+ */
+function friendlyBlockName( blockName ) {
+ if ( ! blockName || typeof blockName !== 'string' ) {
+ return __( 'block' );
+ }
+ const slashIdx = blockName.indexOf( '/' );
+ if ( slashIdx === -1 ) {
+ return blockName;
+ }
+ return blockName.slice( slashIdx + 1 ) || blockName;
+}
+
/**
* Mapping of inline HTML tags — as emitted by RichText serialization — to
* human-readable format names. The key is the lower-cased tag name; the
@@ -223,6 +243,13 @@ export function summarizeOperations( operations ) {
const formattingLabels = [];
for ( const op of operations ) {
+ if ( op.type === 'block-remove' ) {
+ lines.push( {
+ label: __( 'Remove block:' ),
+ value: friendlyBlockName( op.blockName ),
+ } );
+ continue;
+ }
if ( op.type !== 'attribute-set' ) {
attributeLabels.push( op.attribute );
continue;
diff --git a/packages/editor/src/components/suggestion-mode/test/suggestion-diff.js b/packages/editor/src/components/suggestion-mode/test/suggestion-diff.js
index 1bcb499884a0ef..c3c375dfb33c3a 100644
--- a/packages/editor/src/components/suggestion-mode/test/suggestion-diff.js
+++ b/packages/editor/src/components/suggestion-mode/test/suggestion-diff.js
@@ -1,7 +1,7 @@
/**
* Internal dependencies
*/
-import { wordDiff } from '../suggestion-diff';
+import { wordDiff, collectBlockText } from '../suggestion-diff';
describe( 'wordDiff', () => {
it( 'returns equal segments for identical strings', () => {
@@ -71,3 +71,56 @@ describe( 'wordDiff', () => {
);
} );
} );
+
+describe( 'collectBlockText', () => {
+ it( 'returns an empty string for null input', () => {
+ expect( collectBlockText( null ) ).toBe( '' );
+ expect( collectBlockText( undefined ) ).toBe( '' );
+ } );
+
+ it( 'returns the content of a single-block snapshot', () => {
+ expect(
+ collectBlockText( {
+ name: 'core/paragraph',
+ attributes: { content: 'Hello world' },
+ innerBlocks: [],
+ } )
+ ).toBe( 'Hello world' );
+ } );
+
+ it( 'walks innerBlocks recursively', () => {
+ expect(
+ collectBlockText( {
+ name: 'core/group',
+ attributes: {},
+ innerBlocks: [
+ {
+ name: 'core/paragraph',
+ attributes: { content: 'one' },
+ innerBlocks: [],
+ },
+ {
+ name: 'core/paragraph',
+ attributes: { content: 'two' },
+ innerBlocks: [],
+ },
+ ],
+ } )
+ ).toBe( 'one two' );
+ } );
+
+ it( 'stringifies wrapper-shaped content via toString', () => {
+ const wrapped = {
+ toString() {
+ return 'wrapped text';
+ },
+ };
+ expect(
+ collectBlockText( {
+ name: 'core/paragraph',
+ attributes: { content: wrapped },
+ innerBlocks: [],
+ } )
+ ).toBe( 'wrapped text' );
+ } );
+} );
diff --git a/packages/editor/src/components/suggestion-mode/test/suggestion-summary.js b/packages/editor/src/components/suggestion-mode/test/suggestion-summary.js
index 4961eb9b610a2c..5ff7eb2262fbef 100644
--- a/packages/editor/src/components/suggestion-mode/test/suggestion-summary.js
+++ b/packages/editor/src/components/suggestion-mode/test/suggestion-summary.js
@@ -199,4 +199,39 @@ describe( 'summarizeOperations', () => {
] );
expect( lines ).toEqual( [ { label: 'Formatting:', value: 'bold' } ] );
} );
+
+ it( 'summarizes a block-remove op as "Remove block: "', () => {
+ const lines = summarizeOperations( [
+ {
+ type: 'block-remove',
+ clientId: 'abc',
+ blockName: 'core/paragraph',
+ },
+ ] );
+ expect( lines ).toEqual( [
+ { label: 'Remove block:', value: 'paragraph' },
+ ] );
+ } );
+
+ it( 'falls back to "block" when the block name is missing', () => {
+ const lines = summarizeOperations( [
+ { type: 'block-remove', clientId: 'abc' },
+ ] );
+ expect( lines ).toEqual( [
+ { label: 'Remove block:', value: 'block' },
+ ] );
+ } );
+
+ it( 'preserves a non-namespaced block name', () => {
+ const lines = summarizeOperations( [
+ {
+ type: 'block-remove',
+ clientId: 'abc',
+ blockName: 'custom-block',
+ },
+ ] );
+ expect( lines ).toEqual( [
+ { label: 'Remove block:', value: 'custom-block' },
+ ] );
+ } );
} );
diff --git a/packages/editor/src/components/suggestion-mode/test/with-suggestion-overlay.js b/packages/editor/src/components/suggestion-mode/test/with-suggestion-overlay.js
index afacd69920caf1..2ab9fe2693335c 100644
--- a/packages/editor/src/components/suggestion-mode/test/with-suggestion-overlay.js
+++ b/packages/editor/src/components/suggestion-mode/test/with-suggestion-overlay.js
@@ -14,6 +14,7 @@ import { store as noticesStore } from '@wordpress/notices';
*/
import withSuggestionOverlay, {
mergeOverlayAttributes,
+ structuralMarkerClass,
} from '../with-suggestion-overlay';
import {
SuggestionOverlayProvider,
@@ -273,3 +274,22 @@ describe( 'mergeOverlayAttributes', () => {
).toEqual( { custom: { other: 'new' } } );
} );
} );
+
+describe( 'structuralMarkerClass', () => {
+ it( 'maps each known marker type to its class', () => {
+ expect( structuralMarkerClass( 'pending-remove' ) ).toBe(
+ 'is-suggestion-pending-remove'
+ );
+ expect( structuralMarkerClass( 'pending-insert' ) ).toBe(
+ 'is-suggestion-pending-insert'
+ );
+ expect( structuralMarkerClass( 'pending-move' ) ).toBe(
+ 'is-suggestion-pending-move'
+ );
+ } );
+
+ it( 'returns null for unknown or missing types', () => {
+ expect( structuralMarkerClass( undefined ) ).toBeNull();
+ expect( structuralMarkerClass( 'something-else' ) ).toBeNull();
+ } );
+} );
diff --git a/packages/editor/src/components/suggestion-mode/with-suggestion-overlay.js b/packages/editor/src/components/suggestion-mode/with-suggestion-overlay.js
index 7a5a4981783286..98525a1eb4b6e8 100644
--- a/packages/editor/src/components/suggestion-mode/with-suggestion-overlay.js
+++ b/packages/editor/src/components/suggestion-mode/with-suggestion-overlay.js
@@ -8,6 +8,7 @@ import clsx from 'clsx';
*/
import { createHigherOrderComponent } from '@wordpress/compose';
import { useSelect } from '@wordpress/data';
+import { store as blockEditorStore } from '@wordpress/block-editor';
import { useCallback, useMemo, useRef } from '@wordpress/element';
import { addFilter } from '@wordpress/hooks';
@@ -136,29 +137,69 @@ const withSuggestionOverlay = createHigherOrderComponent(
'withSuggestionOverlay'
);
+/**
+ * Map a `metadata.suggestion.type` marker to the class that drives the
+ * structural-suggestion visual treatment. Keeps the marker → class lookup
+ * in one place so the rendering layer stays a thin shell over the data
+ * model.
+ *
+ * @param {string|undefined} type Marker type.
+ * @return {string|null} Class name for the marker, or null when the type
+ * is not a recognized structural marker.
+ */
+function structuralMarkerClass( type ) {
+ switch ( type ) {
+ case 'pending-remove':
+ return 'is-suggestion-pending-remove';
+ case 'pending-insert':
+ return 'is-suggestion-pending-insert';
+ case 'pending-move':
+ return 'is-suggestion-pending-move';
+ default:
+ return null;
+ }
+}
+
/**
* HOC that tags the rendered block list item with a class whenever it has a
- * pending suggestion overlay. The class is the hook for the "bracket"
- * styling that makes edited blocks discoverable without relying on the
- * block toolbar being visible.
+ * pending suggestion — either an attribute overlay (renders the green
+ * "bracket" treatment) or a structural marker stored in
+ * `metadata.suggestion` (renders strikethrough/dim/move overlays).
+ *
+ * The attribute "bracket" is suggest-mode-only — it represents the suggester's
+ * uncommitted edits living in the local overlay, which other intents have no
+ * way to interact with. Structural markers, by contrast, are persisted on the
+ * live block (synced through the same path as block content), so reviewers in
+ * Edit or View intent see and can act on them too — that's the visual cue a
+ * post author needs to spot a pending removal/insertion/move at a glance.
*/
const withSuggestionBlockClassName = createHigherOrderComponent(
( BlockListBlock ) =>
function BlockListBlockWithSuggestionClass( props ) {
const { clientId } = props;
const { entries } = useSuggestionOverlay();
- const isSuggestMode = useSelect(
- ( select ) =>
- select( EDITOR_STORE_NAME ).getEditorIntent() ===
- SUGGEST_INTENT,
- []
+ const { isSuggestMode, structuralClass } = useSelect(
+ ( select ) => {
+ const editor = select( EDITOR_STORE_NAME );
+ const blockEditor = select( blockEditorStore );
+ return {
+ isSuggestMode:
+ editor.getEditorIntent() === SUGGEST_INTENT,
+ structuralClass: structuralMarkerClass(
+ blockEditor?.getBlockAttributes?.( clientId )
+ ?.metadata?.suggestion?.type
+ ),
+ };
+ },
+ [ clientId ]
);
const entry = entries[ clientId ];
const hasPendingOverlay =
!! entry &&
Object.keys( entry.overlayAttributes ?? {} ).length > 0;
+ const showOverlayBracket = isSuggestMode && hasPendingOverlay;
- if ( ! isSuggestMode || ! hasPendingOverlay ) {
+ if ( ! showOverlayBracket && ! structuralClass ) {
return ;
}
@@ -167,7 +208,8 @@ const withSuggestionBlockClassName = createHigherOrderComponent(
{ ...props }
className={ clsx(
props.className,
- 'is-suggestion-pending'
+ showOverlayBracket && 'is-suggestion-pending',
+ structuralClass
) }
/>
);
@@ -175,6 +217,8 @@ const withSuggestionBlockClassName = createHigherOrderComponent(
'withSuggestionBlockClassName'
);
+export { structuralMarkerClass };
+
let filterRegistered = false;
/**