import { useTranslation } from "@browser-skill/i18n/react"; import { RiArrowDownSLine } from "@remixicon/react"; import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react"; import type { HelpHighlightRect } from "@/lib/help-bridge"; import logoUrl from "../../assets/logo.png"; export interface HelpRequestData { id: string; prompt: string; /** Custom overlay title; falls back to i18n when omitted. */ title?: string; /** Full task UI on the subject tab; compact status UI on related tabs. */ displayMode?: "full" | "compact"; /** CSS selectors to scroll to + flash-highlight. */ selectors: string[]; /** Pre-resolved top-viewport rectangles, used for cross-frame targets. */ rects?: HelpHighlightRect[]; /** Re-resolve cross-frame rectangles after scroll, resize, or layout changes. */ refreshRects?: () => Promise; onContinue: (note: string) => void; onCancel: () => void; } interface Props { request: HelpRequestData | null; } interface Box { top: number; left: number; width: number; height: number; } interface PanelPos { top?: number; bottom?: number; left: number; placement: PanelPlacement; } type PanelPlacement = "above" | "below"; const PANEL_GAP = 12; const VIEWPORT_MARGIN = 16; const PANEL_WIDTH = 420; const FALLBACK_PANEL_H = 180; const FALLBACK_PANEL_H_COLLAPSED = 44; const EMPTY_SELECTORS: string[] = []; const EMPTY_RECTS: HelpHighlightRect[] = []; function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } function clampDragPos( top: number, left: number, panelW: number, panelH: number, ): { top: number; left: number } { const maxLeft = window.innerWidth - panelW - VIEWPORT_MARGIN; const maxTop = window.innerHeight - panelH - VIEWPORT_MARGIN; return { top: clamp(top, VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, maxTop)), left: clamp(left, VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, maxLeft)), }; } function boxesEqual(a: Box[], b: Box[]): boolean { return ( a.length === b.length && a.every( (box, i) => box.top === b[i]?.top && box.left === b[i]?.left && box.width === b[i]?.width && box.height === b[i]?.height, ) ); } function panelPosEqual(a: PanelPos | null, b: PanelPos | null): boolean { return ( a?.top === b?.top && a?.bottom === b?.bottom && a?.left === b?.left && a?.placement === b?.placement ); } /** Union of multiple viewport boxes into one anchor rect. */ function unionBox(boxes: Box[]): Box | null { if (boxes.length === 0) return null; let top = Number.POSITIVE_INFINITY; let left = Number.POSITIVE_INFINITY; let right = Number.NEGATIVE_INFINITY; let bottom = Number.NEGATIVE_INFINITY; for (const b of boxes) { top = Math.min(top, b.top); left = Math.min(left, b.left); right = Math.max(right, b.left + b.width); bottom = Math.max(bottom, b.top + b.height); } return { top, left, width: right - left, height: bottom - top }; } function choosePlacement(anchor: Box, panelH: number): PanelPlacement { const viewportH = window.innerHeight; const anchorBottom = anchor.top + anchor.height; const spaceBelow = viewportH - anchorBottom; const spaceAbove = anchor.top; const minSpace = panelH + PANEL_GAP + VIEWPORT_MARGIN; if (spaceBelow >= minSpace) { return "below"; } if (spaceAbove >= minSpace) { return "above"; } return spaceBelow >= spaceAbove ? "below" : "above"; } /** Place the panel near the anchor on a stable side, clamped to the viewport. */ function placePanel( anchor: Box, panelW: number, panelH: number, placement: PanelPlacement, ): PanelPos { const viewportW = window.innerWidth; const viewportH = window.innerHeight; const anchorBottom = anchor.top + anchor.height; const anchorCenterX = anchor.left + anchor.width / 2; const minCenterX = VIEWPORT_MARGIN + panelW / 2; const maxCenterX = viewportW - VIEWPORT_MARGIN - panelW / 2; const left = minCenterX <= maxCenterX ? clamp(anchorCenterX, minCenterX, maxCenterX) : viewportW / 2; if (placement === "below") { const top = clamp( anchorBottom + PANEL_GAP, VIEWPORT_MARGIN, viewportH - panelH - VIEWPORT_MARGIN, ); return { top, left, placement }; } let bottom = viewportH - anchor.top + PANEL_GAP; const maxBottom = Math.max(VIEWPORT_MARGIN, viewportH - panelH - VIEWPORT_MARGIN); bottom = clamp(bottom, VIEWPORT_MARGIN, maxBottom); return { bottom, left, placement }; } /** Returns null for invalid selectors instead of throwing. */ function querySelectorSafe(selector: string): Element | null { try { return document.querySelector(selector); } catch { return null; } } /** Resolve selectors to their first matching element's viewport rect. */ function measure(selectors: string[]): Box[] { const boxes: Box[] = []; for (const sel of selectors) { const el = querySelectorSafe(sel); if (!el) continue; const r = el.getBoundingClientRect(); boxes.push({ top: r.top, left: r.left, width: r.width, height: r.height }); } return boxes; } export function HelpRequestOverlay({ request }: Props) { const { t } = useTranslation("extension"); const isCompact = request?.displayMode === "compact"; const effectiveSelectors = isCompact ? EMPTY_SELECTORS : (request?.selectors ?? EMPTY_SELECTORS); const [liveRects, setLiveRects] = useState(null); const effectiveRects = isCompact ? EMPTY_RECTS : (liveRects ?? request?.rects ?? EMPTY_RECTS); const [note, setNote] = useState(""); const [collapsed, setCollapsed] = useState(false); const [boxes, setBoxes] = useState([]); const [panelPos, setPanelPos] = useState(null); const [dragPos, setDragPos] = useState<{ top: number; left: number } | null>(null); const [dragging, setDragging] = useState(false); const bannerRef = useRef(null); const lastScrolledIdRef = useRef(null); const placementRef = useRef<{ requestId: string; placement: PanelPlacement } | null>(null); const userMovedRef = useRef(false); const dragStartRef = useRef<{ pointerX: number; pointerY: number; originLeft: number; originTop: number; panelW: number; panelH: number; } | null>(null); // Reset the note and collapse state whenever a new request appears. useEffect(() => { setNote(""); setLiveRects(null); setCollapsed(false); placementRef.current = null; setDragPos(null); setDragging(false); userMovedRef.current = false; dragStartRef.current = null; }, [request?.id]); useEffect(() => { if (!request || isCompact || !request.refreshRects) return; let disposed = false; let resolving = false; const refresh = async () => { if (resolving) return; resolving = true; try { const rects = await request.refreshRects?.(); if (!disposed && rects) setLiveRects(rects); } finally { resolving = false; } }; const onViewportChange = () => void refresh(); window.addEventListener("scroll", onViewportChange, true); window.addEventListener("resize", onViewportChange); const interval = window.setInterval(onViewportChange, 500); return () => { disposed = true; window.removeEventListener("scroll", onViewportChange, true); window.removeEventListener("resize", onViewportChange); window.clearInterval(interval); }; }, [request, isCompact]); // Scroll the first matched target into view once per distinct request id. useLayoutEffect(() => { if (!request) return; if (isCompact) return; if (lastScrolledIdRef.current === request.id) return; for (const sel of effectiveSelectors) { const el = querySelectorSafe(sel); if (el) { lastScrolledIdRef.current = request.id; el.scrollIntoView({ block: "center", behavior: "smooth" }); break; } } }, [request, isCompact, effectiveSelectors]); // Keep highlight boxes aligned with the page as it scrolls / resizes. useEffect(() => { if (!request) { setBoxes((prev) => (prev.length === 0 ? prev : [])); return; } const update = () => { const nextBoxes = [...measure(effectiveSelectors), ...effectiveRects]; setBoxes((prev) => (boxesEqual(prev, nextBoxes) ? prev : nextBoxes)); }; update(); window.addEventListener("scroll", update, true); window.addEventListener("resize", update); const interval = window.setInterval(update, 500); return () => { window.removeEventListener("scroll", update, true); window.removeEventListener("resize", update); window.clearInterval(interval); }; }, [request, effectiveSelectors, effectiveRects]); const onHeaderPointerDown = useCallback( (e: ReactPointerEvent) => { if (e.button !== 0) return; if (e.target instanceof Element && e.target.closest("button, input, textarea, a, select")) return; const banner = bannerRef.current; if (!banner) return; const rect = banner.getBoundingClientRect(); dragStartRef.current = { pointerX: e.clientX, pointerY: e.clientY, originLeft: rect.left, originTop: rect.top, panelW: banner.offsetWidth || PANEL_WIDTH, panelH: banner.offsetHeight || (collapsed ? FALLBACK_PANEL_H_COLLAPSED : FALLBACK_PANEL_H), }; userMovedRef.current = true; setDragPos({ top: rect.top, left: rect.left }); setDragging(true); e.preventDefault(); }, [collapsed], ); useEffect(() => { if (!dragging) return; const onMove = (e: PointerEvent) => { const start = dragStartRef.current; if (!start) return; const dx = e.clientX - start.pointerX; const dy = e.clientY - start.pointerY; setDragPos( clampDragPos(start.originTop + dy, start.originLeft + dx, start.panelW, start.panelH), ); }; const onUp = () => { setDragging(false); dragStartRef.current = null; }; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); window.addEventListener("pointercancel", onUp); return () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); window.removeEventListener("pointercancel", onUp); }; }, [dragging]); // Anchor the panel near matched targets; re-run when highlights or collapse change. useLayoutEffect(() => { if (!request) { setPanelPos(null); return; } if (isCompact) { setPanelPos(null); return; } if (userMovedRef.current) return; const measured = boxes.length > 0 ? boxes : [...measure(effectiveSelectors), ...effectiveRects]; const anchor = unionBox(measured); const banner = bannerRef.current; if (!anchor || !banner) { setPanelPos(null); return; } const rect = banner.getBoundingClientRect(); let panelW = banner.offsetWidth || rect.width; let panelH = banner.offsetHeight || rect.height; if (panelW === 0) { panelW = Math.min(PANEL_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2); } if (panelH === 0) { panelH = collapsed ? FALLBACK_PANEL_H_COLLAPSED : FALLBACK_PANEL_H; } if (placementRef.current?.requestId !== request.id) { placementRef.current = { requestId: request.id, placement: choosePlacement(anchor, panelH), }; } const nextPos = placePanel(anchor, panelW, panelH, placementRef.current.placement); setPanelPos((prev) => (panelPosEqual(prev, nextPos) ? prev : nextPos)); }, [request, boxes, collapsed, isCompact, effectiveSelectors, effectiveRects]); if (!request) return null; return ( <> {!isCompact && boxes.map((b, i) => (
))}
{!isCompact && (
)}
browser-skill {isCompact ? ( {t("helpRequest.compactStatus")} ) : ( {request.title ?? t("helpRequest.title")} )} {!isCompact && (
)} {!isCompact && ( )}
{!isCompact && (

{request.prompt}

setNote(e.target.value)} />
)}
); }