hyper-undo 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/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +179 -0
- package/dist/hyper-undo.min.js +1 -0
- package/index.js +1 -0
- package/package.json +44 -0
- package/src/emitter.js +22 -0
- package/src/filter.js +23 -0
- package/src/index.js +147 -0
- package/src/keys.js +48 -0
- package/src/primitives.js +184 -0
- package/src/scope.js +259 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# hyper-undo changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — unreleased
|
|
4
|
+
|
|
5
|
+
Initial release. DOM-state undo/redo via MutationObserver inverse-op replay.
|
|
6
|
+
|
|
7
|
+
- Single `MutationObserver` per scope records primitive mutations
|
|
8
|
+
(attr-set/add/remove, text, childList add/remove) with computable inverses
|
|
9
|
+
- Removed subtrees held by reference — undo restores the same live nodes
|
|
10
|
+
(listeners, focus, scroll, custom-attribute wiring preserved)
|
|
11
|
+
- Two batching modes: explicit `commit(label, fn)` and 500ms idle auto-batch
|
|
12
|
+
- Pause-before / commit-on-success helpers (`commitCaptured` / `discardCaptured`)
|
|
13
|
+
so a failed apply never leaves a no-op pair on the stack
|
|
14
|
+
- Global Cmd+Z / Cmd+Shift+Z / Cmd+Y handler with a configurable
|
|
15
|
+
`shadowKeydownIn` bypass list for in-page code editors
|
|
16
|
+
- Reuses hyperclayjs filter-attribute semantics
|
|
17
|
+
(`mutations-ignore` / `save-remove` / `save-ignore` / `save-freeze`)
|
|
18
|
+
- Default singleton on `document.body`; `create({ scope })` for multi-scope use
|
|
19
|
+
- Max-history eviction (default 100) releases removed-node references
|
|
20
|
+
- Standalone npm package + vendor bundle for hyperclayjs (`window.hyperclay.undo`)
|
|
21
|
+
|
|
22
|
+
### Refinements over the reference plan
|
|
23
|
+
|
|
24
|
+
Two correctness fixes applied to the locked plan's reference skeletons:
|
|
25
|
+
|
|
26
|
+
- `commitCaptured(label)` now flushes any pending idle batch BEFORE pushing the
|
|
27
|
+
captured commit. Without this, typing then immediately triggering a structural
|
|
28
|
+
op (before the 500ms idle close) would record the structural commit ahead of
|
|
29
|
+
the typing, inverting undo order.
|
|
30
|
+
- `undo.create({ scope })` delegates getters explicitly instead of
|
|
31
|
+
object-spreading the scope instance. Spreading would snapshot `canUndo` /
|
|
32
|
+
`canRedo` / `history` / `isPaused` at create time, freezing them; explicit
|
|
33
|
+
delegation keeps them live.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Miranda
|
|
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,179 @@
|
|
|
1
|
+
# hyper-undo
|
|
2
|
+
|
|
3
|
+
DOM-state undo/redo for self-editing HTML pages. A single `MutationObserver`
|
|
4
|
+
records primitive DOM mutations with computable inverses, batches them into
|
|
5
|
+
labelled commits, and replays them backward (undo) or forward (redo). Removed
|
|
6
|
+
subtrees are kept **by reference**, so undo restores the same live nodes:
|
|
7
|
+
event listeners, focus, scroll position, and custom-attribute wiring all
|
|
8
|
+
survive.
|
|
9
|
+
|
|
10
|
+
Works standalone, or auto-wired into [hyperclayjs](https://github.com/panphora/hyperclayjs)
|
|
11
|
+
as `window.hyperclay.undo`.
|
|
12
|
+
|
|
13
|
+
## Mental model
|
|
14
|
+
|
|
15
|
+
**The DOM is the state. Undo navigates between DOM states.** Each undoable
|
|
16
|
+
operation is a transition recorded as inverse-able primitives, not a full
|
|
17
|
+
snapshot. Snapshot-based undo re-clones from serialized HTML and loses live
|
|
18
|
+
node identity; mutation-based replay keeps it.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install hyper-undo
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
import { undo } from 'hyper-undo'
|
|
28
|
+
|
|
29
|
+
undo.start({ scope: document.body, maxHistory: 100, bindKeys: true })
|
|
30
|
+
|
|
31
|
+
undo.commit('User edited title', () => {
|
|
32
|
+
document.querySelector('h1').textContent = 'New title'
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
undo.undo() // restores prior state
|
|
36
|
+
undo.redo() // re-applies the undone state
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Or load the IIFE bundle directly (auto-attaches to `window.hyperclay.undo`):
|
|
40
|
+
|
|
41
|
+
```html
|
|
42
|
+
<script src="https://cdn.jsdelivr.net/npm/hyper-undo/dist/hyper-undo.min.js"></script>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Via hyperclayjs
|
|
46
|
+
|
|
47
|
+
The `smooth-sailing` preset includes hyper-undo and auto-starts the singleton on
|
|
48
|
+
`document.body` (in edit mode only, with `bindKeys: true`). Cmd+Z works out of
|
|
49
|
+
the box.
|
|
50
|
+
|
|
51
|
+
```html
|
|
52
|
+
<script type="module">
|
|
53
|
+
await import('https://cdn.jsdelivr.net/npm/hyperclayjs@1/src/hyperclay.js?preset=smooth-sailing')
|
|
54
|
+
hyperclay.undo.commit('Add product', () => addProduct())
|
|
55
|
+
</script>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## API
|
|
59
|
+
|
|
60
|
+
| Call | Effect |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `undo.start(opts)` | start the singleton on `opts.scope` (default `document.body`) |
|
|
63
|
+
| `undo.stop()` | disconnect observer, remove key bindings, clear stacks |
|
|
64
|
+
| `undo.commit(label, fn)` | run a synchronous `fn`, push its mutations as one labelled commit |
|
|
65
|
+
| `undo.commitCaptured(label)` | drain `observer.takeRecords()` and push as one commit (pause-before / commit-on-success pattern) |
|
|
66
|
+
| `undo.discardCaptured()` | drain and throw away the captured records (failure path companion) |
|
|
67
|
+
| `undo.flush()` | force-close the current idle batch as its own commit |
|
|
68
|
+
| `undo.undo()` / `undo.redo()` | navigate history |
|
|
69
|
+
| `undo.clear()` | clear both stacks |
|
|
70
|
+
| `undo.pause()` / `undo.resume()` | recorder skips while paused |
|
|
71
|
+
| `undo.on('change', fn)` / `undo.off('change', fn)` | subscribe to change events (fires after commit, undo, redo, clear) |
|
|
72
|
+
| `undo.canUndo` / `undo.canRedo` | booleans (getters) |
|
|
73
|
+
| `undo.history` | `[{ label, timestamp }, ...]`, oldest first; `timestamp` is `Date.now()` millis |
|
|
74
|
+
| `undo.isPaused` | boolean (getter) |
|
|
75
|
+
| `undo.defaults` | `{ shadowKeydownIn: [...] }` |
|
|
76
|
+
| `undo.create(opts)` | a separate scope for advanced multi-scope use |
|
|
77
|
+
|
|
78
|
+
### Options
|
|
79
|
+
|
|
80
|
+
| Option | Default | Notes |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `scope` | `document.body` | element to observe |
|
|
83
|
+
| `maxHistory` | `100` | older commits drop off the back; dropped commits release their removed-node references |
|
|
84
|
+
| `idleWindowMs` | `500` | how long to wait before auto-closing a batch |
|
|
85
|
+
| `idleLabel` | `'Edit'` | label for auto-closed batches |
|
|
86
|
+
| `bindKeys` | `true` (singleton), `false` (`create`) | install the global Cmd+Z handler |
|
|
87
|
+
| `shadowKeydownIn` | code-editor selectors (see below) | when `event.target.closest(selector)` matches, the global handler bails without `preventDefault` |
|
|
88
|
+
| `ignoreAttribute` | `null` | predicate `(attrName, element) => boolean`; return true to skip recording that attribute mutation |
|
|
89
|
+
| `debug` | `false` | console.log internal state transitions |
|
|
90
|
+
|
|
91
|
+
## Batching
|
|
92
|
+
|
|
93
|
+
Raw mutation records are too fine-grained (typing "hello" is five
|
|
94
|
+
`characterData` records). Two batching modes share one collector:
|
|
95
|
+
|
|
96
|
+
- **Explicit commit** — `undo.commit(label, fn)` wraps a synchronous chunk into
|
|
97
|
+
one labelled commit. Throws if `fn()` returns a Promise (mutations after the
|
|
98
|
+
first `await` would silently land in a different commit).
|
|
99
|
+
- **Idle auto-batch** — mutations made outside an explicit commit collect until
|
|
100
|
+
the scope is idle for `idleWindowMs`, then close into one `Edit` commit.
|
|
101
|
+
|
|
102
|
+
## Keyboard shortcuts
|
|
103
|
+
|
|
104
|
+
`bindKeys: true` installs a `window` keydown capture-phase listener:
|
|
105
|
+
|
|
106
|
+
| Combo | Action |
|
|
107
|
+
|---|---|
|
|
108
|
+
| Cmd+Z / Ctrl+Z | undo |
|
|
109
|
+
| Cmd+Shift+Z / Ctrl+Shift+Z | redo |
|
|
110
|
+
| Cmd+Y / Ctrl+Y | redo (Windows convention) |
|
|
111
|
+
|
|
112
|
+
### In-page editors
|
|
113
|
+
|
|
114
|
+
The handler short-circuits (without `preventDefault`) when `event.target` is
|
|
115
|
+
inside any selector in `shadowKeydownIn`, so an embedded editor's own keymap
|
|
116
|
+
handles the key. The default list covers CodeMirror v5/v6, Monaco, Ace, Quill,
|
|
117
|
+
Tiptap, and ProseMirror:
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
['.CodeMirror', '.cm-editor', '.monaco-editor', '.ace_editor', '.ql-editor', '.tiptap', '.ProseMirror']
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Extend it for your own editor:
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
undo.start({ shadowKeydownIn: [...undo.defaults.shadowKeydownIn, '.my-editor'] })
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Outside the shadow list, the global Cmd+Z intercepts even inside plain
|
|
130
|
+
`<input>`/`<textarea>`, so native char-level input-undo no longer fires there.
|
|
131
|
+
That's intentional: page-state undo is what users expect for Cmd+Z on a
|
|
132
|
+
self-editing page. Pass `bindKeys: false` to opt out entirely and bind your own
|
|
133
|
+
handler.
|
|
134
|
+
|
|
135
|
+
## Filter attributes
|
|
136
|
+
|
|
137
|
+
A mutation is excluded from recording when its target's ancestor chain contains
|
|
138
|
+
any of: `mutations-ignore`, `save-remove`, `save-ignore`, `save-freeze`. These
|
|
139
|
+
mirror hyperclayjs's `_shouldIgnore` semantics; no new attribute is introduced.
|
|
140
|
+
|
|
141
|
+
## Multi-scope (advanced)
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
const pageUndo = undo.start() // singleton on document.body, owns Cmd+Z
|
|
145
|
+
const editorUndo = undo.create({ scope: editorRoot, bindKeys: false })
|
|
146
|
+
editorUndo.start()
|
|
147
|
+
// editorUndo.undo() / .redo() called manually; Cmd+Z still routes to pageUndo
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Only one scope can own the global Cmd+Z binding at a time. Calling `start()`
|
|
151
|
+
again with a *different* scope throws (use `create` for additional scopes);
|
|
152
|
+
calling it again with the *same* scope warns and keeps the original config.
|
|
153
|
+
|
|
154
|
+
## Form input typing (known gap)
|
|
155
|
+
|
|
156
|
+
Pure-property `<input>`/`<textarea>` value changes are not `MutationRecord`s, so
|
|
157
|
+
raw typing into a field isn't directly observable. Coverage:
|
|
158
|
+
|
|
159
|
+
- **CMS form fields** flow through the engine, which mutates the page DOM — the
|
|
160
|
+
recorder sees the page mutation. ✓
|
|
161
|
+
- **`[persist]` inputs** on a Hyperclay page mirror `el.value` to the `value`
|
|
162
|
+
attribute; **`[persist]` textareas** mirror to `data-value`. The recorder sees
|
|
163
|
+
the attribute mutation. ✓
|
|
164
|
+
- **Plain `<input>`/`<textarea>` without `[persist]`** do NOT mirror; their
|
|
165
|
+
typing is invisible to the recorder. ✗
|
|
166
|
+
|
|
167
|
+
For an unmirrored field, do one of: add `[persist]`, wrap the input handler in
|
|
168
|
+
`undo.commit(label, fn)`, or accept that raw typing isn't undoable for that
|
|
169
|
+
field.
|
|
170
|
+
|
|
171
|
+
## What this does NOT do (v1)
|
|
172
|
+
|
|
173
|
+
No persistence across reloads, no cross-tab sync, no semantic diffs (it records
|
|
174
|
+
DOM ops, the label is the only semantic), no collaborative/OT undo, no tracking
|
|
175
|
+
of non-DOM state, no "revert to saved" checkpoint.
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var hyperundo=(()=>{var x=Object.defineProperty;var J=Object.getOwnPropertyDescriptor;var Q=Object.getOwnPropertyNames;var X=Object.prototype.hasOwnProperty;var Y=(e,r)=>{for(var o in r)x(e,o,{get:r[o],enumerable:!0})},$=(e,r,o,t)=>{if(r&&typeof r=="object"||typeof r=="function")for(let n of Q(r))!X.call(e,n)&&n!==o&&x(e,n,{get:()=>r[n],enumerable:!(t=J(r,n))||t.enumerable});return e};var ee=e=>$(x({},"__esModule",{value:!0}),e);var ie={};Y(ie,{default:()=>oe,undo:()=>V});function te(e,r=null){let o=[];if(e.type==="attributes"){let t=e.target,n=e.attributeName,i=e.oldValue,a=t.getAttribute(n);return i==null&&a!=null?o.push({kind:"attr-add",target:t,name:n,newValue:a}):i!=null&&a==null?o.push({kind:"attr-remove",target:t,name:n,oldValue:i}):i!==a&&o.push({kind:"attr-set",target:t,name:n,oldValue:i,newValue:a}),o}if(e.type==="characterData")return o.push({kind:"text",target:e.target,oldValue:e.oldValue,newValue:e.target.data}),o;if(e.type==="childList"){let t=e.target,n=e.nextSibling,i=r?Array.from(e.addedNodes).filter(u=>!r(u)):Array.from(e.addedNodes),a=r?Array.from(e.removedNodes).filter(u=>!r(u)):Array.from(e.removedNodes);return i.length>0&&o.push({kind:"add",parent:t,nodes:i,before:n}),a.length>0&&o.push({kind:"remove",parent:t,nodes:a,before:n}),o}return o}function S(e,r=null){let o=[],t=new Map;for(let n of e){if(n.type==="attributes"){let i=n.target,a=n.attributeName,u=t.get(i);u||(u=new Map,t.set(i,u)),u.has(a)||(u.set(a,{idx:o.length,oldValue:n.oldValue}),o.push(null));continue}for(let i of te(n,r))o.push(i)}for(let[n,i]of t)for(let[a,u]of i){let l=u.oldValue,p=n.getAttribute(a),f=null;l===p?f=null:l==null?f={kind:"attr-add",target:n,name:a,newValue:p}:p==null?f={kind:"attr-remove",target:n,name:a,oldValue:l}:f={kind:"attr-set",target:n,name:a,oldValue:l,newValue:p},o[u.idx]=f}return o.filter(n=>n!=null)}function D(e){switch(e.kind){case"attr-set":case"attr-add":e.target.setAttribute(e.name,e.newValue);return;case"attr-remove":e.target.removeAttribute(e.name);return;case"text":e.target.data=e.newValue;return;case"add":for(let r of e.nodes)e.before&&e.before.parentNode===e.parent?e.parent.insertBefore(r,e.before):e.parent.appendChild(r);return;case"remove":for(let r of e.nodes)r.parentNode===e.parent&&e.parent.removeChild(r);return}}function N(e){switch(e.kind){case"attr-set":e.target.setAttribute(e.name,e.oldValue);return;case"attr-add":e.target.removeAttribute(e.name);return;case"attr-remove":e.target.setAttribute(e.name,e.oldValue);return;case"text":e.target.data=e.oldValue;return;case"add":for(let r of e.nodes)r.parentNode===e.parent&&e.parent.removeChild(r);return;case"remove":for(let r of e.nodes)e.before&&e.before.parentNode===e.parent?e.parent.insertBefore(r,e.before):e.parent.appendChild(r);return}}var re=["mutations-ignore","save-remove","save-ignore","save-freeze"];function C(e,r,o){let t=e&&e.nodeType!==1?e.parentElement:e;for(;t&&t.nodeType===1;){for(let n of re)if(t.hasAttribute&&t.hasAttribute(n))return!0;t=t.parentElement}if(r&&o&&o.type==="attributes")try{if(r(o.attributeName,o.target))return!0}catch{}return!1}function L(){let e=new Map;function r(n,i){let a=e.get(n);return a||(a=new Set,e.set(n,a)),a.add(i),()=>o(n,i)}function o(n,i){let a=e.get(n);a&&a.delete(i)}function t(n,i){let a=e.get(n);if(a)for(let u of Array.from(a))try{u(i)}catch(l){console.error("[hyper-undo] listener threw",l)}}return{on:r,off:o,emit:t}}var ne={maxHistory:100,idleWindowMs:500,idleLabel:"Edit",ignoreAttribute:null,debug:!1};function E(e){let r={...ne,...e};if(!r.scope)throw new Error("hyper-undo: scope is required");let o=L(),t=[],n=[],i=[],a=null,u=null,l=0,p=!1;function f(...d){r.debug&&console.log("[hyper-undo]",...d)}function F(){p||(p=!0,u=new MutationObserver(y),u.observe(r.scope,{childList:!0,attributes:!0,characterData:!0,subtree:!0,attributeOldValue:!0,characterDataOldValue:!0}),f("started"))}function b(){p&&(p=!1,u&&(u.disconnect(),u=null),g(),t.length=0,n.length=0,i=[],l=0,f("stopped"))}function T(){let d=r.scope;return!!(d&&d.nodeType===1&&d.isConnected===!1)}function y(d){if(l>0)return;let c=I(d);if(c.length>0){for(let m of c)i.push(m);z()}}function I(d){let c=d.filter(m=>!C(m.target,r.ignoreAttribute,m));return S(c,m=>C(m,r.ignoreAttribute))}function z(){g(),a=setTimeout(P,r.idleWindowMs)}function g(){a!=null&&(clearTimeout(a),a=null)}function P(){if(a=null,i.length===0)return;let d=i;i=[],v(r.idleLabel,d)}function v(d,c){for(t.push({label:d,timestamp:Date.now(),primitives:c}),n.length=0;t.length>r.maxHistory;)t.shift();f("commit",d,"primitives:",c.length),o.emit("change")}function B(d,c){if(!p)throw new Error("hyper-undo: scope not started");y(u.takeRecords()),w();let m=c();if(m&&typeof m.then=="function")throw new Error("hyper-undo: commit() fn must be synchronous; returned a Promise");if(y(u.takeRecords()),i.length===0){o.emit("change");return}g();let j=i;i=[],v(d,j)}function H(d){if(!p)throw new Error("hyper-undo: scope not started");if(!u)return;if(l>1){u.takeRecords();return}w();let c=I(u.takeRecords());c.length!==0&&(g(),v(d,c))}function W(){u&&u.takeRecords()}function w(){l===0&&u&&y(u.takeRecords()),i.length!==0&&(g(),P())}function k(){l===0&&u&&y(u.takeRecords()),l++,f("paused",l)}function A(){l!==0&&(l--,l===0&&u&&u.takeRecords(),f("resumed",l))}function q(){if(T()){b();return}w();let d=t.pop();if(d){k();try{for(let c=d.primitives.length-1;c>=0;c--)try{N(d.primitives[c])}catch(m){f("undo primitive failed (continuing)",m)}}finally{A()}n.push(d),f("undo",d.label),o.emit("change")}}function G(){if(T()){b();return}w();let d=n.pop();if(d){k();try{for(let c of d.primitives)try{D(c)}catch(m){f("redo primitive failed (continuing)",m)}}finally{A()}t.push(d),f("redo",d.label),o.emit("change")}}function Z(){u&&u.takeRecords(),t.length=0,n.length=0,i=[],g(),f("cleared"),o.emit("change")}return{_config:r,start:F,stop:b,commit:B,commitCaptured:H,discardCaptured:W,flush:w,undo:q,redo:G,clear:Z,pause:k,resume:A,on:o.on,off:o.off,get canUndo(){return t.length>0},get canRedo(){return n.length>0},get isPaused(){return l>0},get history(){return t.map(d=>({label:d.label,timestamp:d.timestamp}))},get scope(){return r.scope}}}function _(e,r){let o=t=>{let n=e.scope;if(n&&n.nodeType===1&&n.isConnected===!1){r&&r();return}let i=t.target,a=e._config.shadowKeydownIn;if(i&&i.closest&&Array.isArray(a))for(let p of a)try{if(i.closest(p))return}catch{}if(!(t.metaKey||t.ctrlKey))return;let l=(t.key||"").toLowerCase();if(l==="z"&&!t.shiftKey){t.preventDefault(),t.stopPropagation(),e.undo();return}if(l==="z"&&t.shiftKey){t.preventDefault(),t.stopPropagation(),e.redo();return}if(l==="y"&&!t.shiftKey){t.preventDefault(),t.stopPropagation(),e.redo();return}};return window.addEventListener("keydown",o,!0),()=>window.removeEventListener("keydown",o,!0)}var K=[".CodeMirror",".cm-editor",".monaco-editor",".ace_editor",".ql-editor",".tiptap",".ProseMirror"],s=null,R=null,M=null,h=null;function U(e,r){if(h){if(h.inst===e)return h.cleanup;throw new Error("hyper-undo: another scope already owns the global Cmd+Z binding (bindKeys: true). Stop it first, or use bindKeys: false for additional scopes.")}let o=_(e,r);return h={inst:e,cleanup:o},o}function O(e){h&&h.inst===e&&(h.cleanup(),h=null)}var V={defaults:{shadowKeydownIn:K},start(e={}){let r=e.scope||(typeof document<"u"?document.body:null);if(!r)throw new Error("hyper-undo: no scope (need document.body or explicit { scope })");if(s){if(R===r)return console.warn("[hyper-undo] start() called again on existing singleton; ignoring new options"),s;throw new Error("hyper-undo: start() called with a different scope while the singleton is already started. Use undo.create({ scope }) for additional scopes, or call undo.stop() first.")}let o={...e,scope:r,shadowKeydownIn:e.shadowKeydownIn||K};return s=E(o),s.start(),R=r,e.bindKeys!==!1&&(M=U(s,()=>V.stop())),s},stop(){s&&(O(s),M=null,s.stop(),s=null,R=null)},create(e={}){let r=e.scope||(typeof document<"u"?document.body:null);if(!r)throw new Error("hyper-undo: no scope (need document.body or explicit { scope })");let o={...e,scope:r,shadowKeydownIn:e.shadowKeydownIn||K},t=E(o),n=null,i={_config:t._config,commit:t.commit,commitCaptured:t.commitCaptured,discardCaptured:t.discardCaptured,flush:t.flush,undo:t.undo,redo:t.redo,clear:t.clear,pause:t.pause,resume:t.resume,on:t.on,off:t.off,get canUndo(){return t.canUndo},get canRedo(){return t.canRedo},get isPaused(){return t.isPaused},get history(){return t.history},get scope(){return t.scope},start(){e.bindKeys&&(n=U(t,()=>i.stop())),t.start()},stop(){O(t),n=null,t.stop()}};return i},commit(e,r){return s?s.commit(e,r):void 0},commitCaptured(e){return s?s.commitCaptured(e):void 0},discardCaptured(){return s?s.discardCaptured():void 0},undo(){return s?s.undo():void 0},redo(){return s?s.redo():void 0},clear(){return s?s.clear():void 0},pause(){return s?s.pause():void 0},resume(){return s?s.resume():void 0},flush(){return s?s.flush():void 0},on(e,r){return s?s.on(e,r):void 0},off(e,r){return s?s.off(e,r):void 0},get canUndo(){return s?s.canUndo:!1},get canRedo(){return s?s.canRedo:!1},get history(){return s?s.history:[]},get isPaused(){return s?s.isPaused:!1}};var oe=V;return ee(ie);})();
|
package/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { undo, default } from './src/index.js'
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hyper-undo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DOM-state undo/redo via MutationObserver inverse-op replay. Standalone or paired with hyperclayjs.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"undo",
|
|
7
|
+
"redo",
|
|
8
|
+
"history",
|
|
9
|
+
"mutationobserver",
|
|
10
|
+
"dom",
|
|
11
|
+
"hyperclay"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/panphora/hyper-undo",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/panphora/hyper-undo/issues"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "David Miranda",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "src/index.js",
|
|
21
|
+
"module": "src/index.js",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./src/index.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"dist",
|
|
28
|
+
"index.js",
|
|
29
|
+
"README.md",
|
|
30
|
+
"CHANGELOG.md"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "npx esbuild src/index.js --bundle --minify --format=iife --global-name=hyperundo --outfile=dist/hyper-undo.min.js",
|
|
34
|
+
"copy-to-hyperclayjs": "npm run build && node scripts/copy-to-hyperclayjs.js",
|
|
35
|
+
"test:node": "node --test 'test-node/**/*.test.js'",
|
|
36
|
+
"test": "npm run test:node",
|
|
37
|
+
"release": "./scripts/release.sh",
|
|
38
|
+
"prepublishOnly": "npm run build && npm test"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"esbuild": "^0.25.0",
|
|
42
|
+
"jsdom": "^25.0.0"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/emitter.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function createEmitter() {
|
|
2
|
+
const listeners = new Map() // name → Set of fns
|
|
3
|
+
|
|
4
|
+
function on(name, fn) {
|
|
5
|
+
let set = listeners.get(name)
|
|
6
|
+
if (!set) { set = new Set(); listeners.set(name, set) }
|
|
7
|
+
set.add(fn)
|
|
8
|
+
return () => off(name, fn)
|
|
9
|
+
}
|
|
10
|
+
function off(name, fn) {
|
|
11
|
+
const set = listeners.get(name)
|
|
12
|
+
if (set) set.delete(fn)
|
|
13
|
+
}
|
|
14
|
+
function emit(name, payload) {
|
|
15
|
+
const set = listeners.get(name)
|
|
16
|
+
if (!set) return
|
|
17
|
+
for (const fn of Array.from(set)) {
|
|
18
|
+
try { fn(payload) } catch (e) { console.error('[hyper-undo] listener threw', e) }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return { on, off, emit }
|
|
22
|
+
}
|
package/src/filter.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Mirrors hyperclayjs/src/utilities/mutation.js _shouldIgnore exactly.
|
|
2
|
+
// Walks the parent chain from `node` and returns true if any ancestor has
|
|
3
|
+
// one of the four ignore attributes. `ignoreAttributePredicate` is an optional
|
|
4
|
+
// caller-supplied predicate for additional filtering on attribute records.
|
|
5
|
+
|
|
6
|
+
const IGNORE_ATTRS = ['mutations-ignore', 'save-remove', 'save-ignore', 'save-freeze']
|
|
7
|
+
|
|
8
|
+
export function shouldIgnore(node, ignoreAttributePredicate, record) {
|
|
9
|
+
// For non-element nodes (like text nodes), start from parent.
|
|
10
|
+
let el = (node && node.nodeType !== 1) ? node.parentElement : node
|
|
11
|
+
while (el && el.nodeType === 1) {
|
|
12
|
+
for (const attr of IGNORE_ATTRS) {
|
|
13
|
+
if (el.hasAttribute && el.hasAttribute(attr)) return true
|
|
14
|
+
}
|
|
15
|
+
el = el.parentElement
|
|
16
|
+
}
|
|
17
|
+
if (ignoreAttributePredicate && record && record.type === 'attributes') {
|
|
18
|
+
try {
|
|
19
|
+
if (ignoreAttributePredicate(record.attributeName, record.target)) return true
|
|
20
|
+
} catch (_) {}
|
|
21
|
+
}
|
|
22
|
+
return false
|
|
23
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createScope } from './scope.js'
|
|
2
|
+
import { installKeys } from './keys.js'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_SHADOW_LIST = [
|
|
5
|
+
'.CodeMirror',
|
|
6
|
+
'.cm-editor',
|
|
7
|
+
'.monaco-editor',
|
|
8
|
+
'.ace_editor',
|
|
9
|
+
'.ql-editor',
|
|
10
|
+
'.tiptap',
|
|
11
|
+
'.ProseMirror',
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
let singleton = null // the Scope instance returned by createScope
|
|
15
|
+
let singletonScope = null // the DOM element it's bound to (for diff-scope detection)
|
|
16
|
+
let singletonKeys = null // cleanup fn from installKeys, if bound
|
|
17
|
+
let keyOwner = null // { inst, cleanup } — the one scope owning global Cmd+Z
|
|
18
|
+
|
|
19
|
+
// Only ONE scope may own the global Cmd+Z binding at a time (locked plan
|
|
20
|
+
// decision). Idempotent for the same owner (so a repeated start() doesn't
|
|
21
|
+
// double-bind); throws for a different live owner until it's stopped.
|
|
22
|
+
function claimKeys(inst, onDetached) {
|
|
23
|
+
if (keyOwner) {
|
|
24
|
+
if (keyOwner.inst === inst) return keyOwner.cleanup
|
|
25
|
+
throw new Error(
|
|
26
|
+
'hyper-undo: another scope already owns the global Cmd+Z binding (bindKeys: true). ' +
|
|
27
|
+
'Stop it first, or use bindKeys: false for additional scopes.'
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
const cleanup = installKeys(inst, onDetached)
|
|
31
|
+
keyOwner = { inst, cleanup }
|
|
32
|
+
return cleanup
|
|
33
|
+
}
|
|
34
|
+
function releaseKeys(inst) {
|
|
35
|
+
if (keyOwner && keyOwner.inst === inst) {
|
|
36
|
+
keyOwner.cleanup()
|
|
37
|
+
keyOwner = null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const undo = {
|
|
42
|
+
defaults: { shadowKeydownIn: DEFAULT_SHADOW_LIST },
|
|
43
|
+
|
|
44
|
+
start(opts = {}) {
|
|
45
|
+
const scopeEl = opts.scope || (typeof document !== 'undefined' ? document.body : null)
|
|
46
|
+
if (!scopeEl) throw new Error('hyper-undo: no scope (need document.body or explicit { scope })')
|
|
47
|
+
|
|
48
|
+
if (singleton) {
|
|
49
|
+
if (singletonScope === scopeEl) {
|
|
50
|
+
console.warn('[hyper-undo] start() called again on existing singleton; ignoring new options')
|
|
51
|
+
return singleton
|
|
52
|
+
}
|
|
53
|
+
throw new Error(
|
|
54
|
+
'hyper-undo: start() called with a different scope while the singleton is already started. ' +
|
|
55
|
+
'Use undo.create({ scope }) for additional scopes, or call undo.stop() first.'
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const config = {
|
|
60
|
+
...opts,
|
|
61
|
+
scope: scopeEl,
|
|
62
|
+
shadowKeydownIn: opts.shadowKeydownIn || DEFAULT_SHADOW_LIST,
|
|
63
|
+
}
|
|
64
|
+
singleton = createScope(config)
|
|
65
|
+
singleton.start()
|
|
66
|
+
singletonScope = scopeEl
|
|
67
|
+
if (opts.bindKeys !== false) {
|
|
68
|
+
singletonKeys = claimKeys(singleton, () => undo.stop())
|
|
69
|
+
}
|
|
70
|
+
return singleton
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
stop() {
|
|
74
|
+
if (!singleton) return
|
|
75
|
+
releaseKeys(singleton)
|
|
76
|
+
singletonKeys = null
|
|
77
|
+
singleton.stop()
|
|
78
|
+
singleton = null
|
|
79
|
+
singletonScope = null
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
create(opts = {}) {
|
|
83
|
+
const scopeEl = opts.scope || (typeof document !== 'undefined' ? document.body : null)
|
|
84
|
+
if (!scopeEl) throw new Error('hyper-undo: no scope (need document.body or explicit { scope })')
|
|
85
|
+
const config = {
|
|
86
|
+
...opts,
|
|
87
|
+
scope: scopeEl,
|
|
88
|
+
shadowKeydownIn: opts.shadowKeydownIn || DEFAULT_SHADOW_LIST,
|
|
89
|
+
}
|
|
90
|
+
const inst = createScope(config)
|
|
91
|
+
let keysCleanup = null
|
|
92
|
+
// Delegate explicitly (NOT object-spread) so the canUndo/canRedo/history/
|
|
93
|
+
// isPaused getters stay live — spreading would snapshot them at create time.
|
|
94
|
+
const wrapper = {
|
|
95
|
+
_config: inst._config,
|
|
96
|
+
commit: inst.commit,
|
|
97
|
+
commitCaptured: inst.commitCaptured,
|
|
98
|
+
discardCaptured: inst.discardCaptured,
|
|
99
|
+
flush: inst.flush,
|
|
100
|
+
undo: inst.undo,
|
|
101
|
+
redo: inst.redo,
|
|
102
|
+
clear: inst.clear,
|
|
103
|
+
pause: inst.pause,
|
|
104
|
+
resume: inst.resume,
|
|
105
|
+
on: inst.on,
|
|
106
|
+
off: inst.off,
|
|
107
|
+
get canUndo() { return inst.canUndo },
|
|
108
|
+
get canRedo() { return inst.canRedo },
|
|
109
|
+
get isPaused() { return inst.isPaused },
|
|
110
|
+
get history() { return inst.history },
|
|
111
|
+
get scope() { return inst.scope },
|
|
112
|
+
start() {
|
|
113
|
+
// Claim the global key binding first so a conflict throws before the
|
|
114
|
+
// observer starts (no partial start). claimKeys is idempotent for this
|
|
115
|
+
// same inst, so a repeated start() never double-binds.
|
|
116
|
+
if (opts.bindKeys) keysCleanup = claimKeys(inst, () => wrapper.stop())
|
|
117
|
+
inst.start()
|
|
118
|
+
},
|
|
119
|
+
stop() {
|
|
120
|
+
releaseKeys(inst)
|
|
121
|
+
keysCleanup = null
|
|
122
|
+
inst.stop()
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
return wrapper
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
// Singleton delegators
|
|
129
|
+
commit(label, fn) { return singleton ? singleton.commit(label, fn) : undefined },
|
|
130
|
+
commitCaptured(label) { return singleton ? singleton.commitCaptured(label) : undefined },
|
|
131
|
+
discardCaptured() { return singleton ? singleton.discardCaptured() : undefined },
|
|
132
|
+
undo() { return singleton ? singleton.undo() : undefined },
|
|
133
|
+
redo() { return singleton ? singleton.redo() : undefined },
|
|
134
|
+
clear() { return singleton ? singleton.clear() : undefined },
|
|
135
|
+
pause() { return singleton ? singleton.pause() : undefined },
|
|
136
|
+
resume() { return singleton ? singleton.resume() : undefined },
|
|
137
|
+
flush() { return singleton ? singleton.flush() : undefined },
|
|
138
|
+
on(name, fn) { return singleton ? singleton.on(name, fn) : undefined },
|
|
139
|
+
off(name, fn) { return singleton ? singleton.off(name, fn) : undefined },
|
|
140
|
+
get canUndo() { return singleton ? singleton.canUndo : false },
|
|
141
|
+
get canRedo() { return singleton ? singleton.canRedo : false },
|
|
142
|
+
get history() { return singleton ? singleton.history : [] },
|
|
143
|
+
get isPaused() { return singleton ? singleton.isPaused : false },
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export { undo }
|
|
147
|
+
export default undo
|
package/src/keys.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Installs a single window-level keydown listener in capture phase.
|
|
2
|
+
// Returns a cleanup function.
|
|
3
|
+
|
|
4
|
+
export function installKeys(scope, onDetached) {
|
|
5
|
+
const handler = (event) => {
|
|
6
|
+
// If the scope root was removed from the document, release the global
|
|
7
|
+
// binding and let native Cmd+Z through instead of undoing a detached tree.
|
|
8
|
+
const scopeEl = scope.scope
|
|
9
|
+
if (scopeEl && scopeEl.nodeType === 1 && scopeEl.isConnected === false) {
|
|
10
|
+
if (onDetached) onDetached()
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Bypass for in-page editors (CodeMirror, Monaco, etc.)
|
|
15
|
+
const target = event.target
|
|
16
|
+
const shadow = scope._config.shadowKeydownIn
|
|
17
|
+
if (target && target.closest && Array.isArray(shadow)) {
|
|
18
|
+
for (const selector of shadow) {
|
|
19
|
+
try { if (target.closest(selector)) return }
|
|
20
|
+
catch (_) { /* invalid selector, skip */ }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const mod = event.metaKey || event.ctrlKey
|
|
25
|
+
if (!mod) return
|
|
26
|
+
|
|
27
|
+
const key = (event.key || '').toLowerCase()
|
|
28
|
+
|
|
29
|
+
if (key === 'z' && !event.shiftKey) {
|
|
30
|
+
event.preventDefault(); event.stopPropagation()
|
|
31
|
+
scope.undo()
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
if (key === 'z' && event.shiftKey) {
|
|
35
|
+
event.preventDefault(); event.stopPropagation()
|
|
36
|
+
scope.redo()
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
if (key === 'y' && !event.shiftKey) {
|
|
40
|
+
event.preventDefault(); event.stopPropagation()
|
|
41
|
+
scope.redo()
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
window.addEventListener('keydown', handler, true)
|
|
47
|
+
return () => window.removeEventListener('keydown', handler, true)
|
|
48
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// One MutationRecord becomes one or more primitives. A childList record
|
|
2
|
+
// with both addedNodes and removedNodes produces two primitives (one add,
|
|
3
|
+
// one remove). Attribute and characterData each produce one.
|
|
4
|
+
//
|
|
5
|
+
// Each primitive carries everything needed to replay it in either direction
|
|
6
|
+
// without re-reading the live DOM.
|
|
7
|
+
|
|
8
|
+
export function recordToPrimitives(record, ignoreNode = null) {
|
|
9
|
+
const out = []
|
|
10
|
+
|
|
11
|
+
if (record.type === 'attributes') {
|
|
12
|
+
const target = record.target
|
|
13
|
+
const name = record.attributeName
|
|
14
|
+
const oldValue = record.oldValue
|
|
15
|
+
const newValue = target.getAttribute(name)
|
|
16
|
+
|
|
17
|
+
if (oldValue == null && newValue != null) {
|
|
18
|
+
out.push({ kind: 'attr-add', target, name, newValue })
|
|
19
|
+
} else if (oldValue != null && newValue == null) {
|
|
20
|
+
out.push({ kind: 'attr-remove', target, name, oldValue })
|
|
21
|
+
} else if (oldValue !== newValue) {
|
|
22
|
+
out.push({ kind: 'attr-set', target, name, oldValue, newValue })
|
|
23
|
+
}
|
|
24
|
+
return out
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (record.type === 'characterData') {
|
|
28
|
+
out.push({
|
|
29
|
+
kind: 'text',
|
|
30
|
+
target: record.target,
|
|
31
|
+
oldValue: record.oldValue,
|
|
32
|
+
newValue: record.target.data,
|
|
33
|
+
})
|
|
34
|
+
return out
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (record.type === 'childList') {
|
|
38
|
+
const parent = record.target
|
|
39
|
+
const before = record.nextSibling // the slot to re-insert before
|
|
40
|
+
// The parent target already passed the caller's ignore filter, but an
|
|
41
|
+
// individual added/removed node can itself be an ignored subtree (e.g. a
|
|
42
|
+
// save-ignore CMS shell appended directly under <body>). Drop those nodes
|
|
43
|
+
// so undo never records or reverses them; emit no primitive if a side ends
|
|
44
|
+
// up empty.
|
|
45
|
+
const added = ignoreNode
|
|
46
|
+
? Array.from(record.addedNodes).filter((n) => !ignoreNode(n))
|
|
47
|
+
: Array.from(record.addedNodes)
|
|
48
|
+
const removed = ignoreNode
|
|
49
|
+
? Array.from(record.removedNodes).filter((n) => !ignoreNode(n))
|
|
50
|
+
: Array.from(record.removedNodes)
|
|
51
|
+
if (added.length > 0) {
|
|
52
|
+
out.push({ kind: 'add', parent, nodes: added, before })
|
|
53
|
+
}
|
|
54
|
+
if (removed.length > 0) {
|
|
55
|
+
out.push({ kind: 'remove', parent, nodes: removed, before })
|
|
56
|
+
}
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Convert a whole batch of records (one observer delivery, or one
|
|
64
|
+
// takeRecords() drain) into primitives, coalescing repeated mutations of the
|
|
65
|
+
// SAME (target, attributeName) within the batch.
|
|
66
|
+
//
|
|
67
|
+
// Why batch-level: MutationObserver does not expose a per-record NEW attribute
|
|
68
|
+
// value, and by the time we run, the DOM already reflects every mutation in the
|
|
69
|
+
// batch. So recordToPrimitives' `getAttribute(name)` can only read the FINAL
|
|
70
|
+
// value. When an attribute is touched twice in one batch (e.g. add-then-remove,
|
|
71
|
+
// or remove-then-readd-same), classifying each record off that final value
|
|
72
|
+
// misclassifies or drops records and corrupts undo. We instead keep the FIRST
|
|
73
|
+
// record's oldValue and the FINAL live value, emitting one primitive per
|
|
74
|
+
// (target, name) — or none when they're equal (a net no-op within the batch).
|
|
75
|
+
// Records arrive already filtered by the caller; order is preserved, with each
|
|
76
|
+
// coalesced attribute primitive landing at its first occurrence.
|
|
77
|
+
export function recordsToPrimitives(records, ignoreNode = null) {
|
|
78
|
+
const out = []
|
|
79
|
+
const seen = new Map() // target → Map(name → { idx, oldValue })
|
|
80
|
+
|
|
81
|
+
for (const record of records) {
|
|
82
|
+
if (record.type === 'attributes') {
|
|
83
|
+
const target = record.target
|
|
84
|
+
const name = record.attributeName
|
|
85
|
+
let byName = seen.get(target)
|
|
86
|
+
if (!byName) { byName = new Map(); seen.set(target, byName) }
|
|
87
|
+
if (!byName.has(name)) {
|
|
88
|
+
byName.set(name, { idx: out.length, oldValue: record.oldValue })
|
|
89
|
+
out.push(null) // placeholder, finalized below
|
|
90
|
+
}
|
|
91
|
+
// Repeat occurrences in this batch are folded into the first.
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
for (const p of recordToPrimitives(record, ignoreNode)) out.push(p)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const [target, byName] of seen) {
|
|
98
|
+
for (const [name, info] of byName) {
|
|
99
|
+
const oldValue = info.oldValue
|
|
100
|
+
const newValue = target.getAttribute(name)
|
|
101
|
+
let prim = null
|
|
102
|
+
if (oldValue === newValue) {
|
|
103
|
+
prim = null // net no-op
|
|
104
|
+
} else if (oldValue == null) {
|
|
105
|
+
prim = { kind: 'attr-add', target, name, newValue }
|
|
106
|
+
} else if (newValue == null) {
|
|
107
|
+
prim = { kind: 'attr-remove', target, name, oldValue }
|
|
108
|
+
} else {
|
|
109
|
+
prim = { kind: 'attr-set', target, name, oldValue, newValue }
|
|
110
|
+
}
|
|
111
|
+
out[info.idx] = prim
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return out.filter((p) => p != null)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Forward replay. Used by redo().
|
|
119
|
+
export function replayForward(p) {
|
|
120
|
+
switch (p.kind) {
|
|
121
|
+
case 'attr-set':
|
|
122
|
+
case 'attr-add':
|
|
123
|
+
p.target.setAttribute(p.name, p.newValue)
|
|
124
|
+
return
|
|
125
|
+
case 'attr-remove':
|
|
126
|
+
p.target.removeAttribute(p.name)
|
|
127
|
+
return
|
|
128
|
+
case 'text':
|
|
129
|
+
p.target.data = p.newValue
|
|
130
|
+
return
|
|
131
|
+
case 'add':
|
|
132
|
+
for (const node of p.nodes) {
|
|
133
|
+
if (p.before && p.before.parentNode === p.parent) {
|
|
134
|
+
p.parent.insertBefore(node, p.before)
|
|
135
|
+
} else {
|
|
136
|
+
p.parent.appendChild(node)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return
|
|
140
|
+
case 'remove':
|
|
141
|
+
for (const node of p.nodes) {
|
|
142
|
+
if (node.parentNode === p.parent) {
|
|
143
|
+
p.parent.removeChild(node)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Reverse replay. Used by undo().
|
|
151
|
+
export function replayReverse(p) {
|
|
152
|
+
switch (p.kind) {
|
|
153
|
+
case 'attr-set':
|
|
154
|
+
p.target.setAttribute(p.name, p.oldValue)
|
|
155
|
+
return
|
|
156
|
+
case 'attr-add':
|
|
157
|
+
p.target.removeAttribute(p.name)
|
|
158
|
+
return
|
|
159
|
+
case 'attr-remove':
|
|
160
|
+
p.target.setAttribute(p.name, p.oldValue)
|
|
161
|
+
return
|
|
162
|
+
case 'text':
|
|
163
|
+
p.target.data = p.oldValue
|
|
164
|
+
return
|
|
165
|
+
case 'add':
|
|
166
|
+
// forward was add → reverse is remove
|
|
167
|
+
for (const node of p.nodes) {
|
|
168
|
+
if (node.parentNode === p.parent) {
|
|
169
|
+
p.parent.removeChild(node)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return
|
|
173
|
+
case 'remove':
|
|
174
|
+
// forward was remove → reverse is add. Re-insert at the same slot.
|
|
175
|
+
for (const node of p.nodes) {
|
|
176
|
+
if (p.before && p.before.parentNode === p.parent) {
|
|
177
|
+
p.parent.insertBefore(node, p.before)
|
|
178
|
+
} else {
|
|
179
|
+
p.parent.appendChild(node)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
}
|
package/src/scope.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { recordsToPrimitives, replayForward, replayReverse } from './primitives.js'
|
|
2
|
+
import { shouldIgnore } from './filter.js'
|
|
3
|
+
import { createEmitter } from './emitter.js'
|
|
4
|
+
|
|
5
|
+
const DEFAULTS = {
|
|
6
|
+
maxHistory: 100,
|
|
7
|
+
idleWindowMs: 500,
|
|
8
|
+
idleLabel: 'Edit',
|
|
9
|
+
ignoreAttribute: null,
|
|
10
|
+
debug: false,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createScope(opts) {
|
|
14
|
+
const config = { ...DEFAULTS, ...opts }
|
|
15
|
+
if (!config.scope) throw new Error('hyper-undo: scope is required')
|
|
16
|
+
|
|
17
|
+
const emitter = createEmitter()
|
|
18
|
+
const undoStack = [] // [{ label, timestamp, primitives }]
|
|
19
|
+
const redoStack = [] // same shape
|
|
20
|
+
let pendingPrimitives = [] // primitives collected since last commit
|
|
21
|
+
let idleTimer = null
|
|
22
|
+
let observer = null
|
|
23
|
+
let pauseDepth = 0 // reference-counted; recording is active only at 0
|
|
24
|
+
let started = false
|
|
25
|
+
|
|
26
|
+
function log(...args) { if (config.debug) console.log('[hyper-undo]', ...args) }
|
|
27
|
+
|
|
28
|
+
// --- Lifecycle ---
|
|
29
|
+
|
|
30
|
+
function start() {
|
|
31
|
+
if (started) return
|
|
32
|
+
started = true
|
|
33
|
+
observer = new MutationObserver(handleRecords)
|
|
34
|
+
observer.observe(config.scope, {
|
|
35
|
+
childList: true,
|
|
36
|
+
attributes: true,
|
|
37
|
+
characterData: true,
|
|
38
|
+
subtree: true,
|
|
39
|
+
attributeOldValue: true,
|
|
40
|
+
characterDataOldValue: true,
|
|
41
|
+
})
|
|
42
|
+
log('started')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function stop() {
|
|
46
|
+
if (!started) return
|
|
47
|
+
started = false
|
|
48
|
+
if (observer) { observer.disconnect(); observer = null }
|
|
49
|
+
clearIdleTimer()
|
|
50
|
+
undoStack.length = 0
|
|
51
|
+
redoStack.length = 0
|
|
52
|
+
pendingPrimitives = []
|
|
53
|
+
pauseDepth = 0 // reset so a reused (created) scope doesn't restart paused
|
|
54
|
+
log('stopped')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The plan requires a scope whose root has been removed from the document to
|
|
58
|
+
// stop itself with its stack cleared. Detected lazily at the next undo/redo
|
|
59
|
+
// (and at the key handler in keys.js) rather than via a polling timer.
|
|
60
|
+
function scopeDisconnected() {
|
|
61
|
+
const el = config.scope
|
|
62
|
+
return !!(el && el.nodeType === 1 && el.isConnected === false)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- Observer callback ---
|
|
66
|
+
|
|
67
|
+
function handleRecords(records) {
|
|
68
|
+
if (pauseDepth > 0) return // records remain in observer buffer until takeRecords() is called
|
|
69
|
+
const prims = convertRecords(records)
|
|
70
|
+
if (prims.length > 0) {
|
|
71
|
+
for (const p of prims) pendingPrimitives.push(p)
|
|
72
|
+
restartIdleTimer()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Filter ignored records, then coalesce same-attribute repeats within the batch.
|
|
77
|
+
function convertRecords(records) {
|
|
78
|
+
const kept = records.filter((r) => !shouldIgnore(r.target, config.ignoreAttribute, r))
|
|
79
|
+
return recordsToPrimitives(kept, (node) => shouldIgnore(node, config.ignoreAttribute))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- Idle batching ---
|
|
83
|
+
|
|
84
|
+
function restartIdleTimer() {
|
|
85
|
+
clearIdleTimer()
|
|
86
|
+
idleTimer = setTimeout(closeIdleBatch, config.idleWindowMs)
|
|
87
|
+
}
|
|
88
|
+
function clearIdleTimer() {
|
|
89
|
+
if (idleTimer != null) { clearTimeout(idleTimer); idleTimer = null }
|
|
90
|
+
}
|
|
91
|
+
function closeIdleBatch() {
|
|
92
|
+
idleTimer = null
|
|
93
|
+
if (pendingPrimitives.length === 0) return
|
|
94
|
+
const primitives = pendingPrimitives
|
|
95
|
+
pendingPrimitives = []
|
|
96
|
+
pushCommit(config.idleLabel, primitives)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- Commit machinery ---
|
|
100
|
+
|
|
101
|
+
function pushCommit(label, primitives) {
|
|
102
|
+
undoStack.push({ label, timestamp: Date.now(), primitives })
|
|
103
|
+
redoStack.length = 0
|
|
104
|
+
while (undoStack.length > config.maxHistory) undoStack.shift()
|
|
105
|
+
log('commit', label, 'primitives:', primitives.length)
|
|
106
|
+
emitter.emit('change')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Explicit commit: caller-driven, synchronous fn.
|
|
110
|
+
function commit(label, fn) {
|
|
111
|
+
if (!started) throw new Error('hyper-undo: scope not started')
|
|
112
|
+
// Drain anything already buffered from this same synchronous tick (e.g.
|
|
113
|
+
// typing whose observer callback hasn't fired yet) into pending, THEN close
|
|
114
|
+
// it as its own batch — so the explicit commit starts clean and prior work
|
|
115
|
+
// isn't swallowed under this label.
|
|
116
|
+
handleRecords(observer.takeRecords())
|
|
117
|
+
flush()
|
|
118
|
+
const result = fn()
|
|
119
|
+
if (result && typeof result.then === 'function') {
|
|
120
|
+
throw new Error('hyper-undo: commit() fn must be synchronous; returned a Promise')
|
|
121
|
+
}
|
|
122
|
+
// Drain records the observer hasn't fired callback for yet.
|
|
123
|
+
handleRecords(observer.takeRecords())
|
|
124
|
+
if (pendingPrimitives.length === 0) {
|
|
125
|
+
emitter.emit('change')
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
clearIdleTimer()
|
|
129
|
+
const primitives = pendingPrimitives
|
|
130
|
+
pendingPrimitives = []
|
|
131
|
+
pushCommit(label, primitives)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Pause-before / commit-on-success pattern used by hypercms.
|
|
135
|
+
// Caller: pause() → run code → commitCaptured(label) on success / discardCaptured() on failure → resume().
|
|
136
|
+
function commitCaptured(label) {
|
|
137
|
+
if (!started) throw new Error('hyper-undo: scope not started')
|
|
138
|
+
if (!observer) return
|
|
139
|
+
// If an OUTER pause is still active (this capture frame is nested inside an
|
|
140
|
+
// exclusion pause), honor the outer "exclude everything" contract: discard
|
|
141
|
+
// the captured records instead of committing through the outer pause.
|
|
142
|
+
if (pauseDepth > 1) { observer.takeRecords(); return }
|
|
143
|
+
// Close any pending idle batch (e.g. in-progress typing captured BEFORE the
|
|
144
|
+
// pause) as its own commit FIRST, so the structural commit we're about to
|
|
145
|
+
// push lands AFTER it in history. Without this, type-then-immediately-add
|
|
146
|
+
// would record the structural op before the typing, inverting undo order.
|
|
147
|
+
flush()
|
|
148
|
+
const primitives = convertRecords(observer.takeRecords())
|
|
149
|
+
if (primitives.length === 0) return
|
|
150
|
+
clearIdleTimer()
|
|
151
|
+
pushCommit(label, primitives)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function discardCaptured() {
|
|
155
|
+
if (observer) observer.takeRecords()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Force-close current idle batch as its own commit. Used by undo()/redo() and
|
|
159
|
+
// by the save seam (snapshot.js calls undo.flush() before cloning).
|
|
160
|
+
function flush() {
|
|
161
|
+
// Drain same-tick records the observer hasn't delivered yet so the batch
|
|
162
|
+
// boundary is correct (e.g. a synchronous save right after a DOM edit).
|
|
163
|
+
// Skip while paused — the paused caller (commitCaptured) drains the buffer
|
|
164
|
+
// itself, and draining here would route those records through the
|
|
165
|
+
// early-returning handleRecords and lose them.
|
|
166
|
+
if (pauseDepth === 0 && observer) handleRecords(observer.takeRecords())
|
|
167
|
+
if (pendingPrimitives.length === 0) return
|
|
168
|
+
clearIdleTimer()
|
|
169
|
+
closeIdleBatch()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// --- Pause/resume ---
|
|
173
|
+
|
|
174
|
+
// Reference-counted so nested pauses (e.g. a hypercms commit wrapping a
|
|
175
|
+
// live-sync morph that also pauses) only resume on the outermost release.
|
|
176
|
+
function pause() {
|
|
177
|
+
// On the outermost pause, drain anything already buffered from this same
|
|
178
|
+
// synchronous tick into pending FIRST, while still unpaused, so a pre-pause
|
|
179
|
+
// edit isn't swallowed into the captured/structural batch that follows.
|
|
180
|
+
if (pauseDepth === 0 && observer) handleRecords(observer.takeRecords())
|
|
181
|
+
pauseDepth++
|
|
182
|
+
log('paused', pauseDepth)
|
|
183
|
+
}
|
|
184
|
+
function resume() {
|
|
185
|
+
if (pauseDepth === 0) return // underflow guard
|
|
186
|
+
pauseDepth--
|
|
187
|
+
// Discard records captured during the pause only on the outermost release.
|
|
188
|
+
if (pauseDepth === 0 && observer) observer.takeRecords()
|
|
189
|
+
log('resumed', pauseDepth)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// --- Undo/redo ---
|
|
193
|
+
|
|
194
|
+
function undo() {
|
|
195
|
+
if (scopeDisconnected()) { stop(); return }
|
|
196
|
+
flush() // close any pending typing first
|
|
197
|
+
const c = undoStack.pop()
|
|
198
|
+
if (!c) return
|
|
199
|
+
pause()
|
|
200
|
+
try {
|
|
201
|
+
// Reverse order so a child restore happens before its parent restore.
|
|
202
|
+
for (let i = c.primitives.length - 1; i >= 0; i--) {
|
|
203
|
+
try { replayReverse(c.primitives[i]) }
|
|
204
|
+
catch (err) { log('undo primitive failed (continuing)', err) }
|
|
205
|
+
}
|
|
206
|
+
} finally { resume() }
|
|
207
|
+
redoStack.push(c)
|
|
208
|
+
log('undo', c.label)
|
|
209
|
+
emitter.emit('change')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function redo() {
|
|
213
|
+
if (scopeDisconnected()) { stop(); return }
|
|
214
|
+
flush()
|
|
215
|
+
const c = redoStack.pop()
|
|
216
|
+
if (!c) return
|
|
217
|
+
pause()
|
|
218
|
+
try {
|
|
219
|
+
for (const p of c.primitives) {
|
|
220
|
+
try { replayForward(p) }
|
|
221
|
+
catch (err) { log('redo primitive failed (continuing)', err) }
|
|
222
|
+
}
|
|
223
|
+
} finally { resume() }
|
|
224
|
+
undoStack.push(c)
|
|
225
|
+
log('redo', c.label)
|
|
226
|
+
emitter.emit('change')
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function clear() {
|
|
230
|
+
// Discard any same-tick buffered records too, so a bootstrap mutation
|
|
231
|
+
// followed by clear() in the same turn doesn't resurface as a commit once
|
|
232
|
+
// the observer callback fires.
|
|
233
|
+
if (observer) observer.takeRecords()
|
|
234
|
+
undoStack.length = 0
|
|
235
|
+
redoStack.length = 0
|
|
236
|
+
pendingPrimitives = []
|
|
237
|
+
clearIdleTimer()
|
|
238
|
+
log('cleared')
|
|
239
|
+
emitter.emit('change')
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// --- Public surface for the singleton wrapper ---
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
_config: config, // private: used by keys.js to read shadowKeydownIn
|
|
246
|
+
start, stop,
|
|
247
|
+
commit, commitCaptured, discardCaptured, flush,
|
|
248
|
+
undo, redo, clear,
|
|
249
|
+
pause, resume,
|
|
250
|
+
on: emitter.on, off: emitter.off,
|
|
251
|
+
get canUndo() { return undoStack.length > 0 },
|
|
252
|
+
get canRedo() { return redoStack.length > 0 },
|
|
253
|
+
get isPaused() { return pauseDepth > 0 },
|
|
254
|
+
get history() {
|
|
255
|
+
return undoStack.map(c => ({ label: c.label, timestamp: c.timestamp }))
|
|
256
|
+
},
|
|
257
|
+
get scope() { return config.scope },
|
|
258
|
+
}
|
|
259
|
+
}
|