Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/editor/src/components/suggestion-mode/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
116 changes: 101 additions & 15 deletions packages/editor/src/components/suggestion-mode/suggestion-diff.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,23 +138,12 @@ export default function SuggestionDiff( { operations } ) {
{ __( 'Suggested change' ) }
</WCText>
{ 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 (
<div key={ key }>
{ canWordDiff ? (
<TextDiff
before={ op.before ?? '' }
after={ op.after }
/>
) : (
<AttributeDiff operation={ op } />
) }
<DiffForOperation operation={ op } />
</div>
);
} ) }
Expand All @@ -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 <BlockRemoveDiff operation={ operation } />;
}
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 (
<TextDiff
before={ operation.before ?? '' }
after={ operation.after }
/>
);
}
return <AttributeDiff operation={ operation } />;
}

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.
Expand Down Expand Up @@ -216,3 +233,72 @@ function AttributeDiff( { operation } ) {
</WCText>
);
}

/**
* 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 (
<WCText
size="13px"
className="editor-collab-sidebar-panel__suggestion-text-diff"
>
<del>
<VisuallyHidden>{ __( 'Deleted:' ) }</VisuallyHidden>
{ innerText
? innerText
: blockName || __( 'Block proposed for removal.' ) }
</del>
</WCText>
);
}

/**
* 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 };
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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' );
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -199,4 +199,39 @@ describe( 'summarizeOperations', () => {
] );
expect( lines ).toEqual( [ { label: 'Formatting:', value: 'bold' } ] );
} );

it( 'summarizes a block-remove op as "Remove block: <name>"', () => {
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' },
] );
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { store as noticesStore } from '@wordpress/notices';
*/
import withSuggestionOverlay, {
mergeOverlayAttributes,
structuralMarkerClass,
} from '../with-suggestion-overlay';
import {
SuggestionOverlayProvider,
Expand Down Expand Up @@ -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();
} );
} );
Loading
Loading