Skip to content
Open
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 @@ -14,6 +14,7 @@

- 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)).
- Notes: Select the note under the caret: an inline note while the caret is inside its highlight, otherwise the block's unsent draft or block-level note, instead of an inline note on any click in the block ([#84147](https://github.com/WordPress/gutenberg/pull/84147)).
- Notes: Keep a floating note thread at its block's place in the document when the block is hidden at the current viewport, and label the thread as hidden, instead of hiding it until the block is selected ([#81549](https://github.com/WordPress/gutenberg/pull/81549)).

## 15.2.0 (2026-10-07)

Expand Down
50 changes: 45 additions & 5 deletions packages/editor/src/components/collab-sidebar/board-store.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getScrollContainer } from '@wordpress/dom';
import { getNoteAnchorRect } from './utils';
import { getHiddenBlockAnchorRect, getNoteAnchorRect } from './utils';

const EMPTY_SNAPSHOT = {
heights: {},
Expand Down Expand Up @@ -39,6 +39,7 @@ function isSameHeights( a, b ) {
export function createBoardStore() {
const listeners = new Set();
const blockRefs = new Map();
const clientIds = new Map();
const floatingRefs = new Map();
const idByElement = new WeakMap();
const heights = {};
Expand All @@ -47,6 +48,7 @@ export function createBoardStore() {
let frameEl = null;
let observer = null;
let styleObserver = null;
let blockOrder = [];
let snapshot = EMPTY_SNAPSHOT;

function measure() {
Expand All @@ -55,8 +57,19 @@ export function createBoardStore() {
const scrollTop = canvas?.scrollTop ?? 0;
const anchorRects = {};
for ( const [ id, blockEl ] of blockRefs ) {
if ( blockEl ) {
const rect = getNoteAnchorRect( id, blockEl );
// A block hidden at the current viewport renders nothing once it
// is deselected. Anchor its threads to the place it would take.
const rect = blockEl
? getNoteAnchorRect( id, blockEl )
: getHiddenBlockAnchorRect(
clientIds.get( id ),
blockOrder,
( clientId ) =>
rootEl?.ownerDocument.getElementById(
`block-${ clientId }`
) ?? null
);
if ( rect ) {
anchorRects[ id ] = { top: rect.top + scrollTop };
}
}
Expand Down Expand Up @@ -132,14 +145,30 @@ export function createBoardStore() {
}
}

// With every noted block hidden there is no block to climb from, so look
// the root up from the threads' document. The canvas is iframed unless
// something (e.g. meta boxes) forces same-document rendering.
function findCanvasRoot() {
const doc = [ ...floatingRefs.values() ][ 0 ]?.ownerDocument;
if ( ! doc ) {
return null;
}
const canvasDoc =
doc.querySelector( 'iframe[name="editor-canvas"]' )
?.contentDocument ?? doc;
return canvasDoc.querySelector( '.is-root-container' );
}

// Watch the block-list root, so editing, adding or removing any block
// re-anchors the threads after it. Climbing to the root also keeps nested
// scroll containers (e.g. a Group with overflow:auto) from shadowing the
// canvas.
function syncRoot() {
const blockEl = [ ...blockRefs.values() ].find( Boolean );
const nextRootEl =
blockEl?.closest( '.is-root-container' ) ?? blockEl ?? null;
blockEl?.closest( '.is-root-container' ) ??
blockEl ??
findCanvasRoot();
if ( nextRootEl === rootEl ) {
return;
}
Expand Down Expand Up @@ -214,8 +243,18 @@ export function createBoardStore() {

requestMeasure,

registerThread( id, blockEl, floatingEl ) {
// Document order of every block, so a thread whose block isn't
// rendered can be placed between the neighbours that are.
setBlockOrder( clientIdsInOrder ) {
if ( clientIdsInOrder !== blockOrder ) {
blockOrder = clientIdsInOrder;
requestMeasure();
}
},

registerThread( id, blockEl, floatingEl, clientId ) {
blockRefs.set( id, blockEl );
clientIds.set( id, clientId );
if ( floatingRefs.get( id ) !== floatingEl ) {
untrackFloating( id );
floatingRefs.set( id, floatingEl );
Expand All @@ -228,6 +267,7 @@ export function createBoardStore() {

unregisterThread( id ) {
blockRefs.delete( id );
clientIds.delete( id );
untrackFloating( id );
delete heights[ id ];
syncRoot();
Expand Down
5 changes: 5 additions & 0 deletions packages/editor/src/components/collab-sidebar/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,7 @@ export function useFloatingBoard( {
selectedNoteId,
isFloating,
sidebarRef,
orderedBlockIds,
} ) {
const [ store ] = useState( createBoardStore );

Expand All @@ -914,6 +915,10 @@ export function useFloatingBoard( {
store.requestMeasure();
}, [ store, threads ] );

useLayoutEffect( () => {
store.setBlockOrder( orderedBlockIds );
}, [ store, orderedBlockIds ] );

// Derived during render, so a resize reaches the screen in the same paint.
const { positions: notePositions, contentHeight } = useMemo(
() =>
Expand Down
83 changes: 70 additions & 13 deletions packages/editor/src/components/collab-sidebar/note-thread.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,38 @@ export function NoteThread( {
// Minimized threads expand only while focused, not on block selection.
const [ hasFocus, setHasFocus ] = useState( false );

/*
* A block hidden at the current viewport has nothing for its thread to
* point at and nothing to highlight, so say so. Whether the metadata
* covers the viewport in view is left to the block-editor package: the
* block either drops out of the DOM (deselected) or stays in it collapsed
* and invisible (selected, so the block toolbar still works). Requiring
* metadata that hides the block somewhere keeps the frame before a freshly
* mounted block registers from reading as hidden.
*/
const canBlockBeHidden = useSelect(
( select ) => {
if ( ! note.blockClientId ) {
return false;
}
const blockVisibility = select(
blockEditorStore
).getBlockAttributes( note.blockClientId )?.metadata
?.blockVisibility;
return (
blockVisibility === false ||
Object.values( blockVisibility?.viewport ?? {} ).includes(
false
)
);
},
[ note.blockClientId ]
);
const isBlockHidden =
canBlockBeHidden &&
( ! relatedBlockElement ||
relatedBlockElement.classList.contains( 'is-block-hidden' ) );

const registerThread = floating?.registerThread;
const unregisterThread = floating?.unregisterThread;

Expand All @@ -110,10 +142,21 @@ export function NoteThread( {
useEffect( () => {
const floatingEl = floatingRef.current;
if ( floatingEl && registerThread ) {
registerThread( note.id, relatedBlockElement, floatingEl );
registerThread(
note.id,
relatedBlockElement,
floatingEl,
note.blockClientId
);
}
return () => unregisterThread?.( note.id );
}, [ relatedBlockElement, note.id, registerThread, unregisterThread ] );
}, [
relatedBlockElement,
note.id,
note.blockClientId,
registerThread,
unregisterThread,
] );

// Scroll the thread into view when it becomes selected, and re-scroll
// when its floating position or height settles after `useFloatingBoard`
Expand Down Expand Up @@ -214,17 +257,26 @@ export function NoteThread( {
stripHTML( note.content?.rendered ),
10
);
const ariaLabel = !! note.blockClientId
? sprintf(
// translators: %s: note excerpt
__( 'Note: %s' ),
noteExcerpt
)
: sprintf(
// translators: %s: note excerpt
__( 'Original block deleted. Note: %s' ),
noteExcerpt
);
let ariaLabel;
if ( ! note.blockClientId ) {
ariaLabel = sprintf(
// translators: %s: note excerpt
__( 'Original block deleted. Note: %s' ),
noteExcerpt
);
} else if ( isBlockHidden ) {
ariaLabel = sprintf(
// translators: %s: note excerpt
__( 'Note on hidden block: %s' ),
noteExcerpt
);
} else {
ariaLabel = sprintf(
// translators: %s: note excerpt
__( 'Note: %s' ),
noteExcerpt
);
}

if ( isFloating && note.id === 'new' ) {
return (
Expand Down Expand Up @@ -291,6 +343,11 @@ export function NoteThread( {
{ __( 'Original block deleted.' ) }
</p>
) }
{ isBlockHidden && (
<p className="editor-collab-sidebar-panel__hidden-block-notice">
{ __( 'Block is hidden.' ) }
</p>
) }
<Note
note={ note }
isSelected={ isSelected }
Expand Down
1 change: 1 addition & 0 deletions packages/editor/src/components/collab-sidebar/notes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export function Notes( { notes, sidebarRef, isFloating = false } ) {
selectedNoteId: selectedNote,
isFloating,
sidebarRef,
orderedBlockIds,
} );

const navigate = ( event, thread, isSelected ) => {
Expand Down
3 changes: 2 additions & 1 deletion packages/editor/src/components/collab-sidebar/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@
font-weight: var(--wpds-typography-font-weight-emphasis);
}

.editor-collab-sidebar-panel__deleted-block-notice {
.editor-collab-sidebar-panel__deleted-block-notice,
.editor-collab-sidebar-panel__hidden-block-notice {
font-weight: var(--wpds-typography-font-weight-emphasis);
color: $gray-700;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,32 @@ describe( 'createBoardStore', () => {
expect( listener ).toHaveBeenCalled();
expect( store.getSnapshot().heights ).toEqual( { 1: 80 } );
} );

it( "anchors a thread whose block isn't rendered between its neighbours", () => {
const store = createBoardStore();
const root = document.createElement( 'div' );
root.className = 'is-root-container';
document.body.appendChild( root );
const before = document.createElement( 'p' );
before.id = 'block-a';
before.getBoundingClientRect = () => ( { top: 100, height: 100 } );
const after = document.createElement( 'p' );
after.id = 'block-c';
after.getBoundingClientRect = () => ( { top: 400, height: 100 } );
root.append( before, after );

store.subscribe( () => {} );
store.setBlockOrder( [ 'a', 'b', 'c' ] );
store.registerThread( 1, before, document.createElement( 'div' ), 'a' );
// Block 'b' is hidden, so it has no element.
store.registerThread( 2, null, document.createElement( 'div' ), 'b' );
resize( root, 500 );

expect( store.getSnapshot().anchorRects ).toEqual( {
1: { top: 100 },
2: { top: 300 },
} );

root.remove();
} );
} );
Loading
Loading