-
Notifications
You must be signed in to change notification settings - Fork 29
Register undo/redo keyboard shortcuts for block editor #78
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: trunk
Are you sure you want to change the base?
Changes from all commits
412f745
54e29bc
0b979e7
6040f6a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -219,6 +219,7 @@ function getWpRestBaseUrl( pressThisRestUrl ) { | |||||||||||||||||||||||||||||
| * @param {Object} props.pendingScrape Pending scraped content to append. | ||||||||||||||||||||||||||||||
| * @param {Function} props.onScrapeProcessed Callback after scrape is processed. | ||||||||||||||||||||||||||||||
| * @param {Function} props.onSaveReady Callback when save handler is ready (receives { handleSave, isSaving, publishLabel }). | ||||||||||||||||||||||||||||||
| * @param {Function} props.onUndoReady Callback when undo/redo handlers are ready (receives { handleUndo, handleRedo, hasUndo, hasRedo }). | ||||||||||||||||||||||||||||||
| * @param {string} props.categoryNonce | ||||||||||||||||||||||||||||||
| * @param {string} props.ajaxUrl | ||||||||||||||||||||||||||||||
| * @return {JSX.Element} Press This Editor component. | ||||||||||||||||||||||||||||||
|
|
@@ -236,6 +237,7 @@ export default function PressThisEditor( { | |||||||||||||||||||||||||||||
| pendingScrape = null, | ||||||||||||||||||||||||||||||
| onScrapeProcessed = () => {}, | ||||||||||||||||||||||||||||||
| onSaveReady = () => {}, | ||||||||||||||||||||||||||||||
| onUndoReady = () => {}, | ||||||||||||||||||||||||||||||
| categoryNonce = '', | ||||||||||||||||||||||||||||||
| ajaxUrl = '', | ||||||||||||||||||||||||||||||
| } ) { | ||||||||||||||||||||||||||||||
|
|
@@ -276,6 +278,104 @@ export default function PressThisEditor( { | |||||||||||||||||||||||||||||
| const [ isLoadingTags, setIsLoadingTags ] = useState( false ); | ||||||||||||||||||||||||||||||
| const tagSearchTimeout = useRef( null ); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Undo/Redo stack. | ||||||||||||||||||||||||||||||
| // The core/block-editor store does not provide undo/redo actions. | ||||||||||||||||||||||||||||||
| // In full Gutenberg, EditorProvider manages undo via core-data entity edits, | ||||||||||||||||||||||||||||||
| // but Press This uses BlockEditorProvider directly with React state. | ||||||||||||||||||||||||||||||
| const undoStackRef = useRef( [] ); | ||||||||||||||||||||||||||||||
| const redoStackRef = useRef( [] ); | ||||||||||||||||||||||||||||||
| const blocksRef = useRef( blocks ); | ||||||||||||||||||||||||||||||
| const isUndoingRef = useRef( false ); | ||||||||||||||||||||||||||||||
| const [ hasUndo, setHasUndo ] = useState( false ); | ||||||||||||||||||||||||||||||
| const [ hasRedo, setHasRedo ] = useState( false ); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Keep blocksRef in sync. | ||||||||||||||||||||||||||||||
| useEffect( () => { | ||||||||||||||||||||||||||||||
| blocksRef.current = blocks; | ||||||||||||||||||||||||||||||
| }, [ blocks ] ); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const syncUndoRedoState = useCallback( () => { | ||||||||||||||||||||||||||||||
| setHasUndo( undoStackRef.current.length > 0 ); | ||||||||||||||||||||||||||||||
| setHasRedo( redoStackRef.current.length > 0 ); | ||||||||||||||||||||||||||||||
| }, [] ); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const handleUndo = useCallback( () => { | ||||||||||||||||||||||||||||||
| if ( undoStackRef.current.length === 0 ) { | ||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| const previousBlocks = undoStackRef.current.pop(); | ||||||||||||||||||||||||||||||
| redoStackRef.current.push( blocksRef.current ); | ||||||||||||||||||||||||||||||
| isUndoingRef.current = true; | ||||||||||||||||||||||||||||||
| blocksRef.current = previousBlocks; | ||||||||||||||||||||||||||||||
| setBlocks( previousBlocks ); | ||||||||||||||||||||||||||||||
| syncUndoRedoState(); | ||||||||||||||||||||||||||||||
| }, [ syncUndoRedoState ] ); | ||||||||||||||||||||||||||||||
|
Comment on lines
+302
to
+312
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const handleRedo = useCallback( () => { | ||||||||||||||||||||||||||||||
| if ( redoStackRef.current.length === 0 ) { | ||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| const nextBlocks = redoStackRef.current.pop(); | ||||||||||||||||||||||||||||||
| undoStackRef.current.push( blocksRef.current ); | ||||||||||||||||||||||||||||||
| isUndoingRef.current = true; | ||||||||||||||||||||||||||||||
| blocksRef.current = nextBlocks; | ||||||||||||||||||||||||||||||
| setBlocks( nextBlocks ); | ||||||||||||||||||||||||||||||
| syncUndoRedoState(); | ||||||||||||||||||||||||||||||
| }, [ syncUndoRedoState ] ); | ||||||||||||||||||||||||||||||
|
Comment on lines
+302
to
+324
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Expose undo/redo to parent (for Header buttons). | ||||||||||||||||||||||||||||||
| useEffect( () => { | ||||||||||||||||||||||||||||||
| if ( onUndoReady ) { | ||||||||||||||||||||||||||||||
| onUndoReady( { handleUndo, handleRedo, hasUndo, hasRedo } ); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| }, [ onUndoReady, handleUndo, handleRedo, hasUndo, hasRedo ] ); // eslint-disable-line react-hooks/exhaustive-deps | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| }, [ onUndoReady, handleUndo, handleRedo, hasUndo, hasRedo ] ); // eslint-disable-line react-hooks/exhaustive-deps | |
| }, [ onUndoReady, handleUndo, handleRedo, hasUndo, hasRedo ] ); |
Copilot
AI
Feb 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new undo/redo functionality (keyboard shortcuts, stack management, and the handleUndo/handleRedo callbacks) has no test coverage. The existing component tests in tests/components/ follow a pattern of checking source code for required props and behaviors. A test file similar to tests/components/header-publish-controls.test.js should be added to verify that PressThisEditor accepts and exposes onUndoReady, and that Header accepts onUndo, onRedo, hasUndo, hasRedo props and wires them to the undo/redo buttons.
Copilot
AI
Feb 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The initial content parsing (line 381) doesn't create an undo level. This means the initial parsed blocks won't be on the undo stack. While this is probably intentional for initial load, it creates an inconsistency with the pendingScrape handling (lines 400-406) which does create an undo level. Consider whether initial content should also create an undo level, or document why this difference exists.
Copilot
AI
Feb 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The undo level is saved using blocksRef.current (line 402) while setBlocks uses a functional update with prevBlocks (line 405). There's a potential race condition where blocksRef.current might not be in sync with the actual previous blocks at the time setBlocks is called. Consider using the prevBlocks value from the functional update to ensure the undo stack captures the actual previous state: save prevBlocks to the undo stack within the functional update, or use setBlocks with a non-functional update to maintain consistency.
| undoStackRef.current = [ | |
| ...undoStackRef.current, | |
| blocksRef.current, | |
| ]; | |
| redoStackRef.current = []; | |
| setBlocks( ( prevBlocks ) => [ ...prevBlocks, ...newBlocks ] ); | |
| setBlocks( ( prevBlocks ) => { | |
| undoStackRef.current = [ | |
| ...undoStackRef.current, | |
| prevBlocks, | |
| ]; | |
| redoStackRef.current = []; | |
| return [ ...prevBlocks, ...newBlocks ]; | |
| } ); |
Copilot
AI
Feb 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The effect uses title (line 393) to check if it's empty, but title is not in the dependency array (line 411). If the effect runs multiple times with different pendingScrape values before title updates, the stale title value could cause incorrect behavior. While this is unlikely in normal usage, consider adding title to the dependency array or using a ref to track whether the title has been set by a scrape operation.
| }, [ pendingScrape, onScrapeProcessed ] ); // eslint-disable-line react-hooks/exhaustive-deps | |
| }, [ pendingScrape, onScrapeProcessed, title ] ); // eslint-disable-line react-hooks/exhaustive-deps |
Copilot
AI
Feb 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
handleBlocksInput doesn't check the isUndoingRef flag. If BlockEditorProvider calls both onInput and onChange during an undo/redo operation, handleBlocksInput could update the blocks state independently, potentially causing race conditions or incorrect state. Consider adding the same isUndoingRef check to handleBlocksInput to ensure consistent behavior, or verify that BlockEditorProvider never calls onInput during controlled value changes.
| const handleBlocksInput = useCallback( ( newBlocks ) => { | |
| const handleBlocksInput = useCallback( ( newBlocks ) => { | |
| // Avoid conflicting updates during undo/redo operations. | |
| if ( isUndoingRef.current ) { | |
| isUndoingRef.current = false; | |
| setBlocks( newBlocks ); | |
| return; | |
| } |
Copilot
AI
Feb 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When isUndoingRef.current is true, handleBlocksChange calls setBlocks(newBlocks) which is redundant since setBlocks was already called in handleUndo/handleRedo (lines 309, 320). If BlockEditorProvider's onChange is being triggered by external value prop changes, this creates an extra unnecessary render. Consider simply returning early without calling setBlocks again, since the blocks state is already being updated by the undo/redo operation.
| setBlocks( newBlocks ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The PR description states the implementation "Dispatches
undo()/redo()from the block editor store", but the actual implementation uses a custom React-state-based undo/redo stack (undoStackRef,redoStackRef) rather than the block editor store's undo/redo actions. The PR description should be updated to reflect that a custom stack is used because the block editor store doesn't expose undo/redo actions at thecore/block-editorlevel.