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.
npm install @kvndy/undo-manager
//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.
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);
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
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
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.
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.
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.
A Signals computed
whose value
getter returns a boolean that provides if undo is possible.
assert.equal(canUndo.value, true);
A Signals computed
whose value
getter returns a boolean that provides if redo is possible.
assert.equal(canRedo.value, true);
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");
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");
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();
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
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.
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.
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.
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.
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.
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.
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.
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.
- Type of return value for
undoDescription
andredoDescription
changed from<string | null>
to<string | undefined>
. - Type of parameter
description
for typeLocalizer
changed fromunknown
toany
. - Type of parameter
description
forundoable
changed fromunknown
toany
. - Type of parameter
description
forgroup
changed fromunknown
toany
.
MIT
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.