From c46daf2a701fe3e0881dee06013598c1b8aab769 Mon Sep 17 00:00:00 2001 From: Woji Piskorz Date: Mon, 26 Oct 2026 20:32:00 +0100 Subject: [PATCH] Blocks V3: Keep the caret in inline text editing when the preview re-renders Typing into an inline editable field fetches the block preview again. React still holds the previous preview's text for the contentEditable element, so the commit writes the new text into its text node, and setting nodeValue moves the selection inside it to offset 0: the next keystrokes land at the start of the field. Wrap the parsed preview in PreserveContentEditableSelection, which reads the selection of the focused inline editable element of the block as character offsets in getSnapshotBeforeUpdate() and restores it in componentDidUpdate(). Adds Jest tests in block-edit.test.js and an E2E test with a fixture block using acf_inline_text_editing_attrs(). Co-Authored-By: Claude Opus 5.5 --- .../js/pro/blocks-v3/components/block-edit.js | 29 +-- .../preserve-content-editable-selection.js | 168 ++++++++++++++++++ tests/e2e/block-v3-inline-editing.spec.ts | 114 ++++++++++++ .../scf-inline-editing-block/block.json | 15 ++ .../scf-inline-editing-block/template.php | 19 ++ .../plugins/scf-test-inline-editing-block.php | 65 +++++++ tests/js/blocks-v3/block-edit.test.js | 157 ++++++++++++++++ 7 files changed, 556 insertions(+), 11 deletions(-) create mode 100644 assets/src/js/pro/blocks-v3/components/preserve-content-editable-selection.js create mode 100644 tests/e2e/block-v3-inline-editing.spec.ts create mode 100644 tests/e2e/plugins/blocks/scf-inline-editing-block/block.json create mode 100644 tests/e2e/plugins/blocks/scf-inline-editing-block/template.php create mode 100644 tests/e2e/plugins/scf-test-inline-editing-block.php 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, + } ); + } ); + } ); } );