hyper-undo 0.2.0 → 0.4.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # hyper-undo changelog
2
2
 
3
+ ## [0.4.0] - 2026-06-16
4
+
5
+ ### Changed
6
+ - Integrated the region capability model into hyper-undo
7
+ - Adopted a shared MutationObserver in hyper-undo
8
+
9
+
10
+
11
+ ## [0.3.0] - 2026-06-07
12
+
13
+
14
+
3
15
  ## 0.2.0 — unreleased
4
16
 
5
17
  - **BREAKING:** the generic `change` event is removed and replaced by dedicated,
@@ -1 +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("commit")}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("commit");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("undo")}}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("redo")}}function Z(){u&&u.takeRecords(),t.length=0,n.length=0,i=[],g(),f("cleared"),o.emit("clear")}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);})();
1
+ var hyperundo=(()=>{var T=Object.defineProperty;var ee=Object.getOwnPropertyDescriptor;var te=Object.getOwnPropertyNames;var re=Object.prototype.hasOwnProperty;var ne=(e,t)=>{for(var n in t)T(e,n,{get:t[n],enumerable:!0})},oe=(e,t,n,r)=>{if(t&&typeof t=="object"||typeof t=="function")for(let o of te(t))!re.call(e,o)&&o!==n&&T(e,o,{get:()=>t[o],enumerable:!(r=ee(t,o))||r.enumerable});return e};var ae=e=>oe(T({},"__esModule",{value:!0}),e);var me={};ne(me,{default:()=>pe,undo:()=>C});function ie(e,t=null){let n=[];if(e.type==="attributes"){let r=e.target,o=e.attributeName,i=e.oldValue,u=r.getAttribute(o);return i==null&&u!=null?n.push({kind:"attr-add",target:r,name:o,newValue:u}):i!=null&&u==null?n.push({kind:"attr-remove",target:r,name:o,oldValue:i}):i!==u&&n.push({kind:"attr-set",target:r,name:o,oldValue:i,newValue:u}),n}if(e.type==="characterData")return n.push({kind:"text",target:e.target,oldValue:e.oldValue,newValue:e.target.data}),n;if(e.type==="childList"){let r=e.target,o=e.nextSibling,i=t?Array.from(e.addedNodes).filter(d=>!t(d)):Array.from(e.addedNodes),u=t?Array.from(e.removedNodes).filter(d=>!t(d)):Array.from(e.removedNodes);return i.length>0&&n.push({kind:"add",parent:r,nodes:i,before:o}),u.length>0&&n.push({kind:"remove",parent:r,nodes:u,before:o}),n}return n}function P(e,t=null){let n=[],r=new Map;for(let o of e){if(o.type==="attributes"){let i=o.target,u=o.attributeName,d=r.get(i);d||(d=new Map,r.set(i,d)),d.has(u)||(d.set(u,{idx:n.length,oldValue:o.oldValue}),n.push(null));continue}for(let i of ie(o,t))n.push(i)}for(let[o,i]of r)for(let[u,d]of i){let l=d.oldValue,m=o.getAttribute(u),p=null;l===m?p=null:l==null?p={kind:"attr-add",target:o,name:u,newValue:m}:m==null?p={kind:"attr-remove",target:o,name:u,oldValue:l}:p={kind:"attr-set",target:o,name:u,oldValue:l,newValue:m},n[d.idx]=p}return n.filter(o=>o!=null)}function L(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"value":e.target[e.prop]=e.newValue;return;case"add":for(let t of e.nodes)e.before&&e.before.parentNode===e.parent?e.parent.insertBefore(t,e.before):e.parent.appendChild(t);return;case"remove":for(let t of e.nodes)t.parentNode===e.parent&&e.parent.removeChild(t);return}}function D(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"value":e.target[e.prop]=e.oldValue;return;case"add":for(let t of e.nodes)t.parentNode===e.parent&&e.parent.removeChild(t);return;case"remove":for(let t of e.nodes)e.before&&e.before.parentNode===e.parent?e.parent.insertBefore(t,e.before):e.parent.appendChild(t);return}}var se=['[src^="chrome-extension://"]','[href^="chrome-extension://"]','[src^="moz-extension://"]','[href^="moz-extension://"]','[src^="safari-web-extension://"]','[href^="safari-web-extension://"]','[id="1p-menu-live-region"]',"com-1password-button","com-1password-menu","com-1password-notification","grammarly-extension","grammarly-desktop-integration","lt-div","lt-card","lt-toolbar","lt-highlighter","[data-lastpass-root]","[data-lastpass-icon-root]","[data-dashlane-shadowhost]","[data-klarna-extension]",'[class*="klarna-extension"]',".skiptranslate","#goog-gt-tt",".goog-te-banner-frame",".goog-te-balloon-frame","style.darkreader","style[data-darkreader-mode]","style[data-darkreader-scheme]","#loom-companion-mv3","loom-container","[ext-id]"],ue=["data-grammarly","data-lt","data-1p","data-com-onepassword","data-lastpass","data-lp","data-kwimpala","data-bitwarden","data-bw","data-dashlane","data-np","data-protonpass","data-proton-pass","data-keeper","data-rf","data-roboform","data-honey","data-rakuten","data-capital-one","data-wikibuy","data-klarna","data-darkreader","data-evernote","data-pocket","data-pin"],M=se.join(","),U=new RegExp("^(?:"+ue.join("|")+")(?:-|$)");var de=["mutations-ignore","save-remove","save-ignore","save-freeze","no-undo","no-watch"];function le(){return typeof window<"u"&&window.hyperclay&&window.hyperclay.region&&typeof window.hyperclay.region.resolveRegionPolicy=="function"?window.hyperclay.region.resolveRegionPolicy:null}function ce(e){let t=le();if(t)try{return!t(e).undoable}catch{}let n=e&&e.nodeType!==1?e.parentElement:e;if(n&&n.closest&&n.closest(M))return!0;for(;n&&n.nodeType===1;){for(let r of de)if(n.hasAttribute&&n.hasAttribute(r))return!0;n=n.parentElement}return!1}function R(e,t,n){if(ce(e))return!0;if(n&&n.type==="attributes"){if(n.attributeName&&U.test(n.attributeName.toLowerCase()))return!0;if(t)try{if(t(n.attributeName,n.target))return!0}catch{}}return!1}function X(){let e=new Map;function t(o,i){let u=e.get(o);return u||(u=new Set,e.set(o,u)),u.add(i),()=>n(o,i)}function n(o,i){let u=e.get(o);u&&u.delete(i)}function r(o,i){let u=e.get(o);if(u)for(let d of Array.from(u))try{d(i)}catch(l){console.error("[hyper-undo] listener threw",l)}}return{on:t,off:n,emit:r}}var fe={maxHistory:100,idleWindowMs:500,idleLabel:"Edit",ignoreAttribute:null,debug:!1};function N(e){let t={...fe,...e};if(!t.scope)throw new Error("hyper-undo: scope is required");let n=X(),r=[],o=[],i=[],u=null,d=null,l=0,m=!1;function p(...s){t.debug&&console.log("[hyper-undo]",...s)}function H(){m||(m=!0,d=W(g),d.observe(t.scope,{childList:!0,attributes:!0,characterData:!0,subtree:!0,attributeOldValue:!0,characterDataOldValue:!0}),p("started"))}function W(s){let c=typeof document<"u"&&t.scope===document.body,f=typeof window<"u"&&window.hyperclay&&window.hyperclay.Mutation||null;return c&&f&&typeof f.createObserver=="function"?(p("sourcing records from window.hyperclay.Mutation (shared observer)"),f.createObserver(s)):new MutationObserver(s)}function E(){m&&(m=!1,d&&(d.disconnect(),d=null),y(),r.length=0,o.length=0,i=[],l=0,p("stopped"))}function V(){let s=t.scope;return!!(s&&s.nodeType===1&&s.isConnected===!1)}function g(s){if(l>0)return;let c=_(s);if(c.length>0){for(let f of c)i.push(f);I()}}function _(s){let c=s.filter(f=>!R(f.target,t.ignoreAttribute,f));return P(c,f=>R(f,t.ignoreAttribute))}function I(){y(),u=setTimeout(K,t.idleWindowMs)}function y(){u!=null&&(clearTimeout(u),u=null)}function K(){if(u=null,i.length===0)return;let s=i;i=[],v(t.idleLabel,s)}function v(s,c){for(r.push({label:s,timestamp:Date.now(),primitives:c}),o.length=0;r.length>t.maxHistory;)r.shift();p("commit",s,"primitives:",c.length),n.emit("commit")}function j(s,c){if(!m)throw new Error("hyper-undo: scope not started");g(d.takeRecords()),w();let f=c();if(f&&typeof f.then=="function")throw new Error("hyper-undo: commit() fn must be synchronous; returned a Promise");if(g(d.takeRecords()),i.length===0){n.emit("commit");return}y();let b=i;i=[],v(s,b)}function G(s){if(!m)throw new Error("hyper-undo: scope not started");if(!d)return;if(l>1){d.takeRecords();return}w();let c=_(d.takeRecords());c.length!==0&&(y(),v(s,c))}function Z(){d&&d.takeRecords()}function O(s){!m||l>0||!s||(i.push(s),I())}function $(s,{prop:c="value",oldValue:f,newValue:b}={}){!s||f===b||O({kind:"value",target:s,prop:c,oldValue:f,newValue:b})}function w(){l===0&&d&&g(d.takeRecords()),i.length!==0&&(y(),K())}function k(){l===0&&d&&g(d.takeRecords()),l++,p("paused",l)}function x(){l!==0&&(l--,l===0&&d&&d.takeRecords(),p("resumed",l))}function J(){if(V()){E();return}w();let s=r.pop();if(s){k();try{for(let c=s.primitives.length-1;c>=0;c--)try{D(s.primitives[c])}catch(f){p("undo primitive failed (continuing)",f)}}finally{x()}o.push(s),p("undo",s.label),n.emit("undo")}}function Q(){if(V()){E();return}w();let s=o.pop();if(s){k();try{for(let c of s.primitives)try{L(c)}catch(f){p("redo primitive failed (continuing)",f)}}finally{x()}r.push(s),p("redo",s.label),n.emit("redo")}}function Y(){d&&d.takeRecords(),r.length=0,o.length=0,i=[],y(),p("cleared"),n.emit("clear")}return{_config:t,start:H,stop:E,commit:j,commitCaptured:G,discardCaptured:Z,flush:w,record:O,recordValue:$,undo:J,redo:Q,clear:Y,pause:k,resume:x,on:n.on,off:n.off,get canUndo(){return r.length>0},get canRedo(){return o.length>0},get isPaused(){return l>0},get history(){return r.map(s=>({label:s.label,timestamp:s.timestamp}))},get scope(){return t.scope}}}function q(e,t){let n=r=>{let o=e.scope;if(o&&o.nodeType===1&&o.isConnected===!1){t&&t();return}let i=r.target,u=e._config.shadowKeydownIn;if(i&&i.closest&&Array.isArray(u))for(let m of u)try{if(i.closest(m))return}catch{}if(!(r.metaKey||r.ctrlKey))return;let l=(r.key||"").toLowerCase();if(l==="z"&&!r.shiftKey){r.preventDefault(),r.stopPropagation(),e.undo();return}if(l==="z"&&r.shiftKey){r.preventDefault(),r.stopPropagation(),e.redo();return}if(l==="y"&&!r.shiftKey){r.preventDefault(),r.stopPropagation(),e.redo();return}};return window.addEventListener("keydown",n,!0),()=>window.removeEventListener("keydown",n,!0)}var A=[".CodeMirror",".cm-editor",".monaco-editor",".ace_editor",".ql-editor",".tiptap",".ProseMirror"],a=null,S=null,z=null,h=null;function F(e,t){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 n=q(e,t);return h={inst:e,cleanup:n},n}function B(e){h&&h.inst===e&&(h.cleanup(),h=null)}var C={defaults:{shadowKeydownIn:A},start(e={}){let t=e.scope||(typeof document<"u"?document.body:null);if(!t)throw new Error("hyper-undo: no scope (need document.body or explicit { scope })");if(a){if(S===t)return console.warn("[hyper-undo] start() called again on existing singleton; ignoring new options"),a;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 n={...e,scope:t,shadowKeydownIn:e.shadowKeydownIn||A};return a=N(n),a.start(),S=t,e.bindKeys!==!1&&(z=F(a,()=>C.stop())),a},stop(){a&&(B(a),z=null,a.stop(),a=null,S=null)},create(e={}){let t=e.scope||(typeof document<"u"?document.body:null);if(!t)throw new Error("hyper-undo: no scope (need document.body or explicit { scope })");let n={...e,scope:t,shadowKeydownIn:e.shadowKeydownIn||A},r=N(n),o=null,i={_config:r._config,commit:r.commit,commitCaptured:r.commitCaptured,discardCaptured:r.discardCaptured,flush:r.flush,record:r.record,recordValue:r.recordValue,undo:r.undo,redo:r.redo,clear:r.clear,pause:r.pause,resume:r.resume,on:r.on,off:r.off,get canUndo(){return r.canUndo},get canRedo(){return r.canRedo},get isPaused(){return r.isPaused},get history(){return r.history},get scope(){return r.scope},start(){e.bindKeys&&(o=F(r,()=>i.stop())),r.start()},stop(){B(r),o=null,r.stop()}};return i},commit(e,t){return a?a.commit(e,t):void 0},commitCaptured(e){return a?a.commitCaptured(e):void 0},discardCaptured(){return a?a.discardCaptured():void 0},record(e){return a?a.record(e):void 0},recordValue(e,t){return a?a.recordValue(e,t):void 0},undo(){return a?a.undo():void 0},redo(){return a?a.redo():void 0},clear(){return a?a.clear():void 0},pause(){return a?a.pause():void 0},resume(){return a?a.resume():void 0},flush(){return a?a.flush():void 0},on(e,t){return a?a.on(e,t):void 0},off(e,t){return a?a.off(e,t):void 0},get canUndo(){return a?a.canUndo:!1},get canRedo(){return a?a.canRedo:!1},get history(){return a?a.history:[]},get isPaused(){return a?a.isPaused:!1}};var pe=C;return ae(me);})();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyper-undo",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "DOM-state undo/redo via MutationObserver inverse-op replay. Standalone or paired with hyperclayjs.",
5
5
  "keywords": [
6
6
  "undo",
@@ -33,7 +33,8 @@
33
33
  "build": "npx esbuild src/index.js --bundle --minify --format=iife --global-name=hyperundo --outfile=dist/hyper-undo.min.js",
34
34
  "copy-to-hyperclayjs": "npm run build && node scripts/copy-to-hyperclayjs.js",
35
35
  "test:node": "node --test 'test-node/**/*.test.js'",
36
- "test": "npm run test:node",
36
+ "test:shimmed": "HYPER_UNDO_SHIMMED=1 node --test 'test-node/**/*.test.js'",
37
+ "test": "npm run test:node && npm run test:shimmed",
37
38
  "release": "./scripts/release.sh",
38
39
  "prepublishOnly": "npm run build && npm test"
39
40
  },
@@ -0,0 +1,63 @@
1
+ /**
2
+ * extension-noise.js — browser-extension DOM noise the page should never own.
3
+ *
4
+ * Extensions touch the live page in two distinct ways, handled differently so a
5
+ * save never loses real content:
6
+ * 1. Standalone elements they own (password-manager menus, Grammarly overlays,
7
+ * translate banners, extension iframes/scripts) — remove them entirely.
8
+ * 2. Marker attributes they stamp onto YOUR real inputs/forms — strip just the
9
+ * attribute and keep the element.
10
+ *
11
+ * The selectors are not stable public APIs; extend both lists from real captures.
12
+ * Keep this file byte-identical to the copy in the other package (hyperclayjs and
13
+ * hyper-undo each carry one) — their save/undo observers must agree, or one will
14
+ * record/save what the other ignores.
15
+ */
16
+
17
+ // Standalone elements browser extensions inject into the page — remove them entirely.
18
+ export const EXTENSION_NODE_SELECTORS = [
19
+ '[src^="chrome-extension://"]', '[href^="chrome-extension://"]',
20
+ '[src^="moz-extension://"]', '[href^="moz-extension://"]',
21
+ '[src^="safari-web-extension://"]', '[href^="safari-web-extension://"]',
22
+ '[id="1p-menu-live-region"]',
23
+ 'com-1password-button', 'com-1password-menu', 'com-1password-notification',
24
+ 'grammarly-extension', 'grammarly-desktop-integration',
25
+ 'lt-div', 'lt-card', 'lt-toolbar', 'lt-highlighter',
26
+ '[data-lastpass-root]', '[data-lastpass-icon-root]', '[data-dashlane-shadowhost]',
27
+ '[data-klarna-extension]', '[class*="klarna-extension"]',
28
+ '.skiptranslate', '#goog-gt-tt', '.goog-te-banner-frame', '.goog-te-balloon-frame',
29
+ 'style.darkreader', 'style[data-darkreader-mode]', 'style[data-darkreader-scheme]',
30
+ '#loom-companion-mv3', 'loom-container',
31
+ '[ext-id]',
32
+ ]
33
+
34
+ // Attribute-name prefixes extensions stamp onto YOUR real elements — strip the attribute, keep the element.
35
+ export const EXTENSION_ATTR_PREFIXES = [
36
+ 'data-grammarly', 'data-lt', 'data-1p', 'data-com-onepassword', 'data-lastpass', 'data-lp', 'data-kwimpala',
37
+ 'data-bitwarden', 'data-bw', 'data-dashlane', 'data-np', 'data-protonpass', 'data-proton-pass',
38
+ 'data-keeper', 'data-rf', 'data-roboform', 'data-honey', 'data-rakuten', 'data-capital-one',
39
+ 'data-wikibuy', 'data-klarna', 'data-darkreader', 'data-evernote', 'data-pocket', 'data-pin',
40
+ ]
41
+
42
+ // Pre-joined once so hot paths call matches()/closest()/querySelectorAll() without re-joining.
43
+ export const EXTENSION_NODE_SELECTOR = EXTENSION_NODE_SELECTORS.join(',')
44
+
45
+ // One compiled test for "is this an extension marker attribute". The (?:-|$) boundary means a
46
+ // prefix like `data-lt` matches `data-lt` / `data-lt-foo` but never an app attribute like `data-ltr`.
47
+ export const EXTENSION_ATTR_PATTERN = new RegExp('^(?:' + EXTENSION_ATTR_PREFIXES.join('|') + ')(?:-|$)')
48
+
49
+ // Remove extension-injected elements and strip extension marker attributes from a (cloned) subtree, in place.
50
+ // Guarded so a malformed denylist entry degrades to "noise not stripped" rather than breaking the save.
51
+ export function stripExtensionNoise(root) {
52
+ if (!root || !root.querySelectorAll) return
53
+ try {
54
+ for (const el of root.querySelectorAll(EXTENSION_NODE_SELECTOR)) el.remove()
55
+ for (const el of root.querySelectorAll('*')) {
56
+ for (const attr of [...el.attributes]) {
57
+ if (EXTENSION_ATTR_PATTERN.test(attr.name.toLowerCase())) el.removeAttribute(attr.name)
58
+ }
59
+ }
60
+ } catch (e) {
61
+ if (typeof console !== 'undefined') console.warn('[hyperclay] stripExtensionNoise skipped:', e)
62
+ }
63
+ }
package/src/filter.js CHANGED
@@ -1,23 +1,59 @@
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.
1
+ // Decides whether a mutation should be ignored for undo. Mirrors hyperclayjs's
2
+ // region capability model: a mutation is ignored when its node sits in a region
3
+ // the resolver marks NOT undoable (no-undo / no-watch / the legacy markers /
4
+ // browser-extension content), when the record is an extension marker attribute,
5
+ // or when the caller's ignoreAttributePredicate rejects it.
6
+ //
7
+ // When paired with hyperclayjs we delegate the region decision to the SAME
8
+ // resolver the platform ships (window.hyperclay.region.resolveRegionPolicy), so
9
+ // undo and the platform can never drift. Standalone (no hyperclay on window) we
10
+ // fall back to a local marker walk that mirrors the resolver's `undoable` axis.
5
11
 
6
- const IGNORE_ATTRS = ['mutations-ignore', 'save-remove', 'save-ignore', 'save-freeze']
12
+ import { EXTENSION_NODE_SELECTOR, EXTENSION_ATTR_PATTERN } from './extension-noise.js'
7
13
 
8
- export function shouldIgnore(node, ignoreAttributePredicate, record) {
9
- // For non-element nodes (like text nodes), start from parent.
14
+ // Local fallback: attributes whose region is NOT undoable. The new no-save /
15
+ // no-trigger-autosave / freeze are deliberately ABSENT — those regions ARE
16
+ // undoable in the capability model; only no-undo / no-watch (and the legacy
17
+ // markers, which all imply no-undo) suppress recording.
18
+ const IGNORE_ATTRS = ['mutations-ignore', 'save-remove', 'save-ignore', 'save-freeze', 'no-undo', 'no-watch']
19
+
20
+ function sharedResolver() {
21
+ return (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.region &&
22
+ typeof window.hyperclay.region.resolveRegionPolicy === 'function')
23
+ ? window.hyperclay.region.resolveRegionPolicy
24
+ : null
25
+ }
26
+
27
+ // Is the node in a region that should not be recorded by undo?
28
+ function regionNotUndoable(node) {
29
+ const resolve = sharedResolver()
30
+ if (resolve) {
31
+ try { return !resolve(node).undoable }
32
+ catch (_) { /* fall through to the local walk */ }
33
+ }
10
34
  let el = (node && node.nodeType !== 1) ? node.parentElement : node
35
+ // Browser-extension injected elements (and their descendants) are not page content.
36
+ if (el && el.closest && el.closest(EXTENSION_NODE_SELECTOR)) return true
11
37
  while (el && el.nodeType === 1) {
12
38
  for (const attr of IGNORE_ATTRS) {
13
39
  if (el.hasAttribute && el.hasAttribute(attr)) return true
14
40
  }
15
41
  el = el.parentElement
16
42
  }
17
- if (ignoreAttributePredicate && record && record.type === 'attributes') {
18
- try {
19
- if (ignoreAttributePredicate(record.attributeName, record.target)) return true
20
- } catch (_) {}
43
+ return false
44
+ }
45
+
46
+ export function shouldIgnore(node, ignoreAttributePredicate, record) {
47
+ if (regionNotUndoable(node)) return true
48
+
49
+ if (record && record.type === 'attributes') {
50
+ // Extension marker attributes on a real element (password-manager field tags) are noise.
51
+ if (record.attributeName && EXTENSION_ATTR_PATTERN.test(record.attributeName.toLowerCase())) return true
52
+ if (ignoreAttributePredicate) {
53
+ try {
54
+ if (ignoreAttributePredicate(record.attributeName, record.target)) return true
55
+ } catch (_) {}
56
+ }
21
57
  }
22
58
  return false
23
59
  }
package/src/index.js CHANGED
@@ -97,6 +97,8 @@ const undo = {
97
97
  commitCaptured: inst.commitCaptured,
98
98
  discardCaptured: inst.discardCaptured,
99
99
  flush: inst.flush,
100
+ record: inst.record,
101
+ recordValue: inst.recordValue,
100
102
  undo: inst.undo,
101
103
  redo: inst.redo,
102
104
  clear: inst.clear,
@@ -129,6 +131,8 @@ const undo = {
129
131
  commit(label, fn) { return singleton ? singleton.commit(label, fn) : undefined },
130
132
  commitCaptured(label) { return singleton ? singleton.commitCaptured(label) : undefined },
131
133
  discardCaptured() { return singleton ? singleton.discardCaptured() : undefined },
134
+ record(p) { return singleton ? singleton.record(p) : undefined },
135
+ recordValue(t, o) { return singleton ? singleton.recordValue(t, o) : undefined },
132
136
  undo() { return singleton ? singleton.undo() : undefined },
133
137
  redo() { return singleton ? singleton.redo() : undefined },
134
138
  clear() { return singleton ? singleton.clear() : undefined },
package/src/primitives.js CHANGED
@@ -128,6 +128,12 @@ export function replayForward(p) {
128
128
  case 'text':
129
129
  p.target.data = p.newValue
130
130
  return
131
+ case 'value':
132
+ // An element PROPERTY write (e.g. input.value / checkbox.checked). These
133
+ // fire no MutationRecord, so they never arrive via the observer — a caller
134
+ // records them explicitly through scope.recordValue().
135
+ p.target[p.prop] = p.newValue
136
+ return
131
137
  case 'add':
132
138
  for (const node of p.nodes) {
133
139
  if (p.before && p.before.parentNode === p.parent) {
@@ -162,6 +168,9 @@ export function replayReverse(p) {
162
168
  case 'text':
163
169
  p.target.data = p.oldValue
164
170
  return
171
+ case 'value':
172
+ p.target[p.prop] = p.oldValue
173
+ return
165
174
  case 'add':
166
175
  // forward was add → reverse is remove
167
176
  for (const node of p.nodes) {
package/src/scope.js CHANGED
@@ -30,7 +30,7 @@ export function createScope(opts) {
30
30
  function start() {
31
31
  if (started) return
32
32
  started = true
33
- observer = new MutationObserver(handleRecords)
33
+ observer = createRecordSource(handleRecords)
34
34
  observer.observe(config.scope, {
35
35
  childList: true,
36
36
  attributes: true,
@@ -42,6 +42,23 @@ export function createScope(opts) {
42
42
  log('started')
43
43
  }
44
44
 
45
+ // Choose the record source ONCE. Paired with hyperclayjs AND watching the
46
+ // document.body singleton, source records from the platform's single shared
47
+ // observer (Mutation.createObserver) so the page runs ONE MutationObserver.
48
+ // Otherwise — standalone, or a created shadow/CodeMirror scope — use a real
49
+ // MutationObserver. The result is duck-typed: only observe / disconnect /
50
+ // takeRecords are touched, and the shared hub observes with options identical
51
+ // to the ones above, so nothing here needs to change between the two paths.
52
+ function createRecordSource(handler) {
53
+ const isBodySingleton = typeof document !== 'undefined' && config.scope === document.body
54
+ const hub = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.Mutation) || null
55
+ if (isBodySingleton && hub && typeof hub.createObserver === 'function') {
56
+ log('sourcing records from window.hyperclay.Mutation (shared observer)')
57
+ return hub.createObserver(handler)
58
+ }
59
+ return new MutationObserver(handler)
60
+ }
61
+
45
62
  function stop() {
46
63
  if (!started) return
47
64
  started = false
@@ -65,7 +82,12 @@ export function createScope(opts) {
65
82
  // --- Observer callback ---
66
83
 
67
84
  function handleRecords(records) {
68
- if (pauseDepth > 0) return // records remain in observer buffer until takeRecords() is called
85
+ // While paused, these callback-delivered records are DROPPED — they have
86
+ // already left the observer's buffer, so they're gone. resume()'s
87
+ // takeRecords() separately purges only the remainder that was never
88
+ // delivered. Those two halves together are the pause contract, and the
89
+ // shimmed (paired) path reproduces both.
90
+ if (pauseDepth > 0) return
69
91
  const prims = convertRecords(records)
70
92
  if (prims.length > 0) {
71
93
  for (const p of prims) pendingPrimitives.push(p)
@@ -155,6 +177,26 @@ export function createScope(opts) {
155
177
  if (observer) observer.takeRecords()
156
178
  }
157
179
 
180
+ // --- Manual recording (for changes the observer can't see) ---
181
+
182
+ // Record a primitive the MutationObserver never delivers — an element PROPERTY
183
+ // write (input.value, checkbox.checked) fires no MutationRecord. The primitive
184
+ // joins the same idle batch as observed edits, so rapid same-field property
185
+ // writes coalesce into one commit exactly like text edits do. No-op while
186
+ // stopped or paused (a paused caller is inside an undo/redo replay or an
187
+ // exclusion frame, where injected primitives would be wrong).
188
+ function record(primitive) {
189
+ if (!started || pauseDepth > 0 || !primitive) return
190
+ pendingPrimitives.push(primitive)
191
+ restartIdleTimer()
192
+ }
193
+
194
+ // Convenience for the common case: a value/checked property write on an element.
195
+ function recordValue(target, { prop = 'value', oldValue, newValue } = {}) {
196
+ if (!target || oldValue === newValue) return
197
+ record({ kind: 'value', target, prop, oldValue, newValue })
198
+ }
199
+
158
200
  // Force-close current idle batch as its own commit. Used by undo()/redo() and
159
201
  // by the save seam (snapshot.js calls undo.flush() before cloning).
160
202
  function flush() {
@@ -245,6 +287,7 @@ export function createScope(opts) {
245
287
  _config: config, // private: used by keys.js to read shadowKeydownIn
246
288
  start, stop,
247
289
  commit, commitCaptured, discardCaptured, flush,
290
+ record, recordValue,
248
291
  undo, redo, clear,
249
292
  pause, resume,
250
293
  on: emitter.on, off: emitter.off,