diff --git a/docs/manifest.json b/docs/manifest.json index f6643182df7541..3fd25be331bdb9 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1925,6 +1925,12 @@ "markdown_source": "../packages/token-list/README.md", "parent": "packages" }, + { + "title": "@wordpress/undo-manager", + "slug": "packages-undo-manager", + "markdown_source": "../packages/undo-manager/README.md", + "parent": "packages" + }, { "title": "@wordpress/url", "slug": "packages-url", diff --git a/docs/reference-guides/data/data-core.md b/docs/reference-guides/data/data-core.md index f2bc3374f9e721..95401834ad4391 100644 --- a/docs/reference-guides/data/data-core.md +++ b/docs/reference-guides/data/data-core.md @@ -371,7 +371,7 @@ _Usage_ _Parameters_ -- _state_ `State`: Editor state. +- _state_ Editor state. _Returns_ diff --git a/package-lock.json b/package-lock.json index 16631cc2fa8df9..cac2325c74aa48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,6 +74,7 @@ "@wordpress/style-engine": "file:packages/style-engine", "@wordpress/sync": "file:packages/sync", "@wordpress/token-list": "file:packages/token-list", + "@wordpress/undo-manager": "file:packages/undo-manager", "@wordpress/url": "file:packages/url", "@wordpress/viewport": "file:packages/viewport", "@wordpress/warning": "file:packages/warning", @@ -15655,6 +15656,10 @@ "resolved": "packages/token-list", "link": true }, + "node_modules/@wordpress/undo-manager": { + "resolved": "packages/undo-manager", + "link": true + }, "node_modules/@wordpress/url": { "resolved": "packages/url", "link": true @@ -55012,6 +55017,7 @@ "@wordpress/is-shallow-equal": "file:../is-shallow-equal", "@wordpress/private-apis": "file:../private-apis", "@wordpress/sync": "file:../sync", + "@wordpress/undo-manager": "file:../undo-manager", "@wordpress/url": "file:../url", "change-case": "^4.1.2", "equivalent-key-map": "^0.2.2", @@ -56564,6 +56570,18 @@ "node": ">=12" } }, + "packages/undo-manager": { + "name": "@wordpress/undo-manager", + "version": "0.1.0", + "license": "GPL-2.0-or-later", + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/is-shallow-equal": "file:../is-shallow-equal" + }, + "engines": { + "node": ">=12" + } + }, "packages/url": { "name": "@wordpress/url", "version": "3.42.0", @@ -67896,6 +67914,7 @@ "@wordpress/is-shallow-equal": "file:../is-shallow-equal", "@wordpress/private-apis": "file:../private-apis", "@wordpress/sync": "file:../sync", + "@wordpress/undo-manager": "file:../undo-manager", "@wordpress/url": "file:../url", "change-case": "^4.1.2", "equivalent-key-map": "^0.2.2", @@ -68903,6 +68922,13 @@ "@babel/runtime": "^7.16.0" } }, + "@wordpress/undo-manager": { + "version": "file:packages/undo-manager", + "requires": { + "@babel/runtime": "^7.16.0", + "@wordpress/is-shallow-equal": "file:../is-shallow-equal" + } + }, "@wordpress/url": { "version": "file:packages/url", "requires": { diff --git a/package.json b/package.json index 2bde358171e2a8..3777de4d958c3a 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "@wordpress/style-engine": "file:packages/style-engine", "@wordpress/sync": "file:packages/sync", "@wordpress/token-list": "file:packages/token-list", + "@wordpress/undo-manager": "file:packages/undo-manager", "@wordpress/url": "file:packages/url", "@wordpress/viewport": "file:packages/viewport", "@wordpress/warning": "file:packages/warning", diff --git a/packages/core-data/README.md b/packages/core-data/README.md index c778b724149ef3..18e131cd7ab6f1 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -548,7 +548,7 @@ _Usage_ _Parameters_ -- _state_ `State`: Editor state. +- _state_ Editor state. _Returns_ diff --git a/packages/core-data/package.json b/packages/core-data/package.json index 80bc41ff0a5afe..1f83dc9814400c 100644 --- a/packages/core-data/package.json +++ b/packages/core-data/package.json @@ -43,6 +43,7 @@ "@wordpress/is-shallow-equal": "file:../is-shallow-equal", "@wordpress/private-apis": "file:../private-apis", "@wordpress/sync": "file:../sync", + "@wordpress/undo-manager": "file:../undo-manager", "@wordpress/url": "file:../url", "change-case": "^4.1.2", "equivalent-key-map": "^0.2.2", diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 1969d2cd717a2a..a79d1236682582 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -391,20 +391,29 @@ export const editEntityRecord = edit.edits ); } else { + if ( ! options.undoIgnore ) { + select.getUndoManager().addRecord( + [ + { + id: { kind, name, recordId }, + changes: Object.keys( edits ).reduce( + ( acc, key ) => { + acc[ key ] = { + from: editedRecord[ key ], + to: edits[ key ], + }; + return acc; + }, + {} + ), + }, + ], + options.isCached + ); + } dispatch( { type: 'EDIT_ENTITY_RECORD', ...edit, - meta: { - undo: ! options.undoIgnore && { - ...edit, - // Send the current values for things like the first undo stack entry. - edits: Object.keys( edits ).reduce( ( acc, key ) => { - acc[ key ] = editedRecord[ key ]; - return acc; - }, {} ), - isCached: options.isCached, - }, - }, } ); } }; @@ -416,13 +425,14 @@ export const editEntityRecord = export const undo = () => ( { select, dispatch } ) => { - const undoEdit = select.getUndoEdits(); + const undoEdit = select.getUndoManager().getUndoRecord(); if ( ! undoEdit ) { return; } + select.getUndoManager().undo(); dispatch( { type: 'UNDO', - stackedEdits: undoEdit, + record: undoEdit, } ); }; @@ -433,13 +443,14 @@ export const undo = export const redo = () => ( { select, dispatch } ) => { - const redoEdit = select.getRedoEdits(); + const redoEdit = select.getUndoManager().getRedoRecord(); if ( ! redoEdit ) { return; } + select.getUndoManager().redo(); dispatch( { type: 'REDO', - stackedEdits: redoEdit, + record: redoEdit, } ); }; @@ -448,9 +459,11 @@ export const redo = * * @return {Object} Action object. */ -export function __unstableCreateUndoLevel() { - return { type: 'CREATE_UNDO_LEVEL' }; -} +export const __unstableCreateUndoLevel = + () => + ( { select } ) => { + select.getUndoManager().addRecord(); + }; /** * Action triggered to save an entity record. diff --git a/packages/core-data/src/private-selectors.ts b/packages/core-data/src/private-selectors.ts index 1e253b900e1cbb..94aa00e1c8de45 100644 --- a/packages/core-data/src/private-selectors.ts +++ b/packages/core-data/src/private-selectors.ts @@ -1,9 +1,8 @@ /** * Internal dependencies */ -import type { State, UndoEdit } from './selectors'; +import type { State } from './selectors'; -type Optional< T > = T | undefined; type EntityRecordKey = string | number; /** @@ -12,22 +11,10 @@ type EntityRecordKey = string | number; * * @param state State tree. * - * @return The edit. + * @return The undo manager. */ -export function getUndoEdits( state: State ): Optional< UndoEdit[] > { - return state.undo.list[ state.undo.list.length - 1 + state.undo.offset ]; -} - -/** - * Returns the next edit from the current undo offset - * for the entity records edits history, if any. - * - * @param state State tree. - * - * @return The edit. - */ -export function getRedoEdits( state: State ): Optional< UndoEdit[] > { - return state.undo.list[ state.undo.list.length + state.undo.offset ]; +export function getUndoManager( state: State ) { + return state.undoManager; } /** diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 20755dad4be8d2..f097d07d047746 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -8,7 +8,7 @@ import fastDeepEqual from 'fast-deep-equal/es6'; */ import { compose } from '@wordpress/compose'; import { combineReducers } from '@wordpress/data'; -import isShallowEqual from '@wordpress/is-shallow-equal'; +import { createUndoManager } from '@wordpress/undo-manager'; /** * Internal dependencies @@ -185,22 +185,25 @@ export function themeGlobalStyleVariations( state = {}, action ) { const withMultiEntityRecordEdits = ( reducer ) => ( state, action ) => { if ( action.type === 'UNDO' || action.type === 'REDO' ) { - const { stackedEdits } = action; + const { record } = action; let newState = state; - stackedEdits.forEach( - ( { kind, name, recordId, property, from, to } ) => { - newState = reducer( newState, { - type: 'EDIT_ENTITY_RECORD', - kind, - name, - recordId, - edits: { - [ property ]: action.type === 'UNDO' ? from : to, + record.forEach( ( { id: { kind, name, recordId }, changes } ) => { + newState = reducer( newState, { + type: 'EDIT_ENTITY_RECORD', + kind, + name, + recordId, + edits: Object.entries( changes ).reduce( + ( acc, [ key, value ] ) => { + acc[ key ] = + action.type === 'UNDO' ? value.from : value.to; + return acc; }, - } ); - } - ); + {} + ), + } ); + } ); return newState; } @@ -435,151 +438,19 @@ export const entities = ( state = {}, action ) => { }; /** - * @typedef {Object} UndoStateMeta - * - * @property {number} list The undo stack. - * @property {number} offset Where in the undo stack we are. - * @property {Object} cache Cache of unpersisted edits. - */ - -/** @typedef {Array & UndoStateMeta} UndoState */ - -/** - * @type {UndoState} - * - * @todo Given how we use this we might want to make a custom class for it. - */ -const UNDO_INITIAL_STATE = { list: [], offset: 0 }; - -/** - * Reducer keeping track of entity edit undo history. - * - * @param {UndoState} state Current state. - * @param {Object} action Dispatched action. - * - * @return {UndoState} Updated state. + * @type {UndoManager} */ -export function undo( state = UNDO_INITIAL_STATE, action ) { - const omitPendingRedos = ( currentState ) => { - return { - ...currentState, - list: currentState.list.slice( - 0, - currentState.offset || undefined - ), - offset: 0, - }; - }; - - const appendCachedEditsToLastUndo = ( currentState ) => { - if ( ! currentState.cache ) { - return currentState; - } - - let nextState = { - ...currentState, - list: [ ...currentState.list ], - }; - nextState = omitPendingRedos( nextState ); - const previousUndoState = nextState.list.pop(); - const updatedUndoState = currentState.cache.reduce( - appendEditToStack, - previousUndoState - ); - nextState.list.push( updatedUndoState ); - - return { - ...nextState, - cache: undefined, - }; - }; - - const appendEditToStack = ( - stack = [], - { kind, name, recordId, property, from, to } - ) => { - const existingEditIndex = stack?.findIndex( - ( { kind: k, name: n, recordId: r, property: p } ) => { - return ( - k === kind && n === name && r === recordId && p === property - ); - } - ); - const nextStack = [ ...stack ]; - if ( existingEditIndex !== -1 ) { - // If the edit is already in the stack leave the initial "from" value. - nextStack[ existingEditIndex ] = { - ...nextStack[ existingEditIndex ], - to, - }; - } else { - nextStack.push( { - kind, - name, - recordId, - property, - from, - to, - } ); - } - return nextStack; - }; +export function undoManager( state = createUndoManager() ) { + return state; +} +export function editsReference( state = {}, action ) { switch ( action.type ) { - case 'CREATE_UNDO_LEVEL': - return appendCachedEditsToLastUndo( state ); - + case 'EDIT_ENTITY_RECORD': case 'UNDO': - case 'REDO': { - const nextState = appendCachedEditsToLastUndo( state ); - return { - ...nextState, - offset: state.offset + ( action.type === 'UNDO' ? -1 : 1 ), - }; - } - - case 'EDIT_ENTITY_RECORD': { - if ( ! action.meta.undo ) { - return state; - } - - const edits = Object.keys( action.edits ).map( ( key ) => { - return { - kind: action.kind, - name: action.name, - recordId: action.recordId, - property: key, - from: action.meta.undo.edits[ key ], - to: action.edits[ key ], - }; - } ); - - if ( action.meta.undo.isCached ) { - return { - ...state, - cache: edits.reduce( appendEditToStack, state.cache ), - }; - } - - let nextState = omitPendingRedos( state ); - nextState = appendCachedEditsToLastUndo( nextState ); - nextState = { ...nextState, list: [ ...nextState.list ] }; - // When an edit is a function it's an optimization to avoid running some expensive operation. - // We can't rely on the function references being the same so we opt out of comparing them here. - const comparisonUndoEdits = Object.values( - action.meta.undo.edits - ).filter( ( edit ) => typeof edit !== 'function' ); - const comparisonEdits = Object.values( action.edits ).filter( - ( edit ) => typeof edit !== 'function' - ); - if ( ! isShallowEqual( comparisonUndoEdits, comparisonEdits ) ) { - nextState.list.push( edits ); - } - - return nextState; - } + case 'REDO': + return {}; } - return state; } @@ -704,7 +575,8 @@ export default combineReducers( { themeGlobalStyleRevisions, taxonomies, entities, - undo, + editsReference, + undoManager, embedPreviews, userPermissions, autosaves, diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 377134ab7c9a3d..e4fb2eada0cf87 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -22,7 +22,7 @@ import { setNestedValue, } from './utils'; import type * as ET from './entity-types'; -import { getUndoEdits, getRedoEdits } from './private-selectors'; +import type { UndoManager } from '@wordpress/undo-manager'; // This is an incomplete, high-level approximation of the State type. // It makes the selectors slightly more safe, but is intended to evolve @@ -40,7 +40,7 @@ export interface State { themeBaseGlobalStyles: Record< string, Object >; themeGlobalStyleVariations: Record< string, string >; themeGlobalStyleRevisions: Record< number, Object >; - undo: UndoState; + undoManager: UndoManager; userPermissions: Record< string, boolean >; users: UserState; navigationFallbackId: EntityRecordKey; @@ -74,20 +74,6 @@ interface EntityConfig { kind: string; } -export interface UndoEdit { - name: string; - kind: string; - recordId: string; - from: any; - to: any; -} - -interface UndoState { - list: Array< UndoEdit[] >; - offset: number; - cache: UndoEdit[]; -} - interface UserState { queries: Record< string, EntityRecordKey[] >; byId: Record< EntityRecordKey, ET.User< 'edit' > >; @@ -875,21 +861,6 @@ export function getLastEntityDeleteError( ?.error; } -/** - * Returns the current undo offset for the - * entity records edits history. The offset - * represents how many items from the end - * of the history stack we are at. 0 is the - * last edit, -1 is the second last, and so on. - * - * @param state State tree. - * - * @return The current undo offset. - */ -function getCurrentUndoOffset( state: State ): number { - return state.undo.offset; -} - /** * Returns the previous edit from the current undo offset * for the entity records edits history, if any. @@ -904,9 +875,7 @@ export function getUndoEdit( state: State ): Optional< any > { deprecated( "select( 'core' ).getUndoEdit()", { since: '6.3', } ); - return state.undo.list[ - state.undo.list.length - 2 + getCurrentUndoOffset( state ) - ]?.[ 0 ]; + return undefined; } /** @@ -923,9 +892,7 @@ export function getRedoEdit( state: State ): Optional< any > { deprecated( "select( 'core' ).getRedoEdit()", { since: '6.3', } ); - return state.undo.list[ - state.undo.list.length + getCurrentUndoOffset( state ) - ]?.[ 0 ]; + return undefined; } /** @@ -937,7 +904,7 @@ export function getRedoEdit( state: State ): Optional< any > { * @return Whether there is a previous edit or not. */ export function hasUndo( state: State ): boolean { - return Boolean( getUndoEdits( state ) ); + return Boolean( state.undoManager.getUndoRecord() ); } /** @@ -949,7 +916,7 @@ export function hasUndo( state: State ): boolean { * @return Whether there is a next edit or not. */ export function hasRedo( state: State ): boolean { - return Boolean( getRedoEdits( state ) ); + return Boolean( state.undoManager.getRedoRecord() ); } /** @@ -1163,11 +1130,9 @@ export const hasFetchedAutosaves = createRegistrySelector( * * @return A value whose reference will change only when an edit occurs. */ -export const getReferenceByDistinctEdits = createSelector( - // This unused state argument is listed here for the documentation generating tool (docgen). - ( state: State ) => [], - ( state: State ) => [ state.undo.list.length, state.undo.offset ] -); +export function getReferenceByDistinctEdits( state ) { + return state.editsReference; +} /** * Retrieve the frontend template used for a given link. diff --git a/packages/core-data/src/test/reducer.js b/packages/core-data/src/test/reducer.js index 7fac52c33c4b36..4142f65af4c7c4 100644 --- a/packages/core-data/src/test/reducer.js +++ b/packages/core-data/src/test/reducer.js @@ -9,7 +9,6 @@ import deepFreeze from 'deep-freeze'; import { terms, entities, - undo, embedPreviews, userPermissions, autosaves, @@ -142,238 +141,6 @@ describe( 'entities', () => { } ); } ); -describe( 'undo', () => { - let lastValues; - let undoState; - let expectedUndoState; - - const createExpectedDiff = ( property, { from, to } ) => ( { - kind: 'someKind', - name: 'someName', - recordId: 'someRecordId', - property, - from, - to, - } ); - const createNextEditAction = ( edits, isCached ) => { - let action = { - kind: 'someKind', - name: 'someName', - recordId: 'someRecordId', - edits, - }; - action = { - type: 'EDIT_ENTITY_RECORD', - ...action, - meta: { - undo: { - isCached, - edits: lastValues, - }, - }, - }; - lastValues = { ...lastValues, ...edits }; - return action; - }; - const createNextUndoState = ( ...args ) => { - let action = {}; - if ( args[ 0 ] === 'isUndo' || args[ 0 ] === 'isRedo' ) { - // We need to "apply" the undo level here and build - // the action to move the offset. - const lastEdits = - undoState.list[ - undoState.list.length - - ( args[ 0 ] === 'isUndo' ? 1 : 0 ) + - undoState.offset - ]; - lastEdits.forEach( ( { property, from, to } ) => { - lastValues[ property ] = args[ 0 ] === 'isUndo' ? from : to; - } ); - action = { - type: args[ 0 ] === 'isUndo' ? 'UNDO' : 'REDO', - }; - } else if ( args[ 0 ] === 'isCreate' ) { - action = { type: 'CREATE_UNDO_LEVEL' }; - } else if ( args.length ) { - action = createNextEditAction( ...args ); - } - return deepFreeze( undo( undoState, action ) ); - }; - beforeEach( () => { - lastValues = {}; - undoState = undefined; - expectedUndoState = { list: [], offset: 0 }; - } ); - - it( 'initializes', () => { - expect( createNextUndoState() ).toEqual( expectedUndoState ); - } ); - - it( 'stacks undo levels', () => { - undoState = createNextUndoState(); - - // Check that the first edit creates an undo level for the current state and - // one for the new one. - undoState = createNextUndoState( { value: 1 } ); - expectedUndoState.list.push( [ - createExpectedDiff( 'value', { from: undefined, to: 1 } ), - ] ); - expect( undoState ).toEqual( expectedUndoState ); - - // Check that the second and third edits just create an undo level for - // themselves. - undoState = createNextUndoState( { value: 2 } ); - expectedUndoState.list.push( [ - createExpectedDiff( 'value', { from: 1, to: 2 } ), - ] ); - expect( undoState ).toEqual( expectedUndoState ); - undoState = createNextUndoState( { value: 3 } ); - expectedUndoState.list.push( [ - createExpectedDiff( 'value', { from: 2, to: 3 } ), - ] ); - expect( undoState ).toEqual( expectedUndoState ); - } ); - - it( 'stacks multi-property undo levels', () => { - undoState = createNextUndoState(); - - undoState = createNextUndoState( { value: 1 } ); - undoState = createNextUndoState( { value2: 2 } ); - expectedUndoState.list.push( - [ createExpectedDiff( 'value', { from: undefined, to: 1 } ) ], - [ createExpectedDiff( 'value2', { from: undefined, to: 2 } ) ] - ); - expect( undoState ).toEqual( expectedUndoState ); - - // Check that that creating another undo level merges the "edits" - undoState = createNextUndoState( { value: 2 } ); - expectedUndoState.list.push( [ - createExpectedDiff( 'value', { from: 1, to: 2 } ), - ] ); - expect( undoState ).toEqual( expectedUndoState ); - } ); - - it( 'handles undos/redos', () => { - undoState = createNextUndoState(); - undoState = createNextUndoState( { value: 1 } ); - undoState = createNextUndoState( { value: 2 } ); - undoState = createNextUndoState( { value: 3 } ); - expectedUndoState.list.push( - [ createExpectedDiff( 'value', { from: undefined, to: 1 } ) ], - [ createExpectedDiff( 'value', { from: 1, to: 2 } ) ], - [ createExpectedDiff( 'value', { from: 2, to: 3 } ) ] - ); - expect( undoState ).toEqual( expectedUndoState ); - - // Check that undoing and redoing an equal - // number of steps does not lose edits. - undoState = createNextUndoState( 'isUndo' ); - expectedUndoState.offset--; - expect( undoState ).toEqual( expectedUndoState ); - undoState = createNextUndoState( 'isUndo' ); - expectedUndoState.offset--; - expect( undoState ).toEqual( expectedUndoState ); - undoState = createNextUndoState( 'isRedo' ); - expectedUndoState.offset++; - expect( undoState ).toEqual( expectedUndoState ); - undoState = createNextUndoState( 'isRedo' ); - expectedUndoState.offset++; - expect( undoState ).toEqual( expectedUndoState ); - - // Check that another edit will go on top when there - // is no undo level offset. - undoState = createNextUndoState( { value: 4 } ); - expectedUndoState.list.push( [ - createExpectedDiff( 'value', { from: 3, to: 4 } ), - ] ); - expect( undoState ).toEqual( expectedUndoState ); - - // Check that undoing and editing will slice of - // all the levels after the current one. - undoState = createNextUndoState( 'isUndo' ); - undoState = createNextUndoState( 'isUndo' ); - - undoState = createNextUndoState( { value: 5 } ); - expectedUndoState.list.pop(); - expectedUndoState.list.pop(); - expectedUndoState.list.push( [ - createExpectedDiff( 'value', { from: 2, to: 5 } ), - ] ); - expect( undoState ).toEqual( expectedUndoState ); - } ); - - it( 'handles flattened undos/redos', () => { - undoState = createNextUndoState(); - undoState = createNextUndoState( { value: 1 } ); - undoState = createNextUndoState( { transientValue: 2 }, true ); - undoState = createNextUndoState( { value: 3 } ); - expectedUndoState.list.push( - [ - createExpectedDiff( 'value', { from: undefined, to: 1 } ), - createExpectedDiff( 'transientValue', { - from: undefined, - to: 2, - } ), - ], - [ createExpectedDiff( 'value', { from: 1, to: 3 } ) ] - ); - expect( undoState ).toEqual( expectedUndoState ); - } ); - - it( 'handles explicit undo level creation', () => { - undoState = createNextUndoState(); - - // Check that nothing happens if there are no pending - // transient edits. - undoState = createNextUndoState( { value: 1 } ); - undoState = createNextUndoState( 'isCreate' ); - expectedUndoState.list.push( [ - createExpectedDiff( 'value', { from: undefined, to: 1 } ), - ] ); - expect( undoState ).toEqual( expectedUndoState ); - - // Check that transient edits are merged into the last - // edits. - undoState = createNextUndoState( { transientValue: 2 }, true ); - undoState = createNextUndoState( 'isCreate' ); - expectedUndoState.list[ expectedUndoState.list.length - 1 ].push( - createExpectedDiff( 'transientValue', { from: undefined, to: 2 } ) - ); - expect( undoState ).toEqual( expectedUndoState ); - - // Check that create after undo does nothing. - undoState = createNextUndoState( { value: 3 } ); - undoState = createNextUndoState( 'isUndo' ); - undoState = createNextUndoState( 'isCreate' ); - expectedUndoState.list.push( [ - createExpectedDiff( 'value', { from: 1, to: 3 } ), - ] ); - expectedUndoState.offset = -1; - expect( undoState ).toEqual( expectedUndoState ); - } ); - - it( 'explicitly creates an undo level when undoing while there are pending transient edits', () => { - undoState = createNextUndoState(); - undoState = createNextUndoState( { value: 1 } ); - undoState = createNextUndoState( { transientValue: 2 }, true ); - undoState = createNextUndoState( 'isUndo' ); - expectedUndoState.list.push( [ - createExpectedDiff( 'value', { from: undefined, to: 1 } ), - createExpectedDiff( 'transientValue', { from: undefined, to: 2 } ), - ] ); - expectedUndoState.offset--; - expect( undoState ).toEqual( expectedUndoState ); - } ); - - it( 'does not create new levels for the same function edits', () => { - const value = () => {}; - undoState = createNextUndoState(); - undoState = createNextUndoState( { value } ); - undoState = createNextUndoState( { value: () => {} } ); - expect( undoState ).toEqual( expectedUndoState ); - } ); -} ); - describe( 'embedPreviews()', () => { it( 'returns an empty object by default', () => { const state = embedPreviews( undefined, {} ); diff --git a/packages/core-data/src/test/selectors.js b/packages/core-data/src/test/selectors.js index 84fecc7d07cda9..161d0af4ea5bca 100644 --- a/packages/core-data/src/test/selectors.js +++ b/packages/core-data/src/test/selectors.js @@ -22,7 +22,6 @@ import { getAutosave, getAutosaves, getCurrentUser, - getReferenceByDistinctEdits, } from '../selectors'; // getEntityRecord and __experimentalGetEntityRecordNoResolver selectors share the same tests. describe.each( [ @@ -835,56 +834,3 @@ describe( 'getCurrentUser', () => { expect( getCurrentUser( state ) ).toEqual( currentUser ); } ); } ); - -describe( 'getReferenceByDistinctEdits', () => { - it( 'should return referentially equal values across empty states', () => { - const state = { undo: { list: [] } }; - expect( getReferenceByDistinctEdits( state ) ).toBe( - getReferenceByDistinctEdits( state ) - ); - - const beforeState = { undo: { list: [] } }; - const afterState = { undo: { list: [] } }; - expect( getReferenceByDistinctEdits( beforeState ) ).toBe( - getReferenceByDistinctEdits( afterState ) - ); - } ); - - it( 'should return referentially equal values across unchanging non-empty state', () => { - const undoStates = { list: [ {} ] }; - const state = { undo: undoStates }; - expect( getReferenceByDistinctEdits( state ) ).toBe( - getReferenceByDistinctEdits( state ) - ); - - const beforeState = { undo: undoStates }; - const afterState = { undo: undoStates }; - expect( getReferenceByDistinctEdits( beforeState ) ).toBe( - getReferenceByDistinctEdits( afterState ) - ); - } ); - - describe( 'when adding edits', () => { - it( 'should return referentially different values across changing states', () => { - const beforeState = { undo: { list: [ {} ] } }; - beforeState.undo.offset = 0; - const afterState = { undo: { list: [ {}, {} ] } }; - afterState.undo.offset = 1; - expect( getReferenceByDistinctEdits( beforeState ) ).not.toBe( - getReferenceByDistinctEdits( afterState ) - ); - } ); - } ); - - describe( 'when using undo', () => { - it( 'should return referentially different values across changing states', () => { - const beforeState = { undo: { list: [ {}, {} ] } }; - beforeState.undo.offset = 1; - const afterState = { undo: { list: [ {}, {} ] } }; - afterState.undo.offset = 0; - expect( getReferenceByDistinctEdits( beforeState ) ).not.toBe( - getReferenceByDistinctEdits( afterState ) - ); - } ); - } ); -} ); diff --git a/packages/core-data/tsconfig.json b/packages/core-data/tsconfig.json index 031d697f8dbe6b..3fe698f758b54d 100644 --- a/packages/core-data/tsconfig.json +++ b/packages/core-data/tsconfig.json @@ -19,6 +19,7 @@ { "path": "../is-shallow-equal" }, { "path": "../private-apis" }, { "path": "../sync" }, + { "path": "../undo-manager" }, { "path": "../url" } ], "include": [ "src/**/*" ] diff --git a/packages/dependency-extraction-webpack-plugin/lib/util.js b/packages/dependency-extraction-webpack-plugin/lib/util.js index a22837af6a72e9..ac637eb33fbb2f 100644 --- a/packages/dependency-extraction-webpack-plugin/lib/util.js +++ b/packages/dependency-extraction-webpack-plugin/lib/util.js @@ -1,5 +1,9 @@ const WORDPRESS_NAMESPACE = '@wordpress/'; -const BUNDLED_PACKAGES = [ '@wordpress/icons', '@wordpress/interface' ]; +const BUNDLED_PACKAGES = [ + '@wordpress/icons', + '@wordpress/interface', + '@wordpress/undo-manager', +]; /** * Default request to global transformation diff --git a/packages/undo-manager/.npmrc b/packages/undo-manager/.npmrc new file mode 100644 index 00000000000000..43c97e719a5a82 --- /dev/null +++ b/packages/undo-manager/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/undo-manager/CHANGELOG.md b/packages/undo-manager/CHANGELOG.md new file mode 100644 index 00000000000000..32b01e3da3a957 --- /dev/null +++ b/packages/undo-manager/CHANGELOG.md @@ -0,0 +1,4 @@ + + +## Unreleased + diff --git a/packages/undo-manager/README.md b/packages/undo-manager/README.md new file mode 100644 index 00000000000000..cc9727469bcadf --- /dev/null +++ b/packages/undo-manager/README.md @@ -0,0 +1,33 @@ +# Undo Manager + +A simple undo manager. + +## Installation + +Install the module + +```bash +npm install @wordpress/undo-manager --save +``` + +## API + + + +### createUndoManager + +Creates an undo manager. + +_Returns_ + +- `UndoManager`: Undo manager. + + + +## Contributing to this package + +This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. + +To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md). + +

Code is Poetry.

diff --git a/packages/undo-manager/package.json b/packages/undo-manager/package.json new file mode 100644 index 00000000000000..7ca465023b5e83 --- /dev/null +++ b/packages/undo-manager/package.json @@ -0,0 +1,37 @@ +{ + "name": "@wordpress/undo-manager", + "version": "0.1.0", + "description": "A small package to manage undo/redo.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "undo", + "history" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/undo-manager/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/undo-manager" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=12" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "types": "build-types", + "sideEffects": false, + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/is-shallow-equal": "file:../is-shallow-equal" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/undo-manager/src/index.js b/packages/undo-manager/src/index.js new file mode 100644 index 00000000000000..379172943dfb02 --- /dev/null +++ b/packages/undo-manager/src/index.js @@ -0,0 +1,171 @@ +/** + * WordPress dependencies + */ +import isShallowEqual from '@wordpress/is-shallow-equal'; + +/** @typedef {import('./types').HistoryRecord} HistoryRecord */ +/** @typedef {import('./types').HistoryChange} HistoryChange */ +/** @typedef {import('./types').HistoryChanges} HistoryChanges */ +/** @typedef {import('./types').UndoManager} UndoManager */ + +/** + * Merge changes for a single item into a record of changes. + * + * @param {Record< string, HistoryChange >} changes1 Previous changes + * @param {Record< string, HistoryChange >} changes2 NextChanges + * + * @return {Record< string, HistoryChange >} Merged changes + */ +function mergeHistoryChanges( changes1, changes2 ) { + /** + * @type {Record< string, HistoryChange >} + */ + const newChanges = { ...changes1 }; + Object.entries( changes2 ).forEach( ( [ key, value ] ) => { + if ( newChanges[ key ] ) { + newChanges[ key ] = { ...newChanges[ key ], to: value.to }; + } else { + newChanges[ key ] = value; + } + } ); + + return newChanges; +} + +/** + * Adds history changes for a single item into a record of changes. + * + * @param {HistoryRecord} record The record to merge into. + * @param {HistoryChanges} changes The changes to merge. + */ +const addHistoryChangesIntoRecord = ( record, changes ) => { + const existingChangesIndex = record?.findIndex( + ( { id: recordIdentifier } ) => { + return typeof recordIdentifier === 'string' + ? recordIdentifier === changes.id + : isShallowEqual( recordIdentifier, changes.id ); + } + ); + const nextRecord = [ ...record ]; + + if ( existingChangesIndex !== -1 ) { + // If the edit is already in the stack leave the initial "from" value. + nextRecord[ existingChangesIndex ] = { + id: changes.id, + changes: mergeHistoryChanges( + nextRecord[ existingChangesIndex ].changes, + changes.changes + ), + }; + } else { + nextRecord.push( changes ); + } + return nextRecord; +}; + +/** + * Creates an undo manager. + * + * @return {UndoManager} Undo manager. + */ +export function createUndoManager() { + /** + * @type {HistoryRecord[]} + */ + let history = []; + /** + * @type {HistoryRecord} + */ + let stagedRecord = []; + /** + * @type {number} + */ + let offset = 0; + + const dropPendingRedos = () => { + history = history.slice( 0, offset || undefined ); + offset = 0; + }; + + const appendStagedRecordToLatestHistoryRecord = () => { + const index = history.length === 0 ? 0 : history.length - 1; + let latestRecord = history[ index ] ?? []; + stagedRecord.forEach( ( changes ) => { + latestRecord = addHistoryChangesIntoRecord( latestRecord, changes ); + } ); + stagedRecord = []; + history[ index ] = latestRecord; + }; + + /** + * Checks whether a record is empty. + * A record is considered empty if it the changes keep the same values. + * Also updates to function values are ignored. + * + * @param {HistoryRecord} record + * @return {boolean} Whether the record is empty. + */ + const isRecordEmpty = ( record ) => { + const filteredRecord = record.filter( ( { changes } ) => { + return Object.values( changes ).some( + ( { from, to } ) => + typeof from !== 'function' && + typeof to !== 'function' && + ! isShallowEqual( from, to ) + ); + } ); + return ! filteredRecord.length; + }; + + return { + /** + * Record changes into the history. + * + * @param {HistoryRecord=} record A record of changes to record. + * @param {boolean} isStaged Whether to immediately create an undo point or not. + */ + addRecord( record, isStaged = false ) { + const isEmpty = ! record || isRecordEmpty( record ); + if ( isStaged ) { + if ( isEmpty ) { + return; + } + record.forEach( ( changes ) => { + stagedRecord = addHistoryChangesIntoRecord( + stagedRecord, + changes + ); + } ); + } else { + dropPendingRedos(); + if ( stagedRecord.length ) { + appendStagedRecordToLatestHistoryRecord(); + } + if ( isEmpty ) { + return; + } + history.push( record ); + } + }, + + undo() { + if ( stagedRecord.length ) { + dropPendingRedos(); + appendStagedRecordToLatestHistoryRecord(); + } + offset -= 1; + }, + + redo() { + offset += 1; + }, + + getUndoRecord() { + return history[ history.length - 1 + offset ]; + }, + + getRedoRecord() { + return history[ history.length + offset ]; + }, + }; +} diff --git a/packages/undo-manager/src/test/index.js b/packages/undo-manager/src/test/index.js new file mode 100644 index 00000000000000..32ec2713f7bc28 --- /dev/null +++ b/packages/undo-manager/src/test/index.js @@ -0,0 +1,257 @@ +/** + * Internal dependencies + */ +import { createUndoManager } from '../'; + +describe( 'Undo Manager', () => { + it( 'stacks undo levels', () => { + const undo = createUndoManager(); + + undo.addRecord( [ + { id: '1', changes: { value: { from: undefined, to: 1 } } }, + ] ); + expect( undo.getUndoRecord() ).toEqual( [ + { id: '1', changes: { value: { from: undefined, to: 1 } } }, + ] ); + + undo.addRecord( [ + { id: '1', changes: { value: { from: 1, to: 2 } } }, + ] ); + undo.addRecord( [ + { id: '1', changes: { value: { from: 2, to: 3 } } }, + ] ); + expect( undo.getUndoRecord() ).toEqual( [ + { id: '1', changes: { value: { from: 2, to: 3 } } }, + ] ); + } ); + + it( 'handles undos/redos', () => { + const undo = createUndoManager(); + undo.addRecord( [ + { id: '1', changes: { value: { from: undefined, to: 1 } } }, + ] ); + undo.addRecord( [ + { id: '1', changes: { value: { from: 1, to: 2 } } }, + ] ); + undo.addRecord( [ + { id: '1', changes: { value: { from: 2, to: 3 } } }, + ] ); + + undo.undo(); + expect( undo.getUndoRecord() ).toEqual( [ + { id: '1', changes: { value: { from: 1, to: 2 } } }, + ] ); + expect( undo.getRedoRecord() ).toEqual( [ + { id: '1', changes: { value: { from: 2, to: 3 } } }, + ] ); + + undo.undo(); + expect( undo.getUndoRecord() ).toEqual( [ + { id: '1', changes: { value: { from: undefined, to: 1 } } }, + ] ); + expect( undo.getRedoRecord() ).toEqual( [ + { id: '1', changes: { value: { from: 1, to: 2 } } }, + ] ); + + undo.redo(); + undo.redo(); + expect( undo.getUndoRecord() ).toEqual( [ + { id: '1', changes: { value: { from: 2, to: 3 } } }, + ] ); + + undo.addRecord( [ + { id: '1', changes: { value: { from: 3, to: 4 } } }, + ] ); + expect( undo.getUndoRecord() ).toEqual( [ + { id: '1', changes: { value: { from: 3, to: 4 } } }, + ] ); + + // Check that undoing and editing will slice of + // all the levels after the current one. + undo.undo(); + undo.undo(); + undo.addRecord( [ + { id: '1', changes: { value: { from: 2, to: 5 } } }, + ] ); + undo.undo(); + expect( undo.getUndoRecord() ).toEqual( [ + { id: '1', changes: { value: { from: 1, to: 2 } } }, + ] ); + } ); + + it( 'handles staged edits', () => { + const undo = createUndoManager(); + undo.addRecord( [ + { id: '1', changes: { value: { from: undefined, to: 1 } } }, + ] ); + undo.addRecord( + [ { id: '1', changes: { value2: { from: undefined, to: 2 } } } ], + true + ); + undo.addRecord( + [ { id: '1', changes: { value: { from: 1, to: 3 } } } ], + true + ); + undo.addRecord( [ + { id: '1', changes: { value: { from: 3, to: 4 } } }, + ] ); + undo.undo(); + expect( undo.getUndoRecord() ).toEqual( [ + { + id: '1', + changes: { + value: { from: undefined, to: 3 }, + value2: { from: undefined, to: 2 }, + }, + }, + ] ); + } ); + + it( 'handles explicit undo level creation', () => { + const undo = createUndoManager(); + undo.addRecord( [ + { id: '1', changes: { value: { from: undefined, to: 1 } } }, + ] ); + // These three calls do nothing because they're empty. + undo.addRecord( [] ); + undo.addRecord(); + undo.addRecord( [ + { id: '1', changes: { value: { from: 1, to: 1 } } }, + ] ); + // Check that nothing happens if there are no pending + // transient edits. + undo.undo(); + expect( undo.getUndoRecord() ).toBe( undefined ); + undo.redo(); + + // Check that transient edits are merged into the last + // edits. + undo.addRecord( + [ { id: '1', changes: { value2: { from: undefined, to: 2 } } } ], + true + ); + undo.addRecord( [] ); // Records the staged edits. + undo.undo(); + expect( undo.getRedoRecord() ).toEqual( [ + { + id: '1', + changes: { + value: { from: undefined, to: 1 }, + value2: { from: undefined, to: 2 }, + }, + }, + ] ); + } ); + + it( 'explicitly creates an undo level when undoing while there are pending transient edits', () => { + const undo = createUndoManager(); + undo.addRecord( [ + { id: '1', changes: { value: { from: undefined, to: 1 } } }, + ] ); + undo.addRecord( + [ { id: '1', changes: { value2: { from: undefined, to: 2 } } } ], + true + ); + undo.undo(); + expect( undo.getRedoRecord() ).toEqual( [ + { + id: '1', + changes: { + value: { from: undefined, to: 1 }, + value2: { from: undefined, to: 2 }, + }, + }, + ] ); + } ); + + it( 'supports records as ids', () => { + const undo = createUndoManager(); + + undo.addRecord( + [ + { + id: { kind: 'postType', name: 'post', recordId: 1 }, + changes: { value: { from: undefined, to: 1 } }, + }, + ], + true + ); + undo.addRecord( + [ + { + id: { kind: 'postType', name: 'post', recordId: 1 }, + changes: { value2: { from: undefined, to: 2 } }, + }, + ], + true + ); + undo.addRecord( + [ + { + id: { kind: 'postType', name: 'post', recordId: 2 }, + changes: { value: { from: undefined, to: 3 } }, + }, + ], + true + ); + undo.addRecord(); + expect( undo.getUndoRecord() ).toEqual( [ + { + id: { kind: 'postType', name: 'post', recordId: 1 }, + changes: { + value: { from: undefined, to: 1 }, + value2: { from: undefined, to: 2 }, + }, + }, + { + id: { kind: 'postType', name: 'post', recordId: 2 }, + changes: { + value: { from: undefined, to: 3 }, + }, + }, + ] ); + } ); + + it( 'should ignore empty records', () => { + const undo = createUndoManager(); + + // All the following changes are considered empty for different reasons. + undo.addRecord(); + undo.addRecord( [] ); + undo.addRecord( [ + { id: '1', changes: { a: { from: 'value', to: 'value' } } }, + ] ); + undo.addRecord( [ + { + id: '1', + changes: { + a: { from: 'value', to: 'value' }, + b: { from: () => {}, to: () => {} }, + }, + }, + ] ); + + expect( undo.getUndoRecord() ).toBeUndefined(); + + // The following changes is not empty + // and should also record the function changes in the history. + + undo.addRecord( [ + { + id: '1', + changes: { + a: { from: 'value1', to: 'value2' }, + b: { from: () => {}, to: () => {} }, + }, + }, + ] ); + + const undoRecord = undo.getUndoRecord(); + expect( undoRecord ).not.toBeUndefined(); + // b is included in the changes. + expect( Object.keys( undoRecord[ 0 ].changes ) ).toEqual( [ + 'a', + 'b', + ] ); + } ); +} ); diff --git a/packages/undo-manager/src/types.ts b/packages/undo-manager/src/types.ts new file mode 100644 index 00000000000000..e2e1d995f8e5db --- /dev/null +++ b/packages/undo-manager/src/types.ts @@ -0,0 +1,19 @@ +export type HistoryChange = { + from: any; + to: any; +}; + +export type HistoryChanges = { + id: string | Record< string, any >; + changes: Record< string, HistoryChange >; +}; + +export type HistoryRecord = Array< HistoryChanges >; + +export type UndoManager = { + addRecord: ( record: HistoryRecord, isStaged: boolean ) => void; + undo: () => void; + redo: () => void; + getUndoRecord: () => HistoryRecord; + getRedoRecord: () => HistoryRecord; +}; diff --git a/packages/undo-manager/tsconfig.json b/packages/undo-manager/tsconfig.json new file mode 100644 index 00000000000000..e53ddb4792e576 --- /dev/null +++ b/packages/undo-manager/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "declarationDir": "build-types", + "types": [ "node" ] + }, + "references": [ { "path": "../is-shallow-equal" } ], + "include": [ "src/**/*" ] +} diff --git a/tools/webpack/packages.js b/tools/webpack/packages.js index e5bb74abdb0a1f..3dc8407d7974b9 100644 --- a/tools/webpack/packages.js +++ b/tools/webpack/packages.js @@ -24,7 +24,11 @@ const WORDPRESS_NAMESPACE = '@wordpress/'; // Experimental or other packages that should be private are bundled when used. // That way, we can iterate on these package without making them part of the public API. // See: https://github.com/WordPress/gutenberg/pull/19809 -const BUNDLED_PACKAGES = [ '@wordpress/icons', '@wordpress/interface' ]; +const BUNDLED_PACKAGES = [ + '@wordpress/icons', + '@wordpress/interface', + '@wordpress/undo-manager', +]; // PHP files in packages that have to be copied during build. const bundledPackagesPhpConfig = [ diff --git a/tsconfig.json b/tsconfig.json index 2c395450fb6a0e..4ee1787a247cf7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -44,6 +44,7 @@ { "path": "packages/style-engine" }, { "path": "packages/sync" }, { "path": "packages/token-list" }, + { "path": "packages/undo-manager" }, { "path": "packages/url" }, { "path": "packages/warning" }, { "path": "packages/wordcount" }