From 67c59dd720570f6d4e9684e0bcd1a7da97032a3b Mon Sep 17 00:00:00 2001 From: Ella Date: Sat, 18 Nov 2023 10:48:21 +0100 Subject: [PATCH 1/4] Rich text: preserve multiple spaces on front-end --- .../src/component/use-input-and-selection.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/rich-text/src/component/use-input-and-selection.js b/packages/rich-text/src/component/use-input-and-selection.js index 870bfe170635c0..d33280b93721c0 100644 --- a/packages/rich-text/src/component/use-input-and-selection.js +++ b/packages/rich-text/src/component/use-input-and-selection.js @@ -99,6 +99,24 @@ export function useInputAndSelection( props ) { const currentValue = createRecord(); const { start, activeFormats: oldActiveFormats = [] } = record.current; + const insertedChars = currentValue.text.slice( + start, + currentValue.start + ); + + // When inserting multiple spaces, alternate between a normal space + // and a non-breaking space. This is to prevent browsers from + // collapsing multiple spaces into one. + if ( insertedChars === ' ' ) { + const previousChar = currentValue.text.charAt( start - 1 ); + + if ( previousChar === ' ' ) { + currentValue.text = + currentValue.text.slice( 0, currentValue.start - 1 ) + + '\u00a0' + + currentValue.text.slice( currentValue.start ); + } + } // Update the formats between the last and new caret position. const change = updateFormats( { From 2f71e19e89402beacba6335830260b61c16eb103 Mon Sep 17 00:00:00 2001 From: Ella Date: Sat, 18 Nov 2023 13:23:55 +0100 Subject: [PATCH 2/4] Fix delete handling --- .../src/component/use-input-and-selection.js | 55 +++++++++++++------ 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/packages/rich-text/src/component/use-input-and-selection.js b/packages/rich-text/src/component/use-input-and-selection.js index d33280b93721c0..ce438aa17e00e0 100644 --- a/packages/rich-text/src/component/use-input-and-selection.js +++ b/packages/rich-text/src/component/use-input-and-selection.js @@ -9,6 +9,7 @@ import { useRefEffect } from '@wordpress/compose'; */ import { getActiveFormats } from '../get-active-formats'; import { updateFormats } from '../update-formats'; +import { isCollapsed } from '../is-collapsed'; /** * All inserting input types that would insert HTML into the DOM. @@ -96,33 +97,53 @@ export function useInputAndSelection( props ) { return; } - const currentValue = createRecord(); - const { start, activeFormats: oldActiveFormats = [] } = + const newValue = createRecord(); + const { start: oldStart, activeFormats: oldActiveFormats = [] } = record.current; - const insertedChars = currentValue.text.slice( - start, - currentValue.start - ); + + const SP = ' '; + const NBSP = '\u00a0'; + const isSpOrNbsp = ( char ) => char === SP || char === NBSP; // When inserting multiple spaces, alternate between a normal space // and a non-breaking space. This is to prevent browsers from // collapsing multiple spaces into one. - if ( insertedChars === ' ' ) { - const previousChar = currentValue.text.charAt( start - 1 ); - - if ( previousChar === ' ' ) { - currentValue.text = - currentValue.text.slice( 0, currentValue.start - 1 ) + - '\u00a0' + - currentValue.text.slice( currentValue.start ); + if ( + isCollapsed( newValue ) && + oldStart !== newValue.start && + ( oldStart < newValue.start + ? newValue.text.slice( oldStart, newValue.start ) === SP + : isSpOrNbsp( + record.current.text.slice( + newValue.start, + oldStart + ) + ) ) + ) { + const text = newValue.text; + let startOffset = newValue.start; + let endOffset = newValue.start; + + while ( isSpOrNbsp( text[ startOffset - 1 ] ) ) { + startOffset--; + } + while ( isSpOrNbsp( text[ endOffset ] ) ) { + endOffset++; } + + newValue.text = + text.slice( 0, startOffset ) + + Array.from( { length: endOffset - startOffset } ) + .map( ( _, index ) => ( index % 2 === 0 ? SP : NBSP ) ) + .join( '' ) + + text.slice( endOffset ); } // Update the formats between the last and new caret position. const change = updateFormats( { - value: currentValue, - start, - end: currentValue.start, + value: newValue, + start: oldStart, + end: newValue.start, formats: oldActiveFormats, } ); From 49df163067e74a2cd3169045fa604cb0aa78f1f6 Mon Sep 17 00:00:00 2001 From: Ella Date: Sat, 18 Nov 2023 16:42:49 +0100 Subject: [PATCH 3/4] add e2e tests --- test/e2e/specs/editor/blocks/links.spec.js | 4 +- .../specs/editor/various/rich-text.spec.js | 60 +++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/test/e2e/specs/editor/blocks/links.spec.js b/test/e2e/specs/editor/blocks/links.spec.js index 7e654ca12790f7..1ccd421987f33a 100644 --- a/test/e2e/specs/editor/blocks/links.spec.js +++ b/test/e2e/specs/editor/blocks/links.spec.js @@ -969,9 +969,7 @@ test.describe( 'Links', () => { name: 'core/paragraph', attributes: { content: - 'Text with leading and trailing' + - textToSelect + - '', + 'Text with leading and trailing         spaces     ', }, }, ] ); diff --git a/test/e2e/specs/editor/various/rich-text.spec.js b/test/e2e/specs/editor/various/rich-text.spec.js index 2969a33d254852..fd26071b2d2c57 100644 --- a/test/e2e/specs/editor/various/rich-text.spec.js +++ b/test/e2e/specs/editor/various/rich-text.spec.js @@ -853,4 +853,64 @@ test.describe( 'RichText', () => { }, ] ); } ); + + test( 'should pad multiple spaces', async ( { page, editor } ) => { + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'a b' ); + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'a  b' }, + }, + ] ); + } ); + + test( 'should pad starting from space', async ( { page, editor } ) => { + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'a b' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.type( ' ' ); + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'a  b' }, + }, + ] ); + } ); + + test( 'should restore alternating padding on backspace', async ( { + page, + editor, + } ) => { + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'a b' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'Backspace' ); + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'a   b' }, + }, + ] ); + } ); + + test( 'should restore alternating padding on delete', async ( { + page, + editor, + } ) => { + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'a b' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'Delete' ); + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'a   b' }, + }, + ] ); + } ); } ); From 7665b703329f2ddcc12dacf79d3a04c77f11541a Mon Sep 17 00:00:00 2001 From: Ella Date: Sun, 19 Nov 2023 12:17:30 +0100 Subject: [PATCH 4/4] Add comments --- .../rich-text/src/component/use-input-and-selection.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/rich-text/src/component/use-input-and-selection.js b/packages/rich-text/src/component/use-input-and-selection.js index ce438aa17e00e0..b06f82adb6d518 100644 --- a/packages/rich-text/src/component/use-input-and-selection.js +++ b/packages/rich-text/src/component/use-input-and-selection.js @@ -112,8 +112,12 @@ export function useInputAndSelection( props ) { isCollapsed( newValue ) && oldStart !== newValue.start && ( oldStart < newValue.start - ? newValue.text.slice( oldStart, newValue.start ) === SP - : isSpOrNbsp( + ? // Check if the inserted character is a space. + newValue.text.slice( oldStart, newValue.start ) === SP + : // Check if the deleted character is a space or + // non-breaking space (in both cases we need to fix the + // alternating pattern). + isSpOrNbsp( record.current.text.slice( newValue.start, oldStart @@ -124,6 +128,8 @@ export function useInputAndSelection( props ) { let startOffset = newValue.start; let endOffset = newValue.start; + // We need to make sure the alternating pattern is maintained, + // so replace all spaces before and after the selection. while ( isSpOrNbsp( text[ startOffset - 1 ] ) ) { startOffset--; }