mirror of
https://github.com/lexmount/moli.git
synced 2026-10-03 16:00:45 +00:00
fix(layout): resolve CSSOM metrics in absolute zoom space
This commit is contained in:
@@ -364,7 +364,15 @@ where
|
||||
) {
|
||||
layout_box.css_images = self.css_image_resources(source_node, &style);
|
||||
}
|
||||
layout_box.scroll_offset = self.source.scroll_offset(source_node);
|
||||
// DOM scroll state is stored in CSSOM units. Numeric layout operates
|
||||
// in effective-zoomed CSS pixels, so import the sampled state into the
|
||||
// same coordinate space as the box geometry.
|
||||
let scroll_offset = self.source.scroll_offset(source_node);
|
||||
let effective_zoom = style.effective_zoom();
|
||||
layout_box.scroll_offset = crate::LayoutPoint::new(
|
||||
scroll_offset.x * effective_zoom,
|
||||
scroll_offset.y * effective_zoom,
|
||||
);
|
||||
layout_box
|
||||
}
|
||||
|
||||
|
||||
@@ -325,6 +325,12 @@ pub struct LayoutScrollExtent {
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct LayoutBoxGeometry {
|
||||
pub id: LayoutOutputBoxId,
|
||||
/// Accumulated CSS `zoom` already applied to this box's layout lengths.
|
||||
///
|
||||
/// Transformed client geometry keeps the zoomed values. CSSOM integer box
|
||||
/// metrics divide their untransformed layout values by this factor, as
|
||||
/// Blink's `AdjustForAbsoluteZoom` does at its DOM binding boundary.
|
||||
pub effective_zoom: f32,
|
||||
/// Source/LayoutObject ancestry before formatting-tree normalization.
|
||||
pub structural_parent: Option<LayoutOutputBoxId>,
|
||||
/// Parent in the normalized formatting tree.
|
||||
|
||||
@@ -35,8 +35,11 @@ pub struct LayoutDocumentMetrics {
|
||||
|
||||
/// CSSOM View and observer metrics for one source element.
|
||||
///
|
||||
/// Transformed quads use viewport CSS pixels. Offset and size fields retain
|
||||
/// the untransformed layout values required by offset/client/scroll APIs.
|
||||
/// Transformed quads use viewport CSS pixels and therefore include CSS
|
||||
/// `zoom`. Offset, size, and scroll fields are untransformed and have the
|
||||
/// queried box's absolute zoom removed, as required by CSSOM View. Root client
|
||||
/// dimensions come directly from the layout viewport and are not adjusted a
|
||||
/// second time.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct LayoutElementMetrics<N> {
|
||||
pub offset_parent: Option<N>,
|
||||
|
||||
@@ -135,6 +135,7 @@ where
|
||||
geometry.border_box.y,
|
||||
))
|
||||
});
|
||||
let unzoom = CssomAbsoluteZoom::new(geometry.effective_zoom);
|
||||
let client_size = if is_root {
|
||||
// CSSOM defines root client dimensions from the viewport and only
|
||||
// subtracts an actually-present viewport scrollbar. Stable empty
|
||||
@@ -153,20 +154,25 @@ where
|
||||
.max(0.0),
|
||||
)
|
||||
} else {
|
||||
LayoutSize::new(extent.scrollport.width, extent.scrollport.height)
|
||||
unzoom.size(LayoutSize::new(
|
||||
extent.scrollport.width,
|
||||
extent.scrollport.height,
|
||||
))
|
||||
};
|
||||
let scroll_size = extent.scroll_size;
|
||||
Some(LayoutElementMetrics {
|
||||
offset_parent,
|
||||
offset_position: LayoutPoint::new(
|
||||
offset_position: unzoom.point(LayoutPoint::new(
|
||||
layout_origin.x - offset_parent_origin.x,
|
||||
layout_origin.y - offset_parent_origin.y,
|
||||
),
|
||||
)),
|
||||
border_origin_in_viewport_ignoring_css_transforms,
|
||||
offset_size,
|
||||
content_size: LayoutSize::new(geometry.content_box.width, geometry.content_box.height),
|
||||
offset_size: unzoom.size(offset_size),
|
||||
content_size: unzoom.size(LayoutSize::new(
|
||||
geometry.content_box.width,
|
||||
geometry.content_box.height,
|
||||
)),
|
||||
client_size,
|
||||
client_border: LayoutPoint::new(
|
||||
client_border: unzoom.point(LayoutPoint::new(
|
||||
if is_root {
|
||||
0.0
|
||||
} else {
|
||||
@@ -177,11 +183,11 @@ where
|
||||
} else {
|
||||
extent.scrollport.y - geometry.border_box.y
|
||||
},
|
||||
),
|
||||
scroll_size,
|
||||
scroll_offset: extent.applied_offset,
|
||||
minimum_scroll_offset: extent.minimum_offset,
|
||||
maximum_scroll_offset: extent.maximum_offset,
|
||||
)),
|
||||
scroll_size: unzoom.size(extent.scroll_size),
|
||||
scroll_offset: unzoom.point(extent.applied_offset),
|
||||
minimum_scroll_offset: unzoom.point(extent.minimum_offset),
|
||||
maximum_scroll_offset: unzoom.point(extent.maximum_offset),
|
||||
scrollport: if is_root {
|
||||
LayoutTransform2D::IDENTITY.map_rect(extent.scrollport)
|
||||
} else {
|
||||
@@ -449,6 +455,31 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts effective-zoomed layout scalars to the coordinate space exposed
|
||||
/// by CSSOM integer box and scroll metrics. Viewport quads intentionally stay
|
||||
/// zoomed: their normalized bases map points back into these unzoomed sizes.
|
||||
#[derive(Clone, Copy)]
|
||||
struct CssomAbsoluteZoom(f32);
|
||||
|
||||
impl CssomAbsoluteZoom {
|
||||
fn new(effective_zoom: f32) -> Self {
|
||||
debug_assert!(effective_zoom.is_finite() && effective_zoom > 0.0);
|
||||
Self(if effective_zoom.is_finite() && effective_zoom > 0.0 {
|
||||
effective_zoom
|
||||
} else {
|
||||
1.0
|
||||
})
|
||||
}
|
||||
|
||||
fn point(self, point: LayoutPoint) -> LayoutPoint {
|
||||
LayoutPoint::new(point.x / self.0, point.y / self.0)
|
||||
}
|
||||
|
||||
fn size(self, size: LayoutSize) -> LayoutSize {
|
||||
LayoutSize::new(size.width / self.0, size.height / self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl<N> FrozenLayoutTree<N>
|
||||
where
|
||||
N: Copy + Debug + Eq + Hash,
|
||||
@@ -573,6 +604,7 @@ where
|
||||
return None;
|
||||
}
|
||||
let base_is_positioned = geometry.position != LayoutPosition::Static;
|
||||
let base_effective_zoom = geometry.effective_zoom;
|
||||
let mut in_fixed_position_chain = geometry.position == LayoutPosition::Fixed;
|
||||
let mut candidate = geometry.structural_parent;
|
||||
while let Some(id) = candidate {
|
||||
@@ -606,6 +638,14 @@ where
|
||||
{
|
||||
return Some(id);
|
||||
}
|
||||
// CSSOM View preserves WebKit/Blink's long-standing extension:
|
||||
// offsetParent stops at the first exposed layout ancestor whose
|
||||
// absolute zoom differs from the target. Resolve ordinary
|
||||
// containing-block candidates first, exactly as Blink does, then
|
||||
// admit this geometry-coordinate boundary.
|
||||
if base_effective_zoom != parent.effective_zoom {
|
||||
return Some(id);
|
||||
}
|
||||
in_fixed_position_chain |= parent.position == LayoutPosition::Fixed;
|
||||
candidate = parent.structural_parent;
|
||||
}
|
||||
@@ -698,6 +738,7 @@ mod tests {
|
||||
vec![FrozenLayoutBox {
|
||||
geometry: LayoutBoxGeometry {
|
||||
id: box_id,
|
||||
effective_zoom: 1.0,
|
||||
structural_parent: None,
|
||||
parent: None,
|
||||
layout_parent: None,
|
||||
|
||||
@@ -545,6 +545,7 @@ where
|
||||
.global_layout_origin(LayoutBoxId::from_index(index));
|
||||
self.boxes.push(LayoutBoxGeometry {
|
||||
id,
|
||||
effective_zoom: layout_box.style.effective_zoom(),
|
||||
structural_parent: layout_box
|
||||
.structural_parent
|
||||
.map(|parent| LayoutOutputBoxId::from_index(parent.index())),
|
||||
|
||||
@@ -1333,6 +1333,19 @@ impl ResolvedLayoutStyle {
|
||||
self.paint_containment
|
||||
}
|
||||
|
||||
/// Returns the accumulated CSS `zoom` applied to this box's layout values.
|
||||
///
|
||||
/// Stylo has already applied this factor to computed CSS lengths. Layout,
|
||||
/// paint, and client rects retain that zoomed space. Browser state is
|
||||
/// scaled into it on import, while CSSOM integer box and scroll metrics
|
||||
/// remove the factor when they are published. Synthetic styles have no
|
||||
/// Stylo value and therefore use the initial factor.
|
||||
pub(crate) fn effective_zoom(&self) -> f32 {
|
||||
self.computed
|
||||
.as_ref()
|
||||
.map_or(1.0, |computed| computed.effective_zoom.value())
|
||||
}
|
||||
|
||||
pub(crate) const fn is_visible(&self) -> bool {
|
||||
self.visible
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
use super::*;
|
||||
|
||||
fn numeric(value: &serde_json::Value, path: &str) -> f64 {
|
||||
value
|
||||
.as_f64()
|
||||
.unwrap_or_else(|| panic!("missing numeric {path}: {value}"))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn cssom_box_metrics_remove_effective_zoom_without_unzooming_client_rects() {
|
||||
run_page_vm_async_test(async move {
|
||||
let loader =
|
||||
crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader");
|
||||
let mut page_vm = test_page_vm_with_loader_and_document_url(
|
||||
&loader,
|
||||
Vec::new(),
|
||||
Url::parse("https://example.com/cssom-absolute-zoom-box-metrics.html")?,
|
||||
);
|
||||
page_vm.vm_mut().eval(
|
||||
r#"
|
||||
document.head.innerHTML = `<style>
|
||||
html,body{margin:0}
|
||||
.square{box-sizing:content-box;width:64px;height:64px;border:4px solid}
|
||||
#zoomed{zoom:4}
|
||||
#nested-parent{zoom:4}
|
||||
#nested{zoom:4}
|
||||
.button-row{display:flex;width:400px;zoom:2}
|
||||
.button-row button{flex:0 0 auto;min-width:200px;margin:0}
|
||||
</style>`;
|
||||
document.body.innerHTML = `<div id=plain class=square></div><div id=zoomed class=square></div><div id=nested-parent><div id=nested class=square></div></div><div class=button-row><button id=button>Run</button></div>`;
|
||||
'installed'
|
||||
"#,
|
||||
)?;
|
||||
page_vm.vm_mut().sync_live_document_style_sources();
|
||||
page_vm
|
||||
.vm_mut()
|
||||
.screenshot_layout_snapshot(moli_layout::PaintViewport::new(1600, 1200, 1.0))?
|
||||
.expect("CSS zoom box-metric screenshot layout");
|
||||
|
||||
let metrics = page_vm.vm_mut().eval(
|
||||
r#"JSON.stringify(Object.fromEntries(['plain','zoomed','nested','button'].map(id=>{
|
||||
const element=document.getElementById(id),rect=element.getBoundingClientRect();
|
||||
return [id,{
|
||||
offset:[element.offsetWidth,element.offsetHeight],
|
||||
client:[element.clientWidth,element.clientHeight,element.clientLeft,element.clientTop],
|
||||
rect:[rect.width,rect.height]
|
||||
}];
|
||||
})))"#,
|
||||
)?;
|
||||
let metrics: serde_json::Value = serde_json::from_str(&metrics)?;
|
||||
for id in ["plain", "zoomed", "nested"] {
|
||||
assert_eq!(numeric(&metrics[id]["offset"][0], id), 72.0, "{metrics}");
|
||||
assert_eq!(numeric(&metrics[id]["offset"][1], id), 72.0, "{metrics}");
|
||||
assert_eq!(numeric(&metrics[id]["client"][0], id), 64.0, "{metrics}");
|
||||
assert_eq!(numeric(&metrics[id]["client"][1], id), 64.0, "{metrics}");
|
||||
assert_eq!(numeric(&metrics[id]["client"][2], id), 4.0, "{metrics}");
|
||||
assert_eq!(numeric(&metrics[id]["client"][3], id), 4.0, "{metrics}");
|
||||
}
|
||||
assert_eq!(numeric(&metrics["plain"]["rect"][0], "plain rect"), 72.0);
|
||||
assert_eq!(numeric(&metrics["zoomed"]["rect"][0], "zoomed rect"), 288.0);
|
||||
assert_eq!(numeric(&metrics["nested"]["rect"][0], "nested rect"), 1152.0);
|
||||
assert_eq!(numeric(&metrics["button"]["offset"][0], "button offset"), 200.0);
|
||||
assert_eq!(numeric(&metrics["button"]["rect"][0], "button rect"), 400.0);
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await
|
||||
.expect("CSS zoom box-metric fixture should run");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn cssom_root_client_size_remains_in_layout_viewport_space_under_zoom() {
|
||||
run_page_vm_async_test(async move {
|
||||
let loader =
|
||||
crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader");
|
||||
let mut page_vm = test_page_vm_with_loader_and_document_url(
|
||||
&loader,
|
||||
Vec::new(),
|
||||
Url::parse("https://example.com/cssom-root-zoom-client-size.html")?,
|
||||
);
|
||||
page_vm.vm_mut().eval(
|
||||
r#"
|
||||
document.documentElement.style.zoom='2';
|
||||
document.body.style.margin='0';
|
||||
document.body.innerHTML='<div id=child style="width:100px;height:20px"></div>';
|
||||
'installed'
|
||||
"#,
|
||||
)?;
|
||||
page_vm.vm_mut().sync_live_document_style_sources();
|
||||
page_vm
|
||||
.vm_mut()
|
||||
.screenshot_layout_snapshot(moli_layout::PaintViewport::new(800, 600, 1.0))?
|
||||
.expect("root zoom CSSOM screenshot layout");
|
||||
|
||||
let metrics = page_vm.vm_mut().eval(
|
||||
r#"JSON.stringify((()=>{const root=document.documentElement,child=document.getElementById('child'),rootRect=root.getBoundingClientRect(),childRect=child.getBoundingClientRect();return {rootClient:[root.clientWidth,root.clientHeight],rootWidth:[root.offsetWidth,rootRect.width],childWidth:[child.clientWidth,child.offsetWidth,childRect.width]}})())"#,
|
||||
)?;
|
||||
let metrics: serde_json::Value = serde_json::from_str(&metrics)?;
|
||||
assert_eq!(metrics["rootClient"], serde_json::json!([800, 600]));
|
||||
assert_eq!(numeric(&metrics["rootWidth"][0], "root offset width"), 400.0);
|
||||
assert_eq!(numeric(&metrics["rootWidth"][1], "root rect width"), 800.0);
|
||||
for (index, expected) in [100.0, 100.0, 200.0].into_iter().enumerate() {
|
||||
assert_eq!(numeric(&metrics["childWidth"][index], "child width"), expected);
|
||||
}
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await
|
||||
.expect("root zoom CSSOM fixture should run");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn cssom_offsets_resolve_the_offset_parent_before_removing_target_zoom() {
|
||||
run_page_vm_async_test(async move {
|
||||
let loader =
|
||||
crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader");
|
||||
let mut page_vm = test_page_vm_with_loader_and_document_url(
|
||||
&loader,
|
||||
Vec::new(),
|
||||
Url::parse("https://example.com/cssom-absolute-zoom-offsets.html")?,
|
||||
);
|
||||
page_vm.vm_mut().eval(
|
||||
r#"
|
||||
document.head.innerHTML = `<style>
|
||||
.outer{width:100px;height:100px;border:1px solid;position:relative;margin:10px}
|
||||
.square{width:10px;height:10px;margin:1px}
|
||||
.one{position:relative;top:10px;left:10px}
|
||||
.two{position:absolute;top:20px;left:20px;zoom:2}
|
||||
.three{position:absolute;top:10px;left:50px;zoom:.5}
|
||||
</style>`;
|
||||
document.body.innerHTML = `
|
||||
<div class=outer><div id=u1 class="square one"></div><div id=u2 class="square two"></div><div id=u3 class="square three"></div></div>
|
||||
<div class=outer style="zoom:3"><div id=z1 class="square one"></div><div id=z2 class="square two"></div><div id=z3 class="square three"></div></div>
|
||||
<div id=outer class=outer style="margin:30px"><div style="margin:10px;zoom:2"><div id=inner-in-zoom class=square></div></div></div>
|
||||
<div class=outer style="margin:30px"><div id=zoom-boundary><div id=zoomed-inner class=square style="zoom:2;width:100px;height:100px;border:1px solid"></div></div></div>`;
|
||||
'installed'
|
||||
"#,
|
||||
)?;
|
||||
page_vm.vm_mut().sync_live_document_style_sources();
|
||||
page_vm
|
||||
.vm_mut()
|
||||
.screenshot_layout_snapshot(moli_layout::PaintViewport::new(1200, 1200, 1.0))?
|
||||
.expect("CSS zoom offset screenshot layout");
|
||||
|
||||
let offsets = page_vm.vm_mut().eval(
|
||||
r#"JSON.stringify(Object.fromEntries(['u1','u2','u3','z1','z2','z3','inner-in-zoom','zoomed-inner'].map(id=>{const e=document.getElementById(id);return [id,[e.offsetTop,e.offsetLeft,e.offsetWidth,e.offsetHeight,e.offsetParent&&e.offsetParent.id]]})))"#,
|
||||
)?;
|
||||
let offsets: serde_json::Value = serde_json::from_str(&offsets)?;
|
||||
for (id, expected) in [
|
||||
("u1", [11.0, 11.0]),
|
||||
("u2", [21.0, 21.0]),
|
||||
("u3", [11.0, 51.0]),
|
||||
("z1", [11.0, 11.0]),
|
||||
("z2", [21.0, 21.0]),
|
||||
("z3", [11.0, 51.0]),
|
||||
("inner-in-zoom", [10.0, 11.0]),
|
||||
("zoomed-inner", [0.0, 1.0]),
|
||||
] {
|
||||
assert_eq!(numeric(&offsets[id][0], id), expected[0], "{offsets}");
|
||||
assert_eq!(numeric(&offsets[id][1], id), expected[1], "{offsets}");
|
||||
}
|
||||
assert_eq!(offsets["zoomed-inner"][4], "zoom-boundary");
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await
|
||||
.expect("CSS zoom offset fixture should run");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn cssom_scroll_metrics_and_scroll_state_share_unzoomed_units() {
|
||||
run_page_vm_async_test(async move {
|
||||
let loader =
|
||||
crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader");
|
||||
let mut page_vm = test_page_vm_with_loader_and_document_url(
|
||||
&loader,
|
||||
Vec::new(),
|
||||
Url::parse("https://example.com/cssom-absolute-zoom-scroll.html")?,
|
||||
);
|
||||
page_vm.vm_mut().eval(
|
||||
r#"
|
||||
document.head.innerHTML = `<style>
|
||||
html,body{margin:0}
|
||||
.container{height:100px;width:100px;overflow:scroll}
|
||||
.content{height:250px;width:250px}
|
||||
#zoomed{zoom:4}
|
||||
</style>`;
|
||||
document.body.innerHTML = `
|
||||
<div class=container id=plain><div class=content></div></div>
|
||||
<div class=container id=zoomed><div class=content></div></div>
|
||||
<div class=container id=zoomed-content><div class=content style="zoom:2"></div></div>
|
||||
<div style="zoom:2"><div class=container id=nested><div class=content></div></div></div>`;
|
||||
'installed'
|
||||
"#,
|
||||
)?;
|
||||
page_vm.vm_mut().sync_live_document_style_sources();
|
||||
page_vm
|
||||
.vm_mut()
|
||||
.screenshot_layout_snapshot(moli_layout::PaintViewport::new(1200, 1400, 1.0))?
|
||||
.expect("CSS zoom initial scroll layout");
|
||||
|
||||
let initial = page_vm.vm_mut().eval(
|
||||
r#"JSON.stringify(Object.fromEntries(['plain','zoomed','zoomed-content','nested'].map(id=>{const e=document.getElementById(id);return [id,[e.clientWidth,e.clientHeight,e.scrollWidth,e.scrollHeight]]})))"#,
|
||||
)?;
|
||||
let initial: serde_json::Value = serde_json::from_str(&initial)?;
|
||||
// Chromium keeps the classic scrollbar at 15 layout pixels instead
|
||||
// of scaling it with CSS zoom. Removing each container's effective
|
||||
// zoom therefore exposes 85px, 96.25px, and 92.5px client widths,
|
||||
// rounded by the CSSOM integer API to 85, 96, and 93 respectively.
|
||||
for (id, expected) in [
|
||||
("plain", [85.0, 85.0, 250.0, 250.0]),
|
||||
("zoomed", [96.0, 96.0, 250.0, 250.0]),
|
||||
("zoomed-content", [85.0, 85.0, 500.0, 500.0]),
|
||||
("nested", [93.0, 93.0, 250.0, 250.0]),
|
||||
] {
|
||||
for (index, expected) in expected.into_iter().enumerate() {
|
||||
assert_eq!(numeric(&initial[id][index], id), expected, "{initial}");
|
||||
}
|
||||
}
|
||||
|
||||
page_vm.vm_mut().eval(
|
||||
"document.getElementById('plain').scrollTo(125,125);document.getElementById('zoomed').scrollTo(125,125);'scrolled'",
|
||||
)?;
|
||||
page_vm
|
||||
.vm_mut()
|
||||
.screenshot_layout_snapshot(moli_layout::PaintViewport::new(1200, 1400, 1.0))?
|
||||
.expect("CSS zoom scrolled layout");
|
||||
let offsets = page_vm.vm_mut().eval(
|
||||
"JSON.stringify(['plain','zoomed'].map(id=>{const e=document.getElementById(id);return [e.scrollLeft,e.scrollTop]}))",
|
||||
)?;
|
||||
let offsets: serde_json::Value = serde_json::from_str(&offsets)?;
|
||||
assert_eq!(offsets, serde_json::json!([[125, 125], [125, 125]]));
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await
|
||||
.expect("CSS zoom scroll fixture should run");
|
||||
}
|
||||
@@ -116,6 +116,7 @@ mod child_parser_module_root_start_completion;
|
||||
mod child_realm_materialization;
|
||||
mod child_realm_materialization_completion;
|
||||
mod command_checkpoint;
|
||||
mod cssom_zoom;
|
||||
mod dedicated_worker_client_event;
|
||||
mod document_script_completion;
|
||||
mod element_toggle_event;
|
||||
|
||||
Reference in New Issue
Block a user