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/block-editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
- `RichText`: Skip the block input transforms in fields that are not passed an `onReplace` ([#80978](https://github.com/WordPress/gutenberg/pull/80978)).
- `RichText`: Ignore pasted files, which carry no text to paste inline ([#81010](https://github.com/WordPress/gutenberg/pull/81010)).
- Background block support: Fix gradients not being applied to a block when a theme opts out of `settings.background.gradient` in `theme.json` ([#81056](https://github.com/WordPress/gutenberg/pull/81056)).
- Writing flow: Move the end of a triple click selection back to the last text position before the boundary the browser overshoots to, instead of only correcting a next sibling reported at an offset of 0, so triple clicking a paragraph followed by a separator or a container block no longer starts a multi selection ([#81022](https://github.com/WordPress/gutenberg/pull/81022)).

## 16.0.0 (2026-07-14)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
create,
privateApis as richTextPrivateApis,
} from '@wordpress/rich-text';
import { isSelectionForward } from '@wordpress/dom';

/**
* Internal dependencies
Expand Down Expand Up @@ -59,26 +58,40 @@ function extractSelectionEndNode( selection, isTripleClick ) {
return focusNode;
}

if ( focusOffset === focusNode.childNodes.length ) {
return focusNode;
}

// A triple click selects the paragraph, but the browser extends the
// forward selection into the next element at an offset of 0. This may
// trigger multi selection even though the selection does not visually end
// in the next block. Keyboard selections that legitimately extend to the
// same boundary (e.g. Shift+ArrowDown into a focusable block, where the
// browser reports the boundary at the element instead of its first text
// position) must not be corrected, so only do this for triple clicks.
const endNode =
focusOffset === focusNode.childNodes.length
? focusNode
: focusNode.childNodes[ focusOffset ];
const range = selection.getRangeAt( 0 );
const clientId = getBlockClientId( selection.anchorNode );

// A triple click extends the forward selection past the block, which would
// trigger multi selection. Move the end back to the last text position
// before it, but only within the block the selection starts in, so a drag
// across blocks is left alone. The focus is the range end for a forward
// selection; node positions can't tell, as the focus may be an ancestor of
// the anchor.
if (
focusOffset === 0 &&
isSelectionForward( selection ) &&
isTripleClick
range.endContainer === focusNode &&
range.endOffset === focusOffset &&
isTripleClick &&
clientId &&
getBlockClientId( endNode ) !== clientId
) {
return focusNode.previousSibling ?? focusNode.parentElement;
const { ownerDocument } = focusNode;
const walker = ownerDocument.createTreeWalker(
ownerDocument.body,
ownerDocument.defaultView.NodeFilter.SHOW_TEXT
);
walker.currentNode = endNode;
const previousTextNode = walker.previousNode();

if ( getBlockClientId( previousTextNode ) === clientId ) {
return previousTextNode;
}
}

return focusNode.childNodes[ focusOffset ];
return endNode;
}

function findDepth( a, b ) {
Expand Down
193 changes: 193 additions & 0 deletions test/e2e/specs/editor/various/multi-block-selection.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -995,6 +995,199 @@ test.describe( 'Multi-block selection (@firefox, @webkit)', () => {
} );
} );

test( 'should select a single paragraph on triple click before a non text block', async ( {
page,
editor,
multiBlockSelectionUtils,
} ) => {
await editor.insertBlock( {
name: 'core/paragraph',
attributes: { content: 'One two three' },
} );
await editor.insertBlock( { name: 'core/separator' } );

await editor.selectBlocks(
editor.canvas.getByRole( 'document', { name: 'Block: Paragraph' } )
);

// Triple click selects the paragraph. The browser extends the forward
// selection to the separator instead of into it at offset 0; that
// overshoot must not collapse the selection or extend it into the
// separator.
await editor.canvas
.getByRole( 'document', { name: 'Block: Paragraph' } )
.click( { clickCount: 3 } );

// Only the paragraph is selected, not a multi-block selection reaching
// into the separator.
await expect
.poll( multiBlockSelectionUtils.getSelectedBlocks )
.toMatchObject( [ { name: 'core/paragraph' } ] );

// The whole paragraph is selected (not collapsed), so typing replaces
// its content.
await page.keyboard.type( 'a' );
await expect
.poll( editor.getBlocks )
.toMatchObject( [
{ name: 'core/paragraph', attributes: { content: 'a' } },
{ name: 'core/separator' },
] );
} );

test( 'should select a single paragraph on triple click before a placeholder block', async ( {
page,
editor,
multiBlockSelectionUtils,
} ) => {
await editor.insertBlock( {
name: 'core/paragraph',
attributes: { content: 'One two three' },
} );
await editor.insertBlock( { name: 'core/image' } );

await editor.selectBlocks(
editor.canvas.getByRole( 'document', { name: 'Block: Paragraph' } )
);

// Triple click selects the paragraph. The browser extends the forward
// selection into an empty element of the placeholder, where the offset
// is both 0 and the number of child nodes; that overshoot must not
// collapse the selection or extend it into the image.
await editor.canvas
.getByRole( 'document', { name: 'Block: Paragraph' } )
.click( { clickCount: 3 } );

// Only the paragraph is selected, not a multi-block selection reaching
// into the image.
await expect
.poll( multiBlockSelectionUtils.getSelectedBlocks )
.toMatchObject( [ { name: 'core/paragraph' } ] );

// The whole paragraph is selected (not collapsed), so typing replaces
// its content.
await page.keyboard.type( 'a' );
await expect
.poll( editor.getBlocks )
.toMatchObject( [
{ name: 'core/paragraph', attributes: { content: 'a' } },
{ name: 'core/image' },
] );
} );

test( 'should select a single paragraph on triple click before a container block', async ( {
page,
editor,
multiBlockSelectionUtils,
} ) => {
await editor.insertBlock( {
name: 'core/paragraph',
attributes: { content: 'One two three' },
} );
await editor.insertBlock( {
name: 'core/group',
innerBlocks: [
{ name: 'core/paragraph', attributes: { content: 'Inner' } },
],
} );

await editor.selectBlocks(
editor.canvas
.getByRole( 'document', { name: 'Block: Paragraph' } )
.first()
);

// Triple click selects the paragraph. The browser extends the forward
// selection into the group, where the boundary has no preceding
// sibling; that overshoot must not collapse the selection or extend it
// into the group.
await editor.canvas
.getByRole( 'document', { name: 'Block: Paragraph' } )
.first()
.click( { clickCount: 3 } );

// Only the first paragraph is selected, not a multi-block selection
// reaching into the group.
await expect
.poll( multiBlockSelectionUtils.getSelectedBlocks )
.toMatchObject( [ { name: 'core/paragraph' } ] );

// The whole paragraph is selected (not collapsed), so typing replaces
// its content.
await page.keyboard.type( 'a' );
await expect.poll( editor.getBlocks ).toMatchObject( [
{ name: 'core/paragraph', attributes: { content: 'a' } },
{
name: 'core/group',
innerBlocks: [
{
name: 'core/paragraph',
attributes: { content: 'Inner' },
},
],
},
] );
} );

test( 'should select a single paragraph on triple click inside a container block', async ( {
page,
editor,
multiBlockSelectionUtils,
} ) => {
// The group needs two paragraphs: a container holding a single
// paragraph does not host an editable root.
await editor.insertBlock( {
name: 'core/group',
innerBlocks: [
{
name: 'core/paragraph',
attributes: { content: 'Inner one' },
},
{
name: 'core/paragraph',
attributes: { content: 'Inner two' },
},
],
} );
await editor.insertBlock( { name: 'core/separator' } );

await editor.selectBlocks(
editor.canvas
.getByRole( 'document', { name: 'Block: Paragraph' } )
.last()
);

// Triple click selects the paragraph. The browser extends the forward
// selection past the group, to the separator after it; that overshoot
// must not select the group as a whole.
await editor.canvas
.getByRole( 'document', { name: 'Block: Paragraph' } )
.last()
.click( { clickCount: 3 } );

// Only the paragraph is selected, not the group containing it.
await expect
.poll( multiBlockSelectionUtils.getSelectedBlocks )
.toMatchObject( [ { name: 'core/paragraph' } ] );

// The whole paragraph is selected (not collapsed), so typing replaces
// its content.
await page.keyboard.type( 'a' );
await expect.poll( editor.getBlocks ).toMatchObject( [
{
name: 'core/group',
innerBlocks: [
{
name: 'core/paragraph',
attributes: { content: 'Inner one' },
},
{ name: 'core/paragraph', attributes: { content: 'a' } },
],
},
{ name: 'core/separator' },
] );
} );

test( 'should gradually multi-select', async ( {
page,
editor,
Expand Down
Loading