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
4 changes: 4 additions & 0 deletions packages/editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Bug Fixes

- Notes: Highlight the text an unsent note is about, and keep the note anchored to it when the selection changes or the text is edited before the note is sent ([#84125](https://github.com/WordPress/gutenberg/pull/84125)).

## 15.2.0 (2026-10-07)

### Enhancements
Expand Down
8 changes: 4 additions & 4 deletions packages/editor/src/components/collab-sidebar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ collab-sidebar/
├── note-indicator-toolbar.jsx NoteAvatarIndicator - toolbar participants avatars
├── floating-container.jsx FloatingContainer - stack wrapper that applies `top` in floating mode
│
├── hooks.js useNoteThreads, useNoteActions, useNoteSelection, useFloatingBoard, useEnableFloatingSidebar
├── hooks.js useNoteThreads, useNoteActions, useNoteSelection, useNoteFocus, useFloatingBoard, useEnableFloatingSidebar
├── utils.js focusNoteThread, getNoteExcerpt, sanitizeNoteContent, calculateNotePositions, getAvatarBorderColor
├── board-store.js createBoardStore - DOM measurement for the floating layout
├── constants.js sidebar identifier strings
Expand All @@ -37,11 +37,11 @@ collab-sidebar/

```
NotesSidebarContainer (index.jsx) - gates on post type support, owns the unsent drafts Map
└── NotesSidebar (index.jsx) - owns sidebarRef + useNoteThreads + sidebar registration
└── NotesSidebar (index.jsx) - owns sidebarRef + useNoteThreads + useNoteSelection + sidebar registration
├── AddNoteMenuItem - slot fill in the block toolbar
├── NoteAvatarIndicator - slot fill in the block toolbar (per-thread avatars)
├── PluginSidebar (all-notes) - full sidebar
│ └── Notes (notes.jsx) - owns outer Stack + aria-label + useNoteActions + keyboard nav
│ └── Notes (notes.jsx) - owns outer Stack + aria-label + useNoteActions + useNoteFocus + keyboard nav
│ ├── AddNote - new note form for the selected block, rendered when selectedNote === 'new'
│ └── NoteThread[] - per thread
│ └── <FloatingContainer>
Expand All @@ -56,7 +56,7 @@ NotesSidebarContainer (index.jsx) - gates on post type support, owns the

`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).

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.
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. The text a new note is started from is wrapped with a `core/note` marker whose id is `new`, the same sentinel `selectNote` uses for the form, until the note is sent, so the anchor follows edits like a saved note's does; `onCreate` swaps in the saved id and `onDiscard` strips it.

## Floating board

Expand Down
10 changes: 9 additions & 1 deletion packages/editor/src/components/collab-sidebar/add-note.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ import { unlock } from '../../lock-unlock';

const { useBlockElement } = unlock( blockEditorPrivateApis );

export function AddNote( { clientId, onSubmit, sidebarRef, floating } ) {
export function AddNote( {
clientId,
onSubmit,
onDiscard,
sidebarRef,
floating,
} ) {
const blockElement = useBlockElement( clientId );
const { toggleBlockSpotlight } = unlock( useDispatch( blockEditorStore ) );
const { selectNote } = unlock( useDispatch( editorStore ) );
Expand Down Expand Up @@ -50,13 +56,15 @@ export function AddNote( { clientId, onSubmit, sidebarRef, floating } ) {
* newly selected note.
*/
if ( getSelectedNote() === 'new' && ! hasDraft() ) {
onDiscard( clientId );
toggleBlockSpotlight( clientId, false );
selectNote( undefined );
}
} );

const unselectNote = () => {
setDraft( '' );
onDiscard( clientId );
selectNote( undefined );
blockElement?.focus();
toggleBlockSpotlight( clientId, false );
Expand Down
3 changes: 2 additions & 1 deletion packages/editor/src/components/collab-sidebar/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ function NoteFormat( { isActive, activeAttributes } ) {
// Select-only; no cleanup on leave. The block-level sync owns
// clearing/reverting, and deselecting here would drop the block's
// note while the caret is still inside the block.
selectNote( Number( noteId ) );
// The draft marker of an unsent note carries the form's own id.
selectNote( noteId === 'new' ? noteId : Number( noteId ) );
}, [
isActive,
noteId,
Expand Down
147 changes: 95 additions & 52 deletions packages/editor/src/components/collab-sidebar/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -215,17 +215,51 @@ export function useNoteActions() {
__unstableMarkLastChangeAsPersistent();
};

const removeNoteMarker = ( clientId, noteId ) => {
const removed = removeInlineNote(
getBlockAttributes( clientId ),
noteId
);
if ( removed ) {
updateNoteAnchor( clientId, {
[ removed.attributeKey ]: removed.value,
} );
}
};

// The draft marker (id `new`) wraps the text an unsent note is about, so
// the anchor follows edits until the note is sent.
const onDiscard = ( clientId ) => removeNoteMarker( clientId, 'new' );

// Starting again from another selection moves the marker; a collapsed
// selection keeps it.
const onStart = ( clientId ) => {
const selection = readInlineSelection(
getSelectionStart,
getSelectionEnd
);
if ( selection?.clientId !== clientId ) {
return;
}
onDiscard( clientId );
const wrapped = wrapInlineNote(
getBlockAttributes( clientId )?.[ selection.attributeKey ],
'new',
selection.start,
selection.end
);
if ( wrapped ) {
updateNoteAnchor( clientId, {
[ selection.attributeKey ]: wrapped,
} );
}
};

const onCreate = async ( { content, parent } ) => {
try {
// Capture the target block and inline selection *before* the async
// save: selection may shift during the round-trip, attaching the
// note to the wrong block or collapsing its inline anchor.
const inlineSelection = ! parent
? readInlineSelection( getSelectionStart, getSelectionEnd )
: null;
const clientId = ! parent
? inlineSelection?.clientId || getSelectedBlockClientId()
: null;
// Capture the target block *before* the async save: selection may
// shift during the round-trip, attaching the note to the wrong block.
const clientId = ! parent ? getSelectedBlockClientId() : null;

const savedRecord = await saveEntityRecord(
'root',
Expand Down Expand Up @@ -259,18 +293,15 @@ export function useNoteActions() {
metadata: cleanEmptyObject( updatedMetadata ),
};

// Inline path: also wrap the selected text with a core/note
// marker so the anchor survives later edits.
if ( inlineSelection ) {
const wrapped = wrapInlineNote(
attributes?.[ inlineSelection.attributeKey ],
// Inline path: the draft marker becomes the note's marker.
const draft = removeInlineNote( attributes, 'new' );
if ( draft ) {
newAttributes[ draft.attributeKey ] = wrapInlineNote(
draft.value,
savedRecord.id,
inlineSelection.start,
inlineSelection.end
draft.start,
draft.end
);
if ( wrapped ) {
newAttributes[ inlineSelection.attributeKey ] = wrapped;
}
}

updateNoteAnchor( clientId, newAttributes );
Expand Down Expand Up @@ -332,17 +363,8 @@ export function useNoteActions() {

// Resolving a note drops its inline highlight: strip the marker
// so the note falls back to a block-level note in the content.
const removed =
status === 'approved' &&
note.blockClientId &&
removeInlineNote(
getBlockAttributes( note.blockClientId ),
id
);
if ( removed ) {
updateNoteAnchor( note.blockClientId, {
[ removed.attributeKey ]: removed.value,
} );
if ( status === 'approved' && note.blockClientId ) {
removeNoteMarker( note.blockClientId, id );
}

// The note visibly updates in place, so there is no snackbar,
Expand Down Expand Up @@ -511,7 +533,7 @@ export function useNoteActions() {
}
};

return { onCreate, onEdit, onDelete };
return { onStart, onDiscard, onCreate, onEdit, onDelete };
}

/**
Expand All @@ -535,35 +557,35 @@ export function useNoteDraft( key ) {
}

/**
* Keeps the selected note in step with the selected block, and focuses the
* selected note's thread when the selection asks for it.
* Keeps the selected note in step with the selected block.
*
* @param {Object} props
* @param {Array} props.notes Threads shown in the sidebar.
* @param {Object} props.sidebarRef Ref to the sidebar element.
* @param {Object} props
* @param {Array} props.notes Threads shown in the sidebar.
* @param {Map} props.drafts Unsent note drafts, see `NoteDraftsContext`.
* @param {(clientId: string) => void} props.onDiscard Drops a block's draft marker.
*/
export function useNoteSelection( { notes, sidebarRef } ) {
export function useNoteSelection( { notes, drafts, onDiscard } ) {
const registry = useRegistry();
const drafts = useContext( NoteDraftsContext );
const { selectNote } = unlock( useDispatch( editorStore ) );
const selectedBlockClientId = useSelect(
( select ) => select( blockEditorStore ).getSelectedBlockClientId(),
[]
);
const { selectedNote, noteFocused } = useSelect( ( select ) => {
const { getSelectedNote, isNoteFocused } = unlock(
select( editorStore )
);
return {
selectedNote: getSelectedNote(),
noteFocused: isNoteFocused(),
};
}, [] );

const syncWithBlock = useEvent( ( clientId ) => {
const syncWithBlock = useEvent( ( clientId, previousClientId ) => {
const { getSelectedNote, isNoteFocused } = unlock(
registry.select( editorStore )
);
// Leaving the new note form without a draft closes it for good, so
// its marker goes too. The form's own focus-out can't: the block
// change unmounts it first.
if (
getSelectedNote() === 'new' &&
previousClientId &&
! drafts.has( previousClientId )
) {
onDiscard( previousClientId );
}
// A pending focus request is an explicit pick; leave it alone.
if ( isNoteFocused() ) {
return;
Expand All @@ -582,17 +604,38 @@ export function useNoteSelection( { notes, sidebarRef } ) {
} );

// Sync only on block transitions, so in-block changes (Escape, Cancel,
// the new note form) are left alone.
// the new note form) are left alone. A layout effect, so it reads the
// focus flag before `useNoteFocus` clears it.
const prevBlockIdRef = useRef( selectedBlockClientId );
useEffect( () => {
useLayoutEffect( () => {
if ( prevBlockIdRef.current === selectedBlockClientId ) {
return;
}
const previousClientId = prevBlockIdRef.current;
prevBlockIdRef.current = selectedBlockClientId;
syncWithBlock( selectedBlockClientId );
syncWithBlock( selectedBlockClientId, previousClientId );
}, [ selectedBlockClientId, syncWithBlock ] );
}

/**
* Focuses the selected note's thread when the selection asks for it. Lives
* with the threads' DOM: the sidebar ref is only set once they render.
*
* @param {Object} props
* @param {Object} props.sidebarRef Ref to the sidebar element.
*/
export function useNoteFocus( { sidebarRef } ) {
const { selectNote } = unlock( useDispatch( editorStore ) );
const { selectedNote, noteFocused } = useSelect( ( select ) => {
const { getSelectedNote, isNoteFocused } = unlock(
select( editorStore )
);
return {
selectedNote: getSelectedNote(),
noteFocused: isNoteFocused(),
};
}, [] );

// Must run after the sync above, which reads the focus flag this clears.
useEffect( () => {
if ( ! noteFocused || ! selectedNote ) {
return;
Expand Down
Loading
Loading