From a9226a32acd8719eb384cc84c2365fc8205d306b Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Tue, 25 Aug 2026 18:01:28 +0800 Subject: [PATCH 01/10] fix(webview): serialize native surface geometry lifecycle Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../__tests__/useWebviewCommands.test.ts | 1 + .../__tests__/useWebviewLayout.test.ts | 151 ++++++++++++ .../__tests__/visibleWebviewRect.test.ts | 173 ++++++++++++++ .../useInlineWebview/useInlineWebview.ts | 14 +- .../useInlineWebviewNativeVisibility.ts | 30 +-- .../useInlineWebviewUrlEffect.ts | 6 +- .../useInlineWebview/useWebviewCommands.ts | 4 + .../useInlineWebview/useWebviewLayout.ts | 223 +++++++++++++++--- .../useInlineWebview/visibleWebviewRect.ts | 189 +++++++++++++++ .../useInlineWebview/webviewLayoutEvents.ts | 15 ++ .../Browser/shared/SharedBrowserHostSlot.tsx | 32 ++- 11 files changed, 769 insertions(+), 69 deletions(-) create mode 100644 src/hooks/platform/useInlineWebview/__tests__/useWebviewLayout.test.ts create mode 100644 src/hooks/platform/useInlineWebview/__tests__/visibleWebviewRect.test.ts create mode 100644 src/hooks/platform/useInlineWebview/visibleWebviewRect.ts 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/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/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/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) => { From 0793d4675eaa10d0bb1e179254b1f5d65bd45142 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Tue, 25 Aug 2026 18:01:36 +0800 Subject: [PATCH 02/10] refactor(ui): register overlay geometry for native surfaces Pre-commit hook ran. Total eslint: 0, total circular: 0 --- src/components/Dropdown/index.tsx | 2 +- .../FileTreePreview/FileTreeHoverPreview.tsx | 5 + src/components/ImagePreviewOverlay/index.tsx | 8 +- src/components/MarkDown/MermaidBlock.tsx | 4 + .../PropertyField/PropertyFieldEditable.tsx | 9 +- .../SessionHoverCard/HoverCardBase.tsx | 2 + src/components/TabPill/index.tsx | 2 + src/components/Tooltip/index.tsx | 2 + .../panels/LinkSessionToWorkItemModal.tsx | 8 +- .../components/FactoryViewPill/index.test.ts | 3 +- src/hooks/dropdown/useDropdownEngine.ts | 4 +- .../shared/QuickActionsPanel/index.tsx | 7 +- .../shared/StatusBar/EditorStatusBar.tsx | 6 +- .../DevTools/ComponentIssueModal/index.tsx | 4 + .../shell/SpotlightShellChrome.tsx | 2 +- src/scaffold/ModalSystem/index.tsx | 4 +- .../NavigationSidebar/HoverSidebar.tsx | 3 +- src/scaffold/Tutorials/CodeEditorTour.tsx | 12 +- src/scaffold/Tutorials/GeneralLayoutTour.tsx | 12 +- .../Tutorials/GuideHighlightOverlay.tsx | 6 +- src/store/ui/overlayLayerAtom.test.ts | 100 +++++++++ src/store/ui/overlayLayerAtom.ts | 196 ++++++++++++++---- 22 files changed, 345 insertions(+), 56 deletions(-) create mode 100644 src/store/ui/overlayLayerAtom.test.ts diff --git a/src/components/Dropdown/index.tsx b/src/components/Dropdown/index.tsx index b4d278a4dc..be61bb8101 100644 --- a/src/components/Dropdown/index.tsx +++ b/src/components/Dropdown/index.tsx @@ -193,7 +193,7 @@ const Dropdown: React.FC = ({ const isControlled = controlledVisible !== undefined; const visible = isControlled ? controlledVisible : internalVisible; - useOverlayLayer(visible); + useOverlayLayer(visible, dropdownRef); 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 0cd2f0c443..8add4c118c 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 { @@ -218,6 +219,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..bca5ea90e7 100644 --- a/src/components/TabPill/index.tsx +++ b/src/components/TabPill/index.tsx @@ -2,6 +2,7 @@ import React, { memo, useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { DROPDOWN_CLASSES } from "@src/components/Dropdown/tokens"; +import { useOverlayLayer } from "@src/store/ui/overlayLayerAtom"; import { getViewportSize } from "@src/util/ui/window/viewport"; import { SidebarTabButton } from "./SidebarTabButton"; @@ -58,6 +59,7 @@ const TabPill: React.FC = ({ const [dropdownPositioned, setDropdownPositioned] = useState(false); const dropdownTriggerRef = useRef(null); const dropdownPanelRef = useRef(null); + useOverlayLayer(dropdownOpen, dropdownPanelRef); 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/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..ec7e4ab896 100644 --- a/src/hooks/dropdown/useDropdownEngine.ts +++ b/src/hooks/dropdown/useDropdownEngine.ts @@ -201,9 +201,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, panelRef); const updatePosition = useCallback(() => { const triggerElement = latestTriggerRef.current.current; 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 */} = memo( handleToggleLspDropdown, handleCloseLspDropdown, } = useLspDropdown(); + const lspDropdownRef = useRef(null); + useOverlayLayer(lspDropdownOpen, lspDropdownRef); const isIndexingActive = useAtomValue(isIndexingAtom); const indexingProgress = useAtomValue(indexingProgressAtom); @@ -258,6 +261,7 @@ export const EditorStatusBar: React.FC = memo( onClick={handleCloseLspDropdown} />
= ({ 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.tsx b/src/scaffold/ModalSystem/index.tsx index 78915ecee3..58a0226635 100644 --- a/src/scaffold/ModalSystem/index.tsx +++ b/src/scaffold/ModalSystem/index.tsx @@ -130,7 +130,9 @@ const Modal: React.FC = ({ const previousActiveElement = useRef(null); const [okLoading, setOkLoading] = useState(false); - useOverlayLayer(visible); + // Mask only the opaque dialog surface. The translucent backdrop cannot be + // composited across sibling native views without removing the live page. + useOverlayLayer(visible, modalRef); // 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..4975886cbd 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"; @@ -13,6 +19,7 @@ import { POPUP_SHADOW, } from "@src/scaffold/shared/popupTokens"; import { WorkStationViewService } from "@src/services/workStation/WorkStationViewService"; +import { useOverlayLayer } from "@src/store/ui/overlayLayerAtom"; import { stationModeAtom } from "@src/store/ui/simulatorAtom"; import { sourceControlFilterModeAtom } from "@src/store/workstation/codeEditor/sourceControlFilterModeAtom"; import { useCurrentTheme } from "@src/util/ui/theme/themeUtils"; @@ -207,6 +214,8 @@ const CodeEditorTour: React.FC = ({ open, onClose }) => { const setSourceControlFilterMode = useSetAtom(sourceControlFilterModeAtom); const [stepIndex, setStepIndex] = useState(0); const [targetRect, setTargetRect] = useState(null); + const popoverRef = useRef(null); + useOverlayLayer(open, popoverRef); const currentStep = TOUR_STEPS[stepIndex]; const isFirstStep = stepIndex === 0; @@ -366,6 +375,7 @@ const CodeEditorTour: React.FC = ({ open, onClose }) => { )} = ({ const setStationMode = useSetAtom(stationModeAtom); const [stepIndex, setStepIndex] = useState(0); const [targetRect, setTargetRect] = useState(null); + const popoverRef = useRef(null); + useOverlayLayer(open, popoverRef); const currentStep = TOUR_STEPS[stepIndex]; const isFirstStep = stepIndex === 0; @@ -343,6 +352,7 @@ const GeneralLayoutTour: React.FC = ({ )} { highlight && targetRect?.targetId === highlight.targetId ? targetRect.rect : null; + const popoverRef = useRef(null); + useOverlayLayer(Boolean(highlight && rect), popoverRef); const highlightStyle = useMemo( () => (rect ? buildHighlightStyle(rect) : undefined), [rect] @@ -226,6 +229,7 @@ const GuideHighlightOverlay: React.FC = () => { transition={{ duration: 0.18, ease: "easeOut" }} /> ({}), +} as DOMRect; + +function Overlay({ active }: { active: boolean }) { + const ref = useRef(null); + useOverlayLayer(active, ref); + // eslint-disable-next-line react-hooks/refs -- Vitest only collects `.test.ts`; createElement is the JSX-equivalent ref prop. + return createElement("div", { ref }, "overlay"); +} + +describe("useOverlayLayer", () => { + let container: HTMLDivElement; + let root: Root; + let rectSpy: ReturnType; + 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); + rectSpy = vi + .spyOn(HTMLElement.prototype, "getBoundingClientRect") + .mockReturnValue(RECT); + }); + + afterEach(() => { + act(() => root.unmount()); + rectSpy.mockRestore(); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("publishes geometry while open and removes it on close under StrictMode", async () => { + const store = createStore(); + const render = async (active: boolean) => { + await act(async () => { + root.render( + createElement( + StrictMode, + null, + createElement( + Provider, + { store }, + createElement(Overlay, { active }) + ) + ) + ); + await new Promise((resolve) => + requestAnimationFrame(() => resolve()) + ); + }); + }; + + await render(false); + expect(store.get(activeOverlayCountAtom)).toBe(0); + + await render(true); + expect(store.get(activeOverlayCountAtom)).toBe(1); + expect(store.get(overlayOcclusionStateAtom)).toEqual({ + rects: [{ x: 20, y: 30, width: 200, height: 100 }], + blocksNativeInput: true, + }); + + await render(false); + expect(store.get(activeOverlayCountAtom)).toBe(0); + expect(store.get(overlayOcclusionStateAtom)).toEqual({ + rects: [], + blocksNativeInput: false, + }); + }); +}); diff --git a/src/store/ui/overlayLayerAtom.ts b/src/store/ui/overlayLayerAtom.ts index efa2d17872..cdc8310d34 100644 --- a/src/store/ui/overlayLayerAtom.ts +++ b/src/store/ui/overlayLayerAtom.ts @@ -1,48 +1,174 @@ /** - * Overlay Layer — reference-counted "is any React overlay currently visible?" + * Overlay occlusion registry for native inline WebViews. * - * Problem: On macOS, Tauri inline browser WKWebViews are native NSViews that - * render and hit-test above sibling React content regardless of CSS z-index. - * Any React overlay (dropdown, modal, spotlight, tooltip) rendered into a - * portal at document.body will visually lose to an overlapping inline - * webview. - * - * Solution: track how many overlays are currently mounted through a single - * global ref counter. A bridge effect mounted at the app root watches the - * count and, when it crosses 0 → 1+, sends all inline browser webviews to - * the back of their NSView superviews. When the count returns to 0, it - * brings them back to the front. All existing overlay primitives - * (`useDropdownEngine`, `SpotlightPortal`, `Tooltip` portal) contribute - * automatically — no per-call-site work. - * - * See `docs/workstation/Browser/webview-layering--0418.md`. + * Tauri child WebViews are native surfaces rather than DOM descendants, so a + * CSS z-index cannot place a React portal above them. Each mounted overlay + * publishes its current viewport rectangle here. Browser surfaces consume the + * registry, intersect it with their own frame, and project only those holes to + * the native compositor. */ -import { atom, useAtom } from "jotai"; -import { useEffect } from "react"; +import { atom, useSetAtom } from "jotai"; +import { + type RefObject, + useCallback, + useId, + useLayoutEffect, + useRef, +} from "react"; -/** - * Number of currently visible overlays (dropdowns, modals, tooltips, etc.). - * Increment on overlay open / mount, decrement on close / unmount. Never - * mutate directly outside the `useOverlayLayer` helper hook. - */ -export const activeOverlayCountAtom = atom(0); +export interface OverlayOcclusionRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface OverlayLayerEntry { + id: string; + rect: OverlayOcclusionRect | null; + /** Interactive overlays temporarily own pointer input over the browser. */ + blocksNativeInput: boolean; +} + +export interface OverlayLayerOptions { + /** Passive overlays such as tooltips can leave native page input enabled. */ + blocksNativeInput?: boolean; +} + +export type OverlayLayerRegistry = Record; + +export const overlayLayerRegistryAtom = atom({}); +overlayLayerRegistryAtom.debugLabel = "overlayLayerRegistryAtom"; + +export const activeOverlayCountAtom = atom( + (get) => Object.keys(get(overlayLayerRegistryAtom)).length +); +activeOverlayCountAtom.debugLabel = "activeOverlayCountAtom"; + +export const overlayOcclusionStateAtom = atom((get) => { + const entries = Object.values(get(overlayLayerRegistryAtom)); + return { + rects: entries.flatMap((entry) => (entry.rect ? [entry.rect] : [])), + blocksNativeInput: entries.some((entry) => entry.blocksNativeInput), + }; +}); +overlayOcclusionStateAtom.debugLabel = "overlayOcclusionStateAtom"; + +function sameRect( + left: OverlayOcclusionRect | null, + right: OverlayOcclusionRect | null +): boolean { + if (!left || !right) return left === right; + return ( + Math.abs(left.x - right.x) < 0.5 && + Math.abs(left.y - right.y) < 0.5 && + Math.abs(left.width - right.width) < 0.5 && + Math.abs(left.height - right.height) < 0.5 + ); +} + +function readElementRect( + targetRef: RefObject | undefined +): OverlayOcclusionRect | null { + const element = targetRef?.current; + if (!element) return null; + + const rect = element.getBoundingClientRect(); + if ( + !Number.isFinite(rect.left) || + !Number.isFinite(rect.top) || + !Number.isFinite(rect.width) || + !Number.isFinite(rect.height) || + rect.width <= 0 || + rect.height <= 0 + ) { + return null; + } + + return { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }; +} /** - * Contributes one reference to `activeOverlayCountAtom` while `active` is - * true. Call from any overlay primitive whose portal can visually cross an - * inline Browser webview's rect. + * Register one portal/popup while it is visible. * - * Safe across strict-mode double-invocations: the cleanup always decrements - * exactly once per active-mount, and the effect is keyed by `active`. + * Geometry work exists only during the active lifetime. Scroll, resize, and + * ResizeObserver bursts are coalesced to one measurement per animation frame, + * and cleanup removes both listeners and the registry entry. */ -export function useOverlayLayer(active: boolean): void { - const [, setCount] = useAtom(activeOverlayCountAtom); +export function useOverlayLayer( + active: boolean, + targetRef?: RefObject, + options: OverlayLayerOptions = {} +): void { + const id = useId(); + const setRegistry = useSetAtom(overlayLayerRegistryAtom); + const frameRef = useRef(null); + const blocksNativeInput = options.blocksNativeInput ?? true; + + const publish = useCallback(() => { + const nextRect = readElementRect(targetRef); + setRegistry((previous) => { + const current = previous[id]; + if ( + current && + current.blocksNativeInput === blocksNativeInput && + sameRect(current.rect, nextRect) + ) { + return previous; + } - useEffect(() => { + return { + ...previous, + [id]: { id, rect: nextRect, blocksNativeInput }, + }; + }); + }, [blocksNativeInput, id, setRegistry, targetRef]); + + const schedulePublish = useCallback(() => { + if (frameRef.current !== null) return; + frameRef.current = window.requestAnimationFrame(() => { + frameRef.current = null; + publish(); + }); + }, [publish]); + + useLayoutEffect(() => { if (!active) return; - setCount((prev) => prev + 1); + + // Register synchronously for the non-macOS full-surface fallback. A + // second measurement catches portaled elements mounted in this commit. + publish(); + schedulePublish(); + + const element = targetRef?.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); + return () => { - setCount((prev) => Math.max(0, prev - 1)); + resizeObserver?.disconnect(); + window.removeEventListener("resize", schedulePublish); + window.removeEventListener("scroll", schedulePublish, true); + if (frameRef.current !== null) { + window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + setRegistry((previous) => { + if (!previous[id]) return previous; + const next = { ...previous }; + delete next[id]; + return next; + }); }; - }, [active, setCount]); + }, [active, id, publish, schedulePublish, setRegistry, targetRef]); } From 93a25d904dcda8bfa9f80ad2831fb1bd08aa992d Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Tue, 25 Aug 2026 18:01:48 +0800 Subject: [PATCH 03/10] fix(webview): mask macOS overlay intersections Pre-commit hook ran. Total eslint: 0, total circular: 0 --- src-tauri/Cargo.lock | 3 + src-tauri/Cargo.toml | 2 + src-tauri/crates/browser/Cargo.toml | 2 + src-tauri/crates/browser/src/inline.rs | 2 + src-tauri/crates/browser/src/layering.rs | 177 ------- src-tauri/crates/browser/src/lib.rs | 4 +- src-tauri/crates/browser/src/occlusion.rs | 439 ++++++++++++++++++ src-tauri/src/commands/handler_list.inc | 6 +- .../BrowserCore/BrowserSessionWebview.tsx | 22 +- src/engines/BrowserCore/index.tsx | 10 + .../nativeSurfaceVisibility.test.ts | 27 ++ .../BrowserCore/nativeSurfaceVisibility.ts | 17 + .../__tests__/nativeWebviewOcclusion.test.ts | 59 +++ .../useInlineWebviewOcclusions.test.ts | 165 +++++++ .../nativeWebviewOcclusion.ts | 124 +++++ .../useInlineWebviewOcclusions.ts | 234 ++++++++++ .../WorkStation/Browser/hooks/index.ts | 2 - .../Browser/hooks/useBrowserLayering.ts | 119 ----- .../hooks/useGlobalBrowserWebviewLayering.ts | 43 -- .../content/UrlPreviewContent/index.tsx | 13 +- src/modules/index.tsx | 7 - src/store/ui/__tests__/overlayAtom.test.ts | 48 +- src/store/ui/overlayAtom.ts | 29 +- 23 files changed, 1173 insertions(+), 381 deletions(-) delete mode 100644 src-tauri/crates/browser/src/layering.rs create mode 100644 src-tauri/crates/browser/src/occlusion.rs create mode 100644 src/engines/BrowserCore/nativeSurfaceVisibility.test.ts create mode 100644 src/engines/BrowserCore/nativeSurfaceVisibility.ts create mode 100644 src/hooks/platform/useInlineWebview/__tests__/nativeWebviewOcclusion.test.ts create mode 100644 src/hooks/platform/useInlineWebview/__tests__/useInlineWebviewOcclusions.test.ts create mode 100644 src/hooks/platform/useInlineWebview/nativeWebviewOcclusion.ts create mode 100644 src/hooks/platform/useInlineWebview/useInlineWebviewOcclusions.ts delete mode 100644 src/modules/WorkStation/Browser/hooks/useBrowserLayering.ts delete mode 100644 src/modules/WorkStation/Browser/hooks/useGlobalBrowserWebviewLayering.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 220bb02ae2..52980d41a7 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 f4797aa4fd..9a2f51196c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -122,6 +122,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", "CGGeometry", "CGPath"] } +objc2-quartz-core = { version = "0.3", default-features = false, features = ["std", "CALayer", "CAShapeLayer", "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..efe6b6c34a --- /dev/null +++ b/src-tauri/crates/browser/src/occlusion.rs @@ -0,0 +1,439 @@ +//! 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. 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() +} + +/// 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. +#[tauri::command] +pub async fn set_inline_webview_occlusions( + app: AppHandle, + label: String, + rects: Vec, + block_input: bool, +) -> 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, block_input).await + } + + #[cfg(not(target_os = "macos"))] + { + let _ = webview; + let _ = rects; + let _ = block_input; + Ok(()) + } +} + +#[cfg(target_os = "macos")] +mod macos { + use super::{sanitize_occlusion_rects, WebviewOcclusionRect}; + use objc2::runtime::{AnyClass, AnyObject, Imp, Sel}; + use objc2::{msg_send, sel}; + use objc2_core_graphics::CGMutablePath; + use objc2_foundation::{NSPoint, NSRect, NSSize}; + use objc2_quartz_core::{kCAFillRuleEvenOdd, CALayer, CAShapeLayer}; + use std::collections::HashMap; + use std::sync::{Mutex, OnceLock}; + + type HitTestImplementation = + unsafe extern "C-unwind" fn(&AnyObject, Sel, NSPoint) -> *mut AnyObject; + + /// Maps an occluded inline WKWebView to the main React WKWebView that must + /// receive pointer input while an interactive overlay is open. + static INPUT_TARGET_WEBVIEWS: OnceLock>> = OnceLock::new(); + static ORIGINAL_HIT_TESTS: OnceLock>> = OnceLock::new(); + + fn input_target_webviews() -> &'static Mutex> { + INPUT_TARGET_WEBVIEWS.get_or_init(|| Mutex::new(HashMap::new())) + } + + fn original_hit_tests() -> &'static Mutex> { + ORIGINAL_HIT_TESTS.get_or_init(|| Mutex::new(HashMap::new())) + } + + fn input_target_webview(webview: &AnyObject) -> Option { + input_target_webviews().lock().ok().and_then(|targets| { + targets + .get(&(webview as *const AnyObject as usize)) + .copied() + }) + } + + 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 { + let key = this as *const AnyObject as usize; + if let Some(target_key) = input_target_webview(this) { + // Route directly to the main React WKWebView. Asking the inline + // WebView's parent to re-run hit testing can return nil when the + // two WebViews have different native container views; AppKit may + // then deliver the click to a window in another application. + if target_key != 0 && target_key != key { + let target = unsafe { &*(target_key as *const AnyObject) }; + let point_in_target: NSPoint = + unsafe { msg_send![this, convertPoint: point, toView: target] }; + let routed: *mut AnyObject = unsafe { msg_send![target, hitTest: point_in_target] }; + if !routed.is_null() { + return routed; + } + } + + // Fail closed: keeping the event inside ORG2 is safer than a bare + // nil, even if the main surface is temporarily being recreated. + 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_input_target(webview: &AnyObject, target: Option) -> Result<(), String> { + let key = webview as *const AnyObject as usize; + if target.is_some() { + ensure_hit_test_hook(webview)?; + } + + let mut registry = input_target_webviews() + .lock() + .map_err(|_| "native WebView input registry is poisoned".to_string())?; + if let Some(target) = target { + registry.insert(key, target); + } else { + registry.remove(&key); + } + 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) + } + + pub(super) async fn apply( + webview: &tauri::Webview, + main_webview: Option, + rects: Vec, + block_input: bool, + ) -> Result<(), String> { + let input_target = if block_input { + 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 }; + + set_input_target(wk_webview, input_target)?; + + 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; + + if rects.is_empty() { + layer.setMask(None); + return Ok(()); + } + + let bounds: NSRect = msg_send![wk_webview, bounds]; + let sanitized = + sanitize_occlusion_rects(&rects, bounds.size.width, bounds.size.height); + if sanitized.is_empty() { + layer.setMask(None); + return Ok(()); + } + + 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 { + 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)); + } + + 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_input_target(wk_webview, None); + let layer: *mut CALayer = msg_send![wk_webview, layer]; + if !layer.is_null() { + (&*layer).setMask(None); + } + }); + } +} + +#[cfg(target_os = "macos")] +async fn apply_macos_occlusions( + webview: &tauri::Webview, + main_webview: Option, + rects: Vec, + block_input: bool, +) -> Result<(), String> { + macos::apply(webview, main_webview, rects, block_input).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 + ); + } +} diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index f7206a98e1..b5cba5ab12 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/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 3ebe00bd7c..d0c13fdd6a 100644 --- a/src/engines/BrowserCore/index.tsx +++ b/src/engines/BrowserCore/index.tsx @@ -36,6 +36,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"); @@ -222,6 +223,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; @@ -288,6 +297,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/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..b5aac8bd62 --- /dev/null +++ b/src/hooks/platform/useInlineWebview/__tests__/useInlineWebviewOcclusions.test.ts @@ -0,0 +1,165 @@ +// @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, +})); +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, + }, + }); + }); + await flushEffects(); + + expect(invokeMock).toHaveBeenLastCalledWith( + "set_inline_webview_occlusions", + { + label: "browser-session-test", + rects: [{ x: 350, y: 0, width: 50, height: 70 }], + blockInput: true, + } + ); + + act(() => store.set(overlayLayerRegistryAtom, {})); + await flushEffects(); + expect(invokeMock).toHaveBeenLastCalledWith( + "set_inline_webview_occlusions", + { + label: "browser-session-test", + rects: [], + blockInput: false, + } + ); + }); + + 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, + }, + }); + }); + 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: [], + blockInput: false, + } + ); + }); +}); diff --git a/src/hooks/platform/useInlineWebview/nativeWebviewOcclusion.ts b/src/hooks/platform/useInlineWebview/nativeWebviewOcclusion.ts new file mode 100644 index 0000000000..b2fd2547de --- /dev/null +++ b/src/hooks/platform/useInlineWebview/nativeWebviewOcclusion.ts @@ -0,0 +1,124 @@ +import type { OverlayOcclusionRect } from "@src/store/ui/overlayLayerAtom"; + +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; + +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 +): NativeWebviewOcclusionRect[] { + if ( + !Number.isFinite(nativeFrameScale) || + nativeFrameScale <= 0 || + surface.right <= surface.left || + surface.bottom <= surface.top + ) { + return []; + } + + const nativeSurfaceLeft = Math.round(surface.left * nativeFrameScale); + const nativeSurfaceTop = Math.round(surface.top * nativeFrameScale); + 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 left = Math.max(surface.left, overlay.x); + const top = Math.max(surface.top, overlay.y); + const right = Math.min(surface.right, overlay.x + overlay.width); + const bottom = Math.min(surface.bottom, overlay.y + overlay.height); + if (right <= left || bottom <= top) continue; + + const nativeLeft = Math.round(left * nativeFrameScale); + const nativeTop = Math.round(top * nativeFrameScale); + const nativeRight = Math.round(right * nativeFrameScale); + const nativeBottom = Math.round(bottom * nativeFrameScale); + + intersections.push({ + x: nativeLeft - nativeSurfaceLeft, + y: nativeTop - nativeSurfaceTop, + width: nativeRight - nativeLeft, + height: nativeBottom - nativeTop, + }); + } + + return coalesceOcclusionRects( + intersections.filter((rect) => rect.width > 0 && rect.height > 0) + ); +} diff --git a/src/hooks/platform/useInlineWebview/useInlineWebviewOcclusions.ts b/src/hooks/platform/useInlineWebview/useInlineWebviewOcclusions.ts new file mode 100644 index 0000000000..0e40f1d1e2 --- /dev/null +++ b/src/hooks/platform/useInlineWebview/useInlineWebviewOcclusions.ts @@ -0,0 +1,234 @@ +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 { 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; + rects: ReturnType; + blockInput: boolean; +} + +function samePayload( + left: DesiredOcclusionState | null, + right: DesiredOcclusionState +): boolean { + if (!left || left.blockInput !== right.blockInput) return false; + if (left.rects.length !== right.rects.length) return false; + return 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 + ); + }); +} + +/** + * 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, + rects: [], + blockInput: false, + }); + // 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, + rects: [], + blockInput: false, + }); + 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, rects: [], blockInput: false }; + } + + const surface = getVisibleWebviewRect(containerRef.current); + if (!surface) return { revision, rects: [], blockInput: false }; + + return { + revision, + rects: computeNativeWebviewOcclusions( + surface, + overlayState.rects, + getNativeFrameScale() + ), + blockInput: overlayState.blocksNativeInput, + }; + }, [ + containerRef, + isSurfaceVisible, + isWebviewCreated, + overlayState.blocksNativeInput, + overlayState.rects, + ]); + + 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.rects, + blockInput: desired.blockInput, + }); + } 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.rects.length === 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.rects.length, + 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: [], + blockInput: false, + }).catch(() => undefined); + } + }; + }, [isWebviewCreated, 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/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/index.tsx b/src/modules/index.tsx index 90228f81b2..4d90986992 100644 --- a/src/modules/index.tsx +++ b/src/modules/index.tsx @@ -32,7 +32,6 @@ import { useAgentADEActions } from "@src/engines/SessionCore/hooks/useAgentADEAc import { useProjectDataChangedListener } from "@src/hooks/project"; import { useUrlPreviewEvents } from "@src/hooks/tabHost/useUrlPreviewEvents"; import { useBackgroundImage } from "@src/hooks/theme/useBackgroundImage"; -import { useGlobalBrowserWebviewLayering } from "@src/modules/WorkStation/Browser/hooks"; import { CODE_EDITOR_TOUR_EVENT } from "@src/scaffold/Tutorials/codeEditorTourConfig"; import { GENERAL_LAYOUT_TOUR_EVENT, @@ -167,12 +166,6 @@ const ConfiguredBackgroundLayer: React.FC = () => { 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/store/ui/__tests__/overlayAtom.test.ts b/src/store/ui/__tests__/overlayAtom.test.ts index 79d3198584..3754a86cea 100644 --- a/src/store/ui/__tests__/overlayAtom.test.ts +++ b/src/store/ui/__tests__/overlayAtom.test.ts @@ -1,22 +1,56 @@ import { createStore } from "jotai/vanilla"; -import { vi } from "vitest"; -import { webviewOverlayBlockedAtom } from "../overlayAtom"; -import { activeOverlayCountAtom } from "../overlayLayerAtom"; +import { + componentIssueModalOpenAtom, + hasGlobalErrorAtom, + quitConfirmationModalOpenAtom, + toolbarDropdownOpenAtom, + webviewOverlayBlockedAtom, +} from "../overlayAtom"; +import { overlayLayerRegistryAtom } from "../overlayLayerAtom"; +import { spotlightOpenAtom } from "../uiAtom"; + +const platform = vi.hoisted(() => ({ isMacOS: false })); vi.mock("@src/util/platform/tauri", () => ({ - isMacOS: () => false, + isMacOS: () => platform.isMacOS, })); describe("webviewOverlayBlockedAtom", () => { - it("blocks native webviews for overlays when native layering is unavailable", () => { + beforeEach(() => { + platform.isMacOS = false; + }); + + it("uses the offscreen fallback where native layering is unavailable", () => { const store = createStore(); expect(store.get(webviewOverlayBlockedAtom)).toBe(false); - store.set(activeOverlayCountAtom, 1); + store.set(overlayLayerRegistryAtom, { + menu: { id: "menu", rect: null, blocksNativeInput: true }, + }); expect(store.get(webviewOverlayBlockedAtom)).toBe(true); - store.set(activeOverlayCountAtom, 0); + store.set(overlayLayerRegistryAtom, {}); expect(store.get(webviewOverlayBlockedAtom)).toBe(false); }); + + it("keeps the page visible on macOS while native layering handles overlays", () => { + platform.isMacOS = true; + const store = createStore(); + + store.set(overlayLayerRegistryAtom, { + menu: { id: "menu", rect: null, blocksNativeInput: true }, + }); + + expect(store.get(webviewOverlayBlockedAtom)).toBe(false); + + store.set(componentIssueModalOpenAtom, true); + store.set(quitConfirmationModalOpenAtom, true); + store.set(toolbarDropdownOpenAtom, true); + store.set(spotlightOpenAtom, true); + expect(store.get(webviewOverlayBlockedAtom)).toBe(false); + + store.set(hasGlobalErrorAtom, true); + expect(store.get(webviewOverlayBlockedAtom)).toBe(true); + }); }); diff --git a/src/store/ui/overlayAtom.ts b/src/store/ui/overlayAtom.ts index 682a4c7bcb..a9d9fba2b2 100644 --- a/src/store/ui/overlayAtom.ts +++ b/src/store/ui/overlayAtom.ts @@ -73,24 +73,19 @@ locationSelectorOpenAtom.debugLabel = "locationSelectorOpenAtom"; * Agent Station can host the same native browser without recreating it. */ export const webviewOverlayBlockedAtom = atom((get) => { - // macOS can move native WKWebViews behind React overlays. Other platforms - // need a visibility fallback because inline webviews may paint above modals. - const hasNativeBlockingOverlay = - !isMacOS() && get(activeOverlayCountAtom) > 0; + // Native child WebViews do not participate in the DOM stacking context. + // macOS masks only the overlapping portion of each WKWebView. Other + // platforms currently need the offscreen fallback for every UI overlay. + const needsOffscreenOverlayFallback = + !isMacOS() && + (get(activeOverlayCountAtom) > 0 || + get(componentIssueModalOpenAtom) || + get(quitConfirmationModalOpenAtom) || + get(toolbarDropdownOpenAtom) || + get(spotlightOpenAtom)); const hasGlobalError = get(hasGlobalErrorAtom); - const isComponentIssueModalOpen = get(componentIssueModalOpenAtom); - const isQuitConfirmationModalOpen = get(quitConfirmationModalOpenAtom); - const isToolbarDropdownOpen = get(toolbarDropdownOpenAtom); - const isSpotlightOpen = get(spotlightOpenAtom); - - return ( - hasNativeBlockingOverlay || - hasGlobalError || - isComponentIssueModalOpen || - isQuitConfirmationModalOpen || - isToolbarDropdownOpen || - isSpotlightOpen - ); + + return hasGlobalError || needsOffscreenOverlayFallback; }); webviewOverlayBlockedAtom.debugLabel = "webviewOverlayBlockedAtom"; From 65cab395c7ee1f8d3b6c1f86dd49d7d4433880f1 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Tue, 25 Aug 2026 18:01:52 +0800 Subject: [PATCH 04/10] docs(webview): document native occlusion architecture Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../native-webview-scale-system--0622.md | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/docs/workstation/native-webview-scale-system--0622.md b/docs/workstation/native-webview-scale-system--0622.md index 1d7fe2ae55..3690e526dd 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,39 @@ 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 registry + ↓ intersect + native-frame scale +BrowserSession WebView-local holes + ↓ latest-wins IPC +macOS CALayer mask + 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, each overlay publishes its real viewport rectangle. Every visible browser session intersects those rectangles with its host and applies only the local holes to the WKWebView. The native page remains painted everywhere else; the app never sends the entire WebView behind the opaque main surface. +- 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. + +Only opaque overlay surfaces are registered as holes. A translucent backdrop cannot be alpha-composited with a sibling native WKWebView; registering the backdrop itself would replace the live page with the opaque main app surface. Dialog content remains correctly visible and interactive, while the live page stays visible outside it. + ## Layout-change event Some layout changes move the browser anchor without changing its size. Examples: @@ -125,12 +158,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. From 64fda4a8df74f68c8d4e0fa62ba06f0a2802b935 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Tue, 25 Aug 2026 18:12:11 +0800 Subject: [PATCH 05/10] fix(modal): cover native webviews with full-screen scrims Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../native-webview-scale-system--0622.md | 7 +- src/scaffold/ModalSystem/index.test.ts | 88 +++++++++++++++++++ src/scaffold/ModalSystem/index.tsx | 10 ++- 3 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 src/scaffold/ModalSystem/index.test.ts diff --git a/docs/workstation/native-webview-scale-system--0622.md b/docs/workstation/native-webview-scale-system--0622.md index 3690e526dd..323c0b8d58 100644 --- a/docs/workstation/native-webview-scale-system--0622.md +++ b/docs/workstation/native-webview-scale-system--0622.md @@ -112,7 +112,7 @@ macOS CALayer mask + 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, each overlay publishes its real viewport rectangle. Every visible browser session intersects those rectangles with its host and applies only the local holes to the WKWebView. The native page remains painted everywhere else; the app never sends the entire WebView behind the opaque main surface. +- On macOS, each overlay publishes its real viewport coverage rectangle. Local overlays such as dropdowns publish only their panel; full-screen modal overlays publish their wrapper, including the scrim. Every visible browser session intersects those rectangles with its host and applies only the resulting WebView-local holes. - 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. @@ -124,7 +124,10 @@ Important invariants: 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. -Only opaque overlay surfaces are registered as holes. A translucent backdrop cannot be alpha-composited with a sibling native WKWebView; registering the backdrop itself would replace the live page with the opaque main app surface. Dialog content remains correctly visible and interactive, while the live page stays visible outside it. +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 their entire wrapper. A translucent DOM scrim cannot alpha-composite with a sibling native WKWebView, so the native surface is masked for the modal's full coverage area. The page remains mounted and preserves cookies, history, scroll, and in-page state; clearing the modal restores it without navigation or reload. ## Layout-change event diff --git a/src/scaffold/ModalSystem/index.test.ts b/src/scaffold/ModalSystem/index.test.ts new file mode 100644 index 0000000000..2721b3a326 --- /dev/null +++ b/src/scaffold/ModalSystem/index.test.ts @@ -0,0 +1,88 @@ +// @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 from "./index"; + +const mocks = vi.hoisted(() => ({ + useOverlayLayer: vi.fn(), +})); + +vi.mock("@src/store/ui/overlayLayerAtom", () => ({ + useOverlayLayer: mocks.useOverlayLayer, +})); + +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(); +}); + +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("registers the full-screen wrapper instead of only the dialog panel", async () => { + await act(async () => root.render(renderModal(true))); + + const [, coverageRef] = mocks.useOverlayLayer.mock.calls.at(-1) as [ + boolean, + RefObject, + ]; + const wrapper = document.querySelector(".liquid-modal-wrapper"); + const panel = document.querySelector(".liquid-modal-content"); + + expect(wrapper).not.toBeNull(); + expect(coverageRef.current).toBe(wrapper); + expect(coverageRef.current).not.toBe(panel); + }); + + 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))); + + const [active, coverageRef] = mocks.useOverlayLayer.mock.calls.at(-1) as [ + boolean, + RefObject, + ]; + + expect(active).toBe(false); + expect(coverageRef.current).toBeNull(); + expect(document.querySelector(".liquid-modal-wrapper")).toBeNull(); + }); +}); diff --git a/src/scaffold/ModalSystem/index.tsx b/src/scaffold/ModalSystem/index.tsx index 58a0226635..3e68adac6e 100644 --- a/src/scaffold/ModalSystem/index.tsx +++ b/src/scaffold/ModalSystem/index.tsx @@ -126,13 +126,16 @@ const Modal: React.FC = ({ style, }) => { const handleClose = onClose || onCancel; + const modalWrapperRef = useRef(null); const modalRef = useRef(null); const previousActiveElement = useRef(null); const [okLoading, setOkLoading] = useState(false); - // Mask only the opaque dialog surface. The translucent backdrop cannot be - // composited across sibling native views without removing the live page. - useOverlayLayer(visible, modalRef); + // A modal owns the entire viewport, including its dimming scrim. Register + // the wrapper instead of only the dialog panel so native child WebViews do + // not paint through the backdrop. The native page remains mounted and its + // mask is cleared when the modal closes. + useOverlayLayer(visible, modalWrapperRef); // Store the previously focused element useEffect(() => { @@ -303,6 +306,7 @@ const Modal: React.FC = ({ const modalContent = (
Date: Tue, 25 Aug 2026 18:26:50 +0800 Subject: [PATCH 06/10] fix(modal): dim live native webviews beneath scrims Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../native-webview-scale-system--0622.md | 10 +- src-tauri/Cargo.toml | 4 +- src-tauri/crates/browser/src/occlusion.rs | 145 +++++++++++++----- .../useInlineWebviewOcclusions.test.ts | 36 +++++ .../useInlineWebviewOcclusions.ts | 24 ++- src/scaffold/ModalSystem/index.test.ts | 12 +- src/scaffold/ModalSystem/index.tsx | 11 +- src/store/ui/overlayLayerAtom.test.ts | 17 +- src/store/ui/overlayLayerAtom.ts | 28 +++- 9 files changed, 221 insertions(+), 66 deletions(-) diff --git a/docs/workstation/native-webview-scale-system--0622.md b/docs/workstation/native-webview-scale-system--0622.md index 323c0b8d58..8b8ebcdf41 100644 --- a/docs/workstation/native-webview-scale-system--0622.md +++ b/docs/workstation/native-webview-scale-system--0622.md @@ -102,17 +102,17 @@ Native child WebViews do not participate in DOM stacking contexts. CSS `z-index` ORGII therefore separates surface visibility from overlay occlusion: ```text -overlay DOMRect registry +overlay DOMRect + dimming registry ↓ intersect + native-frame scale -BrowserSession WebView-local holes +BrowserSession local holes + strongest scrim alpha ↓ latest-wins IPC -macOS CALayer mask + native input handoff +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, each overlay publishes its real viewport coverage rectangle. Local overlays such as dropdowns publish only their panel; full-screen modal overlays publish their wrapper, including the scrim. Every visible browser session intersects those rectangles with its host and applies only the resulting WebView-local holes. +- 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. @@ -127,7 +127,7 @@ BrowserCore's loading and confirmed error panels also set `isVisible=false` whil 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 their entire wrapper. A translucent DOM scrim cannot alpha-composite with a sibling native WKWebView, so the native surface is masked for the modal's full coverage area. The page remains mounted and preserves cookies, history, scroll, and in-page state; clearing the modal restores it without navigation or reload. +- 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 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9a2f51196c..fb9d8760ac 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -122,8 +122,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", "CGGeometry", "CGPath"] } -objc2-quartz-core = { version = "0.3", default-features = false, features = ["std", "CALayer", "CAShapeLayer", "objc2-core-foundation", "objc2-core-graphics"] } +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/src/occlusion.rs b/src-tauri/crates/browser/src/occlusion.rs index efe6b6c34a..2f87e8cef1 100644 --- a/src-tauri/crates/browser/src/occlusion.rs +++ b/src-tauri/crates/browser/src/occlusion.rs @@ -2,9 +2,10 @@ //! //! 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. This keeps -//! the rest of the page painted instead of moving the entire WebView behind -//! the opaque main app surface. +//! `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}; @@ -65,17 +66,26 @@ fn sanitize_occlusion_rects( .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. +/// 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, 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 @@ -86,7 +96,7 @@ pub async fn set_inline_webview_occlusions( #[cfg(target_os = "macos")] { let main_webview = app.get_webview("main"); - apply_macos_occlusions(&webview, main_webview, rects, block_input).await + apply_macos_occlusions(&webview, main_webview, rects, block_input, dimming_alpha).await } #[cfg(not(target_os = "macos"))] @@ -94,18 +104,20 @@ pub async fn set_inline_webview_occlusions( let _ = webview; let _ = rects; let _ = block_input; + let _ = dimming_alpha; Ok(()) } } #[cfg(target_os = "macos")] mod macos { - use super::{sanitize_occlusion_rects, WebviewOcclusionRect}; + use super::{sanitize_dimming_alpha, sanitize_occlusion_rects, WebviewOcclusionRect}; use objc2::runtime::{AnyClass, AnyObject, Imp, Sel}; - use objc2::{msg_send, sel}; + use objc2::{msg_send, sel, Message}; + use objc2_app_kit::NSColor; use objc2_core_graphics::CGMutablePath; - use objc2_foundation::{NSPoint, NSRect, NSSize}; - use objc2_quartz_core::{kCAFillRuleEvenOdd, CALayer, CAShapeLayer}; + use objc2_foundation::{NSPoint, NSRect, NSSize, NSString}; + use objc2_quartz_core::{kCAFillRuleEvenOdd, CALayer, CAShapeLayer, CATransaction}; use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; @@ -116,6 +128,7 @@ mod macos { /// receive pointer input while an interactive overlay is open. static INPUT_TARGET_WEBVIEWS: OnceLock>> = OnceLock::new(); static ORIGINAL_HIT_TESTS: OnceLock>> = OnceLock::new(); + const DIMMING_LAYER_NAME: &str = "org2.inline-webview-dimming"; fn input_target_webviews() -> &'static Mutex> { INPUT_TARGET_WEBVIEWS.get_or_init(|| Mutex::new(HashMap::new())) @@ -250,11 +263,50 @@ mod macos { 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) { + 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); + } + pub(super) async fn apply( webview: &tauri::Webview, main_webview: Option, rects: Vec, block_input: bool, + dimming_alpha: f64, ) -> Result<(), String> { let input_target = if block_input { let main_webview = main_webview @@ -285,41 +337,42 @@ mod macos { } let layer = &*layer; - if rects.is_empty() { - layer.setMask(None); - return Ok(()); - } - let bounds: NSRect = msg_send![wk_webview, bounds]; let sanitized = sanitize_occlusion_rects(&rects, bounds.size.width, bounds.size.height); + + CATransaction::begin(); + CATransaction::setDisableActions(true); + if sanitized.is_empty() { layer.setMask(None); - return Ok(()); + } 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 { + 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)); } - 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 { - 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)); + update_dimming_layer(layer, bounds, dimming_alpha); + CATransaction::commit(); } Ok(()) @@ -343,7 +396,12 @@ mod macos { let _ = set_input_target(wk_webview, None); let layer: *mut CALayer = msg_send![wk_webview, layer]; if !layer.is_null() { - (&*layer).setMask(None); + CATransaction::begin(); + CATransaction::setDisableActions(true); + let layer = &*layer; + layer.setMask(None); + update_dimming_layer(layer, NSRect::ZERO, 0.0); + CATransaction::commit(); } }); } @@ -355,8 +413,9 @@ async fn apply_macos_occlusions( main_webview: Option, rects: Vec, block_input: bool, + dimming_alpha: f64, ) -> Result<(), String> { - macos::apply(webview, main_webview, rects, block_input).await + macos::apply(webview, main_webview, rects, block_input, dimming_alpha).await } /// Clear native projection state before closing a WebView so pointer-address @@ -436,4 +495,12 @@ mod tests { 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/hooks/platform/useInlineWebview/__tests__/useInlineWebviewOcclusions.test.ts b/src/hooks/platform/useInlineWebview/__tests__/useInlineWebviewOcclusions.test.ts index b5aac8bd62..cf52cbb846 100644 --- a/src/hooks/platform/useInlineWebview/__tests__/useInlineWebviewOcclusions.test.ts +++ b/src/hooks/platform/useInlineWebview/__tests__/useInlineWebviewOcclusions.test.ts @@ -91,6 +91,7 @@ describe("useInlineWebviewOcclusions", () => { id: "menu", rect: { x: 450, y: 20, width: 100, height: 100 }, blocksNativeInput: true, + nativeDimmingAlpha: 0, }, }); }); @@ -102,6 +103,7 @@ describe("useInlineWebviewOcclusions", () => { label: "browser-session-test", rects: [{ x: 350, y: 0, width: 50, height: 70 }], blockInput: true, + dimmingAlpha: 0, } ); @@ -113,6 +115,38 @@ describe("useInlineWebviewOcclusions", () => { label: "browser-session-test", rects: [], 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: 100, y: 50, width: 200, height: 120 }], + blockInput: true, + dimmingAlpha: 0.6, } ); }); @@ -139,6 +173,7 @@ describe("useInlineWebviewOcclusions", () => { id: "menu", rect: { x: 200, y: 100, width: 100, height: 100 }, blocksNativeInput: true, + nativeDimmingAlpha: 0.6, }, }); }); @@ -159,6 +194,7 @@ describe("useInlineWebviewOcclusions", () => { label: "browser-session-test", rects: [], blockInput: false, + dimmingAlpha: 0, } ); }); diff --git a/src/hooks/platform/useInlineWebview/useInlineWebviewOcclusions.ts b/src/hooks/platform/useInlineWebview/useInlineWebviewOcclusions.ts index 0e40f1d1e2..e6c4357324 100644 --- a/src/hooks/platform/useInlineWebview/useInlineWebviewOcclusions.ts +++ b/src/hooks/platform/useInlineWebview/useInlineWebviewOcclusions.ts @@ -28,13 +28,20 @@ interface DesiredOcclusionState { revision: number; rects: ReturnType; blockInput: boolean; + dimmingAlpha: number; } function samePayload( left: DesiredOcclusionState | null, right: DesiredOcclusionState ): boolean { - if (!left || left.blockInput !== right.blockInput) return false; + if ( + !left || + left.blockInput !== right.blockInput || + left.dimmingAlpha !== right.dimmingAlpha + ) { + return false; + } if (left.rects.length !== right.rects.length) return false; return left.rects.every((rect, index) => { const candidate = right.rects[index]; @@ -63,6 +70,7 @@ export function useInlineWebviewOcclusions({ revision: 0, rects: [], 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. @@ -70,6 +78,7 @@ export function useInlineWebviewOcclusions({ revision: 0, rects: [], blockInput: false, + dimmingAlpha: 0, }); const applyingRef = useRef(false); const frameRef = useRef(null); @@ -78,11 +87,13 @@ export function useInlineWebviewOcclusions({ const measureDesired = useCallback((): DesiredOcclusionState => { const revision = desiredRef.current.revision + 1; if (!isWebviewCreated || !isSurfaceVisible || !containerRef.current) { - return { revision, rects: [], blockInput: false }; + return { revision, rects: [], blockInput: false, dimmingAlpha: 0 }; } const surface = getVisibleWebviewRect(containerRef.current); - if (!surface) return { revision, rects: [], blockInput: false }; + if (!surface) { + return { revision, rects: [], blockInput: false, dimmingAlpha: 0 }; + } return { revision, @@ -92,12 +103,14 @@ export function useInlineWebviewOcclusions({ getNativeFrameScale() ), blockInput: overlayState.blocksNativeInput, + dimmingAlpha: overlayState.nativeDimmingAlpha, }; }, [ containerRef, isSurfaceVisible, isWebviewCreated, overlayState.blocksNativeInput, + overlayState.nativeDimmingAlpha, overlayState.rects, ]); @@ -122,6 +135,7 @@ export function useInlineWebviewOcclusions({ label, rects: desired.rects, blockInput: desired.blockInput, + dimmingAlpha: desired.dimmingAlpha, }); } catch (error) { failedRevision = desired.revision; @@ -172,7 +186,7 @@ export function useInlineWebviewOcclusions({ !isMacOS() || !isWebviewCreated || !isSurfaceVisible || - overlayState.rects.length === 0 + (overlayState.rects.length === 0 && overlayState.nativeDimmingAlpha === 0) ) { return; } @@ -212,6 +226,7 @@ export function useInlineWebviewOcclusions({ isSurfaceVisible, isWebviewCreated, label, + overlayState.nativeDimmingAlpha, overlayState.rects.length, schedulePublish, ]); @@ -227,6 +242,7 @@ export function useInlineWebviewOcclusions({ label, rects: [], blockInput: false, + dimmingAlpha: 0, }).catch(() => undefined); } }; diff --git a/src/scaffold/ModalSystem/index.test.ts b/src/scaffold/ModalSystem/index.test.ts index 2721b3a326..add3fadc28 100644 --- a/src/scaffold/ModalSystem/index.test.ts +++ b/src/scaffold/ModalSystem/index.test.ts @@ -57,19 +57,23 @@ afterAll(() => { }); describe("Modal native surface coverage", () => { - it("registers the full-screen wrapper instead of only the dialog panel", async () => { + it("registers the opaque panel and dims the live native page", async () => { await act(async () => root.render(renderModal(true))); - const [, coverageRef] = mocks.useOverlayLayer.mock.calls.at(-1) as [ + const [, coverageRef, options] = mocks.useOverlayLayer.mock.calls.at( + -1 + ) as [ boolean, RefObject, + { nativeDimmingAlpha: number }, ]; const wrapper = document.querySelector(".liquid-modal-wrapper"); const panel = document.querySelector(".liquid-modal-content"); expect(wrapper).not.toBeNull(); - expect(coverageRef.current).toBe(wrapper); - expect(coverageRef.current).not.toBe(panel); + expect(coverageRef.current).toBe(panel); + expect(coverageRef.current).not.toBe(wrapper); + expect(options).toEqual({ nativeDimmingAlpha: 0.6 }); }); it("publishes the inactive state and unmounts coverage when closed", async () => { diff --git a/src/scaffold/ModalSystem/index.tsx b/src/scaffold/ModalSystem/index.tsx index 3e68adac6e..d0da801311 100644 --- a/src/scaffold/ModalSystem/index.tsx +++ b/src/scaffold/ModalSystem/index.tsx @@ -126,16 +126,14 @@ const Modal: React.FC = ({ style, }) => { const handleClose = onClose || onCancel; - const modalWrapperRef = useRef(null); const modalRef = useRef(null); const previousActiveElement = useRef(null); const [okLoading, setOkLoading] = useState(false); - // A modal owns the entire viewport, including its dimming scrim. Register - // the wrapper instead of only the dialog panel so native child WebViews do - // not paint through the backdrop. The native page remains mounted and its - // mask is cleared when the modal closes. - useOverlayLayer(visible, modalWrapperRef); + // Keep the live native page visible under the modal scrim. Only the opaque + // dialog panel becomes a compositor hole; macOS renders the matching black + // dim layer directly above the sibling WKWebView. + useOverlayLayer(visible, modalRef, { nativeDimmingAlpha: 0.6 }); // Store the previously focused element useEffect(() => { @@ -306,7 +304,6 @@ const Modal: React.FC = ({ const modalContent = (
({}), } as DOMRect; -function Overlay({ active }: { active: boolean }) { +function Overlay({ + active, + nativeDimmingAlpha, +}: { + active: boolean; + nativeDimmingAlpha?: number; +}) { const ref = useRef(null); - useOverlayLayer(active, ref); + useOverlayLayer(active, ref, { nativeDimmingAlpha }); // eslint-disable-next-line react-hooks/refs -- Vitest only collects `.test.ts`; createElement is the JSX-equivalent ref prop. return createElement("div", { ref }, "overlay"); } @@ -70,7 +76,10 @@ describe("useOverlayLayer", () => { createElement( Provider, { store }, - createElement(Overlay, { active }) + createElement(Overlay, { + active, + nativeDimmingAlpha: 0.6, + }) ) ) ); @@ -88,6 +97,7 @@ describe("useOverlayLayer", () => { expect(store.get(overlayOcclusionStateAtom)).toEqual({ rects: [{ x: 20, y: 30, width: 200, height: 100 }], blocksNativeInput: true, + nativeDimmingAlpha: 0.6, }); await render(false); @@ -95,6 +105,7 @@ describe("useOverlayLayer", () => { expect(store.get(overlayOcclusionStateAtom)).toEqual({ rects: [], blocksNativeInput: false, + nativeDimmingAlpha: 0, }); }); }); diff --git a/src/store/ui/overlayLayerAtom.ts b/src/store/ui/overlayLayerAtom.ts index cdc8310d34..01db95bf52 100644 --- a/src/store/ui/overlayLayerAtom.ts +++ b/src/store/ui/overlayLayerAtom.ts @@ -28,11 +28,18 @@ export interface OverlayLayerEntry { rect: OverlayOcclusionRect | null; /** Interactive overlays temporarily own pointer input over the browser. */ blocksNativeInput: boolean; + /** Black scrim alpha rendered above the live native surface. */ + nativeDimmingAlpha?: number; } export interface OverlayLayerOptions { /** Passive overlays such as tooltips can leave native page input enabled. */ blocksNativeInput?: boolean; + /** + * Dim the live native page without removing it. Full-screen modals use this + * while their opaque panel remains the registered occlusion rectangle. + */ + nativeDimmingAlpha?: number; } export type OverlayLayerRegistry = Record; @@ -50,10 +57,20 @@ export const overlayOcclusionStateAtom = atom((get) => { return { rects: entries.flatMap((entry) => (entry.rect ? [entry.rect] : [])), blocksNativeInput: entries.some((entry) => entry.blocksNativeInput), + nativeDimmingAlpha: entries.reduce( + (strongest, entry) => + Math.max(strongest, normalizeDimmingAlpha(entry.nativeDimmingAlpha)), + 0 + ), }; }); overlayOcclusionStateAtom.debugLabel = "overlayOcclusionStateAtom"; +function normalizeDimmingAlpha(value: number | undefined): number { + if (value === undefined || !Number.isFinite(value)) return 0; + return Math.max(0, Math.min(1, value)); +} + function sameRect( left: OverlayOcclusionRect | null, right: OverlayOcclusionRect | null @@ -109,6 +126,7 @@ export function useOverlayLayer( const setRegistry = useSetAtom(overlayLayerRegistryAtom); const frameRef = useRef(null); const blocksNativeInput = options.blocksNativeInput ?? true; + const nativeDimmingAlpha = normalizeDimmingAlpha(options.nativeDimmingAlpha); const publish = useCallback(() => { const nextRect = readElementRect(targetRef); @@ -117,6 +135,7 @@ export function useOverlayLayer( if ( current && current.blocksNativeInput === blocksNativeInput && + current.nativeDimmingAlpha === nativeDimmingAlpha && sameRect(current.rect, nextRect) ) { return previous; @@ -124,10 +143,15 @@ export function useOverlayLayer( return { ...previous, - [id]: { id, rect: nextRect, blocksNativeInput }, + [id]: { + id, + rect: nextRect, + blocksNativeInput, + nativeDimmingAlpha, + }, }; }); - }, [blocksNativeInput, id, setRegistry, targetRef]); + }, [blocksNativeInput, id, nativeDimmingAlpha, setRegistry, targetRef]); const schedulePublish = useCallback(() => { if (frameRef.current !== null) return; From f28b4dfdea2d5229ea22b94654cdd590803b1c30 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Tue, 25 Aug 2026 18:27:40 +0800 Subject: [PATCH 07/10] fix(modal): match native dimming to theme scrims Pre-commit hook ran. Total eslint: 0, total circular: 0 --- src/scaffold/ModalSystem/index.test.ts | 18 ++++++++++++++++++ src/scaffold/ModalSystem/index.tsx | 6 +++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/scaffold/ModalSystem/index.test.ts b/src/scaffold/ModalSystem/index.test.ts index add3fadc28..f843526160 100644 --- a/src/scaffold/ModalSystem/index.test.ts +++ b/src/scaffold/ModalSystem/index.test.ts @@ -16,11 +16,15 @@ import Modal from "./index"; const mocks = vi.hoisted(() => ({ useOverlayLayer: vi.fn(), + theme: { isDark: false }, })); vi.mock("@src/store/ui/overlayLayerAtom", () => ({ useOverlayLayer: mocks.useOverlayLayer, })); +vi.mock("@src/util/ui/theme/themeUtils", () => ({ + useCurrentTheme: () => ({ theme: "test", isDark: mocks.theme.isDark }), +})); function renderModal(visible: boolean) { return React.createElement( @@ -34,6 +38,7 @@ afterEach(() => { act(() => root.unmount()); container.remove(); mocks.useOverlayLayer.mockReset(); + mocks.theme.isDark = false; }); let container: HTMLDivElement; @@ -76,6 +81,19 @@ describe("Modal native surface coverage", () => { expect(options).toEqual({ nativeDimmingAlpha: 0.6 }); }); + it("matches the stronger dark-theme modal scrim", async () => { + mocks.theme.isDark = true; + await act(async () => root.render(renderModal(true))); + + const [, , options] = mocks.useOverlayLayer.mock.calls.at(-1) as [ + boolean, + RefObject, + { nativeDimmingAlpha: number }, + ]; + + expect(options).toEqual({ nativeDimmingAlpha: 0.7 }); + }); + 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))); diff --git a/src/scaffold/ModalSystem/index.tsx b/src/scaffold/ModalSystem/index.tsx index d0da801311..e38d302537 100644 --- a/src/scaffold/ModalSystem/index.tsx +++ b/src/scaffold/ModalSystem/index.tsx @@ -26,6 +26,7 @@ import PanelHeader, { PANEL_HEADER_TOKENS, } from "@src/modules/shared/layouts/blocks/PanelHeader"; import { useOverlayLayer } from "@src/store/ui/overlayLayerAtom"; +import { useCurrentTheme } from "@src/util/ui/theme/themeUtils"; import "./index.scss"; @@ -129,11 +130,14 @@ const Modal: React.FC = ({ const modalRef = useRef(null); const previousActiveElement = useRef(null); const [okLoading, setOkLoading] = useState(false); + const { isDark } = useCurrentTheme(); // Keep the live native page visible under the modal scrim. Only the opaque // dialog panel becomes a compositor hole; macOS renders the matching black // dim layer directly above the sibling WKWebView. - useOverlayLayer(visible, modalRef, { nativeDimmingAlpha: 0.6 }); + useOverlayLayer(visible, modalRef, { + nativeDimmingAlpha: isDark ? 0.7 : 0.6, + }); // Store the previously focused element useEffect(() => { From 52f0177b98084247158db13741d895ba77bad078 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Wed, 26 Aug 2026 00:34:16 +0800 Subject: [PATCH 08/10] fix(webview): split native mask and dim holes for overlays Separate WebView mask cuts from dim-layer holes so modals, spotlight tours, and dropdowns behave like normal React components over inline browsers without bright bleed-through, white padding artifacts, or premature hole registration. --- src-tauri/crates/browser/src/occlusion.rs | 271 +++++++++++++++--- src/components/Dropdown/index.tsx | 11 +- src/components/TabPill/index.tsx | 11 +- src/hooks/dropdown/useDropdownEngine.ts | 7 +- .../useInlineWebviewOcclusions.test.ts | 57 +++- .../nativeWebviewOcclusion.ts | 90 ++++-- .../useInlineWebviewOcclusions.ts | 90 ++++-- src/scaffold/ModalSystem/index.test.ts | 62 ++-- src/scaffold/ModalSystem/index.tsx | 20 +- src/scaffold/Tutorials/CodeEditorTour.tsx | 6 +- src/scaffold/Tutorials/GeneralLayoutTour.tsx | 6 +- .../Tutorials/GuideHighlightOverlay.tsx | 16 +- .../useSpotlightTourNativeOcclusion.ts | 41 +++ src/store/ui/overlayLayerAtom.test.ts | 6 +- src/store/ui/overlayLayerAtom.ts | 104 ++++++- 15 files changed, 667 insertions(+), 131 deletions(-) create mode 100644 src/scaffold/Tutorials/useSpotlightTourNativeOcclusion.ts diff --git a/src-tauri/crates/browser/src/occlusion.rs b/src-tauri/crates/browser/src/occlusion.rs index 2f87e8cef1..1e4afb2bee 100644 --- a/src-tauri/crates/browser/src/occlusion.rs +++ b/src-tauri/crates/browser/src/occlusion.rs @@ -66,6 +66,36 @@ fn sanitize_occlusion_rects( .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; @@ -84,6 +114,7 @@ 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> { @@ -96,7 +127,15 @@ pub async fn set_inline_webview_occlusions( #[cfg(target_os = "macos")] { let main_webview = app.get_webview("main"); - apply_macos_occlusions(&webview, main_webview, rects, block_input, dimming_alpha).await + apply_macos_occlusions( + &webview, + main_webview, + rects, + dim_hole_rects, + block_input, + dimming_alpha, + ) + .await } #[cfg(not(target_os = "macos"))] @@ -124,28 +163,80 @@ mod macos { type HitTestImplementation = unsafe extern "C-unwind" fn(&AnyObject, Sel, NSPoint) -> *mut AnyObject; - /// Maps an occluded inline WKWebView to the main React WKWebView that must - /// receive pointer input while an interactive overlay is open. - static INPUT_TARGET_WEBVIEWS: OnceLock>> = OnceLock::new(); + #[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 input_target_webviews() -> &'static Mutex> { - INPUT_TARGET_WEBVIEWS.get_or_init(|| Mutex::new(HashMap::new())) + 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 input_target_webview(webview: &AnyObject) -> Option { - input_target_webviews().lock().ok().and_then(|targets| { - targets + fn occlusion_input_state(webview: &AnyObject) -> Option { + occlusion_input_states().lock().ok().and_then(|states| { + states .get(&(webview as *const AnyObject as usize)) - .copied() + .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()); @@ -167,25 +258,28 @@ mod macos { } extern "C-unwind" fn hit_test(this: &AnyObject, _cmd: Sel, point: NSPoint) -> *mut AnyObject { - let key = this as *const AnyObject as usize; - if let Some(target_key) = input_target_webview(this) { - // Route directly to the main React WKWebView. Asking the inline - // WebView's parent to re-run hit testing can return nil when the - // two WebViews have different native container views; AppKit may - // then deliver the click to a window in another application. - if target_key != 0 && target_key != key { - let target = unsafe { &*(target_key as *const AnyObject) }; - let point_in_target: NSPoint = - unsafe { msg_send![this, convertPoint: point, toView: target] }; - let routed: *mut AnyObject = unsafe { msg_send![target, hitTest: point_in_target] }; - if !routed.is_null() { - return routed; + 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; + } } - } - // Fail closed: keeping the event inside ORG2 is safer than a bare - // nil, even if the main surface is temporarily being recreated. - return original_hit_test(this, _cmd, point); + return original_hit_test(this, _cmd, point); + } } original_hit_test(this, _cmd, point) @@ -228,19 +322,32 @@ mod macos { Ok(()) } - fn set_input_target(webview: &AnyObject, target: Option) -> Result<(), String> { + 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; - if target.is_some() { + let needs_hook = block_input || !holes.is_empty(); + if needs_hook { ensure_hit_test_hook(webview)?; } - let mut registry = input_target_webviews() + let mut registry = occlusion_input_states() .lock() .map_err(|_| "native WebView input registry is poisoned".to_string())?; - if let Some(target) = target { - registry.insert(key, target); - } else { + if main_target.is_none() && holes.is_empty() && !block_input { registry.remove(&key); + } else { + registry.insert( + key, + OcclusionInputState { + main_target, + holes, + block_input, + }, + ); } Ok(()) } @@ -276,7 +383,12 @@ mod macos { }) } - fn update_dimming_layer(root_layer: &CALayer, bounds: NSRect, dimming_alpha: f64) { + 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); @@ -299,16 +411,26 @@ mod macos { 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 { + 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())?; @@ -327,8 +449,6 @@ mod macos { } let wk_webview = unsafe { &*wk_webview }; - set_input_target(wk_webview, input_target)?; - unsafe { let _: () = msg_send![wk_webview, setWantsLayer: true]; let layer: *mut CALayer = msg_send![wk_webview, layer]; @@ -338,20 +458,49 @@ mod macos { let layer = &*layer; let bounds: NSRect = msg_send![wk_webview, bounds]; - let sanitized = - sanitize_occlusion_rects(&rects, bounds.size.width, bounds.size.height); + 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.is_empty() { + 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 { + for rect in &sanitized_mask { let y = if is_flipped { bounds.origin.y + rect.y } else { @@ -371,7 +520,30 @@ mod macos { layer.setMask(Some(&mask)); } - update_dimming_layer(layer, bounds, dimming_alpha); + 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(); } @@ -393,14 +565,14 @@ mod macos { return; } let wk_webview = &*wk_webview; - let _ = set_input_target(wk_webview, None); + 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); + update_dimming_layer(layer, NSRect::ZERO, 0.0, None); CATransaction::commit(); } }); @@ -412,10 +584,19 @@ 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, block_input, dimming_alpha).await + 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 diff --git a/src/components/Dropdown/index.tsx b/src/components/Dropdown/index.tsx index be61bb8101..a24a8a406c 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,11 @@ const Dropdown: React.FC = ({ const isControlled = controlledVisible !== undefined; const visible = isControlled ? controlledVisible : internalVisible; - useOverlayLayer(visible, dropdownRef); + useOverlayLayer( + visible && Boolean(dropdownPosition), + dropdownRef, + DROPDOWN_OCCLUSION_OPTIONS + ); const setVisible = useCallback( (newVisible: boolean) => { diff --git a/src/components/TabPill/index.tsx b/src/components/TabPill/index.tsx index bca5ea90e7..d950c76c9e 100644 --- a/src/components/TabPill/index.tsx +++ b/src/components/TabPill/index.tsx @@ -2,7 +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 { 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 { SidebarTabButton } from "./SidebarTabButton"; @@ -59,7 +62,11 @@ const TabPill: React.FC = ({ const [dropdownPositioned, setDropdownPositioned] = useState(false); const dropdownTriggerRef = useRef(null); const dropdownPanelRef = useRef(null); - useOverlayLayer(dropdownOpen, dropdownPanelRef); + 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/hooks/dropdown/useDropdownEngine.ts b/src/hooks/dropdown/useDropdownEngine.ts index ec7e4ab896..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,7 +204,7 @@ export function useDropdownEngine< maxHeight: DROPDOWN_PANEL.maxHeight, }); - useOverlayLayer(isOpen, panelRef); + useOverlayLayer(isOpen && isPositioned, panelRef, DROPDOWN_OCCLUSION_OPTIONS); const updatePosition = useCallback(() => { const triggerElement = latestTriggerRef.current.current; diff --git a/src/hooks/platform/useInlineWebview/__tests__/useInlineWebviewOcclusions.test.ts b/src/hooks/platform/useInlineWebview/__tests__/useInlineWebviewOcclusions.test.ts index cf52cbb846..8370da79e1 100644 --- a/src/hooks/platform/useInlineWebview/__tests__/useInlineWebviewOcclusions.test.ts +++ b/src/hooks/platform/useInlineWebview/__tests__/useInlineWebviewOcclusions.test.ts @@ -13,6 +13,15 @@ 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: () => ({ @@ -101,7 +110,8 @@ describe("useInlineWebviewOcclusions", () => { "set_inline_webview_occlusions", { label: "browser-session-test", - rects: [{ x: 350, y: 0, width: 50, height: 70 }], + rects: [{ x: 346, y: 0, width: 54, height: 74 }], + dimHoleRects: [{ x: 346, y: 0, width: 54, height: 74 }], blockInput: true, dimmingAlpha: 0, } @@ -114,6 +124,7 @@ describe("useInlineWebviewOcclusions", () => { { label: "browser-session-test", rects: [], + dimHoleRects: [], blockInput: false, dimmingAlpha: 0, } @@ -144,13 +155,54 @@ describe("useInlineWebviewOcclusions", () => { "set_inline_webview_occlusions", { label: "browser-session-test", - rects: [{ x: 100, y: 50, width: 200, height: 120 }], + 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 () => { @@ -193,6 +245,7 @@ describe("useInlineWebviewOcclusions", () => { { label: "browser-session-test", rects: [], + dimHoleRects: [], blockInput: false, dimmingAlpha: 0, } diff --git a/src/hooks/platform/useInlineWebview/nativeWebviewOcclusion.ts b/src/hooks/platform/useInlineWebview/nativeWebviewOcclusion.ts index b2fd2547de..252c9159d5 100644 --- a/src/hooks/platform/useInlineWebview/nativeWebviewOcclusion.ts +++ b/src/hooks/platform/useInlineWebview/nativeWebviewOcclusion.ts @@ -1,4 +1,5 @@ import type { OverlayOcclusionRect } from "@src/store/ui/overlayLayerAtom"; +import { toNativeFrameFromCorners } from "@src/util/platform/tauri/nativeFrame"; export interface NativeWebviewOcclusionRect { x: number; @@ -15,6 +16,35 @@ export interface ViewportRect { } 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, @@ -72,7 +102,8 @@ export function coalesceOcclusionRects( export function computeNativeWebviewOcclusions( surface: ViewportRect, overlays: readonly OverlayOcclusionRect[], - nativeFrameScale: number + nativeFrameScale: number, + options: { holeInflationCss?: number } = {} ): NativeWebviewOcclusionRect[] { if ( !Number.isFinite(nativeFrameScale) || @@ -83,8 +114,18 @@ export function computeNativeWebviewOcclusions( return []; } - const nativeSurfaceLeft = Math.round(surface.left * nativeFrameScale); - const nativeSurfaceTop = Math.round(surface.top * nativeFrameScale); + 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)) { @@ -99,22 +140,39 @@ export function computeNativeWebviewOcclusions( continue; } - const left = Math.max(surface.left, overlay.x); - const top = Math.max(surface.top, overlay.y); - const right = Math.min(surface.right, overlay.x + overlay.width); - const bottom = Math.min(surface.bottom, overlay.y + overlay.height); - if (right <= left || bottom <= top) 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 nativeLeft = Math.round(left * nativeFrameScale); - const nativeTop = Math.round(top * nativeFrameScale); - const nativeRight = Math.round(right * nativeFrameScale); - const nativeBottom = Math.round(bottom * nativeFrameScale); + 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: nativeLeft - nativeSurfaceLeft, - y: nativeTop - nativeSurfaceTop, - width: nativeRight - nativeLeft, - height: nativeBottom - nativeTop, + x: left - nativeSurface.x, + y: top - nativeSurface.y, + width: right - left, + height: bottom - top, }); } diff --git a/src/hooks/platform/useInlineWebview/useInlineWebviewOcclusions.ts b/src/hooks/platform/useInlineWebview/useInlineWebviewOcclusions.ts index e6c4357324..d19fe3a1d8 100644 --- a/src/hooks/platform/useInlineWebview/useInlineWebviewOcclusions.ts +++ b/src/hooks/platform/useInlineWebview/useInlineWebviewOcclusions.ts @@ -7,7 +7,11 @@ import { overlayOcclusionStateAtom } from "@src/store/ui/overlayLayerAtom"; import { isMacOS } from "@src/util/platform/tauri"; import { getNativeFrameScale } from "@src/util/platform/tauri/nativeFrame"; -import { computeNativeWebviewOcclusions } from "./nativeWebviewOcclusion"; +import { + DEFAULT_HOLE_INFLATION_CSS, + MODAL_HOLE_INFLATION_CSS, + computeNativeWebviewOcclusions, +} from "./nativeWebviewOcclusion"; import { getVisibleWebviewRect } from "./visibleWebviewRect"; import { WEBVIEW_LAYOUT_CHANGED_EVENT, @@ -26,7 +30,8 @@ export interface UseInlineWebviewOcclusionsParams { interface DesiredOcclusionState { revision: number; - rects: ReturnType; + maskRects: ReturnType; + dimHoleRects: ReturnType; blockInput: boolean; dimmingAlpha: number; } @@ -42,16 +47,23 @@ function samePayload( ) { return false; } - if (left.rects.length !== right.rects.length) return false; - return 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 - ); - }); + 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; } /** @@ -68,7 +80,8 @@ export function useInlineWebviewOcclusions({ const overlayState = useAtomValue(overlayOcclusionStateAtom); const desiredRef = useRef({ revision: 0, - rects: [], + maskRects: [], + dimHoleRects: [], blockInput: false, dimmingAlpha: 0, }); @@ -76,7 +89,8 @@ export function useInlineWebviewOcclusions({ // avoids one no-op IPC for every restored but inactive browser session. const appliedRef = useRef({ revision: 0, - rects: [], + maskRects: [], + dimHoleRects: [], blockInput: false, dimmingAlpha: 0, }); @@ -87,20 +101,44 @@ export function useInlineWebviewOcclusions({ const measureDesired = useCallback((): DesiredOcclusionState => { const revision = desiredRef.current.revision + 1; if (!isWebviewCreated || !isSurfaceVisible || !containerRef.current) { - return { revision, rects: [], blockInput: false, dimmingAlpha: 0 }; + return { + revision, + maskRects: [], + dimHoleRects: [], + blockInput: false, + dimmingAlpha: 0, + }; } const surface = getVisibleWebviewRect(containerRef.current); if (!surface) { - return { revision, rects: [], blockInput: false, dimmingAlpha: 0 }; + return { + revision, + maskRects: [], + dimHoleRects: [], + blockInput: false, + dimmingAlpha: 0, + }; } + const holeInflationCss = + overlayState.nativeDimmingAlpha > 0 + ? MODAL_HOLE_INFLATION_CSS + : DEFAULT_HOLE_INFLATION_CSS; + return { revision, - rects: computeNativeWebviewOcclusions( + maskRects: computeNativeWebviewOcclusions( + surface, + overlayState.maskRects, + getNativeFrameScale(), + { holeInflationCss } + ), + dimHoleRects: computeNativeWebviewOcclusions( surface, - overlayState.rects, - getNativeFrameScale() + overlayState.dimHoleRects, + getNativeFrameScale(), + { holeInflationCss } ), blockInput: overlayState.blocksNativeInput, dimmingAlpha: overlayState.nativeDimmingAlpha, @@ -110,8 +148,9 @@ export function useInlineWebviewOcclusions({ isSurfaceVisible, isWebviewCreated, overlayState.blocksNativeInput, + overlayState.dimHoleRects, + overlayState.maskRects, overlayState.nativeDimmingAlpha, - overlayState.rects, ]); const applyLatest = useCallback(async () => { @@ -133,7 +172,8 @@ export function useInlineWebviewOcclusions({ try { await invoke("set_inline_webview_occlusions", { label, - rects: desired.rects, + rects: desired.maskRects, + dimHoleRects: desired.dimHoleRects, blockInput: desired.blockInput, dimmingAlpha: desired.dimmingAlpha, }); @@ -186,7 +226,9 @@ export function useInlineWebviewOcclusions({ !isMacOS() || !isWebviewCreated || !isSurfaceVisible || - (overlayState.rects.length === 0 && overlayState.nativeDimmingAlpha === 0) + (overlayState.maskRects.length === 0 && + overlayState.dimHoleRects.length === 0 && + overlayState.nativeDimmingAlpha === 0) ) { return; } @@ -226,8 +268,9 @@ export function useInlineWebviewOcclusions({ isSurfaceVisible, isWebviewCreated, label, + overlayState.dimHoleRects.length, + overlayState.maskRects.length, overlayState.nativeDimmingAlpha, - overlayState.rects.length, schedulePublish, ]); @@ -241,6 +284,7 @@ export function useInlineWebviewOcclusions({ void invoke("set_inline_webview_occlusions", { label, rects: [], + dimHoleRects: [], blockInput: false, dimmingAlpha: 0, }).catch(() => undefined); diff --git a/src/scaffold/ModalSystem/index.test.ts b/src/scaffold/ModalSystem/index.test.ts index f843526160..7a8bbb6e16 100644 --- a/src/scaffold/ModalSystem/index.test.ts +++ b/src/scaffold/ModalSystem/index.test.ts @@ -12,6 +12,8 @@ import { vi, } from "vitest"; +import { MODAL_MASK_OCCLUSION_OPTIONS } from "@src/store/ui/overlayLayerAtom"; + import Modal from "./index"; const mocks = vi.hoisted(() => ({ @@ -19,9 +21,14 @@ const mocks = vi.hoisted(() => ({ theme: { isDark: false }, })); -vi.mock("@src/store/ui/overlayLayerAtom", () => ({ - useOverlayLayer: mocks.useOverlayLayer, -})); +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 }), })); @@ -62,49 +69,60 @@ afterAll(() => { }); describe("Modal native surface coverage", () => { - it("registers the opaque panel and dims the live native page", async () => { + it("dims the live native page and masks the opaque panel", async () => { await act(async () => root.render(renderModal(true))); - const [, coverageRef, options] = mocks.useOverlayLayer.mock.calls.at( - -1 - ) as [ + expect(mocks.useOverlayLayer).toHaveBeenCalledTimes(2); + const [dimActive, dimRef, dimOptions] = mocks.useOverlayLayer.mock + .calls[0] as [ boolean, RefObject, - { nativeDimmingAlpha: number }, + { nativeDimmingAlpha: number; cutsNativeSurface: boolean }, + ]; + const [maskActive, maskRef, maskOptions] = mocks.useOverlayLayer.mock + .calls[1] as [ + boolean, + RefObject, + typeof MODAL_MASK_OCCLUSION_OPTIONS, ]; - const wrapper = document.querySelector(".liquid-modal-wrapper"); const panel = document.querySelector(".liquid-modal-content"); - expect(wrapper).not.toBeNull(); - expect(coverageRef.current).toBe(panel); - expect(coverageRef.current).not.toBe(wrapper); - expect(options).toEqual({ nativeDimmingAlpha: 0.6 }); + 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 [, , options] = mocks.useOverlayLayer.mock.calls.at(-1) as [ + const [, , dimOptions] = mocks.useOverlayLayer.mock.calls[0] as [ boolean, RefObject, { nativeDimmingAlpha: number }, ]; - expect(options).toEqual({ nativeDimmingAlpha: 0.7 }); + 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))); - const [active, coverageRef] = mocks.useOverlayLayer.mock.calls.at(-1) as [ - boolean, - RefObject, - ]; - - expect(active).toBe(false); - expect(coverageRef.current).toBeNull(); + 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 e38d302537..83a3dc5a52 100644 --- a/src/scaffold/ModalSystem/index.tsx +++ b/src/scaffold/ModalSystem/index.tsx @@ -25,7 +25,10 @@ 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"; @@ -131,13 +134,16 @@ const Modal: React.FC = ({ const previousActiveElement = useRef(null); const [okLoading, setOkLoading] = useState(false); const { isDark } = useCurrentTheme(); - - // Keep the live native page visible under the modal scrim. Only the opaque - // dialog panel becomes a compositor hole; macOS renders the matching black - // dim layer directly above the sibling WKWebView. - useOverlayLayer(visible, modalRef, { - nativeDimmingAlpha: isDark ? 0.7 : 0.6, + 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/Tutorials/CodeEditorTour.tsx b/src/scaffold/Tutorials/CodeEditorTour.tsx index 4975886cbd..1b27369c34 100644 --- a/src/scaffold/Tutorials/CodeEditorTour.tsx +++ b/src/scaffold/Tutorials/CodeEditorTour.tsx @@ -19,7 +19,6 @@ import { POPUP_SHADOW, } from "@src/scaffold/shared/popupTokens"; import { WorkStationViewService } from "@src/services/workStation/WorkStationViewService"; -import { useOverlayLayer } from "@src/store/ui/overlayLayerAtom"; import { stationModeAtom } from "@src/store/ui/simulatorAtom"; import { sourceControlFilterModeAtom } from "@src/store/workstation/codeEditor/sourceControlFilterModeAtom"; import { useCurrentTheme } from "@src/util/ui/theme/themeUtils"; @@ -27,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]; @@ -215,7 +215,8 @@ const CodeEditorTour: React.FC = ({ open, onClose }) => { const [stepIndex, setStepIndex] = useState(0); const [targetRect, setTargetRect] = useState(null); const popoverRef = useRef(null); - useOverlayLayer(open, popoverRef); + const highlightRef = useRef(null); + useSpotlightTourNativeOcclusion(open, popoverRef, highlightRef); const currentStep = TOUR_STEPS[stepIndex]; const isFirstStep = stepIndex === 0; @@ -367,6 +368,7 @@ const CodeEditorTour: React.FC = ({ open, onClose }) => { {highlightStyle && ( = ({ const [stepIndex, setStepIndex] = useState(0); const [targetRect, setTargetRect] = useState(null); const popoverRef = useRef(null); - useOverlayLayer(open, popoverRef); + const highlightRef = useRef(null); + useSpotlightTourNativeOcclusion(open, popoverRef, highlightRef); const currentStep = TOUR_STEPS[stepIndex]; const isFirstStep = stepIndex === 0; @@ -344,6 +345,7 @@ const GeneralLayoutTour: React.FC = ({ {highlightStyle && ( { ? targetRect.rect : null; const popoverRef = useRef(null); - useOverlayLayer(Boolean(highlight && rect), popoverRef); + const highlightRef = useRef(null); + useSpotlightTourNativeOcclusion( + Boolean(highlight && rect), + popoverRef, + highlightRef, + { + dimmingAlpha: GUIDE_HIGHLIGHT_NATIVE_DIMMING_ALPHA, + } + ); const highlightStyle = useMemo( () => (rect ? buildHighlightStyle(rect) : undefined), [rect] @@ -220,6 +231,7 @@ const GuideHighlightOverlay: React.FC = () => { {highlight && rect && highlightStyle && popoverStyle && (