diff --git a/moli-layout/src/layout_tree/hit_test.rs b/moli-layout/src/layout_tree/hit_test.rs index 0918a57e34..fc0e1d4fe4 100644 --- a/moli-layout/src/layout_tree/hit_test.rs +++ b/moli-layout/src/layout_tree/hit_test.rs @@ -34,6 +34,18 @@ pub struct LayoutHit { pub fragment: Option, pub local_point: LayoutPoint, pub is_text: bool, + /// The exact physical content box in the hit fragment's own coordinate + /// space. + /// + /// Unlike `box_model`, this rectangle has not been projected through CSS + /// transforms. Embedded-content consumers use it to enter the child frame + /// without reconstructing the iframe's used size from authored CSS. + pub local_content_box: Option, + /// Converts a point from this tree's viewport into the hit fragment's + /// coordinate space. This is retained on the short-lived hit result so + /// frame and native-scrollbar routing can compose the same inverse + /// transforms that produced `local_point`. + pub viewport_to_local: LayoutTransform2D, /// Box geometry copied from the same frozen tree when the hit source owns a /// CSS box. Consumers use it for source-dependent follow-up work such as /// descending through a transformed child-frame content box without @@ -363,11 +375,21 @@ where .local_to_viewport .inverse()?; let local_point = inverse.map_point(viewport_point); + let local_content_box = self + .fragment(entry.fragment) + .and_then(|fragment| fragment.box_model.map(|model| model.content)) + .or_else(|| { + let box_id = self.fragment_box_id(entry.fragment)?; + let geometry = self.box_geometry(box_id)?; + Some(geometry.content_box) + }); entry.local_rect.contains(local_point).then_some(LayoutHit { source: entry.source, fragment: Some(entry.fragment), local_point, is_text: entry.is_text, + local_content_box, + viewport_to_local: inverse, box_model: self.box_model_for_source(entry.source), }) } diff --git a/moli-layout/src/layout_tree/source_query.rs b/moli-layout/src/layout_tree/source_query.rs index 1be7351d4b..a6110482e5 100644 --- a/moli-layout/src/layout_tree/source_query.rs +++ b/moli-layout/src/layout_tree/source_query.rs @@ -53,6 +53,19 @@ where self.element_metrics_for_source_with_offset_parent_filter(source, |_| true) } + /// Returns the exact unprojected content box produced for a source's + /// principal layout box. + /// + /// Embedded browsing contexts use this as their LocalFrameView size. The + /// value deliberately comes from used layout geometry rather than + /// authored width/height CSS, and therefore remains correct for + /// percentages, `calc()`, box sizing, borders, padding, and transforms. + pub fn local_content_box_for_source(&self, source: N) -> Option { + let box_id = self.source_output(source)?.principal_box?; + self.box_geometry(box_id) + .map(|geometry| geometry.content_box) + } + /// Resolves CSSOM View element metrics while allowing the renderer to hide /// flat-tree ancestors that do not belong to the queried element's /// ancestor tree scopes. diff --git a/moli-renderer-v8/src/native_bridge/context_host/layout.rs b/moli-renderer-v8/src/native_bridge/context_host/layout.rs index 7b1c95049f..07f9978dab 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/layout.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/layout.rs @@ -93,6 +93,21 @@ impl JsContextHost { ) } + /// Returns the last exact used content viewport published by the iframe's + /// parent layout. A non-blocking borrow is intentional: child style + /// resolution can run while the document layout state is already lent to + /// a recursive paint pass, in which case the caller uses its ordinary + /// authored-style fallback. + pub(crate) fn retained_iframe_layout_viewport( + &self, + frame: DomHandle, + ) -> Option { + self.document_layout_state + .try_borrow() + .ok()? + .embedded_frame_viewport(frame) + } + pub(crate) fn with_fresh_layout_pass_for_document( &self, document: DomHandle, @@ -106,6 +121,7 @@ impl JsContextHost { else { return Ok(None); }; + let pass_viewport = request.viewport; let _active = ActiveLayoutPass::enter(&self.layout_pass_active)?; let mut pass = { let mut state = self.document_layout_state.borrow_mut(); @@ -113,6 +129,9 @@ impl JsContextHost { self.child_browsing_context_host_for_document_handle(candidate) .is_some() }); + state.retain_live_embedded_frame_viewports(|frame| { + self.child_browsing_context_is_live(frame) + }); state.with_services_for_document( document, self.document_handle(), @@ -137,10 +156,30 @@ impl JsContextHost { pass.validate_retention_budget()?; let consumed = consume(&mut pass)?; let metrics = pass.metrics; + let embedded_frame_viewports = self + .child_browsing_context_handles_in_document_order() + .into_iter() + .filter(|frame| self.dom_host().owner_document_handle(*frame) == Some(document)) + .map(|frame| { + let viewport = pass + .tree + .local_content_box_for_source(frame) + .map(|content| { + LayoutViewport::new( + css_viewport_dimension(f64::from(content.width)), + css_viewport_dimension(f64::from(content.height)), + pass_viewport.device_pixel_ratio, + ) + }); + (frame, viewport) + }) + .collect::>(); let tree = pass.into_tree(); - self.document_layout_state - .borrow_mut() - .publish_latest_layout(document, tree); + { + let mut state = self.document_layout_state.borrow_mut(); + state.publish_latest_layout(document, tree); + state.publish_embedded_frame_viewports(embedded_frame_viewports); + } self.last_layout_pass_metrics.set(Some(metrics)); self.layout_snapshot_cache_publishes .set(self.layout_snapshot_cache_publishes.get().saturating_add(1)); @@ -189,6 +228,31 @@ impl JsContextHost { reason: LayoutFlushReason, viewport: LayoutViewport, queries: &LayoutQueryBatch, + ) -> Result, LayoutError> { + self.answer_layout_for_document_with_viewport_policy( + document, reason, viewport, queries, false, + ) + } + + pub(crate) fn answer_layout_for_document_with_exact_viewport( + &self, + document: DomHandle, + reason: LayoutFlushReason, + viewport: LayoutViewport, + queries: &LayoutQueryBatch, + ) -> Result, LayoutError> { + self.answer_layout_for_document_with_viewport_policy( + document, reason, viewport, queries, true, + ) + } + + fn answer_layout_for_document_with_viewport_policy( + &self, + document: DomHandle, + reason: LayoutFlushReason, + viewport: LayoutViewport, + queries: &LayoutQueryBatch, + require_exact_viewport: bool, ) -> Result, LayoutError> { #[cfg(test)] let reuse_latest = !self.force_fresh_layout_reads_for_test; @@ -196,11 +260,14 @@ impl JsContextHost { let reuse_latest = true; let cached = if reuse_latest { let state = self.document_layout_state.borrow(); - state.latest_layout(document).and_then(|tree| { - self.last_layout_pass_metrics - .get() - .map(|metrics| self.answer_layout_queries(tree, metrics, viewport, queries)) - }) + state + .latest_layout(document) + .filter(|tree| !require_exact_viewport || tree.viewport == viewport) + .and_then(|tree| { + self.last_layout_pass_metrics + .get() + .map(|metrics| self.answer_layout_queries(tree, metrics, viewport, queries)) + }) } else { None }; diff --git a/moli-renderer-v8/src/native_bridge/context_host/layout_state.rs b/moli-renderer-v8/src/native_bridge/context_host/layout_state.rs index 3d71557554..76a1b40ad1 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/layout_state.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/layout_state.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use moli_layout::{DocumentLayoutServices, FrozenLayoutTree}; +use moli_layout::{DocumentLayoutServices, FrozenLayoutTree, LayoutViewport}; use super::layout_snapshot::LatestLayoutTreeCache; use crate::{ @@ -25,6 +25,11 @@ pub(super) struct DocumentLayoutState { web_fonts: DocumentWebFontState, web_font_sources_dirty: bool, latest_layout: LatestLayoutTreeCache, + /// Last used content viewport published by each live iframe owner's + /// parent layout. Blink keeps the equivalent size on LocalFrameView; it is + /// separate from the single latest-tree slot because parent and child + /// layout queries replace that slot while the frame view remains live. + embedded_frame_viewports: HashMap, } impl Default for DocumentLayoutState { @@ -35,6 +40,7 @@ impl Default for DocumentLayoutState { web_fonts: DocumentWebFontState::default(), web_font_sources_dirty: true, latest_layout: LatestLayoutTreeCache::default(), + embedded_frame_viewports: HashMap::new(), } } } @@ -100,6 +106,34 @@ impl DocumentLayoutState { self.latest_layout.clear(); } + pub(super) fn embedded_frame_viewport(&self, frame: DomHandle) -> Option { + self.embedded_frame_viewports.get(&frame).copied() + } + + pub(super) fn publish_embedded_frame_viewports( + &mut self, + updates: impl IntoIterator)>, + ) { + for (frame, viewport) in updates { + match viewport { + Some(viewport) => { + self.embedded_frame_viewports.insert(frame, viewport); + } + None => { + self.embedded_frame_viewports.remove(&frame); + } + } + } + } + + pub(super) fn retain_live_embedded_frame_viewports( + &mut self, + mut is_live: impl FnMut(DomHandle) -> bool, + ) { + self.embedded_frame_viewports + .retain(|frame, _| is_live(*frame)); + } + #[cfg(test)] pub(super) fn latest_layout_observability( &self, diff --git a/moli-renderer-v8/src/native_bridge/element/geometry.rs b/moli-renderer-v8/src/native_bridge/element/geometry.rs index 566ab229e6..8c131b74e8 100644 --- a/moli-renderer-v8/src/native_bridge/element/geometry.rs +++ b/moli-renderer-v8/src/native_bridge/element/geometry.rs @@ -25,10 +25,10 @@ pub(in crate::native_bridge) use metrics::{ node_scroll_width_getter_function, }; pub(crate) use provider::{ - observable_bounding_client_rect, observable_bounding_client_rects, observable_caret_position, - observable_client_rects, observable_deep_hit_test, observable_element_metrics, - observable_event_offset, observable_geometry_batch, observable_hit_test_all, - observable_input_hit_test, observable_scroll_adjusted_client_rect, + observable_bounding_client_rect, observable_bounding_client_rects, observable_box_model, + observable_caret_position, observable_client_rects, observable_deep_hit_test, + observable_element_metrics, observable_event_offset, observable_geometry_batch, + observable_hit_test_all, observable_input_hit_test, observable_scroll_adjusted_client_rect, observable_scroll_into_view_geometry, observable_scrollbar_hit_test, observable_sources_with_fragments, }; diff --git a/moli-renderer-v8/src/native_bridge/element/geometry/metrics.rs b/moli-renderer-v8/src/native_bridge/element/geometry/metrics.rs index fbf8582252..6a39466694 100644 --- a/moli-renderer-v8/src/native_bridge/element/geometry/metrics.rs +++ b/moli-renderer-v8/src/native_bridge/element/geometry/metrics.rs @@ -10,7 +10,7 @@ use super::super::super::{ use super::super::styles::raw_inline_style_property_value; use super::super::{queue_revealed_lazy_image_loads, queue_revealed_lazy_media_loads}; use super::{ - ClientRect, compute_mock_client_rect, observable_element_metrics, + ClientRect, compute_mock_client_rect, observable_box_model, observable_element_metrics, observable_scroll_into_view_geometry, }; @@ -499,6 +499,7 @@ pub(crate) fn scroll_node_into_view_if_needed( runtime_ptr, handle, relative_rect, + None, ScrollIntoViewAlignment::Nearest, ScrollIntoViewAlignment::Nearest, true, @@ -518,6 +519,7 @@ fn scroll_node_into_view( runtime_ptr, handle, None, + None, horizontal, vertical, false, @@ -544,6 +546,7 @@ fn scroll_node_into_view_with_geometry( runtime_ptr: *mut JsContextHost, handle: DomHandle, relative_rect: Option, + target_rects_override: Option>, horizontal: ScrollIntoViewAlignment, vertical: ScrollIntoViewAlignment, center_if_fully_hidden: bool, @@ -575,9 +578,6 @@ fn scroll_node_into_view_with_geometry( else { return Ok(None); }; - if geometry.target_rects.is_empty() { - return Ok(None); - } if let Some(relative) = relative_rect { let Some(bounds) = quads_bounding_rect(&geometry.target_rects) else { return Ok(None); @@ -592,6 +592,15 @@ fn scroll_node_into_view_with_geometry( )), ]; } + if let Some(target_rects) = target_rects_override { + if target_rects.is_empty() { + return Ok(None); + } + geometry.target_rects = target_rects; + } + if geometry.target_rects.is_empty() { + return Ok(None); + } let target_document = runtime.dom_host().owner_document_handle(target); let mut changed = false; @@ -686,17 +695,30 @@ fn scroll_node_into_view_with_geometry( ); } if frame_depth < 16 - && let Some(frame) = target_document.and_then(|document| { + && let Some((document, frame)) = target_document.and_then(|document| { let runtime = unsafe { &*runtime_ptr }; (document != runtime.document_handle()) .then(|| runtime.child_browsing_context_host_for_document_handle(document)) .flatten() + .map(|frame| (document, frame)) }) + && let Some(frame_content) = observable_box_model( + unsafe { &*runtime_ptr }, + frame, + moli_layout::LayoutFlushReason::SynchronousGeometry, + )? + .map(|model| model.content) + && let Some(parent_target_rects) = map_child_viewport_quads_to_parent( + &geometry.target_rects, + unsafe { &*runtime_ptr }.layout_viewport_for_document(document), + frame_content, + ) && let Some(parent_changed) = scroll_node_into_view_with_geometry( scope, runtime_ptr, frame, None, + Some(parent_target_rects), ScrollIntoViewAlignment::Nearest, ScrollIntoViewAlignment::Nearest, center_if_fully_hidden, @@ -708,6 +730,40 @@ fn scroll_node_into_view_with_geometry( Ok(Some(changed)) } +/// Convert a rectangle carried by a child frame into the parent document's +/// viewport coordinates. This mirrors Blink's cross-frame scroll bubbling: +/// the target rectangle is converted through the frame owner's content box, +/// rather than replacing it with the bounds of the whole frame. +fn map_child_viewport_quads_to_parent( + child_quads: &[moli_layout::LayoutQuad], + child_viewport: moli_layout::LayoutViewport, + parent_content: moli_layout::LayoutQuad, +) -> Option> { + let width = child_viewport.css_width as f32; + let height = child_viewport.css_height as f32; + if width <= 0.0 || height <= 0.0 { + return None; + } + let [origin, x_corner, _, y_corner] = parent_content.points; + let x_basis = moli_layout::LayoutPoint::new(x_corner.x - origin.x, x_corner.y - origin.y); + let y_basis = moli_layout::LayoutPoint::new(y_corner.x - origin.x, y_corner.y - origin.y); + Some( + child_quads + .iter() + .map(|quad| moli_layout::LayoutQuad { + points: quad.points.map(|point| { + let u = point.x / width; + let v = point.y / height; + moli_layout::LayoutPoint::new( + origin.x + x_basis.x * u + y_basis.x * v, + origin.y + x_basis.y * u + y_basis.y * v, + ) + }), + }) + .collect(), + ) +} + fn quads_bounding_rect(quads: &[moli_layout::LayoutQuad]) -> Option { quads .iter() @@ -1312,7 +1368,7 @@ fn reveal_lazy_images_for_scroll( #[cfg(test)] mod scroll_alignment_tests { - use super::scroll_axis_to_expose; + use super::{map_child_viewport_quads_to_parent, scroll_axis_to_expose}; #[test] fn center_if_needed_returns_the_unclamped_chromium_alignment_position() { @@ -1328,4 +1384,52 @@ mod scroll_alignment_tests { assert_eq!(scroll_axis_to_expose(200.0, 220.0, 0.0, viewport), 160.0); assert_eq!(scroll_axis_to_expose(0.0, 20.0, 200.0, viewport), -40.0); } + + #[test] + fn child_viewport_rect_maps_through_the_parent_content_quad() { + let child_quad = moli_layout::LayoutTransform2D::IDENTITY + .map_rect(moli_layout::LayoutRect::new(50.0, 25.0, 100.0, 50.0)); + let parent_content = moli_layout::LayoutQuad { + points: [ + moli_layout::LayoutPoint::new(10.0, 20.0), + moli_layout::LayoutPoint::new(110.0, 40.0), + moli_layout::LayoutPoint::new(90.0, 100.0), + moli_layout::LayoutPoint::new(-10.0, 80.0), + ], + }; + + let mapped = map_child_viewport_quads_to_parent( + &[child_quad], + moli_layout::LayoutViewport::new(200, 100, 1.0), + parent_content, + ) + .expect("non-empty child viewport should map"); + + assert_eq!( + mapped[0].points, + [ + moli_layout::LayoutPoint::new(30.0, 40.0), + moli_layout::LayoutPoint::new(80.0, 50.0), + moli_layout::LayoutPoint::new(70.0, 80.0), + moli_layout::LayoutPoint::new(20.0, 70.0), + ] + ); + } + + #[test] + fn empty_child_viewport_cannot_produce_parent_geometry() { + let child_quad = moli_layout::LayoutTransform2D::IDENTITY + .map_rect(moli_layout::LayoutRect::new(0.0, 0.0, 10.0, 10.0)); + let parent_content = moli_layout::LayoutTransform2D::IDENTITY + .map_rect(moli_layout::LayoutRect::new(0.0, 0.0, 10.0, 10.0)); + + assert!( + map_child_viewport_quads_to_parent( + &[child_quad], + moli_layout::LayoutViewport::new(0, 100, 1.0), + parent_content, + ) + .is_none() + ); + } } diff --git a/moli-renderer-v8/src/native_bridge/element/geometry/provider.rs b/moli-renderer-v8/src/native_bridge/element/geometry/provider.rs index 4fcde04746..ddb2380c68 100644 --- a/moli-renderer-v8/src/native_bridge/element/geometry/provider.rs +++ b/moli-renderer-v8/src/native_bridge/element/geometry/provider.rs @@ -5,6 +5,7 @@ use moli_layout::{ LayoutElementMetrics, LayoutError, LayoutFlushReason, LayoutHit, LayoutIntersectionGeometry, LayoutPassMetrics, LayoutPoint, LayoutQuad, LayoutQuery, LayoutQueryAnswer, LayoutQueryBatch, LayoutScrollContainerMetrics, LayoutScrollIntoViewGeometry, LayoutScrollbarHit, LayoutSize, + LayoutTransform2D, LayoutViewport, }; use super::layout::{ @@ -16,6 +17,16 @@ use crate::{document_runtime::DomHandle, dom::native::Node, native_bridge::JsCon const HIT_TEST_CHILD_FRAME_DEPTH_LIMIT: usize = 16; +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct ObservableInputHit { + pub(crate) handle: DomHandle, + /// Converts a root-frame input position to viewport-relative CSS + /// coordinates in `handle`'s owner Document. Keeping the affine map lets + /// boundary and capture events convert a new root position into a + /// previously targeted child Document. + pub(crate) root_to_client: LayoutTransform2D, +} + pub(crate) fn observable_geometry_batch( runtime: &JsContextHost, document: DomHandle, @@ -149,6 +160,29 @@ pub(crate) fn observable_bounding_client_rects( .collect() } +pub(crate) fn observable_box_model( + runtime: &JsContextHost, + source: DomHandle, + reason: LayoutFlushReason, +) -> Result, LayoutError> { + if !runtime.dom_host().is_connected(source) { + return Ok(None); + } + let Some(document) = runtime.layout_document_for_source(source) else { + return Ok(None); + }; + let answers = observable_geometry_batch( + runtime, + document, + reason, + &LayoutQueryBatch::new(vec![LayoutQuery::BoxModel { source }]), + )?; + match answers.answers.into_iter().next() { + Some(LayoutQueryAnswer::BoxModel(model)) => Ok(model), + _ => Err(provider_contract_error("box model")), + } +} + /// Resolve one viewport-relative box for scroll anchoring. Real layout already /// projects root scrolling into viewport coordinates; the explicit adjustment /// is retained only inside the legacy Mock provider. @@ -260,6 +294,32 @@ fn observable_hit_test( } } +fn observable_hit_test_with_viewport( + runtime: &JsContextHost, + document: DomHandle, + viewport: LayoutViewport, + point: LayoutPoint, + ignore_pointer_events_none: bool, + reason: LayoutFlushReason, +) -> Result>, LayoutError> { + if !runtime.layout_policy().uses_real_layout() { + return observable_hit_test(runtime, document, point, ignore_pointer_events_none, reason); + } + let answers = runtime.answer_layout_for_document_with_exact_viewport( + document, + reason, + viewport, + &LayoutQueryBatch::new(vec![LayoutQuery::HitTest { + point, + ignore_pointer_events_none, + }]), + )?; + match answers.answers.into_iter().next() { + Some(LayoutQueryAnswer::HitTest(hit)) => Ok(hit), + _ => Err(provider_contract_error("viewport-scoped hit test")), + } +} + pub(crate) fn observable_caret_position( runtime: &JsContextHost, document: DomHandle, @@ -295,8 +355,8 @@ pub(crate) fn observable_input_hit_test( runtime: &JsContextHost, document: DomHandle, point: LayoutPoint, -) -> Result, LayoutError> { - observable_deep_hit_test(runtime, document, point, false) +) -> Result, LayoutError> { + observable_deep_input_hit_test(runtime, document, point, false) } pub(crate) fn observable_scrollbar_hit_test( @@ -307,17 +367,14 @@ pub(crate) fn observable_scrollbar_hit_test( if !runtime.layout_policy().uses_real_layout() { return Ok(None); } - // Publish a current tree through the normal on-demand layout boundary; - // the control hit itself deliberately does not become another query model. - let _ = observable_geometry_batch( + observable_deep_scrollbar_hit_test_inner( runtime, document, - LayoutFlushReason::HitTest, - &LayoutQueryBatch::new(vec![LayoutQuery::DocumentMetrics]), - )?; - Ok(runtime - .with_latest_layout_tree_for_document(document, |tree| tree.scrollbar_hit_test(point)) - .flatten()) + runtime.layout_viewport_for_document(document), + point, + LayoutTransform2D::IDENTITY, + 0, + ) } pub(crate) fn observable_deep_hit_test( @@ -326,19 +383,150 @@ pub(crate) fn observable_deep_hit_test( point: LayoutPoint, ignore_pointer_events_none: bool, ) -> Result, LayoutError> { - observable_deep_hit_test_inner(runtime, document, point, ignore_pointer_events_none, 0) + Ok( + observable_deep_input_hit_test(runtime, document, point, ignore_pointer_events_none)? + .map(|hit| hit.handle), + ) } -fn observable_deep_hit_test_inner( +fn observable_deep_input_hit_test( runtime: &JsContextHost, document: DomHandle, point: LayoutPoint, ignore_pointer_events_none: bool, - depth: usize, -) -> Result, LayoutError> { - let first_hit = observable_hit_test( +) -> Result, LayoutError> { + observable_deep_input_hit_test_inner( runtime, document, + runtime.layout_viewport_for_document(document), + point, + LayoutTransform2D::IDENTITY, + ignore_pointer_events_none, + 0, + ) +} + +fn observable_deep_input_hit_test_inner( + runtime: &JsContextHost, + document: DomHandle, + viewport: LayoutViewport, + point: LayoutPoint, + root_to_viewport: LayoutTransform2D, + ignore_pointer_events_none: bool, + depth: usize, +) -> Result, LayoutError> { + let Some((hit, target)) = observable_live_hit_with_viewport( + runtime, + document, + viewport, + point, + ignore_pointer_events_none, + )? + else { + return Ok(None); + }; + let target_hit = ObservableInputHit { + handle: target, + root_to_client: root_to_viewport, + }; + if depth >= HIT_TEST_CHILD_FRAME_DEPTH_LIMIT { + return Ok(Some(target_hit)); + } + let Some(child_document) = runtime.child_browsing_context_document_handle(target) else { + return Ok(Some(target_hit)); + }; + let Some(content_box) = hit.local_content_box else { + return Ok(Some(target_hit)); + }; + if !content_box.contains(hit.local_point) { + return Ok(Some(target_hit)); + } + let viewport_to_child = LayoutTransform2D::translation(-content_box.x, -content_box.y) + .concatenate(hit.viewport_to_local); + let child_point = viewport_to_child.map_point(point); + let root_to_child = viewport_to_child.concatenate(root_to_viewport); + let child_viewport = LayoutViewport::new( + css_viewport_dimension(content_box.width), + css_viewport_dimension(content_box.height), + viewport.device_pixel_ratio, + ); + Ok(observable_deep_input_hit_test_inner( + runtime, + child_document, + child_viewport, + child_point, + root_to_child, + ignore_pointer_events_none, + depth + 1, + )? + .or(Some(target_hit))) +} + +fn observable_deep_scrollbar_hit_test_inner( + runtime: &JsContextHost, + document: DomHandle, + viewport: LayoutViewport, + point: LayoutPoint, + root_to_viewport: LayoutTransform2D, + depth: usize, +) -> Result>, LayoutError> { + // Publish a current tree through the same exact-viewport hit-test + // boundary used for DOM input. Scrollbar geometry intentionally remains a + // frozen-tree control query because it is user-agent chrome, not a DOM + // target. + let live_hit = observable_live_hit_with_viewport(runtime, document, viewport, point, false)?; + if let Some(mut hit) = runtime + .with_latest_layout_tree_for_document(document, |tree| tree.scrollbar_hit_test(point)) + .flatten() + { + hit.viewport_to_local = hit.viewport_to_local.concatenate(root_to_viewport); + return Ok(Some(hit)); + } + if depth >= HIT_TEST_CHILD_FRAME_DEPTH_LIMIT { + return Ok(None); + } + let Some((frame_hit, frame)) = live_hit else { + return Ok(None); + }; + let Some(child_document) = runtime.child_browsing_context_document_handle(frame) else { + return Ok(None); + }; + let Some(content_box) = frame_hit.local_content_box else { + return Ok(None); + }; + if !content_box.contains(frame_hit.local_point) { + return Ok(None); + } + let viewport_to_child = LayoutTransform2D::translation(-content_box.x, -content_box.y) + .concatenate(frame_hit.viewport_to_local); + let root_to_child = viewport_to_child.concatenate(root_to_viewport); + let child_point = viewport_to_child.map_point(point); + let child_viewport = LayoutViewport::new( + css_viewport_dimension(content_box.width), + css_viewport_dimension(content_box.height), + viewport.device_pixel_ratio, + ); + observable_deep_scrollbar_hit_test_inner( + runtime, + child_document, + child_viewport, + child_point, + root_to_child, + depth + 1, + ) +} + +fn observable_live_hit_with_viewport( + runtime: &JsContextHost, + document: DomHandle, + viewport: LayoutViewport, + point: LayoutPoint, + ignore_pointer_events_none: bool, +) -> Result, DomHandle)>, LayoutError> { + let first_hit = observable_hit_test_with_viewport( + runtime, + document, + viewport, point, ignore_pointer_events_none, LayoutFlushReason::HitTest, @@ -355,55 +543,24 @@ fn observable_deep_hit_test_inner( // paint stack only on that stale-source path until it identifies a // currently connected element. This live DOM check neither refreshes nor // invalidates layout; the common case retains the single-hit fast path. - let fallback_hit = if live_first_hit.is_none() && first_hit.is_some() { - let (_, hits) = observable_hit_test_all( - runtime, - document, - point, - ignore_pointer_events_none, - LayoutFlushReason::HitTest, - )?; - hits.into_iter().find_map(|hit| { - let target = element_for_hit_source(runtime, hit.source)?; - runtime - .dom_host() - .is_connected(target) - .then_some((hit, target)) - }) - } else { - None - }; - let Some((hit, target)) = live_first_hit.or(fallback_hit) else { - return Ok(None); - }; - if depth >= HIT_TEST_CHILD_FRAME_DEPTH_LIMIT { - return Ok(Some(target)); + if live_first_hit.is_some() || first_hit.is_none() { + return Ok(live_first_hit); } - let Some(child_document) = runtime.child_browsing_context_document_handle(target) else { - return Ok(Some(target)); - }; - let Some(content_quad) = hit.box_model.map(|model| model.content) else { - return Ok(Some(target)); - }; - let child_viewport = runtime.layout_viewport_for_document(child_document); - let Some(child_point) = map_point_into_quad( - point, - content_quad, - LayoutSize::new( - child_viewport.css_width as f32, - child_viewport.css_height as f32, - ), - ) else { - return Ok(Some(target)); - }; - Ok(observable_deep_hit_test_inner( + let hits = observable_hit_test_all_with_viewport( runtime, - child_document, - child_point, + document, + viewport, + point, ignore_pointer_events_none, - depth + 1, - )? - .or(Some(target))) + LayoutFlushReason::HitTest, + )?; + Ok(hits.into_iter().find_map(|hit| { + let target = element_for_hit_source(runtime, hit.source)?; + runtime + .dom_host() + .is_connected(target) + .then_some((hit, target)) + })) } fn element_for_hit_source(runtime: &JsContextHost, mut source: DomHandle) -> Option { @@ -420,36 +577,6 @@ fn element_for_hit_source(runtime: &JsContextHost, mut source: DomHandle) -> Opt } } -fn map_point_into_quad( - point: LayoutPoint, - quad: LayoutQuad, - target_size: LayoutSize, -) -> Option { - let [origin, x_corner, _, y_corner] = quad.points; - let basis_x = ( - f64::from(x_corner.x - origin.x), - f64::from(x_corner.y - origin.y), - ); - let basis_y = ( - f64::from(y_corner.x - origin.x), - f64::from(y_corner.y - origin.y), - ); - let relative = (f64::from(point.x - origin.x), f64::from(point.y - origin.y)); - let determinant = basis_x.0 * basis_y.1 - basis_x.1 * basis_y.0; - if !determinant.is_finite() || determinant.abs() <= f64::EPSILON { - return None; - } - let u = (relative.0 * basis_y.1 - relative.1 * basis_y.0) / determinant; - let v = (basis_x.0 * relative.1 - basis_x.1 * relative.0) / determinant; - if !(0.0..=1.0).contains(&u) || !(0.0..=1.0).contains(&v) { - return None; - } - Some(LayoutPoint::new( - (u * f64::from(target_size.width)) as f32, - (v * f64::from(target_size.height)) as f32, - )) -} - pub(crate) fn observable_hit_test_all( runtime: &JsContextHost, document: DomHandle, @@ -479,6 +606,46 @@ pub(crate) fn observable_hit_test_all( } } +fn observable_hit_test_all_with_viewport( + runtime: &JsContextHost, + document: DomHandle, + viewport: LayoutViewport, + point: LayoutPoint, + ignore_pointer_events_none: bool, + reason: LayoutFlushReason, +) -> Result>, LayoutError> { + if !runtime.layout_policy().uses_real_layout() { + return observable_hit_test_all( + runtime, + document, + point, + ignore_pointer_events_none, + reason, + ) + .map(|(_, hits)| hits); + } + let answers = runtime.answer_layout_for_document_with_exact_viewport( + document, + reason, + viewport, + &LayoutQueryBatch::new(vec![LayoutQuery::HitTestAll { + point, + ignore_pointer_events_none, + }]), + )?; + match answers.answers.into_iter().next() { + Some(LayoutQueryAnswer::HitTestAll(hits)) => Ok(hits), + _ => Err(provider_contract_error("viewport-scoped complete hit test")), + } +} + +fn css_viewport_dimension(value: f32) -> u32 { + if !value.is_finite() { + return 0; + } + value.round().clamp(0.0, u32::MAX as f32) as u32 +} + fn answer_mock_queries( runtime: &JsContextHost, document: DomHandle, @@ -554,6 +721,8 @@ fn answer_mock_queries( fragment: None, local_point: *point, is_text: false, + local_content_box: None, + viewport_to_local: moli_layout::LayoutTransform2D::IDENTITY, box_model: mock_box_model(runtime, source), }), ), @@ -568,6 +737,8 @@ fn answer_mock_queries( fragment: None, local_point: *point, is_text: false, + local_content_box: None, + viewport_to_local: moli_layout::LayoutTransform2D::IDENTITY, box_model: mock_box_model(runtime, source), }] }) diff --git a/moli-renderer-v8/src/native_bridge/element/styles.rs b/moli-renderer-v8/src/native_bridge/element/styles.rs index fca902de54..654c66c035 100644 --- a/moli-renderer-v8/src/native_bridge/element/styles.rs +++ b/moli-renderer-v8/src/native_bridge/element/styles.rs @@ -692,6 +692,15 @@ fn iframe_handle_viewport_with_depth( .filter(|parent_frame| *parent_frame != frame_handle) .and_then(|parent_frame| iframe_handle_viewport_with_depth(host, parent_frame, depth + 1)); let parent_viewport = parent_viewport.unwrap_or_else(|| host.style_viewport()); + if let Some(viewport) = host.retained_iframe_layout_viewport(frame_handle) { + return Some( + StyleViewport::new( + Some(f64::from(viewport.css_width)), + Some(f64::from(viewport.css_height)), + ) + .with_screen_size(parent_viewport.screen_width, parent_viewport.screen_height), + ); + } let computed_width = style_property_value_with_context( host, frame_handle, diff --git a/moli-renderer-v8/src/script_vm.rs b/moli-renderer-v8/src/script_vm.rs index c4b108a8cf..64a9e14e39 100644 --- a/moli-renderer-v8/src/script_vm.rs +++ b/moli-renderer-v8/src/script_vm.rs @@ -924,6 +924,11 @@ pub(super) struct ScriptVm { pressed_mouse_buttons: i32, pending_mouse_press: Option, hovered_mouse_handle: Option, + /// Root-frame to client-coordinate transform for the Document that owns + /// `hovered_mouse_handle`. Blink keeps this conversion on LocalFrameView; + /// retaining the last affine map lets exit events use the old frame even + /// after the new hit has entered another frame. + hovered_mouse_root_to_client: moli_layout::LayoutTransform2D, active_touch_pointer_handle: Option, active_touch_pointer_handles: BTreeMap, active_touch_event_handle: Option, @@ -2309,6 +2314,7 @@ impl ScriptVmDefaultWorldBootstrap { pressed_mouse_buttons: 0, pending_mouse_press: None, hovered_mouse_handle: None, + hovered_mouse_root_to_client: moli_layout::LayoutTransform2D::IDENTITY, active_touch_pointer_handle: None, active_touch_pointer_handles: BTreeMap::new(), active_touch_event_handle: None, diff --git a/moli-renderer-v8/src/script_vm/input_dispatch.rs b/moli-renderer-v8/src/script_vm/input_dispatch.rs index 821cefe5ae..ceb41c18f6 100644 --- a/moli-renderer-v8/src/script_vm/input_dispatch.rs +++ b/moli-renderer-v8/src/script_vm/input_dispatch.rs @@ -480,11 +480,13 @@ impl ScriptVm { return Ok(outcome); } - let hit_handle = observable_input_hit_test( + let root_point = moli_layout::LayoutPoint::new(x as f32, y as f32); + let hit = observable_input_hit_test( &self._context_host.borrow(), self.document_runtime.document_handle(), - moli_layout::LayoutPoint::new(x as f32, y as f32), + root_point, )?; + let hit_handle = hit.map(|hit| hit.handle); let pointer_event_name = pointer_event_name_for_mouse_event(event_name); let mut pending_pointer_capture_events = Vec::new(); if pointer_event_name.is_some() { @@ -539,12 +541,22 @@ impl ScriptVm { let Some(handle) = capture_handle.or(hit_handle).or(wheel_fallback_handle) else { return Ok(input_dispatch_outcome(false)); }; + let root_to_client = hit + .filter(|hit| hit.handle == handle) + .map(|hit| hit.root_to_client) + .unwrap_or(moli_layout::LayoutTransform2D::IDENTITY); + let client_point = root_to_client.map_point(root_point); + let client_x = f64::from(client_point.x); + let client_y = f64::from(client_point.y); let hover_transition = if tracks_mouse_hover_for_event(event_name) { let previous = self.hovered_mouse_handle; if previous != Some(handle) { + let previous_root_to_client = self.hovered_mouse_root_to_client; self.hovered_mouse_handle = Some(handle); - Some(previous) + self.hovered_mouse_root_to_client = root_to_client; + Some((previous, previous_root_to_client)) } else { + self.hovered_mouse_root_to_client = root_to_client; None } } else { @@ -588,13 +600,16 @@ impl ScriptVm { let result = self.with_default_context_scope(|scope, runtime_ptr| { let mut pointer_dispatch_handle = handle; - if let Some(Some(previous_handle)) = hover_transition { + if let Some((Some(previous_handle), previous_root_to_client)) = hover_transition { + let previous_client_point = previous_root_to_client.map_point(root_point); + let previous_client_x = f64::from(previous_client_point.x); + let previous_client_y = f64::from(previous_client_point.y); let related_target = related_target_value(scope, Some(handle)); if let Some(event) = construct_pointer_event_with_related_target_and_modifiers( scope, "pointerout", - x, - y, + previous_client_x, + previous_client_y, button, buttons, &pointer, @@ -607,8 +622,8 @@ impl ScriptVm { if let Some(event) = construct_pointer_event_with_related_target_and_modifiers( scope, "pointerleave", - x, - y, + previous_client_x, + previous_client_y, button, buttons, &pointer, @@ -618,13 +633,13 @@ impl ScriptVm { let _ = dispatch_public_event(scope, runtime_ptr, previous_handle, event); } } - if let Some(previous_handle) = hover_transition { + if let Some((previous_handle, _)) = hover_transition { let related_target = related_target_value(scope, previous_handle); if let Some(event) = construct_pointer_event_with_related_target_and_modifiers( scope, "pointerover", - x, - y, + client_x, + client_y, button, buttons, &pointer, @@ -637,8 +652,8 @@ impl ScriptVm { if let Some(event) = construct_pointer_event_with_related_target_and_modifiers( scope, "pointerenter", - x, - y, + client_x, + client_y, button, buttons, &pointer, @@ -652,8 +667,8 @@ impl ScriptVm { && let Some(event) = construct_pointer_event_with_modifiers( scope, "pointerrawupdate", - x, - y, + client_x, + client_y, button, buttons, &pointer, @@ -670,8 +685,8 @@ impl ScriptVm { runtime_ptr, MOUSE_POINTER_ID, &post_raw_update_capture_events, - x, - y, + client_x, + client_y, button, buttons, &pointer, @@ -684,13 +699,16 @@ impl ScriptVm { .unwrap_or(pointer_dispatch_handle); } } - if let Some(Some(previous_handle)) = hover_transition { + if let Some((Some(previous_handle), previous_root_to_client)) = hover_transition { + let previous_client_point = previous_root_to_client.map_point(root_point); + let previous_client_x = f64::from(previous_client_point.x); + let previous_client_y = f64::from(previous_client_point.y); let related_target = related_target_value(scope, Some(handle)); if let Some(event) = construct_mouse_event_with_related_target_and_modifiers( scope, "mouseout", - x, - y, + previous_client_x, + previous_client_y, button, buttons, related_target, @@ -702,8 +720,8 @@ impl ScriptVm { if let Some(event) = construct_mouse_event_with_related_target_and_modifiers( scope, "mouseleave", - x, - y, + previous_client_x, + previous_client_y, button, buttons, related_target, @@ -712,13 +730,13 @@ impl ScriptVm { let _ = dispatch_public_event(scope, runtime_ptr, previous_handle, event); } } - if let Some(previous_handle) = hover_transition { + if let Some((previous_handle, _)) = hover_transition { let related_target = related_target_value(scope, previous_handle); if let Some(event) = construct_mouse_event_with_related_target_and_modifiers( scope, "mouseover", - x, - y, + client_x, + client_y, button, buttons, related_target, @@ -730,8 +748,8 @@ impl ScriptVm { if let Some(event) = construct_mouse_event_with_related_target_and_modifiers( scope, "mouseenter", - x, - y, + client_x, + client_y, button, buttons, related_target, @@ -744,8 +762,8 @@ impl ScriptVm { && let Some(event) = construct_pointer_event_with_modifiers( scope, pointer_event_name, - x, - y, + client_x, + client_y, button, buttons, &pointer, @@ -763,8 +781,8 @@ impl ScriptVm { scope, runtime_ptr, MOUSE_POINTER_ID, - x, - y, + client_x, + client_y, button, buttons, &pointer, @@ -776,11 +794,12 @@ impl ScriptVm { if !suppress_current_mouse_event { let event = if event_name == "wheel" { construct_wheel_event( - scope, event_name, x, y, delta_x, delta_y, button, buttons, modifiers, + scope, event_name, client_x, client_y, delta_x, delta_y, button, buttons, + modifiers, ) } else { construct_mouse_event_with_modifiers( - scope, event_name, x, y, button, buttons, modifiers, + scope, event_name, client_x, client_y, button, buttons, modifiers, ) }; if let Some(event) = event { @@ -824,8 +843,14 @@ impl ScriptVm { }; if let Some(data_transfer) = crate::context_bootstrap::build_data_transfer_object(scope, &empty_drag_data) - && let Some(event) = - construct_drag_event(scope, "dragstart", x, y, data_transfer.into(), 0) + && let Some(event) = construct_drag_event( + scope, + "dragstart", + client_x, + client_y, + data_transfer.into(), + 0, + ) { if dispatch_public_event(scope, runtime_ptr, drag_start_handle, event) .allows_default() @@ -843,9 +868,14 @@ impl ScriptVm { let active_drag_session = unsafe { &mut *active_drag_session }; if let Some(session) = active_drag_session.as_mut() { let data_transfer = v8::Local::new(scope, &session.data_transfer); - if let Some(event) = - construct_drag_event(scope, "dragover", x, y, data_transfer.into(), 0) - { + if let Some(event) = construct_drag_event( + scope, + "dragover", + client_x, + client_y, + data_transfer.into(), + 0, + ) { session.drop_allowed = !dispatch_public_event(scope, runtime_ptr, handle, event) .allows_default(); @@ -859,9 +889,14 @@ impl ScriptVm { if let Some(session) = active_drag_session.as_mut() { if session.drop_allowed { let data_transfer = v8::Local::new(scope, &session.data_transfer); - let allows_default = if let Some(event) = - construct_drag_event(scope, "drop", x, y, data_transfer.into(), 0) - { + let allows_default = if let Some(event) = construct_drag_event( + scope, + "drop", + client_x, + client_y, + data_transfer.into(), + 0, + ) { dispatch_public_event(scope, runtime_ptr, handle, event) .allows_default() } else { @@ -888,8 +923,8 @@ impl ScriptVm { scope, runtime_ptr, handle, - x, - y, + client_x, + client_y, button, buttons, click_count.max(1), @@ -899,8 +934,8 @@ impl ScriptVm { && let Some(event) = construct_mouse_event_with_detail_and_modifiers( scope, "dblclick", - x, - y, + client_x, + client_y, click_count, button, buttons, @@ -918,8 +953,8 @@ impl ScriptVm { if let Some(event) = construct_mouse_event_with_modifiers( scope, follow_up_event_name, - x, - y, + client_x, + client_y, button, buttons, modifiers, @@ -1047,7 +1082,8 @@ impl ScriptVm { &self._context_host.borrow(), self.document_runtime.document_handle(), moli_layout::LayoutPoint::new(x as f32, y as f32), - )?; + )? + .map(|hit| hit.handle); if event_name == "touchstart" && hit_handle.is_none() { return Ok(input_dispatch_outcome(false)); } @@ -1278,7 +1314,8 @@ impl ScriptVm { &self._context_host.borrow(), self.document_runtime.document_handle(), moli_layout::LayoutPoint::new(point.x as f32, point.y as f32), - )?; + )? + .map(|hit| hit.handle); let target = match event_name { "touchstart" => hit_handle, "touchmove" | "touchend" | "touchcancel" => self @@ -1505,7 +1542,7 @@ impl ScriptVm { self.document_runtime.document_handle(), moli_layout::LayoutPoint::new(x as f32, y as f32), )? - else { + .map(|hit| hit.handle) else { return Ok(input_dispatch_outcome(false)); }; diff --git a/moli-renderer-v8/src/script_vm/input_helpers.rs b/moli-renderer-v8/src/script_vm/input_helpers.rs index 99e2cb2ecd..e8f3c66575 100644 --- a/moli-renderer-v8/src/script_vm/input_helpers.rs +++ b/moli-renderer-v8/src/script_vm/input_helpers.rs @@ -50,6 +50,7 @@ pub(super) fn clear_input_dispatch_state(vm: &mut ScriptVm) { vm.pressed_mouse_buttons = 0; vm.pending_mouse_press = None; vm.hovered_mouse_handle = None; + vm.hovered_mouse_root_to_client = moli_layout::LayoutTransform2D::IDENTITY; vm._context_host .borrow() .dom_host()