diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index 82c03ae4bf50b1..c8fa2950cfea41 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -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) diff --git a/packages/editor/src/components/collab-sidebar/board-store.js b/packages/editor/src/components/collab-sidebar/board-store.js index 61049d857c152f..900ffb96eb18b3 100644 --- a/packages/editor/src/components/collab-sidebar/board-store.js +++ b/packages/editor/src/components/collab-sidebar/board-store.js @@ -1,5 +1,5 @@ import { getScrollContainer } from '@wordpress/dom'; -import { getNoteAnchorRect } from './utils'; +import { getHiddenBlockAnchorRect, getNoteAnchorRect } from './utils'; const EMPTY_SNAPSHOT = { heights: {}, @@ -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 = {}; @@ -47,6 +48,7 @@ export function createBoardStore() { let frameEl = null; let observer = null; let styleObserver = null; + let blockOrder = []; let snapshot = EMPTY_SNAPSHOT; function measure() { @@ -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 }; } } @@ -132,6 +145,20 @@ 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 @@ -139,7 +166,9 @@ export function createBoardStore() { 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; } @@ -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 ); @@ -228,6 +267,7 @@ export function createBoardStore() { unregisterThread( id ) { blockRefs.delete( id ); + clientIds.delete( id ); untrackFloating( id ); delete heights[ id ]; syncRoot(); diff --git a/packages/editor/src/components/collab-sidebar/hooks.js b/packages/editor/src/components/collab-sidebar/hooks.js index 7a9723f8ce5628..4f2674e7be2cf9 100644 --- a/packages/editor/src/components/collab-sidebar/hooks.js +++ b/packages/editor/src/components/collab-sidebar/hooks.js @@ -898,6 +898,7 @@ export function useFloatingBoard( { selectedNoteId, isFloating, sidebarRef, + orderedBlockIds, } ) { const [ store ] = useState( createBoardStore ); @@ -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( () => diff --git a/packages/editor/src/components/collab-sidebar/note-thread.jsx b/packages/editor/src/components/collab-sidebar/note-thread.jsx index 6aeef3577d6435..db7fc48e3223ff 100644 --- a/packages/editor/src/components/collab-sidebar/note-thread.jsx +++ b/packages/editor/src/components/collab-sidebar/note-thread.jsx @@ -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; @@ -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` @@ -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 ( @@ -291,6 +343,11 @@ export function NoteThread( { { __( 'Original block deleted.' ) }

) } + { isBlockHidden && ( +

+ { __( 'Block is hidden.' ) } +

+ ) } { diff --git a/packages/editor/src/components/collab-sidebar/style.scss b/packages/editor/src/components/collab-sidebar/style.scss index 04f984e1d9cbc5..f0ec14d5af32ea 100644 --- a/packages/editor/src/components/collab-sidebar/style.scss +++ b/packages/editor/src/components/collab-sidebar/style.scss @@ -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; } diff --git a/packages/editor/src/components/collab-sidebar/test/board-store.jsdom.test.js b/packages/editor/src/components/collab-sidebar/test/board-store.jsdom.test.js index 1763e4cd632141..a2fc7e4c6934de 100644 --- a/packages/editor/src/components/collab-sidebar/test/board-store.jsdom.test.js +++ b/packages/editor/src/components/collab-sidebar/test/board-store.jsdom.test.js @@ -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(); + } ); } ); diff --git a/packages/editor/src/components/collab-sidebar/test/utils.jsdom.test.js b/packages/editor/src/components/collab-sidebar/test/utils.jsdom.test.js index 7e2e42800ef444..6db97ef788f3a4 100644 --- a/packages/editor/src/components/collab-sidebar/test/utils.jsdom.test.js +++ b/packages/editor/src/components/collab-sidebar/test/utils.jsdom.test.js @@ -18,6 +18,7 @@ import { addNoteIdToMetadata, removeNoteIdFromMetadata, calculateNotePositions, + getHiddenBlockAnchorRect, pickPrimaryNote, getNoteAtCaret, pickNoteForCaret, @@ -717,6 +718,191 @@ describe( 'calculateNotePositions', () => { expect( positions ).toEqual( { 1: 334, new: 234 } ); expect( positions[ 1 ] ).toBeGreaterThan( blockRects[ 1 ].top - 50 ); } ); + + it( 'positions a thread anchored to a hidden block boundary rect', () => { + // Thread 2's block is hidden, so it anchors to the zero-height + // boundary where the block would be, between its two neighbours. + const threads = [ { id: 1 }, { id: 2 }, { id: 3 } ]; + const blockRects = { + 1: makeRect( 100 ), + 2: { top: 300, height: 0 }, + 3: makeRect( 500 ), + }; + const heights = { 1: 50, 2: 50, 3: 50 }; + + const { positions } = calculateNotePositions( { + threads, + selectedNoteId: 1, + blockRects, + heights, + scrollTop: 0, + } ); + + // 1: 100 - 16 = 84 + // 2: 300 - 16 = 284 + // 3: 500 - 16 = 484 + expect( positions ).toEqual( { 1: 84, 2: 284, 3: 484 } ); + } ); + + it( 'keeps a hidden thread ahead of the thread it shares a boundary with', () => { + // A hidden block's boundary rect sits at the top of the next + // rendered block, tying with that block's own thread. Document + // order decides, and the sweep pushes the later thread down. + const threads = [ { id: 1 }, { id: 2 } ]; + const blockRects = { + 1: { top: 300, height: 0 }, + 2: makeRect( 300 ), + }; + const heights = { 1: 50, 2: 50 }; + + const { positions } = calculateNotePositions( { + threads, + selectedNoteId: 1, + blockRects, + heights, + scrollTop: 0, + } ); + + // 1 (anchor): 300 - 16 = 284 + // 2 (downward): (284 + 50) - 300 + 20 = 54 → 354 + expect( positions ).toEqual( { 1: 284, 2: 354 } ); + expect( positions[ 1 ] ).toBeLessThan( positions[ 2 ] ); + } ); + + it( 'positions every thread when the hidden thread is the anchor', () => { + const threads = [ { id: 1 }, { id: 2 }, { id: 3 } ]; + const blockRects = { + 1: makeRect( 100 ), + 2: { top: 300, height: 0 }, + 3: makeRect( 500 ), + }; + const heights = { 1: 50, 2: 50, 3: 50 }; + + const { positions } = calculateNotePositions( { + threads, + selectedNoteId: 2, + blockRects, + heights, + scrollTop: 0, + } ); + + expect( positions ).toEqual( { 1: 84, 2: 284, 3: 484 } ); + } ); +} ); + +describe( 'getHiddenBlockAnchorRect', () => { + // Only the rendered blocks appear in the lookup; a hidden block (and + // anything nested inside it) resolves to null, like the real DOM. + function makeLookup( rectsByClientId ) { + return ( clientId ) => { + const rect = rectsByClientId[ clientId ]; + return rect ? { getBoundingClientRect: () => rect } : null; + }; + } + + const rect = ( top, height = 100 ) => ( { + top, + left: 20, + width: 600, + height, + } ); + + it( 'centres the rect in the gap between the rendered neighbours', () => { + // 'a' ends at 200, 'c' starts at 400, so the hidden block's slot is + // at 300 - where the block itself sits when it is selected. + const result = getHiddenBlockAnchorRect( + 'b', + [ 'a', 'b', 'c' ], + makeLookup( { a: rect( 100 ), c: rect( 400 ) } ) + ); + + expect( result ).toEqual( { + top: 300, + left: 20, + width: 600, + height: 0, + } ); + } ); + + it( 'skips consecutive unrendered blocks, such as a hidden subtree', () => { + const result = getHiddenBlockAnchorRect( + 'parent', + [ 'a', 'parent', 'child', 'grandchild', 'c' ], + makeLookup( { a: rect( 100 ), c: rect( 400 ) } ) + ); + + expect( result.top ).toBe( 300 ); + } ); + + it( "anchors to the next block's top when nothing precedes", () => { + const result = getHiddenBlockAnchorRect( + 'a', + [ 'a', 'b' ], + makeLookup( { b: rect( 400 ) } ) + ); + + expect( result ).toEqual( { + top: 400, + left: 20, + width: 600, + height: 0, + } ); + } ); + + it( "anchors to the previous block's bottom when nothing follows", () => { + const result = getHiddenBlockAnchorRect( + 'c', + [ 'a', 'b', 'c' ], + makeLookup( { a: rect( 100 ), b: rect( 250, 80 ) } ) + ); + + expect( result ).toEqual( { + top: 330, + left: 20, + width: 600, + height: 0, + } ); + } ); + + it( 'skips blocks inside collapsed content', () => { + // 'b' sits in a closed Details, which still reports its expanded box. + const lookup = makeLookup( { + a: rect( 100 ), + b: rect( 200 ), + d: rect( 400 ), + } ); + const result = getHiddenBlockAnchorRect( + 'c', + [ 'a', 'b', 'c', 'd' ], + ( clientId ) => { + const element = lookup( clientId ); + return element && clientId === 'b' + ? { ...element, checkVisibility: () => false } + : element; + } + ); + + expect( result.top ).toBe( 300 ); + } ); + + it( 'returns null when no block in the document is rendered', () => { + expect( + getHiddenBlockAnchorRect( 'b', [ 'a', 'b', 'c' ], makeLookup( {} ) ) + ).toBeNull(); + } ); + + it( 'returns null for a client ID that is not in the document', () => { + expect( + getHiddenBlockAnchorRect( + 'gone', + [ 'a', 'b' ], + makeLookup( { a: rect( 100 ), b: rect( 400 ) } ) + ) + ).toBeNull(); + expect( + getHiddenBlockAnchorRect( 'a', undefined, makeLookup( {} ) ) + ).toBeNull(); + } ); } ); describe( 'findNoteRange', () => { diff --git a/packages/editor/src/components/collab-sidebar/utils.js b/packages/editor/src/components/collab-sidebar/utils.js index 505e5fef8eeaac..fbbd2e7ca91167 100644 --- a/packages/editor/src/components/collab-sidebar/utils.js +++ b/packages/editor/src/components/collab-sidebar/utils.js @@ -692,6 +692,70 @@ export function removeNoteIdFromMetadata( metadata, noteId ) { }; } +/** + * Derives a stand-in anchor rect for a block that has no rendered element, + * which happens when the block is hidden at the current viewport: `block.js` + * renders nothing for it once it is deselected. + * + * The rect marks the gap the block leaves between its rendered neighbours, + * so its threads keep their place in the notes column instead of collapsing + * to the top of the panel. Centring it in the gap matches where the block + * itself sits while it is selected - still hidden, but back in the DOM + * collapsed to zero height between two block gaps - so selecting a thread + * does not shift it. + * + * The element lookup is injected to keep this testable without a DOM. + * + * @param {string} clientId Client ID of the block with no element. + * @param {string[]} orderedClientIds Client IDs in document order. + * @param {(clientId: string) => ?HTMLElement} getBlockElement Resolves a client ID to its element, or null. + * @return {?{top: number, left: number, width: number, height: number}} Zero-height + * boundary rect, or `null` when no block in the document is rendered. + */ +export function getHiddenBlockAnchorRect( + clientId, + orderedClientIds, + getBlockElement +) { + const index = orderedClientIds?.indexOf( clientId ) ?? -1; + if ( index === -1 ) { + return null; + } + + // Blocks nested inside a hidden block have no element either, so both + // walks skip whole hidden subtrees on their way to a rendered block. + // Collapsed content (e.g. a closed Details) still reports the box it + // would have when expanded, so skip it as well. + const findRect = ( from, step ) => { + for ( let i = from; i >= 0 && i < orderedClientIds.length; i += step ) { + const element = getBlockElement( orderedClientIds[ i ] ); + if ( element && element.checkVisibility?.() !== false ) { + return element.getBoundingClientRect(); + } + } + return null; + }; + + const next = findRect( index + 1, 1 ); + const previous = findRect( index - 1, -1 ); + + if ( ! next && ! previous ) { + return null; + } + + let top; + if ( next && previous ) { + top = ( previous.top + previous.height + next.top ) / 2; + } else if ( next ) { + top = next.top; + } else { + top = previous.top + previous.height; + } + + const { left, width } = next ?? previous; + return { top, left, width, height: 0 }; +} + /** * Calculate final top positions for all floating note threads in the * editor's content coordinate space. Adjusts positions to prevent overlapping @@ -721,7 +785,10 @@ export function calculateNotePositions( { // above notes that precede it in the list. Sort by measured top so the // sweep's assumption holds and cards never displace past their markers. // Threads without a rect keep their relative order; they are skipped - // below and never receive a position. + // below and never receive a position. Threads sharing a top - two hidden + // blocks resolving to the same boundary rect, say (see + // `getHiddenBlockAnchorRect`) - keep document order too, because the sort + // is stable, and the sweep below spaces them out. const orderedThreads = threads.toSorted( ( a, b ) => ( blockRects[ a.id ]?.top ?? Number.MAX_VALUE ) - diff --git a/test/e2e/specs/editor/various/block-notes-floating.spec.js b/test/e2e/specs/editor/various/block-notes-floating.spec.js index ac6164831df2c2..918b0c487421e5 100644 --- a/test/e2e/specs/editor/various/block-notes-floating.spec.js +++ b/test/e2e/specs/editor/various/block-notes-floating.spec.js @@ -1369,4 +1369,177 @@ test.describe( 'Block Notes: floating notes', () => { ).toBeVisible(); } ); } ); + + // A block hidden at the current viewport renders nothing once it is + // deselected, leaving its thread without an anchor element (#73565). + test.describe( 'Hidden blocks', () => { + // 'Hide' and 'Show' open the same dialog. + async function setDesktopVisibility( { editor, page, item, hide } ) { + await editor.clickBlockOptionsMenuItem( item ); + const dialog = page.getByRole( 'dialog', { name: 'Hide block' } ); + const checkbox = dialog.getByRole( 'checkbox', { + name: 'Hide on Desktop', + } ); + await checkbox.setChecked( hide ); + await dialog.getByRole( 'button', { name: 'Apply' } ).click(); + // Applying leaves the options menu open, where it would swallow + // the next click on the canvas. + await page.keyboard.press( 'Escape' ); + } + + // Located by content: the accessible name gains a hidden-block prefix. + function getAnyThread( page, content ) { + return getFloatingNotes( page ) + .getByRole( 'treeitem' ) + .filter( { hasText: content } ); + } + + // The centre of the gap the hidden block left, which is also where + // the block sits while selected, collapsed to zero height. + function getGapCentre( before, after ) { + return async () => { + const beforeBox = await before.boundingBox(); + const afterBox = await after.boundingBox(); + return ( beforeBox.y + beforeBox.height + afterBox.y ) / 2; + }; + } + + // The noted block is not the first one, so a thread parked at the + // top of the panel can't pass by coincidence. + async function addNotedMiddleBlock( { + editor, + page, + blockNoteUtils, + comment, + } ) { + for ( const content of [ + SPACER_TEXT, + 'Noted paragraph', + 'Trailing paragraph', + ] ) { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content }, + } ); + } + const spacer = getParagraph( editor, 'Lorem ipsum' ); + const noted = getParagraph( editor, 'Noted paragraph' ); + const trailing = getParagraph( editor, 'Trailing paragraph' ); + + await editor.selectBlocks( noted ); + await blockNoteUtils.addNote( comment ); + const thread = getAnyThread( page, comment ); + await expectAligned( thread, noted ); + + await editor.selectBlocks( noted ); + await setDesktopVisibility( { + editor, + page, + item: 'Hide', + hide: true, + } ); + // A hidden block stays rendered while selected. + await editor.selectBlocks( spacer ); + await expect( noted ).toBeHidden(); + + return { spacer, noted, trailing, thread }; + } + + test( 'keeps the thread where the block would be', async ( { + editor, + page, + blockNoteUtils, + } ) => { + const { spacer, trailing, thread } = await addNotedMiddleBlock( { + editor, + page, + blockNoteUtils, + comment: 'Hidden host note', + } ); + + await expectAligned( thread, getGapCentre( spacer, trailing ) ); + await expect( + thread.getByText( 'Block is hidden.' ) + ).toBeVisible(); + await expect( + getFloatingNotes( page ).getByRole( 'treeitem', { + name: 'Note on hidden block: Hidden host note', + } ) + ).toBeVisible(); + } ); + + test( 'keeps the thread in place while selected and realigns on show', async ( { + editor, + page, + blockNoteUtils, + } ) => { + const { spacer, noted, trailing, thread } = + await addNotedMiddleBlock( { + editor, + page, + blockNoteUtils, + comment: 'Reveal me', + } ); + + // Selecting the thread selects the block, which renders it again, + // collapsed and still invisible. + await thread.click(); + await expect( thread ).toHaveAttribute( 'aria-expanded', 'true' ); + await expect( noted ).toBeHidden(); + await expect( + thread.getByText( 'Block is hidden.' ) + ).toBeVisible(); + await expectAligned( thread, getGapCentre( spacer, trailing ) ); + + // The block is still selected, so its options menu is at hand. + await setDesktopVisibility( { + editor, + page, + item: 'Show', + hide: false, + } ); + await editor.selectBlocks( spacer ); + await expect( noted ).toBeVisible(); + await expect( thread.getByText( 'Block is hidden.' ) ).toBeHidden(); + await expectAligned( thread, noted ); + } ); + + test( 'places the thread of a hidden last block below the block before it', async ( { + editor, + page, + blockNoteUtils, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: SPACER_TEXT }, + } ); + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Last paragraph' }, + } ); + const spacer = getParagraph( editor, 'Lorem ipsum' ); + const last = getParagraph( editor, 'Last paragraph' ); + + await editor.selectBlocks( last ); + await blockNoteUtils.addNote( 'Last block note' ); + const thread = getAnyThread( page, 'Last block note' ); + await expectAligned( thread, last ); + + await editor.selectBlocks( last ); + await setDesktopVisibility( { + editor, + page, + item: 'Hide', + hide: true, + } ); + await editor.selectBlocks( spacer ); + await expect( last ).toBeHidden(); + + // The only noted block is hidden, so nothing registers the canvas. + await expectAligned( thread, async () => { + const box = await spacer.boundingBox(); + return box.y + box.height; + } ); + } ); + } ); } );