diff --git a/docs/workstation/native-webview-scale-system--0622.md b/docs/workstation/native-webview-scale-system--0622.md index 1d7fe2ae55..8b8ebcdf41 100644 --- a/docs/workstation/native-webview-scale-system--0622.md +++ b/docs/workstation/native-webview-scale-system--0622.md @@ -1,7 +1,7 @@ --- title: Native WebView Scale System status: active -last_updated: 2026-06-13 +last_updated: 2026-08-25 --- # Native WebView Scale System @@ -73,7 +73,7 @@ BrowserCore .browser-content └── .browser-webview-frame-anchor ``` -The anchor is the source of truth for the desired native child WebView rectangle. `useWebviewLayout` reads `anchor.getBoundingClientRect()`, converts it with `toNativeFrame`, and sends the result to Rust. +The anchor is the source of truth for the desired native child WebView rectangle. `useWebviewLayout` intersects the anchor with the viewport and every overflow-clipping ancestor, converts the resulting visible rectangle with `toNativeFrame`, and sends it to Rust. A fully clipped or invalid rectangle fails closed by staging the native surface offscreen. ## Shared browser owner flow @@ -93,6 +93,42 @@ This means layout changes must update both: 1. the visible host rect registry, and 2. the native child WebView position after the shared owner host moves. +`SharedBrowserHostSlot` must publish the clipped rectangle from the original visible panel. Clipping only inside the fixed shared owner is insufficient because the original panel's overflow ancestors no longer exist in that copied DOM path. + +## Native surface visibility and overlays + +Native child WebViews do not participate in DOM stacking contexts. CSS `z-index`, portal roots, and `overflow: hidden` cannot reliably place React UI above or clip a child WebView. + +ORGII therefore separates surface visibility from overlay occlusion: + +```text +overlay DOMRect + dimming registry + ↓ intersect + native-frame scale +BrowserSession local holes + strongest scrim alpha + ↓ latest-wins IPC +macOS CALayer mask + dim layer + native input handoff +``` + +Important invariants: + +- `isActive` controls page lifecycle; `isVisible` controls only the native surface. Opening an overlay must not destroy, reload, or navigate the page. +- On macOS, every opaque overlay surface publishes its real viewport rectangle. Each visible browser session intersects those rectangles with its host and applies only the resulting WebView-local holes. Full-screen modals additionally publish a black scrim alpha; they do not publish the translucent wrapper as an opaque hole. +- Interactive overlays temporarily hand native pointer input back to React while they are open. Passive overlays such as tooltips can leave page input enabled. +- macOS input handoff routes hit testing directly from the covered child WKWebView to the main React WKWebView. Re-running the child container's parent hit test is not sufficient because the two WebViews can live under different native container views and produce a bare `nil`, which lets the click escape to another application. The fallback must fail closed inside the inline WebView, and it must not change an individual WKWebView's runtime class because AppKit may KVO-observe its frame. +- Overlapping rectangles are conservatively coalesced before the even-odd mask is built, and both frontend and Rust cap the path at 64 rectangles. +- Platforms without native region masking currently retain the offscreen compatibility fallback. +- Resize, scroll, scale, and delayed layout callbacks re-check the latest desired visibility before writing a frame. A stale callback cannot move an obscured surface back onscreen. +- Surface commands are serialized per WebView. The last requested visibility wins. +- Restoration uses `reposition_and_show_webview`, which sets position and size before calling `show()` in one native command. +- Frame de-duplication state is committed only after the native command succeeds, so failed IPC remains retryable. + +BrowserCore's loading and confirmed error panels also set `isVisible=false` while keeping `isActive=true`. The sensitive-host fallback is only a time-based hint and must not hide a successfully loaded native page. This preserves cookies, login state, history, and in-page memory while real blocking UI is shown. + +Overlay coverage follows the interaction contract: + +- Local popovers, dropdowns, hover cards, and tooltips register only their visible panel, so the native page remains painted and interactive everywhere else. +- Full-screen modals register the opaque dialog panel as a local hole and publish the matching scrim alpha. Because a DOM scrim cannot alpha-composite above a sibling WKWebView, macOS adds a named black `CALayer` above the live native page. The WKWebView's parent mask also masks that dim layer inside the dialog hole, allowing the React panel to remain fully opaque. Closing the modal removes both the mask and dim layer without navigation, reload, or loss of cookies, history, scroll, or in-page state. + ## Layout-change event Some layout changes move the browser anchor without changing its size. Examples: @@ -125,12 +161,20 @@ When inline WebViews are misaligned under UI scale: 6. Confirm Rust receives `a/b` and derives size from corners. 7. If the browser panel moves without resizing, confirm `orgii-webview-layout-changed` reaches `SharedBrowserHostSlot` and `useWebviewLayout`. 8. If using the shared browser owner, confirm `SharedBrowserApp` has moved its fixed host before the final native position update. +9. If a React overlay is covered, confirm its primitive calls `useOverlayLayer(active, elementRef)` and publishes a non-zero rectangle in `overlayOcclusionStateAtom`. +10. If a hidden WebView reappears during resize or scroll, confirm all native position writes pass through `useWebviewLayout`'s serialized visibility gate. +11. If the whole page becomes white when an overlay opens, confirm no caller invokes the removed `browser_webviews_set_layer_for_all` z-order command. ## Files of interest - `src/app/root/useAppShellEffects.ts` — applies native app zoom and CSS scale variables. - `src/util/platform/tauri/nativeFrame.ts` — converts DOMRect to `x/y/a/b` native frame payloads. - `src/hooks/platform/useInlineWebview/useWebviewLayout.ts` — observes and repositions inline WebViews. +- `src/hooks/platform/useInlineWebview/visibleWebviewRect.ts` — intersects anchors with viewport and overflow clipping ancestors. +- `src/store/ui/overlayLayerAtom.ts` — owns the runtime overlay rectangle registry. +- `src/hooks/platform/useInlineWebview/nativeWebviewOcclusion.ts` — intersects/coalesces overlay holes in the WebView-local coordinate system. +- `src/hooks/platform/useInlineWebview/useInlineWebviewOcclusions.ts` — serializes latest-wins native mask projection per browser session. +- `src-tauri/crates/browser/src/occlusion.rs` — applies the macOS CALayer mask and input handoff. - `src/hooks/platform/useInlineWebview/useWebviewCommands.ts` — creates inline WebViews with native frame payloads. - `src/hooks/platform/useInlineWebview/webviewLayoutEvents.ts` — shared layout-change event helper. - `src/engines/BrowserCore/index.tsx` — owns the browser frame anchor. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 83c8a9602e..a19bfa20c8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -891,7 +891,9 @@ dependencies = [ "block2", "objc2", "objc2-app-kit", + "objc2-core-graphics", "objc2-foundation", + "objc2-quartz-core", "perf_utils", "serde", "serde_json", @@ -5136,6 +5138,7 @@ dependencies = [ "bitflags 2.13.0", "objc2", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index dd87e24621..950a00842b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -121,6 +121,8 @@ axum = { version = "0.8", features = ["ws"] } objc2 = "0.6" objc2-foundation = "0.3" objc2-app-kit = "0.3" +objc2-core-graphics = { version = "0.3", default-features = false, features = ["std", "CGColor", "CGGeometry", "CGPath"] } +objc2-quartz-core = { version = "0.3", default-features = false, features = ["std", "CALayer", "CAShapeLayer", "CATransaction", "objc2-core-foundation", "objc2-core-graphics"] } block2 = "0.6" dispatch2 = "0.3" diff --git a/src-tauri/crates/browser/Cargo.toml b/src-tauri/crates/browser/Cargo.toml index 047c45de29..0e50685db3 100644 --- a/src-tauri/crates/browser/Cargo.toml +++ b/src-tauri/crates/browser/Cargo.toml @@ -68,6 +68,8 @@ window-vibrancy = "0.6" objc2 = { workspace = true } objc2-foundation = { workspace = true } objc2-app-kit = { workspace = true } +objc2-core-graphics = { workspace = true } +objc2-quartz-core = { workspace = true } block2 = { workspace = true } # Windows-specific WebView2 bindings for retrieving JavaScript evaluation diff --git a/src-tauri/crates/browser/src/inline.rs b/src-tauri/crates/browser/src/inline.rs index 20877d3461..395f2a285b 100644 --- a/src-tauri/crates/browser/src/inline.rs +++ b/src-tauri/crates/browser/src/inline.rs @@ -515,6 +515,7 @@ pub fn close_inline_webview( } if let Some(webview) = app.get_webview(&label) { + crate::occlusion::clear_webview_occlusions(&webview); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| webview.close())); clear_generation(&label); @@ -618,6 +619,7 @@ pub fn close_all_inline_webviews(app: AppHandle) -> Result, String> // Reset lifecycle state so the next create starts fresh. reset_ref(label); clear_generation(label); + crate::occlusion::clear_webview_occlusions(webview); // Clone webview for catch_unwind (needs 'static lifetime) let webview_clone = webview.clone(); diff --git a/src-tauri/crates/browser/src/layering.rs b/src-tauri/crates/browser/src/layering.rs deleted file mode 100644 index 53cb35daa5..0000000000 --- a/src-tauri/crates/browser/src/layering.rs +++ /dev/null @@ -1,177 +0,0 @@ -//! Inline Webview Z-Order Layering (macOS) -//! -//! Controls the NSView subview ordering of an inline WKWebView relative to -//! its siblings (in particular, the React "main" webview). On macOS, all -//! child webviews of a Tauri window are sibling NSViews under the window's -//! contentView, and their z-order is determined by subview order — last -//! added draws on top. Clicks and pointer events also follow this order: -//! the front-most subview in the click region receives the event. -//! -//! This module exposes two operations: -//! -//! - [`browser_webview_send_to_back`]: move the given webview's NSView to -//! the back of its superview's subviews. Other siblings (React UI) will -//! draw above it and intercept clicks in their bounds. Used when a React -//! overlay (dropdown, modal, tooltip) temporarily needs to cover the -//! browser region. -//! -//! - [`browser_webview_bring_to_front`]: move it to the front. This is the -//! default state: the browser is interactive and draws above any -//! overlapping React surface. -//! -//! ## Pointer events -//! -//! This approach works because we only reorder when the React UI genuinely -//! wants to occupy the region (it has visible opaque pixels). We never -//! leave React "transparently" on top of the browser, which would steal -//! clicks the user intended for the page. See the design discussion in the -//! agent transcript that produced this module. -//! -//! ## Platforms -//! -//! macOS only for now. Windows (WebView2) and Linux (WebKitGTK) have -//! different windowing stacks; add platform branches when needed. - -use tauri::{AppHandle, Manager}; - -/// Move the given inline webview's native NSView to the back of its -/// superview's subview stack, so React surfaces draw above it. -#[tauri::command] -pub fn browser_webview_send_to_back(app: AppHandle, label: String) -> Result<(), String> { - reorder_webview(&app, &label, Order::Back) -} - -/// Move the given inline webview's native NSView to the front of its -/// superview's subview stack, so it draws above all other children of the -/// window's contentView (default state — fully interactive). -#[tauri::command] -pub fn browser_webview_bring_to_front(app: AppHandle, label: String) -> Result<(), String> { - reorder_webview(&app, &label, Order::Front) -} - -/// Reorder every inline browser webview at once. Used by the global overlay -/// layering bridge (React-side) to drop all inline webviews behind portals -/// when any overlay opens, and lift them back on close. -/// -/// Matches labels that begin with `"browser-session-"` — the prefix used by -/// `BrowserSessionWebview` in the frontend. Preview webviews and -/// other inline webviews are intentionally excluded because they don't -/// occupy the same regions where selectors/sidebars render. -/// -/// Returns the list of labels actually reordered. -#[tauri::command] -pub fn browser_webviews_set_layer_for_all( - app: AppHandle, - send_to_back: bool, -) -> Result, String> { - let order = if send_to_back { - Order::Back - } else { - Order::Front - }; - let mut reordered: Vec = Vec::new(); - - for label in app.webviews().keys() { - if !label.starts_with("browser-session-") { - continue; - } - - if let Err(err) = reorder_webview(&app, label, order) { - // Not fatal — a webview might be mid-teardown. Log and continue. - eprintln!( - "[browser_webviews_set_layer_for_all] '{}' skipped: {}", - label, err - ); - continue; - } - reordered.push(label.clone()); - } - - Ok(reordered) -} - -#[derive(Clone, Copy)] -enum Order { - Front, - Back, -} - -fn reorder_webview(app: &AppHandle, label: &str, order: Order) -> Result<(), String> { - let webview = app - .get_webview(label) - .ok_or_else(|| format!("Webview '{}' not found", label))?; - - #[cfg(target_os = "macos")] - { - reorder_macos(&webview, order).map_err(|e| format!("reorder failed: {}", e)) - } - - #[cfg(not(target_os = "macos"))] - { - let _ = webview; - let _ = order; - Err("webview z-order control is only implemented on macOS".to_string()) - } -} - -#[cfg(target_os = "macos")] -fn reorder_macos(webview: &tauri::Webview, order: Order) -> Result<(), String> { - use objc2::msg_send; - use objc2::runtime::AnyObject; - use std::sync::{Arc, Mutex}; - - // NSWindowOrderingMode constants used by addSubview:positioned:relativeTo: - // NSWindowAbove = 1, NSWindowBelow = -1 - const NS_WINDOW_ABOVE: i64 = 1; - const NS_WINDOW_BELOW: i64 = -1; - - let positioned: i64 = match order { - Order::Front => NS_WINDOW_ABOVE, - Order::Back => NS_WINDOW_BELOW, - }; - - // `with_webview` hops to the main thread and takes a closure that - // returns `()`, so we capture the outcome through a shared Mutex. - let outcome: Arc>> = - Arc::new(Mutex::new(Err("reorder closure did not run".to_string()))); - let outcome_for_closure = Arc::clone(&outcome); - - // SAFETY: Objective-C runtime access on a valid WKWebView* obtained - // from `wv.inner()`. We read its `superview` (the window's contentView), - // then re-add the WKWebView at the requested ordering. NSView allows a - // subview to be re-added via `addSubview:positioned:relativeTo:`; it is - // removed from its previous position and inserted at the new one - // without losing its retain count or event wiring. - // - // All pointers are null-checked. Passing a nil `relativeTo` places the - // subview at the extreme front (NSWindowAbove) or back (NSWindowBelow). - webview - .with_webview(move |wv| unsafe { - let wk_webview: *mut AnyObject = wv.inner() as *mut AnyObject; - if wk_webview.is_null() { - *outcome_for_closure.lock().unwrap() = Err("WKWebView pointer is null".to_string()); - return; - } - - let superview: *mut AnyObject = msg_send![wk_webview, superview]; - if superview.is_null() { - *outcome_for_closure.lock().unwrap() = - Err("WKWebView has no superview yet".to_string()); - return; - } - - let relative_to: *mut AnyObject = std::ptr::null_mut(); - let _: () = msg_send![ - superview, - addSubview: wk_webview, - positioned: positioned, - relativeTo: relative_to, - ]; - - *outcome_for_closure.lock().unwrap() = Ok(()); - }) - .map_err(|e| format!("with_webview failed: {}", e))?; - - let guard = outcome.lock().unwrap(); - guard.clone() -} diff --git a/src-tauri/crates/browser/src/lib.rs b/src-tauri/crates/browser/src/lib.rs index 2245041093..1d893b6f3e 100644 --- a/src-tauri/crates/browser/src/lib.rs +++ b/src-tauri/crates/browser/src/lib.rs @@ -40,8 +40,8 @@ pub mod dom_editor; pub mod inline; pub mod internal_browser_commands; pub mod internal_browser_state; -pub mod layering; pub mod logging; +pub mod occlusion; pub mod screenshot_store; pub mod scripts; pub mod types; @@ -54,8 +54,8 @@ pub use dom_editor::*; pub use inline::*; pub use internal_browser_commands::*; pub use internal_browser_state::*; -pub use layering::*; pub use logging::*; +pub use occlusion::*; pub use screenshot_store::*; pub use scripts::*; pub use types::*; diff --git a/src-tauri/crates/browser/src/occlusion.rs b/src-tauri/crates/browser/src/occlusion.rs new file mode 100644 index 0000000000..7e84ab78f9 --- /dev/null +++ b/src-tauri/crates/browser/src/occlusion.rs @@ -0,0 +1,687 @@ +//! Geometry-aware inline WebView occlusion. +//! +//! Native child WebViews do not participate in the React DOM stacking +//! context. On macOS we keep the live WKWebView in front, but apply a +//! `CAShapeLayer` mask with holes matching opaque React overlays. Translucent +//! modal scrims are mirrored by a named black `CALayer` above the live page. +//! This keeps the rest of the page painted instead of moving the entire +//! WebView behind the opaque main app surface. + +use serde::Deserialize; +use tauri::{AppHandle, Manager}; + +const MAX_OCCLUSION_RECTS: usize = 64; + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct WebviewOcclusionRect { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +fn sanitize_occlusion_rects( + rects: &[WebviewOcclusionRect], + surface_width: f64, + surface_height: f64, +) -> Vec { + if !surface_width.is_finite() + || !surface_height.is_finite() + || surface_width <= 0.0 + || surface_height <= 0.0 + { + return Vec::new(); + } + + rects + .iter() + .take(MAX_OCCLUSION_RECTS) + .filter_map(|rect| { + if !rect.x.is_finite() + || !rect.y.is_finite() + || !rect.width.is_finite() + || !rect.height.is_finite() + || rect.width <= 0.0 + || rect.height <= 0.0 + { + return None; + } + + let left = rect.x.max(0.0).min(surface_width); + let top = rect.y.max(0.0).min(surface_height); + let right = (rect.x + rect.width).max(0.0).min(surface_width); + let bottom = (rect.y + rect.height).max(0.0).min(surface_height); + if right <= left || bottom <= top { + return None; + } + + Some(WebviewOcclusionRect { + x: left, + y: top, + width: right - left, + height: bottom - top, + }) + }) + .collect() +} + +fn expand_occlusion_holes( + rects: Vec, + surface_width: f64, + surface_height: f64, + padding: f64, +) -> Vec { + if padding <= 0.0 { + return rects; + } + + rects + .into_iter() + .filter_map(|rect| { + let x = (rect.x - padding).max(0.0); + let y = (rect.y - padding).max(0.0); + let right = (rect.x + rect.width + padding).min(surface_width); + let bottom = (rect.y + rect.height + padding).min(surface_height); + if right <= x || bottom <= y { + return None; + } + Some(WebviewOcclusionRect { + x, + y, + width: right - x, + height: bottom - y, + }) + }) + .collect() +} + +fn sanitize_dimming_alpha(dimming_alpha: f64) -> f32 { + if !dimming_alpha.is_finite() { + return 0.0; + } + dimming_alpha.clamp(0.0, 1.0) as f32 +} + +/// Apply overlay holes to one inline WebView. +/// +/// `rects` are WebView-local logical points with a top-left origin. The +/// frontend derives them from the same scaled frame used to position the +/// native child view. `dimming_alpha` mirrors a translucent black DOM scrim +/// without turning the full WebView into an opaque compositor hole. +#[tauri::command] +pub async fn set_inline_webview_occlusions( + app: AppHandle, + label: String, + rects: Vec, + dim_hole_rects: Vec, + block_input: bool, + dimming_alpha: f64, +) -> Result<(), String> { + let Some(webview) = app.get_webview(&label) else { + // Creation and teardown race with overlay effects; a missing surface + // is already in the desired non-interactive/non-painted state. + return Ok(()); + }; + + #[cfg(target_os = "macos")] + { + let main_webview = app.get_webview("main"); + apply_macos_occlusions( + &webview, + main_webview, + rects, + dim_hole_rects, + block_input, + dimming_alpha, + ) + .await + } + + #[cfg(not(target_os = "macos"))] + { + let _ = webview; + let _ = rects; + let _ = block_input; + let _ = dimming_alpha; + Ok(()) + } +} + +#[cfg(target_os = "macos")] +mod macos { + use super::{sanitize_dimming_alpha, WebviewOcclusionRect}; + use objc2::runtime::{AnyClass, AnyObject, Imp, Sel}; + use objc2::{msg_send, sel, Message}; + use objc2_app_kit::NSColor; + use objc2_core_graphics::CGMutablePath; + use objc2_foundation::{NSPoint, NSRect, NSSize, NSString}; + use objc2_quartz_core::{kCAFillRuleEvenOdd, CALayer, CAShapeLayer, CATransaction}; + use std::collections::HashMap; + use std::sync::{Mutex, OnceLock}; + + type HitTestImplementation = + unsafe extern "C-unwind" fn(&AnyObject, Sel, NSPoint) -> *mut AnyObject; + + #[derive(Clone, Debug, Default)] + struct OcclusionInputState { + main_target: Option, + holes: Vec, + block_input: bool, + } + + /// Per-inline-WebView occlusion routing state used by the hit-test hook. + static OCCLUSION_INPUT_STATES: OnceLock>> = + OnceLock::new(); + static ORIGINAL_HIT_TESTS: OnceLock>> = OnceLock::new(); + const DIMMING_LAYER_NAME: &str = "org2.inline-webview-dimming"; + + fn occlusion_input_states() -> &'static Mutex> { + OCCLUSION_INPUT_STATES.get_or_init(|| Mutex::new(HashMap::new())) + } + + fn original_hit_tests() -> &'static Mutex> { + ORIGINAL_HIT_TESTS.get_or_init(|| Mutex::new(HashMap::new())) + } + + fn occlusion_input_state(webview: &AnyObject) -> Option { + occlusion_input_states().lock().ok().and_then(|states| { + states + .get(&(webview as *const AnyObject as usize)) + .cloned() + }) + } + + fn point_in_hole( + point: NSPoint, + bounds: NSRect, + holes: &[WebviewOcclusionRect], + is_flipped: bool, + ) -> bool { + let local_x = point.x - bounds.origin.x; + let local_y = if is_flipped { + point.y - bounds.origin.y + } else { + bounds.size.height - (point.y - bounds.origin.y) + }; + + holes.iter().any(|rect| { + local_x >= rect.x + && local_x <= rect.x + rect.width + && local_y >= rect.y + && local_y <= rect.y + rect.height + }) + } + + fn route_hit_to_main( + source: &AnyObject, + point: NSPoint, + target_key: usize, + ) -> *mut AnyObject { + if target_key == 0 || target_key == source as *const AnyObject as usize { + return std::ptr::null_mut(); + } + + let target = unsafe { &*(target_key as *const AnyObject) }; + // Convert through the window so sibling WKWebViews with different + // container views still agree on the click location. + let window_point: NSPoint = + unsafe { msg_send![source, convertPoint: point, toView: std::ptr::null::()] }; + let point_in_target: NSPoint = unsafe { + msg_send![ + target, + convertPoint: window_point, + fromView: std::ptr::null::() + ] + }; + unsafe { msg_send![target, hitTest: point_in_target] } + } + + fn original_hit_test(this: &AnyObject, command: Sel, point: NSPoint) -> *mut AnyObject { + let original = original_hit_tests().lock().ok().and_then(|originals| { + let mut class = Some(this.class()); + while let Some(candidate) = class { + let key = candidate as *const AnyClass as usize; + if let Some(implementation) = originals.get(&key) { + return Some(*implementation); + } + class = candidate.superclass(); + } + None + }); + let Some(original) = original else { + return std::ptr::null_mut(); + }; + + let original: HitTestImplementation = unsafe { std::mem::transmute(original) }; + unsafe { original(this, command, point) } + } + + extern "C-unwind" fn hit_test(this: &AnyObject, _cmd: Sel, point: NSPoint) -> *mut AnyObject { + if let Some(state) = occlusion_input_state(this) { + let bounds: NSRect = unsafe { msg_send![this, bounds] }; + let is_flipped: bool = unsafe { msg_send![this, isFlipped] }; + let in_hole = point_in_hole(point, bounds, &state.holes, is_flipped); + let should_route = state.block_input || in_hole; + + if should_route { + if let Some(target_key) = state.main_target { + let routed = route_hit_to_main(this, point, target_key); + if !routed.is_null() { + return routed; + } + + // Absorb into the main React surface instead of falling + // through to the live inline page underneath. + if target_key != 0 { + return target_key as *mut AnyObject; + } + } + + return original_hit_test(this, _cmd, point); + } + } + + original_hit_test(this, _cmd, point) + } + + fn ensure_hit_test_hook(webview: &AnyObject) -> Result<(), String> { + let class = webview.class(); + let class_key = class as *const AnyClass as usize; + let mut originals = original_hit_tests() + .lock() + .map_err(|_| "native WebView hit-test registry is poisoned".to_string())?; + if originals.contains_key(&class_key) { + return Ok(()); + } + + let selector = sel!(hitTest:); + let method = class + .instance_method(selector) + .ok_or_else(|| "WKWebView has no hitTest: method".to_string())?; + let inherited_implementation = method.implementation(); + let replacement: Imp = + unsafe { std::mem::transmute::(hit_test) }; + let type_encoding = unsafe { objc2::ffi::method_getTypeEncoding(method) }; + if type_encoding.is_null() { + return Err("WKWebView hitTest: has no type encoding".to_string()); + } + + // Add/replace on the WebView's existing class. Avoid object_setClass: + // AppKit may KVO-observe NSView.frame, and changing an individual + // WKWebView's runtime class can invalidate that observation chain. + let previous = unsafe { + objc2::ffi::class_replaceMethod( + class as *const AnyClass as *mut AnyClass, + selector, + replacement, + type_encoding, + ) + }; + originals.insert(class_key, previous.unwrap_or(inherited_implementation)); + Ok(()) + } + + fn set_occlusion_input_state( + webview: &AnyObject, + main_target: Option, + holes: Vec, + block_input: bool, + ) -> Result<(), String> { + let key = webview as *const AnyObject as usize; + let needs_hook = block_input || !holes.is_empty(); + if needs_hook { + ensure_hit_test_hook(webview)?; + } + + let mut registry = occlusion_input_states() + .lock() + .map_err(|_| "native WebView input registry is poisoned".to_string())?; + if main_target.is_none() && holes.is_empty() && !block_input { + registry.remove(&key); + } else { + registry.insert( + key, + OcclusionInputState { + main_target, + holes, + block_input, + }, + ); + } + Ok(()) + } + + async fn native_webview_pointer(webview: &tauri::Webview) -> Result { + let (sender, receiver) = tokio::sync::oneshot::channel(); + webview + .with_webview(move |wv| { + let pointer = wv.inner() as *mut AnyObject as usize; + let _ = sender.send(pointer); + }) + .map_err(|error| format!("with_webview failed: {error}"))?; + + let pointer = receiver + .await + .map_err(|_| "native WebView pointer task was cancelled".to_string())?; + if pointer == 0 { + return Err("WKWebView pointer is null".to_string()); + } + Ok(pointer) + } + + fn find_dimming_layer(root_layer: &CALayer) -> Option> { + unsafe { root_layer.sublayers() }.and_then(|sublayers| { + sublayers + .iter() + .find(|candidate| { + candidate + .name() + .is_some_and(|name| name.to_string() == DIMMING_LAYER_NAME) + }) + .map(|candidate| candidate.retain()) + }) + } + + fn update_dimming_layer( + root_layer: &CALayer, + bounds: NSRect, + dimming_alpha: f64, + mask: Option<&CAShapeLayer>, + ) { + let dimming_alpha = sanitize_dimming_alpha(dimming_alpha); + let existing = find_dimming_layer(root_layer); + + if dimming_alpha <= 0.0 { + if let Some(layer) = existing { + layer.removeFromSuperlayer(); + } + return; + } + + let dimming_layer = existing.unwrap_or_else(|| { + let layer = CALayer::layer(); + let name = NSString::from_str(DIMMING_LAYER_NAME); + layer.setName(Some(&name)); + root_layer.addSublayer(&layer); + layer + }); + let black = NSColor::blackColor().CGColor(); + dimming_layer.setFrame(bounds); + dimming_layer.setBackgroundColor(Some(&black)); + dimming_layer.setOpacity(dimming_alpha); + dimming_layer.setZPosition(1_000_000.0); + if let Some(mask) = mask { + unsafe { + dimming_layer.setMask(Some(mask)); + } + } else { + unsafe { + dimming_layer.setMask(None); + } + } + } + + pub(super) async fn apply( + webview: &tauri::Webview, + main_webview: Option, + rects: Vec, + dim_hole_rects: Vec, + block_input: bool, + dimming_alpha: f64, + ) -> Result<(), String> { + let input_target = if block_input || !rects.is_empty() { + let main_webview = main_webview + .as_ref() + .ok_or_else(|| "main React WebView is unavailable".to_string())?; + Some(native_webview_pointer(main_webview).await?) + } else { + None + }; + let (sender, receiver) = tokio::sync::oneshot::channel(); + + webview + .with_webview(move |wv| { + let result = (|| -> Result<(), String> { + let wk_webview: *mut AnyObject = wv.inner() as *mut AnyObject; + if wk_webview.is_null() { + return Err("WKWebView pointer is null".to_string()); + } + let wk_webview = unsafe { &*wk_webview }; + + unsafe { + let _: () = msg_send![wk_webview, setWantsLayer: true]; + let layer: *mut CALayer = msg_send![wk_webview, layer]; + if layer.is_null() { + return Err("WKWebView has no backing layer".to_string()); + } + let layer = &*layer; + + let bounds: NSRect = msg_send![wk_webview, bounds]; + let sanitized_mask = super::expand_occlusion_holes( + super::sanitize_occlusion_rects( + &rects, + bounds.size.width, + bounds.size.height, + ), + bounds.size.width, + bounds.size.height, + 3.0, + ); + let dim_source = if dim_hole_rects.is_empty() { + sanitized_mask.clone() + } else { + super::expand_occlusion_holes( + super::sanitize_occlusion_rects( + &dim_hole_rects, + bounds.size.width, + bounds.size.height, + ), + bounds.size.width, + bounds.size.height, + 3.0, + ) + }; + + set_occlusion_input_state( + wk_webview, + input_target, + sanitized_mask.clone(), + block_input, + )?; + + CATransaction::begin(); + CATransaction::setDisableActions(true); + + if sanitized_mask.is_empty() { + layer.setMask(None); + } else { + let is_flipped: bool = msg_send![wk_webview, isFlipped]; + let path = CGMutablePath::new(); + CGMutablePath::add_rect(Some(&path), std::ptr::null(), bounds); + + for rect in &sanitized_mask { + let y = if is_flipped { + bounds.origin.y + rect.y + } else { + bounds.origin.y + bounds.size.height - rect.y - rect.height + }; + let hole = NSRect::new( + NSPoint::new(bounds.origin.x + rect.x, y), + NSSize::new(rect.width, rect.height), + ); + CGMutablePath::add_rect(Some(&path), std::ptr::null(), hole); + } + + let mask = CAShapeLayer::layer(); + mask.setFrame(bounds); + mask.setPath(Some(&path)); + mask.setFillRule(kCAFillRuleEvenOdd); + layer.setMask(Some(&mask)); + } + + if dim_source.is_empty() { + update_dimming_layer(layer, bounds, dimming_alpha, None); + } else { + let is_flipped: bool = msg_send![wk_webview, isFlipped]; + let dim_path = CGMutablePath::new(); + CGMutablePath::add_rect(Some(&dim_path), std::ptr::null(), bounds); + for rect in &dim_source { + let y = if is_flipped { + bounds.origin.y + rect.y + } else { + bounds.origin.y + bounds.size.height - rect.y - rect.height + }; + let hole = NSRect::new( + NSPoint::new(bounds.origin.x + rect.x, y), + NSSize::new(rect.width, rect.height), + ); + CGMutablePath::add_rect(Some(&dim_path), std::ptr::null(), hole); + } + let dim_mask = CAShapeLayer::layer(); + dim_mask.setFrame(bounds); + dim_mask.setPath(Some(&dim_path)); + dim_mask.setFillRule(kCAFillRuleEvenOdd); + update_dimming_layer(layer, bounds, dimming_alpha, Some(&dim_mask)); + } + CATransaction::commit(); + } + + Ok(()) + })(); + let _ = sender.send(result); + }) + .map_err(|error| format!("with_webview failed: {error}"))?; + + receiver + .await + .map_err(|_| "occlusion main-thread task was cancelled".to_string())? + } + + pub(super) fn clear(webview: &tauri::Webview) { + let _ = webview.with_webview(|wv| unsafe { + let wk_webview: *mut AnyObject = wv.inner() as *mut AnyObject; + if wk_webview.is_null() { + return; + } + let wk_webview = &*wk_webview; + let _ = set_occlusion_input_state(wk_webview, None, Vec::new(), false); + let layer: *mut CALayer = msg_send![wk_webview, layer]; + if !layer.is_null() { + CATransaction::begin(); + CATransaction::setDisableActions(true); + let layer = &*layer; + layer.setMask(None); + update_dimming_layer(layer, NSRect::ZERO, 0.0, None); + CATransaction::commit(); + } + }); + } +} + +#[cfg(target_os = "macos")] +async fn apply_macos_occlusions( + webview: &tauri::Webview, + main_webview: Option, + rects: Vec, + dim_hole_rects: Vec, + block_input: bool, + dimming_alpha: f64, +) -> Result<(), String> { + macos::apply( + webview, + main_webview, + rects, + dim_hole_rects, + block_input, + dimming_alpha, + ) + .await +} + +/// Clear native projection state before closing a WebView so pointer-address +/// reuse cannot inherit a stale input block. +pub(crate) fn clear_webview_occlusions(webview: &tauri::Webview) { + #[cfg(target_os = "macos")] + macos::clear(webview); + + #[cfg(not(target_os = "macos"))] + let _ = webview; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitizes_and_clips_rectangles_to_surface_bounds() { + let rects = sanitize_occlusion_rects( + &[ + WebviewOcclusionRect { + x: -5.0, + y: 10.0, + width: 20.0, + height: 30.0, + }, + WebviewOcclusionRect { + x: 95.0, + y: 70.0, + width: 20.0, + height: 20.0, + }, + WebviewOcclusionRect { + x: f64::NAN, + y: 0.0, + width: 1.0, + height: 1.0, + }, + ], + 100.0, + 80.0, + ); + + assert_eq!( + rects, + vec![ + WebviewOcclusionRect { + x: 0.0, + y: 10.0, + width: 15.0, + height: 30.0, + }, + WebviewOcclusionRect { + x: 95.0, + y: 70.0, + width: 5.0, + height: 10.0, + }, + ] + ); + } + + #[test] + fn bounds_native_path_complexity() { + let rects = vec![ + WebviewOcclusionRect { + x: 1.0, + y: 1.0, + width: 1.0, + height: 1.0, + }; + MAX_OCCLUSION_RECTS + 10 + ]; + + assert_eq!( + sanitize_occlusion_rects(&rects, 100.0, 100.0).len(), + MAX_OCCLUSION_RECTS + ); + } + + #[test] + fn sanitizes_native_dimming_alpha() { + assert_eq!(sanitize_dimming_alpha(f64::NAN), 0.0); + assert_eq!(sanitize_dimming_alpha(-0.5), 0.0); + assert_eq!(sanitize_dimming_alpha(0.6), 0.6); + assert_eq!(sanitize_dimming_alpha(4.0), 1.0); + } +} diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index 3d91ba7131..6f55f6a2c1 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -98,10 +98,8 @@ browser::automation::commands::browser_automation_resume, browser::automation::commands::browser_screenshot_get, // Browser commands - Inline webview capture (Camera button → chat attachment) browser::browser_inline_capture, -// Browser commands - Inline webview z-order layering (send-to-back / bring-to-front) -browser::browser_webview_send_to_back, -browser::browser_webview_bring_to_front, -browser::browser_webviews_set_layer_for_all, +// Browser commands - geometry-aware inline WebView occlusion +browser::set_inline_webview_occlusions, // Browser commands - Active internal browser target state browser::set_active_internal_browser_state, browser::clear_active_internal_browser_state, diff --git a/src/components/Dropdown/index.tsx b/src/components/Dropdown/index.tsx index b4d278a4dc..8a98f7165e 100644 --- a/src/components/Dropdown/index.tsx +++ b/src/components/Dropdown/index.tsx @@ -36,7 +36,10 @@ import React, { } from "react"; import { useDropdownAutoKeyboard } from "@src/hooks/dropdown"; -import { useOverlayLayer } from "@src/store/ui/overlayLayerAtom"; +import { + DROPDOWN_OCCLUSION_OPTIONS, + useOverlayLayer, +} from "@src/store/ui/overlayLayerAtom"; import DropdownMenuSurface from "./DropdownMenuSurface"; import DropdownOptionsContent from "./DropdownOptionsContent"; @@ -193,7 +196,16 @@ const Dropdown: React.FC = ({ const isControlled = controlledVisible !== undefined; const visible = isControlled ? controlledVisible : internalVisible; - useOverlayLayer(visible); + // Only the portal branch renders at measured viewport coordinates, so only + // it can flash a bogus rect at (0,0) before the first measurement. In-flow + // panels are placed by their Tailwind position classes and never populate + // `dropdownPosition`, so gating them on it would leave the native WebView + // unmasked underneath for the whole time they are open. + useOverlayLayer( + visible && (!getPopupContainer || Boolean(dropdownPosition)), + dropdownRef, + DROPDOWN_OCCLUSION_OPTIONS + ); const setVisible = useCallback( (newVisible: boolean) => { diff --git a/src/components/FileTreePreview/FileTreeHoverPreview.tsx b/src/components/FileTreePreview/FileTreeHoverPreview.tsx index 79c8bbf0fa..9cccfcb758 100644 --- a/src/components/FileTreePreview/FileTreeHoverPreview.tsx +++ b/src/components/FileTreePreview/FileTreeHoverPreview.tsx @@ -8,6 +8,8 @@ import React, { } from "react"; import { createPortal } from "react-dom"; +import { useOverlayLayer } from "@src/store/ui/overlayLayerAtom"; + import FileTreePreview from "./index"; import type { FileTreePreviewProps } from "./types"; @@ -46,7 +48,9 @@ const FileTreeHoverPreview: React.FC = ({ }, []); const showTimeoutRef = useRef | null>(null); const hideTimeoutRef = useRef | null>(null); + const previewRef = useRef(null); const [showPreview, setShowPreview] = useState(false); + useOverlayLayer(showPreview, previewRef); const [previewPosition, setPreviewPosition] = useState({ left: 0, top: 0 }); const clearShowTimeout = useCallback(() => { @@ -135,6 +139,7 @@ const FileTreeHoverPreview: React.FC = ({ {showPreview && createPortal(
= memo( ({ dataUrl, fileName, onClose, showCopyButton = true }) => { const { t } = useTranslation("common"); - // Drop inline browser webviews behind this fullscreen modal. - useOverlayLayer(true); + const imagePanelRef = useRef(null); + useOverlayLayer(true, imagePanelRef); // Close on ESC useEffect(() => { @@ -88,7 +88,7 @@ const ImagePreviewOverlay: React.FC = memo( aria-label={t("imagePreview.dialogLabel")} > {/* Image container with toolbar overlay */} -
+
{/* Toolbar — floating inside image top-right */}
{showCopyButton && ( diff --git a/src/components/MarkDown/MermaidBlock.tsx b/src/components/MarkDown/MermaidBlock.tsx index 51888c31a2..eb266809e8 100644 --- a/src/components/MarkDown/MermaidBlock.tsx +++ b/src/components/MarkDown/MermaidBlock.tsx @@ -19,6 +19,7 @@ import { EventBlockHeaderTitle, getEventBlockContainerClasses, } from "@src/engines/ChatPanel/blocks/primitives"; +import { useOverlayLayer } from "@src/store/ui/overlayLayerAtom"; // ============================================ // Module-level SVG cache (FIFO, max 50) @@ -253,6 +254,8 @@ const MermaidBlock: React.FC = memo( const [error, setError] = useState(null); const [loading, setLoading] = useState(!svg); const [expanded, setExpanded] = useState(false); + const expandedPanelRef = useRef(null); + useOverlayLayer(expanded, expandedPanelRef); const [isCollapsed, setIsCollapsed] = useState(false); const [isHeaderHovered, setIsHeaderHovered] = useState(false); const containerRef = useRef(null); @@ -518,6 +521,7 @@ const MermaidBlock: React.FC = memo( svg && createPortal(
( align === "right" ? "right" : "left" ); + const dropdownElementRef = useRef(null); const dropdownRef = useCallback( (dropdown: HTMLDivElement | null) => { + dropdownElementRef.current = dropdown; if (!dropdown) return; if (align !== "auto") { if (resolvedAlign !== align) setResolvedAlign(align); @@ -197,7 +200,7 @@ function useResolvedDropdownAlign(align: DropdownAlign) { [align, resolvedAlign] ); - return { dropdownRef, resolvedAlign }; + return { dropdownElementRef, dropdownRef, resolvedAlign }; } export interface DropdownProps { @@ -265,7 +268,9 @@ export const SearchableDropdown: React.FC = ({ width?: number; } | null>(null); const anchorRef = useRef(null); - const { dropdownRef, resolvedAlign } = useResolvedDropdownAlign(align); + const { dropdownElementRef, dropdownRef, resolvedAlign } = + useResolvedDropdownAlign(align); + useOverlayLayer(Boolean(portalPosition), dropdownElementRef); const positionClass = widthMode === "menu" ? resolvedAlign === "right" diff --git a/src/components/SessionHoverCard/HoverCardBase.tsx b/src/components/SessionHoverCard/HoverCardBase.tsx index 34c9614528..8268f14d50 100644 --- a/src/components/SessionHoverCard/HoverCardBase.tsx +++ b/src/components/SessionHoverCard/HoverCardBase.tsx @@ -9,6 +9,7 @@ import React, { } from "react"; import { createPortal } from "react-dom"; +import { useOverlayLayer } from "@src/store/ui/overlayLayerAtom"; import { getViewportSize } from "@src/util/ui/window/viewport"; import { @@ -220,6 +221,7 @@ const HoverCardPortal: React.FC = ({ const cardRef = useRef(null); const [cardSize, setCardSize] = useState({ width: 0, height: 0 }); const { triggerRect } = useHoverCardState(); + useOverlayLayer(Boolean(triggerRect), cardRef); useLayoutEffect(() => { const node = cardRef.current; diff --git a/src/components/TabPill/index.tsx b/src/components/TabPill/index.tsx index 5e721b81c7..d950c76c9e 100644 --- a/src/components/TabPill/index.tsx +++ b/src/components/TabPill/index.tsx @@ -2,6 +2,10 @@ import React, { memo, useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { DROPDOWN_CLASSES } from "@src/components/Dropdown/tokens"; +import { + DROPDOWN_OCCLUSION_OPTIONS, + useOverlayLayer, +} from "@src/store/ui/overlayLayerAtom"; import { getViewportSize } from "@src/util/ui/window/viewport"; import { SidebarTabButton } from "./SidebarTabButton"; @@ -58,6 +62,11 @@ const TabPill: React.FC = ({ const [dropdownPositioned, setDropdownPositioned] = useState(false); const dropdownTriggerRef = useRef(null); const dropdownPanelRef = useRef(null); + useOverlayLayer( + dropdownOpen && dropdownPositioned, + dropdownPanelRef, + DROPDOWN_OCCLUSION_OPTIONS + ); const [dropdownPos, setDropdownPos] = useState({ top: 0, right: 0 }); const dropdownTab = normalizedTabs.find((tab) => tab.dropdown); diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx index beea6905a3..e2e420cbc7 100644 --- a/src/components/Tooltip/index.tsx +++ b/src/components/Tooltip/index.tsx @@ -39,6 +39,7 @@ import React, { } from "react"; import ReactDOM from "react-dom"; +import { useOverlayLayer } from "@src/store/ui/overlayLayerAtom"; import { getViewportSize } from "@src/util/ui/window/viewport"; import "./index.scss"; @@ -247,6 +248,7 @@ const Tooltip = forwardRef( const isControlled = open !== undefined; const effectiveOpen = isControlled ? open : internalOpen; + useOverlayLayer(effectiveOpen, tooltipRef, { blocksNativeInput: false }); const usesFramedSurface = framedPanel || (!panelStyle && !backgroundColor); const updatePosition = useCallback(() => { diff --git a/src/engines/BrowserCore/BrowserSessionWebview.tsx b/src/engines/BrowserCore/BrowserSessionWebview.tsx index 71e24ca3f8..0801a66939 100644 --- a/src/engines/BrowserCore/BrowserSessionWebview.tsx +++ b/src/engines/BrowserCore/BrowserSessionWebview.tsx @@ -11,6 +11,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from "react"; import { createLogger } from "@src/hooks/logger"; import { useInlineWebview } from "@src/hooks/platform/useInlineWebview"; +import { useInlineWebviewOcclusions } from "@src/hooks/platform/useInlineWebview/useInlineWebviewOcclusions"; import { sidebarWidthAtom } from "@src/store/ui/sidebarAtom"; import { simulatorPrimarySidebarCollapsedAtom, @@ -79,6 +80,7 @@ interface BrowserSessionWebviewProps { session: BrowserSession; isActive: boolean; isTabActive: boolean; + isSurfaceVisible: boolean; containerRef: React.RefObject; onSessionUpdate: ( sessionId: string, @@ -103,6 +105,7 @@ const BrowserSessionWebview: React.FC = ({ session, isActive, isTabActive, + isSurfaceVisible, containerRef, onSessionUpdate, onNewTab, @@ -157,6 +160,7 @@ const BrowserSessionWebview: React.FC = ({ const webviewConfig = useMemo(() => { const shouldActivateWebview = hasNavigableUrl && isActive && isTabActive; + const shouldShowWebview = shouldActivateWebview && isSurfaceVisible; return { containerRef, @@ -164,7 +168,7 @@ const BrowserSessionWebview: React.FC = ({ // Defers native creation for restored/background tabs so old URLs do not // replay as live browser pages when the shared Browser host remounts. isActive: shouldActivateWebview, - isVisible: shouldActivateWebview, + isVisible: shouldShowWebview, // Use exact label (no UUID) so we can predict it for console log polling labelPrefix: webviewLabel, useExactLabel: true, @@ -215,6 +219,7 @@ const BrowserSessionWebview: React.FC = ({ session.incognito, isActive, isTabActive, + isSurfaceVisible, webviewLabel, onSessionUpdate, onNewTab, @@ -228,6 +233,14 @@ const BrowserSessionWebview: React.FC = ({ isWebviewCreated, } = useInlineWebview(webviewConfig); + useInlineWebviewOcclusions({ + containerRef, + isWebviewCreated, + isSurfaceVisible: + hasNavigableUrl && isActive && isTabActive && isSurfaceVisible, + label: webviewLabel, + }); + useEffect(() => { if (!isWebviewAvailable) return; @@ -290,7 +303,11 @@ const BrowserSessionWebview: React.FC = ({ updatedAt: Date.now(), }; const shouldSyncActiveState = - hasNavigableUrl && isActive && isTabActive && isWebviewCreated; + hasNavigableUrl && + isActive && + isTabActive && + isSurfaceVisible && + isWebviewCreated; if (shouldSyncActiveState) { activeInternalBrowserSyncRef.current = sync; @@ -326,6 +343,7 @@ const BrowserSessionWebview: React.FC = ({ hasNavigableUrl, isActive, isTabActive, + isSurfaceVisible, isWebviewAvailable, isWebviewCreated, session.id, diff --git a/src/engines/BrowserCore/index.tsx b/src/engines/BrowserCore/index.tsx index 2f2b9b87c2..3d8d58e253 100644 --- a/src/engines/BrowserCore/index.tsx +++ b/src/engines/BrowserCore/index.tsx @@ -33,6 +33,7 @@ import BrowserSessionWebview from "./BrowserSessionWebview"; import type { UseBrowserStateReturn } from "./hooks/useBrowserState"; import "./index.scss"; import { BROWSER_WEBVIEW_FRAME_ANCHOR_ATTRIBUTE } from "./nativeFrameAnchor"; +import { shouldShowNativeSurface } from "./nativeSurfaceVisibility"; const log = createLogger("BrowserCore"); @@ -224,6 +225,14 @@ export const BrowserCore: React.FC = ({ const showEmbeddedBrowserFallback = Boolean(currentUrl) && embeddedFallbackUrl === currentUrl; + // The sensitive-host fallback is a time-based hint, not proof that the + // native page failed. GitHub/Google can load successfully, so it must never + // hide a healthy WebView merely because the timer elapsed. + const isNativeSurfaceVisible = shouldShowNativeSurface({ + isLoading, + hasConfirmedError: Boolean(displayError), + hasTimedSensitiveHostHint: showEmbeddedBrowserFallback, + }); const handleOpenExternal = useCallback(() => { if (!currentUrl) return; @@ -277,6 +286,7 @@ export const BrowserCore: React.FC = ({ session={session} isActive={session.id === activeSessionId} isTabActive={isTabReallyActive} + isSurfaceVisible={isNativeSurfaceVisible} containerRef={webviewFrameAnchorRef} onSessionUpdate={updateSession} onNewTab={addSession} diff --git a/src/engines/BrowserCore/nativeSurfaceVisibility.test.ts b/src/engines/BrowserCore/nativeSurfaceVisibility.test.ts new file mode 100644 index 0000000000..66a4998ac6 --- /dev/null +++ b/src/engines/BrowserCore/nativeSurfaceVisibility.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { shouldShowNativeSurface } from "./nativeSurfaceVisibility"; + +describe("shouldShowNativeSurface", () => { + it("keeps a successfully loaded GitHub-like page visible after a timed host hint", () => { + expect( + shouldShowNativeSurface({ + isLoading: false, + hasConfirmedError: false, + hasTimedSensitiveHostHint: true, + }) + ).toBe(true); + }); + + it.each([ + { isLoading: true, hasConfirmedError: false }, + { isLoading: false, hasConfirmedError: true }, + ])("hides the surface for real blocking state: %o", (state) => { + expect( + shouldShowNativeSurface({ + ...state, + hasTimedSensitiveHostHint: false, + }) + ).toBe(false); + }); +}); diff --git a/src/engines/BrowserCore/nativeSurfaceVisibility.ts b/src/engines/BrowserCore/nativeSurfaceVisibility.ts new file mode 100644 index 0000000000..407b23443a --- /dev/null +++ b/src/engines/BrowserCore/nativeSurfaceVisibility.ts @@ -0,0 +1,17 @@ +export interface NativeSurfaceVisibilityState { + isLoading: boolean; + hasConfirmedError: boolean; + hasTimedSensitiveHostHint: boolean; +} + +/** + * Timed host hints are advisory only. A known host such as GitHub may be + * rendering successfully, so only an active loading panel or confirmed error + * is allowed to move the native page offscreen. + */ +export function shouldShowNativeSurface({ + isLoading, + hasConfirmedError, +}: NativeSurfaceVisibilityState): boolean { + return !isLoading && !hasConfirmedError; +} diff --git a/src/engines/ChatPanel/panels/LinkSessionToWorkItemModal.tsx b/src/engines/ChatPanel/panels/LinkSessionToWorkItemModal.tsx index a55fb6c999..603db60ef3 100644 --- a/src/engines/ChatPanel/panels/LinkSessionToWorkItemModal.tsx +++ b/src/engines/ChatPanel/panels/LinkSessionToWorkItemModal.tsx @@ -9,6 +9,7 @@ import { linkSessionToWorkItem } from "@src/api/tauri/agent/session"; import Button from "@src/components/Button"; import Input from "@src/components/Input"; import Message from "@src/components/Message"; +import { useOverlayLayer } from "@src/store/ui/overlayLayerAtom"; import { type WorkItemLinkOption, @@ -41,6 +42,8 @@ const LinkSessionToWorkItemModal: React.FC = ({ const [error, setError] = useState(null); const loadGenerationGuardRef = useRef(createAsyncGenerationGuard()); const linkGenerationGuardRef = useRef(createAsyncGenerationGuard()); + const modalPanelRef = useRef(null); + useOverlayLayer(open, modalPanelRef); useEffect(() => { const guard = loadGenerationGuardRef.current; @@ -141,7 +144,10 @@ const LinkSessionToWorkItemModal: React.FC = ({ aria-modal="true" data-testid="session-link-work-item-modal" > -
+
diff --git a/src/features/TaskKanban/components/FactoryViewPill/index.test.ts b/src/features/TaskKanban/components/FactoryViewPill/index.test.ts index dfc297c017..38e648eea5 100644 --- a/src/features/TaskKanban/components/FactoryViewPill/index.test.ts +++ b/src/features/TaskKanban/components/FactoryViewPill/index.test.ts @@ -20,7 +20,8 @@ const mocks = vi.hoisted(() => ({ search: "?view=list", })); -vi.mock("jotai", () => ({ +vi.mock("jotai", async (importOriginal) => ({ + ...(await importOriginal()), useSetAtom: () => mocks.openRuntime, })); diff --git a/src/hooks/dropdown/useDropdownEngine.ts b/src/hooks/dropdown/useDropdownEngine.ts index 1f282f8e6b..174686ea0d 100644 --- a/src/hooks/dropdown/useDropdownEngine.ts +++ b/src/hooks/dropdown/useDropdownEngine.ts @@ -19,7 +19,10 @@ import { } from "react"; import { DROPDOWN_PANEL } from "@src/components/Dropdown/tokens"; -import { useOverlayLayer } from "@src/store/ui/overlayLayerAtom"; +import { + DROPDOWN_OCCLUSION_OPTIONS, + useOverlayLayer, +} from "@src/store/ui/overlayLayerAtom"; import { getViewportSize } from "@src/util/ui/window/viewport"; import { useDropdownAutoKeyboard } from "./useDropdownAutoKeyboard"; @@ -201,9 +204,7 @@ export function useDropdownEngine< maxHeight: DROPDOWN_PANEL.maxHeight, }); - // Participate in the global overlay-layer count so inline browser - // WKWebViews drop behind React portals while this dropdown is open. - useOverlayLayer(isOpen); + useOverlayLayer(isOpen && isPositioned, panelRef, DROPDOWN_OCCLUSION_OPTIONS); const updatePosition = useCallback(() => { const triggerElement = latestTriggerRef.current.current; diff --git a/src/hooks/platform/useInlineWebview/__tests__/nativeWebviewOcclusion.test.ts b/src/hooks/platform/useInlineWebview/__tests__/nativeWebviewOcclusion.test.ts new file mode 100644 index 0000000000..c671942243 --- /dev/null +++ b/src/hooks/platform/useInlineWebview/__tests__/nativeWebviewOcclusion.test.ts @@ -0,0 +1,59 @@ +import { + coalesceOcclusionRects, + computeNativeWebviewOcclusions, +} from "../nativeWebviewOcclusion"; + +describe("nativeWebviewOcclusion", () => { + it("intersects viewport overlays and converts them to local scaled points", () => { + expect( + computeNativeWebviewOcclusions( + { left: 100, top: 50, right: 500, bottom: 350 }, + [ + { x: 450, y: 20, width: 100, height: 100 }, + { x: 10, y: 10, width: 20, height: 20 }, + ], + 1.25 + ) + ).toEqual([{ x: 438, y: 0, width: 62, height: 87 }]); + }); + + it("returns no holes for overlays outside the webview", () => { + expect( + computeNativeWebviewOcclusions( + { left: 100, top: 100, right: 300, bottom: 300 }, + [{ x: 0, y: 0, width: 50, height: 50 }], + 1 + ) + ).toEqual([]); + }); + + it("merges overlapping holes so even-odd masking cannot XOR the overlap", () => { + expect( + coalesceOcclusionRects([ + { x: 10, y: 10, width: 30, height: 30 }, + { x: 30, y: 20, width: 30, height: 20 }, + { x: 80, y: 80, width: 10, height: 10 }, + ]) + ).toEqual([ + { x: 10, y: 10, width: 50, height: 30 }, + { x: 80, y: 80, width: 10, height: 10 }, + ]); + }); + + it("rejects invalid geometry and scale", () => { + expect( + computeNativeWebviewOcclusions( + { left: 0, top: 0, right: 100, bottom: 100 }, + [{ x: 10, y: 10, width: Number.NaN, height: 20 }], + 1 + ) + ).toEqual([]); + expect( + computeNativeWebviewOcclusions( + { left: 0, top: 0, right: 100, bottom: 100 }, + [{ x: 10, y: 10, width: 20, height: 20 }], + 0 + ) + ).toEqual([]); + }); +}); diff --git a/src/hooks/platform/useInlineWebview/__tests__/useInlineWebviewOcclusions.test.ts b/src/hooks/platform/useInlineWebview/__tests__/useInlineWebviewOcclusions.test.ts new file mode 100644 index 0000000000..8370da79e1 --- /dev/null +++ b/src/hooks/platform/useInlineWebview/__tests__/useInlineWebviewOcclusions.test.ts @@ -0,0 +1,254 @@ +// @vitest-environment jsdom +import { Provider, createStore } from "jotai"; +import React, { act, createElement, useRef } from "react"; +import { type Root, createRoot } from "react-dom/client"; + +import { overlayLayerRegistryAtom } from "@src/store/ui/overlayLayerAtom"; + +import { useInlineWebviewOcclusions } from "../useInlineWebviewOcclusions"; + +const invokeMock = vi.hoisted(() => vi.fn()); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); +vi.mock("@src/util/platform/tauri", () => ({ isMacOS: () => true })); +vi.mock("@src/util/platform/tauri/nativeFrame", () => ({ + getNativeFrameScale: () => 1, + toNativeFrameFromCorners: ( + rect: { left: number; top: number; right: number; bottom: number }, + _scale: number + ) => ({ + x: rect.left, + y: rect.top, + width: rect.right - rect.left, + height: rect.bottom - rect.top, + }), +})); +vi.mock("../visibleWebviewRect", () => ({ + getVisibleWebviewRect: () => ({ + left: 100, + top: 50, + right: 500, + bottom: 350, + width: 400, + height: 300, + }), +})); + +function Harness() { + const ref = useRef(null); + useInlineWebviewOcclusions({ + containerRef: ref, + isWebviewCreated: true, + isSurfaceVisible: true, + label: "browser-session-test", + }); + // eslint-disable-next-line react-hooks/refs -- Vitest only collects `.test.ts`; createElement is the JSX-equivalent ref prop. + return createElement("div", { ref }); +} + +function renderHarness( + store: ReturnType +): React.ReactElement { + return createElement(Provider, { store }, createElement(Harness)); +} + +async function flushEffects(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("useInlineWebviewOcclusions", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + invokeMock.mockReset().mockResolvedValue(undefined); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("applies a local hole and clears it when the overlay closes", async () => { + const store = createStore(); + await act(async () => { + root.render(renderHarness(store)); + }); + await flushEffects(); + invokeMock.mockClear(); + + act(() => { + store.set(overlayLayerRegistryAtom, { + menu: { + id: "menu", + rect: { x: 450, y: 20, width: 100, height: 100 }, + blocksNativeInput: true, + nativeDimmingAlpha: 0, + }, + }); + }); + await flushEffects(); + + expect(invokeMock).toHaveBeenLastCalledWith( + "set_inline_webview_occlusions", + { + label: "browser-session-test", + rects: [{ x: 346, y: 0, width: 54, height: 74 }], + dimHoleRects: [{ x: 346, y: 0, width: 54, height: 74 }], + blockInput: true, + dimmingAlpha: 0, + } + ); + + act(() => store.set(overlayLayerRegistryAtom, {})); + await flushEffects(); + expect(invokeMock).toHaveBeenLastCalledWith( + "set_inline_webview_occlusions", + { + label: "browser-session-test", + rects: [], + dimHoleRects: [], + blockInput: false, + dimmingAlpha: 0, + } + ); + }); + + it("keeps a modal panel local while dimming the live native page", async () => { + const store = createStore(); + await act(async () => { + root.render(renderHarness(store)); + }); + await flushEffects(); + invokeMock.mockClear(); + + act(() => { + store.set(overlayLayerRegistryAtom, { + modal: { + id: "modal", + rect: { x: 200, y: 100, width: 200, height: 120 }, + blocksNativeInput: true, + nativeDimmingAlpha: 0.6, + }, + }); + }); + await flushEffects(); + + expect(invokeMock).toHaveBeenLastCalledWith( + "set_inline_webview_occlusions", + { + label: "browser-session-test", + rects: [{ x: 84, y: 34, width: 232, height: 152 }], + dimHoleRects: [{ x: 84, y: 34, width: 232, height: 152 }], + blockInput: true, + dimmingAlpha: 0.6, + } + ); + }); + + it("dims spotlight tours with a popover mask hole only", async () => { + const store = createStore(); + await act(async () => { + root.render(renderHarness(store)); + }); + await flushEffects(); + invokeMock.mockClear(); + + act(() => { + store.set(overlayLayerRegistryAtom, { + scrim: { + id: "scrim", + rect: null, + blocksNativeInput: true, + nativeDimmingAlpha: 0.3, + cutsNativeSurface: false, + }, + popover: { + id: "popover", + rect: { x: 450, y: 20, width: 100, height: 100 }, + blocksNativeInput: true, + nativeDimmingAlpha: 0, + maskHoleOnly: true, + }, + }); + }); + await flushEffects(); + + expect(invokeMock).toHaveBeenLastCalledWith( + "set_inline_webview_occlusions", + { + label: "browser-session-test", + rects: [{ x: 334, y: 0, width: 66, height: 86 }], + dimHoleRects: [], + blockInput: true, + dimmingAlpha: 0.3, + } + ); + }); + + it("applies the latest close after a slower open command completes", async () => { + const store = createStore(); + await act(async () => { + root.render(renderHarness(store)); + }); + await flushEffects(); + invokeMock.mockClear(); + + let resolveOpen!: () => void; + invokeMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOpen = resolve; + }) + ); + + act(() => { + store.set(overlayLayerRegistryAtom, { + menu: { + id: "menu", + rect: { x: 200, y: 100, width: 100, height: 100 }, + blocksNativeInput: true, + nativeDimmingAlpha: 0.6, + }, + }); + }); + await flushEffects(); + expect(invokeMock).toHaveBeenCalledTimes(1); + + act(() => store.set(overlayLayerRegistryAtom, {})); + await flushEffects(); + expect(invokeMock).toHaveBeenCalledTimes(1); + + resolveOpen(); + await flushEffects(); + + expect(invokeMock).toHaveBeenCalledTimes(2); + expect(invokeMock).toHaveBeenLastCalledWith( + "set_inline_webview_occlusions", + { + label: "browser-session-test", + rects: [], + dimHoleRects: [], + blockInput: false, + dimmingAlpha: 0, + } + ); + }); +}); diff --git a/src/hooks/platform/useInlineWebview/__tests__/useWebviewCommands.test.ts b/src/hooks/platform/useInlineWebview/__tests__/useWebviewCommands.test.ts index 8f09379d52..d4f092b6b0 100644 --- a/src/hooks/platform/useInlineWebview/__tests__/useWebviewCommands.test.ts +++ b/src/hooks/platform/useInlineWebview/__tests__/useWebviewCommands.test.ts @@ -33,6 +33,7 @@ function createParams( pollIntervalRef: { current: null }, newWindowListenerRef: { current: null }, lastPolledUrlRef: { current: "https://example.com" }, + lastRequestedUrlRef: { current: "" }, getContainerRect: () => null, log: vi.fn(), safeUnlisten: vi.fn(), diff --git a/src/hooks/platform/useInlineWebview/__tests__/useWebviewLayout.test.ts b/src/hooks/platform/useInlineWebview/__tests__/useWebviewLayout.test.ts new file mode 100644 index 0000000000..42cb6e2fef --- /dev/null +++ b/src/hooks/platform/useInlineWebview/__tests__/useWebviewLayout.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { UseWebviewLayoutParams } from "../useWebviewLayout"; + +const invokeMock = vi.fn(); +const visibleRectMock = vi.fn(); + +vi.mock("react", () => ({ + useCallback: unknown>( + callback: Callback + ) => callback, + useEffect: () => undefined, + useRef: (value: Value) => ({ current: value }), +})); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: invokeMock, +})); + +vi.mock("@src/hooks/logger", () => ({ + createLogger: () => ({ rateLimited: vi.fn() }), +})); + +vi.mock("@src/hooks/perf/useDebouncedCallback", () => ({ + DEBOUNCE_DELAYS: { FRAME: 16 }, + useDebouncedCallback: (callback: () => void) => + Object.assign(callback, { + cancel: vi.fn(), + flush: vi.fn(), + pending: () => false, + }), +})); + +vi.mock("../visibleWebviewRect", () => ({ + getVisibleWebviewRect: visibleRectMock, +})); + +const visibleRect = { + x: 20, + y: 30, + width: 800, + height: 600, + top: 30, + right: 820, + bottom: 630, + left: 20, + toJSON: () => ({}), +} as DOMRect; + +function createParams( + overrides: Partial = {} +): UseWebviewLayoutParams { + return { + containerRef: { current: {} as HTMLDivElement }, + isWebviewCreated: true, + isWebviewAvailable: true, + isVisible: true, + labelRef: { current: "browser-session-test" }, + log: vi.fn(), + ...overrides, + }; +} + +describe("useWebviewLayout native surface commands", () => { + beforeEach(() => { + invokeMock.mockReset(); + invokeMock.mockResolvedValue(undefined); + visibleRectMock.mockReset(); + visibleRectMock.mockReturnValue(visibleRect); + }); + + it("never writes an on-screen frame while the surface is hidden", async () => { + const { useWebviewLayout } = await import("../useWebviewLayout"); + const layout = useWebviewLayout(createParams({ isVisible: false })); + + await layout.updatePosition({ force: true }); + + expect(invokeMock).toHaveBeenCalledWith("update_inline_webview_position", { + label: "browser-session-test", + x: -10000, + y: -10000, + width: 1, + height: 1, + }); + expect(invokeMock).not.toHaveBeenCalledWith( + "update_inline_webview_position", + expect.objectContaining({ x: 20, y: 30 }) + ); + }); + + it("repositions and shows in one native command", async () => { + const { useWebviewLayout } = await import("../useWebviewLayout"); + const layout = useWebviewLayout(createParams()); + + await expect(layout.repositionAndShow()).resolves.toBe(true); + + expect(invokeMock).toHaveBeenCalledTimes(1); + expect(invokeMock).toHaveBeenCalledWith("reposition_and_show_webview", { + label: "browser-session-test", + x: 20, + y: 30, + a: 820, + b: 630, + width: 800, + height: 600, + }); + }); + + it("serializes a later offscreen transition behind an in-flight update", async () => { + let finishFirst: (() => void) | undefined; + invokeMock.mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = resolve; + }) + ); + + const { useWebviewLayout } = await import("../useWebviewLayout"); + const layout = useWebviewLayout(createParams()); + const update = layout.updatePosition({ force: true }); + const hide = layout.stageOffscreen({ force: true }); + + await Promise.resolve(); + await Promise.resolve(); + expect(invokeMock).toHaveBeenCalledTimes(1); + + finishFirst?.(); + await Promise.all([update, hide]); + + expect(invokeMock.mock.calls.map(([command]) => command)).toEqual([ + "update_inline_webview_position", + "update_inline_webview_position", + ]); + expect(invokeMock).toHaveBeenLastCalledWith( + "update_inline_webview_position", + expect.objectContaining({ x: -10000, y: -10000 }) + ); + }); + + it("retries an offscreen write after an IPC failure", async () => { + invokeMock.mockRejectedValueOnce(new Error("ipc unavailable")); + + const { useWebviewLayout } = await import("../useWebviewLayout"); + const layout = useWebviewLayout(createParams()); + + await layout.stageOffscreen(); + await layout.stageOffscreen(); + + expect(invokeMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/hooks/platform/useInlineWebview/__tests__/visibleWebviewRect.test.ts b/src/hooks/platform/useInlineWebview/__tests__/visibleWebviewRect.test.ts new file mode 100644 index 0000000000..938678aaea --- /dev/null +++ b/src/hooks/platform/useInlineWebview/__tests__/visibleWebviewRect.test.ts @@ -0,0 +1,173 @@ +// @vitest-environment jsdom +import { describe, expect, it } from "vitest"; + +import { + type ClippingRect, + type RectEdges, + computeVisibleWebviewRect, + getVisibleWebviewRect, +} from "../visibleWebviewRect"; + +function rect( + left: number, + top: number, + right: number, + bottom: number +): RectEdges { + return { left, top, right, bottom }; +} + +function expectRect( + actual: DOMRect | null, + expected: RectEdges & { width: number; height: number } +) { + expect(actual).toMatchObject(expected); +} + +describe("computeVisibleWebviewRect", () => { + const viewport = rect(0, 0, 1_000, 800); + + it("keeps a fully visible anchor unchanged", () => { + expectRect(computeVisibleWebviewRect(rect(100, 80, 700, 500), viewport), { + left: 100, + top: 80, + right: 700, + bottom: 500, + width: 600, + height: 420, + }); + }); + + it("clips an anchor that is only partially inside the viewport", () => { + expectRect( + computeVisibleWebviewRect(rect(-40, 100, 1_040, 900), viewport), + { + left: 0, + top: 100, + right: 1_000, + bottom: 800, + width: 1_000, + height: 700, + } + ); + }); + + it("returns null when the anchor is completely outside the viewport", () => { + expect( + computeVisibleWebviewRect(rect(1_001, 100, 1_200, 300), viewport) + ).toBeNull(); + }); + + it("intersects multiple clipping ancestors on their configured axes", () => { + const ancestors: ClippingRect[] = [ + { rect: rect(120, -1_000, 900, 1_000), clipX: true, clipY: false }, + { rect: rect(-1_000, 140, 2_000, 620), clipX: false, clipY: true }, + { rect: rect(180, 180, 840, 580), clipX: true, clipY: true }, + ]; + + expectRect( + computeVisibleWebviewRect(rect(100, 100, 950, 700), viewport, ancestors), + { + left: 180, + top: 180, + right: 840, + bottom: 580, + width: 660, + height: 400, + } + ); + }); + + it("returns null when an ancestor fully clips the visible anchor", () => { + expect( + computeVisibleWebviewRect(rect(100, 100, 500, 500), viewport, [ + { rect: rect(600, 0, 900, 800), clipX: true, clipY: false }, + ]) + ).toBeNull(); + }); + + it.each([ + ["zero-width anchor", rect(100, 100, 100, 200), viewport, []], + ["zero-height viewport", rect(100, 100, 200, 200), rect(0, 0, 500, 0), []], + ["non-finite anchor", rect(100, 100, Number.NaN, 200), viewport, []], + [ + "zero-width clipping ancestor", + rect(100, 100, 200, 200), + viewport, + [{ rect: rect(150, 0, 150, 800), clipX: true, clipY: false }], + ], + ] satisfies Array<[string, RectEdges, RectEdges, readonly ClippingRect[]]>)( + "returns null for %s", + (_name, anchor, viewportRect, ancestors) => { + expect( + computeVisibleWebviewRect(anchor, viewportRect, ancestors) + ).toBeNull(); + } + ); + + it("ignores an invalid ancestor that does not clip either axis", () => { + expectRect( + computeVisibleWebviewRect(rect(100, 100, 200, 200), viewport, [ + { + rect: rect(Number.NaN, Number.NaN, Number.NaN, Number.NaN), + clipX: false, + clipY: false, + }, + ]), + { + left: 100, + top: 100, + right: 200, + bottom: 200, + width: 100, + height: 100, + } + ); + }); +}); + +describe("getVisibleWebviewRect", () => { + it("collects hidden, clip, auto, and scroll ancestors while ignoring visible overflow", () => { + Object.defineProperties(document.documentElement, { + clientWidth: { configurable: true, value: 1_000 }, + clientHeight: { configurable: true, value: 800 }, + }); + + const visibleParent = document.createElement("div"); + visibleParent.style.overflow = "visible"; + const outerClip = document.createElement("div"); + outerClip.style.overflowX = "hidden"; + outerClip.style.overflowY = "visible"; + const explicitClip = document.createElement("div"); + explicitClip.style.overflowY = "clip"; + const scrollClip = document.createElement("div"); + scrollClip.style.overflowX = "auto"; + scrollClip.style.overflowY = "scroll"; + const anchor = document.createElement("div"); + + document.body.append(visibleParent); + visibleParent.append(outerClip); + outerClip.append(explicitClip); + explicitClip.append(scrollClip); + scrollClip.append(anchor); + + visibleParent.getBoundingClientRect = () => + rect(-500, -500, 500, 500) as DOMRect; + outerClip.getBoundingClientRect = () => rect(100, 0, 900, 800) as DOMRect; + explicitClip.getBoundingClientRect = () => + rect(0, 120, 1_000, 680) as DOMRect; + scrollClip.getBoundingClientRect = () => rect(0, 80, 1_000, 720) as DOMRect; + anchor.getBoundingClientRect = () => rect(20, 20, 980, 760) as DOMRect; + + expectRect(getVisibleWebviewRect(anchor), { + left: 100, + top: 120, + right: 900, + bottom: 680, + width: 800, + height: 560, + }); + + visibleParent.remove(); + }); +}); diff --git a/src/hooks/platform/useInlineWebview/nativeWebviewOcclusion.ts b/src/hooks/platform/useInlineWebview/nativeWebviewOcclusion.ts new file mode 100644 index 0000000000..252c9159d5 --- /dev/null +++ b/src/hooks/platform/useInlineWebview/nativeWebviewOcclusion.ts @@ -0,0 +1,182 @@ +import type { OverlayOcclusionRect } from "@src/store/ui/overlayLayerAtom"; +import { toNativeFrameFromCorners } from "@src/util/platform/tauri/nativeFrame"; + +export interface NativeWebviewOcclusionRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface ViewportRect { + left: number; + top: number; + right: number; + bottom: number; +} + +const MAX_OCCLUSION_RECTS = 64; +const DEFAULT_HOLE_INFLATION_CSS = 4; +const MODAL_HOLE_INFLATION_CSS = 16; + +export { DEFAULT_HOLE_INFLATION_CSS, MODAL_HOLE_INFLATION_CSS }; + +function inflateViewportRect( + rect: ViewportRect, + inflationCss: number +): ViewportRect { + if (inflationCss <= 0) return rect; + return { + left: rect.left - inflationCss, + top: rect.top - inflationCss, + right: rect.right + inflationCss, + bottom: rect.bottom + inflationCss, + }; +} + +function clampViewportRectToSurface( + rect: ViewportRect, + surface: ViewportRect +): ViewportRect | null { + const left = Math.max(surface.left, rect.left); + const top = Math.max(surface.top, rect.top); + const right = Math.min(surface.right, rect.right); + const bottom = Math.min(surface.bottom, rect.bottom); + if (right <= left || bottom <= top) return null; + return { left, top, right, bottom }; +} + +function overlapsOrTouches( + left: NativeWebviewOcclusionRect, + right: NativeWebviewOcclusionRect +): boolean { + return !( + left.x + left.width < right.x || + right.x + right.width < left.x || + left.y + left.height < right.y || + right.y + right.height < left.y + ); +} + +function mergeRects( + left: NativeWebviewOcclusionRect, + right: NativeWebviewOcclusionRect +): NativeWebviewOcclusionRect { + const x = Math.min(left.x, right.x); + const y = Math.min(left.y, right.y); + const rightEdge = Math.max(left.x + left.width, right.x + right.width); + const bottomEdge = Math.max(left.y + left.height, right.y + right.height); + return { x, y, width: rightEdge - x, height: bottomEdge - y }; +} + +/** + * CAShapeLayer's even-odd rule treats overlapping hole paths as XOR. Merge + * intersecting rectangles first so the overlap cannot become visible again. + * The bounding rectangle is deliberately conservative: hiding a few extra + * pixels is safer than letting a native surface paint over React UI. + */ +export function coalesceOcclusionRects( + rects: readonly NativeWebviewOcclusionRect[] +): NativeWebviewOcclusionRect[] { + const merged: NativeWebviewOcclusionRect[] = []; + + for (const source of rects.slice(0, MAX_OCCLUSION_RECTS)) { + let candidate = source; + let index = 0; + while (index < merged.length) { + if (!overlapsOrTouches(candidate, merged[index])) { + index += 1; + continue; + } + candidate = mergeRects(candidate, merged[index]); + merged.splice(index, 1); + index = 0; + } + merged.push(candidate); + } + + return merged; +} + +/** Convert viewport CSS rectangles into WebView-local native logical points. */ +export function computeNativeWebviewOcclusions( + surface: ViewportRect, + overlays: readonly OverlayOcclusionRect[], + nativeFrameScale: number, + options: { holeInflationCss?: number } = {} +): NativeWebviewOcclusionRect[] { + if ( + !Number.isFinite(nativeFrameScale) || + nativeFrameScale <= 0 || + surface.right <= surface.left || + surface.bottom <= surface.top + ) { + return []; + } + + const holeInflationCss = Math.max(0, options.holeInflationCss ?? 0); + const nativeSurface = toNativeFrameFromCorners( + { + left: surface.left, + top: surface.top, + right: surface.right, + bottom: surface.bottom, + }, + nativeFrameScale + ); + const nativeSurfaceRight = nativeSurface.x + nativeSurface.width; + const nativeSurfaceBottom = nativeSurface.y + nativeSurface.height; + const intersections: NativeWebviewOcclusionRect[] = []; + + for (const overlay of overlays.slice(0, MAX_OCCLUSION_RECTS)) { + if ( + !Number.isFinite(overlay.x) || + !Number.isFinite(overlay.y) || + !Number.isFinite(overlay.width) || + !Number.isFinite(overlay.height) || + overlay.width <= 0 || + overlay.height <= 0 + ) { + continue; + } + + const overlayViewport = inflateViewportRect( + { + left: overlay.x, + top: overlay.y, + right: overlay.x + overlay.width, + bottom: overlay.y + overlay.height, + }, + holeInflationCss + ); + const intersection = clampViewportRectToSurface(overlayViewport, surface); + if (!intersection) continue; + + const nativeOverlay = toNativeFrameFromCorners( + intersection, + nativeFrameScale + ); + const left = Math.max(nativeSurface.x, nativeOverlay.x); + const top = Math.max(nativeSurface.y, nativeOverlay.y); + const right = Math.min( + nativeSurfaceRight, + nativeOverlay.x + nativeOverlay.width + ); + const bottom = Math.min( + nativeSurfaceBottom, + nativeOverlay.y + nativeOverlay.height + ); + if (right <= left || bottom <= top) continue; + + intersections.push({ + x: left - nativeSurface.x, + y: top - nativeSurface.y, + width: right - left, + height: bottom - top, + }); + } + + return coalesceOcclusionRects( + intersections.filter((rect) => rect.width > 0 && rect.height > 0) + ); +} diff --git a/src/hooks/platform/useInlineWebview/useInlineWebview.ts b/src/hooks/platform/useInlineWebview/useInlineWebview.ts index 0ebfea3af7..041619f0a2 100644 --- a/src/hooks/platform/useInlineWebview/useInlineWebview.ts +++ b/src/hooks/platform/useInlineWebview/useInlineWebview.ts @@ -73,10 +73,16 @@ export function useInlineWebview( onNewWindow, }); - const { getContainerRect, updatePosition } = useWebviewLayout({ + const { + getContainerRect, + updatePosition, + repositionAndShow, + stageOffscreen, + } = useWebviewLayout({ containerRef, isWebviewCreated, isWebviewAvailable, + isVisible, labelRef, log, }); @@ -107,6 +113,7 @@ export function useInlineWebview( pollIntervalRef, newWindowListenerRef, lastPolledUrlRef, + lastRequestedUrlRef, getContainerRect, log, safeUnlisten, @@ -126,8 +133,8 @@ export function useInlineWebview( isWebviewCreated, isVisible, isWebviewAvailable, - labelRef, - updatePosition, + repositionAndShow, + stageOffscreen, log, }); @@ -138,6 +145,7 @@ export function useInlineWebview( isWebviewAvailable, createDelay, containerRef, + getContainerRect, isDestroyedRef, lastRequestedUrlRef, createWebview, diff --git a/src/hooks/platform/useInlineWebview/useInlineWebviewNativeVisibility.ts b/src/hooks/platform/useInlineWebview/useInlineWebviewNativeVisibility.ts index e9bb5adfd4..7f5026aa02 100644 --- a/src/hooks/platform/useInlineWebview/useInlineWebviewNativeVisibility.ts +++ b/src/hooks/platform/useInlineWebview/useInlineWebviewNativeVisibility.ts @@ -1,12 +1,11 @@ -import { invoke } from "@tauri-apps/api/core"; -import { type MutableRefObject, useEffect } from "react"; +import { useEffect } from "react"; export interface UseInlineWebviewNativeVisibilityParams { isWebviewCreated: boolean; isVisible: boolean; isWebviewAvailable: boolean; - labelRef: MutableRefObject; - updatePosition: (options?: { force?: boolean }) => Promise; + repositionAndShow: () => Promise; + stageOffscreen: (options?: { force?: boolean }) => Promise; log: (...args: unknown[]) => void; } @@ -17,8 +16,8 @@ export function useInlineWebviewNativeVisibility( isWebviewCreated, isVisible, isWebviewAvailable, - labelRef, - updatePosition, + repositionAndShow, + stageOffscreen, log, } = params; @@ -31,21 +30,10 @@ export function useInlineWebviewNativeVisibility( try { if (isVisible) { log("Showing WebView (isVisible=true)"); - await updatePosition({ force: true }); - if (cancelled) return; - await invoke("set_inline_webview_visibility", { - label: labelRef.current, - visible: true, - }); + await repositionAndShow(); } else { log("Staging WebView offscreen (isVisible=false, but still mounted)"); - await invoke("update_inline_webview_position", { - label: labelRef.current, - x: -10000, - y: -10000, - width: 1, - height: 1, - }); + await stageOffscreen({ force: true }); } } catch (err) { if (!cancelled) { @@ -63,8 +51,8 @@ export function useInlineWebviewNativeVisibility( isWebviewCreated, isVisible, isWebviewAvailable, - labelRef, - updatePosition, + repositionAndShow, + stageOffscreen, log, ]); } diff --git a/src/hooks/platform/useInlineWebview/useInlineWebviewOcclusions.ts b/src/hooks/platform/useInlineWebview/useInlineWebviewOcclusions.ts new file mode 100644 index 0000000000..d19fe3a1d8 --- /dev/null +++ b/src/hooks/platform/useInlineWebview/useInlineWebviewOcclusions.ts @@ -0,0 +1,294 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useAtomValue } from "jotai"; +import { type RefObject, useCallback, useEffect, useRef } from "react"; + +import { createLogger } from "@src/hooks/logger"; +import { overlayOcclusionStateAtom } from "@src/store/ui/overlayLayerAtom"; +import { isMacOS } from "@src/util/platform/tauri"; +import { getNativeFrameScale } from "@src/util/platform/tauri/nativeFrame"; + +import { + DEFAULT_HOLE_INFLATION_CSS, + MODAL_HOLE_INFLATION_CSS, + computeNativeWebviewOcclusions, +} from "./nativeWebviewOcclusion"; +import { getVisibleWebviewRect } from "./visibleWebviewRect"; +import { + WEBVIEW_LAYOUT_CHANGED_EVENT, + WEBVIEW_NATIVE_FRAME_UPDATED_EVENT, + type WebviewNativeFrameUpdatedDetail, +} from "./webviewLayoutEvents"; + +const log = createLogger("InlineWebviewOcclusions"); + +export interface UseInlineWebviewOcclusionsParams { + containerRef: RefObject; + isWebviewCreated: boolean; + isSurfaceVisible: boolean; + label: string; +} + +interface DesiredOcclusionState { + revision: number; + maskRects: ReturnType; + dimHoleRects: ReturnType; + blockInput: boolean; + dimmingAlpha: number; +} + +function samePayload( + left: DesiredOcclusionState | null, + right: DesiredOcclusionState +): boolean { + if ( + !left || + left.blockInput !== right.blockInput || + left.dimmingAlpha !== right.dimmingAlpha + ) { + return false; + } + for (const rects of ["maskRects", "dimHoleRects"] as const) { + if (left[rects].length !== right[rects].length) return false; + if ( + !left[rects].every((rect, index) => { + const candidate = right[rects][index]; + return ( + rect.x === candidate.x && + rect.y === candidate.y && + rect.width === candidate.width && + rect.height === candidate.height + ); + }) + ) { + return false; + } + } + return true; +} + +/** + * Projects the global React overlay registry into one native browser surface. + * IPC is latest-wins and serialized so a late open/close response cannot + * restore a stale mask. + */ +export function useInlineWebviewOcclusions({ + containerRef, + isWebviewCreated, + isSurfaceVisible, + label, +}: UseInlineWebviewOcclusionsParams): void { + const overlayState = useAtomValue(overlayOcclusionStateAtom); + const desiredRef = useRef({ + revision: 0, + maskRects: [], + dimHoleRects: [], + blockInput: false, + dimmingAlpha: 0, + }); + // Native surfaces start with no mask/input block. Seeding that projection + // avoids one no-op IPC for every restored but inactive browser session. + const appliedRef = useRef({ + revision: 0, + maskRects: [], + dimHoleRects: [], + blockInput: false, + dimmingAlpha: 0, + }); + const applyingRef = useRef(false); + const frameRef = useRef(null); + const mountedRef = useRef(true); + + const measureDesired = useCallback((): DesiredOcclusionState => { + const revision = desiredRef.current.revision + 1; + if (!isWebviewCreated || !isSurfaceVisible || !containerRef.current) { + return { + revision, + maskRects: [], + dimHoleRects: [], + blockInput: false, + dimmingAlpha: 0, + }; + } + + const surface = getVisibleWebviewRect(containerRef.current); + if (!surface) { + return { + revision, + maskRects: [], + dimHoleRects: [], + blockInput: false, + dimmingAlpha: 0, + }; + } + + const holeInflationCss = + overlayState.nativeDimmingAlpha > 0 + ? MODAL_HOLE_INFLATION_CSS + : DEFAULT_HOLE_INFLATION_CSS; + + return { + revision, + maskRects: computeNativeWebviewOcclusions( + surface, + overlayState.maskRects, + getNativeFrameScale(), + { holeInflationCss } + ), + dimHoleRects: computeNativeWebviewOcclusions( + surface, + overlayState.dimHoleRects, + getNativeFrameScale(), + { holeInflationCss } + ), + blockInput: overlayState.blocksNativeInput, + dimmingAlpha: overlayState.nativeDimmingAlpha, + }; + }, [ + containerRef, + isSurfaceVisible, + isWebviewCreated, + overlayState.blocksNativeInput, + overlayState.dimHoleRects, + overlayState.maskRects, + overlayState.nativeDimmingAlpha, + ]); + + const applyLatest = useCallback(async () => { + if (applyingRef.current || !isMacOS()) return; + applyingRef.current = true; + let failedRevision: number | null = null; + + try { + while ( + mountedRef.current && + appliedRef.current?.revision !== desiredRef.current.revision + ) { + const desired = desiredRef.current; + if (samePayload(appliedRef.current, desired)) { + appliedRef.current = desired; + continue; + } + + try { + await invoke("set_inline_webview_occlusions", { + label, + rects: desired.maskRects, + dimHoleRects: desired.dimHoleRects, + blockInput: desired.blockInput, + dimmingAlpha: desired.dimmingAlpha, + }); + } catch (error) { + failedRevision = desired.revision; + log.warn("Failed to apply native WebView occlusions:", error); + break; + } + + appliedRef.current = desired; + } + } finally { + applyingRef.current = false; + if ( + mountedRef.current && + desiredRef.current.revision !== appliedRef.current?.revision && + desiredRef.current.revision !== failedRevision + ) { + void applyLatest(); + } + } + }, [label]); + + const publish = useCallback(() => { + desiredRef.current = measureDesired(); + void applyLatest(); + }, [applyLatest, measureDesired]); + + const schedulePublish = useCallback(() => { + if (frameRef.current !== null) return; + frameRef.current = window.requestAnimationFrame(() => { + frameRef.current = null; + publish(); + }); + }, [publish]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + publish(); + }, [publish]); + + useEffect(() => { + if ( + !isMacOS() || + !isWebviewCreated || + !isSurfaceVisible || + (overlayState.maskRects.length === 0 && + overlayState.dimHoleRects.length === 0 && + overlayState.nativeDimmingAlpha === 0) + ) { + return; + } + + const element = containerRef.current; + const resizeObserver = + element && typeof ResizeObserver !== "undefined" + ? new ResizeObserver(schedulePublish) + : null; + if (element) resizeObserver?.observe(element); + + window.addEventListener("resize", schedulePublish); + window.addEventListener("scroll", schedulePublish, true); + window.addEventListener(WEBVIEW_LAYOUT_CHANGED_EVENT, schedulePublish); + const handleNativeFrameUpdated = (event: Event) => { + const detail = (event as CustomEvent) + .detail; + if (detail?.label === label) schedulePublish(); + }; + window.addEventListener( + WEBVIEW_NATIVE_FRAME_UPDATED_EVENT, + handleNativeFrameUpdated + ); + + return () => { + resizeObserver?.disconnect(); + window.removeEventListener("resize", schedulePublish); + window.removeEventListener("scroll", schedulePublish, true); + window.removeEventListener(WEBVIEW_LAYOUT_CHANGED_EVENT, schedulePublish); + window.removeEventListener( + WEBVIEW_NATIVE_FRAME_UPDATED_EVENT, + handleNativeFrameUpdated + ); + }; + }, [ + containerRef, + isSurfaceVisible, + isWebviewCreated, + label, + overlayState.dimHoleRects.length, + overlayState.maskRects.length, + overlayState.nativeDimmingAlpha, + schedulePublish, + ]); + + useEffect(() => { + return () => { + if (frameRef.current !== null) { + window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + if (isMacOS() && isWebviewCreated) { + void invoke("set_inline_webview_occlusions", { + label, + rects: [], + dimHoleRects: [], + blockInput: false, + dimmingAlpha: 0, + }).catch(() => undefined); + } + }; + }, [isWebviewCreated, label]); +} diff --git a/src/hooks/platform/useInlineWebview/useInlineWebviewUrlEffect.ts b/src/hooks/platform/useInlineWebview/useInlineWebviewUrlEffect.ts index 22ac1c63ed..0041c6fc20 100644 --- a/src/hooks/platform/useInlineWebview/useInlineWebviewUrlEffect.ts +++ b/src/hooks/platform/useInlineWebview/useInlineWebviewUrlEffect.ts @@ -7,6 +7,7 @@ export interface UseInlineWebviewUrlEffectParams { isWebviewAvailable: boolean; createDelay: number; containerRef: RefObject; + getContainerRect: () => DOMRect | null; isDestroyedRef: MutableRefObject; lastRequestedUrlRef: MutableRefObject; createWebview: (targetUrl: string) => Promise; @@ -25,6 +26,7 @@ export function useInlineWebviewUrlEffect( isWebviewAvailable, createDelay, containerRef, + getContainerRect, isDestroyedRef, lastRequestedUrlRef, createWebview, @@ -71,7 +73,7 @@ export function useInlineWebviewUrlEffect( return; } - const rect = containerRef.current?.getBoundingClientRect(); + const rect = getContainerRect(); if (!rect || rect.width === 0 || rect.height === 0) { if (retriesLeft > 0) { @@ -90,7 +92,6 @@ export function useInlineWebviewUrlEffect( } void createWebview(url); - lastRequestedUrlRef.current = url; }; const timer = setTimeout(() => { @@ -111,6 +112,7 @@ export function useInlineWebviewUrlEffect( createWebview, navigate, containerRef, + getContainerRect, log, isDestroyedRef, lastRequestedUrlRef, diff --git a/src/hooks/platform/useInlineWebview/useWebviewCommands.ts b/src/hooks/platform/useInlineWebview/useWebviewCommands.ts index ab61e3f995..b7bfbad818 100644 --- a/src/hooks/platform/useInlineWebview/useWebviewCommands.ts +++ b/src/hooks/platform/useInlineWebview/useWebviewCommands.ts @@ -22,6 +22,7 @@ export interface UseWebviewCommandsParams { pollIntervalRef: MutableRefObject | null>; newWindowListenerRef: MutableRefObject; lastPolledUrlRef: MutableRefObject; + lastRequestedUrlRef: MutableRefObject; getContainerRect: () => DOMRect | null; log: (...args: unknown[]) => void; safeUnlisten: (listenerFn: UnlistenFn | null) => void; @@ -59,6 +60,7 @@ export function useWebviewCommands( pollIntervalRef, newWindowListenerRef, lastPolledUrlRef, + lastRequestedUrlRef, getContainerRect, log, safeUnlisten, @@ -157,6 +159,7 @@ export function useWebviewCommands( setIsWebviewCreated(true); setCurrentUrl(targetUrl); lastPolledUrlRef.current = targetUrl; + lastRequestedUrlRef.current = targetUrl; log("WebView created successfully with label:", labelRef.current); @@ -194,6 +197,7 @@ export function useWebviewCommands( isDestroyedRef, labelRef, lastPolledUrlRef, + lastRequestedUrlRef, log, isVisible, onCreated, diff --git a/src/hooks/platform/useInlineWebview/useWebviewLayout.ts b/src/hooks/platform/useInlineWebview/useWebviewLayout.ts index 25f299d88f..0fb0cdb7cc 100644 --- a/src/hooks/platform/useInlineWebview/useWebviewLayout.ts +++ b/src/hooks/platform/useInlineWebview/useWebviewLayout.ts @@ -14,7 +14,18 @@ import { } from "@src/hooks/perf/useDebouncedCallback"; import { toNativeFrame } from "@src/util/platform/tauri/nativeFrame"; -import { WEBVIEW_LAYOUT_CHANGED_EVENT } from "./webviewLayoutEvents"; +import { getVisibleWebviewRect } from "./visibleWebviewRect"; +import { + WEBVIEW_LAYOUT_CHANGED_EVENT, + dispatchWebviewNativeFrameUpdated, +} from "./webviewLayoutEvents"; + +const OFFSCREEN_FRAME = { + x: -10000, + y: -10000, + width: 1, + height: 1, +} as const; const logger = createLogger("InlineWebviewLayout"); @@ -22,6 +33,7 @@ export interface UseWebviewLayoutParams { containerRef: RefObject; isWebviewCreated: boolean; isWebviewAvailable: boolean; + isVisible: boolean; labelRef: MutableRefObject; log: (...args: unknown[]) => void; } @@ -29,16 +41,26 @@ export interface UseWebviewLayoutParams { export interface UseWebviewLayoutReturn { getContainerRect: () => DOMRect | null; updatePosition: (options?: { force?: boolean }) => Promise; + repositionAndShow: () => Promise; + stageOffscreen: (options?: { force?: boolean }) => Promise; } export function useWebviewLayout( params: UseWebviewLayoutParams ): UseWebviewLayoutReturn { - const { containerRef, isWebviewCreated, isWebviewAvailable, labelRef, log } = - params; + const { + containerRef, + isWebviewCreated, + isWebviewAvailable, + isVisible, + labelRef, + log, + } = params; const resizeObserverRef = useRef(null); const scrollListenerRef = useRef<(() => void) | null>(null); + const isVisibleRef = useRef(isVisible); + const surfaceCommandQueueRef = useRef>(Promise.resolve()); const lastResizeRect = useRef<{ width: number; height: number; @@ -48,60 +70,184 @@ export function useWebviewLayout( const getContainerRect = useCallback(() => { if (!containerRef.current) return null; - return containerRef.current.getBoundingClientRect(); + return getVisibleWebviewRect(containerRef.current); }, [containerRef]); - const updatePosition = useCallback( - async (options?: { force?: boolean }) => { - if (!isWebviewCreated || !containerRef.current) return; - - const rect = getContainerRect(); - if (!rect) return; + useEffect(() => { + isVisibleRef.current = isVisible; + }, [isVisible]); - const nativeFrame = toNativeFrame(rect); - logger.rateLimited("native-frame", 1000, "measured frame", { - label: labelRef.current, - rect: { - left: rect.left, - top: rect.top, - right: rect.right, - bottom: rect.bottom, - width: rect.width, - height: rect.height, - }, - nativeFrame, - }); + const enqueueSurfaceCommand = useCallback( + (operation: () => Promise): Promise => { + const queued = surfaceCommandQueueRef.current + .catch(() => undefined) + .then(operation); + surfaceCommandQueueRef.current = queued.catch(() => undefined); + return queued; + }, + [] + ); + const applyOffscreenPosition = useCallback( + async (force = false) => { const lastRect = lastResizeRect.current; if ( - !options?.force && - lastRect && - Math.abs(lastRect.width - nativeFrame.width) < 2 && - Math.abs(lastRect.height - nativeFrame.height) < 2 && - Math.abs(lastRect.x - nativeFrame.x) < 2 && - Math.abs(lastRect.y - nativeFrame.y) < 2 + !force && + lastRect?.x === OFFSCREEN_FRAME.x && + lastRect.y === OFFSCREEN_FRAME.y && + lastRect.width === OFFSCREEN_FRAME.width && + lastRect.height === OFFSCREEN_FRAME.height ) { return; } - lastResizeRect.current = nativeFrame; + + await invoke("update_inline_webview_position", { + label: labelRef.current, + ...OFFSCREEN_FRAME, + }); + // Commit only after native success so a failed IPC remains retryable. + lastResizeRect.current = OFFSCREEN_FRAME; + }, + [labelRef] + ); + + const stageOffscreen = useCallback( + async (options?: { force?: boolean }) => { + if (!isWebviewCreated) return; try { - await invoke("update_inline_webview_position", { - label: labelRef.current, - ...nativeFrame, + await enqueueSurfaceCommand(() => + applyOffscreenPosition(options?.force) + ); + } catch (err) { + log("Failed to stage WebView offscreen:", err); + } + }, + [applyOffscreenPosition, enqueueSurfaceCommand, isWebviewCreated, log] + ); + + const updatePosition = useCallback( + async (options?: { force?: boolean }) => { + if (!isWebviewCreated || !containerRef.current) return; + + try { + await enqueueSurfaceCommand(async () => { + // Re-check the latest desired state at execution time. This also + // makes stale ResizeObserver and timer callbacks fail closed. + if (!isVisibleRef.current) { + await applyOffscreenPosition(options?.force); + return; + } + + const rect = getContainerRect(); + if (!rect) { + await applyOffscreenPosition(options?.force); + return; + } + + const nativeFrame = toNativeFrame(rect); + logger.rateLimited("native-frame", 1000, "measured frame", { + label: labelRef.current, + rect: { + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + width: rect.width, + height: rect.height, + }, + nativeFrame, + }); + + const lastRect = lastResizeRect.current; + if ( + !options?.force && + lastRect && + Math.abs(lastRect.width - nativeFrame.width) < 2 && + Math.abs(lastRect.height - nativeFrame.height) < 2 && + Math.abs(lastRect.x - nativeFrame.x) < 2 && + Math.abs(lastRect.y - nativeFrame.y) < 2 + ) { + return; + } + + await invoke("update_inline_webview_position", { + label: labelRef.current, + ...nativeFrame, + }); + // Commit only after native success so a failed IPC remains retryable. + lastResizeRect.current = nativeFrame; + dispatchWebviewNativeFrameUpdated(labelRef.current); + log("Position updated:", { rect, nativeFrame }); }); - log("Position updated:", { rect, nativeFrame }); } catch (err) { log("Failed to update position:", err); } }, - [isWebviewCreated, containerRef, getContainerRect, labelRef, log] + [ + isWebviewCreated, + containerRef, + applyOffscreenPosition, + enqueueSurfaceCommand, + getContainerRect, + labelRef, + log, + ] ); + const repositionAndShow = useCallback(async (): Promise => { + if (!isWebviewCreated || !containerRef.current) return false; + + let shown = false; + try { + await enqueueSurfaceCommand(async () => { + if (!isVisibleRef.current) { + await applyOffscreenPosition(); + return; + } + + const rect = getContainerRect(); + if (!rect) { + await applyOffscreenPosition(); + return; + } + + const nativeFrame = toNativeFrame(rect); + // One native command guarantees the frame is current before show(), so + // an overlay close cannot flash the WebView at its previous position. + await invoke("reposition_and_show_webview", { + label: labelRef.current, + ...nativeFrame, + }); + lastResizeRect.current = nativeFrame; + dispatchWebviewNativeFrameUpdated(labelRef.current); + shown = true; + log("WebView repositioned and shown:", { rect, nativeFrame }); + }); + } catch (err) { + log("Failed to reposition and show WebView:", err); + } + return shown; + }, [ + applyOffscreenPosition, + containerRef, + enqueueSurfaceCommand, + getContainerRect, + isWebviewCreated, + labelRef, + log, + ]); + const debouncedUpdatePosition = useDebouncedCallback(() => { void updatePosition(); }, DEBOUNCE_DELAYS.FRAME); + useEffect(() => { + if (!isVisible) { + debouncedUpdatePosition.cancel(); + } + }, [debouncedUpdatePosition, isVisible]); + useEffect(() => { if (!containerRef.current || !isWebviewAvailable) return; @@ -195,5 +341,10 @@ export function useWebviewLayout( updatePosition, ]); - return { getContainerRect, updatePosition }; + return { + getContainerRect, + updatePosition, + repositionAndShow, + stageOffscreen, + }; } diff --git a/src/hooks/platform/useInlineWebview/visibleWebviewRect.ts b/src/hooks/platform/useInlineWebview/visibleWebviewRect.ts new file mode 100644 index 0000000000..bb582f7afd --- /dev/null +++ b/src/hooks/platform/useInlineWebview/visibleWebviewRect.ts @@ -0,0 +1,189 @@ +export interface RectEdges { + left: number; + top: number; + right: number; + bottom: number; +} + +export interface ClippingRect { + rect: RectEdges; + clipX: boolean; + clipY: boolean; +} + +const CLIPPING_OVERFLOW_VALUES = new Set([ + "auto", + "clip", + "hidden", + "overlay", + "scroll", +]); + +function hasValidHorizontalEdges(rect: RectEdges): boolean { + return ( + Number.isFinite(rect.left) && + Number.isFinite(rect.right) && + rect.right > rect.left + ); +} + +function hasValidVerticalEdges(rect: RectEdges): boolean { + return ( + Number.isFinite(rect.top) && + Number.isFinite(rect.bottom) && + rect.bottom > rect.top + ); +} + +function hasPositiveArea(rect: RectEdges): boolean { + return hasValidHorizontalEdges(rect) && hasValidVerticalEdges(rect); +} + +function createDomRect(rect: RectEdges): DOMRect { + const width = rect.right - rect.left; + const height = rect.bottom - rect.top; + + return { + x: rect.left, + y: rect.top, + width, + height, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + toJSON: () => ({ + x: rect.left, + y: rect.top, + width, + height, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + }), + }; +} + +/** + * Intersects an anchor with the viewport and every ancestor overflow clip. + * + * Ancestors clip each axis independently because CSS can make only one of + * `overflow-x` and `overflow-y` scrollable. Invalid or zero-area bounds fail + * closed so a native child WebView is never positioned outside a known-safe + * visible region. + */ +export function computeVisibleWebviewRect( + anchorRect: RectEdges, + viewportRect: RectEdges, + clippingAncestors: readonly ClippingRect[] = [] +): DOMRect | null { + if (!hasPositiveArea(anchorRect) || !hasPositiveArea(viewportRect)) { + return null; + } + + const visible: RectEdges = { + left: Math.max(anchorRect.left, viewportRect.left), + top: Math.max(anchorRect.top, viewportRect.top), + right: Math.min(anchorRect.right, viewportRect.right), + bottom: Math.min(anchorRect.bottom, viewportRect.bottom), + }; + + if (!hasPositiveArea(visible)) return null; + + for (const ancestor of clippingAncestors) { + if (ancestor.clipX) { + if (!hasValidHorizontalEdges(ancestor.rect)) return null; + visible.left = Math.max(visible.left, ancestor.rect.left); + visible.right = Math.min(visible.right, ancestor.rect.right); + } + + if (ancestor.clipY) { + if (!hasValidVerticalEdges(ancestor.rect)) return null; + visible.top = Math.max(visible.top, ancestor.rect.top); + visible.bottom = Math.min(visible.bottom, ancestor.rect.bottom); + } + + if (!hasPositiveArea(visible)) return null; + } + + return createDomRect(visible); +} + +function clipsOverflow(value: string): boolean { + return CLIPPING_OVERFLOW_VALUES.has(value.trim().toLowerCase()); +} + +function getAncestorClipRect(element: Element): RectEdges { + const rect = element.getBoundingClientRect(); + const offsetWidth = + element instanceof HTMLElement ? element.offsetWidth : undefined; + const offsetHeight = + element instanceof HTMLElement ? element.offsetHeight : undefined; + + // Overflow clips at the padding edge. When layout dimensions are available, + // convert the client box into viewport coordinates and preserve any uniform + // scale applied by a transformed ancestor. + if ( + offsetWidth && + offsetHeight && + Number.isFinite(element.clientWidth) && + Number.isFinite(element.clientHeight) + ) { + const scaleX = rect.width / offsetWidth; + const scaleY = rect.height / offsetHeight; + const trailingBorderX = + offsetWidth - element.clientLeft - element.clientWidth; + const trailingBorderY = + offsetHeight - element.clientTop - element.clientHeight; + + return { + left: rect.left + element.clientLeft * scaleX, + top: rect.top + element.clientTop * scaleY, + right: rect.right - trailingBorderX * scaleX, + bottom: rect.bottom - trailingBorderY * scaleY, + }; + } + + return rect; +} + +function getViewportRect(): RectEdges { + return { + left: 0, + top: 0, + right: document.documentElement.clientWidth || window.innerWidth, + bottom: document.documentElement.clientHeight || window.innerHeight, + }; +} + +/** + * Measures the portion of an element that can safely be occupied by a native + * inline WebView in the current document viewport. + */ +export function getVisibleWebviewRect(anchor: Element): DOMRect | null { + const clippingAncestors: ClippingRect[] = []; + + let ancestor = anchor.parentElement; + while (ancestor) { + const style = window.getComputedStyle(ancestor); + const clipX = clipsOverflow(style.overflowX || style.overflow); + const clipY = clipsOverflow(style.overflowY || style.overflow); + + if (clipX || clipY) { + clippingAncestors.push({ + rect: getAncestorClipRect(ancestor), + clipX, + clipY, + }); + } + + ancestor = ancestor.parentElement; + } + + return computeVisibleWebviewRect( + anchor.getBoundingClientRect(), + getViewportRect(), + clippingAncestors + ); +} diff --git a/src/hooks/platform/useInlineWebview/webviewLayoutEvents.ts b/src/hooks/platform/useInlineWebview/webviewLayoutEvents.ts index 90c104349f..8b128ae67a 100644 --- a/src/hooks/platform/useInlineWebview/webviewLayoutEvents.ts +++ b/src/hooks/platform/useInlineWebview/webviewLayoutEvents.ts @@ -1,7 +1,22 @@ export const WEBVIEW_LAYOUT_CHANGED_EVENT = "orgii-webview-layout-changed"; +export const WEBVIEW_NATIVE_FRAME_UPDATED_EVENT = + "orgii-webview-native-frame-updated"; + +export interface WebviewNativeFrameUpdatedDetail { + label: string; +} export function dispatchWebviewLayoutChanged(): void { requestAnimationFrame(() => { window.dispatchEvent(new CustomEvent(WEBVIEW_LAYOUT_CHANGED_EVENT)); }); } + +export function dispatchWebviewNativeFrameUpdated(label: string): void { + window.dispatchEvent( + new CustomEvent( + WEBVIEW_NATIVE_FRAME_UPDATED_EVENT, + { detail: { label } } + ) + ); +} diff --git a/src/modules/WorkStation/Browser/hooks/index.ts b/src/modules/WorkStation/Browser/hooks/index.ts index a3228cad68..062f98ce12 100644 --- a/src/modules/WorkStation/Browser/hooks/index.ts +++ b/src/modules/WorkStation/Browser/hooks/index.ts @@ -5,9 +5,7 @@ * DOM inspection, design tokens, and browser state. */ export { useBrowserConsole } from "./useBrowserConsole"; -export { useBrowserLayering } from "./useBrowserLayering"; export { useBrowserNetworkLogs } from "./useBrowserNetworkLogs"; -export { useGlobalBrowserWebviewLayering } from "./useGlobalBrowserWebviewLayering"; export { useGlobalTokens } from "./useGlobalTokens"; export { useSourceNavigation } from "./useSourceNavigation"; export { useWebviewDOMTree } from "./useWebviewDOMTree"; diff --git a/src/modules/WorkStation/Browser/hooks/useBrowserLayering.ts b/src/modules/WorkStation/Browser/hooks/useBrowserLayering.ts deleted file mode 100644 index 612dd1bed9..0000000000 --- a/src/modules/WorkStation/Browser/hooks/useBrowserLayering.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * useBrowserLayering - * - * Controls the native z-order of an inline Browser WKWebView relative to - * the main React webview (macOS only). On macOS, Tauri child webviews are - * sibling NSViews of the window's contentView, and subview order - * determines both rendering order and mouse-event routing. - * - * By default, the Browser webview sits in front so clicks reach the page - * normally. When React needs to render an overlay UI that visually crosses - * the Browser's rect — a URL-bar dropdown, tooltip, history popover, or - * modal — call `sendToBack` to drop the Browser beneath the React surface - * so the overlay draws on top. Call `bringToFront` when the overlay closes. - * - * Design discussion: we never leave the Browser behind indefinitely. Doing - * so would route clicks in its rect to the transparent React surface - * above, which is not what the user wants. Persistent sidebars should - * instead shrink the Browser rect via `update_inline_webview_position`. - * - * ## Example - * - * ```tsx - * const { overlay } = useBrowserLayering({ - * webviewLabel: `browser-session-${sessionId}`, - * }); - * - * async function openHistoryMenu() { - * const release = await overlay(); - * const result = await showHistoryMenu(); - * release(); // Browser returns to front, clicks reach the page again. - * return result; - * } - * ``` - */ -import { useCallback, useEffect, useRef } from "react"; - -import { createLogger } from "@src/hooks/logger"; -import { invokeTauri } from "@src/util/platform/tauri/init"; - -const log = createLogger("useBrowserLayering"); - -export interface UseBrowserLayeringOptions { - /** Inline webview label, e.g. `browser-session-${sessionId}`. */ - webviewLabel: string | null | undefined; -} - -export interface UseBrowserLayeringReturn { - /** Move the webview behind React siblings (call when opening an overlay). */ - sendToBack: () => Promise; - /** Restore the webview to the top of the sibling stack (default). */ - bringToFront: () => Promise; - /** - * Convenience: scoped send-to-back. Call on overlay open; the returned - * function brings the webview back to front when the overlay closes. - * - * const release = await overlay(); - * // …user interacts with dropdown… - * release(); - */ - overlay: () => Promise<() => void>; -} - -export function useBrowserLayering( - options: UseBrowserLayeringOptions -): UseBrowserLayeringReturn { - const { webviewLabel } = options; - - // Capture the latest label so the unmount cleanup effect can read it - // without depending on `webviewLabel` (which would re-run cleanup on - // every label change and undo the front-restore the next mount expects). - const labelRef = useRef(webviewLabel); - useEffect(() => { - labelRef.current = webviewLabel; - }, [webviewLabel]); - - const sendToBack = useCallback(async () => { - if (!webviewLabel) return; - try { - await invokeTauri("browser_webview_send_to_back", { - label: webviewLabel, - }); - } catch (error) { - log.warn("[useBrowserLayering] sendToBack failed:", error); - } - }, [webviewLabel]); - - const bringToFront = useCallback(async () => { - if (!webviewLabel) return; - try { - await invokeTauri("browser_webview_bring_to_front", { - label: webviewLabel, - }); - } catch (error) { - log.warn("[useBrowserLayering] bringToFront failed:", error); - } - }, [webviewLabel]); - - const overlay = useCallback(async () => { - await sendToBack(); - let released = false; - return () => { - if (released) return; - released = true; - void bringToFront(); - }; - }, [sendToBack, bringToFront]); - - useEffect(() => { - return () => { - const label = labelRef.current; - if (!label) return; - void invokeTauri("browser_webview_bring_to_front", { label }).catch( - () => {} - ); - }; - }, []); - - return { sendToBack, bringToFront, overlay }; -} diff --git a/src/modules/WorkStation/Browser/hooks/useGlobalBrowserWebviewLayering.ts b/src/modules/WorkStation/Browser/hooks/useGlobalBrowserWebviewLayering.ts deleted file mode 100644 index 3c1bf4b563..0000000000 --- a/src/modules/WorkStation/Browser/hooks/useGlobalBrowserWebviewLayering.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * useGlobalBrowserWebviewLayering - * - * Single-mount bridge between React overlay state (`activeOverlayCountAtom`) - * and the native z-order of every inline Browser WKWebView. Mount once at - * the app root. When any overlay opens anywhere in the app, all inline - * browser webviews drop behind the React UI so portals (dropdowns, modals, - * spotlights, tooltips) paint and receive clicks correctly. When the last - * overlay closes, the webviews return to the front. - * - * No call-site changes are needed in individual overlay components — the - * overlay primitives themselves (`useDropdownEngine`, `SpotlightPortal`, - * `Tooltip`) contribute to the count via `useOverlayLayer`. - */ -import { useAtomValue } from "jotai"; -import { useEffect, useRef } from "react"; - -import { createLogger } from "@src/hooks/logger"; -import { activeOverlayCountAtom } from "@src/store/ui/overlayLayerAtom"; -import { isMacOS } from "@src/util/platform/tauri"; -import { invokeTauri } from "@src/util/platform/tauri/init"; - -const log = createLogger("useGlobalBrowserWebviewLayering"); - -export function useGlobalBrowserWebviewLayering(): void { - const count = useAtomValue(activeOverlayCountAtom); - const lastStateRef = useRef<"front" | "back" | null>(null); - - useEffect(() => { - if (!isMacOS()) return; - - const shouldBeBack = count > 0; - const next = shouldBeBack ? "back" : "front"; - if (lastStateRef.current === next) return; - lastStateRef.current = next; - - void invokeTauri("browser_webviews_set_layer_for_all", { - sendToBack: shouldBeBack, - }).catch((error) => { - log.warn("[useGlobalBrowserWebviewLayering] reorder failed:", error); - }); - }, [count]); -} diff --git a/src/modules/WorkStation/Browser/shared/SharedBrowserHostSlot.tsx b/src/modules/WorkStation/Browser/shared/SharedBrowserHostSlot.tsx index f5b7208795..6067485ae5 100644 --- a/src/modules/WorkStation/Browser/shared/SharedBrowserHostSlot.tsx +++ b/src/modules/WorkStation/Browser/shared/SharedBrowserHostSlot.tsx @@ -1,6 +1,7 @@ import { useSetAtom } from "jotai"; import React, { useEffect, useLayoutEffect, useRef } from "react"; +import { getVisibleWebviewRect } from "@src/hooks/platform/useInlineWebview/visibleWebviewRect"; import { WEBVIEW_LAYOUT_CHANGED_EVENT } from "@src/hooks/platform/useInlineWebview/webviewLayoutEvents"; import { @@ -29,14 +30,22 @@ function getDefaultScope(hostId: SharedBrowserHostId): SharedBrowserHostScope { } function toHostRect( - rect: DOMRect, + visibleRect: DOMRect, + measuredRect: DOMRect, bottomInsetPx: number -): SharedBrowserHostRect { +): SharedBrowserHostRect | null { + const bottom = Math.min( + visibleRect.bottom, + measuredRect.bottom - bottomInsetPx + ); + const height = bottom - visibleRect.top; + if (visibleRect.width <= 0 || height <= 0) return null; + return { - x: rect.x, - y: rect.y, - width: rect.width, - height: Math.max(0, rect.height - bottomInsetPx), + x: visibleRect.x, + y: visibleRect.y, + width: visibleRect.width, + height, }; } @@ -108,7 +117,16 @@ export const SharedBrowserHostSlot: React.FC = ({ const insetPx = measureTarget === element ? bottomInsetPx : 0; const nextRect = active ? measureTarget - ? toHostRect(measureTarget.getBoundingClientRect(), insetPx) + ? (() => { + const visibleRect = getVisibleWebviewRect(measureTarget); + return visibleRect + ? toHostRect( + visibleRect, + measureTarget.getBoundingClientRect(), + insetPx + ) + : null; + })() : null : null; setRegistry((prev) => { diff --git a/src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/content/UrlPreviewContent/index.tsx b/src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/content/UrlPreviewContent/index.tsx index db2b06ec06..ec207158fc 100644 --- a/src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/content/UrlPreviewContent/index.tsx +++ b/src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/content/UrlPreviewContent/index.tsx @@ -7,15 +7,18 @@ * Uses the same useInlineWebview hook as the Browser module to create * native webviews that bypass X-Frame-Options restrictions. */ +import { useAtomValue } from "jotai"; import { RefreshCw, SquareArrowOutUpRight } from "lucide-react"; import React, { memo, useCallback, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import Button from "@src/components/Button"; import { useInlineWebview } from "@src/hooks/platform/useInlineWebview"; +import { useInlineWebviewOcclusions } from "@src/hooks/platform/useInlineWebview/useInlineWebviewOcclusions"; import { usePublishWorkstationTabHeader } from "@src/hooks/tabHost/useWorkstationTabHeader"; import { useRefreshSpin } from "@src/hooks/ui"; import { Placeholder } from "@src/modules/shared/layouts/blocks"; +import { webviewOverlayBlockedAtom } from "@src/store/ui/overlayAtom"; import { isTauriDesktop } from "@src/util/platform/tauri"; interface UrlPreviewContentProps { @@ -34,6 +37,7 @@ const UrlPreviewContent: React.FC = memo( const { t } = useTranslation(); const containerRef = useRef(null); const isTauri = isTauriDesktop(); + const isWebviewBlocked = useAtomValue(webviewOverlayBlockedAtom); // Generate a stable label for the webview (useState to avoid ref access in render) const [label] = React.useState(getNextLabel); @@ -42,7 +46,7 @@ const UrlPreviewContent: React.FC = memo( containerRef, url, isActive: true, - isVisible: true, + isVisible: !isWebviewBlocked, labelPrefix: label, useExactLabel: true, incognito: false, @@ -54,6 +58,13 @@ const UrlPreviewContent: React.FC = memo( }, }); + useInlineWebviewOcclusions({ + containerRef, + isWebviewCreated, + isSurfaceVisible: !isWebviewBlocked, + label, + }); + // Update position when mounted useEffect(() => { if (isWebviewCreated) { diff --git a/src/modules/WorkStation/shared/QuickActionsPanel/index.tsx b/src/modules/WorkStation/shared/QuickActionsPanel/index.tsx index 415bd7f876..74d5a8d7ef 100644 --- a/src/modules/WorkStation/shared/QuickActionsPanel/index.tsx +++ b/src/modules/WorkStation/shared/QuickActionsPanel/index.tsx @@ -20,7 +20,7 @@ */ import { AnimatePresence, motion } from "framer-motion"; import { Box } from "lucide-react"; -import React, { memo, useCallback, useEffect } from "react"; +import React, { memo, useCallback, useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { @@ -28,6 +28,7 @@ import { KeyboardShortcut, } from "@src/components/KeyboardShortcut"; import { SURFACE_TOKENS } from "@src/config/surfaceTokens"; +import { useOverlayLayer } from "@src/store/ui/overlayLayerAtom"; import type { QuickAction, QuickActionsPanelProps } from "./types"; @@ -139,6 +140,9 @@ AppLogo.displayName = "AppLogo"; export const QuickActionsPanel = memo( ({ visible, actions, onClose, title, subtitle, showLogo = true }) => { + const panelRef = useRef(null); + useOverlayLayer(visible, panelRef); + // Handle ESC key to close useEffect(() => { if (!visible) return; @@ -185,6 +189,7 @@ export const QuickActionsPanel = memo( {/* Panel */} { const AppShell = () => { const location = useLocation(); - // === Global Browser Webview Layering === - // Drops inline browser WKWebViews behind React portals whenever any - // overlay (dropdown, modal, spotlight) is visible. See - // `docs/workstation/Browser/webview-layering--0418.md`. - useGlobalBrowserWebviewLayering(); - const navigate = useNavigate(); useWorkspaceEvents(); diff --git a/src/modules/shared/DevTools/ComponentIssueModal/index.tsx b/src/modules/shared/DevTools/ComponentIssueModal/index.tsx index 24df08d451..920033c660 100644 --- a/src/modules/shared/DevTools/ComponentIssueModal/index.tsx +++ b/src/modules/shared/DevTools/ComponentIssueModal/index.tsx @@ -8,6 +8,7 @@ import Message from "@src/components/Message"; import { getShortcutKeys } from "@src/config/keyboard/shortcutDisplay"; import { PanelFooter } from "@src/modules/shared/layouts/blocks"; import { componentIssueModalOpenAtom } from "@src/store/ui/overlayAtom"; +import { useOverlayLayer } from "@src/store/ui/overlayLayerAtom"; import { ComponentIssuePayload, buildIssuePayload, @@ -45,6 +46,8 @@ const ModalComponentIssue: React.FC = ({ const { query: searchQuery, currentMatchIndex } = searchState; const searchInputRef = useRef(null); const contentRef = useRef(null); + const modalPanelRef = useRef(null); + useOverlayLayer(visible, modalPanelRef); const handleCopy = useCallback(() => { if (!payload) { @@ -216,6 +219,7 @@ const ModalComponentIssue: React.FC = ({ return ReactDOM.createPortal(
event.stopPropagation()} > diff --git a/src/scaffold/GlobalSpotlight/shell/SpotlightShellChrome.tsx b/src/scaffold/GlobalSpotlight/shell/SpotlightShellChrome.tsx index 0d3e5d02c1..6864d4baa8 100644 --- a/src/scaffold/GlobalSpotlight/shell/SpotlightShellChrome.tsx +++ b/src/scaffold/GlobalSpotlight/shell/SpotlightShellChrome.tsx @@ -44,7 +44,7 @@ export const SpotlightShellChrome: React.FC = ({ const inputHostRef = useRef(null); const spotlightPlacement = useAtomValue(spotlightPlacementAtom); - useOverlayLayer(isOpen && asPortal); + useOverlayLayer(isOpen && asPortal, inputHostRef); // Bubble-phase escape handler (portal mode only — non-portal callers // expect the parent's focus trap to own escape). diff --git a/src/scaffold/ModalSystem/index.test.ts b/src/scaffold/ModalSystem/index.test.ts new file mode 100644 index 0000000000..7a8bbb6e16 --- /dev/null +++ b/src/scaffold/ModalSystem/index.test.ts @@ -0,0 +1,128 @@ +// @vitest-environment jsdom +import React, { type RefObject, act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { MODAL_MASK_OCCLUSION_OPTIONS } from "@src/store/ui/overlayLayerAtom"; + +import Modal from "./index"; + +const mocks = vi.hoisted(() => ({ + useOverlayLayer: vi.fn(), + theme: { isDark: false }, +})); + +vi.mock("@src/store/ui/overlayLayerAtom", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useOverlayLayer: mocks.useOverlayLayer, + }; +}); +vi.mock("@src/util/ui/theme/themeUtils", () => ({ + useCurrentTheme: () => ({ theme: "test", isDark: mocks.theme.isDark }), +})); + +function renderModal(visible: boolean) { + return React.createElement( + Modal, + { visible, title: "Coverage test", footer: null }, + "Modal body" + ); +} + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + mocks.useOverlayLayer.mockReset(); + mocks.theme.isDark = false; +}); + +let container: HTMLDivElement; +let root: Root; +const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; + +beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; +}); + +beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); +}); + +describe("Modal native surface coverage", () => { + it("dims the live native page and masks the opaque panel", async () => { + await act(async () => root.render(renderModal(true))); + + expect(mocks.useOverlayLayer).toHaveBeenCalledTimes(2); + const [dimActive, dimRef, dimOptions] = mocks.useOverlayLayer.mock + .calls[0] as [ + boolean, + RefObject, + { nativeDimmingAlpha: number; cutsNativeSurface: boolean }, + ]; + const [maskActive, maskRef, maskOptions] = mocks.useOverlayLayer.mock + .calls[1] as [ + boolean, + RefObject, + typeof MODAL_MASK_OCCLUSION_OPTIONS, + ]; + const panel = document.querySelector(".liquid-modal-content"); + + expect(dimActive).toBe(true); + expect(dimRef?.current).toBeUndefined(); + expect(dimOptions).toEqual({ + nativeDimmingAlpha: 0.6, + cutsNativeSurface: false, + }); + expect(maskActive).toBe(true); + expect(maskRef.current).toBe(panel); + expect(maskOptions).toEqual(MODAL_MASK_OCCLUSION_OPTIONS); + }); + + it("matches the stronger dark-theme modal scrim", async () => { + mocks.theme.isDark = true; + await act(async () => root.render(renderModal(true))); + + const [, , dimOptions] = mocks.useOverlayLayer.mock.calls[0] as [ + boolean, + RefObject, + { nativeDimmingAlpha: number }, + ]; + + expect(dimOptions).toEqual({ + nativeDimmingAlpha: 0.7, + cutsNativeSurface: false, + }); + }); + + it("publishes the inactive state and unmounts coverage when closed", async () => { + await act(async () => root.render(renderModal(true))); + await act(async () => root.render(renderModal(false))); + + expect(mocks.useOverlayLayer).toHaveBeenLastCalledWith( + false, + expect.any(Object), + MODAL_MASK_OCCLUSION_OPTIONS + ); + expect(document.querySelector(".liquid-modal-wrapper")).toBeNull(); + }); +}); diff --git a/src/scaffold/ModalSystem/index.tsx b/src/scaffold/ModalSystem/index.tsx index 78915ecee3..83a3dc5a52 100644 --- a/src/scaffold/ModalSystem/index.tsx +++ b/src/scaffold/ModalSystem/index.tsx @@ -25,7 +25,11 @@ import PanelFooter from "@src/modules/shared/layouts/blocks/PanelFooter"; import PanelHeader, { PANEL_HEADER_TOKENS, } from "@src/modules/shared/layouts/blocks/PanelHeader"; -import { useOverlayLayer } from "@src/store/ui/overlayLayerAtom"; +import { + MODAL_MASK_OCCLUSION_OPTIONS, + useOverlayLayer, +} from "@src/store/ui/overlayLayerAtom"; +import { useCurrentTheme } from "@src/util/ui/theme/themeUtils"; import "./index.scss"; @@ -129,8 +133,17 @@ const Modal: React.FC = ({ const modalRef = useRef(null); const previousActiveElement = useRef(null); const [okLoading, setOkLoading] = useState(false); - - useOverlayLayer(visible); + const { isDark } = useCurrentTheme(); + const modalDimmingAlpha = isDark ? 0.7 : 0.6; + + // Uniform dim on the live browser surface; the opaque dialog panel gets its + // own WebView mask hole aligned to `.liquid-modal-content` (not the padded + // bounds wrapper, which would leave transparent slack showing as white). + useOverlayLayer(visible, undefined, { + nativeDimmingAlpha: modalDimmingAlpha, + cutsNativeSurface: false, + }); + useOverlayLayer(visible, modalRef, MODAL_MASK_OCCLUSION_OPTIONS); // Store the previously focused element useEffect(() => { diff --git a/src/scaffold/NavigationSidebar/HoverSidebar.tsx b/src/scaffold/NavigationSidebar/HoverSidebar.tsx index 54217b0938..aeeef5b7c6 100644 --- a/src/scaffold/NavigationSidebar/HoverSidebar.tsx +++ b/src/scaffold/NavigationSidebar/HoverSidebar.tsx @@ -99,8 +99,7 @@ export const HoverSidebarContainer: React.FC = ({ const containerRef = useRef(null); const hideTimeoutRef = useRef(null); - // Drop inline browser webviews behind this floating sidebar while open. - useOverlayLayer(isHoverSidebarOpen && isSidebarCollapsed); + useOverlayLayer(isHoverSidebarOpen && isSidebarCollapsed, containerRef); // Handle mouse enter on sidebar const handleMouseEnter = useCallback(() => { diff --git a/src/scaffold/Tutorials/CodeEditorTour.tsx b/src/scaffold/Tutorials/CodeEditorTour.tsx index e57ff2a5ae..1b27369c34 100644 --- a/src/scaffold/Tutorials/CodeEditorTour.tsx +++ b/src/scaffold/Tutorials/CodeEditorTour.tsx @@ -1,7 +1,13 @@ import { AnimatePresence, motion } from "framer-motion"; import { useSetAtom } from "jotai"; import { ArrowLeft, ArrowRight, Check, X } from "lucide-react"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; @@ -20,6 +26,7 @@ import { getViewportSize } from "@src/util/ui/window/viewport"; import { createAnimationFrameScheduler } from "./animationFrameScheduler"; import { CODE_EDITOR_TOUR_TARGETS } from "./codeEditorTourConfig"; +import { useSpotlightTourNativeOcclusion } from "./useSpotlightTourNativeOcclusion"; type CodeEditorTourTarget = (typeof CODE_EDITOR_TOUR_TARGETS)[keyof typeof CODE_EDITOR_TOUR_TARGETS]; @@ -207,6 +214,9 @@ const CodeEditorTour: React.FC = ({ open, onClose }) => { const setSourceControlFilterMode = useSetAtom(sourceControlFilterModeAtom); const [stepIndex, setStepIndex] = useState(0); const [targetRect, setTargetRect] = useState(null); + const popoverRef = useRef(null); + const highlightRef = useRef(null); + useSpotlightTourNativeOcclusion(open, popoverRef, highlightRef); const currentStep = TOUR_STEPS[stepIndex]; const isFirstStep = stepIndex === 0; @@ -358,6 +368,7 @@ const CodeEditorTour: React.FC = ({ open, onClose }) => { {highlightStyle && ( = ({ open, onClose }) => { )} = ({ const setStationMode = useSetAtom(stationModeAtom); const [stepIndex, setStepIndex] = useState(0); const [targetRect, setTargetRect] = useState(null); + const popoverRef = useRef(null); + const highlightRef = useRef(null); + useSpotlightTourNativeOcclusion(open, popoverRef, highlightRef); const currentStep = TOUR_STEPS[stepIndex]; const isFirstStep = stepIndex === 0; @@ -335,6 +345,7 @@ const GeneralLayoutTour: React.FC = ({ {highlightStyle && ( = ({ )} { highlight && targetRect?.targetId === highlight.targetId ? targetRect.rect : null; + const popoverRef = useRef(null); + const highlightRef = useRef(null); + useSpotlightTourNativeOcclusion( + Boolean(highlight && rect), + popoverRef, + highlightRef, + { + dimmingAlpha: GUIDE_HIGHLIGHT_NATIVE_DIMMING_ALPHA, + } + ); const highlightStyle = useMemo( () => (rect ? buildHighlightStyle(rect) : undefined), [rect] @@ -217,6 +231,7 @@ const GuideHighlightOverlay: React.FC = () => { {highlight && rect && highlightStyle && popoverStyle && (