@b2m9/reversible 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/index.d.mts +86 -0
- package/dist/index.mjs +257 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bob Massarczyk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# @b2m9/reversible
|
|
2
|
+
|
|
3
|
+
Headless, framework-agnostic undo/redo for any state. You give it reversible
|
|
4
|
+
operations; it gives you `undo`/`redo`/`jump` traversal, grouped transactions,
|
|
5
|
+
checkpoints, and timeline scrubbing. It does **not** own your state, but composes
|
|
6
|
+
with whatever store you already have.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @b2m9/reversible
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
ESM-only. Zero runtime dependencies.
|
|
13
|
+
|
|
14
|
+
## The model: command, not snapshot
|
|
15
|
+
|
|
16
|
+
Most undo libraries snapshot your whole state into a `past`/`future` stack.
|
|
17
|
+
`reversible` stores **inverse operations** instead. You `commit` a `do`/`undo`
|
|
18
|
+
pair; the engine runs `do` and remembers how to reverse it.
|
|
19
|
+
|
|
20
|
+
| | Snapshot-based | **@b2m9/reversible** |
|
|
21
|
+
| ---------------------- | ----------------------------------- | ------------------------------------------- |
|
|
22
|
+
| Coupling | Higher-order reducer / store plugin | Headless, zero-dep, any store or none |
|
|
23
|
+
| History model | Full copies of `present` | Inverse operations (`do`/`undo`) |
|
|
24
|
+
| Memory cost | State size × history depth | Inverse-payload size — usually ≪ a snapshot |
|
|
25
|
+
| Non-serializable state | Effectively unsupported | Supported — you write the inverse |
|
|
26
|
+
| Grouping | Auto-capture heuristics | Explicit `transaction()` boundary |
|
|
27
|
+
| What's undoable | Filter config | Caller decides — just don't `commit()` it |
|
|
28
|
+
|
|
29
|
+
The engine holds one ordered list of entries and a **cursor**:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
committed entries
|
|
33
|
+
┌──────┬──────┬──────┐ ┌──────┐
|
|
34
|
+
│ e1 │ e2 │ e3 │ cursor │ e4 │
|
|
35
|
+
└──────┴──────┴──────┘ ▲ └──────┘
|
|
36
|
+
undoable here redoable
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Everything left of the cursor can be undone; everything right can be redone.
|
|
40
|
+
"Present state" is **your app's**, not the engine's and it only knows how to move
|
|
41
|
+
along the timeline of effects.
|
|
42
|
+
|
|
43
|
+
> **The one responsibility you own:** `do` and `undo` must be true inverses.
|
|
44
|
+
> The engine keeps its own bookkeeping consistent; it cannot fix a broken inverse.
|
|
45
|
+
|
|
46
|
+
## One tiny example
|
|
47
|
+
|
|
48
|
+
The external `count` is the point: your state, wherever it lives.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { createHistory } from "@b2m9/reversible";
|
|
52
|
+
|
|
53
|
+
const history = createHistory();
|
|
54
|
+
let count = 0;
|
|
55
|
+
|
|
56
|
+
history.commit({
|
|
57
|
+
label: "increment",
|
|
58
|
+
do: () => {
|
|
59
|
+
count += 1;
|
|
60
|
+
},
|
|
61
|
+
undo: () => {
|
|
62
|
+
count -= 1;
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
history.undo(); // count === 0
|
|
67
|
+
history.redo(); // count === 1
|
|
68
|
+
history.jump(-1); // count === 0 — scrub the whole timeline
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## One transaction example
|
|
72
|
+
|
|
73
|
+
Group many operations into one atomic entry — undone and redone as a unit.
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
history.transaction('type "hello"', (tx) => {
|
|
77
|
+
for (const ch of "hello") {
|
|
78
|
+
tx.commit({
|
|
79
|
+
do: () => doc.append(ch),
|
|
80
|
+
undo: () => doc.trimEnd(1),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
history.undo(); // removes the whole word, not one letter
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Wire it to UI with `subscribe`, which hands each listener a meta snapshot:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
const off = history.subscribe((m) => {
|
|
92
|
+
undoBtn.disabled = !m.canUndo;
|
|
93
|
+
undoBtn.title = m.undoLabel ? `Undo: ${m.undoLabel}` : "Undo";
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Failure rules
|
|
98
|
+
|
|
99
|
+
- **Synchronous only.** `do`/`undo` must run synchronously. If one returns a
|
|
100
|
+
thenable, the engine throws loudly rather than fire-and-forget a promise it
|
|
101
|
+
can't reverse.
|
|
102
|
+
- **Atomic transactions (best-effort).** If anything throws before a
|
|
103
|
+
`transaction` builder returns, the applied operations roll back in reverse and
|
|
104
|
+
the entry never enters history. "Best-effort" because atomicity holds only as
|
|
105
|
+
far as your inverses allow.
|
|
106
|
+
- **Failed `commit` is a no-op.** If `do` throws, nothing is recorded, the cursor
|
|
107
|
+
doesn't move, and the redo branch is left intact.
|
|
108
|
+
- **Boundaries clamp.** `undo()`/`redo()` return `false` at the ends. `jump(n)`
|
|
109
|
+
clamps and returns the signed count of steps actually moved (`jump(n) === n`
|
|
110
|
+
means fully moved). A throw mid-`jump` stops at the last successful step.
|
|
111
|
+
- **A new commit after undo clears the redo branch.** Traversal never does.
|
|
112
|
+
- **Reentrancy throws.** Calling back into the engine from inside a running
|
|
113
|
+
`do`/`undo`/rollback throws. `subscribe` listeners fire after the operation
|
|
114
|
+
settles, so calling back from a listener is fine.
|
|
115
|
+
|
|
116
|
+
## API
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
const history = createHistory({ limit: 100 }); // limit is optional
|
|
120
|
+
|
|
121
|
+
history.commit({ label?, do, undo });
|
|
122
|
+
history.transaction(label, (tx) => tx.commit({ do, undo }));
|
|
123
|
+
|
|
124
|
+
history.undo(); // boolean
|
|
125
|
+
history.redo(); // boolean
|
|
126
|
+
history.jump(n); // signed steps moved
|
|
127
|
+
|
|
128
|
+
history.canUndo; // booleans / derived meta
|
|
129
|
+
history.canRedo;
|
|
130
|
+
history.undoLabel; // string | undefined
|
|
131
|
+
history.redoLabel;
|
|
132
|
+
history.position; // cursor in [0, length]
|
|
133
|
+
history.length;
|
|
134
|
+
|
|
135
|
+
history.checkpoint(name);
|
|
136
|
+
history.hasCheckpoint(name); // gate revertTo the way canUndo gates undo
|
|
137
|
+
history.revertTo(name); // throws if the name is unknown or was pruned
|
|
138
|
+
|
|
139
|
+
history.clear();
|
|
140
|
+
history.subscribe((meta) => { /* ... */ }); // returns unsubscribe
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
With `limit`, the oldest entries are evicted past the cap. Checkpoints anchor to
|
|
144
|
+
the entry they sit after, so they stay correct as eviction renumbers positions; a
|
|
145
|
+
checkpoint whose anchor is evicted (or dropped when a commit clears a redo branch)
|
|
146
|
+
is pruned, and `revertTo` on it throws.
|
|
147
|
+
|
|
148
|
+
## Non-goals
|
|
149
|
+
|
|
150
|
+
- **It doesn't own your state.** No `present`, no store, no deep-clone. A thin
|
|
151
|
+
state-owning convenience wrapper may land later, layered on top.
|
|
152
|
+
- **No async.** Reversing remote or async effects is a compensation-and-idempotency
|
|
153
|
+
problem this package deliberately does not solve.
|
|
154
|
+
- **No branching timelines.** Checkpoints are markers on one line, not forks.
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT © 2026 Bob Massarczyk
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* A reversible operation: a `do`/`undo` pair the engine runs and remembers how
|
|
4
|
+
* to reverse. Both must be **synchronous** and **true inverses** of each other —
|
|
5
|
+
* that is the one responsibility the caller owns.
|
|
6
|
+
*/
|
|
7
|
+
interface Reversible {
|
|
8
|
+
/** Optional label, surfaced as `undoLabel`/`redoLabel` for UI tooltips. */
|
|
9
|
+
label?: string;
|
|
10
|
+
/** Run now and on every redo. Must be synchronous. */
|
|
11
|
+
do: () => void;
|
|
12
|
+
/** Run on undo. Must be synchronous. */
|
|
13
|
+
undo: () => void;
|
|
14
|
+
}
|
|
15
|
+
/** The builder handed to a {@link History.transaction} callback. */
|
|
16
|
+
interface TransactionBuilder {
|
|
17
|
+
commit(action: Reversible): void;
|
|
18
|
+
}
|
|
19
|
+
/** A read-only snapshot of the engine's derived state, delivered to subscribers. */
|
|
20
|
+
interface HistoryMeta {
|
|
21
|
+
readonly canUndo: boolean;
|
|
22
|
+
readonly canRedo: boolean;
|
|
23
|
+
readonly undoLabel?: string;
|
|
24
|
+
readonly redoLabel?: string;
|
|
25
|
+
readonly position: number;
|
|
26
|
+
readonly length: number;
|
|
27
|
+
}
|
|
28
|
+
interface History {
|
|
29
|
+
/** Run an operation now and append it as a single undoable entry. */
|
|
30
|
+
commit(action: Reversible): void;
|
|
31
|
+
/**
|
|
32
|
+
* Group many operations into one atomic entry. Operations apply eagerly; if
|
|
33
|
+
* anything throws before `build` returns, the applied operations roll back in
|
|
34
|
+
* reverse, the entry never enters history, and the error rethrows.
|
|
35
|
+
*/
|
|
36
|
+
transaction(label: string, build: (tx: TransactionBuilder) => void): void;
|
|
37
|
+
/** Undo one entry. Returns `false` (a no-op) at the start of history. */
|
|
38
|
+
undo(): boolean;
|
|
39
|
+
/** Redo one entry. Returns `false` (a no-op) at the end of history. */
|
|
40
|
+
redo(): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Move `n` steps (`n < 0` undo, `n > 0` redo), clamped at the ends. Returns
|
|
43
|
+
* the signed number of steps actually moved, so `jump(n) === n` means "fully
|
|
44
|
+
* moved". A throw mid-jump stops at the last successful step and rethrows.
|
|
45
|
+
*/
|
|
46
|
+
jump(n: number): number;
|
|
47
|
+
readonly canUndo: boolean;
|
|
48
|
+
readonly canRedo: boolean;
|
|
49
|
+
readonly undoLabel?: string;
|
|
50
|
+
readonly redoLabel?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Cursor index in `[0, length]`. `canUndo === position > 0` and
|
|
53
|
+
* `canRedo === position < length`.
|
|
54
|
+
*/
|
|
55
|
+
readonly position: number;
|
|
56
|
+
readonly length: number;
|
|
57
|
+
/** Tag the current position with a name. Re-tagging an existing name moves it. */
|
|
58
|
+
checkpoint(name: string): void;
|
|
59
|
+
hasCheckpoint(name: string): boolean;
|
|
60
|
+
/** Jump to a checkpoint. Throws if the name is unknown or was pruned. */
|
|
61
|
+
revertTo(name: string): void;
|
|
62
|
+
/** Drop all entries and checkpoints; reset the cursor to 0. */
|
|
63
|
+
clear(): void;
|
|
64
|
+
/**
|
|
65
|
+
* Subscribe to history changes for UI binding; the listener reads its own
|
|
66
|
+
* application state. Returns an unsubscribe function.
|
|
67
|
+
*/
|
|
68
|
+
subscribe(listener: (meta: HistoryMeta) => void): () => void;
|
|
69
|
+
}
|
|
70
|
+
interface HistoryOptions {
|
|
71
|
+
/**
|
|
72
|
+
* Cap on retained entries. When exceeded, the oldest entries are evicted
|
|
73
|
+
* (observable through `length`/`canUndo`). Must be a positive integer.
|
|
74
|
+
*/
|
|
75
|
+
limit?: number;
|
|
76
|
+
}
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/core.d.ts
|
|
79
|
+
/**
|
|
80
|
+
* Create a headless undo/redo engine. It tracks only the undo/redo stacks and a
|
|
81
|
+
* cursor — your application state lives wherever it already does. See the
|
|
82
|
+
* package brief for the full design.
|
|
83
|
+
*/
|
|
84
|
+
declare function createHistory(options?: HistoryOptions): History;
|
|
85
|
+
//#endregion
|
|
86
|
+
export { type History, type HistoryMeta, type HistoryOptions, type Reversible, type TransactionBuilder, createHistory };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
//#region src/transaction.ts
|
|
2
|
+
/**
|
|
3
|
+
* True if `value` is a thenable (exposes a callable `then`). The single source of
|
|
4
|
+
* truth for the engine's synchronous-only contract: a `do`/`undo` or transaction
|
|
5
|
+
* builder that returns one has detached work the engine can never reverse.
|
|
6
|
+
*/
|
|
7
|
+
function isThenable(value) {
|
|
8
|
+
return value !== null && value !== void 0 && typeof value.then === "function";
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Run a transaction: apply each committed operation eagerly, and on success
|
|
12
|
+
* collapse them into one entry whose `do` replays forward and whose `undo`
|
|
13
|
+
* replays the inverses in strict reverse order.
|
|
14
|
+
*
|
|
15
|
+
* If anything throws before `build` returns — a committed op or the build
|
|
16
|
+
* callback itself — the applied operations roll back in reverse and no entry is
|
|
17
|
+
* recorded. Best-effort: if a rollback inverse itself throws, that error
|
|
18
|
+
* surfaces and rollback stops.
|
|
19
|
+
*/
|
|
20
|
+
function runTransaction(engine, label, build) {
|
|
21
|
+
engine.assertIdle();
|
|
22
|
+
engine.beginMutation();
|
|
23
|
+
const ops = [];
|
|
24
|
+
let closed = false;
|
|
25
|
+
const tx = { commit(action) {
|
|
26
|
+
if (closed) throw new Error("reversible: transaction builder used after the transaction finished");
|
|
27
|
+
engine.runGuarded(action.do);
|
|
28
|
+
ops.push({
|
|
29
|
+
do: action.do,
|
|
30
|
+
undo: action.undo
|
|
31
|
+
});
|
|
32
|
+
} };
|
|
33
|
+
try {
|
|
34
|
+
const result = build(tx);
|
|
35
|
+
closed = true;
|
|
36
|
+
if (isThenable(result)) {
|
|
37
|
+
if (result instanceof Promise) result.catch(() => {});
|
|
38
|
+
throw new TypeError("reversible: transaction builder must be synchronous (it returned a thenable)");
|
|
39
|
+
}
|
|
40
|
+
} catch (error) {
|
|
41
|
+
closed = true;
|
|
42
|
+
try {
|
|
43
|
+
for (let i = ops.length - 1; i >= 0; i -= 1) engine.runGuarded(ops[i].undo);
|
|
44
|
+
} finally {
|
|
45
|
+
engine.endMutation();
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
engine.endMutation();
|
|
50
|
+
if (ops.length === 0) return;
|
|
51
|
+
engine.append({
|
|
52
|
+
label,
|
|
53
|
+
do() {
|
|
54
|
+
for (const op of ops) engine.runGuarded(op.do);
|
|
55
|
+
},
|
|
56
|
+
undo() {
|
|
57
|
+
for (let i = ops.length - 1; i >= 0; i -= 1) engine.runGuarded(ops[i].undo);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
engine.notify();
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/core.ts
|
|
64
|
+
/** Sentinel anchor for a checkpoint at position 0 (before all entries). */
|
|
65
|
+
const START = Symbol("reversible.start");
|
|
66
|
+
/**
|
|
67
|
+
* Create a headless undo/redo engine. It tracks only the undo/redo stacks and a
|
|
68
|
+
* cursor — your application state lives wherever it already does. See the
|
|
69
|
+
* package brief for the full design.
|
|
70
|
+
*/
|
|
71
|
+
function createHistory(options = {}) {
|
|
72
|
+
const { limit } = options;
|
|
73
|
+
if (limit !== void 0 && (!Number.isInteger(limit) || limit < 1)) throw new TypeError("reversible: limit must be a positive integer");
|
|
74
|
+
const entries = [];
|
|
75
|
+
let cursor = 0;
|
|
76
|
+
const checkpoints = /* @__PURE__ */ new Map();
|
|
77
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
78
|
+
let mutating = false;
|
|
79
|
+
/** Reentrancy guard: reject any public mutation while user code is running. */
|
|
80
|
+
const assertIdle = () => {
|
|
81
|
+
if (mutating) throw new Error("reversible: reentrant engine call from inside a do/undo/rollback");
|
|
82
|
+
};
|
|
83
|
+
/** Run a user operation, throwing loudly if it returns a thenable. */
|
|
84
|
+
const runGuarded = (fn) => {
|
|
85
|
+
if (isThenable(fn())) throw new TypeError("reversible: do/undo must be synchronous (it returned a thenable)");
|
|
86
|
+
};
|
|
87
|
+
const buildMeta = () => Object.freeze({
|
|
88
|
+
canUndo: cursor > 0,
|
|
89
|
+
canRedo: cursor < entries.length,
|
|
90
|
+
undoLabel: cursor > 0 ? entries[cursor - 1].label : void 0,
|
|
91
|
+
redoLabel: cursor < entries.length ? entries[cursor].label : void 0,
|
|
92
|
+
position: cursor,
|
|
93
|
+
length: entries.length
|
|
94
|
+
});
|
|
95
|
+
const notify = () => {
|
|
96
|
+
if (listeners.size === 0) return;
|
|
97
|
+
const meta = buildMeta();
|
|
98
|
+
for (const listener of listeners) listener(meta);
|
|
99
|
+
};
|
|
100
|
+
const pruneCheckpoints = (removed) => {
|
|
101
|
+
if (checkpoints.size === 0 || removed.length === 0) return;
|
|
102
|
+
const removedSet = new Set(removed);
|
|
103
|
+
for (const [name, anchor] of checkpoints) if (anchor !== START && removedSet.has(anchor)) checkpoints.delete(name);
|
|
104
|
+
};
|
|
105
|
+
const append = (entry) => {
|
|
106
|
+
if (cursor < entries.length) pruneCheckpoints(entries.splice(cursor));
|
|
107
|
+
entries.push(entry);
|
|
108
|
+
cursor += 1;
|
|
109
|
+
if (limit !== void 0 && entries.length > limit) {
|
|
110
|
+
const overflow = entries.length - limit;
|
|
111
|
+
pruneCheckpoints(entries.splice(0, overflow));
|
|
112
|
+
cursor -= overflow;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Walk the cursor `n` steps, running each inverse/forward op. Clamps at the
|
|
117
|
+
* ends. May throw mid-way, leaving the cursor at the last successful step.
|
|
118
|
+
*/
|
|
119
|
+
const traverse = (n) => {
|
|
120
|
+
const dir = n < 0 ? -1 : 1;
|
|
121
|
+
let remaining = Math.abs(n);
|
|
122
|
+
while (remaining > 0) {
|
|
123
|
+
if (dir < 0) {
|
|
124
|
+
if (cursor === 0) break;
|
|
125
|
+
runGuarded(entries[cursor - 1].undo);
|
|
126
|
+
cursor -= 1;
|
|
127
|
+
} else {
|
|
128
|
+
if (cursor === entries.length) break;
|
|
129
|
+
runGuarded(entries[cursor].do);
|
|
130
|
+
cursor += 1;
|
|
131
|
+
}
|
|
132
|
+
remaining -= 1;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
/** Guarded traversal shared by jump/undo/redo/revertTo. Returns steps moved (signed). */
|
|
136
|
+
const move = (n) => {
|
|
137
|
+
const before = cursor;
|
|
138
|
+
mutating = true;
|
|
139
|
+
let caught;
|
|
140
|
+
try {
|
|
141
|
+
traverse(n);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
caught = { error };
|
|
144
|
+
} finally {
|
|
145
|
+
mutating = false;
|
|
146
|
+
}
|
|
147
|
+
const moved = cursor - before;
|
|
148
|
+
if (moved !== 0) notify();
|
|
149
|
+
if (caught) throw caught.error;
|
|
150
|
+
return moved;
|
|
151
|
+
};
|
|
152
|
+
const commit = (action) => {
|
|
153
|
+
assertIdle();
|
|
154
|
+
mutating = true;
|
|
155
|
+
try {
|
|
156
|
+
runGuarded(action.do);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
mutating = false;
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
mutating = false;
|
|
162
|
+
append({
|
|
163
|
+
label: action.label,
|
|
164
|
+
do: action.do,
|
|
165
|
+
undo: action.undo
|
|
166
|
+
});
|
|
167
|
+
notify();
|
|
168
|
+
};
|
|
169
|
+
const undo = () => {
|
|
170
|
+
assertIdle();
|
|
171
|
+
if (cursor === 0) return false;
|
|
172
|
+
move(-1);
|
|
173
|
+
return true;
|
|
174
|
+
};
|
|
175
|
+
const redo = () => {
|
|
176
|
+
assertIdle();
|
|
177
|
+
if (cursor === entries.length) return false;
|
|
178
|
+
move(1);
|
|
179
|
+
return true;
|
|
180
|
+
};
|
|
181
|
+
const jump = (n) => {
|
|
182
|
+
assertIdle();
|
|
183
|
+
if (n === 0) return 0;
|
|
184
|
+
return move(n);
|
|
185
|
+
};
|
|
186
|
+
const checkpoint = (name) => {
|
|
187
|
+
assertIdle();
|
|
188
|
+
checkpoints.set(name, cursor === 0 ? START : entries[cursor - 1]);
|
|
189
|
+
};
|
|
190
|
+
const hasCheckpoint = (name) => checkpoints.has(name);
|
|
191
|
+
const revertTo = (name) => {
|
|
192
|
+
assertIdle();
|
|
193
|
+
const anchor = checkpoints.get(name);
|
|
194
|
+
if (anchor === void 0) throw new Error(`reversible: unknown checkpoint "${name}"`);
|
|
195
|
+
move((anchor === START ? 0 : entries.indexOf(anchor) + 1) - cursor);
|
|
196
|
+
};
|
|
197
|
+
const clear = () => {
|
|
198
|
+
assertIdle();
|
|
199
|
+
if (entries.length === 0 && checkpoints.size === 0) return;
|
|
200
|
+
entries.length = 0;
|
|
201
|
+
cursor = 0;
|
|
202
|
+
checkpoints.clear();
|
|
203
|
+
notify();
|
|
204
|
+
};
|
|
205
|
+
const subscribe = (listener) => {
|
|
206
|
+
listeners.add(listener);
|
|
207
|
+
return () => {
|
|
208
|
+
listeners.delete(listener);
|
|
209
|
+
};
|
|
210
|
+
};
|
|
211
|
+
const internals = {
|
|
212
|
+
assertIdle,
|
|
213
|
+
beginMutation: () => {
|
|
214
|
+
mutating = true;
|
|
215
|
+
},
|
|
216
|
+
endMutation: () => {
|
|
217
|
+
mutating = false;
|
|
218
|
+
},
|
|
219
|
+
runGuarded,
|
|
220
|
+
append,
|
|
221
|
+
notify
|
|
222
|
+
};
|
|
223
|
+
return {
|
|
224
|
+
commit,
|
|
225
|
+
transaction: (label, build) => {
|
|
226
|
+
runTransaction(internals, label, build);
|
|
227
|
+
},
|
|
228
|
+
undo,
|
|
229
|
+
redo,
|
|
230
|
+
jump,
|
|
231
|
+
checkpoint,
|
|
232
|
+
hasCheckpoint,
|
|
233
|
+
revertTo,
|
|
234
|
+
clear,
|
|
235
|
+
subscribe,
|
|
236
|
+
get canUndo() {
|
|
237
|
+
return cursor > 0;
|
|
238
|
+
},
|
|
239
|
+
get canRedo() {
|
|
240
|
+
return cursor < entries.length;
|
|
241
|
+
},
|
|
242
|
+
get undoLabel() {
|
|
243
|
+
return cursor > 0 ? entries[cursor - 1].label : void 0;
|
|
244
|
+
},
|
|
245
|
+
get redoLabel() {
|
|
246
|
+
return cursor < entries.length ? entries[cursor].label : void 0;
|
|
247
|
+
},
|
|
248
|
+
get position() {
|
|
249
|
+
return cursor;
|
|
250
|
+
},
|
|
251
|
+
get length() {
|
|
252
|
+
return entries.length;
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
//#endregion
|
|
257
|
+
export { createHistory };
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@b2m9/reversible",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Headless, framework-agnostic undo/redo for any state — a small command stack of reversible operations with grouped transactions, checkpoints, and timeline scrubbing.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"checkpoint",
|
|
7
|
+
"command-pattern",
|
|
8
|
+
"headless",
|
|
9
|
+
"history",
|
|
10
|
+
"redo",
|
|
11
|
+
"state",
|
|
12
|
+
"time-travel",
|
|
13
|
+
"transaction",
|
|
14
|
+
"undo"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/b2m9/reversible#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/b2m9/reversible/issues"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"author": "Bob Massarczyk <bob@b2m9.com>",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/b2m9/reversible.git"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"type": "module",
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./dist/index.mjs",
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "vp pack",
|
|
40
|
+
"dev": "vp pack --watch",
|
|
41
|
+
"test": "vp test run",
|
|
42
|
+
"check": "vp check",
|
|
43
|
+
"check:exports": "vp pack && vp dlx publint && vp dlx @arethetypeswrong/cli --pack --profile esm-only",
|
|
44
|
+
"prepublishOnly": "vp run build"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^25.6.2",
|
|
48
|
+
"@typescript/native-preview": "7.0.0-dev.20260509.2",
|
|
49
|
+
"bumpp": "^11.1.0",
|
|
50
|
+
"typescript": "^6.0.3",
|
|
51
|
+
"vite-plus": "catalog:"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=22"
|
|
55
|
+
},
|
|
56
|
+
"packageManager": "pnpm@11.7.0"
|
|
57
|
+
}
|