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

4.0.13 • Public • Published

UndoManager

This package provides undo and redo management using Preact Signals. 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.

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

Installation

npm install @kvndy/undo-manager

API

import { UndoManager } from "@kvndy/undo-manager"; // Javascript
//import { UndoManager, Undoable, Preservable, Localizer } from "@kvndy/undo-manager"; // Typescript
//const { UndoManager } = require("@kvndy/undo-manager"); // Node

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);

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

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 presentational state should affect coalescing. Pass true to prevent coalescing or false to permit coalescing.

Example

A non-virtual, non-animated tree view that preserves selection and expanded/collapsed state:

https://gitlab.com/kevindoughty/cute-tree

Live demo:

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

License

MIT

Issues and PRs

Welcome, especially for tooling, bundling, or Typescript/JSDoc definitions.

Package Sidebar

Install

npm i @kvndy/undo-manager

Weekly Downloads

14

Version

4.0.13

License

MIT

Unpacked Size

268 kB

Total Files

26

Last publish

Collaborators

  • kvndy