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,
+ } );
+ } );
+ } );
} );