diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index a37cd2154491b2..11da84a07c7fb8 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -55,6 +55,19 @@ import { store as blockEditorStore } from '../../store'; const wrapperClasses = 'block-editor-rich-text'; const classes = 'block-editor-rich-text__editable'; +function addActiveFormats( value, activeFormats ) { + if ( activeFormats.length ) { + let index = value.formats.length; + + while ( index-- ) { + value.formats[ index ] = [ + ...activeFormats, + ...( value.formats[ index ] || [] ), + ]; + } + } +} + /** * Get the multiline tag based on the multiline prop. * @@ -412,7 +425,31 @@ function RichTextWrapper( ); const onPaste = useCallback( - ( { value, onChange, html, plainText, files, activeFormats } ) => { + ( { + value, + onChange, + html, + plainText, + isInternal, + files, + activeFormats, + } ) => { + // If the data comes from a rich text instance, we can directly use it + // without filtering the data. The filters are only meant for externally + // pasted content and remove inline styles. + if ( isInternal ) { + const pastedValue = create( { + html, + multilineTag, + multilineWrapperTags: + multilineTag === 'li' ? [ 'ul', 'ol' ] : undefined, + preserveWhiteSpace, + } ); + addActiveFormats( pastedValue, activeFormats ); + onChange( insert( value, pastedValue ) ); + return; + } + if ( pastePlainText ) { onChange( insert( value, create( { text: plainText } ) ) ); return; @@ -474,21 +511,11 @@ function RichTextWrapper( if ( typeof content === 'string' ) { let valueToInsert = create( { html: content } ); - // If there are active formats, merge them with the pasted formats. - if ( activeFormats.length ) { - let index = valueToInsert.formats.length; - - while ( index-- ) { - valueToInsert.formats[ index ] = [ - ...activeFormats, - ...( valueToInsert.formats[ index ] || [] ), - ]; - } - } + addActiveFormats( valueToInsert, activeFormats ); // If the content should be multiline, we should process text // separated by a line break as separate lines. - if ( multiline ) { + if ( multilineTag ) { valueToInsert = replace( valueToInsert, /\n+/g, @@ -511,7 +538,7 @@ function RichTextWrapper( onSplit, splitValue, __unstableEmbedURLOnPaste, - multiline, + multilineTag, preserveWhiteSpace, pastePlainText, ] diff --git a/packages/e2e-tests/specs/editor/various/__snapshots__/rich-text.test.js.snap b/packages/e2e-tests/specs/editor/various/__snapshots__/rich-text.test.js.snap index 47c68777e21a64..9bd908b82d03fb 100644 --- a/packages/e2e-tests/specs/editor/various/__snapshots__/rich-text.test.js.snap +++ b/packages/e2e-tests/specs/editor/various/__snapshots__/rich-text.test.js.snap @@ -82,6 +82,22 @@ exports[`RichText should only mutate text data on input 1`] = ` " `; +exports[`RichText should preserve internal formatting 1`] = ` +" +
1
+" +`; + +exports[`RichText should preserve internal formatting 2`] = ` +" +1
+ + + +1
+" +`; + exports[`RichText should return focus when pressing formatting button 1`] = ` "Some bold.
diff --git a/packages/e2e-tests/specs/editor/various/rich-text.test.js b/packages/e2e-tests/specs/editor/various/rich-text.test.js index 17e064b7c58523..2dd9f12ccd6cbd 100644 --- a/packages/e2e-tests/specs/editor/various/rich-text.test.js +++ b/packages/e2e-tests/specs/editor/various/rich-text.test.js @@ -8,6 +8,7 @@ import { clickBlockAppender, pressKeyWithModifier, showBlockToolbar, + clickBlockToolbarButton, } from '@wordpress/e2e-test-utils'; describe( 'RichText', () => { @@ -374,4 +375,44 @@ describe( 'RichText', () => { expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + + it( 'should preserve internal formatting', async () => { + await clickBlockAppender(); + + // Add text and select to color. + await page.keyboard.type( '1' ); + await pressKeyWithModifier( 'primary', 'a' ); + await clickBlockToolbarButton( 'More' ); + + const button = await page.waitForXPath( + `//button[contains(text(), 'Text color')]` + ); + // Clicks may fail if the button is out of view. Assure it is before click. + await button.evaluate( ( element ) => element.scrollIntoView() ); + await button.click(); + + // Select color other than black. + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Enter' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + + // Navigate to the block. + await page.keyboard.press( 'Tab' ); + await pressKeyWithModifier( 'primary', 'a' ); + + // Copy the colored text. + await pressKeyWithModifier( 'primary', 'c' ); + + // Collapsed the selection to the end. + await page.keyboard.press( 'ArrowRight' ); + + // Create a new paragraph. + await page.keyboard.press( 'Enter' ); + + // Paste the colored text. + await pressKeyWithModifier( 'primary', 'v' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); } ); diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js index 471601193b0367..ab1667b7fc401b 100644 --- a/packages/rich-text/src/component/index.js +++ b/packages/rich-text/src/component/index.js @@ -42,6 +42,8 @@ import { useFormatTypes } from './use-format-types'; import { useBoundaryStyle } from './use-boundary-style'; import { useInlineWarning } from './use-inline-warning'; import { insert } from '../insert'; +import { slice } from '../slice'; +import { getTextContent } from '../get-text-content'; /** @typedef {import('@wordpress/element').WPSyntheticEvent} WPSyntheticEvent */ @@ -311,6 +313,24 @@ function RichText( } ); } + function handleCopy( event ) { + if ( isCollapsed( record.current ) ) { + return; + } + + const selectedRecord = slice( record.current ); + const plainText = getTextContent( selectedRecord ); + const html = toHTMLString( { + value: selectedRecord, + multilineTag, + preserveWhiteSpace, + } ); + event.clipboardData.setData( 'text/plain', plainText ); + event.clipboardData.setData( 'text/html', html ); + event.clipboardData.setData( 'rich-text', 'true' ); + event.preventDefault(); + } + /** * Handles a paste event. * @@ -379,12 +399,14 @@ function RichText( if ( onPaste ) { const files = getFilesFromDataTransfer( clipboardData ); + const isInternal = clipboardData.getData( 'rich-text' ) === 'true'; onPaste( { value: removeEditorOnlyFormats( record.current ), onChange: handleChange, html, plainText, + isInternal, files: [ ...files ], activeFormats, } ); @@ -1078,6 +1100,7 @@ function RichText( ref: useMergeRefs( [ forwardedRef, ref ] ), style: defaultStyle, className: 'rich-text', + onCopy: handleCopy, onPaste: handlePaste, onInput: handleInput, onCompositionStart: handleCompositionStart, diff --git a/packages/rich-text/src/get-text-content.js b/packages/rich-text/src/get-text-content.js index 0a3ba1a8211321..c1c23d5bccd75c 100644 --- a/packages/rich-text/src/get-text-content.js +++ b/packages/rich-text/src/get-text-content.js @@ -1,3 +1,11 @@ +/** + * Internal dependencies + */ +import { + OBJECT_REPLACEMENT_CHARACTER, + LINE_SEPARATOR, +} from './special-characters'; + /** @typedef {import('./create').RichTextValue} RichTextValue */ /** @@ -9,5 +17,7 @@ * @return {string} The text content. */ export function getTextContent( { text } ) { - return text; + return text + .replace( new RegExp( OBJECT_REPLACEMENT_CHARACTER, 'g' ), '' ) + .replace( new RegExp( LINE_SEPARATOR, 'g' ), '\n' ); } diff --git a/packages/rich-text/src/insert-line-separator.js b/packages/rich-text/src/insert-line-separator.js index 485f79b0380820..cf87f1a184d2c4 100644 --- a/packages/rich-text/src/insert-line-separator.js +++ b/packages/rich-text/src/insert-line-separator.js @@ -2,7 +2,6 @@ * Internal dependencies */ -import { getTextContent } from './get-text-content'; import { insert } from './insert'; import { LINE_SEPARATOR } from './special-characters'; @@ -24,7 +23,7 @@ export function insertLineSeparator( startIndex = value.start, endIndex = value.end ) { - const beforeText = getTextContent( value ).slice( 0, startIndex ); + const beforeText = value.text.slice( 0, startIndex ); const previousLineSeparatorIndex = beforeText.lastIndexOf( LINE_SEPARATOR ); const previousLineSeparatorFormats = value.replacements[ previousLineSeparatorIndex ];