npm package: @kvndy/undo-manager
Description: Undo and redo management for Preact and Solid Signals, Preact Signals for React, Svelte Stores, Vue Shallow Refs, React Hooks, and Node
Author: Kevin Doughty
Latest version: 6.2.4

# UndoManager

This package provides undo and redo management for Preact and Solid Signals, Preact Signals for React, Svelte Stores, Vue Shallow Refs, React Hooks, and Node. 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

```sh
npm install @kvndy/undo-manager
```

## API

```js
//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`.



```js
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.

```js
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`.

```js
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.

```js
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.

```js
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.

```js
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.

```js
assert.equal(canUndo.value, true);
```

### `canRedo`

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

```js
assert.equal(canRedo.value, true);
```

### `undoDescription`

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

```js
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.

```js
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.

```js
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.

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

```js
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](https://gitlab.com/kevindoughty/undo-manager/-/blob/master/preact-signals/source/types.ts)

[Sample code](https://gitlab.com/kevindoughty/undo-manager/-/blob/master/examples/preact-signals/src/index.tsx)

### 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.

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

[Typescript definitions](https://gitlab.com/kevindoughty/undo-manager/-/blob/master/preact-signals-react/source/types.ts)

[Sample code](https://gitlab.com/kevindoughty/undo-manager/-/blob/master/examples/preact-signals-react/src/App.tsx)

### 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](https://gitlab.com/kevindoughty/undo-manager/-/blob/master/svelte-stores/source/types.ts)

[Sample code](https://gitlab.com/kevindoughty/undo-manager/-/blob/master/examples/svelte-stores/src/App.svelte)

### 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](https://gitlab.com/kevindoughty/undo-manager/-/blob/master/solid-signals/source/types.ts)

[Sample code](https://gitlab.com/kevindoughty/undo-manager/-/blob/master/examples/solid-signals/src/App.jsx)

### 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](https://gitlab.com/kevindoughty/undo-manager/-/blob/master/vue-shallow-refs/source/types.ts)

[Sample code](https://gitlab.com/kevindoughty/undo-manager/-/blob/master/examples/vue-shallow-refs/src/App.vue)


### 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](https://gitlab.com/kevindoughty/undo-manager/-/blob/master/react-hooks/source/types.ts)

[Sample code](https://gitlab.com/kevindoughty/undo-manager/-/blob/master/examples/react-hooks/src/App.tsx)


## 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](https://gitlab.com/kevindoughty/cute-tree)

Live demo:
[https://kevindoughty.gitlab.io/cute-tree/index.html](https://kevindoughty.gitlab.io/cute-tree/index.html)

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

## Changelog

### Version 6.2.3:
* Type definition of `undoable` corrected from `undoable:<T=any>(value:any, ...` to `undoable:<T>(value:T, ...`.
* Type definition of `preservable` corrected from `preservable:<T=any>(value:any, ...` to `preservable:<T>(value:T, ...`.
* Interface for Vue corrected from `extends ShallowRef` to `extends ShallowRef<T>`.

### 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.