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 +12 -0
- package/dist/hyper-undo.min.js +1 -1
- package/package.json +3 -2
- package/src/extension-noise.js +63 -0
- package/src/filter.js +47 -11
- package/src/index.js +4 -0
- package/src/primitives.js +9 -0
- package/src/scope.js +45 -2
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,
|
package/dist/hyper-undo.min.js
CHANGED
|
@@ -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.
|
|
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": "
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
12
|
+
import { EXTENSION_NODE_SELECTOR, EXTENSION_ATTR_PATTERN } from './extension-noise.js'
|
|
7
13
|
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|