diff --git a/packages/block-editor/src/components/block-list/block-html.js b/packages/block-editor/src/components/block-list/block-html.js index c670133de17c41..fd724258ada1d8 100644 --- a/packages/block-editor/src/components/block-list/block-html.js +++ b/packages/block-editor/src/components/block-list/block-html.js @@ -20,9 +20,11 @@ import { * Internal dependencies */ import { store as blockEditorStore } from '../../store'; +import { useNativeUndo } from '../../utils/native-undo'; function BlockHTML( { clientId } ) { const [ html, setHtml ] = useState( '' ); + const nativeUndoRef = useNativeUndo(); const block = useSelect( ( select ) => select( blockEditorStore ).getBlock( clientId ), [ clientId ] @@ -83,6 +85,9 @@ function BlockHTML( { clientId } ) { value={ html } onBlur={ onChange } onChange={ ( event ) => setHtml( event.target.value ) } + // The edits are local state until committed on blur, so undo + // and redo must remain the browser's own within the field. + ref={ nativeUndoRef } /> ); } diff --git a/packages/block-editor/src/components/link-control/index.js b/packages/block-editor/src/components/link-control/index.js index 075036a47dec82..cf6c501d74286b 100644 --- a/packages/block-editor/src/components/link-control/index.js +++ b/packages/block-editor/src/components/link-control/index.js @@ -16,7 +16,7 @@ import { } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import { useRef, useState, useEffect, useMemo } from '@wordpress/element'; -import { useInstanceId } from '@wordpress/compose'; +import { useInstanceId, useMergeRefs } from '@wordpress/compose'; import { focus } from '@wordpress/dom'; import { ENTER } from '@wordpress/keycodes'; import { isShallowEqualObjects } from '@wordpress/is-shallow-equal'; @@ -29,6 +29,7 @@ import { isURL, prependHTTPS } from '@wordpress/url'; /** * Internal dependencies */ +import { useNativeUndo } from '../../utils/native-undo'; import LinkControlSettingsDrawer from './settings-drawer'; import LinkControlSearchInput from './search-input'; import LinkPreview from './link-preview'; @@ -227,6 +228,7 @@ function LinkControl( { const isMountingRef = useRef( true ); const wrapperNode = useRef(); + const wrapperRef = useMergeRefs( [ wrapperNode, useNativeUndo() ] ); const textInputRef = useRef(); const searchInputRef = useRef(); // TODO: Remove entityUrlFallbackRef and previewValue in favor of value prop after taxonomy entity binding @@ -651,7 +653,10 @@ function LinkControl( { return (
{ isCreatingPage && ( diff --git a/packages/block-editor/src/private-apis.js b/packages/block-editor/src/private-apis.js index ed1b03200d4223..82508789683ab2 100644 --- a/packages/block-editor/src/private-apis.js +++ b/packages/block-editor/src/private-apis.js @@ -83,6 +83,7 @@ import useRemoteUrlData from './components/link-control/use-rich-url-data'; import { PrivateBlockContext } from './components/block-list/private-block-context'; import useListViewPanelState from './components/use-list-view-panel-state'; import InnerContent from './components/inner-content'; +import { useNativeUndo, usesNativeUndo } from './utils/native-undo'; import { isHashLink, isRelativePath, @@ -163,4 +164,6 @@ lock( privateApis, { isHashLink, isRelativePath, InnerContent, + useNativeUndo, + usesNativeUndo, } ); diff --git a/packages/block-editor/src/utils/native-undo.js b/packages/block-editor/src/utils/native-undo.js new file mode 100644 index 00000000000000..6b14de50b4cf17 --- /dev/null +++ b/packages/block-editor/src/utils/native-undo.js @@ -0,0 +1,56 @@ +/** + * WordPress dependencies + */ +import { useRefEffect } from '@wordpress/compose'; + +/** + * Elements whose content the browser's own undo acts on (a dialog input, an + * HTML editing field, a search field), registered through `useNativeUndo`. + * The editor history must not act on events originating within them. + */ +const nativeUndoNodes = new WeakSet(); + +/** + * Returns a ref that marks an element (and its descendants) as relying on + * the browser's own undo, so the editor history leaves the undo and redo + * shortcuts alone within it. + * + * @return {Function} Ref callback. + */ +export function useNativeUndo() { + return useRefEffect( ( node ) => { + nativeUndoNodes.add( node ); + return () => { + nativeUndoNodes.delete( node ); + }; + }, [] ); +} + +/** + * Returns true when the event originates from an element registered through + * `useNativeUndo`. + * + * Events coming from the editor canvas are re-dispatched on the iframe + * element, so the real target is resolved through the frame's focused + * element. + * + * @param {KeyboardEvent} event Keyboard event. + * + * @return {boolean} Whether the element relies on the browser's own undo. + */ +export function usesNativeUndo( event ) { + let { target } = event; + + if ( target?.nodeName === 'IFRAME' ) { + target = target.contentDocument?.activeElement; + } + + while ( target ) { + if ( nativeUndoNodes.has( target ) ) { + return true; + } + target = target.parentElement; + } + + return false; +} diff --git a/packages/block-library/src/html/modal.js b/packages/block-library/src/html/modal.js index a4b97d4c4f4c06..4ee1a228075c2f 100644 --- a/packages/block-library/src/html/modal.js +++ b/packages/block-library/src/html/modal.js @@ -13,7 +13,11 @@ import { __experimentalVStack as VStack, __experimentalHStack as HStack, } from '@wordpress/components'; -import { PlainText, store as blockEditorStore } from '@wordpress/block-editor'; +import { + PlainText, + store as blockEditorStore, + privateApis as blockEditorPrivateApis, +} from '@wordpress/block-editor'; import { fullscreen, square } from '@wordpress/icons'; import { useViewportMatch } from '@wordpress/compose'; @@ -25,6 +29,7 @@ import Preview from './preview'; import { parseContent, serializeContent } from './utils'; const { Tabs } = unlock( componentsPrivateApis ); +const { useNativeUndo } = unlock( blockEditorPrivateApis ); export default function HTMLEditModal( { onRequestClose, content, onUpdate } ) { // Parse content into separate sections and use as initial state @@ -33,6 +38,9 @@ export default function HTMLEditModal( { onRequestClose, content, onUpdate } ) { const [ editedCss, setEditedCss ] = useState( css ); const [ editedJs, setEditedJs ] = useState( js ); const [ isFullscreen, setIsFullscreen ] = useState( false ); + // The fields hold local state that is only committed when the dialog is + // saved, so undo and redo within them must remain the browser's own. + const nativeUndoRef = useNativeUndo(); const isMobileViewport = useViewportMatch( 'small', '<' ); @@ -132,7 +140,10 @@ export default function HTMLEditModal( { onRequestClose, content, onUpdate } ) { align="stretch" gap={ 8 } > -
+
{ + if ( usesNativeUndo( event ) ) { + return; + } undo(); event.preventDefault(); } ); useShortcut( 'core/customize-widgets/redo', ( event ) => { + if ( usesNativeUndo( event ) ) { + return; + } redo(); event.preventDefault(); } ); diff --git a/packages/e2e-tests/plugins/meta-box.php b/packages/e2e-tests/plugins/meta-box.php index 37e2ab71c52e79..19921febaa5995 100644 --- a/packages/e2e-tests/plugins/meta-box.php +++ b/packages/e2e-tests/plugins/meta-box.php @@ -12,6 +12,8 @@ */ function gutenberg_test_meta_box_render_meta_box() { echo 'Hello World'; + echo ''; + echo ''; } /** diff --git a/packages/edit-post/src/components/meta-boxes/meta-boxes-area/index.js b/packages/edit-post/src/components/meta-boxes/meta-boxes-area/index.js index 525d633bf9071d..e0704b85adc1a1 100644 --- a/packages/edit-post/src/components/meta-boxes/meta-boxes-area/index.js +++ b/packages/edit-post/src/components/meta-boxes/meta-boxes-area/index.js @@ -9,11 +9,15 @@ import clsx from 'clsx'; import { useRef, useEffect } from '@wordpress/element'; import { Spinner } from '@wordpress/components'; import { useSelect } from '@wordpress/data'; +import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; /** * Internal dependencies */ import { store as editPostStore } from '../../../store'; +import { unlock } from '../../../lock-unlock'; + +const { useNativeUndo } = unlock( blockEditorPrivateApis ); /** * Render metabox area. @@ -52,8 +56,12 @@ function MetaBoxesArea( { location } ) { 'is-loading': isSaving, } ); + // Meta box fields are not part of the editor history, so undo and redo + // within them must remain the browser's own. + const nativeUndoRef = useNativeUndo(); + return ( -
+
{ isSaving && ( ) } diff --git a/packages/edit-widgets/src/components/keyboard-shortcuts/index.js b/packages/edit-widgets/src/components/keyboard-shortcuts/index.js index 65ecceeb4aa8c1..fbfe09ee6c2a40 100644 --- a/packages/edit-widgets/src/components/keyboard-shortcuts/index.js +++ b/packages/edit-widgets/src/components/keyboard-shortcuts/index.js @@ -10,22 +10,32 @@ import { isAppleOS } from '@wordpress/keycodes'; import { useDispatch } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import { store as coreStore } from '@wordpress/core-data'; +import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; /** * Internal dependencies */ import { store as editWidgetsStore } from '../../store'; +import { unlock } from '../../lock-unlock'; + +const { usesNativeUndo } = unlock( blockEditorPrivateApis ); function KeyboardShortcuts() { const { redo, undo } = useDispatch( coreStore ); const { saveEditedWidgetAreas } = useDispatch( editWidgetsStore ); useShortcut( 'core/edit-widgets/undo', ( event ) => { + if ( usesNativeUndo( event ) ) { + return; + } undo(); event.preventDefault(); } ); useShortcut( 'core/edit-widgets/redo', ( event ) => { + if ( usesNativeUndo( event ) ) { + return; + } redo(); event.preventDefault(); } ); diff --git a/packages/editor/src/components/global-keyboard-shortcuts/index.js b/packages/editor/src/components/global-keyboard-shortcuts/index.js index 26bf21e5917ca1..4210c35a1ea4a5 100644 --- a/packages/editor/src/components/global-keyboard-shortcuts/index.js +++ b/packages/editor/src/components/global-keyboard-shortcuts/index.js @@ -4,12 +4,18 @@ import { useShortcut } from '@wordpress/keyboard-shortcuts'; import { useDispatch, useSelect } from '@wordpress/data'; import { store as interfaceStore } from '@wordpress/interface'; -import { store as blockEditorStore } from '@wordpress/block-editor'; +import { + store as blockEditorStore, + privateApis as blockEditorPrivateApis, +} from '@wordpress/block-editor'; /** * Internal dependencies */ import { store as editorStore } from '../../store'; +import { unlock } from '../../lock-unlock'; + +const { usesNativeUndo } = unlock( blockEditorPrivateApis ); /** * Handles the keyboard shortcuts for the editor. @@ -61,11 +67,17 @@ export default function EditorKeyboardShortcuts() { } ); useShortcut( 'core/editor/undo', ( event ) => { + if ( usesNativeUndo( event ) ) { + return; + } undo(); event.preventDefault(); } ); useShortcut( 'core/editor/redo', ( event ) => { + if ( usesNativeUndo( event ) ) { + return; + } redo(); event.preventDefault(); } ); diff --git a/test/e2e/specs/editor/plugins/meta-boxes.spec.js b/test/e2e/specs/editor/plugins/meta-boxes.spec.js index 511b3837f80303..03f0bcc4bb9b27 100644 --- a/test/e2e/specs/editor/plugins/meta-boxes.spec.js +++ b/test/e2e/specs/editor/plugins/meta-boxes.spec.js @@ -38,6 +38,44 @@ test.describe( 'Meta boxes', () => { await expect( saveDraft ).toBeEnabled(); } ); + test( 'should leave undo to the browser inside meta box fields', async ( { + editor, + page, + pageUtils, + } ) => { + await editor.canvas + .locator( 'role=button[name="Add default block"i]' ) + .click(); + await page.keyboard.type( 'canvas text' ); + + // Click near the label, away from the resize handle overlaying the + // center of the toggle. + await page + .getByRole( 'button', { name: 'Meta Boxes', exact: true } ) + .click( { position: { x: 40, y: 10 } } ); + const field = page.getByRole( 'textbox', { + name: 'Test meta box field', + } ); + await field.click(); + await page.keyboard.type( 'META' ); + await expect( field ).toHaveValue( 'META' ); + + await pageUtils.pressKeys( 'primary+z', { times: 4 } ); + + // The browser undoes the typing within the field. The canvas is + // untouched, and redo restores the typing. + await expect( field ).toHaveValue( '' ); + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'canvas text' }, + }, + ] ); + + await pageUtils.pressKeys( 'primaryShift+z', { times: 4 } ); + await expect( field ).toHaveValue( 'META' ); + } ); + test( 'Should render dynamic blocks when the meta box uses the excerpt for front end rendering', async ( { admin, editor, diff --git a/test/e2e/specs/editor/various/undo.spec.js b/test/e2e/specs/editor/various/undo.spec.js index 0c29e68c3ee653..f224125662e37c 100644 --- a/test/e2e/specs/editor/various/undo.spec.js +++ b/test/e2e/specs/editor/various/undo.spec.js @@ -570,6 +570,129 @@ test.describe( 'undo', () => { isCollaborationEnabled ? 'false' : 'true' ); } ); + + test( 'should leave undo to the browser inside the link dialog', async ( { + page, + pageUtils, + editor, + } ) => { + await editor.canvas + .locator( 'role=button[name="Add default block"i]' ) + .click(); + await page.keyboard.type( 'some linked text here' ); + // Select the word "linked". + await pageUtils.pressKeys( 'ArrowLeft', { times: 10 } ); + await pageUtils.pressKeys( 'shift+ArrowLeft', { times: 6 } ); + await pageUtils.pressKeys( 'primary+k' ); + + const urlInput = page.getByRole( 'combobox', { + name: 'Search or type URL', + } ); + await expect( urlInput ).toBeFocused(); + + await page.keyboard.type( 'exampl' ); + await expect( urlInput ).toHaveValue( 'exampl' ); + + await pageUtils.pressKeys( 'primary+z' ); + + // The browser undoes the typing within the field. The dialog stays + // open and the canvas content is untouched. + await expect( urlInput ).toHaveValue( '' ); + await expect( urlInput ).toBeFocused(); + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/paragraph', + attributes: { content: 'some linked text here' }, + }, + ] ); + } ); + + test( 'should leave undo to the browser when editing a block as HTML', async ( { + page, + pageUtils, + editor, + } ) => { + await editor.canvas + .locator( 'role=button[name="Add default block"i]' ) + .click(); + await page.keyboard.type( 'first' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'second' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'third' ); + + await editor.canvas.getByText( 'second', { exact: true } ).click(); + await editor.clickBlockOptionsMenuItem( 'Edit as HTML' ); + + const textarea = editor.canvas.locator( + 'textarea.block-editor-block-list__block-html-textarea' + ); + await textarea.click(); + await page.keyboard.press( 'End' ); + await page.keyboard.type( 'EDIT' ); + await expect( textarea ).toHaveValue( '

second

EDIT' ); + + // Undo granularity within the field is the browser's own; press + // once per typed character. Excess presses are harmless no-ops in + // browsers that group the typing into a single undo unit, since the + // editor history must not act while the field is focused. + await pageUtils.pressKeys( 'primary+z', { times: 4 } ); + + // The browser undoes the typing within the field. The other blocks + // are untouched, and redo restores the typing. + await expect( textarea ).toHaveValue( '

second

' ); + await expect.poll( editor.getBlocks ).toMatchObject( [ + { name: 'core/paragraph', attributes: { content: 'first' } }, + { name: 'core/paragraph', attributes: { content: 'second' } }, + { name: 'core/paragraph', attributes: { content: 'third' } }, + ] ); + + await pageUtils.pressKeys( 'primaryShift+z', { times: 4 } ); + await expect( textarea ).toHaveValue( '

second

EDIT' ); + } ); + + test( 'should leave undo to the browser inside the Custom HTML dialog', async ( { + page, + pageUtils, + editor, + } ) => { + await editor.canvas + .locator( 'role=button[name="Add default block"i]' ) + .click(); + await page.keyboard.type( 'a paragraph' ); + await editor.insertBlock( { + name: 'core/html', + attributes: { content: '

markup

' }, + } ); + await editor.clickBlockToolbarButton( 'Edit code' ); + + const field = page.getByRole( 'dialog' ).getByRole( 'textbox' ); + await field.click(); + await page.keyboard.press( 'End' ); + await page.keyboard.type( 'EDIT' ); + await expect( field ).toHaveValue( '

markup

EDIT' ); + + await pageUtils.pressKeys( 'primary+z', { times: 4 } ); + + // The browser undoes the typing within the field. The dialog stays + // open and the content behind it is untouched. + await expect( field ).toHaveValue( '

markup

' ); + await expect.poll( editor.getEditedPostContent ) + .toBe( ` +

a paragraph

+ + + +

markup

+` ); + + // Redo restores the typing. This distinguishes the browser's own + // undo from the editor history remounting the dialog: a remount + // resets the field to the same committed value, but destroys the + // browser's redo stack with it. + await pageUtils.pressKeys( 'primaryShift+z', { times: 4 } ); + await expect( field ).toHaveValue( '

markup

EDIT' ); + } ); } ); class UndoUtils {