Skip to content

Commit b0a3292

Browse files
committed
Fix multi-entity multi-property undo redo
1 parent f77958c commit b0a3292

File tree

4 files changed

+174
-163
lines changed

4 files changed

+174
-163
lines changed

packages/core-data/src/actions.js

+4-6
Original file line numberDiff line numberDiff line change
@@ -411,9 +411,8 @@ export const undo =
411411
return;
412412
}
413413
dispatch( {
414-
type: 'EDIT_ENTITY_RECORD',
415-
...undoEdit,
416-
meta: { isUndo: true },
414+
type: 'UNDO',
415+
stackedEdits: undoEdit,
417416
} );
418417
};
419418

@@ -429,9 +428,8 @@ export const redo =
429428
return;
430429
}
431430
dispatch( {
432-
type: 'EDIT_ENTITY_RECORD',
433-
...redoEdit,
434-
meta: { isRedo: true },
431+
type: 'REDO',
432+
stackedEdits: redoEdit,
435433
} );
436434
};
437435

packages/core-data/src/reducer.js

+124-107
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,26 @@ export function themeGlobalStyleVariations( state = {}, action ) {
183183
return state;
184184
}
185185

186+
const withMultiEntityRecordEdits = ( reducer ) => ( state, action ) => {
187+
if ( action.type === 'UNDO' || action.type === 'REDO' ) {
188+
const { stackedEdits } = action;
189+
190+
let newState = state;
191+
stackedEdits.forEach( ( { kind, name, recordId, edits } ) => {
192+
newState = reducer( newState, {
193+
type: 'EDIT_ENTITY_RECORD',
194+
kind,
195+
name,
196+
recordId,
197+
edits,
198+
} );
199+
} );
200+
return newState;
201+
}
202+
203+
return reducer( state, action );
204+
};
205+
186206
/**
187207
* Higher Order Reducer for a given entity config. It supports:
188208
*
@@ -196,6 +216,8 @@ export function themeGlobalStyleVariations( state = {}, action ) {
196216
*/
197217
function entity( entityConfig ) {
198218
return compose( [
219+
withMultiEntityRecordEdits,
220+
199221
// Limit to matching action type so we don't attempt to replace action on
200222
// an unhandled action.
201223
ifMatchingAction(
@@ -411,8 +433,9 @@ export const entities = ( state = {}, action ) => {
411433
/**
412434
* @typedef {Object} UndoStateMeta
413435
*
414-
* @property {number} offset Where in the undo stack we are.
415-
* @property {Object} [flattenedUndo] Flattened form of undo stack.
436+
* @property {number} list The undo stack.
437+
* @property {number} offset Where in the undo stack we are.
438+
* @property {Object} cache Cache of unpersisted transient edits.
416439
*/
417440

418441
/** @typedef {Array<Object> & UndoStateMeta} UndoState */
@@ -422,10 +445,7 @@ export const entities = ( state = {}, action ) => {
422445
*
423446
* @todo Given how we use this we might want to make a custom class for it.
424447
*/
425-
const UNDO_INITIAL_STATE = Object.assign( [], { offset: 0 } );
426-
427-
/** @type {Object} */
428-
let lastEditAction;
448+
const UNDO_INITIAL_STATE = { list: [], offset: 0 };
429449

430450
/**
431451
* Reducer keeping track of entity edit undo history.
@@ -436,107 +456,103 @@ let lastEditAction;
436456
* @return {UndoState} Updated state.
437457
*/
438458
export function undo( state = UNDO_INITIAL_STATE, action ) {
459+
const omitPendingRedos = ( currentState ) => {
460+
return {
461+
...currentState,
462+
list: currentState.list.slice(
463+
0,
464+
currentState.offset || undefined
465+
),
466+
offset: 0,
467+
};
468+
};
469+
470+
const appendCachedEditsToLastUndo = ( currentState ) => {
471+
if ( ! currentState.cache ) {
472+
return currentState;
473+
}
474+
475+
let nextState = {
476+
...currentState,
477+
list: [ ...currentState.list ],
478+
};
479+
nextState = omitPendingRedos( nextState );
480+
let previousUndoState = nextState.list.pop();
481+
currentState.cache.forEach( ( edit ) => {
482+
previousUndoState = appendEditsToStack( previousUndoState, edit );
483+
} );
484+
nextState.list.push( previousUndoState );
485+
486+
return {
487+
...nextState,
488+
cache: undefined,
489+
};
490+
};
491+
492+
const appendEditsToStack = (
493+
stack = [],
494+
{ kind, name, recordId, edits }
495+
) => {
496+
const existingEditIndex = stack?.findIndex(
497+
( { kind: k, name: n, recordId: r } ) => {
498+
return k === kind && n === name && r === recordId;
499+
}
500+
);
501+
502+
const nextStack = stack.filter(
503+
( _, index ) => index !== existingEditIndex
504+
);
505+
nextStack.push( {
506+
kind,
507+
name,
508+
recordId,
509+
edits: {
510+
...( existingEditIndex !== -1
511+
? stack[ existingEditIndex ].edits
512+
: {} ),
513+
...edits,
514+
},
515+
} );
516+
return nextStack;
517+
};
518+
439519
switch ( action.type ) {
440-
case 'EDIT_ENTITY_RECORD':
441520
case 'CREATE_UNDO_LEVEL':
442-
let isCreateUndoLevel = action.type === 'CREATE_UNDO_LEVEL';
443-
const isUndoOrRedo =
444-
! isCreateUndoLevel &&
445-
( action.meta.isUndo || action.meta.isRedo );
446-
if ( isCreateUndoLevel ) {
447-
action = lastEditAction;
448-
} else if ( ! isUndoOrRedo ) {
449-
// Don't lose the last edit cache if the new one only has transient edits.
450-
// Transient edits don't create new levels so updating the cache would make
451-
// us skip an edit later when creating levels explicitly.
452-
if (
453-
Object.keys( action.edits ).some(
454-
( key ) => ! action.transientEdits[ key ]
455-
)
456-
) {
457-
lastEditAction = action;
458-
} else {
459-
lastEditAction = {
460-
...action,
461-
edits: {
462-
...( lastEditAction && lastEditAction.edits ),
463-
...action.edits,
464-
},
465-
};
466-
}
467-
}
521+
return appendCachedEditsToLastUndo( state );
468522

469-
/** @type {UndoState} */
470-
let nextState;
471-
472-
if ( isUndoOrRedo ) {
473-
// @ts-ignore we might consider using Object.assign({}, state)
474-
nextState = [ ...state ];
475-
nextState.offset =
476-
state.offset + ( action.meta.isUndo ? -1 : 1 );
477-
478-
if ( state.flattenedUndo ) {
479-
// The first undo in a sequence of undos might happen while we have
480-
// flattened undos in state. If this is the case, we want execution
481-
// to continue as if we were creating an explicit undo level. This
482-
// will result in an extra undo level being appended with the flattened
483-
// undo values.
484-
// We also have to take into account if the `lastEditAction` had opted out
485-
// of being tracked in undo history, like the action that persists the latest
486-
// content right before saving. In that case we have to update the `lastEditAction`
487-
// to avoid returning early before applying the existing flattened undos.
488-
isCreateUndoLevel = true;
489-
if ( ! lastEditAction.meta.undo ) {
490-
lastEditAction.meta.undo = {
491-
edits: {},
492-
};
493-
}
494-
action = lastEditAction;
495-
} else {
496-
return nextState;
497-
}
498-
}
523+
case 'UNDO':
524+
case 'REDO': {
525+
const nextState = appendCachedEditsToLastUndo( state );
526+
return {
527+
...nextState,
528+
offset: state.offset + ( action.type === 'UNDO' ? -1 : 1 ),
529+
};
530+
}
499531

500-
if ( ! action.meta.undo ) {
501-
return state;
502-
}
532+
case 'EDIT_ENTITY_RECORD':
533+
const isCachedChange = Object.keys( action.edits ).every(
534+
( key ) => action.transientEdits[ key ]
535+
);
503536

504-
// Transient edits don't create an undo level, but are
505-
// reachable in the next meaningful edit to which they
506-
// are merged. They are defined in the entity's config.
507-
if (
508-
! isCreateUndoLevel &&
509-
! Object.keys( action.edits ).some(
510-
( key ) => ! action.transientEdits[ key ]
511-
)
512-
) {
513-
// @ts-ignore we might consider using Object.assign({}, state)
514-
nextState = [ ...state ];
515-
nextState.flattenedUndo = {
516-
...state.flattenedUndo,
517-
...action.edits,
537+
if ( isCachedChange ) {
538+
return {
539+
...state,
540+
cache: appendEditsToStack( state.cache, action ),
518541
};
519-
nextState.offset = state.offset;
520-
return nextState;
521542
}
522543

523-
// Clear potential redos, because this only supports linear history.
524-
nextState =
525-
// @ts-ignore this needs additional cleanup, probably involving code-level changes
526-
nextState || state.slice( 0, state.offset || undefined );
527-
nextState.offset = nextState.offset || 0;
528-
nextState.pop();
529-
if ( ! isCreateUndoLevel ) {
530-
nextState.push( {
531-
kind: action.meta.undo.kind,
532-
name: action.meta.undo.name,
533-
recordId: action.meta.undo.recordId,
534-
edits: {
535-
...state.flattenedUndo,
536-
...action.meta.undo.edits,
537-
},
538-
} );
539-
}
544+
let nextState = omitPendingRedos( state );
545+
nextState = appendCachedEditsToLastUndo( nextState );
546+
nextState = { ...nextState, list: [ ...nextState.list ] };
547+
const previousUndoState = nextState.list.pop();
548+
nextState.list.push(
549+
appendEditsToStack( previousUndoState, {
550+
kind: action.kind,
551+
name: action.name,
552+
recordId: action.recordId,
553+
edits: action.meta.undo.edits,
554+
} )
555+
);
540556
// When an edit is a function it's an optimization to avoid running some expensive operation.
541557
// We can't rely on the function references being the same so we opt out of comparing them here.
542558
const comparisonUndoEdits = Object.values(
@@ -546,15 +562,16 @@ export function undo( state = UNDO_INITIAL_STATE, action ) {
546562
( edit ) => typeof edit !== 'function'
547563
);
548564
if ( ! isShallowEqual( comparisonUndoEdits, comparisonEdits ) ) {
549-
nextState.push( {
550-
kind: action.kind,
551-
name: action.name,
552-
recordId: action.recordId,
553-
edits: isCreateUndoLevel
554-
? { ...state.flattenedUndo, ...action.edits }
555-
: action.edits,
556-
} );
565+
nextState.list.push( [
566+
{
567+
kind: action.kind,
568+
name: action.name,
569+
recordId: action.recordId,
570+
edits: action.edits,
571+
},
572+
] );
557573
}
574+
558575
return nextState;
559576
}
560577

packages/core-data/src/selectors.ts

+9-9
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ interface EntityConfig {
7373
kind: string;
7474
}
7575

76-
interface UndoState extends Array< Object > {
77-
flattenedUndo: unknown;
76+
interface UndoState {
77+
list: Array< Object >;
7878
offset: number;
7979
}
8080

@@ -889,7 +889,9 @@ function getCurrentUndoOffset( state: State ): number {
889889
* @return The edit.
890890
*/
891891
export function getUndoEdit( state: State ): Optional< any > {
892-
return state.undo[ state.undo.length - 2 + getCurrentUndoOffset( state ) ];
892+
return state.undo.list[
893+
state.undo.list.length - 2 + getCurrentUndoOffset( state )
894+
];
893895
}
894896

895897
/**
@@ -901,7 +903,9 @@ export function getUndoEdit( state: State ): Optional< any > {
901903
* @return The edit.
902904
*/
903905
export function getRedoEdit( state: State ): Optional< any > {
904-
return state.undo[ state.undo.length + getCurrentUndoOffset( state ) ];
906+
return state.undo.list[
907+
state.undo.list.length + getCurrentUndoOffset( state )
908+
];
905909
}
906910

907911
/**
@@ -1142,11 +1146,7 @@ export const hasFetchedAutosaves = createRegistrySelector(
11421146
export const getReferenceByDistinctEdits = createSelector(
11431147
// This unused state argument is listed here for the documentation generating tool (docgen).
11441148
( state: State ) => [],
1145-
( state: State ) => [
1146-
state.undo.length,
1147-
state.undo.offset,
1148-
state.undo.flattenedUndo,
1149-
]
1149+
( state: State ) => [ state.undo.list.length, state.undo.offset ]
11501150
);
11511151

11521152
/**

0 commit comments

Comments
 (0)