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 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
+ }