Skip to content

Commit c8ff08f

Browse files
Mamadukaclaude
andcommitted
Notes: Preserve unsent note and reply drafts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 6906205 commit c8ff08f

8 files changed

Lines changed: 214 additions & 15 deletions

File tree

‎packages/editor/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
- Notes: Stop the editor's undo from detaching a newly added note from its block, or bringing back the highlight of a resolved note ([#84079](https://github.com/WordPress/gutenberg/pull/84079)).
2323
- Notes: Delete notes permanently when they can't be moved to the trash, instead of failing with an error ([#84102](https://github.com/WordPress/gutenberg/pull/84102)).
2424
- Notes: Disable the note form field while a note or reply is being sent, instead of accepting text the save would not include ([#84083](https://github.com/WordPress/gutenberg/pull/84083)).
25+
- Notes: Keep an unsent note or reply when selecting another block or thread, instead of discarding it.
2526

2627
### Internal
2728

‎packages/editor/src/components/collab-sidebar/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ collab-sidebar/
3636
## Component hierarchy
3737

3838
```
39-
NotesSidebarContainer (index.jsx) - gates on post type support
39+
NotesSidebarContainer (index.jsx) - gates on post type support, owns the unsent drafts Map
4040
└── NotesSidebar (index.jsx) - owns sidebarRef + useNoteThreads + sidebar registration
4141
├── AddNoteMenuItem - slot fill in the block toolbar
4242
├── NoteAvatarIndicator - slot fill in the block toolbar (per-thread avatars)
@@ -56,6 +56,8 @@ NotesSidebarContainer (index.jsx) - gates on post type support
5656

5757
`Notes` is reused for both sidebar surfaces. The only visual difference is driven by `isFloating` (whether to layer threads over the canvas or stack them in a panel).
5858

59+
Unsent note and reply text lives in a `Map` owned by `NotesSidebarContainer`, keyed by block client ID or note ID, so it survives the forms unmounting. It is a cache, not state: `useNoteDraft` reads it on mount and writes it on change, so typing doesn't re-render the sidebar. Slot fills don't inherit context, so `NotesSidebar` provides it inside each fill.
60+
5961
## Floating board
6062

6163
Goal: in the floating sidebar, each unresolved note appears beside its associated block, tracks canvas scroll, and shifts up/down to avoid overlapping with neighbors - all without re-rendering threads as the canvas scrolls.

‎packages/editor/src/components/collab-sidebar/add-note.jsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { NoteCard } from './note-card';
1010
import { NoteForm } from './note-form';
1111
import { FloatingContainer } from './floating-container';
1212
import { focusNoteThread } from './utils';
13+
import { useNoteDraft } from './hooks';
1314
import { store as editorStore } from '../../store';
1415
import { unlock } from '../../lock-unlock';
1516

@@ -21,6 +22,7 @@ export function AddNote( { clientId, onSubmit, sidebarRef, floating } ) {
2122
const { selectNote } = unlock( useDispatch( editorStore ) );
2223
const { getSelectedNote } = unlock( useSelect( editorStore ) );
2324
const isSubmittingRef = useRef( false );
25+
const { initialValue, setDraft, hasDraft } = useNoteDraft( clientId );
2426

2527
/*
2628
* Dismiss the form once focus leaves it. `useFocusOutside` keeps the form
@@ -47,13 +49,14 @@ export function AddNote( { clientId, onSubmit, sidebarRef, floating } ) {
4749
* clear it while this still owns the selection, or it would wipe out the
4850
* newly selected note.
4951
*/
50-
if ( getSelectedNote() === 'new' ) {
52+
if ( getSelectedNote() === 'new' && ! hasDraft() ) {
5153
toggleBlockSpotlight( clientId, false );
5254
selectNote( undefined );
5355
}
5456
} );
5557

5658
const unselectNote = () => {
59+
setDraft( '' );
5760
selectNote( undefined );
5861
blockElement?.focus();
5962
toggleBlockSpotlight( clientId, false );
@@ -95,6 +98,8 @@ export function AddNote( { clientId, onSubmit, sidebarRef, floating } ) {
9598
}
9699
} }
97100
onCancel={ unselectNote }
101+
initialValue={ initialValue }
102+
onChange={ setDraft }
98103
labels={ {
99104
input: __( 'New note' ),
100105
placeholder: __( 'Add a note or @ mention' ),

‎packages/editor/src/components/collab-sidebar/hooks.js‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { speak } from '@wordpress/a11y';
22
import { __ } from '@wordpress/i18n';
33
import {
4+
createContext,
5+
useContext,
46
useState,
57
useEffect,
68
useLayoutEffect,
@@ -36,6 +38,11 @@ import {
3638

3739
const { cleanEmptyObject } = unlock( blockEditorPrivateApis );
3840

41+
/**
42+
* Unsent note drafts, keyed by block client ID for new notes and note ID for replies.
43+
*/
44+
export const NoteDraftsContext = createContext();
45+
3946
export function useNoteThreads( postId ) {
4047
const queryArgs = {
4148
post: postId,
@@ -507,6 +514,26 @@ export function useNoteActions() {
507514
return { onCreate, onEdit, onDelete };
508515
}
509516

517+
/**
518+
* Keeps a note form's unsent content, so it survives the form unmounting.
519+
*
520+
* @param {string|number} key The block client ID for a new note, or the note ID for a reply.
521+
* @return {Object} The draft stored when the form mounted, its setter, and a check for a stored draft.
522+
*/
523+
export function useNoteDraft( key ) {
524+
const drafts = useContext( NoteDraftsContext );
525+
const [ initialValue ] = useState( () => drafts.get( key ) ?? '' );
526+
const setDraft = ( content ) => {
527+
if ( content ) {
528+
drafts.set( key, content );
529+
} else {
530+
drafts.delete( key );
531+
}
532+
};
533+
const hasDraft = () => drafts.has( key );
534+
return { initialValue, setDraft, hasDraft };
535+
}
536+
510537
/**
511538
* Keeps the selected note in step with the selected block, and focuses the
512539
* selected note's thread when the selection asks for it.
@@ -517,6 +544,7 @@ export function useNoteActions() {
517544
*/
518545
export function useNoteSelection( { notes, sidebarRef } ) {
519546
const registry = useRegistry();
547+
const drafts = useContext( NoteDraftsContext );
520548
const { selectNote } = unlock( useDispatch( editorStore ) );
521549
const selectedBlockClientId = useSelect(
522550
( select ) => select( blockEditorStore ).getSelectedBlockClientId(),
@@ -532,7 +560,6 @@ export function useNoteSelection( { notes, sidebarRef } ) {
532560
};
533561
}, [] );
534562

535-
// Select the block's primary note, or clear the selection if it has none.
536563
const syncWithBlock = useEvent( ( clientId ) => {
537564
const { getSelectedNote, isNoteFocused } = unlock(
538565
registry.select( editorStore )
@@ -550,7 +577,8 @@ export function useNoteSelection( { notes, sidebarRef } ) {
550577
if ( blockThreads.some( ( thread ) => thread.id === currentNoteId ) ) {
551578
return;
552579
}
553-
selectNote( pickPrimaryNote( blockThreads )?.id );
580+
const draftNoteId = drafts.has( clientId ) ? 'new' : undefined;
581+
selectNote( pickPrimaryNote( blockThreads )?.id ?? draftNoteId );
554582
} );
555583

556584
// Sync only on block transitions, so in-block changes (Escape, Cancel,

‎packages/editor/src/components/collab-sidebar/index.jsx‎

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import clsx from 'clsx';
22
import { __ } from '@wordpress/i18n';
33
import { useDispatch, useSelect } from '@wordpress/data';
4-
import { useRef } from '@wordpress/element';
4+
import { useRef, useState } from '@wordpress/element';
55
import { useViewportMatch } from '@wordpress/compose';
66
import { __experimentalUseSlot as useSlot } from '@wordpress/components';
77
import { useShortcut } from '@wordpress/keyboard-shortcuts';
@@ -17,13 +17,13 @@ import { store as editorStore } from '../../store';
1717
import { AddNoteMenuItem } from './add-note-menu-item';
1818
import { NoteAvatarIndicator } from './note-indicator-toolbar';
1919
import { NoteHighlightStyles } from './note-highlight-styles';
20-
import { useNoteThreads } from './hooks';
20+
import { NoteDraftsContext, useNoteThreads } from './hooks';
2121
import { getNoteIdsFromMetadata, pickPrimaryNote } from './utils';
2222
import PostTypeSupportCheck from '../post-type-support-check';
2323
import { CanvasMargin } from '../visual-editor/canvas-margin';
2424
import { unlock } from '../../lock-unlock';
2525

26-
function NotesSidebar( { postId } ) {
26+
function NotesSidebar( { postId, drafts } ) {
2727
const { getActiveComplementaryArea } = useSelect( interfaceStore );
2828
const { enableComplementaryArea } = useDispatch( interfaceStore );
2929
const { toggleBlockSpotlight, selectBlock } = unlock(
@@ -185,7 +185,9 @@ function NotesSidebar( { postId } ) {
185185
icon={ commentIcon }
186186
closeLabel={ __( 'Close Notes' ) }
187187
>
188-
<Notes notes={ notes } sidebarRef={ sidebarRef } />
188+
<NoteDraftsContext.Provider value={ drafts }>
189+
<Notes notes={ notes } sidebarRef={ sidebarRef } />
190+
</NoteDraftsContext.Provider>
189191
</PluginSidebar>
190192
) }
191193
{ showFloatingNotes && (
@@ -197,11 +199,13 @@ function NotesSidebar( { postId } ) {
197199
'is-minimized': notesDisplayMode === 'minimized',
198200
} ) }
199201
>
200-
<Notes
201-
notes={ unresolvedNotes }
202-
sidebarRef={ sidebarRef }
203-
isFloating
204-
/>
202+
<NoteDraftsContext.Provider value={ drafts }>
203+
<Notes
204+
notes={ unresolvedNotes }
205+
sidebarRef={ sidebarRef }
206+
isFloating
207+
/>
208+
</NoteDraftsContext.Provider>
205209
</div>
206210
</CanvasMargin.Fill>
207211
) }
@@ -210,6 +214,7 @@ function NotesSidebar( { postId } ) {
210214
}
211215

212216
export default function NotesSidebarContainer() {
217+
const [ drafts ] = useState( () => new Map() );
213218
const { postId, editorMode, revisionsMode } = useSelect( ( select ) => {
214219
const { getCurrentPostId, getEditorMode, isRevisionsMode } = unlock(
215220
select( editorStore )
@@ -232,7 +237,7 @@ export default function NotesSidebarContainer() {
232237

233238
return (
234239
<PostTypeSupportCheck supportKeys="editor.notes">
235-
<NotesSidebar postId={ postId } />
240+
<NotesSidebar postId={ postId } drafts={ drafts } />
236241
</PostTypeSupportCheck>
237242
);
238243
}

‎packages/editor/src/components/collab-sidebar/note-thread.jsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { Note } from './note';
1818
import { NoteCard } from './note-card';
1919
import { NoteForm } from './note-form';
2020
import { FloatingContainer } from './floating-container';
21+
import { useNoteDraft } from './hooks';
2122
import {
2223
focusNoteThread,
2324
getNoteExcerpt,
@@ -29,6 +30,7 @@ import { unlock } from '../../lock-unlock';
2930
const { useBlockElement } = unlock( blockEditorPrivateApis );
3031

3132
function NoteReply( { note, onEditNote, onAddReply, onCancel } ) {
33+
const { initialValue, setDraft } = useNoteDraft( note.id );
3234
return (
3335
<NoteCard role="treeitem">
3436
<NoteForm
@@ -46,7 +48,12 @@ function NoteReply( { note, onEditNote, onAddReply, onCancel } ) {
4648
parent: note.id,
4749
} );
4850
} }
49-
onCancel={ onCancel }
51+
onCancel={ ( event ) => {
52+
setDraft( '' );
53+
onCancel( event );
54+
} }
55+
initialValue={ initialValue }
56+
onChange={ setDraft }
5057
labels={ {
5158
submit:
5259
'approved' === note.status
@@ -218,6 +225,7 @@ export function NoteThread( {
218225
if ( isFloating && note.id === 'new' ) {
219226
return (
220227
<AddNote
228+
key={ note.blockClientId }
221229
clientId={ note.blockClientId }
222230
onSubmit={ onAddReply }
223231
sidebarRef={ sidebarRef }

‎packages/editor/src/components/collab-sidebar/notes.jsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ export function Notes( { notes, sidebarRef, isFloating = false } ) {
207207
>
208208
{ isAddingNote && (
209209
<AddNote
210+
key={ selectedBlockClientId }
210211
clientId={ selectedBlockClientId }
211212
onSubmit={ onAddReply }
212213
sidebarRef={ sidebarRef }

0 commit comments

Comments
 (0)