npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

ngrx-wieder

v13.0.0

Published

Lightweight undo-redo for NgRx

Downloads

2,201

Readme

npm-badge   travis-badge   codecov-badge

ngrx-wieder is a lightweight yet configurable solution for implementing undo-redo in Angular apps on top of NgRx. It's based on immer hence the name wieder (German for: again)

Example (Demo)

Prerequisites

Make sure you're using immer to update your NgRx (sub-)state. That means you're using mutating APIs to update the state while immer provides a new state behind the scenes. If you're just starting out, install immer with this command:

npm i immer

Installation

Install this library with the following command:

npm i ngrx-wieder

Usage

Firstly, extend the UndoRedoState with your custom state definition. This will prepare state properties for containing the undo-redo history. You can spread initialUndoRedoState into your initial state to initialze these properties:

// todo.state.ts
import { UndoRedoState, initialUndoRedoState } from "ngrx-wieder";

export interface State extends UndoRedoState {
  todos: Todo[];
}

export const initialState: State = {
  todos: [],
  ...initialUndoRedoState,
};

Then, you'll initialize ngrx-wieder and optionally pass a custom config. It'll return an object with the function createUndoRedoReducer which you can use just like createReducer from NgRx, however, state inside on will be a immer draft of the last state. If you'd rather not return the state in each on-reducer, you can use produceOn instead.

Tip: Inside on or produceOn you can access the original state of an immer.js draft, therefore the last state, with the original function.

// todo.reducer.ts
import { undoRedo, produceOn } from "ngrx-wieder";

// initialize ngrx-wieder with custom config
const { createUndoRedoReducer } = undoRedo({
  allowedActionTypes: [Actions.addTodo, Actions.removeTodo, Actions.toggleTodo],
});

export const reducer = createUndoRedoReducer(
  initialState,
  on(Actions.addTodo, (state, { text }) => {
    state.todos.push({ id: nextId(), text, checked: false });
    return state;
  }),
  on(Actions.toggleTodo, (state, { id }) => {
    const todo = state.todos.find((t) => t.id === id);
    todo.checked = !todo.checked;
    return state;
  }),
  produceOn(Actions.removeTodo, (state, { id }) => {
    state.todos.splice(
      state.todos.findIndex((t) => t.id === id),
      1
    );
  })
);

Then whenever you'd like to undo or redo one of the passed allowedActionTypes simply dispatch the corresponding actions:

this.store.dispatch({ type: "UNDO" });
this.store.dispatch({ type: "REDO" });

Configuration

| Option | Default | Description | | :--------------------- | :------------------------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------- | | allowedActionTypes | [] | Actions applicable for being undone/redone (leave empty to allow all actions) | | mergeActionTypes | [] | Types of actions whose state difference should be merged when they appear consecutively | | mergeRules | {} | Predicate dictionary for deciding whether differences from consecutive actions of the same type should be merged. Use action type as key and predicate as value. | | maxBufferSize | 32 | How many state differences should be buffered in either direction | | undoActionType | 'UNDO' | Override for the undo action's type | | redoActionType | 'REDO' | Override for the redo action's type | | breakMergeActionType | 'BREAK_MERGE' | Override for the break-merge action's type. | | clearActionType | 'CLEAR' | Override for the clear action's type | | trackActionPayload | false | Whether the action payloads should be kept in the history | | segmentationOverride | (action: Action) => undefined | Override for active segmentation based on key resolved from action |

History Selectors

You can access the undo-redo history through the included selectors after passing a selector that leads to your corresponding state feature to the createHistorySelectors factory function (you can also pass a segmenter as a second parameter):

// todo.selectors.ts
import { createHistorySelectors } from "ngrx-wieder";

// in this example the whole state is undoable
// otherwise return feature state
const selectFeature = (state: State) => state;
export const {
  selectHistory,
  selectCanUndo,
  selectCanRedo,
} = createHistorySelectors<State, State>(selectFeature);

The generated selectors could be used like this:

import * as fromTodo from "../todo.selectors";

@Component({
  selector: "my-undo-redo",
  template: `
    <button (click)="undo()" [disabled]="!(canUndo$ | async)">Undo</button>
    <button (click)="undo()" [disabled]="!(canRedo$ | async)">Redo</button>
  `,
})
export class UndoRedoComponent {
  canUndo$ = this.store.select(fromTodo.selectCanUndo());
  canRedo$ = this.store.select(fromTodo.selectCanRedo());

  constructor(private store: Store) {}
}

If you're using segmentation, you can override the history key by passing an object with a key property to the selector factory functions.

Dealing with consecutive changes

Sometimes you want to enable undo/redo in broader chunks than the ones you actually use for transforming your state. Take a range input for example:

@Component({
  selector: 'my-slider',
  template: `
    <input #rangeIn type="range" id="rangeIn" min="0" max="10" step="1"
      (change)="rangeChange()" (input)="rangeInput(rangeIn.value)">
  `
})
export class SliderComponent {

  // ...

  rangeChange() {
    this.store.dispatch({ type: 'BREAK_MERGE' })
  }

  rangeInput(count: number) {
    this.store.dispatch(new CountChange({ count })
  }
}

The method rangeInput will be called for any step that the slider is moved by the user. This method may also dispatch an action to update the state and thus display the result of moving the slider. When the user now wants to revert changing the range input, he'd have to retrace every single step that he moved the slider. Instead a more expectable redo behaviour would place the slider back where the user picked it up before.

To facilitate this you can specify the CountChange action as an action whose state changes are merged consecutively by passing its type to the configuration property mergeActionTypes (you can even get more fine grained by using predicates through the mergeRules property).

In order to break the merging at some point you can dispatch a special action of type BREAK_MERGE. A good place to do this for the range input would be inside the change input - which is called when the user drops the range knob (this is also covered in the example).

Clearing the undo/redo stack

You can clear the stack for undoable and redoable actions by dispatching a special clearing action:

this.store.dispatch({ type: "CLEAR" });

Switch-based Reducers

ngrx-wieder works by recording patches from immer and applying them based on dispatch of actions for perfoming undo and redo. While createUndoRedoReducer handles interaction with immer, this is not possible when you're using a reducer that is based on a switch-statement. In that case the reducer on which you want to apply the undo-redo feature has to update the NgRx state directly through immer. In order to let ngrx-wieder record the changes your reducer has to be adapted so that it can forward the patches from immer:

Before

import { produce } from "immer";

const reducer = (state: State, action: Actions): State =>
  produce(state, (nextState) => {
    switch (
      action.type
      /* action handling */
    ) {
    }
  });

After

import { produce, PatchListener } from "immer";

const reducer = (
  state: State,
  action: Actions,
  patchListener?: PatchListener
): State =>
  produce(
    state,
    (nextState) => {
      switch (
        action.type
        /* action handling */
      ) {
      }
    },
    patchListener
  );

Next you'll configure the undo-redo behaviour by instantiating undoRedo and wrapping your custom reducer with the wrapReducer function:

import { undoRedo } from "ngrx-wieder";

// initialize ngrx-wieder
const { wrapReducer } = undoRedo({
  allowedActionTypes: [Actions.addTodo, Actions.removeTodo, Actions.toggleTodo],
});

// wrap reducer inside meta-reducer to make it undoable
const undoableReducer = wrapReducer(reducer);

// wrap into exported function to keep Angular AOT working
export function myReducer(state = initialState, action) {
  return undoableReducer(state, action);
}

Segmentation

Segmentation provides distinct undo-redo stacks for multiple instances of the same kind of state. For example, this allows you to implement an application that can have multiple documents open at the same time in multiple tabs as illustrated by this state:

interface State {
  activeDocument: string;
  documents: { [id: string]: Document };
  canUndo: boolean;
  canRedo: boolean;
}

Now, when the user is viewing one document, he probably doesn't want to undo changes in a different one. In order to make this work, you need to inform ngrx-wieder about your segmentation by using createSegmentedUndoRedoReducer providing a segmenter. Note that any actions that change the result of the segmenter must not be undoable (here it's documentSwitch). Moreover, when tracking is active, canUndo and canRedo will reflect the active undo-redo stack.

// helper function for manipulating active document in reducer
const activeDocument = (state: TestState): Document =>
  state.documents[state.activeDocument];

const { createSegmentedUndoRedoReducer } = undoRedo({
  allowedActionTypes: [nameChange.type],
  track: true,
});

const reducer = createSegmentedUndoRedoReducer(
  initialState,
  (state) => state.activeDocument, // segmenter identifying undo-redo stack
  produceOn(nameChange, (state, action) => {
    activeDocument(state).name = action.name;
  }),
  produceOn(documentSwitch, (state, action) => {
    state.activeDocument = action.document;
  })
);

When you're using a switch-based reducer, simply pass the segmenter as a second argument to wrapReducer:

const {wrapReducer} = undoRedo({...})
const reducer = (state = initialState, action: Actions, listener?: PatchListener): State =>
    produce(state, next => {
        switch (action.type) {
          case nameChange.type:
            activeDocument(next).name = action.name
            return
          case documentSwitch.type:
            next.activeDocument = action.document
            return
        }
    }, listener)
return wrapReducer(reducer, state => state.activeDocument)

You can override the segmentation based on an action by providing segmentationOverride to the config. This way you can target a specific - possibly non-active - segment with actions. For example, the actions from above could contain an optional property targetDocument which you'd resolve with the following segmentationOverride:

const {createSegmentedUndoRedoReducer} = undoRedo({
    ...
    segmentationOverride: action => action.targetDocument
})