diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js
index f945668..28b4898 100644
--- a/crates/obscura-js/js/bootstrap.js
+++ b/crates/obscura-js/js/bootstrap.js
@@ -1654,12 +1654,18 @@ class Document extends Node {
// jQuery 3.x with it. Reuse the DOMParser path to build a detached
// document, then optionally set the title.
createHTMLDocument(title) {
- const skeleton = `
`;
- const doc = new DOMParser().parseFromString(skeleton, "text/html");
- if (title != null) {
- const t = doc.querySelector("title");
- if (t) t.textContent = String(title);
- }
+ // Build head>title and body explicitly. Parsing a full skeleton string
+ // as innerHTML of collapses through the fragment parser (it
+ // dropped head/body and kept only ), leaving doc.body null.
+ const doc = new DOMParser().parseFromString("", "text/html");
+ const root = doc.documentElement;
+ const head = document.createElement("head");
+ const titleEl = document.createElement("title");
+ if (title != null) titleEl.textContent = String(title);
+ head.appendChild(titleEl);
+ const body = document.createElement("body");
+ root.appendChild(head);
+ root.appendChild(body);
return doc;
},
// Real spec: createDocument(namespaceURI, qualifiedName, doctype) →
@@ -1669,8 +1675,10 @@ class Document extends Node {
createDocument(_ns, qualifiedName, _doctype) {
const name = (qualifiedName && String(qualifiedName)) || "root";
const safe = name.replace(/[^a-zA-Z0-9-]/g, "");
- const html = `<${safe}>${safe}>`;
- return new DOMParser().parseFromString(html, "application/xml");
+ const html = qualifiedName ? `<${safe}>${safe}>` : "";
+ const doc = new DOMParser().parseFromString(html, "application/xml");
+ if (_doctype) doc._docType = _doctype;
+ return doc;
},
// createDocumentType(qualifiedName, publicId, systemId): build a detached
// DocumentType node. Browsers validate leniently here (only a name with
@@ -2480,6 +2488,10 @@ if (typeof Request === 'undefined') {
async text() { return this.body ? String(this.body) : ''; }
async json() { return JSON.parse(await this.text()); }
async arrayBuffer() { return new TextEncoder().encode(await this.text()).buffer; }
+ async blob() {
+ const ct = this.headers && this.headers.get ? (this.headers.get('content-type') || '') : '';
+ return new Blob(this.body != null ? [this.body] : [], { type: ct });
+ }
};
}
@@ -3177,6 +3189,15 @@ globalThis.PopStateEvent = class extends Event {
};
globalThis.HashChangeEvent = class extends Event {};
globalThis.MessageEvent = class extends Event { constructor(t,o={}) { super(t,o);this.data=o.data; } };
+globalThis.ProgressEvent = class ProgressEvent extends Event {
+ constructor(type, init) {
+ super(type, init || {});
+ const i = init || {};
+ this.lengthComputable = !!i.lengthComputable;
+ this.loaded = i.loaded != null ? Number(i.loaded) : 0;
+ this.total = i.total != null ? Number(i.total) : 0;
+ }
+};
globalThis.ClipboardEvent = class extends Event {};
globalThis.SubmitEvent = class extends Event {};
@@ -3195,8 +3216,61 @@ _markNative(globalThis.ToggleEvent);
globalThis.AbortController = class AbortController { constructor(){this.signal={aborted:false,addEventListener(){},removeEventListener(){},onabort:null};} abort(){this.signal.aborted=true;} };
globalThis.AbortSignal = { timeout(ms){return {aborted:false,addEventListener(){},removeEventListener(){}}; } };
-if (typeof Blob === "undefined") globalThis.Blob = class Blob { constructor(parts=[],opts={}){this._data=parts.join("");this.size=this._data.length;this.type=opts.type||"";} async text(){return this._data;} };
-if (typeof File === "undefined") globalThis.File = class extends Blob { constructor(parts,name,opts){super(parts,opts);this.name=name;} };
+// Normalize one Blob part to bytes. `native` newline normalization applies to
+// string parts when the Blob/File `endings` option is "native".
+function _blobPartToBytes(p, native) {
+ if (p == null) return new Uint8Array(0);
+ if (typeof Blob === "function" && p instanceof Blob) return p._bytes || new Uint8Array(0);
+ if (p instanceof ArrayBuffer) return new Uint8Array(p.slice(0));
+ if (ArrayBuffer.isView(p)) return new Uint8Array(p.buffer.slice(p.byteOffset, p.byteOffset + p.byteLength));
+ let s = String(p);
+ if (native) s = s.replace(/\r\n|\r|\n/g, "\n");
+ return new TextEncoder().encode(s);
+}
+function _bytesToBinaryString(bytes) { let s = ""; for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); return s; }
+if (typeof Blob === "undefined") globalThis.Blob = class Blob {
+ constructor(parts, opts) {
+ opts = opts || {};
+ const endings = opts.endings != null ? String(opts.endings) : "transparent";
+ if (endings !== "transparent" && endings !== "native") throw new TypeError("Failed to construct 'Blob': The provided value '" + endings + "' is not a valid enum value of type EndingType.");
+ const native = endings === "native";
+ const chunks = []; let total = 0;
+ if (parts != null) {
+ if (typeof parts === "string" || typeof parts[Symbol.iterator] !== "function") throw new TypeError("Failed to construct 'Blob': The provided value cannot be converted to a sequence.");
+ for (const p of parts) { const b = _blobPartToBytes(p, native); chunks.push(b); total += b.length; }
+ }
+ const data = new Uint8Array(total); let off = 0;
+ for (const c of chunks) { data.set(c, off); off += c.length; }
+ this._bytes = data;
+ this.size = total;
+ const t = opts.type != null ? String(opts.type) : "";
+ this.type = /^[\x20-\x7e]*$/.test(t) ? t.toLowerCase() : "";
+ }
+ get [Symbol.toStringTag]() { return "Blob"; }
+ slice(start, end, contentType) {
+ const len = this.size;
+ const s = start === undefined ? 0 : (start < 0 ? Math.max(len + start, 0) : Math.min(start, len));
+ let e = end === undefined ? len : (end < 0 ? Math.max(len + end, 0) : Math.min(end, len));
+ if (e < s) e = s;
+ const out = new Blob([], contentType != null ? { type: contentType } : {});
+ out._bytes = this._bytes.slice(s, e);
+ out.size = out._bytes.length;
+ return out;
+ }
+ text() { return Promise.resolve(new TextDecoder().decode(this._bytes)); }
+ arrayBuffer() { return Promise.resolve(_arrayBufferFromBytes(this._bytes)); }
+ bytes() { return Promise.resolve(this._bytes.slice()); }
+};
+if (typeof File === "undefined") globalThis.File = class File extends Blob {
+ constructor(parts, name, opts) {
+ if (arguments.length < 2) throw new TypeError("Failed to construct 'File': 2 arguments required, but only " + arguments.length + " present.");
+ opts = opts || {};
+ super(parts, opts);
+ this.name = String(name);
+ this.lastModified = opts.lastModified != null ? Number(opts.lastModified) : Date.now();
+ }
+ get [Symbol.toStringTag]() { return "File"; }
+};
if (typeof FormData === "undefined") globalThis.FormData = class FormData { constructor(){this._d=[];} append(k,v){this._d.push([k,v]);} get(k){const e=this._d.find(([a])=>a===k);return e?e[1]:null;} getAll(k){return this._d.filter(([a])=>a===k).map(([,v])=>v);} has(k){return this._d.some(([a])=>a===k);} entries(){return this._d[Symbol.iterator]();} forEach(cb){this._d.forEach(([k,v])=>cb(v,k));} };
// application/x-www-form-urlencoded serializer: like encodeURIComponent but
// space -> '+' and also percent-encoding the chars encodeURIComponent leaves
@@ -3331,6 +3405,14 @@ globalThis.DOMParser = class DOMParser {
},
adoptNode: (n) => n,
importNode: (n) => n,
+ // Document-level node insertion. Detached docs from createHTMLDocument /
+ // createDocument back onto the same tree, so appending lands under the
+ // documentElement; enough for dom/common.js to build its Range fixtures.
+ appendChild: function (n) { try { root.appendChild(n); } catch (e) {} return n; },
+ removeChild: function (n) { try { root.removeChild(n); } catch (e) {} return n; },
+ insertBefore: function (n, ref) { try { root.insertBefore(n, ref); } catch (e) {} return n; },
+ _docType: null,
+ get doctype() { return this._docType; },
cloneNode: function (deep) {
return new DOMParser().parseFromString(root.outerHTML, mimeType);
},
@@ -3679,26 +3761,22 @@ function _rngNodeLength(n) {
if (t === 3 || t === 4 || t === 8 || t === 7) return (n.data || n.nodeValue || "").length;
return n.childNodes.length;
}
+// Index among siblings, computed in Rust (one op) instead of serializing the
+// whole childNodes list per call: the Range matrices call this heavily.
function _rngNodeIndex(n) {
- const p = n.parentNode;
- if (!p) return 0;
- const kids = p.childNodes;
- for (let i = 0; i < kids.length; i++) if (kids[i] && kids[i]._nid === n._nid) return i;
- return 0;
+ if (!n.parentNode) return 0;
+ return +_dom("node_index", n._nid);
}
function _rngSame(a, b) { return a === b || (!!a && !!b && a._nid === b._nid); }
-function _rngRoot(n) { let r = n; while (r && r.parentNode) r = r.parentNode; return r; }
+// Root nid in one op (callers only read ._nid), instead of an O(depth) walk.
+function _rngRoot(n) { return { _nid: +_dom("node_root", n._nid) }; }
function _rngAncestors(n) { const a = []; let c = n; while (c) { a.push(c); c = c.parentNode; } return a; }
// document (preorder) tree order: -1 if a precedes b, 1 if a follows b, 0 same.
+// Computed in Rust (one op) rather than walking ancestor chains over per-step
+// DOM ops, which made the large dom/ranges matrices time out.
function _rngOrder(a, b) {
if (_rngSame(a, b)) return 0;
- const aa = _rngAncestors(a).reverse(), bb = _rngAncestors(b).reverse();
- if (aa[0]._nid !== bb[0]._nid) return (a._nid | 0) < (b._nid | 0) ? -1 : 1;
- let i = 0;
- while (i < aa.length && i < bb.length && aa[i]._nid === bb[i]._nid) i++;
- if (i >= aa.length) return -1; // a is an ancestor of b -> a precedes
- if (i >= bb.length) return 1; // b is an ancestor of a -> a follows
- return _rngNodeIndex(aa[i]) < _rngNodeIndex(bb[i]) ? -1 : 1;
+ return +_dom("compare_order", a._nid, b._nid) || 0;
}
// Position of (nA,oA) relative to (nB,oB): -1 before, 0 equal, 1 after.
function _rngCmp(nA, oA, nB, oB) {
@@ -4424,6 +4502,13 @@ Element.prototype.setHTMLUnsafe = function setHTMLUnsafe(html) { this.innerHTML
Element.prototype.getHTML = function getHTML() { return this.innerHTML; };
_markNative(Element.prototype.setHTMLUnsafe);
_markNative(Element.prototype.getHTML);
+// Document.parseHTMLUnsafe(html): static that parses into a new HTML document.
+if (typeof Document !== 'undefined' && typeof Document.parseHTMLUnsafe !== 'function') {
+ Document.parseHTMLUnsafe = function parseHTMLUnsafe(html) {
+ return new DOMParser().parseFromString(String(html == null ? "" : html), "text/html");
+ };
+ _markNative(Document.parseHTMLUnsafe);
+}
globalThis.AudioContext = class AudioContext {
constructor() { this.sampleRate=_fp('audioSampleRate'); this.state='running'; this.currentTime=0; this.baseLatency=_fp('audioBaseLatency'); this.destination={maxChannelCount:2,numberOfInputs:1,numberOfOutputs:0,channelCount:2}; }
@@ -4876,14 +4961,53 @@ if (typeof Audio === 'undefined') {
if (typeof FileReader === 'undefined') {
globalThis.FileReader = class FileReader {
- constructor() { this.result = null; this.readyState = 0; this.onload = null; this.onerror = null; }
- readAsText(blob) { if (blob?.text) blob.text().then(t => { this.result = t; this.readyState = 2; if (this.onload) this.onload({target:this}); }); }
- readAsDataURL(blob) { this.result = 'data:;base64,'; this.readyState = 2; if (this.onload) setTimeout(() => this.onload({target:this}), 0); }
- readAsArrayBuffer(blob) { this.result = new ArrayBuffer(0); this.readyState = 2; if (this.onload) setTimeout(() => this.onload({target:this}), 0); }
- abort() { this.readyState = 0; }
- addEventListener(t, fn) { if (t === 'load') this.onload = fn; }
- removeEventListener() {}
+ constructor() {
+ this.result = null; this.error = null; this.readyState = 0; // EMPTY
+ this.onloadstart = null; this.onprogress = null; this.onload = null;
+ this.onabort = null; this.onerror = null; this.onloadend = null;
+ this._listeners = {};
+ }
+ get [Symbol.toStringTag]() { return "FileReader"; }
+ _read(blob, kind, encoding) {
+ // Spec: reading while LOADING throws InvalidStateError.
+ if (this.readyState === 1) throw new DOMException("The object is already busy reading Blobs.", "InvalidStateError");
+ this.readyState = 1; // LOADING
+ this.result = null; this.error = null;
+ this._fire("loadstart");
+ const self = this;
+ Promise.resolve().then(function () {
+ if (self.readyState !== 1) return; // aborted before completion
+ const bytes = (blob && blob._bytes) ? blob._bytes : new Uint8Array(0);
+ try {
+ if (kind === "text") self.result = new TextDecoder(encoding || "utf-8").decode(bytes);
+ else if (kind === "binary") self.result = _bytesToBinaryString(bytes);
+ else if (kind === "dataurl") self.result = "data:" + ((blob && blob.type) || "application/octet-stream") + ";base64," + btoa(_bytesToBinaryString(bytes));
+ else self.result = _arrayBufferFromBytes(bytes);
+ } catch (e) { self.error = e; }
+ self.readyState = 2; // DONE
+ self._fire("progress"); self._fire("load"); self._fire("loadend");
+ });
+ }
+ readAsText(blob, encoding) { this._read(blob, "text", encoding); }
+ readAsDataURL(blob) { this._read(blob, "dataurl"); }
+ readAsArrayBuffer(blob) { this._read(blob, "arraybuffer"); }
+ readAsBinaryString(blob) { this._read(blob, "binary"); }
+ abort() {
+ const wasReading = this.readyState === 1;
+ this.readyState = 0; this.result = null;
+ if (wasReading) { this._fire("abort"); this._fire("loadend"); }
+ }
+ _fire(type) {
+ const ev = { type: type, target: this, currentTarget: this, lengthComputable: false, loaded: 0, total: 0 };
+ const h = this["on" + type]; if (typeof h === "function") { try { h.call(this, ev); } catch (e) {} }
+ const ls = this._listeners[type]; if (ls) for (const fn of ls.slice()) { try { fn.call(this, ev); } catch (e) {} }
+ }
+ addEventListener(t, fn) { if (typeof fn === "function") (this._listeners[t] = this._listeners[t] || []).push(fn); }
+ removeEventListener(t, fn) { const ls = this._listeners[t]; if (ls) { const i = ls.indexOf(fn); if (i >= 0) ls.splice(i, 1); } }
+ dispatchEvent() { return true; }
};
+ globalThis.FileReader.EMPTY = 0; globalThis.FileReader.LOADING = 1; globalThis.FileReader.DONE = 2;
+ Object.assign(globalThis.FileReader.prototype, { EMPTY: 0, LOADING: 1, DONE: 2 });
}
// Real network sockets aren't implemented; we don't have a runtime WS / SSE
diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs
index 9c13728..efa7546 100644
--- a/crates/obscura-js/src/ops.rs
+++ b/crates/obscura-js/src/ops.rs
@@ -337,10 +337,84 @@ fn op_dom(state: &OpState, #[string] cmd: String, #[string] arg1: String, #[stri
let other = arg2.parse::().unwrap_or(0);
dom.descendants(NodeId::new(nid)).contains(&NodeId::new(other)).to_string()
}
+ // Index of a node among its parent's children. Walks prev siblings in
+ // Rust, avoiding the per-step JS->op round trips a Range comparison
+ // would otherwise make.
+ "node_index" => {
+ let nid = arg1.parse::().unwrap_or(0);
+ node_child_index(dom, NodeId::new(nid)).to_string()
+ }
+ // Document (preorder) tree order of two nodes: -1 if a precedes b, 1 if
+ // a follows b, 0 if equal. Used by the Range boundary-point algorithms.
+ "compare_order" => {
+ let a = NodeId::new(arg1.parse::().unwrap_or(0));
+ let b = NodeId::new(arg2.parse::().unwrap_or(0));
+ compare_node_order(dom, a, b).to_string()
+ }
+ // Root (topmost ancestor) of a node, in one op rather than an O(depth)
+ // walk of parentNode ops from JS.
+ "node_root" => {
+ let mut cur = NodeId::new(arg1.parse::().unwrap_or(0));
+ while let Some(p) = dom.get_node(cur).and_then(|x| x.parent) {
+ cur = p;
+ }
+ cur.index().to_string()
+ }
_ => "null".into(),
}
}
+/// Index of `n` among its parent's children (0-based).
+fn node_child_index(dom: &DomTree, n: NodeId) -> usize {
+ let mut i = 0usize;
+ let mut cur = dom.get_node(n).and_then(|x| x.prev_sibling);
+ while let Some(p) = cur {
+ i += 1;
+ cur = dom.get_node(p).and_then(|x| x.prev_sibling);
+ }
+ i
+}
+
+/// Ancestor chain of `n` from the root down to `n` (root first).
+fn node_ancestors_root_first(dom: &DomTree, n: NodeId) -> Vec {
+ let mut v = vec![n];
+ let mut cur = n;
+ while let Some(p) = dom.get_node(cur).and_then(|x| x.parent) {
+ v.push(p);
+ cur = p;
+ }
+ v.reverse();
+ v
+}
+
+/// Preorder (document) order comparison of two nodes: -1 before, 1 after, 0 same.
+fn compare_node_order(dom: &DomTree, a: NodeId, b: NodeId) -> i32 {
+ if a == b {
+ return 0;
+ }
+ let aa = node_ancestors_root_first(dom, a);
+ let bb = node_ancestors_root_first(dom, b);
+ // Different roots: order is undefined per spec; keep it stable by node id.
+ if aa[0] != bb[0] {
+ return if a.index() < b.index() { -1 } else { 1 };
+ }
+ let mut i = 0usize;
+ while i < aa.len() && i < bb.len() && aa[i] == bb[i] {
+ i += 1;
+ }
+ if i >= aa.len() {
+ return -1; // a is an ancestor of b -> a precedes
+ }
+ if i >= bb.len() {
+ return 1; // b is an ancestor of a -> a follows
+ }
+ if node_child_index(dom, aa[i]) < node_child_index(dom, bb[i]) {
+ -1
+ } else {
+ 1
+ }
+}
+
#[op2(fast)]
fn op_console_msg(state: &OpState, #[string] level: &str, #[string] msg: &str) {
let _ = state;