Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- `PostURL`: Leaving the slug field without typing no longer saves the displayed fallback as the slug, so a new post no longer publishes with the `auto-draft` slug ([#83518](https://github.com/WordPress/gutenberg/pull/83518)).
- Notes: Keep the clicked thread selected on a block with several notes, instead of switching to the block's primary note ([#83528](https://github.com/WordPress/gutenberg/pull/83528)).
- Notes: Keep floating threads aligned with their blocks when an editor notice or the device preview moves the canvas ([#83485](https://github.com/WordPress/gutenberg/pull/83485)).
- Notes: Add an Undo action to the "Note deleted." snackbar, and stop the editor's undo from bringing back the highlight of a deleted note ([#83988](https://github.com/WordPress/gutenberg/pull/83988)).

### Internal

Expand Down
112 changes: 102 additions & 10 deletions packages/editor/src/components/collab-sidebar/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,11 @@ export function useNoteActions() {
getSelectionStart,
getSelectionEnd,
} = useSelect( blockEditorStore );
const { updateBlockAttributes } = useDispatch( blockEditorStore );
const {
updateBlockAttributes,
__unstableMarkNextChangeAsNotPersistent,
__unstableMarkLastChangeAsPersistent,
} = useDispatch( blockEditorStore );

const onError = ( error ) => {
const errorMessage =
Expand Down Expand Up @@ -357,6 +361,79 @@ export function useNoteActions() {
}
};

/*
* Update a note's anchor without an undo step, since undo can't bring the
* note back with it. The last call flags the post as changed, so "Save
* draft" turns on.
*/
const updateNoteAnchor = ( clientId, attributes ) => {
__unstableMarkNextChangeAsNotPersistent( { history: 'ignore' } );
updateBlockAttributes( clientId, attributes );
__unstableMarkLastChangeAsPersistent();
};

const restoreNote = async ( noteId, anchor ) => {
try {
// Untrash first, so a failure doesn't leave an anchor without a note.
await saveEntityRecord(
'root',
'comment',
{ id: noteId, status: 'untrash' },
{ throwOnError: true }
);

// No anchor (a reply or an orphan), or its block is gone: the
// note comes back on its own.
const attributes = anchor
? getBlockAttributes( anchor.clientId )
: null;
if ( attributes ) {
const newAttributes = {};
// The editor's undo may have brought the anchor back already.
if (
! getNoteIdsFromMetadata( attributes.metadata ).includes(
noteId
)
) {
newAttributes.metadata = addNoteIdToMetadata(
attributes.metadata,
noteId
);
}
const { inline } = anchor;
const value = inline && attributes[ inline.attributeKey ];
// Re-wrap only text that hasn't changed since the delete;
// otherwise the note comes back as a block-level note.
if (
inline &&
! findNoteInBlock( attributes, noteId ) &&
value?.text?.slice( inline.start, inline.end ) ===
inline.text
) {
const wrapped = wrapInlineNote(
value,
noteId,
inline.start,
inline.end
);
if ( wrapped ) {
newAttributes[ inline.attributeKey ] = wrapped;
}
}
if ( Object.keys( newAttributes ).length > 0 ) {
updateNoteAnchor( anchor.clientId, newAttributes );
}
}

createNotice( 'snackbar', __( 'Note restored.' ), {
type: 'snackbar',
isDismissible: true,
} );
} catch ( error ) {
onError( error );
}
};

const onDelete = async ( note ) => {
try {
// Capture the target block *before* the async delete: selection may
Expand All @@ -366,23 +443,28 @@ export function useNoteActions() {
? note.blockClientId || getSelectedBlockClientId()
: null;

// Without `force`, this moves the note to the trash, so the
// snackbar's Undo can bring it back.
await deleteEntityRecord( 'root', 'comment', note.id, undefined, {
throwOnError: true,
} );

if ( clientId ) {
const attributes = getBlockAttributes( clientId );
// What Undo needs to re-attach the note to its block.
let anchor = null;
const attributes = clientId ? getBlockAttributes( clientId ) : null;
if (
getNoteIdsFromMetadata( attributes?.metadata ).includes(
note.id
)
) {
anchor = { clientId };
const newAttributes = {
metadata: cleanEmptyObject(
removeNoteIdFromMetadata(
attributes?.metadata,
note.id
)
removeNoteIdFromMetadata( attributes.metadata, note.id )
),
};
// Strip the inline marker too (if any) so the deleted note's
// highlight doesn't linger in the content. Folded into the same
// attribute update so it's a single undo step.
// highlight doesn't linger in the content.
const found = findNoteInBlock( attributes, note.id );
if ( found ) {
const next = removeNoteFormat(
Expand All @@ -391,14 +473,24 @@ export function useNoteActions() {
);
if ( next ) {
newAttributes[ found.attributeKey ] = next;
anchor.inline = {
...found,
text: next.text.slice( found.start, found.end ),
};
}
}
updateBlockAttributes( clientId, newAttributes );
updateNoteAnchor( clientId, newAttributes );
}

createNotice( 'snackbar', __( 'Note deleted.' ), {
type: 'snackbar',
isDismissible: true,
actions: [
{
label: __( 'Undo' ),
onClick: () => restoreNote( note.id, anchor ),
},
],
} );

return true;
Expand Down
43 changes: 38 additions & 5 deletions test/e2e/specs/editor/various/block-notes-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,29 @@ class BlockNoteUtils {
.getByRole( 'button', { name: 'Add note', exact: true } )
.click();
// Wait for the new thread to appear before returning.
await expect(
this.#getNotesSurface().getByRole( 'treeitem', {
name: `Note: ${ content }`,
} )
).toBeVisible();
await expect( this.getThread( content ) ).toBeVisible();
}

/**
* Locates a note thread in the notes sidebar or floating notes.
*
* @param {string} content Text of the thread's root note.
*/
getThread( content ) {
return this.#getNotesSurface().getByRole( 'treeitem', {
name: `Note: ${ content }`,
} );
}

/**
* Locates a snackbar notice.
*
* @param {string} text Text of the notice.
*/
getNotice( text ) {
return this.#page
.getByRole( 'button', { name: 'Dismiss this notice' } )
.filter( { hasText: text } );
}

/**
Expand Down Expand Up @@ -172,6 +190,21 @@ class BlockNoteUtils {
.click();
await this.#page.getByRole( 'menuitem', { name: actionName } ).click();
}

/**
* Deletes a note through its actions menu and waits for the delete to
* finish.
*
* @param {number} [index] Index of the note's "Actions" button.
*/
async deleteNote( index = 0 ) {
await this.clickBlockNoteActionMenuItem( 'Delete', index );
await this.#page
.getByRole( 'dialog' )
.getByRole( 'button', { name: 'Delete' } )
.click();
await expect( this.getNotice( 'Note deleted.' ) ).toBeVisible();
}
}

module.exports = { BlockNoteUtils };
Loading
Loading