diff --git a/assets/src/js/pro/blocks-v3/components/block-edit.js b/assets/src/js/pro/blocks-v3/components/block-edit.js index bf373a11..1c79c890 100644 --- a/assets/src/js/pro/blocks-v3/components/block-edit.js +++ b/assets/src/js/pro/blocks-v3/components/block-edit.js @@ -33,6 +33,7 @@ import { ErrorBoundary, BlockPreviewErrorFallback } from './error-boundary'; import { BlockToolbarFields } from './block-toolbar-fields'; import { InlineEditingToolbar } from './inline-editing-toolbar'; import { PopoverWrapper } from './popover-wrapper'; +import { PreserveContentEditableSelection } from './preserve-content-editable-selection'; import { lockPostSaving, unlockPostSaving, @@ -1490,17 +1491,23 @@ function BlockEditInner( props ) { ) } { /* Render actual preview HTML */ } - { blockPreviewHtml !== 'acf-block-preview-loading' && - blockPreviewHtml !== 'acf-block-preview-no-html' && - blockPreviewHtml && - acf.parseJSX( - blockPreviewHtml, - handleNewInlineEditingElementSelected, - updateFieldValueFromContentEditable, - handleNewContentEditableElementSelected, - blockFieldInfo, - $ - ) } + + { blockPreviewHtml !== + 'acf-block-preview-loading' && + blockPreviewHtml !== + 'acf-block-preview-no-html' && + blockPreviewHtml && + acf.parseJSX( + blockPreviewHtml, + handleNewInlineEditingElementSelected, + updateFieldValueFromContentEditable, + handleNewContentEditableElementSelected, + blockFieldInfo, + $ + ) } + diff --git a/assets/src/js/pro/blocks-v3/components/preserve-content-editable-selection.js b/assets/src/js/pro/blocks-v3/components/preserve-content-editable-selection.js new file mode 100644 index 00000000..55231206 --- /dev/null +++ b/assets/src/js/pro/blocks-v3/components/preserve-content-editable-selection.js @@ -0,0 +1,168 @@ +/** + * PreserveContentEditableSelection Component + * Keeps the caret of an inline text editing element in place while the + * block preview re-renders. + * + * The browser edits the text of a contentEditable element directly, so React + * still holds the text of the previous preview. When the next preview comes + * back from the server, React writes the new text into the text node, and + * writing a text node moves any selection inside it to offset 0 (DOM "replace + * data"), even when the text is unchanged. The caret then jumps to the start + * of the element and the next keystrokes land there. + * + * This component reads the selection as character offsets before React + * updates the DOM and puts it back afterwards. + */ + +/* global NodeFilter */ + +import { Component } from '@wordpress/element'; + +const CONTENT_EDITABLE_SELECTOR = '[data-acf-inline-contenteditable]'; + +/** + * Returns the character offset of a DOM position within an element. + * + * @param {Element} element The contentEditable element. + * @param {Node} node The node of the position. + * @param {number} offset The offset of the position within the node. + * @return {number} The number of characters before the position. + */ +function getCharacterOffset( element, node, offset ) { + const range = element.ownerDocument.createRange(); + range.setStart( element, 0 ); + range.setEnd( node, offset ); + return range.toString().length; +} + +/** + * Returns the DOM position of a character offset within an element. + * An offset past the end of the text resolves to the end of the text. + * + * @param {Element} element The contentEditable element. + * @param {number} characterOffset The number of characters before the position. + * @return {Object} The position, as { node, offset }. + */ +function getDomPosition( element, characterOffset ) { + const walker = element.ownerDocument.createTreeWalker( + element, + NodeFilter.SHOW_TEXT + ); + let remaining = characterOffset; + let lastTextNode = null; + + while ( walker.nextNode() ) { + lastTextNode = walker.currentNode; + if ( remaining <= lastTextNode.length ) { + return { node: lastTextNode, offset: remaining }; + } + remaining -= lastTextNode.length; + } + + if ( lastTextNode ) { + return { node: lastTextNode, offset: lastTextNode.length }; + } + + return { node: element, offset: 0 }; +} + +/** + * Returns the selection in the focused contentEditable element of a block. + * + * @param {Element} container The block element. + * @return {Object|null} The element and the selection's anchor and focus as + * character offsets, or null if no contentEditable + * element of the block has the selection. + */ +function getContentEditableSelection( container ) { + if ( ! container ) { + return null; + } + + const element = container.ownerDocument.activeElement; + if ( + ! element || + ! element.matches( CONTENT_EDITABLE_SELECTOR ) || + ! container.contains( element ) + ) { + return null; + } + + const selection = container.ownerDocument.getSelection(); + if ( + ! selection || + ! selection.rangeCount || + ! element.contains( selection.anchorNode ) || + ! element.contains( selection.focusNode ) + ) { + return null; + } + + return { + element, + anchor: getCharacterOffset( + element, + selection.anchorNode, + selection.anchorOffset + ), + focus: getCharacterOffset( + element, + selection.focusNode, + selection.focusOffset + ), + }; +} + +/** + * Wraps the block preview. A class component, because + * getSnapshotBeforeUpdate() is the only place to read the DOM after rendering + * but before React updates it. + * + * Props: + * - containerRef: ref to the block element, so only the selection of this + * block's own inline editable elements is kept. + */ +export class PreserveContentEditableSelection extends Component { + getSnapshotBeforeUpdate() { + return getContentEditableSelection( this.props.containerRef?.current ); + } + + componentDidUpdate( prevProps, prevState, snapshot ) { + if ( ! snapshot ) { + return; + } + + const { element, anchor, focus } = snapshot; + const { ownerDocument } = element; + + // Leave the selection alone if the element was replaced or lost focus. + if ( + ! element.isConnected || + ownerDocument.activeElement !== element + ) { + return; + } + + const current = getContentEditableSelection( + this.props.containerRef?.current + ); + if ( current && current.anchor === anchor && current.focus === focus ) { + return; + } + + const anchorPosition = getDomPosition( element, anchor ); + const focusPosition = getDomPosition( element, focus ); + ownerDocument + .getSelection() + .setBaseAndExtent( + anchorPosition.node, + anchorPosition.offset, + focusPosition.node, + focusPosition.offset + ); + } + + render() { + return this.props.children; + } +} diff --git a/tests/e2e/block-v3-inline-editing.spec.ts b/tests/e2e/block-v3-inline-editing.spec.ts new file mode 100644 index 00000000..e94dcbcf --- /dev/null +++ b/tests/e2e/block-v3-inline-editing.spec.ts @@ -0,0 +1,114 @@ +/** + * E2E tests for SCF Block V3 inline text editing + * + * Typing into an inline editable element updates the field, and the block + * preview is fetched again and re-rendered. The caret must stay where the + * user is typing through that re-render. + */ +const { test, expect, wpVersionAtLeast } = require( './fixtures' ); + +const PLUGIN_SLUG = 'secure-custom-fields'; +const TEST_PLUGIN_SLUG = 'scf-test-plugin-inline-editing-block'; +const BLOCK_NAME = 'scf/inline-editing-block'; + +const BLOCK_CONTENT = ``; + +test.describe( 'SCF Block V3 Inline Text Editing', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activatePlugin( PLUGIN_SLUG ); + await requestUtils.activatePlugin( TEST_PLUGIN_SLUG ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deactivatePlugin( TEST_PLUGIN_SLUG ); + await requestUtils.deactivatePlugin( PLUGIN_SLUG ); + await requestUtils.deleteAllPosts(); + } ); + + test.beforeEach( async ( { page, admin, requestUtils } ) => { + // ACF block version 3 requires WordPress 6.3+ + await page.goto( '/wp-admin/' ); + test.skip( + ! ( await wpVersionAtLeast( page, 6, 3 ) ), + 'ACF block version 3 requires WordPress 6.3+' + ); + + const post = await requestUtils.createPost( { + title: 'V3 Block Inline Editing Test', + status: 'draft', + content: BLOCK_CONTENT, + } ); + await admin.editPost( post.id ); + } ); + + test( 'keeps the caret at the end of the text when the preview re-renders', async ( { + page, + editor, + } ) => { + const canvas = await editor.canvas; + const block = canvas.locator( '.inline-editing-block' ); + const title = canvas.locator( + '.inline-editing-block__title[contenteditable="true"]' + ); + await expect( title ).toHaveText( 'Hello' ); + + await title.click(); + await page.keyboard.press( 'End' ); + await page.keyboard.type( 'AAA' ); + + // Wait for the preview rendered for the typed value. + await expect( block ).toHaveAttribute( + 'data-rendered-title', + 'HelloAAA' + ); + + await page.keyboard.type( 'BBB' ); + + await expect( block ).toHaveAttribute( + 'data-rendered-title', + 'HelloAAABBB' + ); + await expect( title ).toHaveText( 'HelloAAABBB' ); + } ); + + test( 'keeps the caret in the middle of another field', async ( { + page, + editor, + } ) => { + const canvas = await editor.canvas; + const block = canvas.locator( '.inline-editing-block' ); + const subtitle = canvas.locator( + '.inline-editing-block__subtitle[contenteditable="true"]' + ); + await expect( subtitle ).toHaveText( 'World' ); + + await subtitle.click(); + await page.keyboard.press( 'Home' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.press( 'ArrowRight' ); + await page.keyboard.type( 'X' ); + + await expect( block ).toHaveAttribute( + 'data-rendered-subtitle', + 'WoXrld' + ); + + await page.keyboard.type( 'Y' ); + + await expect( block ).toHaveAttribute( + 'data-rendered-subtitle', + 'WoXYrld' + ); + await expect( subtitle ).toHaveText( 'WoXYrld' ); + await expect( block ).toHaveAttribute( 'data-rendered-title', 'Hello' ); + } ); +} ); diff --git a/tests/e2e/plugins/blocks/scf-inline-editing-block/block.json b/tests/e2e/plugins/blocks/scf-inline-editing-block/block.json new file mode 100644 index 00000000..b6140f74 --- /dev/null +++ b/tests/e2e/plugins/blocks/scf-inline-editing-block/block.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "scf/inline-editing-block", + "title": "SCF Inline Editing Block", + "description": "A test block with inline text editing.", + "category": "text", + "icon": "edit", + "acf": { + "blockVersion": 3, + "mode": "preview", + "renderTemplate": "template.php" + }, + "textdomain": "secure-custom-fields" +} diff --git a/tests/e2e/plugins/blocks/scf-inline-editing-block/template.php b/tests/e2e/plugins/blocks/scf-inline-editing-block/template.php new file mode 100644 index 00000000..402951b3 --- /dev/null +++ b/tests/e2e/plugins/blocks/scf-inline-editing-block/template.php @@ -0,0 +1,19 @@ + +
+ +

>

+ +

>

+
diff --git a/tests/e2e/plugins/scf-test-inline-editing-block.php b/tests/e2e/plugins/scf-test-inline-editing-block.php new file mode 100644 index 00000000..d4a4c0cd --- /dev/null +++ b/tests/e2e/plugins/scf-test-inline-editing-block.php @@ -0,0 +1,65 @@ + 'group_inline_editing_block_fields', + 'title' => 'Inline Editing Block Fields', + 'fields' => array( + array( + 'key' => 'field_inline_editing_title', + 'label' => 'Title', + 'name' => 'title', + 'type' => 'text', + 'default_value' => 'Hello', + ), + array( + 'key' => 'field_inline_editing_subtitle', + 'label' => 'Subtitle', + 'name' => 'subtitle', + 'type' => 'text', + 'default_value' => 'World', + ), + ), + 'location' => array( + array( + array( + 'param' => 'block', + 'operator' => '==', + 'value' => 'scf/inline-editing-block', + ), + ), + ), + ) + ); +} +add_action( 'acf/init', 'scf_test_register_inline_editing_block_fields' ); diff --git a/tests/js/blocks-v3/block-edit.test.js b/tests/js/blocks-v3/block-edit.test.js index 1b866922..7e5b5241 100644 --- a/tests/js/blocks-v3/block-edit.test.js +++ b/tests/js/blocks-v3/block-edit.test.js @@ -526,4 +526,161 @@ describe( 'BlockEdit Component', () => { ).not.toBeInTheDocument(); } ); } ); + + describe( 'inline text editing', () => { + let serverText; + + beforeEach( () => { + // The server renders the field value into an inline editable element. + mockAjax.mockImplementation( () => ( { + done( callback ) { + callback( { + data: { + form: '
Form HTML
', + preview: serverText, + validation: { valid: true }, + }, + } ); + return this; + }, + fail() { + return this; + }, + abort: jest.fn(), + } ) ); + acf.parseJSX.mockImplementation( ( html ) => ( +

+ { html } +

+ ) ); + } ); + + afterEach( () => { + acf.parseJSX.mockImplementation( ( html ) =>
{ html }
); + } ); + + /** + * Renders the block with an inline editable title, places the caret in + * it and types, like the browser does in a contentEditable element. + * Then lets the block fetch the preview of the new value, as the + * server renders it. + * + * @param {Object} options + * @param {string} options.text The initial title. + * @param {number} options.caret Caret offset to type at. + * @param {string} options.typed The typed characters. + * @param {string} options.renderedText The title as the server renders it. + * @return {Element} The inline editable element. + */ + const typeAndRerenderPreview = async ( { + text, + caret, + typed, + renderedText, + } ) => { + serverText = text; + const props = { + ...defaultProps, + attributes: { + ...defaultProps.attributes, + data: { title: text }, + }, + isSelected: true, + }; + let rerender; + await act( async () => { + ( { rerender } = render( ) ); + } ); + await act( async () => { + jest.advanceTimersByTime( 250 ); + } ); + + const editable = screen.getByRole( 'heading' ); + const textNode = editable.firstChild; + expect( textNode.data ).toBe( text ); + editable.focus(); + textNode.insertData( caret, typed ); + editable.ownerDocument + .getSelection() + .collapse( textNode, caret + typed.length ); + + serverText = renderedText; + await act( async () => { + rerender( + + ); + } ); + await act( async () => { + jest.advanceTimersByTime( 250 ); + } ); + + expect( editable ).toHaveTextContent( renderedText ); + return editable; + }; + + const getCaret = ( element ) => { + const selection = element.ownerDocument.getSelection(); + const range = element.ownerDocument.createRange(); + range.setStart( element, 0 ); + range.setEnd( selection.focusNode, selection.focusOffset ); + return { + isCollapsed: selection.isCollapsed, + inElement: element.contains( selection.focusNode ), + offset: range.toString().length, + }; + }; + + test( 'keeps the caret after the typed text when the preview re-renders', async () => { + const editable = await typeAndRerenderPreview( { + text: 'Accessible', + caret: 10, + typed: 'AAA', + renderedText: 'AccessibleAAA', + } ); + + expect( editable ).toHaveFocus(); + expect( getCaret( editable ) ).toEqual( { + isCollapsed: true, + inElement: true, + offset: 13, + } ); + } ); + + test( 'keeps the caret in the middle of the text', async () => { + const editable = await typeAndRerenderPreview( { + text: 'Accessible', + caret: 3, + typed: 'X', + renderedText: 'AccXessible', + } ); + + expect( getCaret( editable ).offset ).toBe( 4 ); + } ); + + test( 'moves the caret to the end when the rendered text is shorter', async () => { + const editable = await typeAndRerenderPreview( { + text: 'Accessible', + caret: 10, + typed: 'AAA', + renderedText: 'Access', + } ); + + expect( getCaret( editable ) ).toEqual( { + isCollapsed: true, + inElement: true, + offset: 6, + } ); + } ); + } ); } );