"use client"; import { useEffect, useState, useRef, useCallback } from "react"; const IDE_SCHEMES = [ { matches: ["antigravity"], createUrl: (locator: string) => `antigravity://file/${locator}`, }, { matches: ["cursor"], createUrl: (locator: string) => `cursor://file/${locator}`, }, { matches: ["vscode", "code"], createUrl: (locator: string) => `vscode://file/${locator}`, }, { matches: ["webstorm", "intellij"], createUrl: (locator: string) => `webstorm://open?file=${locator}`, }, { matches: ["sublime"], createUrl: (locator: string) => `subl://open?url=file://${locator}`, }, { matches: ["atom", "nova"], createUrl: (locator: string) => `atom://open?url=file://${locator}`, }, ] as const; function parseLocator(locator: string) { const match = locator.match(/^(.*):(\d+|unknown):(\d+|unknown)$/); if (!match) { return { filePath: locator, line: null, column: null }; } const [, filePath, line, column] = match; return { filePath, line: line === "unknown" ? null : Number(line), column: column === "unknown" ? null : Number(column), }; } function formatLocatorLabel(locator: string) { const { filePath, line } = parseLocator(locator); const parts = filePath.split(/[/\\]/); const shortPath = parts.slice(-2).join("/"); const lineInfo = line !== null ? `:${line}` : ""; return `${shortPath}${lineInfo}`; } function getHierarchy(startEl: HTMLElement | null): HTMLElement[] { const list: HTMLElement[] = []; let curr: HTMLElement | null = startEl; while (curr && curr !== document.body && curr !== document.documentElement) { if (curr.hasAttribute("data-locator")) { list.push(curr); } curr = curr.parentElement; } return list; // [0: innermost, ..., N-1: outermost] } function selectTargetElement( hierarchy: HTMLElement[], isInnerMode: boolean ): HTMLElement | null { if (hierarchy.length === 0) return null; if (hierarchy.length === 1) return hierarchy[0]; if (isInnerMode) { // Inner Mode (Alt + Ctrl): target the deep inner leaf element return hierarchy[0]; } // Outer Mode (Alt): target the outermost component/section container const nonRootPage = hierarchy.filter((el) => { const loc = el.getAttribute("data-locator") || ""; return !loc.includes("app/layout.") && !loc.includes("app/page."); }); if (nonRootPage.length > 0) { return nonRootPage[nonRootPage.length - 1]; } return hierarchy[hierarchy.length - 1]; } interface HoveredTargetState { rect: DOMRect; locator: string; borderRadius: string; tagName: string; isInner: boolean; hasMultipleLevels: boolean; } export function DevClickToComponent() { const [isInspecting, setIsInspecting] = useState(false); const [isMobile, setIsMobile] = useState(false); const [position, setPosition] = useState<{ x: number; y: number } | null>(null); const [isDragging, setIsDragging] = useState(false); const [hoveredTarget, setHoveredTarget] = useState(null); const [isOpening, setIsOpening] = useState(false); const currentElementRef = useRef(null); const lastPointerPos = useRef<{ x: number; y: number } | null>(null); const dragStart = useRef({ x: 0, y: 0, buttonX: 0, buttonY: 0, hasMoved: false }); // Detect mobile / small screen size useEffect(() => { const checkSize = () => { setIsMobile(window.innerWidth <= 768); }; checkSize(); window.addEventListener("resize", checkSize); return () => window.removeEventListener("resize", checkSize); }, []); // Keep button in bounds on resize useEffect(() => { const handleResize = () => { if (!isMobile) return; setPosition((prev) => { if (!prev) return null; return { x: Math.max(16, Math.min(window.innerWidth - 66, prev.x)), y: Math.max(16, Math.min(window.innerHeight - 66, prev.y)), }; }); }; window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, [isMobile]); const openInIde = useCallback(async (locator: string) => { const userAgent = navigator.userAgent.toLowerCase(); const { filePath, line, column } = parseLocator(locator); const positionSuffix = line === null ? "" : `:${line}${column === null ? "" : `:${column}`}`; const locatorString = `${filePath}${positionSuffix}`; setIsOpening(true); setTimeout(() => setIsOpening(false), 600); try { await fetch("/api/open-in-ide", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ locator: locatorString, userAgent }), }); } catch (err) { console.error("[DevClickToComponent] Error sending API request:", err); // Fallback to URL scheme const ideUrl = IDE_SCHEMES.find(({ matches }) => matches.some((match) => userAgent.includes(match)) )?.createUrl(locatorString) ?? `antigravity://file/${locatorString}`; try { window.location.href = ideUrl; } catch { window.open(`file://${filePath}`, "_blank", "noopener,noreferrer"); } } }, []); // Desktop: Alt (Outer) vs Alt + Ctrl (Inner) inspection & Click handler useEffect(() => { if (process.env.NODE_ENV !== "development") { return; } const updateHoverTarget = (el: HTMLElement | null, isInnerMode: boolean) => { const hierarchy = getHierarchy(el); const targetEl = selectTargetElement(hierarchy, isInnerMode); if (targetEl) { currentElementRef.current = targetEl; const rect = targetEl.getBoundingClientRect(); const computed = window.getComputedStyle(targetEl); setHoveredTarget({ rect, locator: targetEl.getAttribute("data-locator") || "", borderRadius: computed.borderRadius, tagName: targetEl.tagName.toLowerCase(), isInner: isInnerMode || hierarchy.length === 1, hasMultipleLevels: hierarchy.length > 1, }); } else { currentElementRef.current = null; setHoveredTarget(null); } }; const handlePointerMove = (e: PointerEvent) => { lastPointerPos.current = { x: e.clientX, y: e.clientY }; if (e.altKey) { const target = e.target instanceof HTMLElement ? e.target : null; const isInnerMode = Boolean(e.ctrlKey || e.metaKey); updateHoverTarget(target, isInnerMode); } else if (currentElementRef.current) { currentElementRef.current = null; setHoveredTarget(null); } }; const handleKeyDown = (e: KeyboardEvent) => { const isAlt = e.altKey || e.key === "Alt"; const isInnerMode = Boolean( e.ctrlKey || e.metaKey || e.key === "Control" || e.key === "Meta" ); if (isAlt && lastPointerPos.current) { const el = document.elementFromPoint( lastPointerPos.current.x, lastPointerPos.current.y ) as HTMLElement | null; updateHoverTarget(el, isInnerMode); } }; const handleKeyUp = (e: KeyboardEvent) => { if (e.key === "Alt" || !e.altKey) { currentElementRef.current = null; setHoveredTarget(null); } else if (e.altKey && (e.key === "Control" || e.key === "Meta")) { // Released Ctrl while keeping Alt pressed -> switch back to Outer Mode if (lastPointerPos.current) { const el = document.elementFromPoint( lastPointerPos.current.x, lastPointerPos.current.y ) as HTMLElement | null; updateHoverTarget(el, false); } } }; const handleWindowBlur = () => { currentElementRef.current = null; setHoveredTarget(null); }; const handleScrollOrResize = () => { if (currentElementRef.current) { const rect = currentElementRef.current.getBoundingClientRect(); const computed = window.getComputedStyle(currentElementRef.current); setHoveredTarget((prev) => prev ? { ...prev, rect, borderRadius: computed.borderRadius, } : null ); } }; const handleDesktopClick = (event: MouseEvent) => { if (!event.altKey) { return; } const target = event.target instanceof HTMLElement ? event.target : null; const isInnerMode = Boolean(event.ctrlKey || event.metaKey); const hierarchy = getHierarchy(target); const targetEl = selectTargetElement(hierarchy, isInnerMode); const locator = targetEl?.getAttribute("data-locator"); if (!locator) { return; } event.preventDefault(); event.stopPropagation(); openInIde(locator); }; window.addEventListener("pointermove", handlePointerMove, true); window.addEventListener("keydown", handleKeyDown, true); window.addEventListener("keyup", handleKeyUp, true); window.addEventListener("blur", handleWindowBlur); document.addEventListener("mouseleave", handleWindowBlur); window.addEventListener("scroll", handleScrollOrResize, true); window.addEventListener("resize", handleScrollOrResize, true); document.addEventListener("click", handleDesktopClick, true); return () => { window.removeEventListener("pointermove", handlePointerMove, true); window.removeEventListener("keydown", handleKeyDown, true); window.removeEventListener("keyup", handleKeyUp, true); window.removeEventListener("blur", handleWindowBlur); document.removeEventListener("mouseleave", handleWindowBlur); window.removeEventListener("scroll", handleScrollOrResize, true); window.removeEventListener("resize", handleScrollOrResize, true); document.removeEventListener("click", handleDesktopClick, true); }; }, [openInIde]); // Mobile: Inspect Mode click interception useEffect(() => { if (process.env.NODE_ENV !== "development" || !isMobile || !isInspecting) return; const handleInspectClick = async (event: MouseEvent) => { const target = event.target; if (!(target instanceof Element)) return; // Ignore clicks on the inspect button itself if (target.closest(".dev-inspect-btn")) { return; } event.preventDefault(); event.stopPropagation(); const element = target.closest("[data-locator]"); const locator = element?.getAttribute("data-locator"); if (locator && element) { console.log(`[DevClickToComponent] Inspect Mode matched element. Opening in IDE:`, locator); // Flash target element outline briefly as visual feedback const originalOutline = element.style.outline; element.style.outline = "3px solid #2dd9a4"; setTimeout(() => { element.style.outline = originalOutline; }, 400); openInIde(locator); } setIsInspecting(false); }; document.addEventListener("click", handleInspectClick, true); return () => { document.removeEventListener("click", handleInspectClick, true); }; }, [isMobile, isInspecting, openInIde]); // Dragging event handlers for mobile floating button const handlePointerDown = (e: React.PointerEvent) => { e.currentTarget.setPointerCapture(e.pointerId); setIsDragging(true); const startX = position?.x ?? window.innerWidth - 66; const startY = position?.y ?? window.innerHeight - 66; dragStart.current = { x: e.clientX, y: e.clientY, buttonX: startX, buttonY: startY, hasMoved: false, }; }; const handlePointerMoveButton = (e: React.PointerEvent) => { if (!isDragging) return; const dx = e.clientX - dragStart.current.x; const dy = e.clientY - dragStart.current.y; if (Math.abs(dx) > 5 || Math.abs(dy) > 5) { dragStart.current.hasMoved = true; } const newX = Math.max(16, Math.min(window.innerWidth - 66, dragStart.current.buttonX + dx)); const newY = Math.max(16, Math.min(window.innerHeight - 66, dragStart.current.buttonY + dy)); setPosition({ x: newX, y: newY }); }; const handlePointerUpButton = (e: React.PointerEvent) => { if (!isDragging) return; e.currentTarget.releasePointerCapture(e.pointerId); setIsDragging(false); }; const handleToggleInspect = () => { if (!dragStart.current.hasMoved) { setIsInspecting(!isInspecting); } }; // Color scheme: Emerald (#2dd9a4) for Outer, Cyan (#38bdf8) for Inner const accentColor = hoveredTarget?.isInner ? "#38bdf8" : "#2dd9a4"; const accentBg = hoveredTarget?.isInner ? "rgba(56, 189, 248, 0.08)" : "rgba(45, 217, 164, 0.08)"; const accentShadow = hoveredTarget?.isInner ? "0 0 14px rgba(56, 189, 248, 0.5), inset 0 0 6px rgba(56, 189, 248, 0.2)" : "0 0 14px rgba(45, 217, 164, 0.45), inset 0 0 6px rgba(45, 217, 164, 0.15)"; if (process.env.NODE_ENV !== "development") { return null; } return ( <> {/* Alt (Outer) / Alt + Ctrl (Inner) Highlight Overlay Box */} {hoveredTarget && (
{/* Component Info Tag */}
{/* Outer / Inner Mode Pill */} {hoveredTarget.isInner ? "INNER" : "OUTER"} <{hoveredTarget.tagName}> {formatLocatorLabel(hoveredTarget.locator)} {isOpening ? "⚡ Opening IDE..." : hoveredTarget.hasMultipleLevels ? hoveredTarget.isInner ? "⌥+Click | Release Ctrl for Outer" : "⌥+Click | Hold Ctrl for Inner" : "⌥+Click to open"}
)} {/* Floating Action Draggable Button (Inspect Mode for Mobile/Tablet) */} {isMobile && (
)} ); } export default DevClickToComponent;