You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
785 lines
26 KiB
785 lines
26 KiB
"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<HoveredTargetState | null>(null);
|
|
const [isOpening, setIsOpening] = useState(false);
|
|
const [customDepthIndex, setCustomDepthIndex] = useState<number | null>(null);
|
|
|
|
const currentElementRef = useRef<Element | null>(null);
|
|
const currentHierarchyRef = useRef<Element[]>([]);
|
|
const currentDepthIndexRef = useRef<number | null>(null);
|
|
const lastPointerPos = useRef<{ x: number; y: number } | null>(null);
|
|
const lastHoveredLeafRef = useRef<Element | null>(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<HTMLElement>("[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<HTMLButtonElement>) => {
|
|
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<HTMLButtonElement>) => {
|
|
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<HTMLButtonElement>) => {
|
|
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 && (
|
|
<div
|
|
style={{
|
|
position: "fixed",
|
|
top: `${hoveredTarget.rect.top}px`,
|
|
left: `${hoveredTarget.rect.left}px`,
|
|
width: `${hoveredTarget.rect.width}px`,
|
|
height: `${hoveredTarget.rect.height}px`,
|
|
borderRadius: hoveredTarget.borderRadius || "4px",
|
|
border: isOpening ? "2px solid #ffffff" : `2px solid ${accentColor}`,
|
|
backgroundColor: isOpening ? "rgba(255, 255, 255, 0.2)" : accentBg,
|
|
boxShadow: isOpening
|
|
? `0 0 22px ${accentColor}, inset 0 0 12px ${accentColor}`
|
|
: accentShadow,
|
|
pointerEvents: "none",
|
|
zIndex: 999999,
|
|
transition:
|
|
"top 0.05s ease-out, left 0.05s ease-out, width 0.05s ease-out, height 0.05s ease-out, border 0.12s, background-color 0.12s, box-shadow 0.12s",
|
|
}}
|
|
>
|
|
{/* Smart Component Info Tag */}
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
top: hoveredTarget.rect.top < 34 ? "6px" : "-32px",
|
|
left: Math.max(0, -hoveredTarget.rect.left),
|
|
display: "inline-flex",
|
|
flexDirection: "column",
|
|
gap: "3px",
|
|
backgroundColor: "#020d09",
|
|
color: accentColor,
|
|
fontSize: "11px",
|
|
fontWeight: 600,
|
|
fontFamily:
|
|
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
|
padding: "4px 8px",
|
|
borderRadius: "6px",
|
|
whiteSpace: "nowrap",
|
|
boxShadow: "0 6px 18px rgba(0,0,0,0.75)",
|
|
border: `1px solid ${accentColor}88`,
|
|
pointerEvents: "none",
|
|
}}
|
|
>
|
|
{/* Top row: Mode + Tag Name + File Location */}
|
|
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
|
<span
|
|
style={{
|
|
backgroundColor: `${accentColor}33`,
|
|
color: accentColor,
|
|
padding: "1px 6px",
|
|
borderRadius: "3px",
|
|
fontSize: "10px",
|
|
fontWeight: 700,
|
|
letterSpacing: "0.04em",
|
|
}}
|
|
>
|
|
{modeBadgeLabel}
|
|
</span>
|
|
|
|
<span style={{ color: "#ffffff", opacity: 0.85, fontWeight: 700 }}>
|
|
<{hoveredTarget.tagName}>
|
|
</span>
|
|
<span style={{ color: accentColor }}>
|
|
{formatLocatorLabel(hoveredTarget.locator)}
|
|
</span>
|
|
|
|
{isOpening && (
|
|
<span style={{ color: "#ffffff", fontWeight: 700 }}>
|
|
⚡ Opening IDE...
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bottom row: Breadcrumb Hierarchy Trail & Interactive Shortcut Hints */}
|
|
{!isOpening && hoveredTarget.totalLevels > 1 && (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "4px",
|
|
fontSize: "9.5px",
|
|
color: "#94a3b8",
|
|
paddingTop: "2px",
|
|
borderTop: "1px solid rgba(255,255,255,0.08)",
|
|
}}
|
|
>
|
|
{/* Visual breadcrumbs */}
|
|
<span style={{ opacity: 0.7 }}>Hierarchy:</span>
|
|
{hoveredTarget.hierarchyTags.map((tag, idx) => (
|
|
<span
|
|
key={idx}
|
|
style={{
|
|
color:
|
|
idx === hoveredTarget.levelIndex
|
|
? accentColor
|
|
: "rgba(255,255,255,0.45)",
|
|
fontWeight: idx === hoveredTarget.levelIndex ? 700 : 400,
|
|
textDecoration:
|
|
idx === hoveredTarget.levelIndex ? "underline" : "none",
|
|
}}
|
|
>
|
|
{tag}
|
|
{idx < hoveredTarget.hierarchyTags.length - 1 && " › "}
|
|
</span>
|
|
))}
|
|
|
|
{/* Shortcut Guide */}
|
|
<span
|
|
style={{
|
|
marginLeft: "8px",
|
|
color: "rgba(255,255,255,0.5)",
|
|
fontSize: "9px",
|
|
}}
|
|
>
|
|
(Scroll / ↑↓ / [ ]: Level | Ctrl: Inner | Shift: Parent)
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Floating Action Draggable Button (Inspect Mode for Mobile/Tablet) */}
|
|
{isMobile && (
|
|
<div
|
|
style={{
|
|
position: "fixed",
|
|
left: position ? `${position.x}px` : "auto",
|
|
top: position ? `${position.y}px` : "auto",
|
|
bottom: position ? "auto" : "16px",
|
|
right: position ? "auto" : "16px",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "10px",
|
|
zIndex: 99998,
|
|
touchAction: "none",
|
|
}}
|
|
>
|
|
<button
|
|
className="dev-inspect-btn"
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMoveButton}
|
|
onPointerUp={handlePointerUpButton}
|
|
onClick={handleToggleInspect}
|
|
style={{
|
|
width: "50px",
|
|
height: "50px",
|
|
borderRadius: "25px",
|
|
backgroundColor: isInspecting ? "#38bdf8" : "#020d09",
|
|
color: isInspecting ? "#020d09" : "#ffffff",
|
|
border: isInspecting
|
|
? "2px solid #38bdf8"
|
|
: "1px solid rgba(255,255,255,0.15)",
|
|
boxShadow: isInspecting
|
|
? "0 0 16px rgba(56, 189, 248, 0.7)"
|
|
: "0 4px 14px rgba(0,0,0,0.4)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
cursor: isDragging ? "grabbing" : "pointer",
|
|
transition: isDragging
|
|
? "none"
|
|
: "background-color 0.25s, transform 0.25s, box-shadow 0.25s",
|
|
outline: "none",
|
|
transform: isInspecting ? "scale(1.1)" : "scale(1)",
|
|
}}
|
|
title="Toggle Dev Inspect Mode (Drag to move, tap to activate)"
|
|
>
|
|
{isInspecting ? (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
strokeWidth={2.5}
|
|
stroke="currentColor"
|
|
style={{ width: "22px", height: "22px", pointerEvents: "none" }}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M6 18 18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
) : (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
strokeWidth={2}
|
|
stroke="currentColor"
|
|
style={{ width: "22px", height: "22px", pointerEvents: "none" }}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="m15.75 15.75-2.489-2.489m0 0a3.375 3.375 0 1 0-4.773-4.773 3.375 3.375 0 0 0 4.774 4.774ZM21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default DevClickToComponent;
|