From 478f888a6bd6edfc9b7400d9a9033f4a83678f28 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:58:33 +0000 Subject: [PATCH] Implement renderer-backed nested scrolling --- crates/obscura-js/js/bootstrap.js | 133 ++++++-- crates/obscura-js/src/ops.rs | 244 ++++++++++++++- crates/obscura-js/src/runtime.rs | 428 ++++++++++++++++++++++++- crates/obscura-render/src/dom.rs | 320 +++++++++++++++++++ crates/obscura-render/src/lib.rs | 38 ++- crates/obscura-render/src/paint.rs | 483 ++++++++++++++++++++++++++++- 6 files changed, 1580 insertions(+), 66 deletions(-) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 03e7248..11221dc 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -2960,14 +2960,30 @@ class Element extends Node { return rect; } get scrollWidth() { - if (!this._isViewportRoot()) return 100; - const metrics = this._renderScrollMetrics(); - return metrics ? metrics.scrollWidth : (globalThis.innerWidth || 1280); + if (this._isViewportRoot()) { + const metrics = this._renderScrollMetrics(); + return metrics + ? Math.round(Math.max(0, metrics.scrollWidth || 0)) + : (globalThis.innerWidth || 1280); + } + const metrics = this._renderElementScrollMetrics(); + if (metrics !== undefined) { + return metrics ? Math.round(Math.max(0, metrics.scrollWidth || 0)) : 0; + } + return 100; } get scrollHeight() { - if (!this._isViewportRoot()) return 20; - const metrics = this._renderScrollMetrics(); - return metrics ? metrics.scrollHeight : (globalThis.innerHeight || 720); + if (this._isViewportRoot()) { + const metrics = this._renderScrollMetrics(); + return metrics + ? Math.round(Math.max(0, metrics.scrollHeight || 0)) + : (globalThis.innerHeight || 720); + } + const metrics = this._renderElementScrollMetrics(); + if (metrics !== undefined) { + return metrics ? Math.round(Math.max(0, metrics.scrollHeight || 0)) : 0; + } + return 20; } _isViewportRoot() { const t = this.tagName; @@ -2982,6 +2998,17 @@ class Element extends Node { return null; } } + _renderElementScrollMetrics() { + if (typeof Deno.core.ops.op_element_scroll_metrics !== 'function') return undefined; + try { + const raw = Deno.core.ops.op_element_scroll_metrics(String(this._nid | 0)); + if (!raw) return null; + const metrics = JSON.parse(raw); + return metrics && metrics.hasBox !== false ? metrics : null; + } catch (_e) { + return null; + } + } _renderScrollOffset() { if (typeof Deno.core.ops.op_scroll_offset !== 'function') return null; try { @@ -3000,14 +3027,25 @@ class Element extends Node { 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. + _setRenderElementScroll(x, y) { + if (typeof Deno.core.ops.op_element_scroll_to !== 'function') return null; + try { + const raw = Deno.core.ops.op_element_scroll_to(String(this._nid | 0), +x || 0, +y || 0); + return raw ? JSON.parse(raw) : null; + } catch (_e) { + return null; + } + } + // Render builds clamp both viewport and element scroll areas against the + // exact overflow used by geometry and paint. Non-render builds retain the + // synthetic compatibility state. get scrollTop() { if (this._isViewportRoot()) { const offset = this._renderScrollOffset(); if (offset) return offset.y || 0; + } else { + const metrics = this._renderElementScrollMetrics(); + if (metrics !== undefined) return metrics ? (metrics.y || 0) : 0; } return this._scrollTop || 0; } @@ -3020,11 +3058,18 @@ class Element extends Node { const offset = this._renderScrollOffset(); const updated = offset && this._setRenderScroll(offset.x, nv); if (updated) actual = updated.y || 0; + } else { + const metrics = this._renderElementScrollMetrics(); + if (metrics !== undefined) { + actual = metrics ? (metrics.y || 0) : 0; + const updated = metrics && this._setRenderElementScroll(metrics.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() && + if (changed && typeof globalThis.__obscura_recompute_intersections === "function") { globalThis.__obscura_recompute_intersections(); } @@ -3033,6 +3078,9 @@ class Element extends Node { if (this._isViewportRoot()) { const offset = this._renderScrollOffset(); if (offset) return offset.x || 0; + } else { + const metrics = this._renderElementScrollMetrics(); + if (metrics !== undefined) return metrics ? (metrics.x || 0) : 0; } return this._scrollLeft || 0; } @@ -3045,11 +3093,18 @@ class Element extends Node { const offset = this._renderScrollOffset(); const updated = offset && this._setRenderScroll(nv, offset.y); if (updated) actual = updated.x || 0; + } else { + const metrics = this._renderElementScrollMetrics(); + if (metrics !== undefined) { + actual = metrics ? (metrics.x || 0) : 0; + const updated = metrics && this._setRenderElementScroll(nv, metrics.y); + if (updated) actual = updated.x || 0; + } } const changed = actual !== old; this._scrollLeft = actual; if (changed && !this._scrollSuppress) this._fireScroll(); - if (changed && this._isViewportRoot() && + if (changed && typeof globalThis.__obscura_recompute_intersections === "function") { globalThis.__obscura_recompute_intersections(); } @@ -3180,26 +3235,66 @@ class Element extends Node { let left, top; if (x !== null && typeof x === 'object') { left = x.left; top = x.top; } else { left = x; top = y; } + const oldLeft = this.scrollLeft, oldTop = this.scrollTop; + let native = false, updated = null; + if (this._isViewportRoot()) { + const offset = this._renderScrollOffset(); + if (offset) { + native = true; + updated = this._setRenderScroll( + left === undefined ? offset.x : (+left || 0), + top === undefined ? offset.y : (+top || 0), + ); + } + } else { + const metrics = this._renderElementScrollMetrics(); + if (metrics !== undefined) { + native = true; + updated = metrics + ? this._setRenderElementScroll( + left === undefined ? metrics.x : (+left || 0), + top === undefined ? metrics.y : (+top || 0), + ) + : { x: 0, y: 0 }; + } + } + if (native) { + const actualLeft = updated ? (updated.x || 0) : oldLeft; + const actualTop = updated ? (updated.y || 0) : oldTop; + this._scrollLeft = actualLeft; + this._scrollTop = actualTop; + if (actualLeft !== oldLeft || actualTop !== oldTop) { + if (typeof globalThis.__obscura_recompute_intersections === "function") { + globalThis.__obscura_recompute_intersections(); + } + this._fireScroll(); + } + return; + } this._scrollSuppress = true; if (left !== undefined) this.scrollLeft = +left || 0; if (top !== undefined) this.scrollTop = +top || 0; this._scrollSuppress = false; - this._fireScroll(); + if (this.scrollLeft !== oldLeft || this.scrollTop !== oldTop) this._fireScroll(); } scroll(x, y) { this.scrollTo(x, y); } scrollBy(x, y) { let dl, dt; if (x !== null && typeof x === 'object') { dl = x.left; dt = x.top; } else { dl = x; dt = y; } - this._scrollSuppress = true; - this.scrollLeft = (this.scrollLeft || 0) + (+dl || 0); - this.scrollTop = (this.scrollTop || 0) + (+dt || 0); - this._scrollSuppress = false; - this._fireScroll(); + this.scrollTo({ + left: (this.scrollLeft || 0) + (+dl || 0), + top: (this.scrollTop || 0) + (+dt || 0), + }); } _fireScroll() { + if (this._scrollEventPending) return; + this._scrollEventPending = true; const self = this; - setTimeout(() => { try { self.dispatchEvent(new Event('scroll', { bubbles: false })); } catch (e) {} }, 0); + setTimeout(() => { + self._scrollEventPending = false; + try { self.dispatchEvent(new Event('scroll', { bubbles: false })); } catch (e) {} + }, 0); } animate(keyframes, options) { const duration = typeof options === 'number' ? options : (options?.duration || 0); diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index d8f99c4..1f0908a 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -129,6 +129,15 @@ pub struct ObscuraState { /// read by CSSOM geometry and screenshot paint. #[cfg(feature = "render")] pub scroll_offset: (f32, f32), + /// Element scroll offsets persist by DOM identity across relayout. Dense + /// renderer ScrollIds are rebuild-local and are resolved only into the + /// cached snapshot below. + #[cfg(feature = "render")] + pub element_scroll_offsets: HashMap, + #[cfg(feature = "render")] + pub scroll_generation: u64, + #[cfg(feature = "render")] + pub resolved_scroll: Option<(u64, obscura_render::ResolvedScrollState)>, /// Window-global import-map state shared by parser-discovered scripts, /// dynamically inserted import maps, and the module loader. pub(crate) import_map: Rc>, @@ -172,6 +181,12 @@ impl ObscuraState { viewport: (1280.0, 720.0), #[cfg(feature = "render")] scroll_offset: (0.0, 0.0), + #[cfg(feature = "render")] + element_scroll_offsets: HashMap::new(), + #[cfg(feature = "render")] + scroll_generation: 0, + #[cfg(feature = "render")] + resolved_scroll: None, import_map: Rc::new(RefCell::new(ImportMap::default())), already_started_scripts: RefCell::new(HashSet::new()), } @@ -330,22 +345,81 @@ 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 shared = state.borrow::().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" - ) { + { + // Scroll offsets belong to a node at its current tree position. + // Temporary box/style loss keeps that latent state, but DOM removal, + // reparenting, and subtree replacement reset the affected identities, + // matching Chromium's lifecycle behavior. + let reset_nodes = { + let state = shared.borrow(); + let mut roots = Vec::new(); + if let Some(dom) = state.dom.as_ref() { + match cmd.as_str() { + "remove_child" => { + if let Ok(node) = arg1.parse::() { + roots.push(NodeId::new(node)); + } + } + "append_child" => { + if let Ok(node) = arg2.parse::() { + let node = NodeId::new(node); + if dom.get_node(node).and_then(|node| node.parent).is_some() { + roots.push(node); + } + } + } + "insert_before" => { + if let Ok(node) = arg1.parse::() { + let node = NodeId::new(node); + if dom.get_node(node).and_then(|node| node.parent).is_some() { + roots.push(node); + } + } + } + "set_inner_html" | "set_inner_html_context" | "set_text_content" => { + if let Ok(node) = arg1.parse::() { + roots.extend(dom.children(NodeId::new(node))); + } + } + _ => {} + } + roots + .into_iter() + .flat_map(|root| { + let mut nodes = vec![root]; + nodes.extend(dom.descendants(root)); + nodes + }) + .collect::>() + } else { + HashSet::new() + } + }; // 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().prepared_render = None; + let mut state = shared.borrow_mut(); + if !reset_nodes.is_empty() { + state + .element_scroll_offsets + .retain(|node, _| !reset_nodes.contains(node)); + state.scroll_generation = state.scroll_generation.wrapping_add(1); + } + 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" + ) { + state.prepared_render = None; + state.resolved_scroll = None; + } } let gs = shared.borrow(); let dom = match &gs.dom { @@ -2440,6 +2514,7 @@ fn op_set_dynamic_fonts(state: &OpState, #[string] registrations: &str) -> bool if state.dynamic_fonts != fonts { state.dynamic_fonts = fonts; state.prepared_render = None; + state.resolved_scroll = None; } true } @@ -2482,6 +2557,8 @@ pub fn build_extension() -> Extension { ops.push(op_computed_style()); ops.push(op_css_supports()); ops.push(op_layout_metrics()); + ops.push(op_element_scroll_metrics()); + ops.push(op_element_scroll_to()); ops.push(op_scroll_offset()); ops.push(op_scroll_to()); } @@ -2531,10 +2608,53 @@ pub(crate) fn ensure_prepared_render( )? }; state.prepared_render = Some(prepared); + state.resolved_scroll = None; } state.prepared_render.as_ref() } +#[cfg(feature = "render")] +pub(crate) fn ensure_resolved_scroll(state: &mut ObscuraState) -> Option<()> { + ensure_prepared_render(state)?; + if state + .resolved_scroll + .as_ref() + .is_some_and(|(generation, _)| *generation == state.scroll_generation) + { + return Some(()); + } + + let valid = state + .prepared_render + .as_ref()? + .scroll_container_nodes() + .collect::>(); + let snapshot = { + let dom = state.dom.as_ref()?; + state.prepared_render.as_ref()?.resolve_scroll_state( + dom, + state.scroll_offset, + &state.element_scroll_offsets, + ) + }; + state.scroll_offset = snapshot.root_offset(); + for node in valid { + let offset = state + .prepared_render + .as_ref()? + .element_scroll_metrics(node, &snapshot) + .map(|metrics| metrics.offset) + .unwrap_or((0.0, 0.0)); + if offset == (0.0, 0.0) { + state.element_scroll_offsets.remove(&node); + } else { + state.element_scroll_offsets.insert(node, offset); + } + } + state.resolved_scroll = Some((state.scroll_generation, snapshot)); + Some(()) +} + /// Probe one ordinary `` through the renderer's page-scoped resource /// cache. This is deliberately a synchronous render-only op: the current /// renderer loader is synchronous too, and sharing it is what guarantees that @@ -2636,9 +2756,14 @@ fn op_image_metadata(state: &OpState, nid: u32, cached_only: bool) -> String { #[cfg(feature = "render")] pub(crate) fn clamp_scroll_offset(state: &mut ObscuraState, requested: (f32, f32)) -> (f32, f32) { - state.scroll_offset = ensure_prepared_render(state) + let clamped = ensure_prepared_render(state) .map(|prepared| prepared.clamp_scroll(requested)) .unwrap_or((0.0, 0.0)); + if state.scroll_offset != clamped { + state.scroll_offset = clamped; + state.scroll_generation = state.scroll_generation.wrapping_add(1); + state.resolved_scroll = None; + } state.scroll_offset } @@ -2659,15 +2784,20 @@ fn op_layout_geometry(state: &OpState, #[string] nid_str: String) -> String { let nid: u32 = nid_str.parse().unwrap_or(0); let nid = obscura_dom::tree::NodeId::new(nid); let mut gs = shared.borrow_mut(); - let scroll = gs.scroll_offset; - if let Some(prepared) = ensure_prepared_render(&mut gs) { - let Some(rect) = prepared.viewport_rect(nid, scroll) else { + if ensure_resolved_scroll(&mut gs).is_some() { + let Some((_, scroll)) = gs.resolved_scroll.as_ref() else { + return String::new(); + }; + let Some(prepared) = gs.prepared_render.as_ref() else { + return String::new(); + }; + let Some(rect) = prepared.viewport_rect_with_scroll(nid, scroll) else { return String::new(); }; let Some((client_width, client_height)) = prepared.client_size(nid) else { return String::new(); }; - let Some(client_rects) = prepared.viewport_client_rects(nid, scroll) else { + let Some(client_rects) = prepared.viewport_client_rects_with_scroll(nid, scroll) else { return String::new(); }; let client_rects = client_rects @@ -2755,6 +2885,86 @@ fn op_layout_metrics(state: &OpState) -> String { ) } +#[cfg(feature = "render")] +#[op2] +#[string] +fn op_element_scroll_metrics(state: &OpState, #[string] nid_str: String) -> String { + let shared = state.borrow::().clone(); + let nid = NodeId::new(nid_str.parse().unwrap_or(0)); + let mut gs = shared.borrow_mut(); + if ensure_resolved_scroll(&mut gs).is_none() { + return String::new(); + } + let Some((_, scroll)) = gs.resolved_scroll.as_ref() else { + return String::new(); + }; + let Some(metrics) = gs + .prepared_render + .as_ref() + .and_then(|prepared| prepared.element_scroll_metrics(nid, scroll)) + else { + // The op exists in render builds, so an unboxed/detached node must not + // fall through to bootstrap's synthetic non-render metrics. + return r#"{"scrollWidth":0,"scrollHeight":0,"clientWidth":0,"clientHeight":0,"x":0,"y":0,"maxX":0,"maxY":0,"hasBox":false}"#.to_string(); + }; + serde_json::json!({ + "scrollWidth": metrics.content_size.0, + "scrollHeight": metrics.content_size.1, + "clientWidth": metrics.client_size.0, + "clientHeight": metrics.client_size.1, + "x": metrics.offset.0, + "y": metrics.offset.1, + "maxX": metrics.max_offset.0, + "maxY": metrics.max_offset.1, + "hasBox": true, + }) + .to_string() +} + +#[cfg(feature = "render")] +#[op2] +#[string] +fn op_element_scroll_to( + state: &OpState, + #[string] nid_str: String, + x: f64, + y: f64, +) -> String { + let shared = state.borrow::().clone(); + let nid = NodeId::new(nid_str.parse().unwrap_or(0)); + let mut gs = shared.borrow_mut(); + if ensure_resolved_scroll(&mut gs).is_none() { + return String::new(); + } + let current = gs.resolved_scroll.as_ref().and_then(|(_, scroll)| { + gs.prepared_render + .as_ref()? + .element_scroll_metrics(nid, scroll) + }); + let Some(current) = current else { + return String::new(); + }; + let clamp = |value: f64, max: f32| { + if value.is_finite() { + obscura_render::quantize_scroll_value(value as f32, 1.0).clamp(0.0, max) + } else { + 0.0 + } + }; + let requested = (clamp(x, current.max_offset.0), clamp(y, current.max_offset.1)); + if requested != current.offset { + if requested == (0.0, 0.0) { + gs.element_scroll_offsets.remove(&nid); + } else { + gs.element_scroll_offsets.insert(nid, requested); + } + gs.scroll_generation = gs.scroll_generation.wrapping_add(1); + gs.resolved_scroll = None; + return format!("{{\"x\":{},\"y\":{}}}", requested.0, requested.1); + } + format!("{{\"x\":{},\"y\":{}}}", current.offset.0, current.offset.1) +} + #[cfg(feature = "render")] #[op2] #[string] diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index ad67771..2afc3cf 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -12,7 +12,11 @@ pub use deno_core::v8::IsolateHandle; use crate::import_map::ImportMap; use crate::module_loader::ObscuraModuleLoader; #[cfg(feature = "render")] -use crate::ops::{clamp_scroll_offset, document_base_url, ensure_prepared_render}; +use crate::ops::{ + clamp_scroll_offset, document_base_url, ensure_resolved_scroll, +}; +#[cfg(all(test, feature = "render"))] +use crate::ops::ensure_prepared_render; use crate::ops::{ build_extension, node_is_script, ObscuraState, StoredNetworkResponseBody, }; @@ -227,6 +231,9 @@ impl ObscuraJsRuntime { gs.render_resources = obscura_render::RenderResourceCache::default(); gs.dynamic_fonts.clear(); gs.scroll_offset = (0.0, 0.0); + gs.element_scroll_offsets.clear(); + gs.scroll_generation = 0; + gs.resolved_scroll = None; } } @@ -240,6 +247,7 @@ impl ObscuraJsRuntime { // present. Keep already-fetched absolute bytes, but rebuild // candidate selection/layout against the new base. state.prepared_render = None; + state.resolved_scroll = None; } } } @@ -340,6 +348,7 @@ impl ObscuraJsRuntime { let mut state = self.state.borrow_mut(); state.viewport = (width as f32, height as f32); state.prepared_render = None; + state.resolved_scroll = None; } let _ = self.runtime.execute_script( "", @@ -380,20 +389,16 @@ impl ObscuraJsRuntime { if viewport != state.viewport || base_url != effective_base.as_deref() { return None; } - ensure_prepared_render(&mut state)?; - let requested = state.scroll_offset; - let scroll = state - .prepared_render - .as_ref() - .map(|prepared| prepared.clamp_scroll(requested))?; - state.scroll_offset = scroll; + ensure_resolved_scroll(&mut state)?; let ObscuraState { dom, prepared_render, render_resources, + resolved_scroll, .. } = &mut *state; - obscura_render::screenshot_prepared( + let (_, scroll) = resolved_scroll.as_ref()?; + obscura_render::screenshot_prepared_with_scroll( dom.as_ref()?, prepared_render.as_mut()?, render_resources, @@ -1211,6 +1216,8 @@ impl ObscuraJsRuntime { state.prepared_render = None; state.render_resources = obscura_render::RenderResourceCache::default(); state.dynamic_fonts.clear(); + state.element_scroll_offsets.clear(); + state.resolved_scroll = None; } state.dom.take() } @@ -3254,7 +3261,11 @@ mod tests { #[test] fn element_scroll_methods_update_scroll_offsets() { - let mut rt = setup_runtime(r#"
"#); + let mut rt = setup_runtime( + r#"
+
+
"#, + ); let result = rt .evaluate( r#" @@ -3956,6 +3967,403 @@ mod tests { assert!(values[12..].iter().all(|value| value == &serde_json::json!(true))); } + #[cfg(feature = "render")] + #[test] + fn nested_scroll_metrics_geometry_pixels_and_relayout_share_one_state() { + let dom = parse_html( + r#" +
+
+
+
+
+ "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(360.0, 240.0); + rt.run_page_init(); + + let top = rt + .screenshot_prepared((360.0, 240.0), Some("about:blank")) + .expect("top screenshot"); + let result = rt + .evaluate( + r#" + const outer = document.getElementById('outer'); + const inner = document.getElementById('inner'); + const target = document.getElementById('target'); + const before = { + outer: outer.getBoundingClientRect(), + inner: inner.getBoundingClientRect(), + target: target.getBoundingClientRect(), + }; + outer.scrollTo(9999, 9999); + inner.scrollTo(9999, 9999); + const after = { + outer: outer.getBoundingClientRect(), + inner: inner.getBoundingClientRect(), + target: target.getBoundingClientRect(), + }; + const first = [target.getBoundingClientRect().left, target.getBoundingClientRect().top]; + outer.scrollTo(0, 0); inner.scrollTo(0, 0); + outer.scrollTo(9999, 9999); inner.scrollTo(9999, 9999); + const repeated = [target.getBoundingClientRect().left, target.getBoundingClientRect().top]; + return { + outerMetrics: [outer.clientWidth, outer.clientHeight, outer.scrollWidth, outer.scrollHeight], + innerMetrics: [inner.clientWidth, inner.clientHeight, inner.scrollWidth, inner.scrollHeight], + offsets: [outer.scrollLeft, outer.scrollTop, inner.scrollLeft, inner.scrollTop], + outerDelta: [after.outer.left - before.outer.left, after.outer.top - before.outer.top], + innerDelta: [after.inner.left - before.inner.left, after.inner.top - before.inner.top], + targetDelta: [after.target.left - before.target.left, after.target.top - before.target.top], + repeated: [first[0] === repeated[0], first[1] === repeated[1]], + }; + "#, + ) + .expect("nested scroll state"); + assert_eq!(result["outerMetrics"], serde_json::json!([112, 92, 220, 200])); + assert_eq!(result["innerMetrics"], serde_json::json!([220, 200, 330, 300])); + assert_eq!(result["offsets"], serde_json::json!([108, 108, 110, 100])); + assert_eq!(result["outerDelta"], serde_json::json!([0, 0])); + assert_eq!(result["innerDelta"], serde_json::json!([-108, -108])); + assert_eq!(result["targetDelta"], serde_json::json!([-218, -208])); + assert_eq!(result["repeated"], serde_json::json!([true, true])); + + let scrolled = rt + .screenshot_prepared((360.0, 240.0), Some("about:blank")) + .expect("scrolled screenshot"); + let scrolled_repeat = rt + .screenshot_prepared((360.0, 240.0), Some("about:blank")) + .expect("repeat screenshot"); + assert_ne!(top, scrolled, "nested scroll must move painted pixels"); + assert_eq!(scrolled, scrolled_repeat, "capture must not accumulate movement"); + + let retained = rt + .evaluate( + r#" + const outer = document.getElementById('outer'); + const inner = document.getElementById('inner'); + outer.setAttribute('data-relayout', '1'); + return [outer.scrollLeft, outer.scrollTop, inner.scrollLeft, inner.scrollTop]; + "#, + ) + .expect("retained offsets"); + assert_eq!(retained, serde_json::json!([108, 108, 110, 100])); + + let reclamped = rt + .evaluate( + r#" + const outer = document.getElementById('outer'); + const inner = document.getElementById('inner'); + inner.setAttribute('style', 'width:150px;height:120px;overflow:hidden;position:relative;background:blue'); + document.getElementById('target').setAttribute( + 'style', + 'position:absolute;left:100px;top:80px;width:30px;height:20px;background:lime' + ); + return [outer.scrollLeft, outer.scrollTop, inner.scrollLeft, inner.scrollTop]; + "#, + ) + .expect("reclamped offsets"); + assert_eq!(reclamped, serde_json::json!([38, 28, 0, 0])); + + rt.evaluate("(function(){ document.getElementById('outer').remove(); document.documentElement.getBoundingClientRect(); return true; })()") + .expect("remove scroller"); + assert!( + rt.state.borrow().element_scroll_offsets.is_empty(), + "removed scroll containers must be pruned after relayout" + ); + } + + /// Chromium 150 oracle for CSSOM scrolling overflow. Visible and clip + /// boxes expose descendant overflow but cannot move; an actual scrolling + /// box includes trailing padding. A clip boundary suppresses propagation + /// only on its clipped axis, and ordinary inline boxes expose zero metrics. + #[cfg(feature = "render")] + #[test] + fn element_scroll_metrics_match_chromium_overflow_oracles() { + let dom = parse_html( + r#" + +
+
+
+
+
+
+
+
+
+
+ long inline text + "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(420.0, 700.0); + rt.run_page_init(); + + let result = rt + .evaluate( + r#" + const visible = document.getElementById('visible'); + const clip = document.getElementById('clip'); + const hidden = document.getElementById('hidden'); + const outer = document.getElementById('outer'); + const axis = document.getElementById('axis'); + const inline = document.getElementById('inline'); + visible.scrollTo(99, 99); + clip.scrollTo(99, 99); + hidden.scrollTo(99, 99); + return { + visible: [visible.scrollWidth, visible.scrollHeight, visible.scrollLeft, visible.scrollTop], + clip: [clip.scrollWidth, clip.scrollHeight, clip.scrollLeft, clip.scrollTop], + hidden: [hidden.scrollWidth, hidden.scrollHeight, hidden.scrollLeft, hidden.scrollTop], + axis: [outer.scrollWidth, outer.scrollHeight, axis.scrollWidth, axis.scrollHeight], + fractional: [document.getElementById('f1').scrollWidth, document.getElementById('f2').scrollWidth], + inline: [inline.scrollWidth, inline.scrollHeight, inline.clientWidth, inline.clientHeight], + }; + "#, + ) + .expect("overflow oracle metrics"); + assert_eq!(result["visible"], serde_json::json!([210, 160, 0, 0])); + assert_eq!(result["clip"], serde_json::json!([210, 160, 0, 0])); + assert_eq!(result["hidden"], serde_json::json!([220, 170, 99, 70])); + assert_eq!(result["axis"], serde_json::json!([300, 120, 300, 250])); + assert_eq!(result["fractional"], serde_json::json!([100, 101])); + assert_eq!(result["inline"], serde_json::json!([0, 0, 0, 0])); + } + + /// Chromium quantizes effective scrolling ranges and assigned offsets to + /// the current device-pixel grid. At the renderer's present 1x scale a + /// 100.4px area cannot move a 100px scrollport, while 100.6px rounds to a + /// one-pixel range and assigning `.5` moves geometry and paint by 1px. + #[cfg(feature = "render")] + #[test] + fn fractional_scroll_ranges_quantize_geometry_and_pixels_at_one_x() { + let dom = parse_html( + r#" +
+
+
+
+
+
+
+
+
+
+ "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(160.0, 120.0); + rt.run_page_init(); + + let initial = rt + .screenshot_prepared((160.0, 120.0), Some("about:blank")) + .expect("initial fractional screenshot"); + let low = rt + .evaluate( + r#" + const low = document.getElementById('low'); + const child = document.getElementById('lowChild'); + const before = child.getBoundingClientRect(); + low.scrollLeft = 999; + const after = child.getBoundingClientRect(); + return [low.scrollWidth, low.clientWidth, low.scrollLeft, after.left - before.left]; + "#, + ) + .expect("low fractional range"); + assert_eq!(low, serde_json::json!([100, 100, 0, 0])); + let after_low = rt + .screenshot_prepared((160.0, 120.0), Some("about:blank")) + .expect("low fractional screenshot"); + assert_eq!(initial, after_low, "a rounded-zero range cannot move pixels"); + + let high = rt + .evaluate( + r#" + const high = document.getElementById('high'); + const child = document.getElementById('highChild'); + const before = child.getBoundingClientRect(); + high.scrollLeft = .5; + const after = child.getBoundingClientRect(); + return [high.scrollWidth, high.clientWidth, high.scrollLeft, after.left - before.left]; + "#, + ) + .expect("high fractional range"); + assert_eq!(high, serde_json::json!([101, 100, 1, -1])); + let after_high = rt + .screenshot_prepared((160.0, 120.0), Some("about:blank")) + .expect("high fractional screenshot"); + assert_ne!(after_low, after_high, "the quantized pixel must repaint"); + + let root_dom = parse_html( + r#" +
+ "#, + ); + let mut root_rt = ObscuraJsRuntime::new(); + root_rt.set_dom(root_dom); + root_rt.set_viewport(100.0, 60.0); + root_rt.run_page_init(); + let root = root_rt + .evaluate( + r#" + const wide = document.getElementById('wide'); + const before = wide.getBoundingClientRect(); + window.scrollTo(.5, 0); + const after = wide.getBoundingClientRect(); + const high = [document.documentElement.scrollWidth, window.scrollX, after.left - before.left]; + wide.style.width = '100.4px'; + window.scrollTo(999, 0); + return { high, low: [document.documentElement.scrollWidth, window.scrollX] }; + "#, + ) + .expect("root fractional range"); + assert_eq!(root["high"], serde_json::json!([101, 1, -1])); + assert_eq!(root["low"], serde_json::json!([100, 0])); + } + + #[cfg(feature = "render")] + #[test] + fn element_scroll_offsets_follow_chromium_box_and_dom_lifecycles() { + let dom = parse_html( + r#" +
+
+
+
+ "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(360.0, 240.0); + rt.run_page_init(); + + let result = rt + .evaluate( + r#" + const scroller = document.getElementById('scroller'); + const first = document.getElementById('first'); + const second = document.getElementById('second'); + scroller.scrollTo(70, 60); + const initial = [scroller.scrollLeft, scroller.scrollTop]; + scroller.style.overflow = 'visible'; + const visible = [scroller.scrollLeft, scroller.scrollTop]; + scroller.style.overflow = 'auto'; + const restoredStyle = [scroller.scrollLeft, scroller.scrollTop]; + scroller.style.display = 'none'; + const noBox = [scroller.scrollWidth, scroller.scrollHeight, scroller.scrollLeft, scroller.scrollTop]; + scroller.scrollTo(5, 5); + scroller.style.display = 'block'; + const restoredDisplay = [scroller.scrollLeft, scroller.scrollTop]; + second.appendChild(scroller); + const moved = [scroller.scrollLeft, scroller.scrollTop]; + scroller.scrollTo(40, 30); + second.removeChild(scroller); + first.appendChild(scroller); + const reattached = [scroller.scrollLeft, scroller.scrollTop]; + scroller.scrollTo(20, 10); + first.textContent = 'replacement'; + document.body.appendChild(scroller); + const textReplacement = [scroller.scrollLeft, scroller.scrollTop]; + const detached = document.createElement('div'); + detached.style.cssText = 'width:100px;height:80px;overflow:auto'; + let recomputes = 0; + globalThis.__obscura_recompute_intersections = () => { recomputes++; }; + detached.scrollTo(30, 20); + scroller.scrollTo(11, 12); + return { + initial, visible, restoredStyle, noBox, restoredDisplay, + moved, reattached, textReplacement, + detached: [detached.scrollWidth, detached.scrollHeight, detached.scrollLeft, detached.scrollTop], + atomic: [scroller.scrollLeft, scroller.scrollTop, recomputes], + }; + "#, + ) + .expect("scroll lifecycle state"); + assert_eq!(result["initial"], serde_json::json!([70, 60])); + assert_eq!(result["visible"], serde_json::json!([0, 0])); + assert_eq!(result["restoredStyle"], serde_json::json!([70, 60])); + assert_eq!(result["noBox"], serde_json::json!([0, 0, 0, 0])); + assert_eq!(result["restoredDisplay"], serde_json::json!([70, 60])); + assert_eq!(result["moved"], serde_json::json!([0, 0])); + assert_eq!(result["reattached"], serde_json::json!([0, 0])); + assert_eq!(result["textReplacement"], serde_json::json!([0, 0])); + assert_eq!(result["detached"], serde_json::json!([0, 0, 0, 0])); + assert_eq!(result["atomic"], serde_json::json!([11, 12, 1])); + } + + #[cfg(feature = "render")] + #[test] + fn fixed_panels_scroll_locally_and_transformed_descendants_remain_supported() { + let dom = parse_html( + r#" + +
+
+
+
+
+
+
+
+ "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(360.0, 240.0); + rt.run_page_init(); + + let result = rt + .evaluate( + r#" + const modal = document.getElementById('modal'); + const panel = document.getElementById('panel'); + const fixedTarget = document.getElementById('fixedTarget'); + const transformedScroller = document.getElementById('transformedScroller'); + const transformedTarget = document.getElementById('transformedTarget'); + const affineAncestorScroller = document.getElementById('affineAncestorScroller'); + const before = { + modal: modal.getBoundingClientRect(), + fixedTarget: fixedTarget.getBoundingClientRect(), + transformedTarget: transformedTarget.getBoundingClientRect(), + }; + panel.scrollTo(60, 50); + transformedScroller.scrollTo(50, 40); + affineAncestorScroller.scrollTo(50, 40); + window.scrollTo(0, 500); + const after = { + modal: modal.getBoundingClientRect(), + fixedTarget: fixedTarget.getBoundingClientRect(), + transformedTarget: transformedTarget.getBoundingClientRect(), + }; + return { + modalDelta: [after.modal.left - before.modal.left, after.modal.top - before.modal.top], + fixedDelta: [after.fixedTarget.left - before.fixedTarget.left, after.fixedTarget.top - before.fixedTarget.top], + transformedDelta: [after.transformedTarget.left - before.transformedTarget.left, after.transformedTarget.top - before.transformedTarget.top], + offsets: [panel.scrollLeft, panel.scrollTop, transformedScroller.scrollLeft, transformedScroller.scrollTop, affineAncestorScroller.scrollLeft, affineAncestorScroller.scrollTop], + }; + "#, + ) + .expect("fixed and transformed scroll state"); + assert_eq!(result["modalDelta"], serde_json::json!([0, 0])); + assert_eq!(result["fixedDelta"], serde_json::json!([-60, -50])); + assert_eq!(result["transformedDelta"], serde_json::json!([-50, -540])); + assert_eq!(result["offsets"], serde_json::json!([60, 50, 50, 40, 0, 0])); + } + /// CSSOM View exposes the viewport through the standards-mode root, but /// ordinary elements (including body) report their padding box. Modern /// animation libraries commonly measure a fixed 100vh sentinel through diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 93e5a29..9a99ea2 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -282,6 +282,49 @@ pub struct DomLayout { pub(crate) generated_boxes: Vec, } +/// Dense identifier for one scrolling area in a prepared render. Index zero +/// is always the root viewport; element scroll containers follow in DOM order. +/// The ids are rebuild-local and must never be persisted by an embedding +/// runtime (persist element offsets by `NodeId` instead). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ScrollId(pub(crate) u32); + +impl ScrollId { + pub(crate) const ROOT: Self = Self(0); + + pub(crate) fn index(self) -> usize { + self.0 as usize + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct ScrollContainer { + pub node: Option, + pub parent: Option, + pub client_size: (f32, f32), + pub content_size: (f32, f32), + pub max_offset: (f32, f32), +} + +/// Immutable scrolling topology derived from one final layout. Hot paint and +/// geometry paths index the two node vectors directly by `NodeId`; they never +/// walk ancestors or hash a node to discover which scrolling area moves it. +#[derive(Debug, Clone)] +pub(crate) struct ScrollTree { + pub containers: Vec, + /// Scrolling area established by this node, if any. + pub node_container: Vec>, + /// CSSOM scrolling-overflow size for every node with a layout box. This is + /// populated even for `overflow:visible` and `overflow:clip`: those values + /// expose real `scrollWidth`/`scrollHeight`, although only an actual scroll + /// container receives a `ScrollId` and can change its offset. + pub node_content_size: Vec>, + /// Area whose content movement applies to this node's own box. A scroll + /// container's border/background therefore retain the parent's owner, + /// while its descendants use the container itself. + pub movement_owner: Vec>, +} + /// Root-scroll sticky-position constraints captured from normal-flow layout. /// /// The normal boxes stay immutable in the layout cache. A scroll offset is @@ -472,6 +515,283 @@ impl DomLayout { fixed } + /// Build the dense element-scroll topology and local scrolling overflow. + /// + /// This is deliberately one pair of DOM-order passes. The first assigns + /// each box to its nearest scrolling-content owner; the second unions each + /// box into exactly that owner. A nested scroller's own border box thus + /// contributes to the outer area, while its hidden inner overflow only + /// contributes to the nested area. + pub(crate) fn scroll_tree( + &self, + tree: &DomTree, + viewport: (f32, f32), + root_content_size: (f32, f32), + viewport_fixed: &HashSet, + ) -> ScrollTree { + let nodes = tree.descendants(tree.document()); + let node_capacity = nodes + .iter() + .map(|id| id.index()) + .max() + .map_or(0, |max| max + 1); + let mut node_container = vec![None; node_capacity]; + let mut movement_owner = vec![None; node_capacity]; + let mut node_content_size = vec![None; node_capacity]; + let mut containers = vec![ScrollContainer { + node: None, + parent: None, + client_size: viewport, + content_size: root_content_size, + max_offset: ( + crate::quantized_scroll_range(root_content_size.0, viewport.0, 1.0), + crate::quantized_scroll_range(root_content_size.1, viewport.1, 1.0), + ), + }]; + + // `transforms` is accumulated top-down, so a non-translation matrix on + // the candidate identifies either its own transform or an affine + // ancestor. Stage 1 cannot insert scroll movement in that coordinate + // space without making geometry and pixels disagree. A transformed + // *descendant* is safe: its visual overflow is measured below and the + // scrolling translation applies to the already transformed subtree. + let mut affine_coordinate_space = vec![false; node_capacity]; + for &id in &nodes { + if let Some(slot) = affine_coordinate_space.get_mut(id.index()) { + *slot = self + .transforms + .get(&id) + .is_some_and(|transform| !transform.is_translation()); + } + } + + let root = nodes + .iter() + .copied() + .find(|id| tree.get_node(*id).is_some_and(|node| node.is_element())); + fn assign( + laid: &DomLayout, + tree: &DomTree, + id: NodeId, + inherited_owner: Option, + viewport_fixed: &HashSet, + affine_coordinate_space: &[bool], + containers: &mut Vec, + node_container: &mut [Option], + movement_owner: &mut [Option], + ) { + let fixed = viewport_fixed.contains(&id); + let parent_fixed = tree + .get_node(id) + .and_then(|node| node.parent) + .is_some_and(|parent| viewport_fixed.contains(&parent)); + // Only the boundary that enters the viewport-fixed coordinate + // space drops root movement. Descendants inherit that zero-based + // space and may establish ordinary nested scrolling areas. + let starts_viewport_fixed = fixed && !parent_fixed; + let owner = if starts_viewport_fixed { + None + } else { + inherited_owner + }; + if let Some(slot) = movement_owner.get_mut(id.index()) { + *slot = owner; + } + + let style = laid.styles.get(&id); + let rect = laid.rects.get(&id).copied(); + let establishes = style.is_some_and(|style| { + style.overflow_scroll_container && !style.overflow_propagated_to_viewport + }) + && rect.is_some() + && !affine_coordinate_space + .get(id.index()) + .copied() + .unwrap_or(false); + let child_owner = if establishes { + let style = style.expect("scroll container style"); + let rect = rect.expect("scroll container rect"); + let client = ( + (rect.width - style.border.left - style.border.right).max(0.0), + (rect.height - style.border.top - style.border.bottom).max(0.0), + ); + let sid = ScrollId(containers.len() as u32); + containers.push(ScrollContainer { + node: Some(id), + parent: owner, + client_size: client, + content_size: client, + max_offset: (0.0, 0.0), + }); + if let Some(slot) = node_container.get_mut(id.index()) { + *slot = Some(sid); + } + Some(sid) + } else { + owner + }; + + for child in rendered_children(tree, id) { + assign( + laid, + tree, + child, + child_owner, + viewport_fixed, + affine_coordinate_space, + containers, + node_container, + movement_owner, + ); + } + } + if let Some(root) = root { + assign( + self, + tree, + root, + Some(ScrollId::ROOT), + viewport_fixed, + &affine_coordinate_space, + &mut containers, + &mut node_container, + &mut movement_owner, + ); + } + + // Compute scrolling overflow for every boxed node in one reverse pass. + // A clipping/scrolling child contributes only its own border box to an + // ancestor, while still exposing its complete local overflow through + // CSSOM. Visible descendants propagate their union upward. + let mut overflow_bounds = vec![None; node_capacity]; + for &id in nodes.iter().rev() { + let Some(rect) = self.rects.get(&id).copied() else { + continue; + }; + let style = self.styles.get(&id); + let visual = |rect: Rect| { + self.transforms + .get(&id) + .copied() + .map(|transform| transform.map_rect(rect)) + .unwrap_or(rect) + }; + let padding_rect = style.map_or(rect, |style| Rect { + x: rect.x + style.border.left, + y: rect.y + style.border.top, + width: (rect.width - style.border.left - style.border.right).max(0.0), + height: (rect.height - style.border.top - style.border.bottom).max(0.0), + }); + let visual_padding = visual(padding_rect); + let mut local = visual_padding; + for child in rendered_children(tree, id) { + let Some(child_overflow) = overflow_bounds + .get(child.index()) + .copied() + .flatten() + else { + continue; + }; + let contribution = if let Some(style) = self.styles.get(&child).filter(|style| { + style.overflow_hidden && !style.overflow_propagated_to_viewport + }) { + let border_box = self.rects + .get(&child) + .copied() + .map(|rect| { + self.transforms + .get(&child) + .copied() + .map(|transform| transform.map_rect(rect)) + .unwrap_or(rect) + }) + .unwrap_or(child_overflow); + let x0 = if style.clips_overflow_x() { + border_box.x + } else { + child_overflow.x + }; + let x1 = if style.clips_overflow_x() { + border_box.x + border_box.width + } else { + child_overflow.x + child_overflow.width + }; + let y0 = if style.clips_overflow_y() { + border_box.y + } else { + child_overflow.y + }; + let y1 = if style.clips_overflow_y() { + border_box.y + border_box.height + } else { + child_overflow.y + child_overflow.height + }; + Rect { + x: x0, + y: y0, + width: (x1 - x0).max(0.0), + height: (y1 - y0).max(0.0), + } + } else { + child_overflow + }; + local = local.union(&contribution); + } + let mut content = ( + (local.x + local.width - visual_padding.x) + .max(visual_padding.width), + (local.y + local.height - visual_padding.y) + .max(visual_padding.height), + ); + // Scroll containers include trailing end padding in their + // scrolling area. Visible/clip boxes expose descendant overflow + // but, matching Chromium, do not append that trailing padding. + if let Some(style) = style.filter(|style| style.overflow_scroll_container) { + if content.0 > visual_padding.width { + content.0 += style.padding.right; + } + if content.1 > visual_padding.height { + content.1 += style.padding.bottom; + } + } + if let Some(slot) = node_content_size.get_mut(id.index()) { + *slot = Some(if style.is_some_and(|style| style.ignores_used_box_sizes()) { + (0.0, 0.0) + } else { + content + }); + } + if let Some(slot) = overflow_bounds.get_mut(id.index()) { + *slot = Some(local); + } + } + for container in containers.iter_mut().skip(1) { + container.content_size = container + .node + .and_then(|node| node_content_size.get(node.index()).copied().flatten()) + .unwrap_or(container.client_size); + container.max_offset = ( + crate::quantized_scroll_range( + container.content_size.0, + container.client_size.0, + 1.0, + ), + crate::quantized_scroll_range( + container.content_size.1, + container.client_size.1, + 1.0, + ), + ); + } + + ScrollTree { + containers, + node_container, + node_content_size, + movement_owner, + } + } + /// Root scrolling overflow in CSS pixels. This is the union of laid-out /// document boxes after translate transforms, bounded by inherited /// overflow clips. Viewport-fixed subtrees do not enlarge the document. diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 3f3ef28..385834b 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -93,9 +93,11 @@ mod image_capability_tests { mod paint; #[cfg(feature = "paint")] pub use paint::{ - paint_dom, paint_dom_scrolled, paint_prepared, prepare_dom, prepare_dom_with_dynamic_fonts, - screenshot_png, screenshot_png_scrolled, screenshot_prepared, DynamicFontFace, - PreparedRender, RenderResourceCache, RenderResourceLoader, SelectedImage, + paint_dom, paint_dom_scrolled, paint_prepared, paint_prepared_with_scroll, prepare_dom, + prepare_dom_with_dynamic_fonts, screenshot_png, screenshot_png_scrolled, + screenshot_prepared, screenshot_prepared_with_scroll, DynamicFontFace, + ElementScrollMetrics, PreparedRender, RenderResourceCache, RenderResourceLoader, + ResolvedScrollState, SelectedImage, }; // Real inline text layout (cosmic-text) lives behind the paint feature; the @@ -357,6 +359,36 @@ impl Rect { } } +/// Snap a CSS scrolling value to the raster device-pixel grid. +/// +/// Captures currently use one device pixel per CSS pixel, but keeping the +/// scale explicit makes the same rule usable when page-level device scale is +/// introduced. CSSOM integer extents and effective offsets must share this +/// quantization or a fractional range can move geometry without moving a +/// screenshot pixel. +#[doc(hidden)] +pub fn quantize_scroll_value(value: f32, device_scale_factor: f32) -> f32 { + if !value.is_finite() { + return 0.0; + } + let scale = if device_scale_factor.is_finite() && device_scale_factor > 0.0 { + device_scale_factor + } else { + 1.0 + }; + (value * scale).round() / scale +} + +pub(crate) fn quantized_scroll_range( + content: f32, + client: f32, + device_scale_factor: f32, +) -> f32 { + (quantize_scroll_value(content, device_scale_factor) + - quantize_scroll_value(client, device_scale_factor)) + .max(0.0) +} + /// Per-edge box values (margin / padding / border) in CSS pixels. #[derive(Debug, Clone, Copy, Default, PartialEq)] pub struct Edges { diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 46d8273..a4008d0 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -306,6 +306,48 @@ pub struct SelectedImage { pub density: f32, } +/// CSSOM scrolling metrics for one element box. Non-scroll containers expose +/// their padding-box size on both surfaces and retain a zero offset. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ElementScrollMetrics { + pub client_size: (f32, f32), + pub content_size: (f32, f32), + pub offset: (f32, f32), + pub max_offset: (f32, f32), +} + +/// One fully resolved scrolling snapshot shared by geometry and paint. Node +/// movement and inherited clips are dense vectors indexed directly by +/// `NodeId`, so a capture pays one top-down resolution pass and no hot-path +/// ancestor walks or per-node scroll hash lookups. +#[derive(Clone, Debug)] +pub struct ResolvedScrollState { + root_offset: (f32, f32), + container_offsets: Vec<(f32, f32)>, + node_movement: Vec<(f32, f32)>, + inherited_clips: Vec>, +} + +impl ResolvedScrollState { + pub fn root_offset(&self) -> (f32, f32) { + self.root_offset + } + + fn movement_for(&self, id: obscura_dom::tree::NodeId) -> (f32, f32) { + self.node_movement + .get(id.index()) + .copied() + .unwrap_or((0.0, 0.0)) + } + + fn inherited_clip_for( + &self, + id: obscura_dom::tree::NodeId, + ) -> Option { + self.inherited_clips.get(id.index()).copied().flatten() + } +} + /// A final image/font-aware document layout retained across viewport paints. /// The DOM must not be mutated while this value is reused. pub struct PreparedRender { @@ -315,6 +357,7 @@ pub struct PreparedRender { content_size: (f32, f32), viewport_fixed: std::collections::HashSet, sticky: crate::StickyLayout, + scroll_tree: crate::dom::ScrollTree, selected_images: HashMap, svg_fonts: Arc, layout: crate::DomLayout, @@ -350,7 +393,10 @@ impl PreparedRender { pub fn clamp_scroll(&self, requested: (f32, f32)) -> (f32, f32) { let clamp_axis = |requested: f32, content: f32, viewport: f32| { if requested.is_finite() { - requested.clamp(0.0, (content - viewport).max(0.0)) + crate::quantize_scroll_value(requested, 1.0).clamp( + 0.0, + crate::quantized_scroll_range(content, viewport, 1.0), + ) } else { 0.0 } @@ -361,6 +407,173 @@ impl PreparedRender { ) } + /// Element scroll containers in this prepared layout, in stable DOM order. + pub fn scroll_container_nodes(&self) -> impl Iterator + '_ { + self.scroll_tree + .containers + .iter() + .skip(1) + .filter_map(|container| container.node) + } + + /// Resolve persistent NodeId-keyed offsets into this layout's dense scroll + /// topology. Unknown/removed nodes are ignored; every retained offset is + /// clamped against the final local scrolling overflow. + pub fn resolve_scroll_state( + &self, + tree: &DomTree, + requested_root: (f32, f32), + requested_elements: &HashMap, + ) -> ResolvedScrollState { + let root = self.clamp_scroll(requested_root); + let mut offsets = vec![(0.0, 0.0); self.scroll_tree.containers.len()]; + offsets[0] = root; + for (index, container) in self.scroll_tree.containers.iter().enumerate().skip(1) { + let requested = container + .node + .and_then(|node| requested_elements.get(&node).copied()) + .unwrap_or((0.0, 0.0)); + let clamp = |value: f32, max: f32| { + if value.is_finite() { + crate::quantize_scroll_value(value, 1.0).clamp(0.0, max) + } else { + 0.0 + } + }; + offsets[index] = ( + clamp(requested.0, container.max_offset.0), + clamp(requested.1, container.max_offset.1), + ); + } + + let mut cumulative = vec![(0.0, 0.0); offsets.len()]; + cumulative[0] = (-root.0, -root.1); + for index in 1..offsets.len() { + let container = self.scroll_tree.containers[index]; + let inherited = container + .parent + .map(|parent| cumulative[parent.index()]) + .unwrap_or((0.0, 0.0)); + cumulative[index] = ( + inherited.0 - offsets[index].0, + inherited.1 - offsets[index].1, + ); + } + + let node_len = self.scroll_tree.movement_owner.len(); + let mut node_movement = vec![(0.0, 0.0); node_len]; + for (index, owner) in self.scroll_tree.movement_owner.iter().copied().enumerate() { + if let Some(owner) = owner { + node_movement[index] = cumulative[owner.index()]; + } + } + // Stage 1 keeps the established root sticky behavior. Nested sticky is + // intentionally absent from ScrollTree until its constraints are made + // scroll-container-relative in Stage 2. + for (id, sticky) in self.sticky.translations(self.viewport, root) { + if let Some(movement) = node_movement.get_mut(id.index()) { + movement.0 += sticky.0; + movement.1 += sticky.1; + } + } + + let mut inherited_clips = vec![None; node_len]; + fn resolve_clips( + tree: &DomTree, + laid: &crate::DomLayout, + movement: &[(f32, f32)], + id: obscura_dom::tree::NodeId, + inherited: Option, + out: &mut [Option], + ) { + if let Some(slot) = out.get_mut(id.index()) { + *slot = inherited; + } + let next = match (laid.styles.get(&id), laid.rects.get(&id)) { + (Some(style), Some(rect)) + if style.overflow_hidden && !style.overflow_propagated_to_viewport => + { + let authored = laid.translates.get(&id).copied().unwrap_or((0.0, 0.0)); + let scroll = movement.get(id.index()).copied().unwrap_or((0.0, 0.0)); + let own = crate::dom::OverflowClip::for_box( + rect, + style, + authored.0 + scroll.0, + authored.1 + scroll.1, + ); + Some(match inherited { + Some(clip) => clip.intersect(own), + None => own, + }) + } + _ => inherited, + }; + for child in crate::dom::rendered_children(tree, id) { + resolve_clips(tree, laid, movement, child, next, out); + } + } + if let Some(root_node) = tree + .descendants(tree.document()) + .into_iter() + .find(|id| tree.get_node(*id).is_some_and(|node| node.is_element())) + { + resolve_clips( + tree, + &self.layout, + &node_movement, + root_node, + None, + &mut inherited_clips, + ); + } + + ResolvedScrollState { + root_offset: root, + container_offsets: offsets, + node_movement, + inherited_clips, + } + } + + pub fn element_scroll_metrics( + &self, + id: obscura_dom::tree::NodeId, + state: &ResolvedScrollState, + ) -> Option { + let client = self.client_size(id)?; + let sid = self + .scroll_tree + .node_container + .get(id.index()) + .copied() + .flatten(); + let Some(sid) = sid else { + let content = self + .scroll_tree + .node_content_size + .get(id.index()) + .copied() + .flatten()?; + return Some(ElementScrollMetrics { + client_size: client, + content_size: content, + offset: (0.0, 0.0), + max_offset: (0.0, 0.0), + }); + }; + let container = self.scroll_tree.containers[sid.index()]; + Some(ElementScrollMetrics { + client_size: container.client_size, + content_size: container.content_size, + offset: state + .container_offsets + .get(sid.index()) + .copied() + .unwrap_or((0.0, 0.0)), + max_offset: container.max_offset, + }) + } + /// Axis-aligned bounds of the transformed border box in immutable document /// space, excluding root-scroll and sticky movement. pub fn document_rect( @@ -797,6 +1010,21 @@ impl PreparedRender { Some(rect) } + /// Border box in the viewport using a pre-resolved root + element scroll + /// snapshot. This is the production CSSOM path; the tuple-only method + /// above remains for compatibility callers that cannot own element state. + pub fn viewport_rect_with_scroll( + &self, + id: obscura_dom::tree::NodeId, + scroll: &ResolvedScrollState, + ) -> Option { + let mut rect = self.document_rect(id)?; + let movement = scroll.movement_for(id); + rect.x += movement.0; + rect.y += movement.1; + Some(rect) + } + /// Every CSS border-box fragment in the current root viewport. Ordinary /// inlines can have one fragment per line; all other boxes expose their /// single border box. Keeping this separate from the bounding union is @@ -840,6 +1068,39 @@ impl PreparedRender { ) } + pub fn viewport_client_rects_with_scroll( + &self, + id: obscura_dom::tree::NodeId, + scroll: &ResolvedScrollState, + ) -> Option> { + let source = self + .layout + .inline_fragments + .get(&id) + .cloned() + .or_else(|| self.layout.rects.get(&id).copied().map(|rect| vec![rect]))?; + let movement = scroll.movement_for(id); + Some( + source + .into_iter() + .map(|rect| { + let rect = self + .layout + .transforms + .get(&id) + .copied() + .map(|transform| transform.map_rect(rect)) + .unwrap_or(rect); + crate::Rect { + x: rect.x + movement.0, + y: rect.y + movement.1, + ..rect + } + }) + .collect(), + ) + } + pub fn selected_image( &self, id: obscura_dom::tree::NodeId, @@ -1061,6 +1322,7 @@ pub fn prepare_dom_with_dynamic_fonts( let content_size = laid.scrolling_content_size(tree, viewport); let viewport_fixed = laid.viewport_fixed_nodes(tree); let sticky = laid.root_sticky_layout(tree, viewport); + let scroll_tree = laid.scroll_tree(tree, viewport, content_size, &viewport_fixed); let root_font_size = tree .query_selector("html") .ok() @@ -1075,6 +1337,7 @@ pub fn prepare_dom_with_dynamic_fonts( content_size, viewport_fixed, sticky, + scroll_tree, selected_images, svg_fonts, layout: laid, @@ -1097,6 +1360,41 @@ pub fn paint_prepared( prepared.viewport, prepared.base_url.as_deref(), scroll, + None, + pixmap, + resources, + &prepared.selected_images, + &prepared.svg_fonts, + prepared.content_size, + &prepared.viewport_fixed, + &prepared.sticky, + &mut prepared.layout, + None, + None, + None, + None, + None, + (0.0, 0.0), + ) +} + +/// Paint from the same resolved root + element scroll snapshot used by CSSOM +/// geometry. No layout or scroll-tree work is performed by this call. +pub fn paint_prepared_with_scroll( + tree: &DomTree, + prepared: &mut PreparedRender, + resources: &mut RenderResourceCache, + scroll: &ResolvedScrollState, +) -> Option { + let (w, h) = (prepared.viewport.0 as u32, prepared.viewport.1 as u32); + let mut pixmap = Pixmap::new(w, h)?; + pixmap.fill(Color::WHITE); + paint_laid_dom_scrolled( + tree, + prepared.viewport, + prepared.base_url.as_deref(), + scroll.root_offset(), + Some(scroll), pixmap, resources, &prepared.selected_images, @@ -1217,6 +1515,7 @@ fn paint_laid_dom_scrolled( viewport: (f32, f32), base_url: Option<&str>, scroll: (f32, f32), + resolved_scroll: Option<&ResolvedScrollState>, mut pixmap: Pixmap, image_cache: &mut RenderResourceCache, selected_images: &HashMap, @@ -1232,16 +1531,26 @@ fn paint_laid_dom_scrolled( clip_scope_root: Option, surface_offset: (f32, f32), ) -> Option { - let scroll_state = ScrollPaintState::new( - tree, - viewport, - scroll, - content_size, - viewport_fixed, - sticky, - clip_scope_root, - surface_offset, - ); + let scroll_state = match resolved_scroll { + Some(resolved) => ScrollPaintState::from_resolved( + tree, + viewport, + viewport_fixed, + resolved, + clip_scope_root, + surface_offset, + ), + None => ScrollPaintState::new( + tree, + viewport, + scroll, + content_size, + viewport_fixed, + sticky, + clip_scope_root, + surface_offset, + ), + }; let root_font_size = tree .query_selector("html") .ok() @@ -1410,6 +1719,7 @@ fn paint_laid_dom_scrolled( viewport, base_url, scroll, + resolved_scroll, pixmap, image_cache, selected_images, @@ -1490,6 +1800,7 @@ fn paint_laid_dom_scrolled( viewport, base_url, scroll, + resolved_scroll, pixmap, image_cache, selected_images, @@ -1558,6 +1869,7 @@ fn paint_laid_dom_scrolled( viewport, base_url, scroll, + resolved_scroll, layer, image_cache, selected_images, @@ -1623,6 +1935,7 @@ fn paint_laid_dom_scrolled( viewport, base_url, scroll, + resolved_scroll, layer, image_cache, selected_images, @@ -2488,6 +2801,7 @@ struct ScrollPaintState<'a> { viewport_fixed: &'a std::collections::HashSet, sticky: std::collections::HashMap, sticky_clips: std::collections::HashMap, + resolved: Option<&'a ResolvedScrollState>, clip_scope_root: Option, surface_offset: (f32, f32), active: bool, @@ -2505,16 +2819,18 @@ impl<'a> ScrollPaintState<'a> { surface_offset: (f32, f32), ) -> Self { let scroll_x = if requested.0.is_finite() { - requested - .0 - .clamp(0.0, (content.0 - viewport.0).max(0.0)) + crate::quantize_scroll_value(requested.0, 1.0).clamp( + 0.0, + crate::quantized_scroll_range(content.0, viewport.0, 1.0), + ) } else { 0.0 }; let scroll_y = if requested.1.is_finite() { - requested - .1 - .clamp(0.0, (content.1 - viewport.1).max(0.0)) + crate::quantize_scroll_value(requested.1, 1.0).clamp( + 0.0, + crate::quantized_scroll_range(content.1, viewport.1, 1.0), + ) } else { 0.0 }; @@ -2528,6 +2844,7 @@ impl<'a> ScrollPaintState<'a> { viewport_fixed, sticky: std::collections::HashMap::new(), sticky_clips: std::collections::HashMap::new(), + resolved: None, clip_scope_root, surface_offset, active, @@ -2543,18 +2860,48 @@ impl<'a> ScrollPaintState<'a> { viewport_fixed, sticky, sticky_clips, + resolved: None, clip_scope_root, surface_offset, active, } } + fn from_resolved( + tree: &'a DomTree, + viewport: (f32, f32), + viewport_fixed: &'a std::collections::HashSet, + resolved: &'a ResolvedScrollState, + clip_scope_root: Option, + surface_offset: (f32, f32), + ) -> Self { + Self { + tree, + viewport, + scroll: resolved.root_offset(), + viewport_fixed, + sticky: std::collections::HashMap::new(), + sticky_clips: std::collections::HashMap::new(), + resolved: Some(resolved), + clip_scope_root, + surface_offset, + active: true, + } + } + fn translation_for( &self, laid: &crate::DomLayout, id: obscura_dom::tree::NodeId, ) -> (f32, f32) { let base = laid.translates.get(&id).copied().unwrap_or((0.0, 0.0)); + if let Some(resolved) = self.resolved { + let movement = resolved.movement_for(id); + return ( + base.0 + movement.0 + self.surface_offset.0, + base.1 + movement.1 + self.surface_offset.1, + ); + } if !self.active || self.viewport_fixed.contains(&id) { return (base.0 + self.surface_offset.0, base.1 + self.surface_offset.1); } @@ -2615,6 +2962,11 @@ impl<'a> ScrollPaintState<'a> { return clip; } } + if let Some(resolved) = self.resolved { + let mut clip = resolved.inherited_clip_for(id)?; + clip.translate(self.surface_offset.0, self.surface_offset.1); + return Some(clip); + } let mut clip = laid.clip_rects.get(&id).copied().flatten()?; if self.active && !self.viewport_fixed.contains(&id) { let sticky = self @@ -3380,6 +3732,17 @@ pub fn screenshot_prepared( .ok() } +pub fn screenshot_prepared_with_scroll( + tree: &DomTree, + prepared: &mut PreparedRender, + resources: &mut RenderResourceCache, + scroll: &ResolvedScrollState, +) -> Option> { + paint_prepared_with_scroll(tree, prepared, resources, scroll)? + .encode_png() + .ok() +} + /// A representative visible color for `background-clip: text` text whose own /// color is transparent, used on the word-split paint path (the cosmic-text IFC /// path samples the gradient per glyph in `inline`). Returns the gradient's mid @@ -9429,4 +9792,90 @@ mod tests { vec!["https://example.test/fonts/fixture.ttf".to_string()] ); } + + #[test] + fn resolved_nested_scroll_keeps_owner_and_clip_stationary_while_pixels_move() { + let tree = parse_html( + r#" +
+
+
+
+
+ "#, + ); + let outer = tree.get_element_by_id("outer").expect("outer"); + let inner = tree.get_element_by_id("inner").expect("inner"); + let target = tree.get_element_by_id("target").expect("target"); + let mut resources = RenderResourceCache::with_loader(|_url: &str| None); + let mut prepared = prepare_dom(&tree, (360.0, 240.0), None, &mut resources) + .expect("prepared render"); + let top_state = prepared.resolve_scroll_state(&tree, (0.0, 0.0), &HashMap::new()); + let top_outer = prepared + .viewport_rect_with_scroll(outer, &top_state) + .expect("top outer"); + let top_target = prepared + .viewport_rect_with_scroll(target, &top_state) + .expect("top target"); + let top = paint_prepared_with_scroll(&tree, &mut prepared, &mut resources, &top_state) + .expect("top paint"); + + let offsets = HashMap::from([(outer, (9999.0, 9999.0)), (inner, (9999.0, 9999.0))]); + let scrolled_state = prepared.resolve_scroll_state(&tree, (0.0, 0.0), &offsets); + let outer_metrics = prepared + .element_scroll_metrics(outer, &scrolled_state) + .expect("outer metrics"); + let inner_metrics = prepared + .element_scroll_metrics(inner, &scrolled_state) + .expect("inner metrics"); + assert_eq!(outer_metrics.client_size, (112.0, 92.0)); + assert_eq!(outer_metrics.content_size, (220.0, 200.0)); + assert_eq!(outer_metrics.offset, (108.0, 108.0)); + assert_eq!(inner_metrics.content_size, (330.0, 300.0)); + assert_eq!(inner_metrics.offset, (110.0, 100.0)); + + let scrolled_outer = prepared + .viewport_rect_with_scroll(outer, &scrolled_state) + .expect("scrolled outer"); + let scrolled_target = prepared + .viewport_rect_with_scroll(target, &scrolled_state) + .expect("scrolled target"); + assert_eq!(scrolled_outer, top_outer, "scroller chrome must not move with its content"); + assert_eq!(scrolled_target.x, top_target.x - 218.0); + assert_eq!(scrolled_target.y, top_target.y - 208.0); + + let scrolled = paint_prepared_with_scroll( + &tree, + &mut prepared, + &mut resources, + &scrolled_state, + ) + .expect("scrolled paint"); + let repeated = paint_prepared_with_scroll( + &tree, + &mut prepared, + &mut resources, + &scrolled_state, + ) + .expect("repeated paint"); + assert_ne!(top.data(), scrolled.data()); + assert_eq!(scrolled.data(), repeated.data(), "scroll deltas must not accumulate"); + let pixel = |pixmap: &Pixmap, x: u32, y: u32| { + pixmap.pixels()[(y * pixmap.width() + x) as usize] + }; + assert_eq!(pixel(&top, 2, 2), pixel(&scrolled, 2, 2), "outer border moved"); + assert_eq!( + pixel(&top, 125, 50), + pixel(&scrolled, 125, 50), + "content escaped the stationary outer clip" + ); + assert_ne!( + pixel(&top, 95, 85), + pixel(&scrolled, 95, 85), + "newly visible nested content did not move into the scrollport" + ); + } }