Implement renderer-backed nested scrolling
This commit is contained in:
1 parent
9fac700464
commit
478f888a6b
6 files changed
+1580
-66
No files matched your search
Vendored
+114
-19
@@ -2960,14 +2960,30 @@ class Element extends Node {
|
|||||||
return rect;
|
return rect;
|
||||||
}
|
}
|
||||||
get scrollWidth() {
|
get scrollWidth() {
|
||||||
if (!this._isViewportRoot()) return 100;
|
if (this._isViewportRoot()) {
|
||||||
const metrics = this._renderScrollMetrics();
|
const metrics = this._renderScrollMetrics();
|
||||||
return metrics ? metrics.scrollWidth : (globalThis.innerWidth || 1280);
|
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() {
|
get scrollHeight() {
|
||||||
if (!this._isViewportRoot()) return 20;
|
if (this._isViewportRoot()) {
|
||||||
const metrics = this._renderScrollMetrics();
|
const metrics = this._renderScrollMetrics();
|
||||||
return metrics ? metrics.scrollHeight : (globalThis.innerHeight || 720);
|
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() {
|
_isViewportRoot() {
|
||||||
const t = this.tagName;
|
const t = this.tagName;
|
||||||
@@ -2982,6 +2998,17 @@ class Element extends Node {
|
|||||||
return null;
|
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() {
|
_renderScrollOffset() {
|
||||||
if (typeof Deno.core.ops.op_scroll_offset !== 'function') return null;
|
if (typeof Deno.core.ops.op_scroll_offset !== 'function') return null;
|
||||||
try {
|
try {
|
||||||
@@ -3000,14 +3027,25 @@ class Element extends Node {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Render builds clamp the viewport root against measured document overflow.
|
_setRenderElementScroll(x, y) {
|
||||||
// Nested element scrollers still use synthetic, deliberately unclamped state:
|
if (typeof Deno.core.ops.op_element_scroll_to !== 'function') return null;
|
||||||
// their contents do not yet participate in scroll-container layout, and a
|
try {
|
||||||
// guessed maximum would pin lazy-loading scrollers at zero.
|
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() {
|
get scrollTop() {
|
||||||
if (this._isViewportRoot()) {
|
if (this._isViewportRoot()) {
|
||||||
const offset = this._renderScrollOffset();
|
const offset = this._renderScrollOffset();
|
||||||
if (offset) return offset.y || 0;
|
if (offset) return offset.y || 0;
|
||||||
|
} else {
|
||||||
|
const metrics = this._renderElementScrollMetrics();
|
||||||
|
if (metrics !== undefined) return metrics ? (metrics.y || 0) : 0;
|
||||||
}
|
}
|
||||||
return this._scrollTop || 0;
|
return this._scrollTop || 0;
|
||||||
}
|
}
|
||||||
@@ -3020,11 +3058,18 @@ class Element extends Node {
|
|||||||
const offset = this._renderScrollOffset();
|
const offset = this._renderScrollOffset();
|
||||||
const updated = offset && this._setRenderScroll(offset.x, nv);
|
const updated = offset && this._setRenderScroll(offset.x, nv);
|
||||||
if (updated) actual = updated.y || 0;
|
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;
|
const changed = actual !== old;
|
||||||
this._scrollTop = actual;
|
this._scrollTop = actual;
|
||||||
if (changed && !this._scrollSuppress) this._fireScroll();
|
if (changed && !this._scrollSuppress) this._fireScroll();
|
||||||
if (changed && this._isViewportRoot() &&
|
if (changed &&
|
||||||
typeof globalThis.__obscura_recompute_intersections === "function") {
|
typeof globalThis.__obscura_recompute_intersections === "function") {
|
||||||
globalThis.__obscura_recompute_intersections();
|
globalThis.__obscura_recompute_intersections();
|
||||||
}
|
}
|
||||||
@@ -3033,6 +3078,9 @@ class Element extends Node {
|
|||||||
if (this._isViewportRoot()) {
|
if (this._isViewportRoot()) {
|
||||||
const offset = this._renderScrollOffset();
|
const offset = this._renderScrollOffset();
|
||||||
if (offset) return offset.x || 0;
|
if (offset) return offset.x || 0;
|
||||||
|
} else {
|
||||||
|
const metrics = this._renderElementScrollMetrics();
|
||||||
|
if (metrics !== undefined) return metrics ? (metrics.x || 0) : 0;
|
||||||
}
|
}
|
||||||
return this._scrollLeft || 0;
|
return this._scrollLeft || 0;
|
||||||
}
|
}
|
||||||
@@ -3045,11 +3093,18 @@ class Element extends Node {
|
|||||||
const offset = this._renderScrollOffset();
|
const offset = this._renderScrollOffset();
|
||||||
const updated = offset && this._setRenderScroll(nv, offset.y);
|
const updated = offset && this._setRenderScroll(nv, offset.y);
|
||||||
if (updated) actual = updated.x || 0;
|
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;
|
const changed = actual !== old;
|
||||||
this._scrollLeft = actual;
|
this._scrollLeft = actual;
|
||||||
if (changed && !this._scrollSuppress) this._fireScroll();
|
if (changed && !this._scrollSuppress) this._fireScroll();
|
||||||
if (changed && this._isViewportRoot() &&
|
if (changed &&
|
||||||
typeof globalThis.__obscura_recompute_intersections === "function") {
|
typeof globalThis.__obscura_recompute_intersections === "function") {
|
||||||
globalThis.__obscura_recompute_intersections();
|
globalThis.__obscura_recompute_intersections();
|
||||||
}
|
}
|
||||||
@@ -3180,26 +3235,66 @@ class Element extends Node {
|
|||||||
let left, top;
|
let left, top;
|
||||||
if (x !== null && typeof x === 'object') { left = x.left; top = x.top; }
|
if (x !== null && typeof x === 'object') { left = x.left; top = x.top; }
|
||||||
else { left = x; top = y; }
|
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;
|
this._scrollSuppress = true;
|
||||||
if (left !== undefined) this.scrollLeft = +left || 0;
|
if (left !== undefined) this.scrollLeft = +left || 0;
|
||||||
if (top !== undefined) this.scrollTop = +top || 0;
|
if (top !== undefined) this.scrollTop = +top || 0;
|
||||||
this._scrollSuppress = false;
|
this._scrollSuppress = false;
|
||||||
this._fireScroll();
|
if (this.scrollLeft !== oldLeft || this.scrollTop !== oldTop) this._fireScroll();
|
||||||
}
|
}
|
||||||
scroll(x, y) { this.scrollTo(x, y); }
|
scroll(x, y) { this.scrollTo(x, y); }
|
||||||
scrollBy(x, y) {
|
scrollBy(x, y) {
|
||||||
let dl, dt;
|
let dl, dt;
|
||||||
if (x !== null && typeof x === 'object') { dl = x.left; dt = x.top; }
|
if (x !== null && typeof x === 'object') { dl = x.left; dt = x.top; }
|
||||||
else { dl = x; dt = y; }
|
else { dl = x; dt = y; }
|
||||||
this._scrollSuppress = true;
|
this.scrollTo({
|
||||||
this.scrollLeft = (this.scrollLeft || 0) + (+dl || 0);
|
left: (this.scrollLeft || 0) + (+dl || 0),
|
||||||
this.scrollTop = (this.scrollTop || 0) + (+dt || 0);
|
top: (this.scrollTop || 0) + (+dt || 0),
|
||||||
this._scrollSuppress = false;
|
});
|
||||||
this._fireScroll();
|
|
||||||
}
|
}
|
||||||
_fireScroll() {
|
_fireScroll() {
|
||||||
|
if (this._scrollEventPending) return;
|
||||||
|
this._scrollEventPending = true;
|
||||||
const self = this;
|
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) {
|
animate(keyframes, options) {
|
||||||
const duration = typeof options === 'number' ? options : (options?.duration || 0);
|
const duration = typeof options === 'number' ? options : (options?.duration || 0);
|
||||||
|
|||||||
+227
-17
@@ -129,6 +129,15 @@ pub struct ObscuraState {
|
|||||||
/// read by CSSOM geometry and screenshot paint.
|
/// read by CSSOM geometry and screenshot paint.
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
pub scroll_offset: (f32, f32),
|
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<NodeId, (f32, f32)>,
|
||||||
|
#[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,
|
/// Window-global import-map state shared by parser-discovered scripts,
|
||||||
/// dynamically inserted import maps, and the module loader.
|
/// dynamically inserted import maps, and the module loader.
|
||||||
pub(crate) import_map: Rc<RefCell<ImportMap>>,
|
pub(crate) import_map: Rc<RefCell<ImportMap>>,
|
||||||
@@ -172,6 +181,12 @@ impl ObscuraState {
|
|||||||
viewport: (1280.0, 720.0),
|
viewport: (1280.0, 720.0),
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
scroll_offset: (0.0, 0.0),
|
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())),
|
import_map: Rc::new(RefCell::new(ImportMap::default())),
|
||||||
already_started_scripts: RefCell::new(HashSet::new()),
|
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 {
|
fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> String {
|
||||||
let shared = state.borrow::<SharedState>().clone();
|
let shared = state.borrow::<SharedState>().clone();
|
||||||
#[cfg(feature = "render")]
|
#[cfg(feature = "render")]
|
||||||
if matches!(
|
{
|
||||||
cmd.as_str(),
|
// Scroll offsets belong to a node at its current tree position.
|
||||||
"set_attribute"
|
// Temporary box/style loss keeps that latent state, but DOM removal,
|
||||||
| "remove_attribute"
|
// reparenting, and subtree replacement reset the affected identities,
|
||||||
| "append_child"
|
// matching Chromium's lifecycle behavior.
|
||||||
| "remove_child"
|
let reset_nodes = {
|
||||||
| "insert_before"
|
let state = shared.borrow();
|
||||||
| "set_inner_html"
|
let mut roots = Vec::new();
|
||||||
| "set_inner_html_context"
|
if let Some(dom) = state.dom.as_ref() {
|
||||||
| "set_text_content"
|
match cmd.as_str() {
|
||||||
) {
|
"remove_child" => {
|
||||||
|
if let Ok(node) = arg1.parse::<u32>() {
|
||||||
|
roots.push(NodeId::new(node));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"append_child" => {
|
||||||
|
if let Ok(node) = arg2.parse::<u32>() {
|
||||||
|
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::<u32>() {
|
||||||
|
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::<u32>() {
|
||||||
|
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::<HashSet<_>>()
|
||||||
|
} else {
|
||||||
|
HashSet::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
// Any attribute can participate in an author selector, so even a
|
// Any attribute can participate in an author selector, so even a
|
||||||
// seemingly non-geometric attribute may change computed layout.
|
// seemingly non-geometric attribute may change computed layout.
|
||||||
// Detached-node creation and every read-only op deliberately skip this
|
// Detached-node creation and every read-only op deliberately skip this
|
||||||
// invalidation; the next geometry/scroll read rebuilds at most once.
|
// 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 gs = shared.borrow();
|
||||||
let dom = match &gs.dom {
|
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 {
|
if state.dynamic_fonts != fonts {
|
||||||
state.dynamic_fonts = fonts;
|
state.dynamic_fonts = fonts;
|
||||||
state.prepared_render = None;
|
state.prepared_render = None;
|
||||||
|
state.resolved_scroll = None;
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -2482,6 +2557,8 @@ pub fn build_extension() -> Extension {
|
|||||||
ops.push(op_computed_style());
|
ops.push(op_computed_style());
|
||||||
ops.push(op_css_supports());
|
ops.push(op_css_supports());
|
||||||
ops.push(op_layout_metrics());
|
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_offset());
|
||||||
ops.push(op_scroll_to());
|
ops.push(op_scroll_to());
|
||||||
}
|
}
|
||||||
@@ -2531,10 +2608,53 @@ pub(crate) fn ensure_prepared_render(
|
|||||||
)?
|
)?
|
||||||
};
|
};
|
||||||
state.prepared_render = Some(prepared);
|
state.prepared_render = Some(prepared);
|
||||||
|
state.resolved_scroll = None;
|
||||||
}
|
}
|
||||||
state.prepared_render.as_ref()
|
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::<HashSet<_>>();
|
||||||
|
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 `<img src>` through the renderer's page-scoped resource
|
/// Probe one ordinary `<img src>` through the renderer's page-scoped resource
|
||||||
/// cache. This is deliberately a synchronous render-only op: the current
|
/// cache. This is deliberately a synchronous render-only op: the current
|
||||||
/// renderer loader is synchronous too, and sharing it is what guarantees that
|
/// 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")]
|
#[cfg(feature = "render")]
|
||||||
pub(crate) fn clamp_scroll_offset(state: &mut ObscuraState, requested: (f32, f32)) -> (f32, f32) {
|
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))
|
.map(|prepared| prepared.clamp_scroll(requested))
|
||||||
.unwrap_or((0.0, 0.0));
|
.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
|
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: u32 = nid_str.parse().unwrap_or(0);
|
||||||
let nid = obscura_dom::tree::NodeId::new(nid);
|
let nid = obscura_dom::tree::NodeId::new(nid);
|
||||||
let mut gs = shared.borrow_mut();
|
let mut gs = shared.borrow_mut();
|
||||||
let scroll = gs.scroll_offset;
|
if ensure_resolved_scroll(&mut gs).is_some() {
|
||||||
if let Some(prepared) = ensure_prepared_render(&mut gs) {
|
let Some((_, scroll)) = gs.resolved_scroll.as_ref() else {
|
||||||
let Some(rect) = prepared.viewport_rect(nid, scroll) 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();
|
return String::new();
|
||||||
};
|
};
|
||||||
let Some((client_width, client_height)) = prepared.client_size(nid) else {
|
let Some((client_width, client_height)) = prepared.client_size(nid) else {
|
||||||
return String::new();
|
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();
|
return String::new();
|
||||||
};
|
};
|
||||||
let client_rects = client_rects
|
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::<SharedState>().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::<SharedState>().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")]
|
#[cfg(feature = "render")]
|
||||||
#[op2]
|
#[op2]
|
||||||
#[string]
|
#[string]
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ pub use deno_core::v8::IsolateHandle;
|
|||||||
use crate::import_map::ImportMap;
|
use crate::import_map::ImportMap;
|
||||||
use crate::module_loader::ObscuraModuleLoader;
|
use crate::module_loader::ObscuraModuleLoader;
|
||||||
#[cfg(feature = "render")]
|
#[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::{
|
use crate::ops::{
|
||||||
build_extension, node_is_script, ObscuraState, StoredNetworkResponseBody,
|
build_extension, node_is_script, ObscuraState, StoredNetworkResponseBody,
|
||||||
};
|
};
|
||||||
@@ -227,6 +231,9 @@ impl ObscuraJsRuntime {
|
|||||||
gs.render_resources = obscura_render::RenderResourceCache::default();
|
gs.render_resources = obscura_render::RenderResourceCache::default();
|
||||||
gs.dynamic_fonts.clear();
|
gs.dynamic_fonts.clear();
|
||||||
gs.scroll_offset = (0.0, 0.0);
|
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
|
// present. Keep already-fetched absolute bytes, but rebuild
|
||||||
// candidate selection/layout against the new base.
|
// candidate selection/layout against the new base.
|
||||||
state.prepared_render = None;
|
state.prepared_render = None;
|
||||||
|
state.resolved_scroll = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,6 +348,7 @@ impl ObscuraJsRuntime {
|
|||||||
let mut state = self.state.borrow_mut();
|
let mut state = self.state.borrow_mut();
|
||||||
state.viewport = (width as f32, height as f32);
|
state.viewport = (width as f32, height as f32);
|
||||||
state.prepared_render = None;
|
state.prepared_render = None;
|
||||||
|
state.resolved_scroll = None;
|
||||||
}
|
}
|
||||||
let _ = self.runtime.execute_script(
|
let _ = self.runtime.execute_script(
|
||||||
"<set-viewport>",
|
"<set-viewport>",
|
||||||
@@ -380,20 +389,16 @@ impl ObscuraJsRuntime {
|
|||||||
if viewport != state.viewport || base_url != effective_base.as_deref() {
|
if viewport != state.viewport || base_url != effective_base.as_deref() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
ensure_prepared_render(&mut state)?;
|
ensure_resolved_scroll(&mut state)?;
|
||||||
let requested = state.scroll_offset;
|
|
||||||
let scroll = state
|
|
||||||
.prepared_render
|
|
||||||
.as_ref()
|
|
||||||
.map(|prepared| prepared.clamp_scroll(requested))?;
|
|
||||||
state.scroll_offset = scroll;
|
|
||||||
let ObscuraState {
|
let ObscuraState {
|
||||||
dom,
|
dom,
|
||||||
prepared_render,
|
prepared_render,
|
||||||
render_resources,
|
render_resources,
|
||||||
|
resolved_scroll,
|
||||||
..
|
..
|
||||||
} = &mut *state;
|
} = &mut *state;
|
||||||
obscura_render::screenshot_prepared(
|
let (_, scroll) = resolved_scroll.as_ref()?;
|
||||||
|
obscura_render::screenshot_prepared_with_scroll(
|
||||||
dom.as_ref()?,
|
dom.as_ref()?,
|
||||||
prepared_render.as_mut()?,
|
prepared_render.as_mut()?,
|
||||||
render_resources,
|
render_resources,
|
||||||
@@ -1211,6 +1216,8 @@ impl ObscuraJsRuntime {
|
|||||||
state.prepared_render = None;
|
state.prepared_render = None;
|
||||||
state.render_resources = obscura_render::RenderResourceCache::default();
|
state.render_resources = obscura_render::RenderResourceCache::default();
|
||||||
state.dynamic_fonts.clear();
|
state.dynamic_fonts.clear();
|
||||||
|
state.element_scroll_offsets.clear();
|
||||||
|
state.resolved_scroll = None;
|
||||||
}
|
}
|
||||||
state.dom.take()
|
state.dom.take()
|
||||||
}
|
}
|
||||||
@@ -3254,7 +3261,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn element_scroll_methods_update_scroll_offsets() {
|
fn element_scroll_methods_update_scroll_offsets() {
|
||||||
let mut rt = setup_runtime(r#"<div id="scroller"></div>"#);
|
let mut rt = setup_runtime(
|
||||||
|
r#"<div id="scroller" style="width:100px;height:100px;overflow:auto">
|
||||||
|
<div style="width:300px;height:300px"></div>
|
||||||
|
</div>"#,
|
||||||
|
);
|
||||||
let result = rt
|
let result = rt
|
||||||
.evaluate(
|
.evaluate(
|
||||||
r#"
|
r#"
|
||||||
@@ -3956,6 +3967,403 @@ mod tests {
|
|||||||
assert!(values[12..].iter().all(|value| value == &serde_json::json!(true)));
|
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#"<html style="margin:0"><body style="margin:0">
|
||||||
|
<div id="outer" style="box-sizing:border-box;width:120px;height:100px;
|
||||||
|
border:4px solid red;overflow:hidden;position:relative;background:red">
|
||||||
|
<div id="inner" style="width:220px;height:200px;overflow:hidden;
|
||||||
|
position:relative;background:blue">
|
||||||
|
<div id="target" style="position:absolute;left:300px;top:280px;
|
||||||
|
width:30px;height:20px;background:lime"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>"#,
|
||||||
|
);
|
||||||
|
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#"<html style="margin:0"><body style="margin:0">
|
||||||
|
<style>
|
||||||
|
.box { width:100px;height:80px;padding:10px;border:2px solid;position:absolute }
|
||||||
|
.child { width:200px;height:150px }
|
||||||
|
</style>
|
||||||
|
<div id="visible" class="box" style="overflow:visible;top:0"><div class="child"></div></div>
|
||||||
|
<div id="clip" class="box" style="overflow:clip;top:150px"><div class="child"></div></div>
|
||||||
|
<div id="hidden" class="box" style="overflow:hidden;top:300px"><div class="child"></div></div>
|
||||||
|
<div id="outer" style="width:100px;height:80px;overflow:visible;position:absolute;top:450px">
|
||||||
|
<div id="axis" style="width:150px;height:120px;overflow-x:visible;overflow-y:clip">
|
||||||
|
<div style="width:300px;height:250px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="f1" style="width:10px;overflow:visible"><div style="width:100.1px;height:1px"></div></div>
|
||||||
|
<div id="f2" style="width:10px;overflow:visible"><div style="width:100.6px;height:1px"></div></div>
|
||||||
|
<span id="inline">long inline text</span>
|
||||||
|
</body></html>"#,
|
||||||
|
);
|
||||||
|
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#"<html style="margin:0"><body style="margin:0">
|
||||||
|
<div id="low" style="width:100px;height:40px;overflow:auto;position:absolute;top:0">
|
||||||
|
<div style="width:100.4px;height:40px;position:relative;background:white">
|
||||||
|
<div id="lowChild" style="position:absolute;left:40px;top:5px;width:10px;height:25px;background:red"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="high" style="width:100px;height:40px;overflow:auto;position:absolute;top:60px">
|
||||||
|
<div style="width:100.6px;height:40px;position:relative;background:white">
|
||||||
|
<div id="highChild" style="position:absolute;left:40px;top:5px;width:10px;height:25px;background:blue"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>"#,
|
||||||
|
);
|
||||||
|
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#"<html style="margin:0"><body style="margin:0">
|
||||||
|
<div id="wide" style="width:100.6px;height:20px"></div>
|
||||||
|
</body></html>"#,
|
||||||
|
);
|
||||||
|
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#"<html style="margin:0"><body style="margin:0">
|
||||||
|
<div id="first"><div id="scroller" style="width:100px;height:80px;overflow:auto">
|
||||||
|
<div style="width:250px;height:200px"></div>
|
||||||
|
</div></div>
|
||||||
|
<div id="second"></div>
|
||||||
|
</body></html>"#,
|
||||||
|
);
|
||||||
|
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#"<html style="margin:0"><body style="margin:0;height:1800px">
|
||||||
|
<div id="modal" style="position:fixed;left:20px;top:20px;width:140px;height:120px;background:red">
|
||||||
|
<div id="panel" style="width:100px;height:80px;overflow:hidden;position:relative;background:blue">
|
||||||
|
<div id="fixedTarget" style="position:absolute;left:180px;top:160px;width:20px;height:20px;background:lime"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="transformedScroller" style="position:absolute;top:300px;width:100px;height:80px;overflow:hidden">
|
||||||
|
<div id="transformedTarget" style="width:240px;height:180px;transform:scale(1.1)"></div>
|
||||||
|
</div>
|
||||||
|
<div style="position:absolute;top:600px;transform:scale(1.2)">
|
||||||
|
<div id="affineAncestorScroller" style="width:100px;height:80px;overflow:hidden">
|
||||||
|
<div style="width:240px;height:180px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>"#,
|
||||||
|
);
|
||||||
|
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
|
/// CSSOM View exposes the viewport through the standards-mode root, but
|
||||||
/// ordinary elements (including body) report their padding box. Modern
|
/// ordinary elements (including body) report their padding box. Modern
|
||||||
/// animation libraries commonly measure a fixed 100vh sentinel through
|
/// animation libraries commonly measure a fixed 100vh sentinel through
|
||||||
|
|||||||
@@ -282,6 +282,49 @@ pub struct DomLayout {
|
|||||||
pub(crate) generated_boxes: Vec<GeneratedBox>,
|
pub(crate) generated_boxes: Vec<GeneratedBox>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<NodeId>,
|
||||||
|
pub parent: Option<ScrollId>,
|
||||||
|
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<ScrollContainer>,
|
||||||
|
/// Scrolling area established by this node, if any.
|
||||||
|
pub node_container: Vec<Option<ScrollId>>,
|
||||||
|
/// 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<Option<(f32, f32)>>,
|
||||||
|
/// 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<Option<ScrollId>>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Root-scroll sticky-position constraints captured from normal-flow layout.
|
/// Root-scroll sticky-position constraints captured from normal-flow layout.
|
||||||
///
|
///
|
||||||
/// The normal boxes stay immutable in the layout cache. A scroll offset is
|
/// The normal boxes stay immutable in the layout cache. A scroll offset is
|
||||||
@@ -472,6 +515,283 @@ impl DomLayout {
|
|||||||
fixed
|
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<NodeId>,
|
||||||
|
) -> 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<ScrollId>,
|
||||||
|
viewport_fixed: &HashSet<NodeId>,
|
||||||
|
affine_coordinate_space: &[bool],
|
||||||
|
containers: &mut Vec<ScrollContainer>,
|
||||||
|
node_container: &mut [Option<ScrollId>],
|
||||||
|
movement_owner: &mut [Option<ScrollId>],
|
||||||
|
) {
|
||||||
|
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
|
/// Root scrolling overflow in CSS pixels. This is the union of laid-out
|
||||||
/// document boxes after translate transforms, bounded by inherited
|
/// document boxes after translate transforms, bounded by inherited
|
||||||
/// overflow clips. Viewport-fixed subtrees do not enlarge the document.
|
/// overflow clips. Viewport-fixed subtrees do not enlarge the document.
|
||||||
|
|||||||
@@ -93,9 +93,11 @@ mod image_capability_tests {
|
|||||||
mod paint;
|
mod paint;
|
||||||
#[cfg(feature = "paint")]
|
#[cfg(feature = "paint")]
|
||||||
pub use paint::{
|
pub use paint::{
|
||||||
paint_dom, paint_dom_scrolled, paint_prepared, prepare_dom, prepare_dom_with_dynamic_fonts,
|
paint_dom, paint_dom_scrolled, paint_prepared, paint_prepared_with_scroll, prepare_dom,
|
||||||
screenshot_png, screenshot_png_scrolled, screenshot_prepared, DynamicFontFace,
|
prepare_dom_with_dynamic_fonts, screenshot_png, screenshot_png_scrolled,
|
||||||
PreparedRender, RenderResourceCache, RenderResourceLoader, SelectedImage,
|
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
|
// 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.
|
/// Per-edge box values (margin / padding / border) in CSS pixels.
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||||
pub struct Edges {
|
pub struct Edges {
|
||||||
|
|||||||
@@ -306,6 +306,48 @@ pub struct SelectedImage {
|
|||||||
pub density: f32,
|
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<Option<crate::dom::OverflowClip>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<crate::dom::OverflowClip> {
|
||||||
|
self.inherited_clips.get(id.index()).copied().flatten()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A final image/font-aware document layout retained across viewport paints.
|
/// A final image/font-aware document layout retained across viewport paints.
|
||||||
/// The DOM must not be mutated while this value is reused.
|
/// The DOM must not be mutated while this value is reused.
|
||||||
pub struct PreparedRender {
|
pub struct PreparedRender {
|
||||||
@@ -315,6 +357,7 @@ pub struct PreparedRender {
|
|||||||
content_size: (f32, f32),
|
content_size: (f32, f32),
|
||||||
viewport_fixed: std::collections::HashSet<obscura_dom::tree::NodeId>,
|
viewport_fixed: std::collections::HashSet<obscura_dom::tree::NodeId>,
|
||||||
sticky: crate::StickyLayout,
|
sticky: crate::StickyLayout,
|
||||||
|
scroll_tree: crate::dom::ScrollTree,
|
||||||
selected_images: HashMap<obscura_dom::tree::NodeId, SelectedImage>,
|
selected_images: HashMap<obscura_dom::tree::NodeId, SelectedImage>,
|
||||||
svg_fonts: Arc<usvg::fontdb::Database>,
|
svg_fonts: Arc<usvg::fontdb::Database>,
|
||||||
layout: crate::DomLayout,
|
layout: crate::DomLayout,
|
||||||
@@ -350,7 +393,10 @@ impl PreparedRender {
|
|||||||
pub fn clamp_scroll(&self, requested: (f32, f32)) -> (f32, f32) {
|
pub fn clamp_scroll(&self, requested: (f32, f32)) -> (f32, f32) {
|
||||||
let clamp_axis = |requested: f32, content: f32, viewport: f32| {
|
let clamp_axis = |requested: f32, content: f32, viewport: f32| {
|
||||||
if requested.is_finite() {
|
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 {
|
} else {
|
||||||
0.0
|
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<Item = obscura_dom::tree::NodeId> + '_ {
|
||||||
|
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<obscura_dom::tree::NodeId, (f32, f32)>,
|
||||||
|
) -> 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<crate::dom::OverflowClip>,
|
||||||
|
out: &mut [Option<crate::dom::OverflowClip>],
|
||||||
|
) {
|
||||||
|
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<ElementScrollMetrics> {
|
||||||
|
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
|
/// Axis-aligned bounds of the transformed border box in immutable document
|
||||||
/// space, excluding root-scroll and sticky movement.
|
/// space, excluding root-scroll and sticky movement.
|
||||||
pub fn document_rect(
|
pub fn document_rect(
|
||||||
@@ -797,6 +1010,21 @@ impl PreparedRender {
|
|||||||
Some(rect)
|
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<crate::Rect> {
|
||||||
|
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
|
/// Every CSS border-box fragment in the current root viewport. Ordinary
|
||||||
/// inlines can have one fragment per line; all other boxes expose their
|
/// inlines can have one fragment per line; all other boxes expose their
|
||||||
/// single border box. Keeping this separate from the bounding union is
|
/// 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<Vec<crate::Rect>> {
|
||||||
|
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(
|
pub fn selected_image(
|
||||||
&self,
|
&self,
|
||||||
id: obscura_dom::tree::NodeId,
|
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 content_size = laid.scrolling_content_size(tree, viewport);
|
||||||
let viewport_fixed = laid.viewport_fixed_nodes(tree);
|
let viewport_fixed = laid.viewport_fixed_nodes(tree);
|
||||||
let sticky = laid.root_sticky_layout(tree, viewport);
|
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
|
let root_font_size = tree
|
||||||
.query_selector("html")
|
.query_selector("html")
|
||||||
.ok()
|
.ok()
|
||||||
@@ -1075,6 +1337,7 @@ pub fn prepare_dom_with_dynamic_fonts(
|
|||||||
content_size,
|
content_size,
|
||||||
viewport_fixed,
|
viewport_fixed,
|
||||||
sticky,
|
sticky,
|
||||||
|
scroll_tree,
|
||||||
selected_images,
|
selected_images,
|
||||||
svg_fonts,
|
svg_fonts,
|
||||||
layout: laid,
|
layout: laid,
|
||||||
@@ -1097,6 +1360,41 @@ pub fn paint_prepared(
|
|||||||
prepared.viewport,
|
prepared.viewport,
|
||||||
prepared.base_url.as_deref(),
|
prepared.base_url.as_deref(),
|
||||||
scroll,
|
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<Pixmap> {
|
||||||
|
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,
|
pixmap,
|
||||||
resources,
|
resources,
|
||||||
&prepared.selected_images,
|
&prepared.selected_images,
|
||||||
@@ -1217,6 +1515,7 @@ fn paint_laid_dom_scrolled(
|
|||||||
viewport: (f32, f32),
|
viewport: (f32, f32),
|
||||||
base_url: Option<&str>,
|
base_url: Option<&str>,
|
||||||
scroll: (f32, f32),
|
scroll: (f32, f32),
|
||||||
|
resolved_scroll: Option<&ResolvedScrollState>,
|
||||||
mut pixmap: Pixmap,
|
mut pixmap: Pixmap,
|
||||||
image_cache: &mut RenderResourceCache,
|
image_cache: &mut RenderResourceCache,
|
||||||
selected_images: &HashMap<obscura_dom::tree::NodeId, SelectedImage>,
|
selected_images: &HashMap<obscura_dom::tree::NodeId, SelectedImage>,
|
||||||
@@ -1232,16 +1531,26 @@ fn paint_laid_dom_scrolled(
|
|||||||
clip_scope_root: Option<obscura_dom::tree::NodeId>,
|
clip_scope_root: Option<obscura_dom::tree::NodeId>,
|
||||||
surface_offset: (f32, f32),
|
surface_offset: (f32, f32),
|
||||||
) -> Option<Pixmap> {
|
) -> Option<Pixmap> {
|
||||||
let scroll_state = ScrollPaintState::new(
|
let scroll_state = match resolved_scroll {
|
||||||
tree,
|
Some(resolved) => ScrollPaintState::from_resolved(
|
||||||
viewport,
|
tree,
|
||||||
scroll,
|
viewport,
|
||||||
content_size,
|
viewport_fixed,
|
||||||
viewport_fixed,
|
resolved,
|
||||||
sticky,
|
clip_scope_root,
|
||||||
clip_scope_root,
|
surface_offset,
|
||||||
surface_offset,
|
),
|
||||||
);
|
None => ScrollPaintState::new(
|
||||||
|
tree,
|
||||||
|
viewport,
|
||||||
|
scroll,
|
||||||
|
content_size,
|
||||||
|
viewport_fixed,
|
||||||
|
sticky,
|
||||||
|
clip_scope_root,
|
||||||
|
surface_offset,
|
||||||
|
),
|
||||||
|
};
|
||||||
let root_font_size = tree
|
let root_font_size = tree
|
||||||
.query_selector("html")
|
.query_selector("html")
|
||||||
.ok()
|
.ok()
|
||||||
@@ -1410,6 +1719,7 @@ fn paint_laid_dom_scrolled(
|
|||||||
viewport,
|
viewport,
|
||||||
base_url,
|
base_url,
|
||||||
scroll,
|
scroll,
|
||||||
|
resolved_scroll,
|
||||||
pixmap,
|
pixmap,
|
||||||
image_cache,
|
image_cache,
|
||||||
selected_images,
|
selected_images,
|
||||||
@@ -1490,6 +1800,7 @@ fn paint_laid_dom_scrolled(
|
|||||||
viewport,
|
viewport,
|
||||||
base_url,
|
base_url,
|
||||||
scroll,
|
scroll,
|
||||||
|
resolved_scroll,
|
||||||
pixmap,
|
pixmap,
|
||||||
image_cache,
|
image_cache,
|
||||||
selected_images,
|
selected_images,
|
||||||
@@ -1558,6 +1869,7 @@ fn paint_laid_dom_scrolled(
|
|||||||
viewport,
|
viewport,
|
||||||
base_url,
|
base_url,
|
||||||
scroll,
|
scroll,
|
||||||
|
resolved_scroll,
|
||||||
layer,
|
layer,
|
||||||
image_cache,
|
image_cache,
|
||||||
selected_images,
|
selected_images,
|
||||||
@@ -1623,6 +1935,7 @@ fn paint_laid_dom_scrolled(
|
|||||||
viewport,
|
viewport,
|
||||||
base_url,
|
base_url,
|
||||||
scroll,
|
scroll,
|
||||||
|
resolved_scroll,
|
||||||
layer,
|
layer,
|
||||||
image_cache,
|
image_cache,
|
||||||
selected_images,
|
selected_images,
|
||||||
@@ -2488,6 +2801,7 @@ struct ScrollPaintState<'a> {
|
|||||||
viewport_fixed: &'a std::collections::HashSet<obscura_dom::tree::NodeId>,
|
viewport_fixed: &'a std::collections::HashSet<obscura_dom::tree::NodeId>,
|
||||||
sticky: std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
sticky: std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
||||||
sticky_clips: std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
sticky_clips: std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
||||||
|
resolved: Option<&'a ResolvedScrollState>,
|
||||||
clip_scope_root: Option<obscura_dom::tree::NodeId>,
|
clip_scope_root: Option<obscura_dom::tree::NodeId>,
|
||||||
surface_offset: (f32, f32),
|
surface_offset: (f32, f32),
|
||||||
active: bool,
|
active: bool,
|
||||||
@@ -2505,16 +2819,18 @@ impl<'a> ScrollPaintState<'a> {
|
|||||||
surface_offset: (f32, f32),
|
surface_offset: (f32, f32),
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let scroll_x = if requested.0.is_finite() {
|
let scroll_x = if requested.0.is_finite() {
|
||||||
requested
|
crate::quantize_scroll_value(requested.0, 1.0).clamp(
|
||||||
.0
|
0.0,
|
||||||
.clamp(0.0, (content.0 - viewport.0).max(0.0))
|
crate::quantized_scroll_range(content.0, viewport.0, 1.0),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
let scroll_y = if requested.1.is_finite() {
|
let scroll_y = if requested.1.is_finite() {
|
||||||
requested
|
crate::quantize_scroll_value(requested.1, 1.0).clamp(
|
||||||
.1
|
0.0,
|
||||||
.clamp(0.0, (content.1 - viewport.1).max(0.0))
|
crate::quantized_scroll_range(content.1, viewport.1, 1.0),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
@@ -2528,6 +2844,7 @@ impl<'a> ScrollPaintState<'a> {
|
|||||||
viewport_fixed,
|
viewport_fixed,
|
||||||
sticky: std::collections::HashMap::new(),
|
sticky: std::collections::HashMap::new(),
|
||||||
sticky_clips: std::collections::HashMap::new(),
|
sticky_clips: std::collections::HashMap::new(),
|
||||||
|
resolved: None,
|
||||||
clip_scope_root,
|
clip_scope_root,
|
||||||
surface_offset,
|
surface_offset,
|
||||||
active,
|
active,
|
||||||
@@ -2543,18 +2860,48 @@ impl<'a> ScrollPaintState<'a> {
|
|||||||
viewport_fixed,
|
viewport_fixed,
|
||||||
sticky,
|
sticky,
|
||||||
sticky_clips,
|
sticky_clips,
|
||||||
|
resolved: None,
|
||||||
clip_scope_root,
|
clip_scope_root,
|
||||||
surface_offset,
|
surface_offset,
|
||||||
active,
|
active,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn from_resolved(
|
||||||
|
tree: &'a DomTree,
|
||||||
|
viewport: (f32, f32),
|
||||||
|
viewport_fixed: &'a std::collections::HashSet<obscura_dom::tree::NodeId>,
|
||||||
|
resolved: &'a ResolvedScrollState,
|
||||||
|
clip_scope_root: Option<obscura_dom::tree::NodeId>,
|
||||||
|
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(
|
fn translation_for(
|
||||||
&self,
|
&self,
|
||||||
laid: &crate::DomLayout,
|
laid: &crate::DomLayout,
|
||||||
id: obscura_dom::tree::NodeId,
|
id: obscura_dom::tree::NodeId,
|
||||||
) -> (f32, f32) {
|
) -> (f32, f32) {
|
||||||
let base = laid.translates.get(&id).copied().unwrap_or((0.0, 0.0));
|
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) {
|
if !self.active || self.viewport_fixed.contains(&id) {
|
||||||
return (base.0 + self.surface_offset.0, base.1 + self.surface_offset.1);
|
return (base.0 + self.surface_offset.0, base.1 + self.surface_offset.1);
|
||||||
}
|
}
|
||||||
@@ -2615,6 +2962,11 @@ impl<'a> ScrollPaintState<'a> {
|
|||||||
return clip;
|
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()?;
|
let mut clip = laid.clip_rects.get(&id).copied().flatten()?;
|
||||||
if self.active && !self.viewport_fixed.contains(&id) {
|
if self.active && !self.viewport_fixed.contains(&id) {
|
||||||
let sticky = self
|
let sticky = self
|
||||||
@@ -3380,6 +3732,17 @@ pub fn screenshot_prepared(
|
|||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn screenshot_prepared_with_scroll(
|
||||||
|
tree: &DomTree,
|
||||||
|
prepared: &mut PreparedRender,
|
||||||
|
resources: &mut RenderResourceCache,
|
||||||
|
scroll: &ResolvedScrollState,
|
||||||
|
) -> Option<Vec<u8>> {
|
||||||
|
paint_prepared_with_scroll(tree, prepared, resources, scroll)?
|
||||||
|
.encode_png()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
/// A representative visible color for `background-clip: text` text whose own
|
/// 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
|
/// 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
|
/// 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()]
|
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#"<html style="margin:0"><body style="margin:0">
|
||||||
|
<div id="outer" style="box-sizing:border-box;width:120px;height:100px;
|
||||||
|
border:4px solid red;overflow:hidden;position:relative;background:red">
|
||||||
|
<div id="inner" style="width:220px;height:200px;overflow:hidden;
|
||||||
|
position:relative;background:blue">
|
||||||
|
<div id="target" style="position:absolute;left:300px;top:280px;
|
||||||
|
width:30px;height:20px;background:lime"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>"#,
|
||||||
|
);
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user