Skip to content

Commit 6dbb1e5

Browse files
Merge remote-tracking branch 'origin/suggest/inline-wiring' into fix/73411-f18-accept-undo
# Conflicts: # packages/editor/CHANGELOG.md # test/e2e/specs/editor/various/suggestion-mode-undo.spec.js
2 parents 9f1f852 + f2264de commit 6dbb1e5

65 files changed

Lines changed: 4533 additions & 237 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/block-editor/src/components/list-view/block.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { useListViewContext } from './context';
3030
import {
3131
getBlockPositionDescription,
3232
getBlockPropertiesDescription,
33+
getBlockSuggestionTreatment,
3334
focusListItem,
3435
} from './utils';
3536
import { store as blockEditorStore } from '../../store';
@@ -119,6 +120,7 @@ function ListViewBlock( {
119120
positionLabel,
120121
isSynced,
121122
isLocked,
123+
suggestion,
122124
} = useSelect(
123125
( select ) => {
124126
const {
@@ -146,6 +148,7 @@ function ListViewBlock( {
146148
positionLabel: getPositionTypeLabel( attributes ),
147149
isSynced: isSyncedBlock( clientId ),
148150
isLocked: isLockedBlock( clientId ),
151+
suggestion: attributes?.metadata?.suggestion,
149152
};
150153
},
151154
[ clientId ]
@@ -556,6 +559,13 @@ function ListViewBlock( {
556559
viewportSettings
557560
);
558561

562+
// Pending structural suggestions (insert / remove / move) are shown on the
563+
// canvas but were invisible here, so a reviewer navigating by List View —
564+
// including anyone reading it with assistive technology — had no way to
565+
// tell that a block is proposed for removal. Both halves matter: the class
566+
// drives the row treatment, the label joins the row's description.
567+
const blockSuggestionTreatment = getBlockSuggestionTreatment( suggestion );
568+
559569
const hasSiblings = siblingBlockCount > 0;
560570
const canShowBlockActions = showBlockActions && ! isDisabled;
561571
const hasRenderedMovers = showBlockMovers && hasSiblings && ! isDisabled;
@@ -576,7 +586,7 @@ function ListViewBlock( {
576586
colSpan = 3;
577587
}
578588

579-
const classes = clsx( {
589+
const classes = clsx( blockSuggestionTreatment?.className, {
580590
'is-selected': isSelected,
581591
'is-first-selected': isFirstSelectedBlock,
582592
'is-last-selected': isLastSelectedBlock,
@@ -665,6 +675,7 @@ function ListViewBlock( {
665675
blockPositionDescription,
666676
blockPropertiesDescription,
667677
blockVisibilityDescription,
678+
blockSuggestionTreatment?.label,
668679
]
669680
.filter( Boolean )
670681
.join( ' ' ) }

packages/block-editor/src/components/list-view/style.scss

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,55 @@
466466
}
467467
}
468468

469+
// Pending structural suggestions. On the canvas these read as an outline,
470+
// a strikethrough or a "Suggested move" tab; List View showed nothing at
471+
// all, which is the surface where structural change is easiest to take in.
472+
// Mirror the canvas semantics — strikethrough for a proposed removal,
473+
// underline for a proposed insertion, a dashed underline for a proposed
474+
// move. Colour never carries this alone: the row's accessible description
475+
// says the same thing in words (see `getBlockSuggestionTreatment`).
476+
//
477+
// The values repeat `block-list/content-suggestion.scss` rather than
478+
// sharing with it, because that stylesheet is compiled into the canvas
479+
// iframe and List View lives in the editor chrome.
480+
&.is-suggestion-pending-insert,
481+
&.is-suggestion-pending-remove,
482+
&.is-suggestion-pending-move {
483+
.block-editor-list-view-block-select-button__title {
484+
text-decoration-thickness: 1px;
485+
text-underline-offset: 2px;
486+
}
487+
}
488+
489+
&.is-suggestion-pending-remove .block-editor-list-view-block-select-button__title {
490+
text-decoration-line: line-through;
491+
}
492+
493+
&.is-suggestion-pending-insert .block-editor-list-view-block-select-button__title,
494+
&.is-suggestion-pending-move .block-editor-list-view-block-select-button__title {
495+
text-decoration-line: underline;
496+
}
497+
498+
&.is-suggestion-pending-move .block-editor-list-view-block-select-button__title {
499+
text-decoration-style: dashed;
500+
}
501+
502+
// Tint only when the row isn't selected — a selected row paints its cells
503+
// with the admin theme colour and its text white.
504+
&:not(.is-selected) {
505+
&.is-suggestion-pending-remove .block-editor-list-view-block-select-button__title {
506+
color: $alert-red;
507+
}
508+
509+
&.is-suggestion-pending-insert .block-editor-list-view-block-select-button__title {
510+
color: #007017;
511+
}
512+
513+
&.is-suggestion-pending-move .block-editor-list-view-block-select-button__title {
514+
color: #188038;
515+
}
516+
}
517+
469518
&.is-disabled {
470519
opacity: 0.2;
471520
@media not ( prefers-reduced-motion ) {

packages/block-editor/src/components/list-view/test/utils.js

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,43 @@
1-
import { getCommonDepthClientIds, getDragDisplacementValues } from '../utils';
1+
import {
2+
getBlockSuggestionTreatment,
3+
getCommonDepthClientIds,
4+
getDragDisplacementValues,
5+
} from '../utils';
6+
7+
describe( 'getBlockSuggestionTreatment', () => {
8+
it( 'returns nothing for a block with no structural suggestion', () => {
9+
expect( getBlockSuggestionTreatment( undefined ) ).toBe( undefined );
10+
expect( getBlockSuggestionTreatment( null ) ).toBe( undefined );
11+
expect( getBlockSuggestionTreatment( {} ) ).toBe( undefined );
12+
} );
13+
14+
it( 'ignores a marker type it does not recognize rather than inventing a class', () => {
15+
expect( getBlockSuggestionTreatment( { type: 'pending-shrug' } ) ).toBe(
16+
undefined
17+
);
18+
} );
19+
20+
it( 'describes each structural marker with a class and a spoken label', () => {
21+
expect(
22+
getBlockSuggestionTreatment( { type: 'pending-insert' } )
23+
).toEqual( {
24+
className: 'is-suggestion-pending-insert',
25+
label: 'Suggested insertion.',
26+
} );
27+
expect(
28+
getBlockSuggestionTreatment( { type: 'pending-remove' } )
29+
).toEqual( {
30+
className: 'is-suggestion-pending-remove',
31+
label: 'Suggested removal.',
32+
} );
33+
expect(
34+
getBlockSuggestionTreatment( { type: 'pending-move' } )
35+
).toEqual( {
36+
className: 'is-suggestion-pending-move',
37+
label: 'Suggested move destination.',
38+
} );
39+
} );
40+
} );
241

342
describe( 'getCommonDepthClientIds', () => {
443
it( 'should return start and end when no depth is provided', () => {

packages/block-editor/src/components/list-view/utils.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,50 @@ export const getBlockPropertiesDescription = ( positionLabel, isLocked ) =>
2828
.filter( Boolean )
2929
.join( ' ' );
3030

31+
/**
32+
* Describe a block's pending structural suggestion for List View.
33+
*
34+
* A structural suggestion lives on the block as `metadata.suggestion` and, on
35+
* the canvas, is conveyed entirely by colour and text-decoration: an outline
36+
* for an insertion, a strikethrough for a removal, a tab for a move. None of
37+
* that reaches List View, which is both the primary way to perceive
38+
* *structural* change and a key non-visual navigation surface — so a row whose
39+
* block is slated for removal has to carry the same information, in a form
40+
* that survives having no sight of the canvas.
41+
*
42+
* The class names match the canvas treatment on purpose (see
43+
* `block-list/content-suggestion.scss`), so the two surfaces stay in step.
44+
* Unrecognized marker types return nothing rather than an invented class.
45+
*
46+
* @param {?Object} suggestion Value of the block's `metadata.suggestion`.
47+
* @return {?{className: string, label: string}} Row class and the sentence
48+
* appended to the row's accessible description, or undefined when the block
49+
* carries no structural suggestion.
50+
*/
51+
export const getBlockSuggestionTreatment = ( suggestion ) => {
52+
switch ( suggestion?.type ) {
53+
case 'pending-insert':
54+
return {
55+
className: 'is-suggestion-pending-insert',
56+
label: __( 'Suggested insertion.' ),
57+
};
58+
case 'pending-remove':
59+
return {
60+
className: 'is-suggestion-pending-remove',
61+
label: __( 'Suggested removal.' ),
62+
};
63+
case 'pending-move':
64+
return {
65+
className: 'is-suggestion-pending-move',
66+
// Matches the canvas wording: the block sits at the position it
67+
// is proposed to move to, not the one it came from.
68+
label: __( 'Suggested move destination.' ),
69+
};
70+
default:
71+
return undefined;
72+
}
73+
};
74+
3175
/**
3276
* Returns true if the client ID occurs within the block selection or multi-selection,
3377
* or false otherwise.

packages/block-editor/src/components/writing-flow/utils.js

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { __ } from '@wordpress/i18n';
2+
import { applyFilters } from '@wordpress/hooks';
23
import { __unstableStripHTML as stripHTML } from '@wordpress/dom';
34
import {
45
serialize,
@@ -40,16 +41,28 @@ export function setClipboardBlocks( event, blocks, registry ) {
4041
const wrapperBlockName = getBlockName( wrapperBlockClientId );
4142

4243
if ( wrapperBlockName ) {
43-
_blocks = createBlock(
44-
wrapperBlockName,
45-
getBlockAttributes( wrapperBlockClientId ),
46-
_blocks
47-
);
44+
_blocks = [
45+
createBlock(
46+
wrapperBlockName,
47+
getBlockAttributes( wrapperBlockClientId ),
48+
_blocks
49+
),
50+
];
4851
}
4952
}
5053
}
5154

52-
const serialized = serialize( _blocks );
55+
/*
56+
* Last chance for a feature to keep document-scoped data off the
57+
* clipboard. Anything that identifies a block by an id that is only
58+
* meaningful inside this post - a note id, a suggestion marker - is
59+
* meaningless (and actively misleading) once the blocks are pasted
60+
* somewhere else, and the clipboard is the one path out of the editor
61+
* that cannot be intercepted at the far end.
62+
*/
63+
const serialized = serialize(
64+
applyFilters( 'blockEditor.copiedBlocks', _blocks )
65+
);
5366

5467
event.clipboardData.setData( 'text/plain', toPlainText( serialized ) );
5568
event.clipboardData.setData( 'text/html', serialized );

packages/core-data/src/utils/crdt.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -496,18 +496,25 @@ export function getPostChangesFromCRDTDoc(
496496
}
497497

498498
// When blocks changed but content didn't (the sender internally used a lazy
499-
// serializer function), inject a closure that captures the synced blocks
500-
// and serializes them on demand. Mirrors what useEntityBlockEditor does
501-
// locally. A fresh function on every persistent edit marks the entity
502-
// dirty (so the save button reactivates for peers), while serialization
503-
// stays lazy (only runs when getEditedPostContent reads it). The closure
504-
// captures `capturedBlocks` so the right content is returned even if the
505-
// caller later clears `record.blocks` (e.g. the Code Editor re-parsing
506-
// from content).
499+
// serializer function), inject a closure that serializes the record's
500+
// blocks on demand. Mirrors what useEntityBlockEditor does locally. A fresh
501+
// function on every persistent edit marks the entity dirty (so the save
502+
// button reactivates for peers), while serialization stays lazy (only runs
503+
// when getEditedPostContent reads it).
504+
//
505+
// It reads the record it is handed rather than the synced blocks alone,
506+
// because a later edit that writes `blocks` without writing `content` (any
507+
// non-persistent block change - withdrawing a pending suggestion, say)
508+
// would otherwise stay invisible to getEditedPostContent and to saving,
509+
// which would put the stale blocks back on the server. `capturedBlocks` is
510+
// the fallback for a caller that clears `record.blocks` (e.g. the Code
511+
// Editor re-parsing from content).
507512
if ( changes.blocks && ! changes.content ) {
508513
const capturedBlocks = changes.blocks;
509-
changes.content = () =>
510-
__unstableSerializeAndClean( capturedBlocks as WPBlock[] );
514+
changes.content = ( record?: { blocks?: Block[] } ) =>
515+
__unstableSerializeAndClean(
516+
( record?.blocks ?? capturedBlocks ) as WPBlock[]
517+
);
511518
}
512519

513520
// Meta changes must be merged with the edited record since not all meta

packages/core-data/src/utils/test/crdt.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1121,7 +1121,7 @@ describe( 'crdt', () => {
11211121
expect( typeof changes.content ).toBe( 'function' );
11221122
} );
11231123

1124-
it( 'injected content function captures the synced blocks and ignores its caller-supplied argument', () => {
1124+
it( 'injected content function serializes the record it is handed, falling back to the synced blocks', () => {
11251125
addBlockToDoc( map, 'block-1', 'Hello world' );
11261126

11271127
const editedRecord = {
@@ -1137,21 +1137,27 @@ describe( 'crdt', () => {
11371137
defaultSyncedProperties
11381138
);
11391139

1140-
// The injected function takes no parameters and serializes the
1141-
// captured (synced) blocks. This is what makes getEditedPostContent
1142-
// keep working after the Code Editor clears `record.blocks` to force
1143-
// a re-parse: the closure already has the right blocks on hand.
1144-
//
11451140
// The mocked __unstableSerializeAndClean returns "serialized:<n>"
1146-
// where n is the length of the blocks it was called with. The
1147-
// captured blocks have one entry, so both calls below should yield
1148-
// "serialized:1" (proving the closure ignores its argument and
1149-
// uses the captured blocks instead).
1141+
// where n is the length of the blocks it was called with, and the
1142+
// captured (synced) blocks have one entry.
11501143
const contentFn = changes.content as ( args?: {
1151-
blocks: Block[];
1144+
blocks?: Block[];
11521145
} ) => string;
1146+
1147+
// A record with blocks wins: a later edit that writes `blocks`
1148+
// without writing `content` (any non-persistent block change) has
1149+
// to be what getEditedPostContent and saving see.
1150+
expect( contentFn( { blocks: [ {}, {}, {} ] as Block[] } ) ).toBe(
1151+
'serialized:3'
1152+
);
1153+
// Including an emptied one — no blocks left means no content.
1154+
expect( contentFn( { blocks: [] } ) ).toBe( 'serialized:0' );
1155+
1156+
// No blocks on the record: fall back to the captured ones. This is
1157+
// what makes getEditedPostContent keep working after the Code
1158+
// Editor clears `record.blocks` to force a re-parse.
11531159
expect( contentFn() ).toBe( 'serialized:1' );
1154-
expect( contentFn( { blocks: [] } ) ).toBe( 'serialized:1' );
1160+
expect( contentFn( {} ) ).toBe( 'serialized:1' );
11551161
} );
11561162

11571163
it( 'does not inject a content function when content also changed in the doc', () => {

packages/editor/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99

1010
### Bug Fixes
1111

12+
- Suggest mode: keep a pasted run's bold, italics and links when it becomes a suggestion. A rich-text paste was diffed as plain text, so the marker held the words and none of their formatting and accepting the suggestion destroyed a pasted link. The pasted run is now carried into the marker as rich text, whether it opens a new addition or grows the author's own pending one, with the marker stamped as the outermost format so the proposal stays a single `<mark>` ([#81672](https://github.com/WordPress/gutenberg/pull/81672)).
1213
- Suggest mode: keep the code editor closed while the Suggest intent is active and while the post still carries unresolved inline suggestion markers, and identify a marker by its element rather than by a substring of the document ([#81662](https://github.com/WordPress/gutenberg/pull/81662)).
1314
- Suggest mode: Announce inline suggestion markers to screen readers. A marker's state was carried entirely by color and text decoration, so a run proposed for deletion was read aloud as ordinary prose. Each marker is now bracketed by an announcement naming the kind of change and the person who proposed it, and add and delete markers carry `role="insertion"` and `role="deletion"`; a formatting suggestion no longer claims to be a deletion. Suggestions over overlapping runs nest, and each is announced and attributed to the person who made it ([#81663](https://github.com/WordPress/gutenberg/pull/81663), [#81957](https://github.com/WordPress/gutenberg/pull/81957)).
1415
- Suggest mode: keep a review decision and its note together through undo. Accepting or rejecting a suggestion changes block content and resolves the note, but only the content half is in the undo stack, so undo put the marker back on a note that stayed resolved - a marked-up run with no Accept/Reject on it and no way to clear it. Undoing a decision made in this session now reopens its note along with the marker ([#81669](https://github.com/WordPress/gutenberg/pull/81669)).
16+
- Suggest mode: refuse post status changes while suggesting. `editPost` drops the `status` field in the `suggest` intent, the status control and the summary panel show the status without offering to change it, and the publish button is disabled there rather than dropping the status edit and saving the post anyway. A status edit that travels with a companion field - the `password` that visibility changes carry, the `date` that scheduling carries - is refused whole rather than half-applied, a status repeated at the value it already holds is not announced as a refusal, and a status staged before the intent changed is discarded on the way in. The refusal is announced and shown in a snackbar ([#81664](https://github.com/WordPress/gutenberg/pull/81664)).
1517
- Register the editor and block editor keyboard shortcuts from the editor provider, so shortcuts work for consumers that mount the editor without rendering `EditorKeyboardShortcutsRegister` themselves ([#81580](https://github.com/WordPress/gutenberg/pull/81580)).
1618
- Header: Allow the Back button column to grow when "Show button text labels" is enabled so the label is not obscured by the following controls ([#81701](https://github.com/WordPress/gutenberg/pull/81701)).
1719
- Notes: Stop forcing capitalization of the user name in a note byline, so the name is shown as the user set it ([#81788](https://github.com/WordPress/gutenberg/pull/81788)).

packages/editor/src/components/collab-sidebar/floating-container.js

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,42 @@ export function FloatingContainer( {
99
...props
1010
} ) {
1111
const isFloating = !! floating;
12+
/*
13+
* The board resolves a thread's `y` from its anchor's measured rect, so
14+
* there is a beat after mount — and after any change that invalidates the
15+
* anchors — where a thread has no position yet. An absolutely positioned
16+
* card with no `top` does not stay put: it falls back to its static
17+
* position, which is the panel's origin, so every unpositioned card piles
18+
* up there on top of whichever card legitimately sits at the top of the
19+
* board and, being later in tree order, wins the hit test and swallows
20+
* clicks meant for it — including a suggestion's Accept button.
21+
*
22+
* Take it out of the hit test until the board has placed it, and fade it
23+
* so the pile never paints. Deliberately not `visibility` or `display`:
24+
* the board's ResizeObserver still has to measure the card's height to
25+
* work out where it goes, and the card still has to be focusable — the
26+
* pending new-note form is focused the moment it mounts.
27+
*/
28+
const isPlaced = ! isFloating || floating.y !== undefined;
1229
return (
1330
<Stack
1431
direction="column"
1532
className={ clsx( className, { 'is-floating': isFloating } ) }
1633
ref={ isFloating ? floating.ref : undefined }
17-
style={ isFloating ? { top: floating.y, ...style } : style }
34+
style={
35+
isFloating
36+
? {
37+
top: floating.y,
38+
...( isPlaced
39+
? undefined
40+
: {
41+
opacity: 0,
42+
pointerEvents: 'none',
43+
} ),
44+
...style,
45+
}
46+
: style
47+
}
1848
{ ...props }
1949
>
2050
{ children }

0 commit comments

Comments
 (0)