@pointyink/rogue 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +61 -0
- package/dist/dom_rope.js +1 -0
- package/dist/headless.js +1 -0
- package/dist/index.js +1 -0
- package/dist/oplog.js +1 -0
- package/dist/rogue.wasm.js +1 -0
- package/dist/rogue_text.js +1 -0
- package/dist/sync-client.js +1 -0
- package/dist/sync.js +4 -0
- package/index.d.ts +11 -0
- package/package.json +62 -0
- package/types/dom_rope.d.ts +196 -0
- package/types/headless.d.ts +58 -0
- package/types/oplog.d.ts +27 -0
- package/types/rogue.wasm.d.ts +3 -0
- package/types/rogue_text.d.ts +33 -0
- package/types/sync-client.d.ts +19 -0
- package/types/sync.d.ts +154 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import a from"./rogue.wasm.js";let s;function u(){return s??=Uint8Array.from(atob(a),h=>h.charCodeAt(0)),s}class o{static async create({wasmBytes:t,replicaId:e=1,capacity:n=1<<20}={}){t??=u();let r;const{instance:_}=await WebAssembly.instantiate(t,{env:{jsLog:()=>{}}}),i=_.exports;return r=i.memory,i.rogue_init(e,n),new o(i,r)}constructor(t,e){this._exp=t,this._mem=e,this._inPtr=t.rogue_in_ptr(),this._outPtr=t.rogue_out_ptr(),this._length=0}get length(){return this._length}insert(t,e){const n=new Uint16Array(this._mem.buffer,this._inPtr,e.length);for(let r=0;r<e.length;r++)n[r]=e.charCodeAt(r);this._exp.rogue_insert(t,0,e.length*2),this._length+=e.length}delete(t,e){this._exp.rogue_hide(t,e),this._length-=e}toString(){const t=this._exp.rogue_render();return new TextDecoder("utf-16le").decode(new Uint8Array(this._mem.buffer,this._outPtr,t*2))}}export{o as RogueText};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{enqueue as v,ackMany as C,pendingFor as x}from"./oplog.js";import"@pointyink/serde/multi_op_v0";import*as I from"@pointyink/serde/ack_v0";import*as S from"@pointyink/serde/node_id_v0";const U=3,P=4,R=5,o=6;function q(i){if(i.byteLength<S.SIZE)return null;const n=new DataView(i.buffer,i.byteOffset,i.byteLength),r=S.decode(n,0);return`${r.replica_id}:${r.sequence}`}function F(i,n){const r=new Uint8Array(o+n.byteLength),u=new DataView(r.buffer);return u.setUint16(0,i,!0),u.setUint32(2,n.byteLength,!0),r.set(n,o),r}const M="rogue.jwt";function N({wsUrl:i,docId:n,mergeOp:r,getToken:u,onStatus:k,onReady:D}){let c=null,f=1e3,d=!1,y=!1,h=null,p=Promise.resolve();function m(e){k?.(e)}function L(e){c?.send(F(U,e))}async function O(){try{const e=await x(n);for(const t of e){try{r(t.payload)}catch(w){console.error("[sync-client] local replay apply failed:",w)}L(t.payload)}}catch(e){console.error("[sync-client] replay failed:",e)}}function A(e){if(e.byteLength<2)return;const t=new DataView(e),w=t.getUint16(0,!0);switch(w){case U:{if(e.byteLength<o)return;const s=t.getUint32(2,!0);if(e.byteLength<o+s)return;const a=new Uint8Array(e,o,s);r(a);return}case P:{if(e.byteLength<o)return;const s=t.getUint32(2,!0),a=new Uint8Array(e,o,s);try{const E=I.decode(a).group_ids.map(g=>`${g.replica_id}:${g.sequence}`);E.length>0&&C(n,E).catch(g=>console.error("[sync-client] ack drop failed:",g))}catch(l){console.error("[sync-client] bad ack frame:",l)}return}case R:{if(e.byteLength<o)return;const s=t.getUint32(2,!0),a=new Uint8Array(e,o,s);let l;try{l=JSON.parse(new TextDecoder().decode(a))}catch{return}l?.type==="sync_done"&&(y||O().then(()=>{y=!0,D?.()}));return}default:console.warn("[sync-client] unknown disc on incoming frame:",w)}}function _(){d||(h=setTimeout(b,f),f=Math.min(f*2,3e4))}async function b(){if(d)return;m("connecting"),y=!1;let e;try{e=await u()}catch(t){console.error("[sync-client] token fetch failed:",t),m("disconnected"),_();return}d||(c=new WebSocket(i,[M,e]),c.binaryType="arraybuffer",c.addEventListener("open",()=>{f=1e3,m("connected")}),c.addEventListener("close",()=>{m("disconnected"),_()}),c.addEventListener("message",t=>{t.data instanceof ArrayBuffer?A(t.data):console.warn("[sync-client] unexpected text frame:",t.data)}))}function T(e){p=p.then(async()=>{const t=q(e);t&&await v(n,t,e),L(e)}).catch(t=>console.error("[sync-client] enqueue/send failed:",t))}function V(){if(d=!0,h&&(clearTimeout(h),h=null),c){try{c.close()}catch{}c=null}}return b(),{send:T,destroy:V,isReady:()=>y}}export{N as createSyncClient};
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import{diff_match_patch as yn}from"diff-match-patch";import vn from"dompurify";import bn from"./rogue.wasm.js";import{buildRope as Re,defaultSerializeNode as _n,defaultOmitNode as Sn,defaultUnwrapNode as wn,escapeText as Ln}from"./dom_rope.js";const Nn=new yn;function be(l,_=document){const I=_.createElement("template");return I.innerHTML=l,I.innerHTML===l}function b(l,_){if(!globalThis.__rogueProfile)return _();performance.mark(`${l}-start`);const I=_();return performance.mark(`${l}-end`),performance.measure(l,`${l}-start`,`${l}-end`),I}function X(l,_){if(!_.isConnected)return{};const I=l.getRange(_);if(I)return{minIx:I[0],maxIx:I[1]};let y=_.previousSibling,v=null;for(;y&&!(v=l.getRange(y));)y=y.previousSibling;let p=_.nextSibling,$=null;for(;p&&!($=l.getRange(p));)p=p.nextSibling;return{...v?{minIx:v[0]}:{startEdge:!0},...$?{maxIx:$[1]}:{endEdge:!0}}}async function Tn({element:l,replicaId:_=Math.floor(Math.random()*65534)+1,onCommit:I,paste:y,doc:v=document,win:p=window,serializeNode:$=_n,omitNode:Z=Sn,unwrapNode:ee=wn}){const Be=Uint8Array.from(atob(bn),e=>e.charCodeAt(0));let L;const{instance:Fe}=await WebAssembly.instantiate(Be,{env:{jsLog:(e,n)=>console.log("[wasm]",new TextDecoder().decode(new Uint8Array(L.buffer,e,n)))}}),_e=Fe.exports;L=_e.memory;const{rogue_init:He,rogue_in_ptr:Ue,rogue_out_ptr:je,rogue_out_len:ne,rogue_render:F,rogue_render_range:Ke,rogue_begin_batch:qe,rogue_hide:Je,rogue_insert:ze,rogue_commit_batch:Ge,rogue_undo:We,rogue_redo:Ye,rogue_merge:Qe,rogue_revert_tail:Xe,rogue_op_is_structural:Ze,rogue_deinit:en,rogue_op_bounds:nn,rogue_vis_len:H,rogue_id_at:T,rogue_vis_ix:D,rogue_prev_vis:te,rogue_next_vis:re,rogue_head:tn,rogue_ca_len:rn,rogue_set_debug_logging:Se}=_e;He(_,1<<20);const U=Ue(),ie=je();let d=Re(l,$,Z,ee);function M(){d.update(null,null,l)}let C=!1;try{C=/(?:^|[?&])debug=sync(?:&|$)/.test(globalThis.location?.search??"")}catch{}Se(C?1:0);const we=()=>!!globalThis.__rogueForceFullSync;let j="";function m(e){const n=new TextEncoder().encode(e);return new Uint8Array(L.buffer,U,n.length).set(n),n.length}function oe(e){return new Uint8Array(L.buffer,U,e.byteLength).set(e),e.byteLength}function se(){const e=ne(),n=new Uint8Array(e);return n.set(new Uint8Array(L.buffer,ie,e)),n}function on(e,n){const t=new Uint16Array(L.buffer,U+e,n.length);for(let r=0;r<n.length;r++)t[r]=n.charCodeAt(r);return n.length*2}function h(){return new TextDecoder().decode(new Uint8Array(L.buffer,ie,ne()))}function Le(){return new TextDecoder().decode(new Uint8Array(L.buffer,U,rn()))}function K(){return new TextDecoder("utf-16le").decode(new Uint8Array(L.buffer,ie,ne()))}let c={render(e,n){return e===void 0?F():Ke(m(`${e}
|
|
2
|
+
${n}`)),K()},visIxBounds(e){if(nn(oe(e))===0)return null;const n=h().split(",");return{minIx:parseInt(n[0],10),maxIx:parseInt(n[1],10)}},idAtVisIx(e){return T(e)?h():null},visIxOf(e){const n=D(m(e));return n<0?null:n},nextVisId(e){return re(m(e))?h():null},prevVisId(e){return te(m(e))?h():null},visLen(){return H()},merge(e){return Qe(oe(e))!==0},revertTail(e){Xe(e)},contentRange(e){const n=e.startCrdtId?c.nextVisId(e.startCrdtId):c.idAtVisIx(0),t=e.endCrdtId?c.prevVisId(e.endCrdtId):c.idAtVisIx(c.visLen()-1);if(!n||!t)return null;const r=c.visIxOf(n),i=c.visIxOf(t);return r===null||i===null||r>i?null:{startId:n,endId:t}}},g={commonParent(e,n){const t=new Map;for(let r=e;r!==l&&r.parentNode;r=r.parentNode)t.set(r.parentNode,r);for(let r=n;r.parentNode;r=r.parentNode){const i=r.parentNode,o=t.get(i);if(o)return{start:o,end:r,parent:i};if(i===l)break}return null},trackedPrevSibling(e){let n=e.previousSibling;for(;n&&!d.getRange(n);)n=n.previousSibling;return n},trackedNextSibling(e){let n=e.nextSibling;for(;n&&!d.getRange(n);)n=n.nextSibling;return n},nodesForVisRange(e,n){if(d.length===0)return null;const t=d.length-1,r=d.getAtVisIx(Math.max(0,Math.min(e,t))),i=d.getAtVisIx(Math.max(0,Math.min(n,t)));return!r||!i?(C&&console.error("nodesForVisRange: null hit on clamped index",{startIx:e,endIx:n,last:t}),null):g.commonParent(r.node.domNode,i.node.domNode)},fenceposts(e,n,t){return{before:g.trackedPrevSibling(e),after:g.trackedNextSibling(n),parent:t}},captureRegion(e,n,t){const r=d.getRange(e),i=e===n?r:d.getRange(n);return!r||!i?null:{...g.fenceposts(e,n,t),startCrdtId:r[0]>0?c.idAtVisIx(r[0]-1):null,endCrdtId:i[1]<c.visLen()-1?c.idAtVisIx(i[1]+1):null}},captureLocalRegion(e,n,t){const r=d.getRange(e),i=e===n?r:d.getRange(n);return!r||!i?null:{...g.fenceposts(e,n,t),startVisIx:r[0],endVisIx:i[1]}},regionForVisBounds(e){if(!e)return null;const n=c.visLen()-1;if(e.minIx<=0&&e.maxIx>=n)return null;const t=g.nodesForVisRange(Math.max(0,Math.min(e.minIx,n)),Math.max(0,Math.min(e.maxIx,n)));if(!t)return null;const r=g.captureRegion(t.start,t.end,t.parent);if(!r)return null;const i=[r];for(let o=t.parent;o!==l;o=o.parentNode){const s=g.captureRegion(o,o,o.parentNode);if(!s)return null;i.push(s)}return i},resyncRope(e){try{d.update(e.before,e.after,e.parent)}catch(n){console.error("[rope] scoped resync failed \u2014 rebuilding whole rope",n),M()}},htmlBetween(e,n,t){const r=e?e.nextSibling:t.firstChild,i=n?n.previousSibling:t.lastChild;return!r||r===n?"":sn(r,i)},swap(e,n,t,r){pe(()=>{const i=v.createRange();e?i.setStartAfter(e):i.setStart(t,0),n?i.setEndBefore(n):i.setEnd(t,t.childNodes.length),i.deleteContents(),i.insertNode(ln(r,t))})},renderLevels(e){for(const n of e){const t=c.contentRange(n),r=t?c.render(t.startId,t.endId):"";if(n.parent===l||un(r,n.parent)){g.swap(n.before,n.after,n.parent,r),g.resyncRope(n);return}}},fullRender(){const e=c.render();e!==de()&&(b("innerhtml_set",()=>pe(()=>{l.innerHTML=e})),b("rope_rebuild",()=>M()),fe(O))},forceFullRender(){const e=c.render();b("innerhtml_set",()=>pe(()=>{l.innerHTML=e})),b("rope_rebuild",()=>M()),fe(O)}};function sn(e,n){if(e=e??l.firstChild,n=n??l.lastChild,!e||!n)return"";const t=o=>{if(Z(o))return"";if(o.nodeType===3)return Ln(o.data);if(o.nodeType===1){if(ee(o)){let s="";for(const f of o.childNodes)s+=t(f);return s}return $(o.cloneNode(!0))}return""};if(e===n)return t(e);let r="",i=e;for(;i&&(r+=t(i),i!==n);)i=i.nextSibling;return r}function ln(e,n){const t=n.cloneNode(!1);t.innerHTML=e;const r=v.createDocumentFragment();for(;t.firstChild;)r.appendChild(t.firstChild);return r}function un(e,n){const t=n.cloneNode(!1);return t.innerHTML=e,t.innerHTML===e}function cn(e,n){let t=1/0,r=-1/0;for(let i=n;i<e.length;i++){const o=c.visIxBounds(e[i].payload);if(!o)return null;o.minIx<t&&(t=o.minIx),o.maxIx>r&&(r=o.maxIx)}return{minIx:t,maxIx:r}}function fn(e){const n=d;let t=1/0,r=-1/0,i=!1,o=!1;const s=e.some(u=>u.removedNodes.length>0),f=u=>{u.minIx!==void 0&&u.minIx<t&&(t=u.minIx),u.maxIx!==void 0&&u.maxIx>r&&(r=u.maxIx),u.startEdge&&(i=!0),u.endEdge&&(o=!0)};for(const u of e)if(u.type==="childList"){const V=u.target===l;u.previousSibling?f(X(n,u.previousSibling)):V&&(i=!0),u.nextSibling?f(X(n,u.nextSibling)):V&&(o=!0),!V&&(!u.previousSibling||!u.nextSibling)&&f(X(n,u.target))}else f(X(n,u.target));if(t===1/0&&r===-1/0)return null;(i||t===1/0)&&(t=0),(o||r===-1/0)&&(r=n.length-1);const a=g.nodesForVisRange(t,r);if(!a)return null;const x=g.captureLocalRegion(a.start,a.end,a.parent);return x?{...x,structural:s}:null}function le(e,n){const t=n>0?e.getAtVisIx(n-1):null;if(!t)return null;if(t.node.kind==="text"){const o=t.node.domNode;return{node:o,offset:o.data.length}}const r=t.node.domNode,i=r.parentNode;return i?{node:i,offset:Array.from(i.childNodes).indexOf(r)+1}:null}function ue(){const e=p.getSelection();if(!e||!e.rangeCount||!l.contains(e.anchorNode))return null;const n=d.domPosToVisIx(e.anchorNode,e.anchorOffset),t=d.domPosToVisIx(e.focusNode,e.focusOffset);if(n===null||t===null)return null;const r=H();if(n===t&&n>=r){if(r===0||!T(r-1))return null;const V=h();return{anchorId:V,focusId:V,focusInclusive:!0}}let i,o=!1;if(T(n))i=h();else if(n>=r&&r>0)T(r-1),i=h(),o=!0;else return null;if(n===t)return{anchorId:i,focusId:i};let s,f=!1;if(T(t))s=h();else if(t>=r&&r>0)T(r-1),s=h(),f=!0;else return null;let a,x,u;return n<=t?(a=i,x=s,u=f):(a=s,x=i,u=o),{anchorId:a,focusId:x,focusInclusive:u}}let O=null,ce=!1,q=null;function fe(e){if(e){ce=!0;try{const n=H();let t=!1,r=D(m(e.anchorId));if(r<0){t=!0;const x=te(m(e.anchorId))?h():null;r=x?D(m(x)):0}const i=d.visIxToDomPos(r);if(!i)return;if(e.anchorId===e.focusId){if(e.focusInclusive){const a=d.visIxToDomPos(r+1)??le(d,n);if(!a)return;p.getSelection()?.setBaseAndExtent(a.node,a.offset,a.node,a.offset)}else p.getSelection()?.setBaseAndExtent(i.node,i.offset,i.node,i.offset);return}const o=D(m(e.focusId));let s=null,f=!1;if(o>=0)e.focusInclusive?s=d.visIxToDomPos(o+1)??le(d,n):s=d.visIxToDomPos(o);else if(f=!0,re(m(e.focusId))){const x=D(m(h()));s=d.visIxToDomPos(x)}else s=le(d,n);if(!s)return;if(t&&f){p.getSelection()?.setBaseAndExtent(s.node,s.offset,s.node,s.offset);return}if(o>=0&&o<=r){p.getSelection()?.setBaseAndExtent(i.node,i.offset,i.node,i.offset);return}p.getSelection()?.setBaseAndExtent(i.node,i.offset,s.node,s.offset)}finally{ce=!1}}}let S=!1,E=null,J=0,z=!1;function an(){return tn(),h()}function dn(e){const n=e??an();return We(m(n))?{payload:se(),nextCa:Le()}:null}function gn(e){return Ye(m(e))?{payload:se(),nextCa:Le()}:null}function Ne(e,n,t){if(e===n)return;const r=b("dmp_diff",()=>Nn.diff_main(e,n));b("wasm_batch",()=>{qe();let i=t,o=0;for(const[s,f]of r)if(s===0)i+=f.length;else if(s===-1)Je(i,f.length),i+=f.length;else{const a=on(o,f);ze(i,o,a),o+=a}if(Ge()){const s=se();if(Q(xe(s),s),I?.(s),z&&(z=!1,E=null,J=0),C){const f=r.filter(([a])=>a!==0).map(([a,x])=>`${a===-1?"\u2212":"+"}${JSON.stringify(x)}`).join(" ");console.log(`%c[crdt] op committed @ ${t}`,"color:#0a0;font-weight:bold",f)}}})}function ae(e){b("rope_rebuild",e);const n=b("save_cursor",()=>ue());n&&(O=n),Ve("finishSync")}function pn(e){const n=c.idAtVisIx(e.startVisIx),t=c.idAtVisIx(e.endVisIx),r=n&&t?c.render(n,t):"",i=g.htmlBetween(e.before,e.after,e.parent);return r===i?(e.structural&&ae(()=>g.resyncRope(e)),!1):(C&&console.log(`%c[diff] scoped @ startVisIx ${e.startVisIx}`,"color:#59f;font-weight:bold",`
|
|
3
|
+
crdt:`,JSON.stringify(r),`
|
|
4
|
+
dom :`,JSON.stringify(i)),Ne(r,i,e.startVisIx),ae(()=>g.resyncRope(e)),!0)}function Ce(){b("full_render",()=>F());const e=b("read_utf16",()=>K()),n=b("clean_innerhtml",()=>de());if(n===e){M();return}Ne(e,n,0),ae(M)}function de(){const e=Re(l,$,Z,ee);return e?e.serialize():""}function Ae(e,n){const t=Math.min(e.length,n.length);for(let r=0;r<t;r++)if(e[r]!==n[r])return r;return e.length===n.length?-1:t}function Ve(e){if(C)try{F();const n=K(),t=de(),r=d?d.serialize():"",i=[];if(n!==t&&i.push("crdt\u2260dom"),r!==t&&i.push("rope\u2260dom"),!i.length){j&&(console.log("%c\u2713 sync resynced","color:#2a2;font-weight:bold"),j="");return}const o=Ae(n!==t?n:r,t),s=`${i.join(",")}@${o}`;if(s===j)return;j=s;const f=a=>o<0?"":JSON.stringify(a.slice(Math.max(0,o-24),o+24));console.error(`%c\u{1F534} SYNC DIVERGENCE @ ${e} [${i.join(", ")}]`,"color:#fff;background:#c00;font-weight:bold;padding:2px 6px"),o>=0&&console.error(`first diff @ vis_ix ${o}`),console.error("crdt near:",f(n)),console.error("dom near:",f(t)),console.error("rope near:",f(r)),console.error("crdt full:",JSON.stringify(n)),console.error("dom full:",JSON.stringify(t)),console.error("rope full:",JSON.stringify(r))}catch(n){console.error(`\u{1F534} sync invariant check threw @ ${e}`,n)}}let P=!1,G=[];function $e(e){e.preventDefault();const n=e.clipboardData?.getData("text/html")??"",t=e.clipboardData?.getData("text/plain")??"",r=n?vn.sanitize(n):t.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/\n/g,"<br>");if(y&&y.maxBytes&&r.length>y.maxBytes){y.onRejected?.(r.length,y.maxBytes);return}v.execCommand("insertHTML",!1,r)}function Te(){me();const e=dn(E);e&&(E=e.nextCa,B(e.payload),I?.(e.payload),J++)}function ge(){if(me(),E===null||J<=0)return;const e=gn(E);e&&(E=e.nextCa,B(e.payload),I?.(e.payload),J--)}function De(e){(e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==="z"?(e.preventDefault(),e.shiftKey?ge():Te()):e.ctrlKey&&e.key.toLowerCase()==="y"&&(e.preventDefault(),ge())}function Oe(){P=!0}function W(){P&&(P=!1,z=!0,Ce(),mn())}function mn(){if(G.length===0)return;const e=G;G=[];for(const n of e)B(n)}let Y=null;function pe(e){e(),Y?.takeRecords()}function me(){const e=Y?.takeRecords();e&&e.length&&Ee(e)}function k(e){if(!e)return"\u2205";if(e.nodeType===3)return`#text${JSON.stringify(e.data.slice(0,24))}`;const n=e,t=n.id?`#${n.id}`:"",r=typeof n.className=="string"&&n.className?`.${n.className.split(/\s+/)[0]}`:"";return`<${n.tagName?.toLowerCase()}${t}${r}>`}function Ee(e){if(S||P)return;const n=we()?null:fn(e);C&&console.log("%c[mut]","color:#a5f;font-weight:bold",e.map(t=>t.type==="childList"?`childList @${k(t.target)} +[${[...t.addedNodes].map(k).join(", ")}] \u2212[${[...t.removedNodes].map(k).join(", ")}]`:`${t.type} @${k(t.target)}${t.attributeName?` (${t.attributeName})`:""}`),"\u2192",n?`region[${n.startVisIx}] ${k(n.before)}\u2026${k(n.after)}`:"null \u2192 full-doc"),b("sync_total",()=>n?pn(n):Ce())}function ke(){ce||l.contains(p.getSelection()?.anchorNode??null)&&(q&&p.cancelAnimationFrame(q),q=p.requestAnimationFrame(()=>{q=null;const e=ue();e&&(O=e)}))}y!==!1&&l.addEventListener("paste",$e),l.addEventListener("keydown",De),l.addEventListener("compositionstart",Oe),l.addEventListener("compositionend",W),l.addEventListener("focusout",W),v.addEventListener("selectionchange",ke);const Me=new p.MutationObserver(Ee);Me.observe(l,{childList:!0,characterData:!0,attributes:!0,subtree:!0}),Y=Me,p.rogue={editorRoot:()=>l,rope:()=>d,visLen:()=>H(),idAt:e=>T(e)?h():null,visIx:e=>{const n=D(m(e));return n<0?null:n},prevVis:e=>te(m(e))?h():null,nextVis:e=>re(m(e))?h():null,saveCursor:()=>ue(),cursorIds:()=>O,render:()=>(F(),K())};const N=[],he=1e4;function xe(e){const n=new DataView(e.buffer,e.byteOffset,e.byteLength);return{replica:n.getUint16(0,!0),seq:n.getUint32(2,!0)}}function Ie(e){return`${e.replica}:${e.seq}`}function ye(e,n){return e.seq-n.seq||e.replica-n.replica}function hn(e){return Ze(oe(e))!==0}function xn(e){if(!e)return be(c.render(),v);const n=e[e.length-1],t=c.contentRange(n);return be(t?c.render(t.startId,t.endId):c.render(),v)}const Pe=new Set;let w=[];const R=new Set;let A=null;function Q(e,n){const t=Ie(e);R.has(t)||(w.push({gid:e,payload:n}),R.add(t),(!A||ye(e,A)>0)&&(A=e))}function ve(){let e=!1;for(let n=N.length-1;n>=0;n--)c.merge(N[n])&&(Q(xe(N[n]),N[n]),N.splice(n,1),e=!0,n=N.length);return e}function In(e){console.error(`[rogue] parked-ops cap (${he}) exceeded \u2014 incoming op dropped. CRDT state is now incomplete; client should reload to re-sync.`,{cap:he,currentParked:N.length,payloadSize:e.byteLength})}function B(e){me();const n=xe(e),t=Ie(n);if(Pe.has(t)||R.has(t))return;const r=S?null:g.regionForVisBounds(c.visIxBounds(e));if(!A||ye(n,A)>0){if(!c.merge(e)){N.length<he?N.push(e):In(e);return}const o=A;Q(n,e);let s=!1;hn(e)&&!xn(r)&&(c.revertTail(1),Pe.add(t),R.delete(t),w.pop(),A=o,s=!0);const f=s||ve();S||(f?g.fullRender():r&&!we()?g.renderLevels(r):g.fullRender())}else{Q(n,e),w.sort((u,V)=>ye(u.gid,V.gid));const o=w.findIndex(u=>u.gid.replica===n.replica&&u.gid.seq===n.seq),s=w.length-o-1,f=s>0&&!S?g.regionForVisBounds(cn(w,o+1)):null;c.revertTail(s);const a=w.splice(o);for(const u of a)R.delete(Ie(u.gid));A=w.length?w[w.length-1].gid:null,S||(f?g.renderLevels(f):g.fullRender());for(const u of a)B(u.payload);const x=ve();!S&&x&&g.fullRender()}S||(fe(O),Ve("mergeOp"))}return{mergeOp(e){if(P){G.push(e);return}B(e)},beginLoad(){S=!0},endLoad(){S&&(S=!1,g.forceFullRender())},undo:Te,redo:ge,drainParked(){ve()&&g.fullRender()},setSyncDebug(e){C=!!e,Se(e?1:0)},markUserEdit(){z=!0},destroy(){y!==!1&&l.removeEventListener("paste",$e),l.removeEventListener("keydown",De),l.removeEventListener("compositionstart",Oe),l.removeEventListener("compositionend",W),l.removeEventListener("focusout",W),v.removeEventListener("selectionchange",ke),Y?.disconnect(),en()}}}export{Tn as createRogueNode,X as nodeSpan,be as roundTrips};
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Public API barrel for @pointyink/rogue. Hand-authored (it's the deliberate
|
|
2
|
+
// export contract); the type *bodies* it re-exports are generated from the
|
|
3
|
+
// source JSDoc via `make build-types` into ./types/.
|
|
4
|
+
|
|
5
|
+
export { createRogueNode } from './types/sync.js'
|
|
6
|
+
export type { RogueNode, RogueNodeOptions } from './types/sync.js'
|
|
7
|
+
|
|
8
|
+
export { DomRope, buildRope } from './types/dom_rope.js'
|
|
9
|
+
export type { SerializeNode, OmitNode, UnwrapNode } from './types/dom_rope.js'
|
|
10
|
+
|
|
11
|
+
export { RogueText } from './types/rogue_text.js'
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pointyink/rogue",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "State-of-the-art HTML sync CRDT engine (Fugue CRDT + rope treap) with a DOM sync layer.",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/fivetentaylor/rogue.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"types": "./index.d.ts",
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"index.d.ts",
|
|
15
|
+
"types"
|
|
16
|
+
],
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./index.d.ts",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./oplog": {
|
|
23
|
+
"types": "./types/oplog.d.ts",
|
|
24
|
+
"default": "./dist/oplog.js"
|
|
25
|
+
},
|
|
26
|
+
"./sync-client": {
|
|
27
|
+
"types": "./types/sync-client.d.ts",
|
|
28
|
+
"default": "./dist/sync-client.js"
|
|
29
|
+
},
|
|
30
|
+
"./text": {
|
|
31
|
+
"types": "./types/rogue_text.d.ts",
|
|
32
|
+
"default": "./dist/rogue_text.js"
|
|
33
|
+
},
|
|
34
|
+
"./headless": {
|
|
35
|
+
"types": "./types/headless.d.ts",
|
|
36
|
+
"default": "./dist/headless.js"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"test:watch": "vitest",
|
|
42
|
+
"build:wasm": "zig build wasm -Doptimize=ReleaseFast && node scripts/emit-wasm.mjs",
|
|
43
|
+
"build:types": "tsc -p tsconfig.types.json",
|
|
44
|
+
"build:dist": "rm -rf dist && esbuild src/index.js src/sync.js src/dom_rope.js src/rogue_text.js src/oplog.js src/sync-client.js src/headless.js src/rogue.wasm.js --minify --outdir=dist --format=esm --platform=neutral",
|
|
45
|
+
"prepublishOnly": "npm run build:dist",
|
|
46
|
+
"build": "esbuild ../client/main.js --bundle --minify --outfile=../public/app.js --platform=browser --format=esm --alias:@pointyink/rogue=./src --alias:@pointyink/serde=../serde --sourcemap",
|
|
47
|
+
"build:tinymce": "esbuild ../tinymce-plugin/plugin.js --bundle --minify --outfile=../public/tinymce-plugin.js --platform=browser --format=esm --alias:@pointyink/rogue=./src --alias:@pointyink/serde=../serde --sourcemap"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@pointyink/serde": "^0.1.0",
|
|
51
|
+
"diff-match-patch": "^1.0.5",
|
|
52
|
+
"dompurify": "^3.2.6"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/diff-match-patch": "^1.0.36",
|
|
56
|
+
"@types/node": "^26.0.1",
|
|
57
|
+
"esbuild": "^0.25.0",
|
|
58
|
+
"jsdom": "^26.1.0",
|
|
59
|
+
"typescript": "^6.0.3",
|
|
60
|
+
"vitest": "^3.1.1"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/** @internal @param {string} text @returns {string} */
|
|
2
|
+
export function escapeText(text: string): string;
|
|
3
|
+
/** @internal @param {Element} el @returns {string} */
|
|
4
|
+
export function getCloseTag(el: Element): string;
|
|
5
|
+
/** @internal @param {Element} el @param {SerializeNode} [serializeNode] @returns {string} */
|
|
6
|
+
export function getOpenTag(el: Element, serializeNode?: SerializeNode): string;
|
|
7
|
+
/**
|
|
8
|
+
* Build a DomRope from the children of a DOM Element (not the element itself).
|
|
9
|
+
* HTML offsets produced by this rope match element.innerHTML. Always returns a
|
|
10
|
+
* DomRope — an element with no children yields an empty rope (root null), never
|
|
11
|
+
* null, so callers never have to null-check the instance.
|
|
12
|
+
* @param {Element} element
|
|
13
|
+
* @param {SerializeNode} [serializeNode]
|
|
14
|
+
* @param {OmitNode} [omitNode]
|
|
15
|
+
* @returns {DomRope}
|
|
16
|
+
*/
|
|
17
|
+
export function buildRope(element: Element, serializeNode?: SerializeNode, omitNode?: OmitNode, unwrapNode?: UnwrapNode): DomRope;
|
|
18
|
+
/**
|
|
19
|
+
* Serialize a *cloned, mutable* element to its clean outerHTML string. The
|
|
20
|
+
* default is native `.outerHTML` (no cleaning); a host (e.g. the TinyMCE plugin)
|
|
21
|
+
* overrides it to strip its internal attributes. Callers always pass a clone, so
|
|
22
|
+
* the hook may mutate it. It must keep native-outerHTML formatting (not run a
|
|
23
|
+
* full HTML serializer) so getOpenTag's close-tag slice stays valid.
|
|
24
|
+
* @typedef {(node: Element) => string} SerializeNode
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Predicate for DOM nodes to omit from the rope entirely — host editing chrome
|
|
28
|
+
* that isn't document content (e.g. TinyMCE's resize handles / bogus nodes). Must
|
|
29
|
+
* be applied identically wherever the DOM is serialized (the rope build here and
|
|
30
|
+
* sync's rangeOuterHtml) or the two representations disagree.
|
|
31
|
+
* @typedef {(node: Node) => boolean} OmitNode
|
|
32
|
+
*/
|
|
33
|
+
/** @internal @type {SerializeNode} */
|
|
34
|
+
export const defaultSerializeNode: SerializeNode;
|
|
35
|
+
/** @internal @type {OmitNode} */
|
|
36
|
+
export const defaultOmitNode: OmitNode;
|
|
37
|
+
/**
|
|
38
|
+
* Predicate for DOM wrappers to traverse *transparently*: their children are kept
|
|
39
|
+
* in the rope but the wrapper itself emits no open/close tags (e.g. TinyMCE's
|
|
40
|
+
* format-caret bookmarks, which wrap real content in a `<span data-mce-type="format-caret">`).
|
|
41
|
+
* @typedef {(node: Node) => boolean} UnwrapNode
|
|
42
|
+
*/
|
|
43
|
+
/** @internal @type {UnwrapNode} */
|
|
44
|
+
export const defaultUnwrapNode: UnwrapNode;
|
|
45
|
+
export class DomRope {
|
|
46
|
+
/**
|
|
47
|
+
* @param {RopeNode | null} root null ⇒ an empty rope (empty document)
|
|
48
|
+
* @param {WeakMap<Node, RopeNode[]>} nodeMap
|
|
49
|
+
* @param {Element} container the element the rope was built from; its children
|
|
50
|
+
* are the tracked top-level nodes. It is *not* itself tokenized, so it's the
|
|
51
|
+
* one untracked node update accepts as a parent (a resync edge).
|
|
52
|
+
* @param {SerializeNode} [serializeNode]
|
|
53
|
+
* @param {OmitNode} [omitNode]
|
|
54
|
+
*/
|
|
55
|
+
constructor(root: RopeNode | null, nodeMap: WeakMap<Node, RopeNode[]>, container: Element, serializeNode?: SerializeNode, omitNode?: OmitNode, unwrapNode?: UnwrapNode);
|
|
56
|
+
root: RopeNode | null;
|
|
57
|
+
_nodeMap: WeakMap<Node, RopeNode[]>;
|
|
58
|
+
_container: Element;
|
|
59
|
+
_serializeNode: SerializeNode;
|
|
60
|
+
_omitNode: OmitNode;
|
|
61
|
+
_unwrapNode: UnwrapNode;
|
|
62
|
+
/** @returns {string} */
|
|
63
|
+
serialize(): string;
|
|
64
|
+
/**
|
|
65
|
+
* Total HTML character length of the rope (== element.innerHTML length).
|
|
66
|
+
* O(1): each node's `weight` is its full subtree aggregate, so the root's
|
|
67
|
+
* weight is the whole-rope length — the same value getAtVisIx bounds against.
|
|
68
|
+
*/
|
|
69
|
+
get length(): number;
|
|
70
|
+
/**
|
|
71
|
+
* Find the rope node whose text contains the given HTML char index (visIx),
|
|
72
|
+
* along with the absolute start of that node and the offset within its text.
|
|
73
|
+
* O(log n) expected.
|
|
74
|
+
* @param {number} visIx
|
|
75
|
+
* @returns {AtIndexResult | null}
|
|
76
|
+
*/
|
|
77
|
+
getAtVisIx(visIx: number): AtIndexResult | null;
|
|
78
|
+
/**
|
|
79
|
+
* Return the [start, end] inclusive HTML character range occupied by a DOM
|
|
80
|
+
* node — from the first char of its open tag to the last char of its close
|
|
81
|
+
* tag (or its text content for text nodes).
|
|
82
|
+
* O(log n) expected via parent-pointer walk; no full-tree traversal.
|
|
83
|
+
* @param {Node} domNode
|
|
84
|
+
* @returns {[number, number] | null}
|
|
85
|
+
*/
|
|
86
|
+
getRange(domNode: Node): [number, number] | null;
|
|
87
|
+
/**
|
|
88
|
+
* Convert a DOM selection endpoint (node + offset) to an HTML char offset
|
|
89
|
+
* into the rope's source HTML (element.innerHTML).
|
|
90
|
+
* Returns null if the node is not tracked by this rope.
|
|
91
|
+
* @param {Node} node
|
|
92
|
+
* @param {number} offset
|
|
93
|
+
* @returns {number | null}
|
|
94
|
+
*/
|
|
95
|
+
domPosToVisIx(node: Node, offset: number): number | null;
|
|
96
|
+
/**
|
|
97
|
+
* Convert an HTML char index (vis_ix) to a DOM position suitable for
|
|
98
|
+
* use with Selection.setBaseAndExtent.
|
|
99
|
+
*
|
|
100
|
+
* - kind 'text' → { node: Text, offset: raw char offset }
|
|
101
|
+
* - kind 'open' → { node: parentElement, offset: childIndex } (before the element)
|
|
102
|
+
* - kind 'close' → { node: element, offset: childNodes.length } (after all children)
|
|
103
|
+
*
|
|
104
|
+
* The 'open' rule keeps domPosToVisIx / visIxToDomPos round-trippable:
|
|
105
|
+
* placing the cursor inside a void element (e.g. <br>) at offset 0 would map
|
|
106
|
+
* back to after the tag, not before it.
|
|
107
|
+
* @param {number} visIx
|
|
108
|
+
* @returns {{ node: Node, offset: number } | null}
|
|
109
|
+
*/
|
|
110
|
+
visIxToDomPos(visIx: number): {
|
|
111
|
+
node: Node;
|
|
112
|
+
offset: number;
|
|
113
|
+
} | null;
|
|
114
|
+
/**
|
|
115
|
+
* Return all element tags that overlap with [startIx, endIx].
|
|
116
|
+
* Walks the rope from startIx rightward, stopping once past endIx.
|
|
117
|
+
* @param {number} startIx
|
|
118
|
+
* @param {number} endIx
|
|
119
|
+
* @returns {{ tag: string, startIx: number, endIx: number, element: Element }[]}
|
|
120
|
+
*/
|
|
121
|
+
getTagRanges(startIx: number, endIx: number): {
|
|
122
|
+
tag: string;
|
|
123
|
+
startIx: number;
|
|
124
|
+
endIx: number;
|
|
125
|
+
element: Element;
|
|
126
|
+
}[];
|
|
127
|
+
/**
|
|
128
|
+
* Re-derive the rope for the DOM region bracketed by two surviving anchors,
|
|
129
|
+
* after the nodes inside that region changed — a local in-place edit, a remote
|
|
130
|
+
* subtree swap, or a full rebuild. This is the rope's single scoped-mutation
|
|
131
|
+
* primitive. O(log n + k) where k = the new rope-node count.
|
|
132
|
+
*
|
|
133
|
+
* `beforeNode`/`afterNode` are DOM nodes that *survived* the change and are
|
|
134
|
+
* still tracked, sitting immediately to the left/right of the region; a null
|
|
135
|
+
* anchor means the region runs to the start/end of `parent`'s content. `parent`
|
|
136
|
+
* is the changed nodes' common parent — used to resolve a null anchor and to
|
|
137
|
+
* walk the live children now occupying the region.
|
|
138
|
+
*
|
|
139
|
+
* The cut window comes from the anchors' own (unchanged) token positions: just
|
|
140
|
+
* after `beforeNode`, just before `afterNode`. A null anchor resolves to
|
|
141
|
+
* `parent`'s content edge when `parent` is a tracked block, or to the rope's
|
|
142
|
+
* absolute edge (0 / length) when `parent` isn't tracked — i.e. it's the root
|
|
143
|
+
* container the rope was built from. The region must be sibling-aligned so the
|
|
144
|
+
* split lands on token boundaries and purgeMap stays consistent;
|
|
145
|
+
* `siblingRegion` upstream guarantees that.
|
|
146
|
+
*
|
|
147
|
+
* @param {Node | null} beforeNode
|
|
148
|
+
* @param {Node | null} afterNode
|
|
149
|
+
* @param {Node} parent
|
|
150
|
+
*/
|
|
151
|
+
update(beforeNode: Node | null, afterNode: Node | null, parent: Node): void;
|
|
152
|
+
/**
|
|
153
|
+
* Test-only convenience: re-derive an in-place node from its live DOM siblings.
|
|
154
|
+
* Production never calls this — the sync layer resolves tracked anchors itself.
|
|
155
|
+
* @param {Node} startNode
|
|
156
|
+
* @param {Node} [endNode]
|
|
157
|
+
*/
|
|
158
|
+
rebuild(startNode: Node, endNode?: Node): void;
|
|
159
|
+
}
|
|
160
|
+
export type RopeNodeKind = "open" | "text" | "close";
|
|
161
|
+
export type RopeNode = {
|
|
162
|
+
text: string;
|
|
163
|
+
weight: number;
|
|
164
|
+
priority: number;
|
|
165
|
+
left: RopeNode | null;
|
|
166
|
+
right: RopeNode | null;
|
|
167
|
+
parent: RopeNode | null;
|
|
168
|
+
domNode: Element | Text;
|
|
169
|
+
kind: RopeNodeKind;
|
|
170
|
+
};
|
|
171
|
+
export type AtIndexResult = {
|
|
172
|
+
node: RopeNode;
|
|
173
|
+
nodeStart: number;
|
|
174
|
+
offset: number;
|
|
175
|
+
};
|
|
176
|
+
/**
|
|
177
|
+
* Serialize a *cloned, mutable* element to its clean outerHTML string. The
|
|
178
|
+
* default is native `.outerHTML` (no cleaning); a host (e.g. the TinyMCE plugin)
|
|
179
|
+
* overrides it to strip its internal attributes. Callers always pass a clone, so
|
|
180
|
+
* the hook may mutate it. It must keep native-outerHTML formatting (not run a
|
|
181
|
+
* full HTML serializer) so getOpenTag's close-tag slice stays valid.
|
|
182
|
+
*/
|
|
183
|
+
export type SerializeNode = (node: Element) => string;
|
|
184
|
+
/**
|
|
185
|
+
* Predicate for DOM nodes to omit from the rope entirely — host editing chrome
|
|
186
|
+
* that isn't document content (e.g. TinyMCE's resize handles / bogus nodes). Must
|
|
187
|
+
* be applied identically wherever the DOM is serialized (the rope build here and
|
|
188
|
+
* sync's rangeOuterHtml) or the two representations disagree.
|
|
189
|
+
*/
|
|
190
|
+
export type OmitNode = (node: Node) => boolean;
|
|
191
|
+
/**
|
|
192
|
+
* Predicate for DOM wrappers to traverse *transparently*: their children are kept
|
|
193
|
+
* in the rope but the wrapper itself emits no open/close tags (e.g. TinyMCE's
|
|
194
|
+
* format-caret bookmarks, which wrap real content in a `<span data-mce-type="format-caret">`).
|
|
195
|
+
*/
|
|
196
|
+
export type UnwrapNode = (node: Node) => boolean;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse `html` and re-serialize it through the rope's canonical form (the same
|
|
3
|
+
* scrub + escape the CRDT render uses). Reports whether it round-trips un-mangled
|
|
4
|
+
* and what it normalizes to.
|
|
5
|
+
* @param {string} html @param {Document} doc
|
|
6
|
+
* @returns {{ ok: boolean, cleaned: string }}
|
|
7
|
+
*/
|
|
8
|
+
export function roundTripHtml(html: string, doc: Document): {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
cleaned: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Create a headless editor (a CRDT replica over a jsdom <div>).
|
|
14
|
+
*
|
|
15
|
+
* Pass `doc`/`win`/`element` to bind an existing jsdom (e.g. the MCP server owns
|
|
16
|
+
* the jsdom + sync client); otherwise a fresh jsdom is created.
|
|
17
|
+
*
|
|
18
|
+
* @param {{ replicaId?: number, onCommit?: (payload: Uint8Array) => void, doc?: Document, win?: Window, element?: HTMLElement }} [options]
|
|
19
|
+
* @returns {Promise<HeadlessEditor>}
|
|
20
|
+
*/
|
|
21
|
+
export function createHeadlessEditor({ replicaId, onCommit, doc, win, element }?: {
|
|
22
|
+
replicaId?: number;
|
|
23
|
+
onCommit?: (payload: Uint8Array) => void;
|
|
24
|
+
doc?: Document;
|
|
25
|
+
win?: Window;
|
|
26
|
+
element?: HTMLElement;
|
|
27
|
+
}): Promise<HeadlessEditor>;
|
|
28
|
+
export type HeadlessEditor = {
|
|
29
|
+
node: import("./sync.js").RogueNode;
|
|
30
|
+
element: HTMLElement;
|
|
31
|
+
getHtml: () => string;
|
|
32
|
+
getVisLen: () => number;
|
|
33
|
+
validate: (html: string) => {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
cleaned: string;
|
|
36
|
+
};
|
|
37
|
+
setHtml: (html: string) => Promise<{
|
|
38
|
+
ok: boolean;
|
|
39
|
+
cleaned?: string;
|
|
40
|
+
html?: string;
|
|
41
|
+
}>;
|
|
42
|
+
appendHtml: (html: string) => Promise<{
|
|
43
|
+
ok: boolean;
|
|
44
|
+
cleaned?: string;
|
|
45
|
+
html?: string;
|
|
46
|
+
}>;
|
|
47
|
+
undo: () => void;
|
|
48
|
+
redo: () => void;
|
|
49
|
+
mergeOp: (payload: Uint8Array) => void;
|
|
50
|
+
markUserEdit: () => void;
|
|
51
|
+
subscribe: (cb: (payload: Uint8Array) => void) => () => boolean;
|
|
52
|
+
assertConsistent: () => {
|
|
53
|
+
ok: boolean;
|
|
54
|
+
crdt: string;
|
|
55
|
+
dom: string;
|
|
56
|
+
};
|
|
57
|
+
destroy: () => void;
|
|
58
|
+
};
|
package/types/oplog.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Add an op to the pending queue. Called before the op is sent over the wire.
|
|
3
|
+
* IndexedDB stores the Uint8Array via the structured-clone algorithm — no
|
|
4
|
+
* encoding wrapper needed; survives reload as the original bytes.
|
|
5
|
+
* @param {string} docId
|
|
6
|
+
* @param {string} id group NodeId "r:s"
|
|
7
|
+
* @param {Uint8Array} payload multi_op_v0 binary as emitted by the CRDT
|
|
8
|
+
*/
|
|
9
|
+
export function enqueue(docId: string, id: string, payload: Uint8Array): Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* Drop a batch of ids in one transaction. Missing ids are no-ops.
|
|
12
|
+
* @param {string} docId
|
|
13
|
+
* @param {string[]} ids
|
|
14
|
+
*/
|
|
15
|
+
export function ackMany(docId: string, ids: string[]): Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* Return all unacked ops for a doc, oldest first.
|
|
18
|
+
* Uses a range query on the compound key prefix — `[]` upper bound sorts
|
|
19
|
+
* above any primitive, so this matches every `[docId, *]` entry.
|
|
20
|
+
* @param {string} docId
|
|
21
|
+
* @returns {Promise<{id: string, payload: Uint8Array, ts: number}[]>}
|
|
22
|
+
*/
|
|
23
|
+
export function pendingFor(docId: string): Promise<{
|
|
24
|
+
id: string;
|
|
25
|
+
payload: Uint8Array;
|
|
26
|
+
ts: number;
|
|
27
|
+
}[]>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export class RogueText {
|
|
2
|
+
/**
|
|
3
|
+
* @param {object} [opts]
|
|
4
|
+
* @param {Uint8Array} [opts.wasmBytes] Raw rogue.wasm bytes. Auto-loaded from
|
|
5
|
+
* the package's bundled wasm if omitted.
|
|
6
|
+
* @param {number} [opts.replicaId=1]
|
|
7
|
+
* @param {number} [opts.capacity=1 << 20] Max nodes the doc can hold.
|
|
8
|
+
* @returns {Promise<RogueText>}
|
|
9
|
+
*/
|
|
10
|
+
static create({ wasmBytes, replicaId, capacity }?: {
|
|
11
|
+
wasmBytes?: Uint8Array<ArrayBufferLike> | undefined;
|
|
12
|
+
replicaId?: number | undefined;
|
|
13
|
+
capacity?: number | undefined;
|
|
14
|
+
}): Promise<RogueText>;
|
|
15
|
+
/**
|
|
16
|
+
* @param {any} exp WASM exports (untyped numeric ABI).
|
|
17
|
+
* @param {WebAssembly.Memory} mem
|
|
18
|
+
*/
|
|
19
|
+
constructor(exp: any, mem: WebAssembly.Memory);
|
|
20
|
+
_exp: any;
|
|
21
|
+
_mem: WebAssembly.Memory;
|
|
22
|
+
_inPtr: any;
|
|
23
|
+
_outPtr: any;
|
|
24
|
+
_length: number;
|
|
25
|
+
/** Visible length in UTF-16 code units. */
|
|
26
|
+
get length(): number;
|
|
27
|
+
/** Insert `str` at visible index `idx`. @param {number} idx @param {string} str */
|
|
28
|
+
insert(idx: number, str: string): void;
|
|
29
|
+
/** Tombstone `len` code units starting at visible index `idx`. @param {number} idx @param {number} len */
|
|
30
|
+
delete(idx: number, len: number): void;
|
|
31
|
+
/** Materialize the current visible text. */
|
|
32
|
+
toString(): string;
|
|
33
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {SyncClientOptions} options
|
|
3
|
+
* @returns {SyncClient}
|
|
4
|
+
*/
|
|
5
|
+
export function createSyncClient({ wsUrl, docId, mergeOp, getToken, onStatus, onReady }: SyncClientOptions): SyncClient;
|
|
6
|
+
export type SyncStatus = "connecting" | "connected" | "disconnected";
|
|
7
|
+
export type SyncClientOptions = {
|
|
8
|
+
wsUrl: string;
|
|
9
|
+
docId: string;
|
|
10
|
+
mergeOp: (payload: Uint8Array) => void;
|
|
11
|
+
getToken: () => Promise<string>;
|
|
12
|
+
onStatus?: (status: SyncStatus) => void;
|
|
13
|
+
onReady?: () => void;
|
|
14
|
+
};
|
|
15
|
+
export type SyncClient = {
|
|
16
|
+
send: (payload: Uint8Array) => void;
|
|
17
|
+
destroy: () => void;
|
|
18
|
+
isReady: () => boolean;
|
|
19
|
+
};
|
package/types/sync.d.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether an HTML string round-trips through the browser parser unchanged.
|
|
3
|
+
* Parsed in an inert <template> (fragment semantics), so context-sensitive
|
|
4
|
+
* fragments (<td>, <tr>, <li>) survive where a <div> would drop them — a
|
|
5
|
+
* nested/unbalanced tag still comes back different and is caught. Raw innerHTML
|
|
6
|
+
* is used instead of the rope+scrub serialization because the CRDT render is
|
|
7
|
+
* already clean (scrubbed + escaped), so the two agree, and the template
|
|
8
|
+
* round-trip is much cheaper than rebuilding a rope. Exported for direct tests.
|
|
9
|
+
* @param {string} html
|
|
10
|
+
* @param {Document} [doc]
|
|
11
|
+
* @returns {boolean}
|
|
12
|
+
*/
|
|
13
|
+
export function roundTrips(html: string, doc?: Document): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* The vis-index span a reference node contributes to a scoped-sync range, robust
|
|
16
|
+
* to the DOM churning mid-batch (see localRegion):
|
|
17
|
+
* - tracked + attached → its stable pre-mutation rope range.
|
|
18
|
+
* - new + attached → walk the live DOM to tracked neighbours, or an edge.
|
|
19
|
+
* - detached → nothing (its removal is bracketed by surviving siblings;
|
|
20
|
+
* an added-then-removed node is a net no-op). Never maps min/max onto a
|
|
21
|
+
* detached node — that's the trailing-content-duplication failure.
|
|
22
|
+
* A boundary that runs off the doc edge is reported as `startEdge`/`endEdge` rather
|
|
23
|
+
* than index 0/len-1, which could resolve back to a just-detached node.
|
|
24
|
+
* @internal
|
|
25
|
+
* @param {NonNullable<ReturnType<typeof buildRope>>} rope
|
|
26
|
+
* @param {Node} node
|
|
27
|
+
* @returns {{ minIx?: number, maxIx?: number, startEdge?: boolean, endEdge?: boolean }}
|
|
28
|
+
*/
|
|
29
|
+
export function nodeSpan(rope: NonNullable<ReturnType<typeof buildRope>>, node: Node): {
|
|
30
|
+
minIx?: number;
|
|
31
|
+
maxIx?: number;
|
|
32
|
+
startEdge?: boolean;
|
|
33
|
+
endEdge?: boolean;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {object} RogueNodeOptions
|
|
37
|
+
* @property {HTMLElement} element The container the engine renders into and
|
|
38
|
+
* observes. It does NOT need `contenteditable` — making it editable is the
|
|
39
|
+
* host's responsibility (and is what triggers the local-edit sync path); a
|
|
40
|
+
* read-only renderer that only calls `mergeOp` works with any element. Pass a
|
|
41
|
+
* matching `doc`/`win` when the element lives in another realm (TinyMCE's iframe).
|
|
42
|
+
* @property {number} [replicaId] This replica's id in the CRDT (defaults to a
|
|
43
|
+
* random u16). Override it to keep a stable identity across reloads — per-author
|
|
44
|
+
* undo, presence, and cursor attribution key off it — or to coordinate ids
|
|
45
|
+
* server-side so concurrent replicas never collide (the u16 space is ~65k).
|
|
46
|
+
* @property {(payload: Uint8Array) => void} [onCommit]
|
|
47
|
+
* @property {false | { maxBytes?: number, onRejected?: (bytes: number, limit: number) => void }} [paste]
|
|
48
|
+
* Paste handling. `false` skips the engine's own paste listener — hosts like
|
|
49
|
+
* TinyMCE have their own paste-clean pipeline, so running both would double-insert.
|
|
50
|
+
* An object enables it, optionally capping size (`maxBytes`) and reporting
|
|
51
|
+
* rejections (`onRejected`). Omit for the engine to handle paste with no cap.
|
|
52
|
+
* @property {Document} [doc] Host abstraction — defaults work for a standard
|
|
53
|
+
* contenteditable in the main window. The TinyMCE binding overrides doc/win to
|
|
54
|
+
* operate inside the editor's iframe.
|
|
55
|
+
* @property {Window} [win]
|
|
56
|
+
* @property {import('./dom_rope.js').SerializeNode} [serializeNode] Host hook to
|
|
57
|
+
* serialize a (cloned) element to clean outerHTML — defaults to native
|
|
58
|
+
* `.outerHTML`. The TinyMCE host overrides it to strip `data-mce-*` so its
|
|
59
|
+
* internal attributes never enter the CRDT.
|
|
60
|
+
* @property {import('./dom_rope.js').OmitNode} [omitNode] Host predicate for DOM
|
|
61
|
+
* nodes to omit entirely — editing chrome that isn't content (e.g. TinyMCE's
|
|
62
|
+
* resize handles / bogus nodes). Applied in both the rope build and the diff's
|
|
63
|
+
* DOM serializer so the CRDT never sees them.
|
|
64
|
+
* @property {import('./dom_rope.js').UnwrapNode} [unwrapNode] Host
|
|
65
|
+
* predicate for wrappers to traverse transparently — their children are kept,
|
|
66
|
+
* the wrapper's own open/close tags are dropped (e.g. TinyMCE's format-caret
|
|
67
|
+
* bookmarks, which wrap real content).
|
|
68
|
+
*/
|
|
69
|
+
/**
|
|
70
|
+
* @typedef {{
|
|
71
|
+
* mergeOp: (payload: Uint8Array) => void,
|
|
72
|
+
* undo: () => void,
|
|
73
|
+
* redo: () => void,
|
|
74
|
+
* beginLoad: () => void,
|
|
75
|
+
* endLoad: () => void,
|
|
76
|
+
* drainParked: () => void,
|
|
77
|
+
* setSyncDebug: (on: boolean) => void,
|
|
78
|
+
* markUserEdit: () => void,
|
|
79
|
+
* destroy: () => void,
|
|
80
|
+
* }} RogueNode
|
|
81
|
+
*/
|
|
82
|
+
/**
|
|
83
|
+
* @param {RogueNodeOptions} options
|
|
84
|
+
* @returns {Promise<RogueNode>}
|
|
85
|
+
*/
|
|
86
|
+
export function createRogueNode({ element: editorRoot, replicaId, onCommit, paste, doc, win, serializeNode, omitNode, unwrapNode, }: RogueNodeOptions): Promise<RogueNode>;
|
|
87
|
+
export type RogueNodeOptions = {
|
|
88
|
+
/**
|
|
89
|
+
* The container the engine renders into and
|
|
90
|
+
* observes. It does NOT need `contenteditable` — making it editable is the
|
|
91
|
+
* host's responsibility (and is what triggers the local-edit sync path); a
|
|
92
|
+
* read-only renderer that only calls `mergeOp` works with any element. Pass a
|
|
93
|
+
* matching `doc`/`win` when the element lives in another realm (TinyMCE's iframe).
|
|
94
|
+
*/
|
|
95
|
+
element: HTMLElement;
|
|
96
|
+
/**
|
|
97
|
+
* This replica's id in the CRDT (defaults to a
|
|
98
|
+
* random u16). Override it to keep a stable identity across reloads — per-author
|
|
99
|
+
* undo, presence, and cursor attribution key off it — or to coordinate ids
|
|
100
|
+
* server-side so concurrent replicas never collide (the u16 space is ~65k).
|
|
101
|
+
*/
|
|
102
|
+
replicaId?: number | undefined;
|
|
103
|
+
onCommit?: ((payload: Uint8Array) => void) | undefined;
|
|
104
|
+
/**
|
|
105
|
+
* Paste handling. `false` skips the engine's own paste listener — hosts like
|
|
106
|
+
* TinyMCE have their own paste-clean pipeline, so running both would double-insert.
|
|
107
|
+
* An object enables it, optionally capping size (`maxBytes`) and reporting
|
|
108
|
+
* rejections (`onRejected`). Omit for the engine to handle paste with no cap.
|
|
109
|
+
*/
|
|
110
|
+
paste?: false | {
|
|
111
|
+
maxBytes?: number;
|
|
112
|
+
onRejected?: (bytes: number, limit: number) => void;
|
|
113
|
+
} | undefined;
|
|
114
|
+
/**
|
|
115
|
+
* Host abstraction — defaults work for a standard
|
|
116
|
+
* contenteditable in the main window. The TinyMCE binding overrides doc/win to
|
|
117
|
+
* operate inside the editor's iframe.
|
|
118
|
+
*/
|
|
119
|
+
doc?: Document | undefined;
|
|
120
|
+
win?: Window | undefined;
|
|
121
|
+
/**
|
|
122
|
+
* Host hook to
|
|
123
|
+
* serialize a (cloned) element to clean outerHTML — defaults to native
|
|
124
|
+
* `.outerHTML`. The TinyMCE host overrides it to strip `data-mce-*` so its
|
|
125
|
+
* internal attributes never enter the CRDT.
|
|
126
|
+
*/
|
|
127
|
+
serializeNode?: import("./dom_rope.js").SerializeNode | undefined;
|
|
128
|
+
/**
|
|
129
|
+
* Host predicate for DOM
|
|
130
|
+
* nodes to omit entirely — editing chrome that isn't content (e.g. TinyMCE's
|
|
131
|
+
* resize handles / bogus nodes). Applied in both the rope build and the diff's
|
|
132
|
+
* DOM serializer so the CRDT never sees them.
|
|
133
|
+
*/
|
|
134
|
+
omitNode?: import("./dom_rope.js").OmitNode | undefined;
|
|
135
|
+
/**
|
|
136
|
+
* Host
|
|
137
|
+
* predicate for wrappers to traverse transparently — their children are kept,
|
|
138
|
+
* the wrapper's own open/close tags are dropped (e.g. TinyMCE's format-caret
|
|
139
|
+
* bookmarks, which wrap real content).
|
|
140
|
+
*/
|
|
141
|
+
unwrapNode?: import("./dom_rope.js").UnwrapNode | undefined;
|
|
142
|
+
};
|
|
143
|
+
export type RogueNode = {
|
|
144
|
+
mergeOp: (payload: Uint8Array) => void;
|
|
145
|
+
undo: () => void;
|
|
146
|
+
redo: () => void;
|
|
147
|
+
beginLoad: () => void;
|
|
148
|
+
endLoad: () => void;
|
|
149
|
+
drainParked: () => void;
|
|
150
|
+
setSyncDebug: (on: boolean) => void;
|
|
151
|
+
markUserEdit: () => void;
|
|
152
|
+
destroy: () => void;
|
|
153
|
+
};
|
|
154
|
+
import { buildRope } from "./dom_rope.js";
|