@kvndy/undo-manager
TypeScript icon, indicating that this package has built-in type declarations

6.1.2 • Public • Published

UndoManager

This package provides undo and redo management for Preact and Solid Signals, Preact Signals for React, Svelte Stores, Vue Shallow Refs, and React Hooks. Its innovation is in having effectively two separate data models. Changes to one class, the Undoable, automatically register with an undo stack. Changes to a second class, the Preservable, do not themselves result in a new addition to the undo stack, but as their name would suggest are preserved when a change to an Undoable is made.

Undoables are meant to provide navigation of user data through history in a document-based architecture for web-based apps. Preservables are meant to restore presentational state and visual appearance. In other words, they take the user to where they were when they made the change. The canonical example is preserving expanded/collapsed state of a tree view with disclosure triangles.

An aspect of Preservables which may not be immediately intuitive is their value as restored by navigating the undo stack depends on the direction traveled. The value of a Preservable when reaching a certain state of an Undoable via undo may be different when reaching the very same state of an Undoable via redo. This is accomplished by capturing the value of all Preservables both before and after a change to an Undoable.

When undoing, the user typically wants appearances to be as they were right before they made a change. When redoing, the user typically wants appearances to be as they were right after a change. Without this behavior it would be impossible to see the location of any changes made because navigation would take them somewhere else every time.

If two consecutive undos drastically change the layout of an app making a change difficult to spot, when immediately followed by a redo appearances would align with user expectation. Toggling undo state back and forth to attract the eye to the location of change naturally occurs to the user, and is an inherent feature which does not need to be documented.

Installation

npm install @kvndy/undo-manager

API

//import { UndoManager } from "@kvndy/undo-manager"; // legacy
import { UndoManager } from "@kvndy/undo-manager/preact-signals"; // Javascript
//import { UndoManager, type Undoable, type Preservable, type Localizer } from "@kvndy/undo-manager/preact-signals"; // Typescript
//const { UndoManager, type Undoable, type Preservable, type Localizer } = require("@kvndy/undo-manager/preact-signals"); // Node
//import { UndoManager, type Undoable, type Preservable, type Localizer } from "@kvndy/undo-manager/svelte-stores"; // Svelte
//import { UndoManager, type Undoable, type Preservable, type Localizer } from "@kvndy/undo-manager/solid-signals"; // Solid
//import { UndoManager, type Undoable, type Preservable, type Localizer } from "@kvndy/undo-manager/vue-shallow-refs"; // Vue
//import { UndoManager, type Undoable, type Preservable, type Localizer } from "@kvndy/undo-manager/preact-signals-react"; // React Signals
//import { UndoManager, type Undoable, type Preservable, type Localizer } from "@kvndy/undo-manager/react-hooks"; // React Hooks

The single export is the UndoManager object constructor which exposes primitives to be used for managing the undo stack.

new UndoManager(undoLocalizer, redoLocalizer, maxCount)

Creates a new UndoManager object for managing an undo stack. Its first two parameters are both optional functions meant to generate strings to be used as tooltips or menu items describing what specific change an undo or redo would produce. Each function is in turn passed a single argument of the developer’s choosing.

undoLocalizer is a function that generates the undoDescription for a given change to an Undoable. It has one parameter, the description from an Undoable or group, and should return a string or null.

redoLocalizer is a function that generates the redoDescription for a given change to an Undoable. It has one parameter, the description from an Undoable or group, and should return a string or null.

maxCount is a positive integer that determines the size of the undo stack. The default is Infinity.

const undoLocalizer = (description) => {
	return "Undo " + description;
}
const redoLocalizer = (description) => {
	return "Redo " + description;
}
const { undoable, preservable, group, undo, redo, canUndo, canRedo, undoDescription, redoDescription } = new UndoManager(undoLocalizer, redoLocalizer);

undoable(value, description, coalescing)

Creates an Undoable object which is meant to be used in place of a Signal. It privately maintains a Signal to hold its value and provide timely UI updates. It exposes a similar API as a Signal, with value getter and setter accessors.

value is passed along to its Signal upon creation.

description is an optional object which is passed to the undoLocalizer and redoLocalizer functions to generate an undoDescription and redoDescription. Pass null or undefined to bypass for no description. There is an alternative method for more dynamic descriptions using group.

coalescing is an optional object with a default value of false but is not limited to booleans. When true, multiple successive changes to an Undoable only register as a single change. When an object, referential equality determines if changes can also coalesce with a group using the same object.

const setting = undoable(0, "change setting", true);
setting.value = 1; // registers for undo

preservable(value, interrupting)

Creates a Preservable object which is also meant to be used in place of a Signal, privately maintains one of its own, and exposes a similar API as a Signal through value getter and setter accessors.

value is passed along to its Signal upon creation.

interrupting is an optional boolean that specifies if changes inhibit coalescing when not called from within an enclosing group.

const appearance = preservable(0, true);
appearance.value = 2; // does not register for undo
setting.value = 3; // previous appearance value of 2 is captured as both its before state and after state
appearance.value = 4; // not captured as the after state of the previous change to setting

group(callback, description, coalescing)

Makes use of the Signals batch function which permits multiple signal writes into one update. A change to a Preservable is considered made after any change to an Undoable regardless of call order. The description and coalescing key from the outer group are used.

callback is the function which gets passed to a Signals batch call.

description is an optional object similar to the second parameter of undoable and is not limited to strings. The undoLocalizer or redoLocalizer functions can be written to handle an array or other object for more precise and dynamic descriptions of a change. Pass null or undefined to bypass for no description.

coalescing is an optional object similar to the third parameter of undoable and is not limited to booleans. If true, the description is used as a unique key to determine if changes should be coalesced. Otherwise if the argument is not null, undefined, or false it is used as the unique key.

group( () => {
	setting.value = 5; // registers for undo
	appearance.value = 6; // properly registers as the after change value
}, "change setting and more", true); // does not coalesce with previous change
group( () => {
	setting.value = 7; // registers for undo
	appearance.value = 8; // properly registers as the after change value
}, "change setting and more", true); // does coalesce with previous change

It is a commonly held belief that parameters should not come after a function, but rather before for readability. Not adhering to this was a concious choice as the second and third parameter are optional.

undo()

Navigates to the previous state.

undo();
assert.equal(setting.value, 3); // both grouped changes were coalesced and now undone
assert.equal(appearance.value, 4); // this was the value before those changes were made
undo();
assert.equal(setting.value, 1);
assert.equal(appearance.value, 2);

Calling undo within a group will silently fail.

redo()

Navigates to the next state.

redo();
assert.equal(setting.value, 3); // arriving at same value from a different direction
assert.equal(appearance.value, 2); // as commented above, value did not change

Calling redo within a group will silently fail.

canUndo

A Signals computed whose value getter returns a boolean that provides if undo is possible.

assert.equal(canUndo.value, true);

canRedo

A Signals computed whose value getter returns a boolean that provides if redo is possible.

assert.equal(canRedo.value, true);

undoDescription

A Signals computed whose value getter returns the result of the undoLocalizer function passed to the UndoManager constructor.

assert.equal(undoDescription.value, "Undo change setting");

redoDescription

A Signals computed whose value getter returns the result of the redoLocalizer function passed to the UndoManager constructor.

assert.equal(redoDescription.value, "Redo change setting and more");

Conceptual

For anything other than the simplest of use cases, all changes should be wrapped in a group. Consistency avoids confusion that may arise due to an overly generous optional API.

It is possible to create undoables and preservables after changes are underway to the undo stack. This is not considered best practice. Their value in the undo stack prior to their creation will be represented as their initial value.

const dont = undoable("just"); // just don't
dont.value = "dont";
undo();
undo();// before it existed
assert(dont.value, "just");
redo();
redo();

coalescing

For the strictest use, the coalescing parameter of undoable and group should only ever be passed a Symbol. It can be thought of as a coalescing key. Passing a boolean or string is for developer convenience and perfectly fine however. Its intended use is for but not limited to changes made by continuous dragging events.

Coalescing does not overwrite any captured preservable before values. Changing a preservable inside a group set to coalesce will not register as a before value even if there is no change to an undoable. It will be captured as an after value, regardless of interrupting behavior or any coalesced changes to an undoable that may follow after its group.

Navigating the undo stack via undo and redo will interrupt and prevent coalescing. Changes to preservables without a change to an undoable are lost on undo.

group( () => {
	appearance.value = 9; // will be registered as the after change value 
}, "conceptual section changes", true);
group( () => {
	setting.value = 7; 
}, "conceptual section changes", true); // coalesces
undo();
assert.equal(appearance.value, 2); // coalescing group did not affect before value
redo();
assert.equal(appearance.value, 9);
group( () => {
	appearance.value = 10;
}, "conceptual section changes", true); // does not coalesce
assert.equal(appearance.value, 10);
undo();
redo();
assert.equal(appearance.value, 9); // changes are lost

interrupting

If preservable changes are always wrapped in a group, the interrupting parameter of preservable can be ignored and omitted. If no changes are ever coalesced it can also be ignored and omitted. Only if both of these two conditions are not met does the author need to decide if changing preservable state should affect coalescing. Pass true to prevent coalescing or false to permit coalescing.

Implementations

Preact Signals

The value accessor must always be used, unlike Preact Signals which have an optimization for fine-grained reactivity which allow it to be omitted inside html components.

Typescript definitions

Sample code

Preact Signals for React

Currently, only the useSignals hook imported from @preact/signals-react/runtime is supported. Use with @preact/signals-react-transform has not been shown to work.

The return value types of Localizer are string or undefined, unlike every other package which return string or null.

Like Preact Signals for Preact, the value accessor must be used.

Typescript definitions

Sample code

Svelte Stores

Undoables and preservables are created in the same way as Svelte’s writable stores. They pass messages to a private store instance through the public API.

The get, set, update and $ accessors of Svelte Stores, along with subscribe, unsubscribe, and derived, work in place of the Preact Signals syntax shown above.

Typescript definitions

Sample code

Solid Signals

Undoables and preservables are created in the same way as Solid’s createSignal. They support the standard behavior of destructuring the getter and setter from the return value, which are to be used in place of the Preact Signals syntax shown above.

Undoables and preservables take an additional parameter for SignalOptions which gets passed to their private signal. Its equals function is used by undo-manager for comparison.

The setter types currently supported are passing a value and passing an update function. The value may be undefined, including a setter with no passed arguments.

The update function setter must be pure and without side effects, as it is used internally by both undo-manager and Solid, resulting in being called more than once.

Typescript definitions

Sample code

Vue Shallow Refs

Usage is mostly the same as with Preact Signals. Unlike shallow refs, the value accessor must be included when contained inside double curly braces. However, the value accessor must be excluded from text inside element attribute binding strings.

Behavior only differs with the absence of a batch function, which can affect the output of effects if they get called more than once.

Typescript definitions

Sample code

React Hooks

The exposed API of the UndoManager constructor varies from other implementations to conform with naming conventions for Hooks.

const { useUndoable, usePreservable, useCanUndo, useCanRedo, useUndoDescription, useRedoDescription, group, undo, redo } = new UndoManager(undoLocalizer, redoLocalizer);

useUndoable and usePreservable return an array with a value in the first position and a setter in the second. As with most hooks, these would typically be destructured.

The description second parameter to useUndoable is required and used as a unique key to differentiate between hooks. This value is also passed as the description parameter to Localizer functions to generate undo and redo descriptions. As in other implementations, the description second parameter to group overrides a description of an undoable. The description second parameter to group does not override the use as a unique key.

The method signature of usePreservable differs from other implementations in that it has the same second parameter description which is required and used as a unique key, like its useUndoable counterpart. This description is not used for any other purpose. The interrupting boolean follows in the third position.

There are currently no signficant tests to verify behavior. It should be noted that the code between various implementations does not vary significantly except in library-specific usage.

This implementation uses internally the hook useSyncExternalStore. It gets passed as a third argument the same getSnapshot function passed to the second, but its behavior when used on the backend is undefined.

Typescript definitions

Sample code

Example

Trivial examples for every supported configuration are included in the repo.

A non-virtual, non-animated tree view that preserves selection and expanded/collapsed state, for Preact: https://gitlab.com/kevindoughty/cute-tree

Live demo: https://kevindoughty.gitlab.io/cute-tree/index.html

This project uses a technique first developed for Objective-C: https://github.com/kevindoughty/cletustheslackjawedoutlineview.

Changelog

Version 6:

  • Type of return value for undoDescription and redoDescription changed from <string | null> to <string | undefined>.
  • Type of parameter description for type Localizer changed from unknown to any.
  • Type of parameter description for undoable changed from unknown to any.
  • Type of parameter description for group changed from unknown to any.

License

MIT

Issues and PRs

Welcome, especially for tooling and bundling, Typescript types and JSDoc definitions, tests for React Hooks, and API and implementation suggestions for Svelte Runes, Solid Stores, Vue Reactivity, and non-shallow Vue Refs.

Package Sidebar

Install

npm i @kvndy/undo-manager

Weekly Downloads

2

Version

6.1.2

License

MIT

Unpacked Size

286 kB

Total Files

58

Last publish

Collaborators

  • kvndy