From 565a1a00e44d6b1ab62e548eb3abbd0bb59e97d5 Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 28 Oct 2026 12:22:47 -0700 Subject: [PATCH 1/4] Non-breaking space: Remove spaces that would collapse when replaced Replacing a non-breaking space next to a regular space left two spaces, which the editor shows as a wide gap and the front end shows as one. Remove the extra spaces instead, and show the dots in the admin theme color so they don't look like an error. Co-Authored-By: Claude Opus 5.5 --- .../src/components/rich-text/content.scss | 2 +- .../src/non-breaking-space/index.tsx | 40 ++++++++++++-- .../specs/editor/various/rich-text.spec.js | 55 +++++++++++++++++++ 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/packages/block-editor/src/components/rich-text/content.scss b/packages/block-editor/src/components/rich-text/content.scss index 63810797a0256f..38a7a4feee1d7d 100644 --- a/packages/block-editor/src/components/rich-text/content.scss +++ b/packages/block-editor/src/components/rich-text/content.scss @@ -59,5 +59,5 @@ figcaption.block-editor-rich-text__editable [data-rich-text-placeholder]::before // A background, not a pseudo-element: generated content adds a caret stop in // Firefox. .is-selected .non-breaking-space { - background: radial-gradient(circle, $alert-red 2px, transparent 2.5px) center / 100% 100% no-repeat; + background: radial-gradient(circle, var(--wp-admin-theme-color) 2px, transparent 2.5px) center / 100% 100% no-repeat; } diff --git a/packages/format-library/src/non-breaking-space/index.tsx b/packages/format-library/src/non-breaking-space/index.tsx index fb5b088337b3f0..f9ea25145d4e32 100644 --- a/packages/format-library/src/non-breaking-space/index.tsx +++ b/packages/format-library/src/non-breaking-space/index.tsx @@ -1,5 +1,5 @@ import { __ } from '@wordpress/i18n'; -import { applyFormat, insert, useAnchor } from '@wordpress/rich-text'; +import { applyFormat, insert, remove, useAnchor } from '@wordpress/rich-text'; import type { RichTextValue } from '@wordpress/rich-text'; import { RichTextShortcut, @@ -65,15 +65,47 @@ export const nonBreakingSpace = { return; } - const [ from, to ] = range; + let [ from, to ] = range; const { text } = value; - onChange( { + + // Spaces next to the range collapse with the replaced ones. + while ( text[ from - 1 ] === ' ' ) { + from--; + } + while ( text[ to ] === ' ' ) { + to++; + } + + let newValue: RichTextValue = { ...value, text: text.slice( 0, from ) + text.slice( from, to ).replaceAll( NBSP, ' ' ) + text.slice( to ), - } ); + }; + const runs = [ + ...newValue.text.slice( from, to ).matchAll( / +/g ), + ]; + + // HTML shows a run of spaces as one, and none at the start or end + // of a line, so remove the rest as reloading would. + for ( const { index, 0: spaces } of runs.reverse() ) { + const runStart = from + index; + const runEnd = runStart + spaces.length; + const isLineEdge = [ + text[ runStart - 1 ], + text[ runEnd ], + ].some( + ( character ) => + character === undefined || character === '\n' + ); + const removeFrom = isLineEdge ? runStart : runStart + 1; + if ( removeFrom < runEnd ) { + newValue = remove( newValue, removeFrom, runEnd ); + } + } + + onChange( newValue ); onFocus(); } diff --git a/test/e2e/specs/editor/various/rich-text.spec.js b/test/e2e/specs/editor/various/rich-text.spec.js index 83cea2556afa99..5a893d9be37ff0 100644 --- a/test/e2e/specs/editor/various/rich-text.spec.js +++ b/test/e2e/specs/editor/various/rich-text.spec.js @@ -1063,4 +1063,59 @@ test.describe( 'RichText (@firefox, @webkit)', () => { }, ] ); } ); + + test( 'should remove non-breaking spaces that would collapse when replaced', async ( { + page, + editor, + pageUtils, + } ) => { + const button = page + .getByRole( 'toolbar', { name: 'Block tools' } ) + .getByRole( 'button', { name: 'Replace non-breaking spaces' } ); + + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'one \u00a0 two' }, + } ); + await editor.canvas + .getByRole( 'document', { name: 'Block: Paragraph' } ) + .click(); + await pageUtils.pressKeys( 'End' ); + // After the non-breaking space. + await pageUtils.pressKeys( 'ArrowLeft', { times: 4 } ); + await editor.showBlockToolbar(); + await button.click(); + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'one two' }, + }, + ] ); + + await page.keyboard.type( '!' ); + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'one !two' }, + }, + ] ); + + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: '\u00a0one\u00a0\u00a0two\u00a0' }, + } ); + await editor.canvas + .getByRole( 'document', { name: 'Block: Paragraph' } ) + .last() + .click(); + await pageUtils.pressKeys( 'primary+a' ); + await editor.showBlockToolbar(); + await button.click(); + await expect + .poll( editor.getBlocks ) + .toMatchObject( [ + { attributes: { content: 'one !two' } }, + { attributes: { content: 'one two' } }, + ] ); + } ); } ); From c531020dd2377880f7751b96617a69f24f505be6 Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 28 Oct 2026 12:23:30 -0700 Subject: [PATCH 2/4] Add changelog entry Co-Authored-By: Claude Opus 5.5 --- packages/format-library/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/format-library/CHANGELOG.md b/packages/format-library/CHANGELOG.md index 3fc5df8fd69a9b..4838dafdc9060d 100644 --- a/packages/format-library/CHANGELOG.md +++ b/packages/format-library/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Bug Fixes + +- Non-breaking space: Remove the spaces that HTML would collapse when replacing non-breaking spaces ([#85873](https://github.com/WordPress/gutenberg/pull/85873)). + ## 6.0.0 (2026-10-20) ### Breaking Changes From 906d7c9f67a2005ae1e91489f0136be0efe2bede Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 28 Oct 2026 12:38:56 -0700 Subject: [PATCH 3/4] Only remove non-breaking spaces, keep regular spaces Co-Authored-By: Claude Opus 5.5 --- packages/format-library/CHANGELOG.md | 2 +- .../src/non-breaking-space/index.tsx | 40 ++++++------------- .../specs/editor/various/rich-text.spec.js | 6 +-- 3 files changed, 16 insertions(+), 32 deletions(-) diff --git a/packages/format-library/CHANGELOG.md b/packages/format-library/CHANGELOG.md index 4838dafdc9060d..340c2660cb4dfc 100644 --- a/packages/format-library/CHANGELOG.md +++ b/packages/format-library/CHANGELOG.md @@ -4,7 +4,7 @@ ### Bug Fixes -- Non-breaking space: Remove the spaces that HTML would collapse when replacing non-breaking spaces ([#85873](https://github.com/WordPress/gutenberg/pull/85873)). +- Non-breaking space: Remove a non-breaking space instead of replacing it when HTML would collapse the space ([#85873](https://github.com/WordPress/gutenberg/pull/85873)). ## 6.0.0 (2026-10-20) diff --git a/packages/format-library/src/non-breaking-space/index.tsx b/packages/format-library/src/non-breaking-space/index.tsx index f9ea25145d4e32..7427c82ebfb251 100644 --- a/packages/format-library/src/non-breaking-space/index.tsx +++ b/packages/format-library/src/non-breaking-space/index.tsx @@ -65,17 +65,8 @@ export const nonBreakingSpace = { return; } - let [ from, to ] = range; + const [ from, to ] = range; const { text } = value; - - // Spaces next to the range collapse with the replaced ones. - while ( text[ from - 1 ] === ' ' ) { - from--; - } - while ( text[ to ] === ' ' ) { - to++; - } - let newValue: RichTextValue = { ...value, text: @@ -83,25 +74,18 @@ export const nonBreakingSpace = { text.slice( from, to ).replaceAll( NBSP, ' ' ) + text.slice( to ), }; - const runs = [ - ...newValue.text.slice( from, to ).matchAll( / +/g ), - ]; - - // HTML shows a run of spaces as one, and none at the start or end - // of a line, so remove the rest as reloading would. - for ( const { index, 0: spaces } of runs.reverse() ) { - const runStart = from + index; - const runEnd = runStart + spaces.length; - const isLineEdge = [ - text[ runStart - 1 ], - text[ runEnd ], - ].some( - ( character ) => - character === undefined || character === '\n' + + // HTML collapses a space next to another space or at the start or + // end of a line, so remove the non-breaking space there instead. + for ( let index = to - 1; index >= from; index-- ) { + const isCollapsed = [ + newValue.text[ index - 1 ], + newValue.text[ index + 1 ], + ].some( ( character ) => + [ undefined, ' ', '\n' ].includes( character ) ); - const removeFrom = isLineEdge ? runStart : runStart + 1; - if ( removeFrom < runEnd ) { - newValue = remove( newValue, removeFrom, runEnd ); + if ( text[ index ] === NBSP && isCollapsed ) { + newValue = remove( newValue, index, index + 1 ); } } diff --git a/test/e2e/specs/editor/various/rich-text.spec.js b/test/e2e/specs/editor/various/rich-text.spec.js index 5a893d9be37ff0..e58688f53cbe7e 100644 --- a/test/e2e/specs/editor/various/rich-text.spec.js +++ b/test/e2e/specs/editor/various/rich-text.spec.js @@ -1088,7 +1088,7 @@ test.describe( 'RichText (@firefox, @webkit)', () => { await expect.poll( editor.getBlocks ).toMatchObject( [ { name: 'core/paragraph', - attributes: { content: 'one two' }, + attributes: { content: 'one two' }, }, ] ); @@ -1096,7 +1096,7 @@ test.describe( 'RichText (@firefox, @webkit)', () => { await expect.poll( editor.getBlocks ).toMatchObject( [ { name: 'core/paragraph', - attributes: { content: 'one !two' }, + attributes: { content: 'one ! two' }, }, ] ); @@ -1114,7 +1114,7 @@ test.describe( 'RichText (@firefox, @webkit)', () => { await expect .poll( editor.getBlocks ) .toMatchObject( [ - { attributes: { content: 'one !two' } }, + { attributes: { content: 'one ! two' } }, { attributes: { content: 'one two' } }, ] ); } ); From 05f39df9403b9df91a0d0dbf7d45c5f3ff907037 Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 28 Oct 2026 19:46:17 -0700 Subject: [PATCH 4/4] Label the button in the singular when it replaces one non-breaking space Co-Authored-By: Claude Opus 5.5 --- packages/format-library/CHANGELOG.md | 1 + .../src/non-breaking-space/index.tsx | 8 ++++++- .../specs/editor/various/rich-text.spec.js | 23 ++++++++++++++----- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/format-library/CHANGELOG.md b/packages/format-library/CHANGELOG.md index 340c2660cb4dfc..cbd261603e8daa 100644 --- a/packages/format-library/CHANGELOG.md +++ b/packages/format-library/CHANGELOG.md @@ -5,6 +5,7 @@ ### Bug Fixes - Non-breaking space: Remove a non-breaking space instead of replacing it when HTML would collapse the space ([#85873](https://github.com/WordPress/gutenberg/pull/85873)). +- Non-breaking space: Label the replace button "Replace non-breaking space" when it replaces only one ([#85873](https://github.com/WordPress/gutenberg/pull/85873)). ## 6.0.0 (2026-10-20) diff --git a/packages/format-library/src/non-breaking-space/index.tsx b/packages/format-library/src/non-breaking-space/index.tsx index 7427c82ebfb251..1dbf4b9f56095f 100644 --- a/packages/format-library/src/non-breaking-space/index.tsx +++ b/packages/format-library/src/non-breaking-space/index.tsx @@ -109,7 +109,13 @@ export const nonBreakingSpace = { diff --git a/test/e2e/specs/editor/various/rich-text.spec.js b/test/e2e/specs/editor/various/rich-text.spec.js index e58688f53cbe7e..c4966ee057b42d 100644 --- a/test/e2e/specs/editor/various/rich-text.spec.js +++ b/test/e2e/specs/editor/various/rich-text.spec.js @@ -1031,7 +1031,10 @@ test.describe( 'RichText (@firefox, @webkit)', () => { } ) => { const button = page .getByRole( 'toolbar', { name: 'Block tools' } ) - .getByRole( 'button', { name: 'Replace non-breaking spaces' } ); + .getByRole( 'button', { + name: 'Replace non-breaking space', + exact: true, + } ); await editor.insertBlock( { name: 'core/paragraph' } ); await page.keyboard.type( 'one' ); @@ -1069,9 +1072,7 @@ test.describe( 'RichText (@firefox, @webkit)', () => { editor, pageUtils, } ) => { - const button = page - .getByRole( 'toolbar', { name: 'Block tools' } ) - .getByRole( 'button', { name: 'Replace non-breaking spaces' } ); + const toolbar = page.getByRole( 'toolbar', { name: 'Block tools' } ); await editor.insertBlock( { name: 'core/paragraph', @@ -1084,7 +1085,12 @@ test.describe( 'RichText (@firefox, @webkit)', () => { // After the non-breaking space. await pageUtils.pressKeys( 'ArrowLeft', { times: 4 } ); await editor.showBlockToolbar(); - await button.click(); + await toolbar + .getByRole( 'button', { + name: 'Replace non-breaking space', + exact: true, + } ) + .click(); await expect.poll( editor.getBlocks ).toMatchObject( [ { name: 'core/paragraph', @@ -1110,7 +1116,12 @@ test.describe( 'RichText (@firefox, @webkit)', () => { .click(); await pageUtils.pressKeys( 'primary+a' ); await editor.showBlockToolbar(); - await button.click(); + await toolbar + .getByRole( 'button', { + name: 'Replace non-breaking spaces', + exact: true, + } ) + .click(); await expect .poll( editor.getBlocks ) .toMatchObject( [