fix(vom): overlay

This commit is contained in:
Ljy-0827
2026-07-28 17:31:33 +08:00
parent 000fbf5540
commit 8f5df59b79
6 changed files with 333 additions and 220 deletions
@@ -0,0 +1,183 @@
import { useTranslation } from "@browser-skill/i18n/react";
import { RiStopCircleLine } from "@remixicon/react";
import { useEffect, useRef, useState } from "react";
import logoUrl from "../../assets/logo.png";
export interface ControlOverlayProps {
visible: boolean;
interrupting: boolean;
automationBypass: boolean;
onInterrupt: () => void;
}
export function ControlOverlay({
visible,
interrupting,
automationBypass,
onInterrupt,
}: ControlOverlayProps) {
const { t } = useTranslation("extension");
const [show, setShow] = useState(false);
const blockerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (visible) {
const raf = requestAnimationFrame(() => setShow(true));
return () => cancelAnimationFrame(raf);
}
setShow(false);
}, [visible]);
useEffect(() => {
const blocker = blockerRef.current;
if (!blocker) return;
const stopScroll = (event: WheelEvent | TouchEvent) => {
if (automationBypass) return;
event.preventDefault();
event.stopPropagation();
};
blocker.addEventListener("wheel", stopScroll, { passive: false });
blocker.addEventListener("touchmove", stopScroll, { passive: false });
return () => {
blocker.removeEventListener("wheel", stopScroll);
blocker.removeEventListener("touchmove", stopScroll);
};
}, [automationBypass]);
useEffect(() => {
if (!visible || automationBypass) return;
const stopScroll = (event: WheelEvent | TouchEvent) => {
event.preventDefault();
event.stopPropagation();
};
window.addEventListener("wheel", stopScroll, { capture: true, passive: false });
window.addEventListener("touchmove", stopScroll, { capture: true, passive: false });
return () => {
window.removeEventListener("wheel", stopScroll, { capture: true });
window.removeEventListener("touchmove", stopScroll, { capture: true });
};
}, [visible, automationBypass]);
if (!visible) return null;
const pointerEvents = automationBypass ? "none" : "auto";
return (
<>
<style>{`
@keyframes bsk-breathe {
0%, 100% {
box-shadow: inset 0 0 20px 4px rgba(249,115,22,0.25);
}
50% {
box-shadow: inset 0 0 40px 8px rgba(249,115,22,0.5);
}
}
`}</style>
<div
data-slot="control-overlay"
style={{
position: "fixed",
inset: 0,
zIndex: 2147483646,
pointerEvents: "none",
animation: "bsk-breathe 3s ease-in-out infinite",
opacity: show ? 1 : 0,
transition: "opacity 300ms ease-out",
}}
/>
<div
ref={blockerRef}
data-slot="control-overlay-blocker"
onPointerDown={(event) => {
if (automationBypass) return;
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
if (automationBypass) return;
event.preventDefault();
event.stopPropagation();
}}
style={{
position: "fixed",
inset: 0,
zIndex: 2147483646,
pointerEvents,
background: "transparent",
opacity: show ? 1 : 0,
transition: "opacity 300ms ease-out",
}}
/>
<div
data-slot="control-overlay-pill"
style={{
position: "fixed",
bottom: 32,
left: "50%",
transform: "translateX(-50%)",
zIndex: 2147483647,
pointerEvents: "auto",
display: "flex",
alignItems: "center",
gap: 12,
backgroundColor: "#fff",
borderRadius: 9999,
padding: "10px 10px 10px 20px",
boxShadow: "0 8px 32px rgba(124,45,18,0.16), 0 2px 8px rgba(0,0,0,0.1)",
opacity: show ? 1 : 0,
transition: "opacity 300ms ease-out",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
}}
>
<img
src={logoUrl}
alt="browser-skill"
style={{ width: 24, height: 24, borderRadius: 4, flexShrink: 0 }}
/>
<span
style={{
fontSize: 16,
fontWeight: 500,
color: "#333",
whiteSpace: "nowrap",
userSelect: "none",
}}
>
{t("controlOverlay.status")}
</span>
<button
type="button"
data-slot="control-overlay-stop-all"
disabled={interrupting}
onClick={onInterrupt}
style={{
pointerEvents: "auto",
display: "flex",
alignItems: "center",
gap: 6,
border: "none",
borderRadius: 9999,
padding: "8px 20px 8px 16px",
fontSize: 15,
fontWeight: 600,
color: "#fff",
backgroundColor: interrupting ? "#9ca3af" : "#f97316",
cursor: interrupting ? "default" : "pointer",
opacity: interrupting ? 0.7 : 1,
transition: "background-color 150ms ease-out, opacity 150ms ease-out",
whiteSpace: "nowrap",
lineHeight: 1,
}}
>
<RiStopCircleLine size={18} color="#fff" />
{interrupting ? t("controlOverlay.interrupting") : t("controlOverlay.interrupt")}
</button>
</div>
</>
);
}
@@ -48,6 +48,7 @@ const VIEWPORT_MARGIN = 16;
const PANEL_WIDTH = 420;
const FALLBACK_PANEL_H = 180;
const FALLBACK_PANEL_H_COLLAPSED = 44;
const EMPTY_SELECTORS: string[] = [];
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
@@ -177,7 +178,7 @@ function measure(selectors: string[]): Box[] {
export function HelpRequestOverlay({ request }: Props) {
const { t } = useTranslation("extension");
const isCompact = request?.displayMode === "compact";
const effectiveSelectors = isCompact ? [] : (request?.selectors ?? []);
const effectiveSelectors = isCompact ? EMPTY_SELECTORS : (request?.selectors ?? EMPTY_SELECTORS);
const [note, setNote] = useState("");
const [collapsed, setCollapsed] = useState(false);
const [boxes, setBoxes] = useState<Box[]>([]);
@@ -226,7 +227,7 @@ export function HelpRequestOverlay({ request }: Props) {
// Keep highlight boxes aligned with the page as it scrolls / resizes.
useEffect(() => {
if (!request) {
setBoxes([]);
setBoxes((prev) => (prev.length === 0 ? prev : []));
return;
}
const update = () => {
@@ -0,0 +1,64 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ControlOverlay } from "../ControlOverlay";
describe("ControlOverlay", () => {
afterEach(() => {
cleanup();
});
it("keeps page blocker none under automationBypass but Interrupt stays clickable", () => {
const { container } = render(
<ControlOverlay
visible={true}
interrupting={false}
automationBypass={true}
onInterrupt={() => {}}
/>,
);
const blocker = container.querySelector("[data-slot='control-overlay-blocker']");
expect(blocker).toBeTruthy();
expect((blocker as HTMLElement).style.pointerEvents).toBe("none");
const pill = container.querySelector("[data-slot='control-overlay-pill']");
expect(pill).toBeTruthy();
expect((pill as HTMLElement).style.pointerEvents).toBe("auto");
const stopBtn = container.querySelector("[data-slot='control-overlay-stop-all']");
expect(stopBtn).toBeTruthy();
expect((stopBtn as HTMLElement).style.pointerEvents).toBe("auto");
});
it("uses pointer-events auto on blocker when automationBypass is false", () => {
const { container } = render(
<ControlOverlay
visible={true}
interrupting={false}
automationBypass={false}
onInterrupt={() => {}}
/>,
);
const blocker = container.querySelector("[data-slot='control-overlay-blocker']");
expect(blocker).toBeTruthy();
expect((blocker as HTMLElement).style.pointerEvents).toBe("auto");
});
it("calls onInterrupt from the stop button", () => {
const onInterrupt = vi.fn();
const { container } = render(
<ControlOverlay
visible={true}
interrupting={false}
automationBypass={false}
onInterrupt={onInterrupt}
/>,
);
const stopBtn = container.querySelector("[data-slot='control-overlay-stop-all']");
(stopBtn as HTMLButtonElement).click();
expect(onInterrupt).toHaveBeenCalledTimes(1);
});
});
@@ -5,7 +5,7 @@ import { createElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { type HelpRequestData, HelpRequestOverlay } from "../HelpRequestOverlay";
function renderOverlay(req: HelpRequestData) {
function renderOverlay(req: HelpRequestData | null) {
return render(
createElement(I18nextProvider, { i18n }, createElement(HelpRequestOverlay, { request: req })),
);
@@ -38,6 +38,16 @@ describe("HelpRequestOverlay", () => {
expect(screen.getByText("Please complete the captcha")).toBeTruthy();
});
it("keeps the inactive render stable", () => {
const { container, rerender } = renderOverlay(null);
rerender(
createElement(I18nextProvider, { i18n }, createElement(HelpRequestOverlay, { request: null })),
);
expect(container.querySelector("[data-slot='help-request-banner']")).toBeNull();
});
it("renders a compact status without full request controls", () => {
const el = document.createElement("div");
el.id = "login";
+14
View File
@@ -1,2 +1,16 @@
@import "tailwindcss";
@source "../content";
:host([data-bsk-overlay-surface]) {
position: fixed !important;
inset: 0 !important;
display: block !important;
width: 100vw !important;
height: 100vh !important;
z-index: 2147483647 !important;
pointer-events: none !important;
}
:host([data-bsk-overlay-surface][data-bsk-overlay-blocking]) {
pointer-events: auto !important;
}