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; /**