ngrx-undoable
Version:
[Redux](https://github.com/reactjs/redux)/[Ngrx](https://github.com/ngrx) implementation of [Undo/Redo](http://redux.js.org/docs/recipes/ImplementingUndoHistory.html) based on Actions instead of States
216 lines (149 loc) • 6.46 kB
text/typescript
import {
DoNStatesExist,
CalculateState,
TravelNStates,
CreateTravelOne,
UpdateHistory,
CreateSelectors,
CreateUndoableReducer
} from './interfaces/internal';
import {
Undoable,
Action,
Reducer,
UndoableReducer,
UndoableState
} from './interfaces/public';
import { UndoableTypes } from './undoable.action';
// when grouping actions we will get multidimensional arrays
// so this helper is used to flatten the history
const flatten = <T> (x: (T | T[])[]) => [].concat(...x) as T[]
// since the oldest past is the init action we never want to remove it from the past
const doNPastStatesExist: DoNStatesExist = (past, nStates) => past.length > nStates
const doNFutureStatesExist: DoNStatesExist = (future, nStates) => future.length >= nStates
// actions can be an array of arrays because of grouped actions, so we flatten it first
const calculateState: CalculateState = (reducer, actions, state) =>
flatten(actions).reduce(reducer, state)
const travelNStates: TravelNStates = (state, nStates = 1, travelOne) => {
if (nStates === 0) return state
return travelNStates(travelOne(state, nStates), --nStates, travelOne)
}
const createUndo: CreateTravelOne = reducer => (state, nStates = 1) => {
if (!doNPastStatesExist(state.past, nStates))
return state
const latestPast = state.past[state.past.length - 1]
const futureWithLatestPast = [ latestPast, ...state.future ]
const pastWithoutLatest = state.past.slice(0, -1)
return {
past : pastWithoutLatest,
present : calculateState(reducer, pastWithoutLatest),
future : futureWithLatestPast
}
}
const createRedo: CreateTravelOne = reducer => (state, nStates = 1) => {
if (!doNFutureStatesExist(state.future, nStates))
return state
const [ latestFuture, ...futureWithoutLatest ] = state.future
const pastWithLatestFuture = [ ...state.past, latestFuture ]
return {
past : pastWithLatestFuture,
present : calculateState(reducer, [ latestFuture ], state.present),
future : futureWithoutLatest
}
}
const updateHistory: UpdateHistory = (state, newPresent, action, comparator) => {
if (comparator(state.present, newPresent))
return state
const newPast = [ ...state.past, action ]
return {
past : newPast,
present : newPresent,
future : [ ]
}
}
const getPastActions = <S, A extends Action>(state: UndoableState<S, A>) => state.past
const getPresentAction = <S, A extends Action>(state: UndoableState<S, A>) => state.past[state.past.length - 1]
const getFutureActions = <S, A extends Action>(state: UndoableState<S, A>) => state.future
const getPresentState = <S, A extends Action>(state: UndoableState<S, A>) => state.present
const getPastActionsFlattened = <S, A extends Action>(state: UndoableState<S, A>) => flatten(state.past)
const getPresentActionFlattened = <S, A extends Action>(state: UndoableState<S, A>) => getPastActionsFlattened(state).slice(-1)[0]
const getFutureActionsFlattened = <S, A extends Action>(state: UndoableState<S, A>) => flatten(state.future)
const getLatestFutureAction = <S, A extends Action>(state: UndoableState<S, A>) => getFutureActions(state)[0]
const getLatestFutureActionFlattened = <S, A extends Action>(state: UndoableState<S, A>) => getFutureActionsFlattened(state)[0]
/**
* Creates the getFutureStates selector.
*
* The selector is mapping the future actions to future states.
* It uses `reduce` instead of `map`, because this way we can reuse the
* previous future state to calculate the next future state.
*
* @param reducer The Reducer that is used to replay actions from the future
*/
const createGetFutureStates = <S, A extends Action>(reducer: Reducer<S, A>) => (state: UndoableState<S, A>): S[] =>
getFutureActions(state)
.reduce(
(states, a, i) =>
Array.isArray(a) // check if action is grouped
? [ ...states, a.reduce(reducer, states[i]) ]
: [ ...states, reducer(states[i], a) ]
, [ getPresentState(state) ] as S[]
).slice(1) // We start with present to calculate future states, but present state should not part be of future so we slice it off
/**
* Creates the getPastStates selector.
*
* The selector is mapping the past actions to past states.
* It uses `reduce` instead of `map`, because this way we can reuse the
* previous past state to calculate the next past state.
*
* @param reducer The Reducer that is used to replay actions from the past
*/
const createGetPastStates = <S, A extends Action>(reducer: Reducer<S, A>) => (state: UndoableState<S, A>): S[] =>
getPastActions(state).reduce(
(states, a, i) =>
Array.isArray(a) // check if action is grouped
? [ ...states, a.reduce(reducer, states[i - 1]) ]
: [ ...states, reducer(states[i - 1], a) ]
, [ ] as S[]
).slice(0, -1) // Slice the last state since its the present
export const createSelectors = <S, A extends Action>(reducer: Reducer<S, A>) => {
return {
getPresentState,
getPastStates: createGetPastStates(reducer),
getFutureStates: createGetFutureStates(reducer),
getPastActions,
getPastActionsFlattened,
getPresentAction,
getPresentActionFlattened,
getFutureActions,
getFutureActionsFlattened,
getLatestFutureAction,
getLatestFutureActionFlattened
}
}
const createUndoableReducer: CreateUndoableReducer = (reducer, initAction, comparator) => {
const initialState = {
past : [ initAction ],
present : reducer(undefined, initAction),
future : [ ] as Action[]
}
const undo = createUndo(reducer)
const redo = createRedo(reducer)
return (state = initialState, action) => {
switch (action.type) {
case UndoableTypes.UNDO:
return travelNStates(state, action.payload, undo)
case UndoableTypes.REDO:
return travelNStates(state, action.payload, redo)
case UndoableTypes.GROUP:
return updateHistory(state, action.payload.reduce(reducer, state.present), action.payload, comparator)
default:
return updateHistory(state, reducer(state.present, action), action, comparator)
}
}
}
export const undoable: Undoable = (reducer, initAction = { type: 'ngrx-undoable/INIT' } as Action, comparator = (s1, s2) => s1 === s2) => {
return {
reducer : createUndoableReducer(reducer, initAction, comparator),
selectors : createSelectors(reducer)
}
}