From 6a9b4416bd0c530440a4bda8a8a061922c4c2637 Mon Sep 17 00:00:00 2001 From: ella Date: Tue, 13 Oct 2026 19:32:20 -0700 Subject: [PATCH 1/5] Format Library: Mark non-breaking spaces in the selection and add a button to replace them Only the non-breaking spaces inside the selection get a red dot. A toolbar button, shown while the text has non-breaking spaces, replaces them with regular spaces. Rich text keeps the direction of a backward selection when it applies the selection again, which the marks now trigger while selecting. Co-Authored-By: Claude Opus 5.5 --- .../src/components/rich-text/content.scss | 20 ++- .../rich-text/format-toolbar/index.jsx | 14 +- .../src/non-breaking-space/index.tsx | 170 ++++++++++++++++-- packages/rich-text/src/to-dom.ts | 46 ++++- .../specs/editor/various/rich-text.spec.js | 71 ++++++++ 5 files changed, 292 insertions(+), 29 deletions(-) diff --git a/packages/block-editor/src/components/rich-text/content.scss b/packages/block-editor/src/components/rich-text/content.scss index 96ee4b6af210da..3c49cf13763fbe 100644 --- a/packages/block-editor/src/components/rich-text/content.scss +++ b/packages/block-editor/src/components/rich-text/content.scss @@ -1,3 +1,4 @@ +@use "@wordpress/base-styles/colors" as *; @use "@wordpress/base-styles/variables" as *; .rich-text { @@ -55,8 +56,19 @@ figcaption.block-editor-rich-text__editable [data-rich-text-placeholder]::before background-color: transparent; } -.is-selected .non-breaking-space { - outline: 1px solid currentColor; - outline-offset: -1px; - opacity: 0.8; +.rich-text .non-breaking-space { + position: relative; + + &::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 4px; + height: 4px; + margin: -2px 0 0 -2px; + border-radius: 50%; + background-color: $alert-red; + pointer-events: none; + } } diff --git a/packages/block-editor/src/components/rich-text/format-toolbar/index.jsx b/packages/block-editor/src/components/rich-text/format-toolbar/index.jsx index f3a08644c1856b..6ea151145a8d91 100644 --- a/packages/block-editor/src/components/rich-text/format-toolbar/index.jsx +++ b/packages/block-editor/src/components/rich-text/format-toolbar/index.jsx @@ -11,12 +11,14 @@ const POPOVER_PROPS = { const FormatToolbar = () => { return ( <> - { [ 'bold', 'italic', 'link', 'unknown' ].map( ( format ) => ( - - ) ) } + { [ 'bold', 'italic', 'link', 'unknown', 'non-breaking-space' ].map( + ( format ) => ( + + ) + ) } { ( fills ) => { if ( ! fills.length ) { diff --git a/packages/format-library/src/non-breaking-space/index.tsx b/packages/format-library/src/non-breaking-space/index.tsx index c5edab43f0bb17..bed78736f07b63 100644 --- a/packages/format-library/src/non-breaking-space/index.tsx +++ b/packages/format-library/src/non-breaking-space/index.tsx @@ -1,13 +1,110 @@ import { __ } from '@wordpress/i18n'; -import { applyFormat, insert, useAnchor } from '@wordpress/rich-text'; +import { + applyFormat, + create, + insert, + RichTextData, + useAnchor, +} from '@wordpress/rich-text'; import type { RichTextValue } from '@wordpress/rich-text'; -// @ts-expect-error Block Editor not fully typed yet. -import { RichTextShortcut } from '@wordpress/block-editor'; -import { Popover } from '@wordpress/components'; +import { + RichTextShortcut, + RichTextToolbarButton, + store as blockEditorStore, + // @ts-expect-error Block Editor not fully typed yet. +} from '@wordpress/block-editor'; +import { Popover, SVG, Path } from '@wordpress/components'; import type { FormatEditProps } from '../types'; const name = 'core/non-breaking-space'; const title = __( 'Non breaking space' ); +const NBSP = ' '; + +const icon = ( + + + +); + +type HandlerContext = { + richTextIdentifier: string; + blockClientId: string; +}; + +type SelectionPoint = { + clientId?: string; + attributeKey?: string; + offset?: number; +}; + +/** + * Returns the part of a field that the selection covers, also when the + * selection runs across blocks. + * + * @param select The registry select function. + * @param context The field. + * @param context.richTextIdentifier The attribute key of the field. + * @param context.blockClientId The block holding the field. + */ +function getSelectedRange( + select: ( store: unknown ) => any, + { richTextIdentifier, blockClientId }: HandlerContext +): [ number, number ] | undefined { + const { + getSelectionStart, + getSelectionEnd, + getFirstMultiSelectedBlockClientId, + getBlockParents, + isBlockMultiSelected, + isAncestorMultiSelected, + } = select( blockEditorStore ); + const start: SelectionPoint = getSelectionStart(); + const end: SelectionPoint = getSelectionEnd(); + + if ( start.clientId === end.clientId ) { + if ( + start.clientId !== blockClientId || + start.attributeKey !== richTextIdentifier || + end.attributeKey !== richTextIdentifier || + start.offset === undefined || + end.offset === undefined + ) { + return; + } + return [ + Math.min( start.offset, end.offset ), + Math.max( start.offset, end.offset ), + ]; + } + + if ( blockClientId === start.clientId || blockClientId === end.clientId ) { + const firstClientId = getFirstMultiSelectedBlockClientId(); + const startIsFirst = + start.clientId === firstClientId || + getBlockParents( start.clientId ).includes( firstClientId ); + const [ head, tail ] = startIsFirst ? [ start, end ] : [ end, start ]; + const point = blockClientId === head.clientId ? head : tail; + // Without a field, the whole block is selected. + if ( point.attributeKey === undefined ) { + return [ 0, Infinity ]; + } + if ( point.attributeKey !== richTextIdentifier ) { + return undefined; + } + return point === head + ? [ head.offset ?? 0, Infinity ] + : [ 0, tail.offset ?? Infinity ]; + } + + if ( + isBlockMultiSelected( blockClientId ) || + isAncestorMultiSelected( blockClientId ) + ) { + return [ 0, Infinity ]; + } + + return undefined; +} function PopoverAnchor( { contentRef, @@ -32,9 +129,14 @@ export const nonBreakingSpace = { title, tagName: 'span', className: 'non-breaking-space', - edit( { value, onChange, contentRef }: FormatEditProps ) { + edit( { value, onChange, onFocus, contentRef }: FormatEditProps ) { function addNonBreakingSpace() { - onChange( insert( value, '\u00a0' ) ); + onChange( insert( value, NBSP ) ); + } + + function replaceNonBreakingSpaces() { + onChange( { ...value, text: value.text.replaceAll( NBSP, ' ' ) } ); + onFocus(); } const selectedValue = @@ -49,29 +151,66 @@ export const nonBreakingSpace = { character=" " onUse={ addNonBreakingSpace } /> - { selectedValue === '\u00a0' && ( + { value.text.includes( NBSP ) && ( + + ) } + { selectedValue === NBSP && ( ) } ); }, - __experimentalCreatePrepareEditableTree(): ( + // Only the non-breaking spaces in the selection are marked, so the editable + // tree is prepared again when that set changes. + __experimentalGetPropsForEditableTreePreparation( + select: ( store: unknown ) => any, + context: HandlerContext + ) { + const range = getSelectedRange( select, context ); + if ( ! range ) { + return { selected: '' }; + } + const attribute = select( blockEditorStore ).getBlockAttributes( + context.blockClientId + )?.[ context.richTextIdentifier ]; + let text = ''; + if ( attribute instanceof RichTextData ) { + text = attribute.text; + } else if ( typeof attribute === 'string' ) { + text = create( { html: attribute } ).text; + } + const indices = []; + let index = text.indexOf( NBSP, range[ 0 ] ); + while ( index !== -1 && index < range[ 1 ] ) { + indices.push( index ); + index = text.indexOf( NBSP, index + 1 ); + } + return { selected: indices.join( ',' ) }; + }, + __experimentalCreatePrepareEditableTree( { + selected, + }: { + selected?: string; + } ): ( formats: RichTextValue[ 'formats' ], text: string ) => RichTextValue[ 'formats' ] { + const indices = selected ? selected.split( ',' ).map( Number ) : []; return ( formats, text ) => { - const NBSP = '\u00a0'; // Not a complete `RichTextValue`: the callback only receives // `formats` and `text`. The assertion is safe because `applyFormat` // is passed explicit indices (so it never falls back to `start`/ // `end`), reads only `formats`, and spreads the rest through // untouched — and only `formats` is read back out below. let record = { formats, text } as RichTextValue; - let index = -1; - - do { - index = text.indexOf( NBSP, index + 1 ); - if ( index !== -1 ) { + for ( const index of indices ) { + if ( text[ index ] === NBSP ) { record = applyFormat( record, { type: name }, @@ -79,8 +218,7 @@ export const nonBreakingSpace = { index + 1 ); } - } while ( index !== -1 ); - + } return record.formats; }; }, diff --git a/packages/rich-text/src/to-dom.ts b/packages/rich-text/src/to-dom.ts index d9d3adefe1b810..ae182649cc6fce 100644 --- a/packages/rich-text/src/to-dom.ts +++ b/packages/rich-text/src/to-dom.ts @@ -283,13 +283,43 @@ export function apply( { doc: current.ownerDocument, } ); + // Updating the tree can reset the live selection, so read its direction + // first: a selection extended backward must keep extending backward. + const isBackward = isSelectionBackward( current ); + applyValue( body, current ); if ( value.start !== undefined && ! __unstableDomOnly ) { - applySelection( selection, current ); + applySelection( selection, current, isBackward ); } } +/** + * Whether the live selection inside the element ends before it starts. + * + * @param current The editable element. + */ +function isSelectionBackward( current: HTMLElement ) { + const { ownerDocument } = current; + const selection = ownerDocument.defaultView!.getSelection(); + + if ( + ! selection || + selection.isCollapsed || + ! selection.anchorNode || + ! selection.focusNode || + ! current.contains( selection.anchorNode ) + ) { + return false; + } + + const range = ownerDocument.createRange(); + range.setStart( selection.anchorNode, selection.anchorOffset ); + // Setting an end before the start collapses the range. + range.setEnd( selection.focusNode, selection.focusOffset ); + return range.collapsed; +} + export function applyValue( future: Node, current: Node ) { let i = 0; let futureChild; @@ -361,7 +391,8 @@ export function applyValue( future: Node, current: Node ) { export function applySelection( { startPath, endPath }: ToDomSelection, - current: HTMLElement + current: HTMLElement, + isBackward = false ) { const { node: startContainer, offset: startOffset } = getNodeByPath( current, @@ -391,7 +422,16 @@ export function applySelection( selection.removeAllRanges(); } - selection.addRange( range ); + if ( isBackward && ! range.collapsed ) { + selection.setBaseAndExtent( + endContainer, + endOffset, + startContainer, + startOffset + ); + } else { + selection.addRange( range ); + } // This function is not intended to cause a shift in focus. Since the above // selection manipulations may shift focus, ensure that focus is restored to diff --git a/test/e2e/specs/editor/various/rich-text.spec.js b/test/e2e/specs/editor/various/rich-text.spec.js index 617ca92d568f9f..ccb981db3f7c73 100644 --- a/test/e2e/specs/editor/various/rich-text.spec.js +++ b/test/e2e/specs/editor/various/rich-text.spec.js @@ -998,4 +998,75 @@ test.describe( 'RichText (@firefox, @webkit)', () => { { name: 'core/paragraph', attributes: { content: 'Outside' } }, ] ); } ); + + test( 'should only mark the non-breaking spaces in the selection', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.insertBlock( { name: 'core/paragraph' } ); + await page.keyboard.type( 'one' ); + await pageUtils.pressKeys( 'primaryShift+Space' ); + await page.keyboard.type( 'two three' ); + await pageUtils.pressKeys( 'primaryShift+Space' ); + await page.keyboard.type( 'four' ); + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'one\u00a0two three\u00a0four' }, + }, + ] ); + + const marked = editor.canvas.locator( '.non-breaking-space' ); + await expect( marked ).toHaveCount( 0 ); + + await pageUtils.pressKeys( 'shift+ArrowLeft', { times: 6 } ); + await expect + .poll( () => + editor.canvas + .locator( ':root' ) + // Firefox reports the non-breaking space as a space. + .evaluate( () => + window + .getSelection() + .toString() + .replace( '\u00a0', ' ' ) + ) + ) + .toBe( 'e four' ); + await expect( marked ).toHaveCount( 1 ); + + await page.keyboard.press( 'ArrowRight' ); + await expect( marked ).toHaveCount( 0 ); + } ); + + test( 'should replace non-breaking spaces from the toolbar', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.insertBlock( { name: 'core/paragraph' } ); + await page.keyboard.type( 'one' ); + await pageUtils.pressKeys( 'primaryShift+Space' ); + await page.keyboard.type( 'two' ); + + await editor.clickBlockToolbarButton( 'Replace non-breaking spaces' ); + await expect + .poll( editor.getBlocks ) + .toMatchObject( [ + { name: 'core/paragraph', attributes: { content: 'one two' } }, + ] ); + await expect( + page + .getByRole( 'toolbar', { name: 'Block tools' } ) + .getByRole( 'button', { name: 'Replace non-breaking spaces' } ) + ).toHaveCount( 0 ); + + await page.keyboard.type( '!' ); + await expect + .poll( editor.getBlocks ) + .toMatchObject( [ + { name: 'core/paragraph', attributes: { content: 'one two!' } }, + ] ); + } ); } ); From ad880a4e5b396c0c90d4e95ba3cb184f080c8f7a Mon Sep 17 00:00:00 2001 From: ella Date: Tue, 13 Oct 2026 20:09:54 -0700 Subject: [PATCH 2/5] Format Library: Mark selected non-breaking spaces with the selection pseudo-element Every non-breaking space is wrapped again, and only the selected ones show a red dot, drawn as a raised dotted underline. The editable tree no longer follows the selection, so rich text no longer needs to keep its direction. Co-Authored-By: Claude Opus 5.5 --- .../src/components/rich-text/content.scss | 21 +-- .../src/non-breaking-space/index.tsx | 135 ++---------------- packages/rich-text/src/to-dom.ts | 46 +----- .../specs/editor/various/rich-text.spec.js | 41 ------ 4 files changed, 19 insertions(+), 224 deletions(-) diff --git a/packages/block-editor/src/components/rich-text/content.scss b/packages/block-editor/src/components/rich-text/content.scss index 3c49cf13763fbe..9cf8c98f392565 100644 --- a/packages/block-editor/src/components/rich-text/content.scss +++ b/packages/block-editor/src/components/rich-text/content.scss @@ -56,19 +56,10 @@ figcaption.block-editor-rich-text__editable [data-rich-text-placeholder]::before background-color: transparent; } -.rich-text .non-breaking-space { - position: relative; - - &::after { - content: ""; - position: absolute; - top: 50%; - left: 50%; - width: 4px; - height: 4px; - margin: -2px 0 0 -2px; - border-radius: 50%; - background-color: $alert-red; - pointer-events: none; - } +// Mark non-breaking spaces only where they are selected. The selection +// pseudo-element allows no shapes, so the dot is a short dotted underline +// raised to the middle of the line. +.non-breaking-space::selection { + text-decoration: underline dotted $alert-red 4px; + text-underline-offset: -0.45em; } diff --git a/packages/format-library/src/non-breaking-space/index.tsx b/packages/format-library/src/non-breaking-space/index.tsx index bed78736f07b63..2062d77b1a795b 100644 --- a/packages/format-library/src/non-breaking-space/index.tsx +++ b/packages/format-library/src/non-breaking-space/index.tsx @@ -1,16 +1,9 @@ import { __ } from '@wordpress/i18n'; -import { - applyFormat, - create, - insert, - RichTextData, - useAnchor, -} from '@wordpress/rich-text'; +import { applyFormat, insert, useAnchor } from '@wordpress/rich-text'; import type { RichTextValue } from '@wordpress/rich-text'; import { RichTextShortcut, RichTextToolbarButton, - store as blockEditorStore, // @ts-expect-error Block Editor not fully typed yet. } from '@wordpress/block-editor'; import { Popover, SVG, Path } from '@wordpress/components'; @@ -18,7 +11,7 @@ import type { FormatEditProps } from '../types'; const name = 'core/non-breaking-space'; const title = __( 'Non breaking space' ); -const NBSP = ' '; +const NBSP = '\u00a0'; const icon = ( @@ -26,86 +19,6 @@ const icon = ( ); -type HandlerContext = { - richTextIdentifier: string; - blockClientId: string; -}; - -type SelectionPoint = { - clientId?: string; - attributeKey?: string; - offset?: number; -}; - -/** - * Returns the part of a field that the selection covers, also when the - * selection runs across blocks. - * - * @param select The registry select function. - * @param context The field. - * @param context.richTextIdentifier The attribute key of the field. - * @param context.blockClientId The block holding the field. - */ -function getSelectedRange( - select: ( store: unknown ) => any, - { richTextIdentifier, blockClientId }: HandlerContext -): [ number, number ] | undefined { - const { - getSelectionStart, - getSelectionEnd, - getFirstMultiSelectedBlockClientId, - getBlockParents, - isBlockMultiSelected, - isAncestorMultiSelected, - } = select( blockEditorStore ); - const start: SelectionPoint = getSelectionStart(); - const end: SelectionPoint = getSelectionEnd(); - - if ( start.clientId === end.clientId ) { - if ( - start.clientId !== blockClientId || - start.attributeKey !== richTextIdentifier || - end.attributeKey !== richTextIdentifier || - start.offset === undefined || - end.offset === undefined - ) { - return; - } - return [ - Math.min( start.offset, end.offset ), - Math.max( start.offset, end.offset ), - ]; - } - - if ( blockClientId === start.clientId || blockClientId === end.clientId ) { - const firstClientId = getFirstMultiSelectedBlockClientId(); - const startIsFirst = - start.clientId === firstClientId || - getBlockParents( start.clientId ).includes( firstClientId ); - const [ head, tail ] = startIsFirst ? [ start, end ] : [ end, start ]; - const point = blockClientId === head.clientId ? head : tail; - // Without a field, the whole block is selected. - if ( point.attributeKey === undefined ) { - return [ 0, Infinity ]; - } - if ( point.attributeKey !== richTextIdentifier ) { - return undefined; - } - return point === head - ? [ head.offset ?? 0, Infinity ] - : [ 0, tail.offset ?? Infinity ]; - } - - if ( - isBlockMultiSelected( blockClientId ) || - isAncestorMultiSelected( blockClientId ) - ) { - return [ 0, Infinity ]; - } - - return undefined; -} - function PopoverAnchor( { contentRef, }: Pick< FormatEditProps, 'contentRef' > ) { @@ -166,42 +79,10 @@ export const nonBreakingSpace = { ); }, - // Only the non-breaking spaces in the selection are marked, so the editable - // tree is prepared again when that set changes. - __experimentalGetPropsForEditableTreePreparation( - select: ( store: unknown ) => any, - context: HandlerContext - ) { - const range = getSelectedRange( select, context ); - if ( ! range ) { - return { selected: '' }; - } - const attribute = select( blockEditorStore ).getBlockAttributes( - context.blockClientId - )?.[ context.richTextIdentifier ]; - let text = ''; - if ( attribute instanceof RichTextData ) { - text = attribute.text; - } else if ( typeof attribute === 'string' ) { - text = create( { html: attribute } ).text; - } - const indices = []; - let index = text.indexOf( NBSP, range[ 0 ] ); - while ( index !== -1 && index < range[ 1 ] ) { - indices.push( index ); - index = text.indexOf( NBSP, index + 1 ); - } - return { selected: indices.join( ',' ) }; - }, - __experimentalCreatePrepareEditableTree( { - selected, - }: { - selected?: string; - } ): ( + __experimentalCreatePrepareEditableTree(): ( formats: RichTextValue[ 'formats' ], text: string ) => RichTextValue[ 'formats' ] { - const indices = selected ? selected.split( ',' ).map( Number ) : []; return ( formats, text ) => { // Not a complete `RichTextValue`: the callback only receives // `formats` and `text`. The assertion is safe because `applyFormat` @@ -209,8 +90,11 @@ export const nonBreakingSpace = { // `end`), reads only `formats`, and spreads the rest through // untouched — and only `formats` is read back out below. let record = { formats, text } as RichTextValue; - for ( const index of indices ) { - if ( text[ index ] === NBSP ) { + let index = -1; + + do { + index = text.indexOf( NBSP, index + 1 ); + if ( index !== -1 ) { record = applyFormat( record, { type: name }, @@ -218,7 +102,8 @@ export const nonBreakingSpace = { index + 1 ); } - } + } while ( index !== -1 ); + return record.formats; }; }, diff --git a/packages/rich-text/src/to-dom.ts b/packages/rich-text/src/to-dom.ts index ae182649cc6fce..d9d3adefe1b810 100644 --- a/packages/rich-text/src/to-dom.ts +++ b/packages/rich-text/src/to-dom.ts @@ -283,43 +283,13 @@ export function apply( { doc: current.ownerDocument, } ); - // Updating the tree can reset the live selection, so read its direction - // first: a selection extended backward must keep extending backward. - const isBackward = isSelectionBackward( current ); - applyValue( body, current ); if ( value.start !== undefined && ! __unstableDomOnly ) { - applySelection( selection, current, isBackward ); + applySelection( selection, current ); } } -/** - * Whether the live selection inside the element ends before it starts. - * - * @param current The editable element. - */ -function isSelectionBackward( current: HTMLElement ) { - const { ownerDocument } = current; - const selection = ownerDocument.defaultView!.getSelection(); - - if ( - ! selection || - selection.isCollapsed || - ! selection.anchorNode || - ! selection.focusNode || - ! current.contains( selection.anchorNode ) - ) { - return false; - } - - const range = ownerDocument.createRange(); - range.setStart( selection.anchorNode, selection.anchorOffset ); - // Setting an end before the start collapses the range. - range.setEnd( selection.focusNode, selection.focusOffset ); - return range.collapsed; -} - export function applyValue( future: Node, current: Node ) { let i = 0; let futureChild; @@ -391,8 +361,7 @@ export function applyValue( future: Node, current: Node ) { export function applySelection( { startPath, endPath }: ToDomSelection, - current: HTMLElement, - isBackward = false + current: HTMLElement ) { const { node: startContainer, offset: startOffset } = getNodeByPath( current, @@ -422,16 +391,7 @@ export function applySelection( selection.removeAllRanges(); } - if ( isBackward && ! range.collapsed ) { - selection.setBaseAndExtent( - endContainer, - endOffset, - startContainer, - startOffset - ); - } else { - selection.addRange( range ); - } + selection.addRange( range ); // This function is not intended to cause a shift in focus. Since the above // selection manipulations may shift focus, ensure that focus is restored to diff --git a/test/e2e/specs/editor/various/rich-text.spec.js b/test/e2e/specs/editor/various/rich-text.spec.js index ccb981db3f7c73..9c5d982ea6f712 100644 --- a/test/e2e/specs/editor/various/rich-text.spec.js +++ b/test/e2e/specs/editor/various/rich-text.spec.js @@ -999,47 +999,6 @@ test.describe( 'RichText (@firefox, @webkit)', () => { ] ); } ); - test( 'should only mark the non-breaking spaces in the selection', async ( { - page, - editor, - pageUtils, - } ) => { - await editor.insertBlock( { name: 'core/paragraph' } ); - await page.keyboard.type( 'one' ); - await pageUtils.pressKeys( 'primaryShift+Space' ); - await page.keyboard.type( 'two three' ); - await pageUtils.pressKeys( 'primaryShift+Space' ); - await page.keyboard.type( 'four' ); - await expect.poll( editor.getBlocks ).toMatchObject( [ - { - name: 'core/paragraph', - attributes: { content: 'one\u00a0two three\u00a0four' }, - }, - ] ); - - const marked = editor.canvas.locator( '.non-breaking-space' ); - await expect( marked ).toHaveCount( 0 ); - - await pageUtils.pressKeys( 'shift+ArrowLeft', { times: 6 } ); - await expect - .poll( () => - editor.canvas - .locator( ':root' ) - // Firefox reports the non-breaking space as a space. - .evaluate( () => - window - .getSelection() - .toString() - .replace( '\u00a0', ' ' ) - ) - ) - .toBe( 'e four' ); - await expect( marked ).toHaveCount( 1 ); - - await page.keyboard.press( 'ArrowRight' ); - await expect( marked ).toHaveCount( 0 ); - } ); - test( 'should replace non-breaking spaces from the toolbar', async ( { page, editor, From 3664a19a6a9ba70503194a510b29594f9c7acc23 Mon Sep 17 00:00:00 2001 From: ella Date: Tue, 13 Oct 2026 20:21:30 -0700 Subject: [PATCH 3/5] Format Library: Show a red dot on non-breaking spaces in the selected block Safari ignores text decoration in the selection pseudo-element, so the dot is drawn on every non-breaking space of the selected block instead. Co-Authored-By: Claude Opus 5.5 --- .../src/components/rich-text/content.scss | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/block-editor/src/components/rich-text/content.scss b/packages/block-editor/src/components/rich-text/content.scss index 9cf8c98f392565..011770a67e1114 100644 --- a/packages/block-editor/src/components/rich-text/content.scss +++ b/packages/block-editor/src/components/rich-text/content.scss @@ -56,10 +56,19 @@ figcaption.block-editor-rich-text__editable [data-rich-text-placeholder]::before background-color: transparent; } -// Mark non-breaking spaces only where they are selected. The selection -// pseudo-element allows no shapes, so the dot is a short dotted underline -// raised to the middle of the line. -.non-breaking-space::selection { - text-decoration: underline dotted $alert-red 4px; - text-underline-offset: -0.45em; +.is-selected .non-breaking-space { + position: relative; + + &::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 4px; + height: 4px; + margin: -2px 0 0 -2px; + border-radius: 50%; + background-color: $alert-red; + pointer-events: none; + } } From 92e9e973eac811a2e98e3f40071d51692592982b Mon Sep 17 00:00:00 2001 From: ella Date: Tue, 13 Oct 2026 20:23:41 -0700 Subject: [PATCH 4/5] Block Editor: Draw the non-breaking space dot as a background Generated content inside the space adds a caret stop in Firefox, so ArrowRight paused after every non-breaking space. Co-Authored-By: Claude Opus 5.5 --- .../src/components/rich-text/content.scss | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/packages/block-editor/src/components/rich-text/content.scss b/packages/block-editor/src/components/rich-text/content.scss index 011770a67e1114..63810797a0256f 100644 --- a/packages/block-editor/src/components/rich-text/content.scss +++ b/packages/block-editor/src/components/rich-text/content.scss @@ -56,19 +56,8 @@ figcaption.block-editor-rich-text__editable [data-rich-text-placeholder]::before background-color: transparent; } +// A background, not a pseudo-element: generated content adds a caret stop in +// Firefox. .is-selected .non-breaking-space { - position: relative; - - &::after { - content: ""; - position: absolute; - top: 50%; - left: 50%; - width: 4px; - height: 4px; - margin: -2px 0 0 -2px; - border-radius: 50%; - background-color: $alert-red; - pointer-events: none; - } + background: radial-gradient(circle, $alert-red 2px, transparent 2.5px) center / 100% 100% no-repeat; } From e3fd63ea57ab124e29b095a7e4e919de73551cd8 Mon Sep 17 00:00:00 2001 From: ella Date: Wed, 14 Oct 2026 13:22:52 -0700 Subject: [PATCH 5/5] Format Library: Replace only the selected non-breaking spaces Show the button when the selection or the caret's neighbours hold a non-breaking space, and replace only those, like the unknown formats button. Co-Authored-By: Claude Opus 5.5 --- .../src/non-breaking-space/index.tsx | 30 ++++++++++++- .../specs/editor/various/rich-text.spec.js | 44 ++++++++++++------- 2 files changed, 55 insertions(+), 19 deletions(-) diff --git a/packages/format-library/src/non-breaking-space/index.tsx b/packages/format-library/src/non-breaking-space/index.tsx index 2062d77b1a795b..d3192ce9c0a7d1 100644 --- a/packages/format-library/src/non-breaking-space/index.tsx +++ b/packages/format-library/src/non-breaking-space/index.tsx @@ -19,6 +19,18 @@ const icon = ( ); +// The selected text, or the characters on both sides of the caret. +function getNonBreakingSpaceRange( { text, start, end }: RichTextValue ) { + if ( start === undefined || end === undefined ) { + return null; + } + + const from = start === end ? Math.max( start - 1, 0 ) : start; + const to = start === end ? end + 1 : end; + + return text.slice( from, to ).includes( NBSP ) ? [ from, to ] : null; +} + function PopoverAnchor( { contentRef, }: Pick< FormatEditProps, 'contentRef' > ) { @@ -47,8 +59,22 @@ export const nonBreakingSpace = { onChange( insert( value, NBSP ) ); } + const range = getNonBreakingSpaceRange( value ); + function replaceNonBreakingSpaces() { - onChange( { ...value, text: value.text.replaceAll( NBSP, ' ' ) } ); + if ( ! range ) { + return; + } + + const [ from, to ] = range; + const { text } = value; + onChange( { + ...value, + text: + text.slice( 0, from ) + + text.slice( from, to ).replaceAll( NBSP, ' ' ) + + text.slice( to ), + } ); onFocus(); } @@ -64,7 +90,7 @@ export const nonBreakingSpace = { character=" " onUse={ addNonBreakingSpace } /> - { value.text.includes( NBSP ) && ( + { range && ( { ] ); } ); - test( 'should replace non-breaking spaces from the toolbar', async ( { + test( 'should replace selected non-breaking spaces from the toolbar', 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' } ); await page.keyboard.type( 'one' ); await pageUtils.pressKeys( 'primaryShift+Space' ); + await editor.showBlockToolbar(); + await expect( button ).toBeVisible(); + await page.keyboard.type( 'two' ); + await pageUtils.pressKeys( 'primaryShift+Space' ); + await page.keyboard.type( 'three' ); + await editor.showBlockToolbar(); + await expect( button ).toHaveCount( 0 ); - await editor.clickBlockToolbarButton( 'Replace non-breaking spaces' ); - await expect - .poll( editor.getBlocks ) - .toMatchObject( [ - { name: 'core/paragraph', attributes: { content: 'one two' } }, - ] ); - await expect( - page - .getByRole( 'toolbar', { name: 'Block tools' } ) - .getByRole( 'button', { name: 'Replace non-breaking spaces' } ) - ).toHaveCount( 0 ); + await pageUtils.pressKeys( 'shift+ArrowLeft', { times: 6 } ); + await button.click(); + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'one\u00a0two three' }, + }, + ] ); + await expect( button ).toHaveCount( 0 ); await page.keyboard.type( '!' ); - await expect - .poll( editor.getBlocks ) - .toMatchObject( [ - { name: 'core/paragraph', attributes: { content: 'one two!' } }, - ] ); + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'one\u00a0two!' }, + }, + ] ); } ); } );