diff --git a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts index 9929ebf19d01a1..3b187625fd47cf 100644 --- a/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts +++ b/packages/e2e-test-utils-playwright/src/page-utils/press-keys.ts @@ -20,11 +20,13 @@ import { } from '@wordpress/keycodes'; let clipboardDataHolder: { - plainText: string; - html: string; + 'text/plain': string; + 'text/html': string; + 'rich-text': string; } = { - plainText: '', - html: '', + 'text/plain': '', + 'text/html': '', + 'rich-text': '', }; /** @@ -38,11 +40,12 @@ let clipboardDataHolder: { */ export function setClipboardData( this: PageUtils, - { plainText = '', html = '' }: typeof clipboardDataHolder + { plainText = '', html = '' } ) { clipboardDataHolder = { - plainText, - html, + 'text/plain': plainText, + 'text/html': html, + 'rich-text': '', }; } @@ -57,11 +60,15 @@ async function emulateClipboard( page: Page, type: 'copy' | 'cut' | 'paste' ) { if ( _type === 'paste' ) { clipboardDataTransfer.setData( 'text/plain', - _clipboardData.plainText + _clipboardData[ 'text/plain' ] ); clipboardDataTransfer.setData( 'text/html', - _clipboardData.html + _clipboardData[ 'text/html' ] + ); + clipboardDataTransfer.setData( + 'rich-text', + _clipboardData[ 'rich-text' ] ); } else { const selection = canvasDoc.defaultView.getSelection()!; @@ -91,8 +98,9 @@ async function emulateClipboard( page: Page, type: 'copy' | 'cut' | 'paste' ) { ); return { - plainText: clipboardDataTransfer.getData( 'text/plain' ), - html: clipboardDataTransfer.getData( 'text/html' ), + 'text/plain': clipboardDataTransfer.getData( 'text/plain' ), + 'text/html': clipboardDataTransfer.getData( 'text/html' ), + 'rich-text': clipboardDataTransfer.getData( 'rich-text' ), }; }, [ type, clipboardDataHolder ] as const 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 deleted file mode 100644 index 7705ff11cbff9d..00000000000000 --- a/packages/e2e-tests/specs/editor/various/__snapshots__/rich-text.test.js.snap +++ /dev/null @@ -1,231 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`RichText should apply active formatting for inline paste 1`] = ` -" -

1323

-" -`; - -exports[`RichText should apply formatting when selection is collapsed 1`] = ` -" -

Some bold.

-" -`; - -exports[`RichText should apply formatting with primary shortcut 1`] = ` -" -

test

-" -`; - -exports[`RichText should apply multiple formats when selection is collapsed 1`] = ` -" -

1.

-" -`; - -exports[`RichText should copy/paste heading 1`] = ` -" -

Heading

- - - -

Heading

-" -`; - -exports[`RichText should handle Home and End keys 1`] = ` -" -

-12+

-" -`; - -exports[`RichText should handle change in tag name gracefully 1`] = ` -" -

-" -`; - -exports[`RichText should keep internal selection after blur 1`] = ` -" -

12

-" -`; - -exports[`RichText should make bold after split and merge 1`] = ` -" -

12

-" -`; - -exports[`RichText should navigate arround emoji 1`] = ` -" -

1🍓

-" -`; - -exports[`RichText should navigate consecutive format boundaries 1`] = ` -" -

12

-" -`; - -exports[`RichText should navigate consecutive format boundaries 2`] = ` -" -

1-2

-" -`; - -exports[`RichText should not format text after code backtick 1`] = ` -" -

A backtick and more.

-" -`; - -exports[`RichText should not lose selection direction 1`] = ` -" -

12-3

-" -`; - -exports[`RichText should not split rich text on inline paste 1`] = ` -" -

123

-" -`; - -exports[`RichText should not split rich text on inline paste with formatting 1`] = ` -" -

a123b

-" -`; - -exports[`RichText should not undo backtick transform with backspace after selection change 1`] = `""`; - -exports[`RichText should not undo backtick transform with backspace after typing 1`] = `""`; - -exports[`RichText should only mutate text data on input 1`] = ` -" -

1234

-" -`; - -exports[`RichText should paste list contents into paragraph 1`] = ` -" - - - - - -" -`; - -exports[`RichText should paste paragraph contents into list 1`] = ` -" -

1
2

- - - - -" -`; - -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.

-" -`; - -exports[`RichText should run input rules after composition end 1`] = ` -" -

a

-" -`; - -exports[`RichText should split rich text on paste 1`] = ` -" -

a

- - - -

1

- - - -

2

- - - -

b

-" -`; - -exports[`RichText should transform backtick to code 1`] = ` -" -

A backtick

-" -`; - -exports[`RichText should transform backtick to code 2`] = ` -" -

A \`backtick\`

-" -`; - -exports[`RichText should transform when typing backtick over selection 1`] = ` -" -

A selection test.

-" -`; - -exports[`RichText should transform when typing backtick over selection 2`] = ` -" -

A \`selection\` test.

-" -`; - -exports[`RichText should undo backtick transform with backspace 1`] = ` -" -

\`a\`

-" -`; - -exports[`RichText should update internal selection after fresh focus 1`] = ` -" -

12

-" -`; diff --git a/packages/e2e-tests/specs/editor/various/rich-text.test.js b/packages/e2e-tests/specs/editor/various/rich-text.test.js deleted file mode 100644 index ff651e61d52ea9..00000000000000 --- a/packages/e2e-tests/specs/editor/various/rich-text.test.js +++ /dev/null @@ -1,570 +0,0 @@ -/** - * WordPress dependencies - */ -import { - createNewPost, - getEditedPostContent, - insertBlock, - clickBlockAppender, - pressKeyWithModifier, - showBlockToolbar, - clickBlockToolbarButton, - canvas, -} from '@wordpress/e2e-test-utils'; - -describe( 'RichText', () => { - beforeEach( async () => { - await createNewPost(); - } ); - - it( 'should handle change in tag name gracefully', async () => { - // Regression test: The heading block changes the tag name of its - // RichText element. Historically this has been prone to breakage, - // because the Editable component prevents rerenders, so React cannot - // update the element by itself. - // - // See: https://github.com/WordPress/gutenberg/issues/3091 - await insertBlock( 'Heading' ); - await page.waitForSelector( '[aria-label="Change level"]' ); - await page.click( '[aria-label="Change level"]' ); - await page.click( '[aria-label="Heading 3"]' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should apply formatting with primary shortcut', async () => { - await clickBlockAppender(); - await page.keyboard.type( 'test' ); - await pressKeyWithModifier( 'primary', 'a' ); - await pressKeyWithModifier( 'primary', 'b' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should apply formatting when selection is collapsed', async () => { - await clickBlockAppender(); - await page.keyboard.type( 'Some ' ); - // All following characters should now be bold. - await pressKeyWithModifier( 'primary', 'b' ); - await page.keyboard.type( 'bold' ); - // All following characters should no longer be bold. - await pressKeyWithModifier( 'primary', 'b' ); - await page.keyboard.type( '.' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should apply multiple formats when selection is collapsed', async () => { - await clickBlockAppender(); - await pressKeyWithModifier( 'primary', 'b' ); - await pressKeyWithModifier( 'primary', 'i' ); - await page.keyboard.type( '1' ); - await pressKeyWithModifier( 'primary', 'i' ); - await pressKeyWithModifier( 'primary', 'b' ); - await page.keyboard.type( '.' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should not highlight more than one format', async () => { - await clickBlockAppender(); - await pressKeyWithModifier( 'primary', 'b' ); - await page.keyboard.type( '1' ); - await pressKeyWithModifier( 'primary', 'b' ); - await page.keyboard.type( ' 2' ); - await pressKeyWithModifier( 'shift', 'ArrowLeft' ); - await pressKeyWithModifier( 'primary', 'b' ); - - const count = await canvas().evaluate( - () => - document.querySelectorAll( '*[data-rich-text-format-boundary]' ) - .length - ); - - expect( count ).toBe( 1 ); - } ); - - it( 'should return focus when pressing formatting button', async () => { - await clickBlockAppender(); - await page.keyboard.type( 'Some ' ); - await showBlockToolbar(); - await page.click( '[aria-label="Bold"]' ); - await page.keyboard.type( 'bold' ); - await showBlockToolbar(); - await page.click( '[aria-label="Bold"]' ); - await page.keyboard.type( '.' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should transform backtick to code', async () => { - await clickBlockAppender(); - await page.keyboard.type( 'A `backtick`' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - - await pressKeyWithModifier( 'primary', 'z' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should undo backtick transform with backspace', async () => { - await clickBlockAppender(); - await page.keyboard.type( '`a`' ); - await page.keyboard.press( 'Backspace' ); - - // Expect "`a`" to be restored. - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should not undo backtick transform with backspace after typing', async () => { - await clickBlockAppender(); - await page.keyboard.type( '`a`' ); - await page.keyboard.type( 'b' ); - await page.keyboard.press( 'Backspace' ); - await page.keyboard.press( 'Backspace' ); - - // Expect "a" to be deleted. - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should not undo backtick transform with backspace after selection change', async () => { - await clickBlockAppender(); - await page.keyboard.type( '`a`' ); - await page.evaluate( () => new Promise( window.requestIdleCallback ) ); - // Move inside format boundary. - await page.keyboard.press( 'ArrowLeft' ); - await page.keyboard.press( 'ArrowLeft' ); - await page.keyboard.press( 'ArrowRight' ); - await page.keyboard.press( 'Backspace' ); - - // Expect "a" to be deleted. - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should not format text after code backtick', async () => { - await clickBlockAppender(); - await page.keyboard.type( 'A `backtick` and more.' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should transform when typing backtick over selection', async () => { - await clickBlockAppender(); - await page.keyboard.type( 'A selection test.' ); - await page.keyboard.press( 'Home' ); - await page.keyboard.press( 'ArrowRight' ); - await page.keyboard.press( 'ArrowRight' ); - await pressKeyWithModifier( 'shiftAlt', 'ArrowRight' ); - await page.keyboard.type( '`' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - - // Should undo the transform. - await pressKeyWithModifier( 'primary', 'z' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should only mutate text data on input', async () => { - await clickBlockAppender(); - await page.keyboard.type( '1' ); - await pressKeyWithModifier( 'primary', 'b' ); - await page.keyboard.type( '2' ); - await pressKeyWithModifier( 'primary', 'b' ); - await page.keyboard.type( '3' ); - - await canvas().evaluate( () => { - let called; - const { body } = document; - const config = { - attributes: true, - childList: true, - characterData: true, - subtree: true, - }; - - const mutationObserver = new MutationObserver( ( records ) => { - if ( called || records.length > 1 ) { - throw new Error( 'Typing should only mutate once.' ); - } - - records.forEach( ( record ) => { - if ( record.type !== 'characterData' ) { - throw new Error( - `Typing mutated more than character data: ${ record.type }` - ); - } - } ); - - called = true; - } ); - - mutationObserver.observe( body, config ); - - window.unsubscribes = [ () => mutationObserver.disconnect() ]; - - document.addEventListener( - 'selectionchange', - () => { - function throwMultipleSelectionChange() { - throw new Error( - 'Typing should only emit one selection change event.' - ); - } - - document.addEventListener( - 'selectionchange', - throwMultipleSelectionChange, - { - once: true, - } - ); - - window.unsubscribes.push( () => { - document.removeEventListener( - 'selectionchange', - throwMultipleSelectionChange - ); - } ); - }, - { once: true } - ); - } ); - - await page.keyboard.type( '4' ); - - await canvas().evaluate( () => { - // The selection change event should be called once. If there's only - // one item in `window.unsubscribes`, it means that only one - // function is present to disconnect the `mutationObserver`. - if ( window.unsubscribes.length === 1 ) { - throw new Error( - 'The selection change event listener was never called.' - ); - } - - window.unsubscribes.forEach( ( unsubscribe ) => unsubscribe() ); - } ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should not lose selection direction', async () => { - await clickBlockAppender(); - await pressKeyWithModifier( 'primary', 'b' ); - await page.keyboard.type( '1' ); - await pressKeyWithModifier( 'primary', 'b' ); - await page.keyboard.type( '23' ); - await page.keyboard.press( 'ArrowLeft' ); - await page.keyboard.down( 'Shift' ); - await page.keyboard.press( 'ArrowLeft' ); - await page.keyboard.press( 'ArrowLeft' ); - await page.keyboard.press( 'ArrowRight' ); - await page.keyboard.press( 'ArrowRight' ); - await page.keyboard.up( 'Shift' ); - - // There should be no selection. The following should insert "-" without - // deleting the numbers. - await page.keyboard.type( '-' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should handle Home and End keys', async () => { - await page.keyboard.press( 'Enter' ); - - // Wait for rich text editor to load. - await canvas().waitForSelector( '.block-editor-rich-text__editable' ); - - await pressKeyWithModifier( 'primary', 'b' ); - await page.keyboard.type( '12' ); - await pressKeyWithModifier( 'primary', 'b' ); - - await page.keyboard.press( 'Home' ); - await page.keyboard.type( '-' ); - await page.keyboard.press( 'End' ); - await page.keyboard.type( '+' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should update internal selection after fresh focus', async () => { - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( '1' ); - await page.keyboard.press( 'Tab' ); - await pressKeyWithModifier( 'shift', 'Tab' ); - await pressKeyWithModifier( 'primary', 'b' ); - await page.keyboard.type( '2' ); - await pressKeyWithModifier( 'primary', 'b' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should keep internal selection after blur', async () => { - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( '1' ); - // Simulate moving focus to a different app, then moving focus back, - // without selection being changed. - await canvas().evaluate( () => { - const activeElement = document.activeElement; - activeElement.blur(); - activeElement.focus(); - } ); - // Wait for the next animation frame, see the focus event listener in - // RichText. - await page.evaluate( - () => new Promise( window.requestAnimationFrame ) - ); - await pressKeyWithModifier( 'primary', 'b' ); - await page.keyboard.type( '2' ); - await pressKeyWithModifier( 'primary', 'b' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should split rich text on paste', async () => { - await clickBlockAppender(); - await page.keyboard.type( '1' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( '2' ); - await pressKeyWithModifier( 'primary', 'a' ); - await pressKeyWithModifier( 'primary', 'a' ); - await pressKeyWithModifier( 'primary', 'x' ); - await page.keyboard.type( 'ab' ); - await page.keyboard.press( 'ArrowLeft' ); - await pressKeyWithModifier( 'primary', 'v' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should not split rich text on inline paste', async () => { - await clickBlockAppender(); - await page.keyboard.type( '2' ); - await pressKeyWithModifier( 'primary', 'a' ); - await pressKeyWithModifier( 'primary', 'x' ); - await page.keyboard.type( '13' ); - await page.keyboard.press( 'ArrowLeft' ); - await pressKeyWithModifier( 'primary', 'v' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should not split rich text on inline paste with formatting', async () => { - await clickBlockAppender(); - await page.keyboard.type( '1' ); - await pressKeyWithModifier( 'primary', 'b' ); - await page.keyboard.type( '2' ); - await pressKeyWithModifier( 'primary', 'b' ); - await page.keyboard.type( '3' ); - await pressKeyWithModifier( 'primary', 'a' ); - await pressKeyWithModifier( 'primary', 'x' ); - await page.keyboard.type( 'ab' ); - await page.keyboard.press( 'ArrowLeft' ); - await pressKeyWithModifier( 'primary', 'v' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should make bold after split and merge', async () => { - await clickBlockAppender(); - await page.keyboard.type( '1' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.press( 'Backspace' ); - await pressKeyWithModifier( 'primary', 'b' ); - await page.keyboard.type( '2' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should apply active formatting for inline paste', async () => { - await clickBlockAppender(); - await pressKeyWithModifier( 'primary', 'b' ); - await page.keyboard.type( '1' ); - await page.keyboard.type( '2' ); - await pressKeyWithModifier( 'primary', 'b' ); - await page.keyboard.type( '3' ); - await pressKeyWithModifier( 'shift', 'ArrowLeft' ); - await pressKeyWithModifier( 'primary', 'c' ); - await page.keyboard.press( 'ArrowLeft' ); - await page.keyboard.press( 'ArrowLeft' ); - await page.keyboard.press( 'ArrowLeft' ); - await pressKeyWithModifier( 'primary', 'v' ); - - 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[text()='Highlight']` - ); - // Clicks may fail if the button is out of view. Assure it is before click. - await button.evaluate( ( element ) => element.scrollIntoView() ); - await button.click(); - - // Wait for the popover with "Text" tab to appear. - await page.waitForXPath( - '//button[@role="tab"][@aria-selected="true"][text()="Text"]' - ); - // Initial focus is on the "Text" tab. - // Tab to the "Custom color picker". - await page.keyboard.press( 'Tab' ); - // Tab to black. - await page.keyboard.press( 'Tab' ); - // Select color other than black. - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Enter' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - - // Dismiss color picker popover. - await page.keyboard.press( 'Escape' ); - - // Navigate to the block. - await page.keyboard.press( 'Tab' ); - - // Copy the colored text. - await pressKeyWithModifier( 'primary', 'c' ); - - // Collapse 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(); - } ); - - it( 'should paste paragraph contents into list', async () => { - await clickBlockAppender(); - - // Create two lines of text in a paragraph. - await page.keyboard.type( '1' ); - await pressKeyWithModifier( 'shift', 'Enter' ); - await page.keyboard.type( '2' ); - - // Select all and copy. - await pressKeyWithModifier( 'primary', 'a' ); - await pressKeyWithModifier( 'primary', 'c' ); - - // Collapse the selection to the end. - await page.keyboard.press( 'ArrowRight' ); - - // Create a list. - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( '* ' ); - - // Paste paragraph contents. - await pressKeyWithModifier( 'primary', 'v' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should paste list contents into paragraph', async () => { - await clickBlockAppender(); - - // Create an indented list of two lines. - await page.keyboard.type( '* 1' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( ' 2' ); - - // Select all text. - await pressKeyWithModifier( 'primary', 'a' ); - // Select the nested list. - await pressKeyWithModifier( 'primary', 'a' ); - // Select the parent list item. - await pressKeyWithModifier( 'primary', 'a' ); - // Select all the parent list item text. - await pressKeyWithModifier( 'primary', 'a' ); - // Select the entire list. - await pressKeyWithModifier( 'primary', 'a' ); - await pressKeyWithModifier( 'primary', 'c' ); - - await page.keyboard.press( 'Enter' ); - - // Paste paragraph contents. - await pressKeyWithModifier( 'primary', 'v' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should navigate arround emoji', async () => { - await clickBlockAppender(); - await page.keyboard.type( '🍓' ); - // Only one press on arrow left should be required to move in front of - // the emoji. - await page.keyboard.press( 'ArrowLeft' ); - await page.keyboard.type( '1' ); - - // Expect '1🍓'. - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should run input rules after composition end', async () => { - await clickBlockAppender(); - // Puppeteer doesn't support composition, so emulate it by inserting - // text in the DOM directly, setting selection in the right place, and - // firing `compositionend`. - // See https://github.com/puppeteer/puppeteer/issues/4981. - await canvas().evaluate( async () => { - document.activeElement.textContent = '`a`'; - const selection = window.getSelection(); - // The `selectionchange` and `compositionend` events should run in separate event - // loop ticks to process all data store updates in time. Native events would be - // scheduled the same way. - selection.selectAllChildren( document.activeElement ); - selection.collapseToEnd(); - await new Promise( ( r ) => setTimeout( r, 0 ) ); - document.activeElement.dispatchEvent( - new CompositionEvent( 'compositionend' ) - ); - } ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - it( 'should navigate consecutive format boundaries', async () => { - await clickBlockAppender(); - await pressKeyWithModifier( 'primary', 'b' ); - await page.keyboard.type( '1' ); - await pressKeyWithModifier( 'primary', 'b' ); - await pressKeyWithModifier( 'primary', 'i' ); - await page.keyboard.type( '2' ); - await pressKeyWithModifier( 'primary', 'i' ); - - expect( await getEditedPostContent() ).toMatchSnapshot(); - - // Should move into the second format. - await page.keyboard.press( 'ArrowLeft' ); - // Should move to the start of the second format. - await page.keyboard.press( 'ArrowLeft' ); - // Should move between the first and second format. - await page.keyboard.press( 'ArrowLeft' ); - - await page.keyboard.type( '-' ); - - // Expect: 1-2 - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); - - test( 'should copy/paste heading', async () => { - await insertBlock( 'Heading' ); - await page.keyboard.type( 'Heading' ); - await pressKeyWithModifier( 'primary', 'a' ); - await pressKeyWithModifier( 'primary', 'c' ); - await page.keyboard.press( 'ArrowRight' ); - await page.keyboard.press( 'Enter' ); - await pressKeyWithModifier( 'primary', 'v' ); - expect( await getEditedPostContent() ).toMatchSnapshot(); - } ); -} ); diff --git a/test/e2e/specs/editor/various/rich-text.spec.js b/test/e2e/specs/editor/various/rich-text.spec.js new file mode 100644 index 00000000000000..75c8d40e191979 --- /dev/null +++ b/test/e2e/specs/editor/various/rich-text.spec.js @@ -0,0 +1,826 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'RichText', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test( 'should handle change in tag name gracefully', async ( { + page, + editor, + } ) => { + // Regression test: The heading block changes the tag name of its + // RichText element. Historically this has been prone to breakage, + // because the Editable component prevents rerenders, so React cannot + // update the element by itself. + // + // See: https://github.com/WordPress/gutenberg/issues/3091 + await editor.insertBlock( { name: 'core/heading' } ); + await editor.clickBlockToolbarButton( 'Change level' ); + await page.locator( 'button[aria-label="Heading 3"]' ).click(); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/heading', + attributes: { level: 3 }, + }, + ] ); + } ); + + test( 'should apply formatting with primary shortcut', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( 'test' ); + await pageUtils.pressKeys( 'primary+a' ); + await pageUtils.pressKeys( 'primary+b' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'test' }, + }, + ] ); + } ); + + test( 'should apply formatting when selection is collapsed', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( 'Some ' ); + await pageUtils.pressKeys( 'primary+b' ); + await page.keyboard.type( 'bold' ); + await pageUtils.pressKeys( 'primary+b' ); + await page.keyboard.type( '.' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Some bold.' }, + }, + ] ); + } ); + + test( 'should apply multiple formats when selection is collapse', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await pageUtils.pressKeys( 'primary+b' ); + await pageUtils.pressKeys( 'primary+i' ); + await page.keyboard.type( '1' ); + await pageUtils.pressKeys( 'primary+i' ); + await pageUtils.pressKeys( 'primary+b' ); + await page.keyboard.type( '.' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: '1.' }, + }, + ] ); + } ); + + test( 'should not highlight more than one format', async ( { + page, + editor, + pageUtils, + } ) => { + await page.keyboard.press( 'Enter' ); + await pageUtils.pressKeys( 'primary+b' ); + await page.keyboard.type( '1' ); + await pageUtils.pressKeys( 'primary+b' ); + await page.keyboard.type( ' 2' ); + await pageUtils.pressKeys( 'shift+ArrowLeft' ); + await pageUtils.pressKeys( 'primary+b' ); + + const count = await editor.canvas.evaluate( + () => + document.querySelectorAll( '*[data-rich-text-format-boundary]' ) + .length + ); + expect( count ).toBe( 1 ); + } ); + + test( 'should return focus when pressing formatting button', async ( { + page, + editor, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( 'Some ' ); + await editor.clickBlockToolbarButton( 'Bold' ); + await page.keyboard.type( 'bold' ); + await editor.clickBlockToolbarButton( 'Bold' ); + await page.keyboard.type( '.' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'Some bold.' }, + }, + ] ); + } ); + + test( 'should transform backtick to code', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( 'A `backtick`' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'A backtick' }, + }, + ] ); + + await pageUtils.pressKeys( 'primary+z' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { name: 'core/paragraph' }, + ] ); + } ); + + test( 'should undo backtick transform with backspace', async ( { + page, + editor, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '`a`' ); + await page.keyboard.press( 'Backspace' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: '`a`' }, + }, + ] ); + } ); + + test( 'should not undo backtick transform with backspace after typing', async ( { + page, + editor, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '`a`' ); + await page.keyboard.type( 'b' ); + await page.keyboard.press( 'Backspace' ); + await page.keyboard.press( 'Backspace' ); + + expect( await editor.getBlocks() ).toMatchObject( [] ); + } ); + + test( 'should not undo backtick transform with backspace after selection change', async ( { + page, + editor, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '`a`' ); + await page.evaluate( () => new Promise( window.requestIdleCallback ) ); + // Move inside format boundary. + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'Backspace' ); + + expect( await editor.getBlocks() ).toMatchObject( [] ); + } ); + + test( 'should not format text after code backtick', async ( { + page, + editor, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( 'A `backtick` and more.' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'A backtick and more.' }, + }, + ] ); + } ); + + test( 'should transform when typing backtick over selection', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( 'A selection test.' ); + await page.keyboard.press( 'Home' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'ArrowRight' ); + await pageUtils.pressKeys( 'shiftAlt+ArrowRight' ); + await page.keyboard.type( '`' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'A selection test.' }, + }, + ] ); + + await pageUtils.pressKeys( 'primary+z' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'A `selection` test.' }, + }, + ] ); + } ); + + test( 'should only mutate text data on input', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '1' ); + await pageUtils.pressKeys( 'primary+b' ); + await page.keyboard.type( '2' ); + await pageUtils.pressKeys( 'primary+b' ); + await page.keyboard.type( '3' ); + + await editor.canvas.evaluate( () => { + let called; + const { body } = document; + const config = { + attributes: true, + childList: true, + characterData: true, + subtree: true, + }; + + const mutationObserver = new MutationObserver( ( records ) => { + if ( called || records.length > 1 ) { + throw new Error( 'Typing should only mutate once.' ); + } + + records.forEach( ( record ) => { + if ( record.type !== 'characterData' ) { + throw new Error( + `Typing mutated more than character data: ${ record.type }` + ); + } + } ); + + called = true; + } ); + + mutationObserver.observe( body, config ); + + window.unsubscribes = [ () => mutationObserver.disconnect() ]; + + document.addEventListener( + 'selectionchange', + () => { + function throwMultipleSelectionChange() { + throw new Error( + 'Typing should only emit one selection change event.' + ); + } + + document.addEventListener( + 'selectionchange', + throwMultipleSelectionChange, + { + once: true, + } + ); + + window.unsubscribes.push( () => { + document.removeEventListener( + 'selectionchange', + throwMultipleSelectionChange + ); + } ); + }, + { once: true } + ); + } ); + + await page.keyboard.type( '4' ); + + await editor.canvas.evaluate( () => { + // The selection change event should be called once. If there's only + // one item in `window.unsubscribes`, it means that only one + // function is present to disconnect the `mutationObserver`. + if ( window.unsubscribes.length === 1 ) { + throw new Error( + 'The selection change event listener was never called.' + ); + } + + window.unsubscribes.forEach( ( unsubscribe ) => unsubscribe() ); + } ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: '1234' }, + }, + ] ); + } ); + + test( 'should not lose selection direction', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await pageUtils.pressKeys( 'primary+b' ); + await page.keyboard.type( '1' ); + await pageUtils.pressKeys( 'primary+b' ); + await page.keyboard.type( '23' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.down( 'Shift' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.up( 'Shift' ); + + // There should be no selection. The following should insert "-" without + // deleting the numbers. + await page.keyboard.type( '-' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: '12-3' }, + }, + ] ); + } ); + + test( 'should handle Home and End keys', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await pageUtils.pressKeys( 'primary+b' ); + await page.keyboard.type( '12' ); + await pageUtils.pressKeys( 'primary+b' ); + await page.keyboard.press( 'Home' ); + await page.keyboard.type( '-' ); + await page.keyboard.press( 'End' ); + await page.keyboard.type( '+' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: '-12+' }, + }, + ] ); + } ); + + test( 'should update internal selection after fresh focus', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '1' ); + await page.keyboard.press( 'Tab' ); + await pageUtils.pressKeys( 'shift+Tab' ); + await pageUtils.pressKeys( 'primary+b' ); + await page.keyboard.type( '2' ); + await pageUtils.pressKeys( 'primary+b' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: '12' }, + }, + ] ); + } ); + + test( 'should keep internal selection after blur', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '1' ); + // Simulate moving focus to a different app, then moving focus back, + // without selection being changed. + await editor.canvas.evaluate( () => { + const activeElement = document.activeElement; + activeElement.blur(); + activeElement.focus(); + } ); + // Wait for the next animation frame, see the focus event listener in + // RichText. + await page.evaluate( + () => new Promise( window.requestAnimationFrame ) + ); + await pageUtils.pressKeys( 'primary+b' ); + await page.keyboard.type( '2' ); + await pageUtils.pressKeys( 'primary+b' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: '12' }, + }, + ] ); + } ); + + test( 'should split rich text on paste', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '1' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '2' ); + await pageUtils.pressKeys( 'primary+a' ); + await pageUtils.pressKeys( 'primary+a' ); + await pageUtils.pressKeys( 'primary+x' ); + await page.keyboard.type( 'ab' ); + await page.keyboard.press( 'ArrowLeft' ); + await pageUtils.pressKeys( 'primary+v' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'a' }, + }, + { + name: 'core/paragraph', + attributes: { content: '1' }, + }, + { + name: 'core/paragraph', + attributes: { content: '2' }, + }, + { + name: 'core/paragraph', + attributes: { content: 'b' }, + }, + ] ); + } ); + + test( 'should not split rich text on inline paste', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '2' ); + await pageUtils.pressKeys( 'primary+a' ); + await pageUtils.pressKeys( 'primary+x' ); + await page.keyboard.type( '13' ); + await page.keyboard.press( 'ArrowLeft' ); + await pageUtils.pressKeys( 'primary+v' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: '123' }, + }, + ] ); + } ); + + test( 'should not split rich text on inline paste with formatting', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '1' ); + await pageUtils.pressKeys( 'primary+b' ); + await page.keyboard.type( '2' ); + await pageUtils.pressKeys( 'primary+b' ); + await page.keyboard.type( '3' ); + await pageUtils.pressKeys( 'primary+a' ); + await pageUtils.pressKeys( 'primary+x' ); + await page.keyboard.type( 'ab' ); + await page.keyboard.press( 'ArrowLeft' ); + await pageUtils.pressKeys( 'primary+v' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'a123b' }, + }, + ] ); + } ); + + test( 'should make bold after split and merge', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '1' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.press( 'Backspace' ); + await pageUtils.pressKeys( 'primary+b' ); + await page.keyboard.type( '2' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: '12' }, + }, + ] ); + } ); + + test( 'should apply active formatting for inline paste', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await pageUtils.pressKeys( 'primary+b' ); + await page.keyboard.type( '1' ); + await page.keyboard.type( '2' ); + await pageUtils.pressKeys( 'primary+b' ); + await page.keyboard.type( '3' ); + await pageUtils.pressKeys( 'shift+ArrowLeft' ); + await pageUtils.pressKeys( 'primary+c' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.press( 'ArrowLeft' ); + await pageUtils.pressKeys( 'primary+v' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: '1323' }, + }, + ] ); + } ); + + test( 'should preserve internal formatting', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + + // Add text and select to color. + await page.keyboard.type( '1' ); + await pageUtils.pressKeys( 'primary+a' ); + await editor.clickBlockToolbarButton( 'More' ); + await page.locator( 'button:text("Highlight")' ).click(); + + // Initial focus is on the "Text" tab. + // Tab to the "Custom color picker". + await page.keyboard.press( 'Tab' ); + // Tab to black. + await page.keyboard.press( 'Tab' ); + // Select color other than black. + await page.keyboard.press( 'Tab' ); + await page.keyboard.press( 'Enter' ); + + const result = { + name: 'core/paragraph', + attributes: { + content: + '1', + }, + }; + + expect( await editor.getBlocks() ).toMatchObject( [ result ] ); + + // Dismiss color picker popover. + await page.keyboard.press( 'Escape' ); + + // Navigate to the block. + await page.keyboard.press( 'Tab' ); + + // Copy the colored text. + await pageUtils.pressKeys( 'primary+c' ); + + // Collapse the selection to the end. + await page.keyboard.press( 'ArrowRight' ); + + // Create a new paragraph. + await page.keyboard.press( 'Enter' ); + + // Paste the colored text. + await pageUtils.pressKeys( 'primary+v' ); + + expect( await editor.getBlocks() ).toMatchObject( + Array( 2 ).fill( result ) + ); + } ); + + test( 'should paste paragraph contents into list', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + // Create two lines of text in a paragraph. + await page.keyboard.type( '1' ); + await pageUtils.pressKeys( 'shift+Enter' ); + await page.keyboard.type( '2' ); + + // Select all and copy. + await pageUtils.pressKeys( 'primary+a' ); + await pageUtils.pressKeys( 'primary+c' ); + + // Collapse the selection to the end. + await page.keyboard.press( 'ArrowRight' ); + + // Create a list. + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( '* ' ); + + // Paste paragraph contents. + await pageUtils.pressKeys( 'primary+v' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: '1
2' }, + }, + { + name: 'core/list', + innerBlocks: [ + { + name: 'core/list-item', + attributes: { content: '1' }, + }, + { + name: 'core/list-item', + attributes: { content: '2' }, + }, + ], + }, + ] ); + } ); + + test( 'should paste list contents into paragraph', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + + // Create an indented list of two lines. + await page.keyboard.type( '* 1' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( ' 2' ); + + // Select all text. + await pageUtils.pressKeys( 'primary+a' ); + // Select the nested list. + await pageUtils.pressKeys( 'primary+a' ); + // Select the parent list item. + await pageUtils.pressKeys( 'primary+a' ); + // Select all the parent list item text. + await pageUtils.pressKeys( 'primary+a' ); + // Select the entire list. + await pageUtils.pressKeys( 'primary+a' ); + await pageUtils.pressKeys( 'primary+c' ); + + await page.keyboard.press( 'Enter' ); + + // Paste paragraph contents. + await pageUtils.pressKeys( 'primary+v' ); + + expect( await editor.getBlocks() ).toMatchObject( + Array( 2 ).fill( { + name: 'core/list', + innerBlocks: [ + { + name: 'core/list-item', + attributes: { content: '1' }, + innerBlocks: [ + { + name: 'core/list', + innerBlocks: [ + { + name: 'core/list-item', + attributes: { content: '2' }, + }, + ], + }, + ], + }, + ], + } ) + ); + } ); + + test( 'should navigate arround emoji', async ( { page, editor } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await page.keyboard.type( '🍓' ); + // Only one press on arrow left should be required to move in front of + // the emoji. + await page.keyboard.press( 'ArrowLeft' ); + await page.keyboard.type( '1' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: '1🍓' }, + }, + ] ); + } ); + + test( 'should run input rules after composition end', async ( { + editor, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + // Playwright doesn't support composition, so emulate it by inserting + // text in the DOM directly, setting selection in the right place, and + // firing `compositionend`. + // See https://github.com/puppeteer/puppeteer/issues/4981. + await editor.canvas.evaluate( async () => { + document.activeElement.textContent = '`a`'; + const selection = window.getSelection(); + // The `selectionchange` and `compositionend` events should run in separate event + // loop ticks to process all data store updates in time. Native events would be + // scheduled the same way. + selection.selectAllChildren( document.activeElement ); + selection.collapseToEnd(); + await new Promise( ( r ) => setTimeout( r, 0 ) ); + document.activeElement.dispatchEvent( + new CompositionEvent( 'compositionend' ) + ); + } ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'a' }, + }, + ] ); + } ); + + test( 'should navigate consecutive format boundaries', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.canvas.click( 'role=button[name="Add default block"i]' ); + await pageUtils.pressKeys( 'primary+b' ); + await page.keyboard.type( '1' ); + await pageUtils.pressKeys( 'primary+b' ); + await pageUtils.pressKeys( 'primary+i' ); + await page.keyboard.type( '2' ); + await pageUtils.pressKeys( 'primary+i' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: '12' }, + }, + ] ); + + // Should move into the second format. + await page.keyboard.press( 'ArrowLeft' ); + // Should move to the start of the second format. + await page.keyboard.press( 'ArrowLeft' ); + // Should move between the first and second format. + await page.keyboard.press( 'ArrowLeft' ); + + await page.keyboard.type( '-' ); + + expect( await editor.getBlocks() ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: '1-2' }, + }, + ] ); + } ); + + test( 'should copy/paste heading', async ( { + page, + editor, + pageUtils, + } ) => { + await editor.insertBlock( { name: 'core/heading' } ); + await page.keyboard.type( 'Heading' ); + await pageUtils.pressKeys( 'primary+a' ); + await pageUtils.pressKeys( 'primary+c' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'Enter' ); + await pageUtils.pressKeys( 'primary+v' ); + + expect( await editor.getBlocks() ).toMatchObject( + Array( 2 ).fill( { + name: 'core/heading', + attributes: { content: 'Heading' }, + } ) + ); + } ); +} );