diff --git a/packages/block-editor/src/components/rich-text/README.md b/packages/block-editor/src/components/rich-text/README.md index ed0f15d38ec019..1e771b486df33f 100644 --- a/packages/block-editor/src/components/rich-text/README.md +++ b/packages/block-editor/src/components/rich-text/README.md @@ -61,6 +61,10 @@ Render a rich [`contenteditable` input](https://developer.mozilla.org/en-US/docs *Optional.* A list of autocompleters to use instead of the default. +### `preserveWhiteSpace: Boolean` + +*Optional.* Whether or not to preserve white space characters in the `value`. Normally tab, newline and space characters are collapsed to a single space. If turned on, soft line breaks will be saved as newline characters, not as line break elements. + ## RichText.Content `RichText.Content` should be used in the `save` function of your block to correctly save rich text content. diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index e433c89eafd24f..63ceffb8032325 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -349,6 +349,7 @@ class RichTextWrapper extends Component { start, reversed, style, + preserveWhiteSpace, // From experimental filter. To do: pick props instead. ...experimentalProps } = this.props; @@ -398,6 +399,7 @@ class RichTextWrapper extends Component { __unstableDidAutomaticChange={ didAutomaticChange } __unstableUndo={ undo } style={ style } + preserveWhiteSpace={ preserveWhiteSpace } > { ( { isSelected, value, onChange, Editable } ) => <> diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index a7956c70e0d216..6e717035783468 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -49,11 +49,14 @@ export function resetBlocks( blocks ) { } /** - * @typedef {WPBlockSelection} A block selection object. + * A block selection object. + * + * @typedef {Object} WPBlockSelection * * @property {string} clientId A block client ID. * @property {string} attributeKey A block attribute key. - * @property {number} offset A block attribute offset. + * @property {number} offset An attribute value offset, based on the rich + * text value. See `wp.richText.create`. */ /** diff --git a/packages/block-editor/src/store/effects.js b/packages/block-editor/src/store/effects.js index 7416143c9bb780..fbfaee23cc715d 100644 --- a/packages/block-editor/src/store/effects.js +++ b/packages/block-editor/src/store/effects.js @@ -123,16 +123,19 @@ export default { const { multiline: multilineTag, __unstableMultilineWrapperTags: multilineWrapperTags, + __unstablePreserveWhiteSpace: preserveWhiteSpace, } = attributeDefinition; const value = insert( create( { html, multilineTag, multilineWrapperTags, + preserveWhiteSpace, } ), START_OF_SELECTED_AREA, offset, offset ); selectedBlock.attributes[ attributeKey ] = toHTMLString( { value, multilineTag, + preserveWhiteSpace, } ); } @@ -161,15 +164,21 @@ export default { const { multiline: multilineTag, __unstableMultilineWrapperTags: multilineWrapperTags, + __unstablePreserveWhiteSpace: preserveWhiteSpace, } = blockAType.attributes[ newAttributeKey ]; const convertedValue = create( { html: convertedHtml, multilineTag, multilineWrapperTags, + preserveWhiteSpace, } ); const newOffset = convertedValue.text.indexOf( START_OF_SELECTED_AREA ); const newValue = remove( convertedValue, newOffset, newOffset + 1 ); - const newHtml = toHTMLString( { value: newValue, multilineTag } ); + const newHtml = toHTMLString( { + value: newValue, + multilineTag, + preserveWhiteSpace, + } ); updatedAttributes[ newAttributeKey ] = newHtml; diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index d2a009b59c6399..60e1eef758b955 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -37,7 +37,8 @@ import { SVG, Rect, G, Path } from '@wordpress/components'; * * @property {string} clientId A block client ID. * @property {string} attributeKey A block attribute key. - * @property {number} offset A block attribute offset. + * @property {number} offset An attribute value offset, based on the rich + * text value. See `wp.richText.create`. */ // Module constants diff --git a/packages/block-library/src/preformatted/block.json b/packages/block-library/src/preformatted/block.json index fadcbd4a860edf..d582451f5eb670 100644 --- a/packages/block-library/src/preformatted/block.json +++ b/packages/block-library/src/preformatted/block.json @@ -6,7 +6,8 @@ "type": "string", "source": "html", "selector": "pre", - "default": "" + "default": "", + "__unstablePreserveWhiteSpace": true } } } diff --git a/packages/block-library/src/preformatted/edit.js b/packages/block-library/src/preformatted/edit.js index 415afea843ef31..d7ce79438080ea 100644 --- a/packages/block-library/src/preformatted/edit.js +++ b/packages/block-library/src/preformatted/edit.js @@ -11,14 +11,11 @@ export default function PreformattedEdit( { attributes, mergeBlocks, setAttribut ' ) } + preserveWhiteSpace + value={ content } onChange={ ( nextContent ) => { setAttributes( { - // Ensure line breaks are normalised to characters. This - // saves space, is easier to read, and ensures display - // filters work correctly. - content: nextContent.replace( /
/g, '\n' ), + content: nextContent, } ); } } placeholder={ __( 'Write preformatted text…' ) } diff --git a/packages/e2e-tests/specs/editor/blocks/__snapshots__/preformatted.test.js.snap b/packages/e2e-tests/specs/editor/blocks/__snapshots__/preformatted.test.js.snap index 07a70a19a6b2be..37881193bbacad 100644 --- a/packages/e2e-tests/specs/editor/blocks/__snapshots__/preformatted.test.js.snap +++ b/packages/e2e-tests/specs/editor/blocks/__snapshots__/preformatted.test.js.snap @@ -14,3 +14,11 @@ exports[`Preformatted should preserve character newlines 2`] = ` 2 " `; + +exports[`Preformatted should preserve white space when merging 1`] = ` +" +
1
+2
+3
+" +`; diff --git a/packages/e2e-tests/specs/editor/blocks/preformatted.test.js b/packages/e2e-tests/specs/editor/blocks/preformatted.test.js index ea6575785613cf..11a1b0e5b14efd 100644 --- a/packages/e2e-tests/specs/editor/blocks/preformatted.test.js +++ b/packages/e2e-tests/specs/editor/blocks/preformatted.test.js @@ -32,4 +32,18 @@ describe( 'Preformatted', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + + it( 'should preserve white space when merging', async () => { + await insertBlock( 'Preformatted' ); + await page.keyboard.type( '1' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '2' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.press( 'Tab' ); + await page.keyboard.type( '3' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'Backspace' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); } ); diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index 70d0dda8f3b940..9cabf7bff4708e 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -1193,11 +1193,14 @@ export function getEditorBlocks( state ) { } /** - * @typedef {WPBlockSelection} A block selection object. + * A block selection object. + * + * @typedef {Object} WPBlockSelection * * @property {string} clientId A block client ID. * @property {string} attributeKey A block attribute key. - * @property {number} offset A block attribute offset. + * @property {number} offset An attribute value offset, based on the rich + * text value. See `wp.richText.create`. */ /** diff --git a/packages/rich-text/README.md b/packages/rich-text/README.md index e911c714792064..30ed4296d162f7 100644 --- a/packages/rich-text/README.md +++ b/packages/rich-text/README.md @@ -84,6 +84,7 @@ _Parameters_ - _$1.range_ `[Range]`: Range to create value from. - _$1.multilineTag_ `[string]`: Multiline tag if the structure is multiline. - _$1.multilineWrapperTags_ `[Array]`: Tags where lines can be found if nesting is possible. +- _$1.preserveWhiteSpace_ `[?boolean]`: Whether or not to collapse white space characters. _Returns_ @@ -328,6 +329,7 @@ _Parameters_ - _$1_ `Object`: Named argements. - _$1.value_ `Object`: Rich text value. - _$1.multilineTag_ `[string]`: Multiline tag. +- _$1.preserveWhiteSpace_ `[?boolean]`: Whether or not to use newline characters for line breaks. _Returns_ diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js index bb591f0f497da7..61e31b01fb3650 100644 --- a/packages/rich-text/src/component/index.js +++ b/packages/rich-text/src/component/index.js @@ -202,7 +202,11 @@ class RichText extends Component { } createRecord() { - const { __unstableMultilineTag: multilineTag, forwardedRef } = this.props; + const { + __unstableMultilineTag: multilineTag, + forwardedRef, + preserveWhiteSpace, + } = this.props; const selection = getSelection(); const range = selection.rangeCount > 0 ? selection.getRangeAt( 0 ) : null; @@ -212,6 +216,7 @@ class RichText extends Component { multilineTag, multilineWrapperTags: multilineTag === 'li' ? [ 'ul', 'ol' ] : undefined, __unstableIsEditableTree: true, + preserveWhiteSpace, } ); } @@ -947,7 +952,11 @@ class RichText extends Component { * @return {Object} An internal rich-text value. */ formatToValue( value ) { - const { format, __unstableMultilineTag: multilineTag } = this.props; + const { + format, + __unstableMultilineTag: multilineTag, + preserveWhiteSpace, + } = this.props; if ( format !== 'string' ) { return value; @@ -959,6 +968,7 @@ class RichText extends Component { html: value, multilineTag, multilineWrapperTags: multilineTag === 'li' ? [ 'ul', 'ol' ] : undefined, + preserveWhiteSpace, } ); value.formats = prepare( value ); @@ -992,7 +1002,11 @@ class RichText extends Component { * @return {*} The external data format, data type depends on props. */ valueToFormat( value ) { - const { format, __unstableMultilineTag: multilineTag } = this.props; + const { + format, + __unstableMultilineTag: multilineTag, + preserveWhiteSpace, + } = this.props; value = this.removeEditorOnlyFormats( value ); @@ -1000,7 +1014,7 @@ class RichText extends Component { return; } - return toHTMLString( { value, multilineTag } ); + return toHTMLString( { value, multilineTag, preserveWhiteSpace } ); } Editable( props ) { diff --git a/packages/rich-text/src/create.js b/packages/rich-text/src/create.js index 92251fc547b8e6..3241552dbd26d0 100644 --- a/packages/rich-text/src/create.js +++ b/packages/rich-text/src/create.js @@ -130,6 +130,8 @@ function toFormat( { type, attributes } ) { * multiline. * @param {Array} [$1.multilineWrapperTags] Tags where lines can be found if * nesting is possible. + * @param {?boolean} [$1.preserveWhiteSpace] Whether or not to collapse white + * space characters. * * @return {Object} A rich text value. */ @@ -141,6 +143,7 @@ export function create( { multilineTag, multilineWrapperTags, __unstableIsEditableTree: isEditableTree, + preserveWhiteSpace, } = {} ) { if ( typeof text === 'string' && text.length > 0 ) { return { @@ -163,6 +166,7 @@ export function create( { element, range, isEditableTree, + preserveWhiteSpace, } ); } @@ -172,6 +176,7 @@ export function create( { multilineTag, multilineWrapperTags, isEditableTree, + preserveWhiteSpace, } ); } @@ -268,14 +273,25 @@ function filterRange( node, range, filter ) { return { startContainer, startOffset, endContainer, endOffset }; } +/** + * Collapse any whitespace used for HTML formatting to one space character, + * because it will also be displayed as such by the browser. + * + * @param {string} string + */ +function collapseWhiteSpace( string ) { + return string.replace( /[\n\r\t]+/g, ' ' ); +} + const ZWNBSPRegExp = new RegExp( ZWNBSP, 'g' ); -function filterString( string ) { - // Reduce any whitespace used for HTML formatting to one space - // character, because it will also be displayed as such by the browser. - return string.replace( /[\n\r\t]+/g, ' ' ) - // Remove padding added by `toTree`. - .replace( ZWNBSPRegExp, '' ); +/** + * Removes padding (zero width non breaking spaces) added by `toTree`. + * + * @param {string} string + */ +function removePadding( string ) { + return string.replace( ZWNBSPRegExp, '' ); } /** @@ -288,6 +304,8 @@ function filterString( string ) { * multiline. * @param {?Array} $1.multilineWrapperTags Tags where lines can be found if * nesting is possible. + * @param {?boolean} $1.preserveWhiteSpace Whether or not to collapse white + * space characters. * * @return {Object} A rich text value. */ @@ -298,6 +316,7 @@ function createFromElement( { multilineWrapperTags, currentWrapperTags = [], isEditableTree, + preserveWhiteSpace, } ) { const accumulator = createEmptyValue(); @@ -318,8 +337,15 @@ function createFromElement( { const type = node.nodeName.toLowerCase(); if ( node.nodeType === TEXT_NODE ) { - const text = filterString( node.nodeValue ); - range = filterRange( node, range, filterString ); + let filter = removePadding; + + if ( ! preserveWhiteSpace ) { + filter = ( string ) => + removePadding( collapseWhiteSpace( string ) ); + } + + const text = filter( node.nodeValue ); + range = filterRange( node, range, filter ); accumulateSelection( accumulator, node, range, { text } ); // Create a sparse array of the same length as `text`, in which // formats can be added. @@ -365,6 +391,7 @@ function createFromElement( { multilineWrapperTags, currentWrapperTags: [ ...currentWrapperTags, format ], isEditableTree, + preserveWhiteSpace, } ); accumulateSelection( accumulator, node, range, value ); @@ -378,6 +405,7 @@ function createFromElement( { multilineTag, multilineWrapperTags, isEditableTree, + preserveWhiteSpace, } ); accumulateSelection( accumulator, node, range, value ); @@ -409,15 +437,17 @@ function createFromElement( { * Creates a rich text value from a DOM element and range that should be * multiline. * - * @param {Object} $1 Named argements. - * @param {?Element} $1.element Element to create value from. - * @param {?Range} $1.range Range to create value from. - * @param {?string} $1.multilineTag Multiline tag if the structure is - * multiline. - * @param {?Array} $1.multilineWrapperTags Tags where lines can be found if - * nesting is possible. - * @param {boolean} $1.currentWrapperTags Whether to prepend a line - * separator. + * @param {Object} $1 Named argements. + * @param {?Element} $1.element Element to create value from. + * @param {?Range} $1.range Range to create value from. + * @param {?string} $1.multilineTag Multiline tag if the structure is + * multiline. + * @param {?Array} $1.multilineWrapperTags Tags where lines can be found if + * nesting is possible. + * @param {boolean} $1.currentWrapperTags Whether to prepend a line + * separator. + * @param {?boolean} $1.preserveWhiteSpace Whether or not to collapse white + * space characters. * * @return {Object} A rich text value. */ @@ -428,6 +458,7 @@ function createFromMultilineElement( { multilineWrapperTags, currentWrapperTags = [], isEditableTree, + preserveWhiteSpace, } ) { const accumulator = createEmptyValue(); @@ -452,6 +483,7 @@ function createFromMultilineElement( { multilineWrapperTags, currentWrapperTags, isEditableTree, + preserveWhiteSpace, } ); // Multiline value text should be separated by a line separator. diff --git a/packages/rich-text/src/to-html-string.js b/packages/rich-text/src/to-html-string.js index 06aa0434992028..bf18f3346cc604 100644 --- a/packages/rich-text/src/to-html-string.js +++ b/packages/rich-text/src/to-html-string.js @@ -18,16 +18,19 @@ import { toTree } from './to-tree'; * Create an HTML string from a Rich Text value. If a `multilineTag` is * provided, text separated by a line separator will be wrapped in it. * - * @param {Object} $1 Named argements. - * @param {Object} $1.value Rich text value. - * @param {string} [$1.multilineTag] Multiline tag. + * @param {Object} $1 Named argements. + * @param {Object} $1.value Rich text value. + * @param {string} [$1.multilineTag] Multiline tag. + * @param {?boolean} [$1.preserveWhiteSpace] Whether or not to use newline + * characters for line breaks. * * @return {string} HTML string. */ -export function toHTMLString( { value, multilineTag } ) { +export function toHTMLString( { value, multilineTag, preserveWhiteSpace } ) { const tree = toTree( { value, multilineTag, + preserveWhiteSpace, createEmpty, append, getLastChild, diff --git a/packages/rich-text/src/to-tree.js b/packages/rich-text/src/to-tree.js index 6e50be6a42cdd5..048be3a821980d 100644 --- a/packages/rich-text/src/to-tree.js +++ b/packages/rich-text/src/to-tree.js @@ -73,6 +73,7 @@ function fromFormat( { type, attributes, unregisteredAttributes, object, boundar export function toTree( { value, multilineTag, + preserveWhiteSpace, createEmpty, append, getLastChild, @@ -223,7 +224,7 @@ export function toTree( { } ) ); // Ensure pointer is text node. pointer = append( getParent( pointer ), '' ); - } else if ( character === '\n' ) { + } else if ( ! preserveWhiteSpace && character === '\n' ) { pointer = append( getParent( pointer ), { type: 'br', attributes: isEditableTree ? {