Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions assets/src/js/pro/blocks-v3/components/block-edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
$
) }
<PreserveContentEditableSelection
containerRef={ previewRef }
>
{ blockPreviewHtml !==
'acf-block-preview-loading' &&
blockPreviewHtml !==
'acf-block-preview-no-html' &&
blockPreviewHtml &&
acf.parseJSX(
blockPreviewHtml,
handleNewInlineEditingElementSelected,
updateFieldValueFromContentEditable,
handleNewContentEditableElementSelected,
blockFieldInfo,
$
) }
</PreserveContentEditableSelection>
</ErrorBoundary>
</BlockPreview>
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
114 changes: 114 additions & 0 deletions tests/e2e/block-v3-inline-editing.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = `<!-- wp:${ BLOCK_NAME } ${ JSON.stringify( {
name: BLOCK_NAME,
data: {
title: 'Hello',
_title: 'field_inline_editing_title',
subtitle: 'World',
_subtitle: 'field_inline_editing_subtitle',
},
mode: 'preview',
} ) } /-->`;

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' );
} );
} );
15 changes: 15 additions & 0 deletions tests/e2e/plugins/blocks/scf-inline-editing-block/block.json
Original file line number Diff line number Diff line change
@@ -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"
}
19 changes: 19 additions & 0 deletions tests/e2e/plugins/blocks/scf-inline-editing-block/template.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php
/**
* Inline Editing Block Template
*
* The wrapper repeats the field values in data attributes, so tests can wait
* until the editor shows the preview rendered for a given value.
*
* @package scf-test-plugins
*/

$block_title = (string) get_field( 'title' );
$block_subtitle = (string) get_field( 'subtitle' );
?>
<div class="inline-editing-block" data-rendered-title="<?php echo esc_attr( $block_title ); ?>" data-rendered-subtitle="<?php echo esc_attr( $block_subtitle ); ?>">
<?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- acf_inline_text_editing_attrs() returns escaped attributes. ?>
<h3 class="inline-editing-block__title" <?php echo acf_inline_text_editing_attrs( 'title' ); ?>><?php echo esc_html( $block_title ); ?></h3>
<?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- acf_inline_text_editing_attrs() returns escaped attributes. ?>
<p class="inline-editing-block__subtitle" <?php echo acf_inline_text_editing_attrs( 'subtitle' ); ?>><?php echo esc_html( $block_subtitle ); ?></p>
</div>
Loading
Loading