Improve modern layout and viewport rendering
This commit is contained in:
1 parent
aff0172987
commit
10b8492116
22 files changed
+5978
-450
No files matched your search
Vendored
+657
-120
@@ -657,8 +657,66 @@ globalThis.setInterval = (fn, delay = 0, ...args) => {
|
||||
};
|
||||
|
||||
globalThis.clearInterval = (id) => { _intervals.delete(id); _clearedTimers.add(id); };
|
||||
globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0);
|
||||
globalThis.cancelAnimationFrame = globalThis.clearTimeout;
|
||||
|
||||
// Animation callbacks are a rendering-phase batch, not zero-delay
|
||||
// microtasks. In particular, a callback which queues itself must yield to
|
||||
// timers, networking, and the embedder between frames. The old setTimeout(0)
|
||||
// alias eventually used Promise.resolve(), so a normal animation loop formed
|
||||
// an unbounded microtask chain and pinned V8 until the watchdog terminated it.
|
||||
const _RAF_FRAME_DELAY_MS = 16;
|
||||
let _rafPending = new Map();
|
||||
let _rafCurrentBatch = null;
|
||||
let _rafFrameScheduled = false;
|
||||
let _rafRunningFrame = false;
|
||||
|
||||
function _scheduleAnimationFrame() {
|
||||
if (_rafFrameScheduled || _rafRunningFrame || _rafPending.size === 0) return;
|
||||
_rafFrameScheduled = true;
|
||||
_scheduleAfter(_RAF_FRAME_DELAY_MS, () => {
|
||||
_rafFrameScheduled = false;
|
||||
if (_rafPending.size === 0) return;
|
||||
|
||||
// Swap before invoking anything. A callback requested while this batch is
|
||||
// running therefore belongs to the next frame. Every callback in this
|
||||
// batch receives the same rendering timestamp.
|
||||
const batch = _rafPending;
|
||||
_rafPending = new Map();
|
||||
_rafCurrentBatch = batch;
|
||||
_rafRunningFrame = true;
|
||||
const timestamp = performance.now();
|
||||
try {
|
||||
for (const [id, callback] of batch) {
|
||||
// cancelAnimationFrame() may remove a later callback while an earlier
|
||||
// callback in the same frame is running.
|
||||
if (!batch.has(id)) continue;
|
||||
batch.delete(id);
|
||||
try { callback(timestamp); }
|
||||
catch (e) { console.error("Animation frame error:", e); }
|
||||
}
|
||||
} finally {
|
||||
_rafRunningFrame = false;
|
||||
_rafCurrentBatch = null;
|
||||
_scheduleAnimationFrame();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.requestAnimationFrame = (fn) => {
|
||||
if (typeof fn !== "function") {
|
||||
throw new TypeError(
|
||||
"Failed to execute 'requestAnimationFrame' on 'Window': parameter 1 is not of type 'Function'."
|
||||
);
|
||||
}
|
||||
const id = ++_tid;
|
||||
_rafPending.set(id, fn);
|
||||
_scheduleAnimationFrame();
|
||||
return id;
|
||||
};
|
||||
|
||||
globalThis.cancelAnimationFrame = (id) => {
|
||||
_rafPending.delete(id);
|
||||
if (_rafCurrentBatch) _rafCurrentBatch.delete(id);
|
||||
};
|
||||
globalThis.queueMicrotask = globalThis.queueMicrotask || ((fn) => Promise.resolve().then(fn));
|
||||
|
||||
class MessageChannel {
|
||||
@@ -2687,49 +2745,103 @@ class Element extends Node {
|
||||
// returning 100x20 there made every element appear off-screen and broke .click().
|
||||
get clientWidth() { return this._isViewportRoot() ? (globalThis.innerWidth || 1280) : 100; }
|
||||
get clientHeight() { return this._isViewportRoot() ? (globalThis.innerHeight || 720) : 20; }
|
||||
get scrollWidth() { return this._isViewportRoot() ? (globalThis.innerWidth || 1280) : 100; }
|
||||
get scrollHeight() { return this._isViewportRoot() ? (globalThis.innerHeight || 720) : 20; }
|
||||
get scrollWidth() {
|
||||
if (!this._isViewportRoot()) return 100;
|
||||
const metrics = this._renderScrollMetrics();
|
||||
return metrics ? metrics.scrollWidth : (globalThis.innerWidth || 1280);
|
||||
}
|
||||
get scrollHeight() {
|
||||
if (!this._isViewportRoot()) return 20;
|
||||
const metrics = this._renderScrollMetrics();
|
||||
return metrics ? metrics.scrollHeight : (globalThis.innerHeight || 720);
|
||||
}
|
||||
_isViewportRoot() {
|
||||
const t = this.tagName;
|
||||
return t === 'HTML' || t === 'BODY';
|
||||
}
|
||||
// No layout engine, so there is no real overflow to scroll and the offset is
|
||||
// deliberately NOT clamped: without real geometry any synthetic max is a
|
||||
// guess, and a max derived from a stub scroll box pins scrollTop at 0, which
|
||||
// deadlocks scroll-driven lazy loaders (no scroll -> no content -> no scroll).
|
||||
// We track the offset so scrollTop/scrollLeft round-trip, and fire a scroll
|
||||
// event on direct assignment -- lazy loaders that set `el.scrollTop = N` rely
|
||||
// on that event, and scrollTo/scrollBy below would otherwise be its only source.
|
||||
get scrollTop() { return this._scrollTop || 0; }
|
||||
_renderScrollMetrics() {
|
||||
if (typeof Deno.core.ops.op_layout_metrics !== 'function') return null;
|
||||
try {
|
||||
const raw = Deno.core.ops.op_layout_metrics();
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
_renderScrollOffset() {
|
||||
if (typeof Deno.core.ops.op_scroll_offset !== 'function') return null;
|
||||
try {
|
||||
const raw = Deno.core.ops.op_scroll_offset();
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
_setRenderScroll(x, y) {
|
||||
if (typeof Deno.core.ops.op_scroll_to !== 'function') return null;
|
||||
try {
|
||||
const raw = Deno.core.ops.op_scroll_to(+x || 0, +y || 0);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Render builds clamp the viewport root against measured document overflow.
|
||||
// Nested element scrollers still use synthetic, deliberately unclamped state:
|
||||
// their contents do not yet participate in scroll-container layout, and a
|
||||
// guessed maximum would pin lazy-loading scrollers at zero.
|
||||
get scrollTop() {
|
||||
if (this._isViewportRoot()) {
|
||||
const offset = this._renderScrollOffset();
|
||||
if (offset) return offset.y || 0;
|
||||
}
|
||||
return this._scrollTop || 0;
|
||||
}
|
||||
set scrollTop(v) {
|
||||
v = +v;
|
||||
const nv = Number.isFinite(v) && v > 0 ? v : 0;
|
||||
const changed = nv !== (this._scrollTop || 0);
|
||||
this._scrollTop = nv;
|
||||
const old = this.scrollTop;
|
||||
let actual = nv;
|
||||
if (this._isViewportRoot()) {
|
||||
const offset = this._renderScrollOffset();
|
||||
const updated = offset && this._setRenderScroll(offset.x, nv);
|
||||
if (updated) actual = updated.y || 0;
|
||||
}
|
||||
const changed = actual !== old;
|
||||
this._scrollTop = actual;
|
||||
if (changed && !this._scrollSuppress) this._fireScroll();
|
||||
if (changed && this._isViewportRoot() &&
|
||||
typeof globalThis.__obscura_recompute_intersections === "function") {
|
||||
globalThis.__obscura_recompute_intersections();
|
||||
}
|
||||
}
|
||||
get scrollLeft() {
|
||||
if (this._isViewportRoot()) {
|
||||
const offset = this._renderScrollOffset();
|
||||
if (offset) return offset.x || 0;
|
||||
}
|
||||
return this._scrollLeft || 0;
|
||||
}
|
||||
get scrollLeft() { return this._scrollLeft || 0; }
|
||||
set scrollLeft(v) {
|
||||
v = +v;
|
||||
const nv = Number.isFinite(v) && v > 0 ? v : 0;
|
||||
const changed = nv !== (this._scrollLeft || 0);
|
||||
this._scrollLeft = nv;
|
||||
const old = this.scrollLeft;
|
||||
let actual = nv;
|
||||
if (this._isViewportRoot()) {
|
||||
const offset = this._renderScrollOffset();
|
||||
const updated = offset && this._setRenderScroll(nv, offset.y);
|
||||
if (updated) actual = updated.x || 0;
|
||||
}
|
||||
const changed = actual !== old;
|
||||
this._scrollLeft = actual;
|
||||
if (changed && !this._scrollSuppress) this._fireScroll();
|
||||
if (changed && this._isViewportRoot() &&
|
||||
typeof globalThis.__obscura_recompute_intersections === "function") {
|
||||
globalThis.__obscura_recompute_intersections();
|
||||
}
|
||||
}
|
||||
getBoundingClientRect() {
|
||||
globalThis.__obscura_click_target = this;
|
||||
// documentElement and body span the full viewport. Without this every
|
||||
// hit test against them clips down to a 100x20 synthetic cell and
|
||||
// Document.elementFromPoint can never recurse into their children.
|
||||
if (this._isViewportRoot()) {
|
||||
const vw = globalThis.innerWidth || 1280;
|
||||
const vh = globalThis.innerHeight || 720;
|
||||
return {
|
||||
x: 0, y: 0, width: vw, height: vh,
|
||||
top: 0, right: vw, bottom: vh, left: 0,
|
||||
toJSON() { return this; },
|
||||
};
|
||||
}
|
||||
// Real layout when the render feature is compiled in: ask the Rust layout
|
||||
// cache for this element's border box. The op is absent in the default
|
||||
// build, so probe with typeof and fall through to the synthetic rect below.
|
||||
@@ -2740,15 +2852,32 @@ class Element extends Node {
|
||||
var _r = JSON.parse(_geom);
|
||||
if (_r && typeof _r.width === 'number') {
|
||||
var _x = _r.x, _y = _r.y, _w = _r.width, _h = _r.height;
|
||||
return {
|
||||
var _rect = {
|
||||
x: _x, y: _y, width: _w, height: _h,
|
||||
top: _y, right: _x + _w, bottom: _y + _h, left: _x,
|
||||
toJSON() { return this; },
|
||||
};
|
||||
Object.defineProperty(_rect, "__obscuraViewportFixed", {
|
||||
value: !!_r.viewportFixed,
|
||||
enumerable: false,
|
||||
});
|
||||
return _rect;
|
||||
}
|
||||
}
|
||||
} catch (_e) {}
|
||||
}
|
||||
// Default (non-render) builds keep viewport-sized roots. Without this
|
||||
// synthetic fallback every hit test against them clips down to a 100x20
|
||||
// cell and Document.elementFromPoint cannot recurse into their children.
|
||||
if (this._isViewportRoot()) {
|
||||
const vw = globalThis.innerWidth || 1280;
|
||||
const vh = globalThis.innerHeight || 720;
|
||||
return {
|
||||
x: 0, y: 0, width: vw, height: vh,
|
||||
top: 0, right: vw, bottom: vh, left: 0,
|
||||
toJSON() { return this; },
|
||||
};
|
||||
}
|
||||
// No layout engine (default build) or this node has no box: synthesize a
|
||||
// deterministic position from the node id so Playwright's actionability
|
||||
// polling still gets a stable, distinct rect for hit-testing (issue #45).
|
||||
@@ -2790,7 +2919,40 @@ class Element extends Node {
|
||||
set ariaHidden(v) { if (v == null) this.removeAttribute('aria-hidden'); else this.setAttribute('aria-hidden', String(v)); }
|
||||
get ariaSelected() { return this.getAttribute('aria-selected'); }
|
||||
set ariaSelected(v) { if (v == null) this.removeAttribute('aria-selected'); else this.setAttribute('aria-selected', String(v)); }
|
||||
scrollIntoView() { globalThis.__obscura_click_target = this; }
|
||||
scrollIntoView(arg) {
|
||||
globalThis.__obscura_click_target = this;
|
||||
const rect = this.getBoundingClientRect();
|
||||
// A viewport-fixed subtree is already expressed in the viewport's
|
||||
// coordinate space and cannot be brought closer by moving the document.
|
||||
if (rect.__obscuraViewportFixed) return;
|
||||
|
||||
let block = "start", inline = "nearest";
|
||||
if (arg === false) block = "end";
|
||||
else if (arg && typeof arg === "object") {
|
||||
if (["start", "center", "end", "nearest"].includes(arg.block)) block = arg.block;
|
||||
if (["start", "center", "end", "nearest"].includes(arg.inline)) inline = arg.inline;
|
||||
}
|
||||
const currentX = globalThis.scrollX || 0;
|
||||
const currentY = globalThis.scrollY || 0;
|
||||
const vw = globalThis.innerWidth || 1280;
|
||||
const vh = globalThis.innerHeight || 720;
|
||||
const align = (mode, start, end, size, viewportSize, current) => {
|
||||
if (mode === "start") return current + start;
|
||||
if (mode === "center") return current + start - (viewportSize - size) / 2;
|
||||
if (mode === "end") return current + end - viewportSize;
|
||||
// CSSOM View's nearest alignment: do nothing when fully visible or when
|
||||
// the box spans both viewport edges; otherwise move the closer edge in.
|
||||
if ((start >= 0 && end <= viewportSize) || (start < 0 && end > viewportSize)) {
|
||||
return current;
|
||||
}
|
||||
if (start < 0) return current + start;
|
||||
if (end > viewportSize) return current + end - viewportSize;
|
||||
return current;
|
||||
};
|
||||
const left = align(inline, rect.left, rect.right, rect.width, vw, currentX);
|
||||
const top = align(block, rect.top, rect.bottom, rect.height, vh, currentY);
|
||||
globalThis.scrollTo({ left, top, behavior: arg && arg.behavior });
|
||||
}
|
||||
// scrollTo/scrollBy/scroll accept either (x, y) or a ScrollToOptions object.
|
||||
// The setters fire a scroll event of their own, so suppress the per-axis ones
|
||||
// here and emit a single event for the whole movement, the way a real browser
|
||||
@@ -5498,104 +5660,182 @@ globalThis.NodeFilter = {
|
||||
// ResizeObserver is defined earlier with real per-target firing; the stub
|
||||
// that previously lived here was a no-op that clobbered the real class.
|
||||
//
|
||||
// IntersectionObserver: without a layout engine we can't compute real
|
||||
// intersection geometry, so every observed target is treated as fully
|
||||
// in-viewport (`isIntersecting: true`, `intersectionRatio: 1`). Real
|
||||
// libraries lean on this in three patterns we must support:
|
||||
//
|
||||
// 1. Lazy load: observe(img) -> first intersection -> load src -> unobserve.
|
||||
// One fire is enough — covered by the initial microtask fire.
|
||||
// 2. Infinite scroll: observe(sentinel) -> on intersection load more ->
|
||||
// new sentinel mounts -> fire again. Needs re-fires as DOM grows.
|
||||
// 3. Reveal-on-scroll animations: observe(card) -> isIntersecting flips
|
||||
// true once and an animation runs. One fire is enough.
|
||||
//
|
||||
// To cover (2) without spinning forever, we burst-fire at an exponential
|
||||
// backoff schedule and ALSO re-fire whenever the DOM mutates (a strong
|
||||
// signal that the page just rendered something new). Per-observer total
|
||||
// fire cap stops us from looping on a never-disconnected observer.
|
||||
// Viewport IntersectionObserver. Render builds provide real, scroll-relative
|
||||
// target boxes through getBoundingClientRect. Element scroll-container roots
|
||||
// remain intentionally unsupported until their offsets participate in layout.
|
||||
globalThis.__intersectionObservers = [];
|
||||
function _ioRect(x, y, width, height) {
|
||||
return {
|
||||
x, y, width, height,
|
||||
top: y, left: x, right: x + width, bottom: y + height,
|
||||
toJSON() { return this; },
|
||||
};
|
||||
}
|
||||
function _ioMargins(value) {
|
||||
const parts = String(value || "0px").trim().split(/\s+/);
|
||||
if (parts.length < 1 || parts.length > 4) return null;
|
||||
const parsed = parts.map((part) => {
|
||||
const match = /^([-+]?(?:\d+(?:\.\d*)?|\.\d+))(px|%)$/.exec(part);
|
||||
return match ? { value: Number(match[1]), unit: match[2] } : null;
|
||||
});
|
||||
if (parsed.some((part) => !part)) return null;
|
||||
if (parsed.length === 1) return [parsed[0], parsed[0], parsed[0], parsed[0]];
|
||||
if (parsed.length === 2) return [parsed[0], parsed[1], parsed[0], parsed[1]];
|
||||
if (parsed.length === 3) return [parsed[0], parsed[1], parsed[2], parsed[1]];
|
||||
return parsed;
|
||||
}
|
||||
function _ioUnsupportedRoot() {
|
||||
const error = new Error(
|
||||
"Obscura IntersectionObserver element roots require element scroll-container layout"
|
||||
);
|
||||
error.name = "NotSupportedError";
|
||||
return error;
|
||||
}
|
||||
globalThis.IntersectionObserver = class IntersectionObserver {
|
||||
constructor(callback, options) {
|
||||
if (typeof callback !== "function") {
|
||||
throw new TypeError("IntersectionObserver callback must be a function");
|
||||
}
|
||||
this._callback = callback;
|
||||
this._options = options || {};
|
||||
if (this._options.root != null) throw _ioUnsupportedRoot();
|
||||
this._margins = _ioMargins(this._options.rootMargin || "0px");
|
||||
if (!this._margins) throw new SyntaxError("Invalid IntersectionObserver rootMargin");
|
||||
const raw = this._options.threshold == null
|
||||
? [0]
|
||||
: (Array.isArray(this._options.threshold) ? this._options.threshold : [this._options.threshold]);
|
||||
this._thresholds = [...new Set(raw.map(Number))].sort((a, b) => a - b);
|
||||
if (!this._thresholds.length) this._thresholds = [0];
|
||||
if (this._thresholds.some((value) => !Number.isFinite(value) || value < 0 || value > 1)) {
|
||||
throw new RangeError("IntersectionObserver threshold must be between 0 and 1");
|
||||
}
|
||||
this._targets = new Set();
|
||||
this._previous = new Map();
|
||||
this._records = [];
|
||||
this._connected = true;
|
||||
this._fireCount = 0;
|
||||
this._deliveryPending = false;
|
||||
globalThis.__intersectionObservers.push(this);
|
||||
}
|
||||
_fireFor(targets) {
|
||||
if (!this._connected || !targets.length || this._fireCount >= 256) return;
|
||||
this._fireCount++;
|
||||
const records = targets.map(target => ({
|
||||
target,
|
||||
isIntersecting: true,
|
||||
intersectionRatio: 1,
|
||||
boundingClientRect: target.getBoundingClientRect
|
||||
? target.getBoundingClientRect()
|
||||
: { x: 0, y: 0, width: 100, height: 20, top: 0, left: 0, right: 100, bottom: 20 },
|
||||
intersectionRect: target.getBoundingClientRect
|
||||
? target.getBoundingClientRect()
|
||||
: { x: 0, y: 0, width: 100, height: 20, top: 0, left: 0, right: 100, bottom: 20 },
|
||||
rootBounds: { x: 0, y: 0, width: 1280, height: 720, top: 0, left: 0, right: 1280, bottom: 720 },
|
||||
time: Date.now(),
|
||||
}));
|
||||
try { this._callback(records, this); } catch (e) { /* IO callbacks must not propagate */ }
|
||||
_rootBounds() {
|
||||
const width = globalThis.innerWidth || 1280;
|
||||
const height = globalThis.innerHeight || 720;
|
||||
const resolve = (margin, basis) =>
|
||||
margin.unit === "%" ? margin.value * basis / 100 : margin.value;
|
||||
// IntersectionObserver resolves every rootMargin percentage against the
|
||||
// root rectangle's width, including the block-axis sides.
|
||||
const top = resolve(this._margins[0], width);
|
||||
const right = resolve(this._margins[1], width);
|
||||
const bottom = resolve(this._margins[2], width);
|
||||
const left = resolve(this._margins[3], width);
|
||||
return _ioRect(-left, -top, width + left + right, height + top + bottom);
|
||||
}
|
||||
observe(el) {
|
||||
if (!el || !this._connected) return;
|
||||
if (this._targets.has(el)) return;
|
||||
this._targets.add(el);
|
||||
Promise.resolve().then(() => this._fireFor([el]));
|
||||
// Exponential burst to cover infinite-scroll sentinels that "re-arm"
|
||||
// after content lands. Without a real scroll/layout signal, we fake the
|
||||
// re-fire schedule. Beyond ~10s the page has usually settled.
|
||||
[120, 500, 1500, 3500, 7000].forEach(delay => {
|
||||
setTimeout(() => {
|
||||
if (this._connected && this._targets.has(el)) this._fireFor([el]);
|
||||
}, delay);
|
||||
_entry(target) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
const root = this._rootBounds();
|
||||
const left = Math.max(rect.left, root.left);
|
||||
const top = Math.max(rect.top, root.top);
|
||||
const right = Math.min(rect.right, root.right);
|
||||
const bottom = Math.min(rect.bottom, root.bottom);
|
||||
const edgesTouch = right >= left && bottom >= top;
|
||||
const width = Math.max(0, right - left);
|
||||
const height = Math.max(0, bottom - top);
|
||||
const targetArea = Math.max(0, rect.width) * Math.max(0, rect.height);
|
||||
const area = width * height;
|
||||
const isIntersecting = edgesTouch;
|
||||
return {
|
||||
target,
|
||||
isIntersecting,
|
||||
intersectionRatio: targetArea > 0 ? area / targetArea : (isIntersecting ? 1 : 0),
|
||||
boundingClientRect: _ioRect(rect.x, rect.y, rect.width, rect.height),
|
||||
intersectionRect: _ioRect(left, top, width, height),
|
||||
rootBounds: root,
|
||||
time: performance.now(),
|
||||
};
|
||||
}
|
||||
_thresholdIndex(ratio) {
|
||||
let index = 0;
|
||||
while (index < this._thresholds.length && this._thresholds[index] <= ratio) index++;
|
||||
return index;
|
||||
}
|
||||
_queueChanged(target, forceInitial) {
|
||||
const entry = this._entry(target);
|
||||
const previous = this._previous.get(target);
|
||||
const changed = forceInitial || !previous ||
|
||||
previous.isIntersecting !== entry.isIntersecting ||
|
||||
this._thresholdIndex(previous.intersectionRatio) !==
|
||||
this._thresholdIndex(entry.intersectionRatio);
|
||||
this._previous.set(target, {
|
||||
isIntersecting: entry.isIntersecting,
|
||||
intersectionRatio: entry.intersectionRatio,
|
||||
});
|
||||
if (changed) this._records.push(entry);
|
||||
}
|
||||
_check(targets, forceInitial) {
|
||||
if (!this._connected) return;
|
||||
for (const target of targets) {
|
||||
if (this._targets.has(target)) this._queueChanged(target, !!forceInitial);
|
||||
}
|
||||
if (!this._records.length || this._deliveryPending) return;
|
||||
this._deliveryPending = true;
|
||||
Promise.resolve().then(() => {
|
||||
this._deliveryPending = false;
|
||||
if (!this._connected || !this._records.length) return;
|
||||
const records = this.takeRecords();
|
||||
try { this._callback(records, this); } catch (e) {}
|
||||
});
|
||||
}
|
||||
unobserve(el) { this._targets.delete(el); }
|
||||
observe(el) {
|
||||
if (!el || !this._connected || this._targets.has(el)) return;
|
||||
this._targets.add(el);
|
||||
this._previous.delete(el);
|
||||
this._check([el], true);
|
||||
}
|
||||
unobserve(el) {
|
||||
this._targets.delete(el);
|
||||
this._previous.delete(el);
|
||||
}
|
||||
disconnect() {
|
||||
this._connected = false;
|
||||
this._targets.clear();
|
||||
const idx = globalThis.__intersectionObservers.indexOf(this);
|
||||
if (idx >= 0) globalThis.__intersectionObservers.splice(idx, 1);
|
||||
this._previous.clear();
|
||||
this._records.length = 0;
|
||||
const index = globalThis.__intersectionObservers.indexOf(this);
|
||||
if (index >= 0) globalThis.__intersectionObservers.splice(index, 1);
|
||||
}
|
||||
takeRecords() { return []; }
|
||||
get root() { return this._options.root || null; }
|
||||
get rootMargin() { return this._options.rootMargin || "0px 0px 0px 0px"; }
|
||||
get thresholds() {
|
||||
const t = this._options.threshold;
|
||||
if (t == null) return [0];
|
||||
return Array.isArray(t) ? t.slice() : [t];
|
||||
takeRecords() { return this._records.splice(0); }
|
||||
get root() { return null; }
|
||||
get rootMargin() {
|
||||
return this._margins.map((margin) => `${margin.value}${margin.unit}`).join(" ");
|
||||
}
|
||||
get thresholds() { return this._thresholds.slice(); }
|
||||
};
|
||||
// When the DOM mutates (e.g. infinite scroll loads a batch of items), re-fire
|
||||
// every active IntersectionObserver so libraries observing dynamic content
|
||||
// see a fresh isIntersecting=true event. Uses the same per-observer fire cap
|
||||
// to prevent runaway loops if the page is mutating in a tight cycle.
|
||||
(function() {
|
||||
const reFire = () => {
|
||||
for (const obs of globalThis.__intersectionObservers) {
|
||||
if (!obs._connected) continue;
|
||||
const ts = [...obs._targets];
|
||||
if (ts.length) obs._fireFor(ts);
|
||||
}
|
||||
};
|
||||
// Lazy-attach a single MutationObserver on document.body once the page is
|
||||
// ready, debounced via a microtask so a flurry of mutations only triggers
|
||||
// one IO sweep.
|
||||
let pending = false;
|
||||
const recompute = () => {
|
||||
if (pending) return;
|
||||
pending = true;
|
||||
Promise.resolve().then(() => {
|
||||
pending = false;
|
||||
for (const observer of globalThis.__intersectionObservers) {
|
||||
if (observer._connected && observer._targets.size) {
|
||||
observer._check([...observer._targets], false);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
globalThis.__obscura_recompute_intersections = recompute;
|
||||
globalThis.addEventListener("resize", recompute);
|
||||
const wireUp = () => {
|
||||
if (!globalThis.document?.body) return;
|
||||
const mo = new MutationObserver(() => {
|
||||
if (pending) return;
|
||||
pending = true;
|
||||
Promise.resolve().then(() => { pending = false; reFire(); });
|
||||
});
|
||||
try { mo.observe(globalThis.document.body, {childList: true, subtree: true}); } catch {}
|
||||
const observer = new MutationObserver(recompute);
|
||||
try {
|
||||
observer.observe(globalThis.document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
if (globalThis.document?.body) wireUp();
|
||||
else Promise.resolve().then(wireUp);
|
||||
@@ -6718,21 +6958,312 @@ globalThis.screenLeft = 0; globalThis.screenTop = 0;
|
||||
globalThis.pageXOffset = 0; globalThis.pageYOffset = 0;
|
||||
globalThis.scrollX = 0; globalThis.scrollY = 0;
|
||||
|
||||
// Keep the JavaScript capability surface aligned with the declarations the
|
||||
// renderer actually implements. Reporting an unknown declaration as supported
|
||||
// is not harmless: Tailwind and other framework sheets use negative probes to
|
||||
// select legacy-browser fallbacks, which can replace their modern cascade.
|
||||
const _CSS_SUPPORTED_DECLARATIONS = new Set((
|
||||
"display width height min-width min-height max-width max-height box-sizing aspect-ratio " +
|
||||
"margin margin-top margin-right margin-bottom margin-left margin-inline margin-inline-start " +
|
||||
"margin-inline-end margin-block margin-block-start margin-block-end padding padding-top " +
|
||||
"padding-right padding-bottom padding-left padding-inline padding-inline-start padding-inline-end " +
|
||||
"padding-block padding-block-start padding-block-end border-radius border border-width " +
|
||||
"border-top-width border-right-width border-bottom-width border-left-width border-top border-right " +
|
||||
"border-bottom border-left background background-color background-image background-size " +
|
||||
"background-position background-clip -webkit-background-clip mask-image -webkit-mask-image " +
|
||||
"mask-size -webkit-mask-size mask-repeat -webkit-mask-repeat color -webkit-text-fill-color fill " +
|
||||
"stroke stroke-width border-color font-size font font-weight font-family font-style text-align " +
|
||||
"text-transform text-decoration text-decoration-line line-height align-items justify-items " +
|
||||
"place-items align-self justify-self place-self align-content justify-content place-content " +
|
||||
"flex-direction flex-wrap flex-grow flex-shrink flex-basis flex order position float object-fit " +
|
||||
"top right bottom left inset overflow overflow-x overflow-y scrollbar-gutter visibility opacity animation " +
|
||||
"animation-name animation-fill-mode animation-iteration-count z-index clear vertical-align " +
|
||||
"list-style list-style-type gap grid-gap row-gap grid-row-gap column-gap grid-column-gap " +
|
||||
"border-spacing border-collapse grid-template-columns grid-template-rows grid-template-areas " +
|
||||
"grid-template grid grid-auto-flow grid-area grid-column grid-row grid-column-start " +
|
||||
"grid-column-end grid-row-start grid-row-end transform filter backdrop-filter " +
|
||||
"-webkit-backdrop-filter perspective contain will-change content-visibility box-shadow " +
|
||||
"-webkit-box-shadow"
|
||||
).split(/\s+/));
|
||||
|
||||
const _CSS_SUPPORTED_COLOR_NAMES = new Set((
|
||||
"transparent white black gray grey silver lightgray lightgrey darkgray darkgrey whitesmoke " +
|
||||
"gainsboro red green lime blue navy yellow orange purple maroon teal aqua cyan fuchsia magenta " +
|
||||
"olive darkblue mediumblue royalblue dodgerblue cornflowerblue steelblue deepskyblue skyblue " +
|
||||
"lightskyblue lightblue powderblue cadetblue slateblue darkslateblue midnightblue indigo " +
|
||||
"darkgreen forestgreen seagreen mediumseagreen limegreen yellowgreen olivedrab darkolivegreen " +
|
||||
"greenyellow lightgreen palegreen springgreen mediumaquamarine aquamarine turquoise " +
|
||||
"mediumturquoise darkcyan crimson firebrick darkred indianred tomato orangered coral salmon " +
|
||||
"lightsalmon darksalmon hotpink deeppink pink lightpink palevioletred mediumvioletred violet " +
|
||||
"orchid plum mediumpurple blueviolet darkviolet darkorchid darkmagenta lavender thistle gold " +
|
||||
"goldenrod darkgoldenrod khaki darkkhaki peachpuff moccasin papayawhip wheat tan burlywood " +
|
||||
"sandybrown peru chocolate sienna saddlebrown brown rosybrown darkorange lightyellow " +
|
||||
"lightgoldenrodyellow lemonchiffon beige ivory azure mintcream honeydew snow seashell linen " +
|
||||
"oldlace floralwhite ghostwhite aliceblue lavenderblush mistyrose cornsilk antiquewhite bisque " +
|
||||
"blanchedalmond navajowhite dimgray dimgrey slategray slategrey lightslategray lightslategrey " +
|
||||
"darkslategray darkslategrey"
|
||||
).split(/\s+/));
|
||||
|
||||
function _cssSupportsColor(value) {
|
||||
const raw = value.trim();
|
||||
const lower = raw.toLowerCase();
|
||||
if (_CSS_SUPPORTED_COLOR_NAMES.has(lower)) return true;
|
||||
if (/^#[0-9a-f]{3,4}(?:[0-9a-f]{2}){0,2}$/i.test(lower)) {
|
||||
return [4, 5, 7, 9].includes(lower.length);
|
||||
}
|
||||
if (lower.startsWith("var(") && lower.endsWith(")")) {
|
||||
const comma = _cssTopLevelComma(raw.slice(4, -1));
|
||||
return comma >= 0 && _cssSupportsColor(raw.slice(4 + comma + 1, -1));
|
||||
}
|
||||
if (/^rgba?\(/.test(lower) && lower.endsWith(")")) {
|
||||
// Chromium accepts relative colors and Tailwind uses this exact syntax as
|
||||
// a browser-family probe. Painting every relative-color expression is a
|
||||
// separate concern from recognizing its valid conditional syntax.
|
||||
if (/\bfrom\b/.test(lower)) {
|
||||
return /^rgba?\(\s*from\s+\S[\s\S]*\)$/i.test(raw);
|
||||
}
|
||||
const parts = lower.slice(lower.indexOf("(") + 1, -1)
|
||||
.split(/[,\s/]+/).filter(Boolean);
|
||||
return parts.length >= 3 && parts.slice(0, 3)
|
||||
.every((part) => /^[-+]?(?:\d+(?:\.\d*)?|\.\d+)%?$/.test(part));
|
||||
}
|
||||
if (/^hsla?\(/.test(lower) && lower.endsWith(")")) {
|
||||
const parts = lower.slice(lower.indexOf("(") + 1, -1)
|
||||
.split(/[,\s/]+/).filter(Boolean);
|
||||
return parts.length >= 3 && parts.slice(0, 3).every((part) =>
|
||||
/^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:deg|%)?$/.test(part));
|
||||
}
|
||||
if (/^okl(?:ab|ch)\(/.test(lower) && lower.endsWith(")")) {
|
||||
const parts = lower.slice(lower.indexOf("(") + 1, -1)
|
||||
.split(/[,\s/]+/).filter(Boolean);
|
||||
return parts.length >= 3 && parts.slice(0, 3).every((part) =>
|
||||
/^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:deg|%)?$/.test(part));
|
||||
}
|
||||
if (lower.startsWith("light-dark(") && lower.endsWith(")")) {
|
||||
const parts = _cssSplitTopLevel(
|
||||
raw.slice("light-dark(".length, -1),
|
||||
","
|
||||
);
|
||||
return !!parts && parts.length === 2 &&
|
||||
parts.every((part) => part.trim() && _cssSupportsColor(part));
|
||||
}
|
||||
if (lower.startsWith("color-mix(") && lower.endsWith(")")) {
|
||||
const parts = _cssSplitTopLevel(lower.slice("color-mix(".length, -1), ",");
|
||||
if (!parts || parts.length < 3 || !/^in\s+\S+$/i.test(parts[0].trim())) return false;
|
||||
const color = (part) => _cssSupportsColor(part.trim().replace(/\s+[-+]?(?:\d+(?:\.\d*)?|\.\d+)%\s*$/, ""));
|
||||
return color(parts[1]) && color(parts[2]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function _cssTopLevelComma(text) {
|
||||
let depth = 0, quote = "";
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const character = text[i];
|
||||
if (quote) {
|
||||
if (character === "\\") i++;
|
||||
else if (character === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (character === "'" || character === '"') quote = character;
|
||||
else if (character === "(") depth++;
|
||||
else if (character === ")") depth--;
|
||||
else if (character === "," && depth === 0) return i;
|
||||
if (depth < 0) return -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function _cssSupportsDeclaration(name, value) {
|
||||
name = name.trim().toLowerCase();
|
||||
value = value.trim();
|
||||
if (!value) return false;
|
||||
if (name.startsWith("--")) return name.length > 2;
|
||||
if (!_CSS_SUPPORTED_DECLARATIONS.has(name)) return false;
|
||||
const lower = value.toLowerCase();
|
||||
if (["initial", "inherit", "unset", "revert", "revert-layer"].includes(lower)) return true;
|
||||
if (name === "display") {
|
||||
return ["none", "flex", "inline-flex", "inline", "inline-block", "grid",
|
||||
"inline-grid", "block", "flow-root", "contents"].includes(lower);
|
||||
}
|
||||
if (name === "position") {
|
||||
return ["static", "relative", "absolute", "fixed", "sticky"].includes(lower);
|
||||
}
|
||||
if (name === "box-sizing") return ["content-box", "border-box"].includes(lower);
|
||||
if (name === "float") return ["none", "left", "right"].includes(lower);
|
||||
if (name === "object-fit") {
|
||||
return ["fill", "contain", "cover", "none", "scale-down"].includes(lower);
|
||||
}
|
||||
if (name === "visibility") return ["visible", "hidden", "collapse"].includes(lower);
|
||||
if (name === "scrollbar-gutter") {
|
||||
return lower === "auto" || lower === "stable" || lower === "stable both-edges";
|
||||
}
|
||||
if (["color", "-webkit-text-fill-color", "background-color", "border-color"].includes(name)) {
|
||||
return _cssSupportsColor(value);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Return the contents only when one pair of parentheses encloses the complete
|
||||
// expression. Declaration leaves such as `(display:grid)` are then evaluated
|
||||
// by the same path as the two-argument overload.
|
||||
function _cssEnclosingGroup(text) {
|
||||
if (!text.startsWith("(")) return null;
|
||||
let depth = 0, quote = "";
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const character = text[i];
|
||||
if (quote) {
|
||||
if (character === "\\") i++;
|
||||
else if (character === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (character === "'" || character === '"') quote = character;
|
||||
else if (character === "(") depth++;
|
||||
else if (character === ")") {
|
||||
depth--;
|
||||
if (depth < 0) return null;
|
||||
if (depth === 0) return i === text.length - 1 ? text.slice(1, i) : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _cssSplitTopLevel(text, operator) {
|
||||
const parts = [];
|
||||
const isWord = /^[a-z]+$/i.test(operator);
|
||||
let start = 0, depth = 0, quote = "";
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const character = text[i];
|
||||
if (quote) {
|
||||
if (character === "\\") i++;
|
||||
else if (character === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (character === "'" || character === '"') {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
if (character === "(" || character === "[") depth++;
|
||||
else if (character === ")" || character === "]") {
|
||||
depth--;
|
||||
if (depth < 0) return null;
|
||||
} else if (depth === 0 &&
|
||||
text.slice(i, i + operator.length).toLowerCase() === operator.toLowerCase() &&
|
||||
(!isWord || (i > 0 && /\s/.test(text[i - 1]) &&
|
||||
i + operator.length < text.length && /\s/.test(text[i + operator.length])))) {
|
||||
const part = text.slice(start, i).trim();
|
||||
if (!part) return null;
|
||||
parts.push(part);
|
||||
i += operator.length - 1;
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
if (depth !== 0 || quote || !parts.length) return null;
|
||||
const tail = text.slice(start).trim();
|
||||
if (!tail) return null;
|
||||
parts.push(tail);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function _cssHasTopLevelComma(text) {
|
||||
let depth = 0, quote = "";
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const character = text[i];
|
||||
if (quote) {
|
||||
if (character === "\\") i++;
|
||||
else if (character === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (character === "\\") { i++; continue; }
|
||||
if (character === "'" || character === '"') quote = character;
|
||||
else if (character === "(" || character === "[") depth++;
|
||||
else if (character === ")" || character === "]") depth--;
|
||||
else if (character === "," && depth === 0) return true;
|
||||
if (depth < 0) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const _CSS_SUPPORTED_SIMPLE_PSEUDOS = new Set((
|
||||
"hover active focus focus-visible focus-within enabled disabled checked link any-link visited " +
|
||||
"first-child last-child only-child root empty scope first-of-type last-of-type only-of-type " +
|
||||
"before after"
|
||||
).split(/\s+/));
|
||||
const _CSS_SUPPORTED_FUNCTIONAL_PSEUDOS = new Set((
|
||||
"nth-child nth-of-type nth-last-child nth-last-of-type is where has host not"
|
||||
).split(/\s+/));
|
||||
|
||||
function _cssSupportsSelector(selector) {
|
||||
selector = selector.trim();
|
||||
if (!selector || /[{};]/.test(selector)) return false;
|
||||
const split = _cssSplitTopLevel(selector, ",");
|
||||
if (!split && _cssHasTopLevelComma(selector)) return false;
|
||||
const selectors = split || [selector];
|
||||
return selectors.every((part) => {
|
||||
part = part.trim();
|
||||
if (!part || /^[>+~]/.test(part) || /[>+~]\s*$/.test(part)) return false;
|
||||
let parens = 0, brackets = 0, quote = "";
|
||||
for (let i = 0; i < part.length; i++) {
|
||||
const character = part[i];
|
||||
if (quote) {
|
||||
if (character === "\\") i++;
|
||||
else if (character === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (character === "\\") { i++; continue; }
|
||||
if (character === "'" || character === '"') quote = character;
|
||||
else if (character === "(") parens++;
|
||||
else if (character === ")") parens--;
|
||||
else if (character === "[") brackets++;
|
||||
else if (character === "]") brackets--;
|
||||
else if (character === ":" && brackets === 0) {
|
||||
const doubleColon = part[i + 1] === ":";
|
||||
let end = i + (doubleColon ? 2 : 1);
|
||||
const start = end;
|
||||
while (end < part.length && /[a-z0-9_-]/i.test(part[end])) end++;
|
||||
if (end === start) return false;
|
||||
const name = part.slice(start, end).toLowerCase();
|
||||
const functional = part[end] === "(";
|
||||
if (doubleColon) {
|
||||
if (functional || !["before", "after"].includes(name)) return false;
|
||||
} else if (functional) {
|
||||
if (!_CSS_SUPPORTED_FUNCTIONAL_PSEUDOS.has(name)) return false;
|
||||
} else if (!_CSS_SUPPORTED_SIMPLE_PSEUDOS.has(name)) {
|
||||
return false;
|
||||
}
|
||||
i = end - 1;
|
||||
}
|
||||
if (parens < 0 || brackets < 0) return false;
|
||||
}
|
||||
return !quote && parens === 0 && brackets === 0;
|
||||
});
|
||||
}
|
||||
|
||||
function _cssSupportsCondition(condition) {
|
||||
condition = condition.trim();
|
||||
if (!condition) return false;
|
||||
const grouped = _cssEnclosingGroup(condition);
|
||||
if (grouped !== null) return _cssSupportsCondition(grouped);
|
||||
if (/^not\s/i.test(condition)) return !_cssSupportsCondition(condition.slice(3).trim());
|
||||
const orParts = _cssSplitTopLevel(condition, "or");
|
||||
if (orParts) return orParts.some(_cssSupportsCondition);
|
||||
const andParts = _cssSplitTopLevel(condition, "and");
|
||||
if (andParts) return andParts.every(_cssSupportsCondition);
|
||||
if (/^selector\(/i.test(condition) && condition.endsWith(")")) {
|
||||
return _cssSupportsSelector(condition.slice(condition.indexOf("(") + 1, -1));
|
||||
}
|
||||
const colon = condition.indexOf(":");
|
||||
if (colon < 0) return false;
|
||||
return _cssSupportsDeclaration(condition.slice(0, colon), condition.slice(colon + 1));
|
||||
}
|
||||
|
||||
globalThis.CSS = {
|
||||
supports(prop, value){
|
||||
try {
|
||||
var p, v;
|
||||
if (arguments.length >= 2) { p = String(prop).trim(); v = String(value).trim(); }
|
||||
else {
|
||||
var cond = String(prop).trim().replace(/^\(+|\)+$/g, "").trim();
|
||||
var idx = cond.indexOf(":");
|
||||
if (idx === -1) return false;
|
||||
p = cond.slice(0, idx).trim(); v = cond.slice(idx + 1).trim();
|
||||
if (arguments.length >= 2) {
|
||||
return _cssSupportsDeclaration(String(prop), String(value));
|
||||
}
|
||||
if (!p || !v) return false;
|
||||
// The engine renders standard CSS; report it as supported so feature-gated
|
||||
// SPAs don't bail to /unsupported. (Previous stub always returned false.)
|
||||
return true;
|
||||
return _cssSupportsCondition(String(prop));
|
||||
} catch (e) { return false; }
|
||||
},
|
||||
escape(s){ return s; }
|
||||
@@ -8290,8 +8821,9 @@ URL.revokeObjectURL = function(url) {
|
||||
//
|
||||
// The page offset is stored on the scrolling element rather than in separate
|
||||
// window state, so window.scrollY and document.scrollingElement.scrollTop are
|
||||
// two views of one value, which is what pages assume. As with #431 there is no
|
||||
// layout, so the offset still cannot be clamped to a real maximum.
|
||||
// two views of one value, which is what pages assume. Render builds clamp that
|
||||
// shared root offset against measured document overflow; non-render builds keep
|
||||
// the legacy synthetic offset used by automation-only consumers.
|
||||
function _scrollRoot() {
|
||||
const doc = globalThis.document;
|
||||
return (doc && doc.scrollingElement) || null;
|
||||
@@ -8299,6 +8831,8 @@ function _scrollRoot() {
|
||||
function _windowScroll(x, y, relative) {
|
||||
const root = _scrollRoot();
|
||||
if (!root) return;
|
||||
const beforeLeft = root.scrollLeft || 0;
|
||||
const beforeTop = root.scrollTop || 0;
|
||||
let left, top;
|
||||
if (x !== null && typeof x === 'object') { left = x.left; top = x.top; }
|
||||
else { left = x; top = y; }
|
||||
@@ -8308,6 +8842,9 @@ function _windowScroll(x, y, relative) {
|
||||
if (top !== undefined) {
|
||||
root.scrollTop = (relative ? (root.scrollTop || 0) : 0) + (+top || 0);
|
||||
}
|
||||
if ((root.scrollLeft || 0) === beforeLeft && (root.scrollTop || 0) === beforeTop) {
|
||||
return;
|
||||
}
|
||||
// Async, matching the element path #431 added. Dispatched at the document
|
||||
// AND the window: a page scroll event reaches both in Chrome, but
|
||||
// Document.dispatchEvent here runs only its own listeners and does not
|
||||
|
||||
@@ -14,4 +14,4 @@ pub use v8_flags::set_v8_flags;
|
||||
// Screenshot rasterization (PNG bytes) from the render layer. Available when the
|
||||
// render feature (which enables obscura-render/paint) is compiled in.
|
||||
#[cfg(feature = "render")]
|
||||
pub use obscura_render::screenshot_png;
|
||||
pub use obscura_render::{screenshot_png, screenshot_png_scrolled};
|
||||
+155
-27
@@ -64,6 +64,14 @@ pub struct JsNetworkEvent {
|
||||
pub timestamp: f64,
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub struct LayoutCache {
|
||||
rects: std::collections::HashMap<obscura_dom::tree::NodeId, obscura_render::Rect>,
|
||||
viewport_fixed: std::collections::HashSet<obscura_dom::tree::NodeId>,
|
||||
sticky: obscura_render::StickyLayout,
|
||||
content_size: (f32, f32),
|
||||
}
|
||||
|
||||
pub struct ObscuraState {
|
||||
pub dom: Option<DomTree>,
|
||||
pub url: String,
|
||||
@@ -104,14 +112,17 @@ pub struct ObscuraState {
|
||||
// Network.requestWillBeSent / responseReceived for them (issue #406).
|
||||
pub js_network_events: Vec<JsNetworkEvent>,
|
||||
/// Lazy layout cache from obscura-render, plus the CSS-pixel viewport. The
|
||||
/// cache is computed on first geometry read and cleared on navigation. It is
|
||||
/// not invalidated on every DOM mutation yet (a follow-up), so geometry read
|
||||
/// mid-script may lag a frame; reading after settle is reliable.
|
||||
/// cache is computed on first geometry read and cleared on navigation,
|
||||
/// viewport changes, and DOM/style mutations that can affect layout.
|
||||
#[cfg(feature = "render")]
|
||||
pub layout_cache:
|
||||
Option<std::collections::HashMap<obscura_dom::tree::NodeId, obscura_render::Rect>>,
|
||||
pub layout_cache: Option<LayoutCache>,
|
||||
#[cfg(feature = "render")]
|
||||
pub viewport: (f32, f32),
|
||||
/// Root scrolling offset in CSS pixels. With render enabled this is
|
||||
/// clamped against the cached document overflow and is the single source
|
||||
/// read by CSSOM geometry and screenshot paint.
|
||||
#[cfg(feature = "render")]
|
||||
pub scroll_offset: (f32, f32),
|
||||
}
|
||||
|
||||
impl ObscuraState {
|
||||
@@ -141,6 +152,8 @@ impl ObscuraState {
|
||||
layout_cache: None,
|
||||
#[cfg(feature = "render")]
|
||||
viewport: (1280.0, 720.0),
|
||||
#[cfg(feature = "render")]
|
||||
scroll_offset: (0.0, 0.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,8 +193,26 @@ fn op_dom(state: &OpState, #[string] cmd: String, #[string] arg1: String, #[stri
|
||||
}
|
||||
|
||||
fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> String {
|
||||
let gs = state.borrow::<SharedState>().clone();
|
||||
let gs = gs.borrow();
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
#[cfg(feature = "render")]
|
||||
if matches!(
|
||||
cmd.as_str(),
|
||||
"set_attribute"
|
||||
| "remove_attribute"
|
||||
| "append_child"
|
||||
| "remove_child"
|
||||
| "insert_before"
|
||||
| "set_inner_html"
|
||||
| "set_inner_html_context"
|
||||
| "set_text_content"
|
||||
) {
|
||||
// Any attribute can participate in an author selector, so even a
|
||||
// seemingly non-geometric attribute may change computed layout.
|
||||
// Detached-node creation and every read-only op deliberately skip this
|
||||
// invalidation; the next geometry/scroll read rebuilds at most once.
|
||||
shared.borrow_mut().layout_cache = None;
|
||||
}
|
||||
let gs = shared.borrow();
|
||||
let dom = match &gs.dom {
|
||||
Some(d) => d,
|
||||
None => return "null".to_string(),
|
||||
@@ -2010,7 +2041,12 @@ pub fn build_extension() -> Extension {
|
||||
// Only registered when the render feature is compiled in. bootstrap.js
|
||||
// probes with typeof before calling, so the op's absence is a clean fallback.
|
||||
#[cfg(feature = "render")]
|
||||
ops.push(op_layout_geometry());
|
||||
{
|
||||
ops.push(op_layout_geometry());
|
||||
ops.push(op_layout_metrics());
|
||||
ops.push(op_scroll_offset());
|
||||
ops.push(op_scroll_to());
|
||||
}
|
||||
Extension {
|
||||
name: "obscura_dom",
|
||||
ops: std::borrow::Cow::Owned(ops),
|
||||
@@ -2018,35 +2054,127 @@ pub fn build_extension() -> Extension {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
fn ensure_layout_cache(state: &mut ObscuraState) -> Option<&LayoutCache> {
|
||||
if state.layout_cache.is_none() {
|
||||
let dom = state.dom.as_ref()?;
|
||||
let laid = obscura_render::layout_dom(dom, state.viewport);
|
||||
let viewport_fixed = laid.viewport_fixed_nodes(dom);
|
||||
let sticky = laid.root_sticky_layout(dom, state.viewport);
|
||||
let content_size = laid.scrolling_content_size(dom, state.viewport);
|
||||
let mut rects = laid.rects;
|
||||
for (&id, rect) in &mut rects {
|
||||
if let Some((tx, ty)) = laid.translates.get(&id) {
|
||||
rect.x += tx;
|
||||
rect.y += ty;
|
||||
}
|
||||
}
|
||||
state.layout_cache = Some(LayoutCache {
|
||||
rects,
|
||||
viewport_fixed,
|
||||
sticky,
|
||||
content_size,
|
||||
});
|
||||
}
|
||||
state.layout_cache.as_ref()
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub(crate) fn clamp_scroll_offset(state: &mut ObscuraState, requested: (f32, f32)) -> (f32, f32) {
|
||||
let viewport = state.viewport;
|
||||
let content_size = ensure_layout_cache(state)
|
||||
.map(|cache| cache.content_size)
|
||||
.unwrap_or(viewport);
|
||||
let max_x = (content_size.0 - viewport.0).max(0.0);
|
||||
let max_y = (content_size.1 - viewport.1).max(0.0);
|
||||
let x = if requested.0.is_finite() {
|
||||
requested.0.clamp(0.0, max_x)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let y = if requested.1.is_finite() {
|
||||
requested.1.clamp(0.0, max_y)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
state.scroll_offset = (x, y);
|
||||
state.scroll_offset
|
||||
}
|
||||
|
||||
/// Real border-box geometry for an element from the obscura-render layout
|
||||
/// cache. The cache is computed lazily on first read and cleared on navigation
|
||||
/// (see `set_dom`). Returns JSON `{"x","y","width","height"}` in CSS pixels, or
|
||||
/// an empty string when the node has no box. Feature-gated.
|
||||
/// (see `set_dom`). Coordinates are viewport-relative after the shared root
|
||||
/// scroll offset, except for viewport-fixed subtrees. Returns JSON
|
||||
/// `{"x","y","width","height"}` in CSS pixels, or an empty string when the
|
||||
/// node has no box. Feature-gated.
|
||||
#[cfg(feature = "render")]
|
||||
#[op2]
|
||||
#[string]
|
||||
fn op_layout_geometry(state: &OpState, #[string] nid_str: String) -> String {
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
{
|
||||
let mut gs = shared.borrow_mut();
|
||||
if gs.layout_cache.is_none() {
|
||||
if let Some(dom) = &gs.dom {
|
||||
let viewport = gs.viewport;
|
||||
gs.layout_cache = Some(obscura_render::layout_dom(dom, viewport).rects);
|
||||
}
|
||||
}
|
||||
}
|
||||
let gs = shared.borrow();
|
||||
let nid: u32 = nid_str.parse().unwrap_or(0);
|
||||
if let Some(rect) = gs
|
||||
.layout_cache
|
||||
.as_ref()
|
||||
.and_then(|c| c.get(&obscura_dom::tree::NodeId::new(nid)))
|
||||
{
|
||||
let nid = obscura_dom::tree::NodeId::new(nid);
|
||||
let mut gs = shared.borrow_mut();
|
||||
let scroll = gs.scroll_offset;
|
||||
let viewport = gs.viewport;
|
||||
if let Some(cache) = ensure_layout_cache(&mut gs) {
|
||||
let Some(rect) = cache.rects.get(&nid) else {
|
||||
return String::new();
|
||||
};
|
||||
let sticky_offset = cache.sticky.translation_for(nid, viewport, scroll);
|
||||
let (x, y) = if cache.viewport_fixed.contains(&nid) {
|
||||
(rect.x, rect.y)
|
||||
} else {
|
||||
(
|
||||
rect.x + sticky_offset.0 - scroll.0,
|
||||
rect.y + sticky_offset.1 - scroll.1,
|
||||
)
|
||||
};
|
||||
let viewport_fixed = cache.viewport_fixed.contains(&nid);
|
||||
return format!(
|
||||
"{{\"x\":{},\"y\":{},\"width\":{},\"height\":{}}}",
|
||||
rect.x, rect.y, rect.width, rect.height
|
||||
"{{\"x\":{},\"y\":{},\"width\":{},\"height\":{},\"viewportFixed\":{}}}",
|
||||
x, y, rect.width, rect.height, viewport_fixed
|
||||
);
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Root scrolling overflow in CSS pixels. The JS CSSOM probes this op only in
|
||||
/// render builds; default scraping builds retain their deliberately unbounded
|
||||
/// synthetic scrolling behavior.
|
||||
#[cfg(feature = "render")]
|
||||
#[op2]
|
||||
#[string]
|
||||
fn op_layout_metrics(state: &OpState) -> String {
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
let mut gs = shared.borrow_mut();
|
||||
let viewport = gs.viewport;
|
||||
let content = ensure_layout_cache(&mut gs)
|
||||
.map(|cache| cache.content_size)
|
||||
.unwrap_or(viewport);
|
||||
format!(
|
||||
"{{\"scrollWidth\":{},\"scrollHeight\":{},\"clientWidth\":{},\"clientHeight\":{}}}",
|
||||
content.0, content.1, viewport.0, viewport.1
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[op2]
|
||||
#[string]
|
||||
fn op_scroll_offset(state: &OpState) -> String {
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
let mut gs = shared.borrow_mut();
|
||||
let requested = gs.scroll_offset;
|
||||
let (x, y) = clamp_scroll_offset(&mut gs, requested);
|
||||
format!("{{\"x\":{},\"y\":{}}}", x, y)
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[op2]
|
||||
#[string]
|
||||
fn op_scroll_to(state: &OpState, x: f64, y: f64) -> String {
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
let mut gs = shared.borrow_mut();
|
||||
let (x, y) = clamp_scroll_offset(&mut gs, (x as f32, y as f32));
|
||||
format!("{{\"x\":{},\"y\":{}}}", x, y)
|
||||
}
|
||||
@@ -10,6 +10,8 @@ use obscura_dom::DomTree;
|
||||
pub use deno_core::v8::IsolateHandle;
|
||||
|
||||
use crate::module_loader::ObscuraModuleLoader;
|
||||
#[cfg(feature = "render")]
|
||||
use crate::ops::clamp_scroll_offset;
|
||||
use crate::ops::{build_extension, ObscuraState, StoredNetworkResponseBody};
|
||||
|
||||
static SNAPSHOT: &[u8] = include_bytes!(env!("OBSCURA_SNAPSHOT_PATH"));
|
||||
@@ -195,6 +197,7 @@ impl ObscuraJsRuntime {
|
||||
#[cfg(feature = "render")]
|
||||
{
|
||||
gs.layout_cache = None;
|
||||
gs.scroll_offset = (0.0, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +288,12 @@ impl ObscuraJsRuntime {
|
||||
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
|
||||
return;
|
||||
}
|
||||
#[cfg(feature = "render")]
|
||||
{
|
||||
let mut state = self.state.borrow_mut();
|
||||
state.viewport = (width as f32, height as f32);
|
||||
state.layout_cache = None;
|
||||
}
|
||||
let _ = self.runtime.execute_script(
|
||||
"<set-viewport>",
|
||||
format!(
|
||||
@@ -294,11 +303,22 @@ impl ObscuraJsRuntime {
|
||||
if(globalThis.visualViewport){{\
|
||||
globalThis.visualViewport.width={width};\
|
||||
globalThis.visualViewport.height={height};\
|
||||
}}\
|
||||
if(typeof globalThis.__obscura_recompute_intersections==='function'){{\
|
||||
globalThis.__obscura_recompute_intersections();\
|
||||
}}",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Current clamped root scroll offset shared by CSSOM geometry and paint.
|
||||
#[cfg(feature = "render")]
|
||||
pub fn scroll_offset(&self) -> (f32, f32) {
|
||||
let mut state = self.state.borrow_mut();
|
||||
let requested = state.scroll_offset;
|
||||
clamp_scroll_offset(&mut state, requested)
|
||||
}
|
||||
|
||||
/// Run __obscura_init() after all per-page properties (UA, platform, stealth, etc.)
|
||||
/// have been set. Must be called once per page setup, after all set_* methods.
|
||||
pub fn run_page_init(&mut self) {
|
||||
@@ -1871,6 +1891,146 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_frame_requires_a_callable_callback() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"(() => {
|
||||
try {
|
||||
requestAnimationFrame(null);
|
||||
return [false, ""];
|
||||
} catch (error) {
|
||||
return [error instanceof TypeError, error.name];
|
||||
}
|
||||
})()"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result, serde_json::json!([true, "TypeError"]));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn animation_frames_are_ordered_batches_with_rendering_timestamps() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
rt.execute_script(
|
||||
"animation-frame-order",
|
||||
r#"
|
||||
globalThis.__rafEvents = [];
|
||||
globalThis.__rafStamps = [];
|
||||
Promise.resolve().then(() => __rafEvents.push("microtask-before"));
|
||||
setTimeout(() => __rafEvents.push("timer"), 1);
|
||||
requestAnimationFrame((timestamp) => {
|
||||
__rafEvents.push("raf-a");
|
||||
__rafStamps.push(timestamp);
|
||||
Promise.resolve().then(() => __rafEvents.push("microtask-in-raf"));
|
||||
requestAnimationFrame((nextTimestamp) => {
|
||||
__rafEvents.push("raf-next");
|
||||
__rafStamps.push(nextTimestamp);
|
||||
});
|
||||
});
|
||||
requestAnimationFrame((timestamp) => {
|
||||
__rafEvents.push("raf-b");
|
||||
__rafStamps.push(timestamp);
|
||||
});
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
rt.run_event_loop_bounded(150).await.unwrap();
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"[
|
||||
__rafEvents,
|
||||
__rafStamps.length,
|
||||
__rafStamps[0] === __rafStamps[1],
|
||||
__rafStamps[2] > __rafStamps[1]
|
||||
]"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
serde_json::json!([
|
||||
[
|
||||
"microtask-before",
|
||||
"timer",
|
||||
"raf-a",
|
||||
"raf-b",
|
||||
"microtask-in-raf",
|
||||
"raf-next"
|
||||
],
|
||||
3,
|
||||
true,
|
||||
true
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn cancel_animation_frame_removes_pending_and_current_batch_callbacks() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
rt.execute_script(
|
||||
"animation-frame-cancel",
|
||||
r#"
|
||||
globalThis.__rafEvents = [];
|
||||
const pending = requestAnimationFrame(() => __rafEvents.push("pending"));
|
||||
cancelAnimationFrame(pending);
|
||||
let sameBatch;
|
||||
requestAnimationFrame(() => {
|
||||
__rafEvents.push("first");
|
||||
cancelAnimationFrame(sameBatch);
|
||||
});
|
||||
sameBatch = requestAnimationFrame(() => __rafEvents.push("same-batch"));
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
rt.run_event_loop_bounded(100).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate("__rafEvents").unwrap(),
|
||||
serde_json::json!(["first"])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn self_requeueing_animation_frame_yields_to_timer_tasks() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
rt.execute_script(
|
||||
"animation-frame-yield",
|
||||
r#"
|
||||
globalThis.__rafCount = 0;
|
||||
globalThis.__rafStopped = false;
|
||||
globalThis.__timerAfterAnimation = false;
|
||||
let frameId = 0;
|
||||
function frame() {
|
||||
__rafCount++;
|
||||
frameId = requestAnimationFrame(frame);
|
||||
}
|
||||
frameId = requestAnimationFrame(frame);
|
||||
setTimeout(() => {
|
||||
cancelAnimationFrame(frameId);
|
||||
__rafStopped = true;
|
||||
}, 55);
|
||||
setTimeout(() => {
|
||||
__timerAfterAnimation = true;
|
||||
}, 65);
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
rt.run_event_loop_bounded(200).await.unwrap();
|
||||
let result = rt
|
||||
.evaluate("[__rafCount, __rafStopped, __timerAfterAnimation]")
|
||||
.unwrap();
|
||||
let values = result.as_array().unwrap();
|
||||
let frame_count = values[0].as_u64().unwrap();
|
||||
assert!(
|
||||
(2..=5).contains(&frame_count),
|
||||
"expected a few paced animation frames before cancellation, got {frame_count}"
|
||||
);
|
||||
assert_eq!(values[1], serde_json::json!(true));
|
||||
assert_eq!(values[2], serde_json::json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_document_url() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
@@ -1892,6 +2052,38 @@ mod tests {
|
||||
assert_eq!(count.as_f64().unwrap() as i64, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_supports_matches_capabilities_and_boolean_conditions() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"JSON.stringify([
|
||||
CSS.supports("-webkit-hyphens", "none"),
|
||||
CSS.supports("margin-trim", "inline"),
|
||||
CSS.supports("-moz-orient", "inline"),
|
||||
CSS.supports("color", "rgb(from red r g b)"),
|
||||
CSS.supports("(((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b))))"),
|
||||
CSS.supports("display", "grid"),
|
||||
CSS.supports("(display:grid) and (selector(.card > *))"),
|
||||
CSS.supports("not (unknown-engine-prop:value)"),
|
||||
CSS.supports("selector(.card >)"),
|
||||
CSS.supports("selector(:obscura-unknown)"),
|
||||
CSS.supports("selector(.card,)"),
|
||||
CSS.supports("scrollbar-gutter", "stable"),
|
||||
CSS.supports("scrollbar-gutter", "floating"),
|
||||
CSS.supports("color", "light-dark(rgb(1, 2, 3), color-mix(in srgb, white 50%, black))"),
|
||||
CSS.supports("(color:light-dark(red, light-dark(white, black)))"),
|
||||
CSS.supports("color", "light-dark(red)"),
|
||||
CSS.supports("color", "light-dark(red, rgb(1, 2, 3)")
|
||||
])"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
serde_json::json!("[false,false,false,true,false,true,true,true,false,false,false,true,false,true,true,false,false]")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_element_by_id() {
|
||||
let mut rt = setup_runtime(r#"<div id="test">Content</div>"#);
|
||||
@@ -2500,6 +2692,7 @@ mod tests {
|
||||
|
||||
/// Issue #468: window.scrollTo/scrollBy/scroll were no-op stubs, so the
|
||||
/// dominant infinite-scroll idiom never advanced the page offset.
|
||||
#[cfg(not(feature = "render"))]
|
||||
#[test]
|
||||
fn window_scroll_methods_move_the_page_offset() {
|
||||
let mut rt = setup_runtime(r#"<html><body><div id="d"></div></body></html>"#);
|
||||
@@ -2528,6 +2721,7 @@ mod tests {
|
||||
|
||||
/// Issue #468: the page offset is one value, readable and writable through
|
||||
/// either `window.scrollY` or `document.scrollingElement.scrollTop`.
|
||||
#[cfg(not(feature = "render"))]
|
||||
#[test]
|
||||
fn window_scroll_offset_is_shared_with_the_scrolling_element() {
|
||||
let mut rt = setup_runtime(r#"<html><body><div id="d"></div></body></html>"#);
|
||||
@@ -2549,6 +2743,7 @@ mod tests {
|
||||
|
||||
/// Issue #468: a scroll event must reach listeners on both the window and
|
||||
/// the document — that is the signal lazy loaders wait for.
|
||||
#[cfg(not(feature = "render"))]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn window_scroll_fires_a_scroll_event() {
|
||||
let mut rt = setup_runtime(r#"<html><body><div id="d"></div></body></html>"#);
|
||||
@@ -2571,6 +2766,587 @@ mod tests {
|
||||
assert_eq!(result.value.unwrap(), serde_json::json!([1, 1, 400]));
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[test]
|
||||
fn rendered_window_scroll_clamps_and_geometry_is_viewport_relative() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div id="wide" style="width:600px;height:700px"></div>
|
||||
<div id="target" style="width:20px;height:300px"></div>
|
||||
<div id="fixed" style="position:fixed;left:12px;top:14px;width:30px;height:25px">
|
||||
<span id="fixed-child">fixed</span>
|
||||
</div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(320.0, 200.0);
|
||||
rt.run_page_init();
|
||||
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"
|
||||
const target = document.getElementById("target");
|
||||
const fixed = document.getElementById("fixed");
|
||||
const fixedChild = document.getElementById("fixed-child");
|
||||
const before = {
|
||||
target: target.getBoundingClientRect(),
|
||||
fixed: fixed.getBoundingClientRect(),
|
||||
fixedChild: fixedChild.getBoundingClientRect(),
|
||||
};
|
||||
window.scrollTo(99999, 99999);
|
||||
const maxX = document.documentElement.scrollWidth - innerWidth;
|
||||
const maxY = document.documentElement.scrollHeight - innerHeight;
|
||||
const after = {
|
||||
target: target.getBoundingClientRect(),
|
||||
fixed: fixed.getBoundingClientRect(),
|
||||
fixedChild: fixedChild.getBoundingClientRect(),
|
||||
};
|
||||
return [
|
||||
innerWidth, innerHeight,
|
||||
document.documentElement.clientWidth,
|
||||
document.documentElement.clientHeight,
|
||||
document.documentElement.scrollWidth,
|
||||
document.documentElement.scrollHeight,
|
||||
window.scrollX, window.scrollY,
|
||||
document.scrollingElement.scrollLeft,
|
||||
document.scrollingElement.scrollTop,
|
||||
maxX, maxY,
|
||||
Math.abs(after.target.left - (before.target.left - maxX)) < 0.01,
|
||||
Math.abs(after.target.top - (before.target.top - maxY)) < 0.01,
|
||||
Math.abs(after.fixed.left - before.fixed.left) < 0.01,
|
||||
Math.abs(after.fixed.top - before.fixed.top) < 0.01,
|
||||
Math.abs(after.fixedChild.left - before.fixedChild.left) < 0.01,
|
||||
Math.abs(after.fixedChild.top - before.fixedChild.top) < 0.01,
|
||||
];
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let values = result.as_array().expect("array");
|
||||
assert_eq!(&values[0..4], &serde_json::json!([320, 200, 320, 200]).as_array().unwrap()[..]);
|
||||
let scroll_width = values[4].as_f64().expect("scrollWidth");
|
||||
let scroll_height = values[5].as_f64().expect("scrollHeight");
|
||||
assert!(scroll_width >= 600.0, "scrollWidth was {scroll_width}");
|
||||
assert!(scroll_height >= 1000.0, "scrollHeight was {scroll_height}");
|
||||
assert_eq!(values[6], values[10]);
|
||||
assert_eq!(values[7], values[11]);
|
||||
assert_eq!(values[8], values[10]);
|
||||
assert_eq!(values[9], values[11]);
|
||||
assert!(values[12..].iter().all(|value| value == &serde_json::json!(true)));
|
||||
}
|
||||
|
||||
/// Chromium 150 reference (800x513 CSS-pixel viewport):
|
||||
/// top=[60,20,20,-267], bottom=[448,448,231,31,-496] at the sampled
|
||||
/// root scroll offsets. This keeps sticky distinct from fixed positioning,
|
||||
/// verifies subtree movement, bottom-only sticking, and the containing
|
||||
/// block's lower boundary without depending on a live site.
|
||||
#[cfg(feature = "render")]
|
||||
#[test]
|
||||
fn root_scroll_sticky_geometry_matches_chromium_constraints() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div style="height:40px"></div>
|
||||
<div id="cb" style="box-sizing:border-box;height:900px;padding:10px 12px;border:4px solid #333">
|
||||
<div id="top" style="box-sizing:border-box;position:sticky;top:20px;height:60px;margin:6px">
|
||||
<div id="top-child" style="height:12px"></div>
|
||||
</div>
|
||||
<div style="height:500px"></div>
|
||||
<div id="bottom" style="box-sizing:border-box;position:sticky;bottom:15px;height:50px;margin:5px"></div>
|
||||
</div>
|
||||
<div style="height:700px"></div>
|
||||
<div id="fixed" style="position:fixed;left:600px;top:20px;width:60px;height:60px"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(800.0, 513.0);
|
||||
rt.run_page_init();
|
||||
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"
|
||||
const top = document.getElementById("top");
|
||||
const child = document.getElementById("top-child");
|
||||
const bottom = document.getElementById("bottom");
|
||||
const fixed = document.getElementById("fixed");
|
||||
const sample = y => {
|
||||
window.scrollTo(0, y);
|
||||
return [
|
||||
window.scrollY,
|
||||
top.getBoundingClientRect().top,
|
||||
child.getBoundingClientRect().top,
|
||||
bottom.getBoundingClientRect().top,
|
||||
fixed.getBoundingClientRect().top,
|
||||
];
|
||||
};
|
||||
return [sample(0), sample(100), sample(400), sample(600), sample(9999)];
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let rows = result.as_array().expect("rows");
|
||||
let number = |row: usize, column: usize| {
|
||||
rows[row].as_array().unwrap()[column].as_f64().unwrap()
|
||||
};
|
||||
let close = |actual: f64, expected: f64| {
|
||||
assert!(
|
||||
(actual - expected).abs() < 0.05,
|
||||
"expected {expected}, got {actual}"
|
||||
);
|
||||
};
|
||||
|
||||
close(number(0, 1), 60.0);
|
||||
close(number(0, 3), 448.0);
|
||||
close(number(1, 1), 20.0);
|
||||
close(number(1, 2), 20.0);
|
||||
close(number(1, 3), 448.0);
|
||||
close(number(2, 1), 20.0);
|
||||
close(number(2, 3), 231.0);
|
||||
close(number(3, 1), 20.0);
|
||||
close(number(3, 3), 31.0);
|
||||
close(number(4, 0), 1127.0);
|
||||
close(number(4, 1), -267.0);
|
||||
close(number(4, 2), -267.0);
|
||||
close(number(4, 3), -496.0);
|
||||
for row in 0..rows.len() {
|
||||
close(number(row, 4), 20.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Chromium 150 horizontal reference for the same constraint algorithm:
|
||||
/// the sticky subtree pins at x=20, remains distinct from fixed, then
|
||||
/// leaves with its 500px containing block at the right boundary.
|
||||
#[cfg(feature = "render")]
|
||||
#[test]
|
||||
fn root_scroll_sticky_supports_the_inline_axis() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div style="box-sizing:border-box;margin-left:40px;width:500px;height:100px;padding:10px;border:4px solid">
|
||||
<div id="sticky" style="box-sizing:border-box;position:sticky;left:20px;width:60px;height:30px;margin:6px">
|
||||
<div id="child" style="width:10px;height:10px"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width:1600px;height:600px"></div>
|
||||
<div id="fixed" style="position:fixed;left:20px;top:100px;width:60px;height:30px"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(800.0, 513.0);
|
||||
rt.run_page_init();
|
||||
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"
|
||||
const sticky = document.getElementById("sticky");
|
||||
const child = document.getElementById("child");
|
||||
const fixed = document.getElementById("fixed");
|
||||
const sample = x => {
|
||||
window.scrollTo(x, 0);
|
||||
return [
|
||||
window.scrollX,
|
||||
sticky.getBoundingClientRect().left,
|
||||
child.getBoundingClientRect().left,
|
||||
fixed.getBoundingClientRect().left,
|
||||
];
|
||||
};
|
||||
return [sample(0), sample(100), sample(400), sample(800)];
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let rows = result.as_array().unwrap();
|
||||
let expected = [
|
||||
[0.0, 60.0, 60.0, 20.0],
|
||||
[100.0, 20.0, 20.0, 20.0],
|
||||
[400.0, 20.0, 20.0, 20.0],
|
||||
[800.0, -340.0, -340.0, 20.0],
|
||||
];
|
||||
for (row, expected) in rows.iter().zip(expected) {
|
||||
for (actual, expected) in row.as_array().unwrap().iter().zip(expected) {
|
||||
let actual = actual.as_f64().unwrap();
|
||||
assert!(
|
||||
(actual - expected).abs() < 0.05,
|
||||
"expected {expected}, got {actual}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[test]
|
||||
fn rendered_layout_cache_is_invalidated_by_style_mutations() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div id="box" style="height:300px;width:40px"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(200.0, 100.0);
|
||||
rt.run_page_init();
|
||||
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"
|
||||
const box = document.getElementById("box");
|
||||
const before = [
|
||||
document.documentElement.scrollHeight,
|
||||
box.getBoundingClientRect().height,
|
||||
];
|
||||
box.setAttribute("style", "height:900px;width:80px");
|
||||
const after = [
|
||||
document.documentElement.scrollHeight,
|
||||
box.getBoundingClientRect().height,
|
||||
box.getBoundingClientRect().width,
|
||||
];
|
||||
return [before, after];
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let values = result.as_array().expect("result");
|
||||
let before = values[0].as_array().expect("before");
|
||||
let after = values[1].as_array().expect("after");
|
||||
assert!(after[0].as_f64().unwrap() > before[0].as_f64().unwrap());
|
||||
assert_eq!(before[1].as_f64(), Some(300.0));
|
||||
assert_eq!(after[1].as_f64(), Some(900.0));
|
||||
assert_eq!(after[2].as_f64(), Some(80.0));
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn rendered_window_scroll_events_require_actual_movement() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div style="height:1000px"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(320.0, 200.0);
|
||||
rt.run_page_init();
|
||||
|
||||
let moved = rt
|
||||
.evaluate_for_cdp(
|
||||
r#"
|
||||
new Promise(resolve => {
|
||||
let win = 0, doc = 0;
|
||||
window.addEventListener("scroll", () => win++);
|
||||
document.addEventListener("scroll", () => doc++);
|
||||
window.scrollTo(0, 100);
|
||||
setTimeout(() => resolve([win, doc, window.scrollY]), 5);
|
||||
})
|
||||
"#,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(moved.value.unwrap(), serde_json::json!([1, 1, 100]));
|
||||
|
||||
rt.evaluate("window.scrollTo(0, 99999)").unwrap();
|
||||
rt.run_event_loop_bounded(20).await.unwrap();
|
||||
let no_op = rt
|
||||
.evaluate_for_cdp(
|
||||
r#"
|
||||
new Promise(resolve => {
|
||||
let win = 0, doc = 0;
|
||||
window.addEventListener("scroll", () => win++);
|
||||
document.addEventListener("scroll", () => doc++);
|
||||
const before = window.scrollY;
|
||||
window.scrollTo(0, 99999);
|
||||
setTimeout(() => resolve([win, doc, before, window.scrollY]), 5);
|
||||
})
|
||||
"#,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let values = no_op.value.unwrap();
|
||||
let values = values.as_array().expect("array");
|
||||
assert_eq!(&values[0..2], &serde_json::json!([0, 0]).as_array().unwrap()[..]);
|
||||
assert_eq!(values[2], values[3]);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn intersection_observer_tracks_viewport_threshold_crossings() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div style="height:150px"></div>
|
||||
<div id="target" style="height:100px"></div>
|
||||
<div style="height:300px"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(200.0, 100.0);
|
||||
rt.run_page_init();
|
||||
|
||||
let result = rt
|
||||
.evaluate_for_cdp(
|
||||
r#"
|
||||
new Promise(resolve => {
|
||||
const records = [];
|
||||
const target = document.getElementById("target");
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
records.push([
|
||||
entry.isIntersecting,
|
||||
Math.round(entry.intersectionRatio * 100) / 100,
|
||||
Math.round(entry.boundingClientRect.top),
|
||||
Math.round(entry.intersectionRect.height),
|
||||
]);
|
||||
}
|
||||
}, { threshold: [0, 0.5, 1] });
|
||||
observer.observe(target);
|
||||
setTimeout(() => window.scrollTo(0, 100), 5);
|
||||
setTimeout(() => window.scrollTo(0, 260), 15);
|
||||
setTimeout(() => resolve(records), 30);
|
||||
})
|
||||
"#,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result.value.unwrap(),
|
||||
serde_json::json!([
|
||||
[false, 0, 150, 0],
|
||||
[true, 0.5, 50, 50],
|
||||
[false, 0, -110, 0],
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn intersection_observer_honors_root_margin_zero_area_and_no_fake_refires() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0;position:relative">
|
||||
<div style="height:110px"></div>
|
||||
<div id="margin-target" style="height:10px"></div>
|
||||
<div id="zero" style="position:absolute;left:20px;top:50px;width:0;height:0"></div>
|
||||
<div id="root" style="height:20px;overflow:auto"></div>
|
||||
<div style="height:300px"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(200.0, 100.0);
|
||||
rt.run_page_init();
|
||||
|
||||
let result = rt
|
||||
.evaluate_for_cdp(
|
||||
r#"
|
||||
new Promise(resolve => {
|
||||
const marginRecords = [], zeroRecords = [];
|
||||
const marginObserver = new IntersectionObserver(
|
||||
entries => marginRecords.push(...entries.map(entry => [
|
||||
entry.isIntersecting,
|
||||
entry.intersectionRatio,
|
||||
entry.rootBounds.bottom,
|
||||
])),
|
||||
{ rootMargin: "0px 0px 20px", threshold: [0, 1] }
|
||||
);
|
||||
const zeroObserver = new IntersectionObserver(
|
||||
entries => zeroRecords.push(...entries.map(entry => [
|
||||
entry.isIntersecting,
|
||||
entry.intersectionRatio,
|
||||
]))
|
||||
);
|
||||
marginObserver.observe(document.getElementById("margin-target"));
|
||||
zeroObserver.observe(document.getElementById("zero"));
|
||||
let unsupported = "";
|
||||
try {
|
||||
new IntersectionObserver(() => {}, {
|
||||
root: document.getElementById("root")
|
||||
});
|
||||
} catch (error) {
|
||||
unsupported = error.name;
|
||||
}
|
||||
setTimeout(() => resolve([
|
||||
marginRecords, zeroRecords, unsupported,
|
||||
marginObserver.rootMargin, marginObserver.thresholds,
|
||||
]), 200);
|
||||
})
|
||||
"#,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result.value.unwrap(),
|
||||
serde_json::json!([
|
||||
[[true, 1, 120]],
|
||||
[[true, 1]],
|
||||
"NotSupportedError",
|
||||
"0px 0px 20px 0px",
|
||||
[0, 1],
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn intersection_observer_recomputes_after_style_mutation_and_resize() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div id="spacer" style="height:150px"></div>
|
||||
<div id="target" style="height:20px"></div>
|
||||
<div style="height:300px"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(200.0, 100.0);
|
||||
rt.run_page_init();
|
||||
rt.execute_script(
|
||||
"intersection-mutation",
|
||||
r#"
|
||||
globalThis.__ioRecords = [];
|
||||
globalThis.__io = new IntersectionObserver(entries => {
|
||||
__ioRecords.push(...entries.map(entry => [
|
||||
entry.isIntersecting,
|
||||
Math.round(entry.boundingClientRect.top),
|
||||
]));
|
||||
});
|
||||
__io.observe(document.getElementById("target"));
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(10).await.unwrap();
|
||||
|
||||
rt.evaluate(
|
||||
r#"document.getElementById("spacer").setAttribute("style", "height:120px")"#,
|
||||
)
|
||||
.unwrap();
|
||||
rt.run_event_loop_bounded(10).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate("__ioRecords").unwrap(),
|
||||
serde_json::json!([[false, 150]])
|
||||
);
|
||||
|
||||
rt.set_viewport(200.0, 160.0);
|
||||
rt.run_event_loop_bounded(20).await.unwrap();
|
||||
assert_eq!(
|
||||
rt.evaluate("__ioRecords").unwrap(),
|
||||
serde_json::json!([[false, 150], [true, 120]])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[test]
|
||||
fn scroll_into_view_aligns_the_root_viewport_and_clamps() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div style="height:300px"></div>
|
||||
<div id="target" style="height:40px"></div>
|
||||
<div style="height:340px"></div>
|
||||
<div id="bottom" style="height:20px"></div>
|
||||
<div id="fixed" style="position:fixed;top:10px;height:20px"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(200.0, 100.0);
|
||||
rt.run_page_init();
|
||||
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"
|
||||
const target = document.getElementById("target");
|
||||
const bottom = document.getElementById("bottom");
|
||||
const fixed = document.getElementById("fixed");
|
||||
target.scrollIntoView();
|
||||
const start = scrollY;
|
||||
scrollTo(0, 0);
|
||||
target.scrollIntoView({ block: "center" });
|
||||
const center = scrollY;
|
||||
scrollTo(0, 0);
|
||||
target.scrollIntoView({ block: "end" });
|
||||
const end = scrollY;
|
||||
scrollTo(0, 0);
|
||||
target.scrollIntoView({ block: "nearest" });
|
||||
const nearestOutside = scrollY;
|
||||
scrollTo(0, 250);
|
||||
target.scrollIntoView({ block: "nearest" });
|
||||
const nearestVisible = scrollY;
|
||||
fixed.scrollIntoView();
|
||||
const afterFixed = scrollY;
|
||||
bottom.scrollIntoView({ block: "start" });
|
||||
const clamped = scrollY;
|
||||
const max = document.documentElement.scrollHeight - innerHeight;
|
||||
return [
|
||||
start, center, end, nearestOutside, nearestVisible,
|
||||
afterFixed, clamped, max,
|
||||
];
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
serde_json::json!([300, 270, 240, 240, 250, 250, 600, 600])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn scroll_into_view_emits_events_only_when_the_root_moves() {
|
||||
let dom = parse_html(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<div style="height:300px"></div>
|
||||
<div id="target" style="height:40px"></div>
|
||||
<div style="height:300px"></div>
|
||||
</body></html>"#,
|
||||
);
|
||||
let mut rt = ObscuraJsRuntime::new();
|
||||
rt.set_dom(dom);
|
||||
rt.set_viewport(200.0, 100.0);
|
||||
rt.run_page_init();
|
||||
|
||||
rt.evaluate("window.scrollTo(0, 250)").unwrap();
|
||||
rt.run_event_loop_bounded(20).await.unwrap();
|
||||
let no_op = rt
|
||||
.evaluate_for_cdp(
|
||||
r#"
|
||||
new Promise(resolve => {
|
||||
let win = 0, doc = 0;
|
||||
window.addEventListener("scroll", () => win++);
|
||||
document.addEventListener("scroll", () => doc++);
|
||||
document.getElementById("target").scrollIntoView({ block: "nearest" });
|
||||
setTimeout(() => resolve([win, doc, scrollY]), 5);
|
||||
})
|
||||
"#,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(no_op.value.unwrap(), serde_json::json!([0, 0, 250]));
|
||||
|
||||
rt.evaluate("window.scrollTo(0, 0)").unwrap();
|
||||
rt.run_event_loop_bounded(20).await.unwrap();
|
||||
let moved = rt
|
||||
.evaluate_for_cdp(
|
||||
r#"
|
||||
new Promise(resolve => {
|
||||
let win = 0, doc = 0;
|
||||
window.addEventListener("scroll", () => win++);
|
||||
document.addEventListener("scroll", () => doc++);
|
||||
document.getElementById("target").scrollIntoView({ block: "center" });
|
||||
setTimeout(() => resolve([win, doc, scrollY]), 5);
|
||||
})
|
||||
"#,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(moved.value.unwrap(), serde_json::json!([1, 1, 270]));
|
||||
}
|
||||
|
||||
/// Issue #469: FILTER_SKIP leaves a skipped node's children eligible, so
|
||||
/// firstChild()/lastChild() must descend into them. FILTER_REJECT must not.
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user