"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: Element | null): Element[] { const list: Element[] = []; let curr: Element | 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 leaf, ..., N-1: outermost container] } export type HierarchyMode = "OUTER" | "PARENT" | "INNER" | "LEVEL"; interface HierarchyTarget { element: Element; index: number; total: number; mode: HierarchyMode; } function selectTargetFromHierarchy( hierarchy: Element[], baseMode: "OUTER" | "PARENT" | "INNER", customIndex: number | null ): HierarchyTarget | null { if (hierarchy.length === 0) return null; const total = hierarchy.length; if (customIndex !== null && customIndex >= 0 && customIndex < total) { let mode: HierarchyMode = "LEVEL"; if (customIndex === 0) mode = "INNER"; else if (customIndex === total - 1) mode = "OUTER"; else if (customIndex === 1) mode = "PARENT"; return { element: hierarchy[customIndex], index: customIndex, total, mode, }; } if (baseMode === "INNER" || total === 1) { return { element: hierarchy[0], index: 0, total, mode: "INNER", }; } if (baseMode === "PARENT") { const parentIdx = Math.min(1, total - 1); return { element: hierarchy[parentIdx], index: parentIdx, total, mode: "PARENT", }; } // Outer Mode: find outermost component container, filtering out root page/layout if deeper elements exist const nonRootIndices: number[] = []; hierarchy.forEach((el, idx) => { const loc = el.getAttribute("data-locator") || ""; if (!loc.includes("app/layout.") && !loc.includes("app/page.")) { nonRootIndices.push(idx); } }); const outerIdx = nonRootIndices.length > 0 ? nonRootIndices[nonRootIndices.length - 1] : total - 1; return { element: hierarchy[outerIdx], index: outerIdx, total, mode: "OUTER", }; } interface HoveredTargetState { rect: DOMRect; locator: string; borderRadius: string; tagName: string; mode: HierarchyMode; levelIndex: number; totalLevels: number; hierarchyTags: string[]; } 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 [customDepthIndex, setCustomDepthIndex] = useState(null); const currentElementRef = useRef(null); const currentHierarchyRef = useRef([]); const currentDepthIndexRef = useRef(null); const lastPointerPos = useRef<{ x: number; y: number } | null>(null); const lastHoveredLeafRef = useRef(null); const dragStart = useRef({ x: 0, y: 0, buttonX: 0, buttonY: 0, hasMoved: false }); // Keep ref synced useEffect(() => { currentDepthIndexRef.current = customDepthIndex; }, [customDepthIndex]); // 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+Shift (Parent) vs Alt+Ctrl (Inner) vs Scroll Wheel / Arrow depth stepping useEffect(() => { if (process.env.NODE_ENV !== "development") { return; } const updateHoverTarget = ( el: Element | null, baseMode: "OUTER" | "PARENT" | "INNER", overrideDepthIndex?: number | null ) => { const hierarchy = getHierarchy(el); currentHierarchyRef.current = hierarchy; const leafEl = hierarchy[0] || null; let depthIdx = overrideDepthIndex !== undefined ? overrideDepthIndex : currentDepthIndexRef.current; // If moved to a completely different leaf element, reset custom depth index if (leafEl !== lastHoveredLeafRef.current) { lastHoveredLeafRef.current = leafEl; if (overrideDepthIndex === undefined) { depthIdx = null; setCustomDepthIndex(null); } } const target = selectTargetFromHierarchy(hierarchy, baseMode, depthIdx); if (target) { currentElementRef.current = target.element; const rect = target.element.getBoundingClientRect(); const computed = window.getComputedStyle(target.element); setHoveredTarget({ rect, locator: target.element.getAttribute("data-locator") || "", borderRadius: computed.borderRadius, tagName: target.element.tagName.toLowerCase(), mode: target.mode, levelIndex: target.index, totalLevels: target.total, hierarchyTags: hierarchy.map((h) => h.tagName.toLowerCase()), }); } else { currentElementRef.current = null; setHoveredTarget(null); } }; const determineBaseMode = (e: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }): "OUTER" | "PARENT" | "INNER" => { if (e.ctrlKey || e.metaKey) return "INNER"; if (e.shiftKey) return "PARENT"; return "OUTER"; }; const handlePointerMove = (e: PointerEvent) => { lastPointerPos.current = { x: e.clientX, y: e.clientY }; if (e.altKey) { const target = e.target instanceof Element ? e.target : null; const mode = determineBaseMode(e); updateHoverTarget(target, mode); } else if (currentElementRef.current) { currentElementRef.current = null; setHoveredTarget(null); setCustomDepthIndex(null); } }; const handleKeyDown = (e: KeyboardEvent) => { const isAlt = e.altKey || e.key === "Alt"; if (!isAlt || !lastPointerPos.current) return; const hierarchy = currentHierarchyRef.current; const total = hierarchy.length; // Level Stepping: ArrowDown or '[' steps deeper (towards inner leaf 0) if (e.key === "ArrowDown" || e.key === "[") { e.preventDefault(); if (total > 0) { const current = currentDepthIndexRef.current ?? (e.shiftKey ? 1 : total - 1); const next = Math.max(0, current - 1); setCustomDepthIndex(next); const el = document.elementFromPoint(lastPointerPos.current.x, lastPointerPos.current.y); updateHoverTarget(el, "LEVEL" as any, next); } return; } // Level Stepping: ArrowUp or ']' steps outwards (towards outer container N-1) if (e.key === "ArrowUp" || e.key === "]") { e.preventDefault(); if (total > 0) { const current = currentDepthIndexRef.current ?? (e.shiftKey ? 1 : 0); const next = Math.min(total - 1, current + 1); setCustomDepthIndex(next); const el = document.elementFromPoint(lastPointerPos.current.x, lastPointerPos.current.y); updateHoverTarget(el, "LEVEL" as any, next); } return; } const mode = determineBaseMode({ ctrlKey: e.ctrlKey || e.key === "Control", metaKey: e.metaKey || e.key === "Meta", shiftKey: e.shiftKey || e.key === "Shift", }); const el = document.elementFromPoint( lastPointerPos.current.x, lastPointerPos.current.y ); updateHoverTarget(el, mode, null); }; const handleKeyUp = (e: KeyboardEvent) => { if (e.key === "Alt" || !e.altKey) { currentElementRef.current = null; setHoveredTarget(null); setCustomDepthIndex(null); } else if (e.altKey && lastPointerPos.current) { // Modifier key released while keeping Alt pressed -> update target accordingly const mode = determineBaseMode(e); const el = document.elementFromPoint( lastPointerPos.current.x, lastPointerPos.current.y ); updateHoverTarget(el, mode, null); } }; const handleWheel = (e: WheelEvent) => { if (!e.altKey || !currentHierarchyRef.current.length) return; e.preventDefault(); e.stopPropagation(); const total = currentHierarchyRef.current.length; if (total <= 1) return; // Scroll Down (deltaY > 0) -> Step Deeper / Inner (towards index 0) // Scroll Up (deltaY < 0) -> Step Outer (towards index total - 1) const current = currentDepthIndexRef.current ?? (hoveredTarget?.levelIndex ?? (e.ctrlKey || e.metaKey ? 0 : total - 1)); const delta = e.deltaY > 0 ? -1 : 1; const next = Math.max(0, Math.min(total - 1, current + delta)); setCustomDepthIndex(next); if (lastPointerPos.current) { const el = document.elementFromPoint( lastPointerPos.current.x, lastPointerPos.current.y ); updateHoverTarget(el, "LEVEL" as any, next); } }; const handleWindowBlur = () => { currentElementRef.current = null; setHoveredTarget(null); setCustomDepthIndex(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 Element ? event.target : null; const hierarchy = getHierarchy(target); const mode = determineBaseMode(event); const selected = selectTargetFromHierarchy( hierarchy, mode, currentDepthIndexRef.current ); const locator = selected?.element?.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("wheel", handleWheel, { capture: true, passive: false }); 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("wheel", handleWheel, 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, hoveredTarget?.levelIndex]); // 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 hierarchy = getHierarchy(target); // On mobile tap, choose innermost leaf by default or first available element const selectedEl = hierarchy[0] || target.closest("[data-locator]"); const locator = selectedEl?.getAttribute("data-locator"); if (locator && selectedEl) { console.log(`[DevClickToComponent] Mobile inspect matched element. Opening in IDE:`, locator); if (selectedEl instanceof HTMLElement) { const originalOutline = selectedEl.style.outline; selectedEl.style.outline = "3px solid #38bdf8"; setTimeout(() => { selectedEl.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); } }; // Theme colors for different modes // INNER: Cyan (#38bdf8), PARENT / LEVEL: Amber (#fbbf24) / Purple (#a78bfa), OUTER: Emerald (#2dd9a4) let accentColor = "#2dd9a4"; let accentBg = "rgba(45, 217, 164, 0.08)"; let accentShadow = "0 0 14px rgba(45, 217, 164, 0.45), inset 0 0 6px rgba(45, 217, 164, 0.15)"; let modeBadgeLabel = "OUTER"; if (hoveredTarget?.mode === "INNER") { accentColor = "#38bdf8"; accentBg = "rgba(56, 189, 248, 0.08)"; accentShadow = "0 0 14px rgba(56, 189, 248, 0.5), inset 0 0 6px rgba(56, 189, 248, 0.2)"; modeBadgeLabel = "INNER LEAF"; } else if (hoveredTarget?.mode === "PARENT") { accentColor = "#a78bfa"; accentBg = "rgba(167, 139, 250, 0.08)"; accentShadow = "0 0 14px rgba(167, 139, 250, 0.45), inset 0 0 6px rgba(167, 139, 250, 0.15)"; modeBadgeLabel = "PARENT"; } else if (hoveredTarget?.mode === "LEVEL") { accentColor = "#fbbf24"; accentBg = "rgba(251, 191, 36, 0.08)"; accentShadow = "0 0 14px rgba(251, 191, 36, 0.45), inset 0 0 6px rgba(251, 191, 36, 0.15)"; modeBadgeLabel = `DEPTH ${hoveredTarget.levelIndex + 1}/${hoveredTarget.totalLevels}`; } if (process.env.NODE_ENV !== "development") { return null; } return ( <> {/* Component Bounding Box Highlight Overlay */} {hoveredTarget && (
{/* Smart Component Info Tag */}
{/* Top row: Mode + Tag Name + File Location */}
{modeBadgeLabel} <{hoveredTarget.tagName}> {formatLocatorLabel(hoveredTarget.locator)} {isOpening && ( ⚡ Opening IDE... )}
{/* Bottom row: Breadcrumb Hierarchy Trail & Interactive Shortcut Hints */} {!isOpening && hoveredTarget.totalLevels > 1 && (
{/* Visual breadcrumbs */} Hierarchy: {hoveredTarget.hierarchyTags.map((tag, idx) => ( {tag} {idx < hoveredTarget.hierarchyTags.length - 1 && " › "} ))} {/* Shortcut Guide */} (Scroll / ↑↓ / [ ]: Level | Ctrl: Inner | Shift: Parent)
)}
)} {/* Floating Action Draggable Button (Inspect Mode for Mobile/Tablet) */} {isMobile && (
)} ); } export default DevClickToComponent;