import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { clsx } from 'clsx'; import './inspect-overlay.css'; import { AxElement, AxPlatform, AxSnapshot, axElementRoleLabel, axElementSelectorExpression, axElementSummary, axElementsEqual, clampAxFrameForScreen, } from '../core/ax-tree'; // Geometry of the rendered video content inside the RemoteControl container, // after object-fit:contain letterboxing. All values are in container-local // pixels (relative to .rc-container's content box) so the overlay boxes line // up with the video. // // The InfoCard uses pointer-event coordinates (clientX/clientY) directly for // its viewport-fixed placement and does NOT need any geometry — the boxes // are the only thing geometry-locked. export interface InspectOverlayGeometry { left: number; top: number; width: number; height: number; } export type InspectMode = 'select' | 'hover-only'; export interface InspectOverlayProps { snapshot: AxSnapshot | null; geometry: InspectOverlayGeometry | null; highlightedId: string | null; selectedId: string | null; mode: InspectMode; // Current pointer position in viewport coordinates (clientX/Y). Drives the // cursor-anchored preview card while hovering. null when the pointer is // outside the device. cursorPosition: { x: number; y: number } | null; // Position where the selection was made (viewport coords). The card stays // anchored here once an element is selected so the user can interact with // its action buttons. frozenCursorPosition: { x: number; y: number } | null; // Selection callback. `clickPosition` is the viewport-space pointer // position at the moment of the click; pass null to clear selection. onSelectChange: (element: AxElement | null, clickPosition?: { x: number; y: number } | null) => void; // Called when the user presses "Tap" in the info card. `tapAt` is the // viewport-space pointer position the user originally aimed at (i.e. the // frozen click position) so we can tap that exact spot instead of an // averaged center. onTapElement: (element: AxElement, tapAt: { x: number; y: number }) => void; } const copyToClipboard = async (text: string): Promise => { if (!text) return false; try { if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; } } catch { // Fall through to the textarea fallback. } try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); document.body.removeChild(ta); return ok; } catch { return false; } }; // ──────────────────────────────────────────────────────────────────────────── // Single element box // ──────────────────────────────────────────────────────────────────────────── interface InspectBoxProps { element: AxElement; screen: { width: number; height: number }; highlighted: boolean; selected: boolean; selectable: boolean; onClick: (element: AxElement, clickPosition: { x: number; y: number }) => void; } const InspectBox = memo( function InspectBox({ element, screen, highlighted, selected, selectable, onClick }: InspectBoxProps) { const visible = clampAxFrameForScreen(element.frame, screen); if (!visible) return null; const summary = axElementSummary(element); return ( )} , document.body, ); }); // ──────────────────────────────────────────────────────────────────────────── // Overlay root // ──────────────────────────────────────────────────────────────────────────── export const InspectOverlay = memo(function InspectOverlay({ snapshot, geometry, highlightedId, selectedId, mode, cursorPosition, frozenCursorPosition, onSelectChange, onTapElement, }: InspectOverlayProps) { const selectable = mode === 'select'; // Card behaviour: // - In `hover-only` mode there is no selection. The card always follows // the cursor and only renders when something is highlighted. // - In `select` mode the card follows the cursor whenever the user is // hovering an element OTHER than the currently selected one (preview). // Hovering the selected element or moving the cursor off any box keeps // the card frozen at the click position so the action buttons remain // reachable. const isPreviewingHover = selectable ? highlightedId !== null && highlightedId !== selectedId : highlightedId !== null; const focusId = selectable ? highlightedId ?? selectedId : highlightedId; // Build an `id → element` index once per snapshot so focus lookups are O(1) // even on max-size trees. Hooks must run unconditionally — gate rendering // afterwards via the `renderable` check. const elementsById = useMemo(() => { const m = new Map(); if (!snapshot) return m; for (const el of snapshot.elements) m.set(el.id, el); return m; }, [snapshot]); const focusElement = useMemo(() => { if (!focusId) return null; return elementsById.get(focusId) ?? null; }, [focusId, elementsById]); // The overlay has nothing to draw until both a snapshot and the device // geometry are available, and the snapshot has at least one usable // element. Conditional rendering goes here — AFTER all hooks — so the // hook-count is stable across the snapshot-arrives-after-mount transition. const renderable = !!snapshot && !!geometry && snapshot.screen.width > 0 && snapshot.screen.height > 0 && snapshot.elements.length > 0; if (!renderable) return null; const anchor = isPreviewingHover ? cursorPosition : frozenCursorPosition ?? cursorPosition; const cursorAnchored = isPreviewingHover; const showActions = selectable && !isPreviewingHover && selectedId !== null; const handleContainerClick = (e: React.MouseEvent) => { if (!selectable) return; // Click was on the container itself (not a box) — clear selection. if (e.target === e.currentTarget) { onSelectChange(null, null); } }; return ( <>
{snapshot!.elements.map((element) => ( ))}
{focusElement && anchor && ( )} ); });