Skip to content

Commit 577edd3

Browse files
Keep suggestion review decisions out of undo history (#81669)
Accepting or rejecting a suggestion has two halves: the block change, which is local, and the comment's resolved status, which lives on the server and is shared with every session. Only the first can sit on the undo stack, so recording it there let Ctrl+Z pull the two apart - restoring the <mark> while the note stayed resolved, leaving a marked-up run with no Accept/Reject and no way to clear it. Mark every live-block write a decision makes with history: 'ignore', the same treatment the note-id linkage and the notes delete-cleanup already use. The applied content still reaches the entity and saves; it is just invisible to undo, so the halves can no longer separate.
1 parent 3fb26d2 commit 577edd3

6 files changed

Lines changed: 592 additions & 2 deletions

File tree

‎packages/editor/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- 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)).
1313
- 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)).
1414
- 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)).
15+
- 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)).
1516
- Suggest mode: extend a format suggestion on a second toggle instead of opening a second one. Toggling a further format over a run that already carries the suggester's own pending `format` marker recorded a suggestion whose before and after were both empty - unreviewable and unapplyable - and made every marker in the block disappear. The existing suggestion is now revised in place, a toggle that restores the original run retracts it rather than storing a note that proposes nothing, and a note that has replies is revised rather than withdrawn ([#81665](https://github.com/WordPress/gutenberg/pull/81665)).
1617
- 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)).
1718
- 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)).

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,10 +426,56 @@ function withDecisionInFlight( decide ) {
426426
return await decide( args );
427427
} finally {
428428
decisionsInFlight.delete( key );
429+
resolvedThisSession.add( key );
429430
}
430431
};
431432
}
432433

434+
/*
435+
* Suggestions this session has applied or rejected.
436+
*
437+
* A decision has two halves: the block change, which the undo stack holds, and
438+
* the comment's lifecycle status, which lives on the server and no keystroke
439+
* here can walk back. Undo therefore puts a marker back while its note stays
440+
* resolved, leaving a marked-up run with no Accept/Reject on it and no way to
441+
* clear it through the UI (issue #73411, F-18). The note collector watches this
442+
* set and reopens a note whose marker reappears, so the two halves travel
443+
* together again.
444+
*
445+
* Deliberately scoped to decisions made HERE rather than to every resolved note
446+
* that has a live marker: a peer's decision arriving through sync before this
447+
* session's content catches up looks identical from the outside, and reopening
448+
* that would undo their review.
449+
*/
450+
const resolvedThisSession = new Set();
451+
452+
/**
453+
* Comment ids this session applied or rejected and has not yet reopened.
454+
*
455+
* @return {Set<string>} Comment id keys.
456+
*/
457+
export function getSuggestionsResolvedThisSession() {
458+
return resolvedThisSession;
459+
}
460+
461+
/**
462+
* Forget a decision, once its note has been reopened or is past reopening.
463+
*
464+
* @param {number|string} commentId Comment id.
465+
*/
466+
export function forgetResolvedSuggestion( commentId ) {
467+
resolvedThisSession.delete( String( commentId ) );
468+
}
469+
470+
/**
471+
* Record a decision again, so a failed reopen is retried on a later pass.
472+
*
473+
* @param {number|string} commentId Comment id.
474+
*/
475+
export function rememberResolvedSuggestion( commentId ) {
476+
resolvedThisSession.add( String( commentId ) );
477+
}
478+
433479
/**
434480
* Comment-meta backed suggestions provider. The provider shape is stable so
435481
* a future Yjs-backed provider can swap in without touching the UI.

‎packages/editor/src/components/suggestion-mode/suggestion-note-gc.js‎

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,11 @@ import { useSuggestionOverlay } from './overlay-context';
3636
import {
3737
findInlineOp,
3838
findStructuralOp,
39+
forgetResolvedSuggestion,
40+
getSuggestionsResolvedThisSession,
3941
isSuggestionDecisionInFlight,
4042
parseSuggestionPayload,
43+
rememberResolvedSuggestion,
4144
} from './provider';
4245
import { findSuggestionRange } from '../inline-suggestions';
4346
import { getNoteIdsFromMetadata } from '../collab-sidebar/utils';
@@ -156,13 +159,25 @@ export default function SuggestionNoteGC() {
156159
// Pending suggestion root notes only; replies and resolved notes have no
157160
// anchor contract.
158161
const suggestionNotes = [];
162+
/*
163+
* Notes this session applied or rejected. Their marker should be gone; if
164+
* it is back, an undo walked the block half of the decision back while the
165+
* note stayed resolved, and the note has to follow (#73411, F-18).
166+
*/
167+
const resolvedNotes = [];
168+
const resolvedIds = getSuggestionsResolvedThisSession();
159169
for ( const note of notes ?? [] ) {
160-
if ( note.parent !== 0 || note.status !== 'hold' ) {
170+
if ( note.parent !== 0 ) {
161171
continue;
162172
}
163173
const anchor = describeAnchor( note );
164-
if ( anchor ) {
174+
if ( ! anchor ) {
175+
continue;
176+
}
177+
if ( note.status === 'hold' ) {
165178
suggestionNotes.push( { note, anchor } );
179+
} else if ( resolvedIds.has( String( note.id ) ) ) {
180+
resolvedNotes.push( { note, anchor } );
166181
}
167182
}
168183

@@ -174,6 +189,8 @@ export default function SuggestionNoteGC() {
174189

175190
const entriesRef = useRef( entries );
176191
entriesRef.current = entries;
192+
const resolvedNotesRef = useRef( resolvedNotes );
193+
resolvedNotesRef.current = resolvedNotes;
177194
const clearOverlayRef = useRef( clearOverlay );
178195
clearOverlayRef.current = clearOverlay;
179196

@@ -210,6 +227,15 @@ export default function SuggestionNoteGC() {
210227
}`
211228
);
212229
}
230+
for ( const { note, anchor } of resolvedNotes ) {
231+
parts.push(
232+
`r${ note.id }:${
233+
isAnchorPresent( note, anchor, blockEditor, entries )
234+
? 1
235+
: 0
236+
}`
237+
);
238+
}
213239
return parts.join( '|' );
214240
},
215241
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -338,6 +364,42 @@ export default function SuggestionNoteGC() {
338364
trashedRef.current.set( idKey, info );
339365
} );
340366
}
367+
/*
368+
* Undo: a decision this session made has had its marker put back. The
369+
* comment's status is the half undo cannot reach, so reopen it here -
370+
* otherwise the run stays marked with no Accept/Reject on it and no way
371+
* to clear it through the UI (#73411, F-18). The in-flight guard keeps
372+
* this off the decision's own window, where the status can land before
373+
* the tree has been mutated.
374+
*/
375+
for ( const { note, anchor } of resolvedNotesRef.current ) {
376+
if (
377+
isSuggestionDecisionInFlight( note.id ) ||
378+
! isAnchorPresent(
379+
note,
380+
anchor,
381+
blockEditor,
382+
entriesRef.current
383+
)
384+
) {
385+
continue;
386+
}
387+
forgetResolvedSuggestion( note.id );
388+
saveEntityRecord(
389+
'root',
390+
'comment',
391+
{
392+
id: note.id,
393+
status: 'hold',
394+
meta: { _wp_suggestion_status: '' },
395+
},
396+
{ throwOnError: true }
397+
).catch( () => {
398+
// Reopen failed; leave it recorded so a later pass retries.
399+
rememberResolvedSuggestion( note.id );
400+
} );
401+
}
402+
341403
// `presenceSignature` fully determines the work; the other values are
342404
// read through refs or stable.
343405
// eslint-disable-next-line react-hooks/exhaustive-deps

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

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import {
3434
findInlineOp,
3535
clearSuggestionMarkerAttributes,
3636
useSuggestionsProvider,
37+
getSuggestionsResolvedThisSession,
38+
forgetResolvedSuggestion,
3739
} from '../provider';
3840

3941
describe( 'operationsFromOverlay', () => {
@@ -1158,3 +1160,169 @@ describe( 'createSuggestion (notes sidebar switch)', () => {
11581160
).toBe( FLOATING_NOTES_SIDEBAR );
11591161
} );
11601162
} );
1163+
1164+
describe( 'review decisions and undo history', () => {
1165+
const PARAGRAPH = 'core/test-decision-paragraph';
1166+
1167+
beforeAll( () => {
1168+
if (
1169+
! select( richTextStore ).getFormatType( SUGGESTION_FORMAT_NAME )
1170+
) {
1171+
registerFormatType( SUGGESTION_FORMAT_NAME, suggestionFormat );
1172+
}
1173+
registerBlockType( PARAGRAPH, {
1174+
apiVersion: 3,
1175+
attributes: {
1176+
content: { type: 'string', default: '' },
1177+
metadata: { type: 'object' },
1178+
},
1179+
save: () => null,
1180+
category: 'text',
1181+
title: 'Test Decision Paragraph',
1182+
} );
1183+
} );
1184+
1185+
afterAll( () => {
1186+
if ( select( richTextStore ).getFormatType( SUGGESTION_FORMAT_NAME ) ) {
1187+
unregisterFormatType( SUGGESTION_FORMAT_NAME );
1188+
}
1189+
getBlockTypes().forEach( ( block ) =>
1190+
unregisterBlockType( block.name )
1191+
);
1192+
} );
1193+
1194+
function setup() {
1195+
const registry = createRegistry();
1196+
registry.register( noticesStore );
1197+
registry.register( blockEditorStore );
1198+
registry.register(
1199+
createReduxStore( 'core', {
1200+
reducer: ( state = {} ) => state,
1201+
actions: {
1202+
saveEntityRecord: () => async () => ( { id: 9 } ),
1203+
},
1204+
selectors: {
1205+
getEditedEntityRecord: () => null,
1206+
getEntityRecord: () => null,
1207+
getCurrentUser: () => null,
1208+
},
1209+
} )
1210+
);
1211+
registry.register( createStubInterfaceStore() );
1212+
1213+
const block = createBlock( PARAGRAPH );
1214+
registry.dispatch( blockEditorStore ).resetBlocks( [ block ] );
1215+
registry
1216+
.dispatch( blockEditorStore )
1217+
.updateBlockAttributes( block.clientId, {
1218+
content: RichTextData.fromHTMLString(
1219+
'Hello <mark class="wp-suggestion" data-suggestion-id="9" data-suggestion-type="add">world</mark>'
1220+
),
1221+
} );
1222+
// Close the setup writes as a normal undo level so the assertions
1223+
// below observe the decision's own history mode, not a leftover.
1224+
registry
1225+
.dispatch( blockEditorStore )
1226+
.__unstableMarkLastChangeAsPersistent();
1227+
1228+
let providerHandle;
1229+
function CaptureProvider() {
1230+
providerHandle = useSuggestionsProvider();
1231+
return null;
1232+
}
1233+
1234+
render(
1235+
<RegistryProvider value={ registry }>
1236+
<CaptureProvider />
1237+
</RegistryProvider>
1238+
);
1239+
1240+
return { registry, block, getProvider: () => providerHandle };
1241+
}
1242+
1243+
const inlinePayload = {
1244+
schemaVersion: 2,
1245+
blockName: PARAGRAPH,
1246+
baseRevision: null,
1247+
operations: [
1248+
{
1249+
type: 'inline-suggestion',
1250+
attribute: 'content',
1251+
suggestionType: 'add',
1252+
},
1253+
],
1254+
};
1255+
1256+
it( 'commits an applied suggestion as a persistent change', async () => {
1257+
const { registry, block, getProvider } = setup();
1258+
1259+
await act( async () => {
1260+
await getProvider().applySuggestion( {
1261+
commentId: 9,
1262+
clientId: block.clientId,
1263+
payload: inlinePayload,
1264+
} );
1265+
} );
1266+
1267+
// The proposed text is committed...
1268+
expect(
1269+
registry
1270+
.select( blockEditorStore )
1271+
.getBlockAttributes( block.clientId )
1272+
?.content.toHTMLString()
1273+
).toBe( 'Hello world' );
1274+
/*
1275+
* ...as a PERSISTENT change. Hiding the decision from undo also hides
1276+
* it from the entity: a non-persistent block change reaches the parent
1277+
* through `onInput`, which never writes `content`, so the post would
1278+
* never go dirty and the applied text would be gone on reload. The
1279+
* undo half of F-18 is handled by reopening the note instead, in
1280+
* `SuggestionNoteGC`.
1281+
*/
1282+
expect(
1283+
registry
1284+
.select( blockEditorStore )
1285+
.__unstableGetLastBlockChangeHistoryMode()
1286+
).toBe( 'persistent' );
1287+
} );
1288+
1289+
it( 'commits a rejected suggestion as a persistent change', async () => {
1290+
const { registry, block, getProvider } = setup();
1291+
1292+
await act( async () => {
1293+
await getProvider().rejectSuggestion( {
1294+
commentId: 9,
1295+
clientId: block.clientId,
1296+
payload: inlinePayload,
1297+
} );
1298+
} );
1299+
1300+
expect(
1301+
registry
1302+
.select( blockEditorStore )
1303+
.getBlockAttributes( block.clientId )
1304+
?.content.toHTMLString()
1305+
).toBe( 'Hello ' );
1306+
expect(
1307+
registry
1308+
.select( blockEditorStore )
1309+
.__unstableGetLastBlockChangeHistoryMode()
1310+
).toBe( 'persistent' );
1311+
} );
1312+
1313+
it( 'records a decided suggestion so its note can be reopened', async () => {
1314+
const { block, getProvider } = setup();
1315+
1316+
await act( async () => {
1317+
await getProvider().applySuggestion( {
1318+
commentId: 9,
1319+
clientId: block.clientId,
1320+
payload: inlinePayload,
1321+
} );
1322+
} );
1323+
1324+
// The note collector reads this to spot a marker an undo put back.
1325+
expect( getSuggestionsResolvedThisSession().has( '9' ) ).toBe( true );
1326+
forgetResolvedSuggestion( 9 );
1327+
} );
1328+
} );

0 commit comments

Comments
 (0)