Browse Source

feat: initial commit for standalone click-to-component tool

master
sina_sajjadi 2 weeks ago
commit
e340d55f7e
  1. 10
      .babelrc.example
  2. 221
      README.md
  3. 40
      add-data-locator.cjs
  4. 577
      client/dev-click-to-component.tsx
  5. 20
      package.json
  6. 221
      read.md
  7. 67
      server/route.ts

10
.babelrc.example

@ -0,0 +1,10 @@
{
"presets": ["next/babel"],
"env": {
"development": {
"plugins": [
"./add-data-locator.cjs"
]
}
}
}

221
README.md

@ -0,0 +1,221 @@
# ⚡ Click-to-Component
A zero-config, high-precision component inspector and **Click-to-Component** developer tool for Next.js and React applications.
Hover over any UI element on your web page while holding <kbd>Alt</kbd> (or <kbd>Option</kbd>) to highlight component boundaries in real time, and **click to instantly jump to the exact file, line, and column in your IDE** (Antigravity IDE, VS Code, Cursor, WebStorm, Sublime Text, etc.).
---
## ✨ Features
- 🎯 **Direct IDE Jump**: Opens your active IDE and focuses the exact file and cursor position (`file.tsx:line:col`).
- 🔍 **Live Hover Highlighting**: Holding <kbd>Alt</kbd> outlines the component under your mouse with dynamic bounding box tracking and smooth scroll support.
- 🔀 **Dual Hierarchy Modes**:
- **Outer Component Mode (<kbd>Alt</kbd>)**: Selects the outer component or section container.
- **Inner Leaf Mode (<kbd>Alt</kbd> + <kbd>Ctrl</kbd> / <kbd>Cmd</kbd>)**: Drills down directly to the exact innermost child element (e.g. `<svg>`, `<button>`, `<span>`).
- **Live Dynamic Toggle**: Press or release <kbd>Ctrl</kbd> while hovering to switch between Outer and Inner mode in real time!
- 🏷️ **Smart Component Badge**: Displays the JSX tag name (e.g. `<section>`, `<button>`), short relative file path, line number, and active inspection mode pill (`OUTER` / `INNER`).
- 📱 **Mobile & Tablet Ready**: Floating draggable inspector button with tap-to-inspect on touch devices.
- 🛡️ **Zero Production Overhead**: Completely disabled, tree-shaken, and stripped out in production builds.
---
## 🏗️ Architecture & How It Works
The system operates across three interconnected layers:
```mermaid
flowchart TD
subgraph BuildTime["1. Build-Time AST Transform"]
A[Babel / Compiler] -->|add-data-locator.cjs| B[Injects data-locator attribute on JSX elements]
end
subgraph ClientSide["2. Client Runtime"]
B --> C[DevClickToComponent]
C -->|Alt + Hover| D[Draws Emerald Bounding Box for Outer Component]
C -->|Alt + Ctrl + Hover| E[Draws Cyan Bounding Box for Inner Element]
C -->|Alt + Click| F[Sends locator to API]
end
subgraph ServerSide["3. Local Server API"]
F -->|POST /api/open-in-ide| G[route.ts]
G -->|CLI Command Execution| H[antigravity-ide / code -r -g filepath:line:col]
H --> I[IDE Opens & Focuses Line]
end
```
---
## 📁 Repository Structure
```
click-to-component/
├── add-data-locator.cjs # Babel AST plugin to inject data-locator attributes
├── .babelrc.example # Babel configuration example
├── client/
│ └── dev-click-to-component.tsx # React Client Component (Inspector UI & Listeners)
├── server/
│ └── route.ts # Next.js App Router API Route (/api/open-in-ide)
├── package.json # Standalone package metadata
└── README.md # Documentation
```
---
## 🚀 Quick Setup & Installation
### Step 1: Install Babel Plugin
Copy `add-data-locator.cjs` to your project root.
Create or update `.babelrc` in your project root:
```json
{
"presets": ["next/babel"],
"env": {
"development": {
"plugins": [
"./add-data-locator.cjs"
]
}
}
}
```
> **Note**: Scoping the plugin inside `"env": { "development": ... }` guarantees that no `data-locator` attributes will be added to your production builds.
---
### Step 2: Add the Server API Route
Create the file `app/api/open-in-ide/route.ts` and copy `server/route.ts` into it:
```ts
import { NextResponse } from "next/server";
import { exec } from "child_process";
import os from "os";
export async function POST(req: Request) {
if (process.env.NODE_ENV !== "development") {
return NextResponse.json(
{ message: "Not available in production" },
{ status: 404 }
);
}
try {
const { locator } = await req.json();
if (!locator) {
return NextResponse.json({ message: "Locator is required" }, { status: 400 });
}
const isWindows = os.platform() === "win32";
const bin = isWindows ? "antigravity-ide.cmd" : "antigravity-ide";
const cmd = `${bin} -r -g "${locator}"`;
return new Promise<Response>((resolve) => {
exec(cmd, (error) => {
if (error) {
const fallbackBin = isWindows ? "code.cmd" : "code";
const fallbackCmd = `${fallbackBin} -r -g "${locator}"`;
exec(fallbackCmd, (fallbackError) => {
if (fallbackError) {
resolve(
NextResponse.json(
{ message: "Failed to open file in IDE", error: error.message },
{ status: 500 }
)
);
} else {
resolve(NextResponse.json({ success: true, via: "code" }));
}
});
return;
}
resolve(NextResponse.json({ success: true, via: "antigravity" }));
});
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json(
{ message: "Internal server error", error: message },
{ status: 500 }
);
}
}
```
---
### Step 3: Add the Client Component
Copy `client/dev-click-to-component.tsx` to `app/components/dev-click-to-component.tsx` (or `components/dev-click-to-component.tsx`).
Include it in your root layout (`app/layout.tsx`):
```tsx
import { DevClickToComponent } from "./components/dev-click-to-component";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
{process.env.NODE_ENV === "development" && <DevClickToComponent />}
</body>
</html>
);
}
```
---
## 🎮 How to Use
### Desktop Shortcuts
| Shortcut | Mode | Highlight Color | Target |
| :--- | :--- | :--- | :--- |
| **<kbd>Alt</kbd> + Hover** | **`OUTER`** | 🟩 Emerald Green (`#2dd9a4`) | Outer Component / Section Container |
| **<kbd>Alt</kbd> + <kbd>Ctrl</kbd> + Hover** *(or <kbd>Alt</kbd>+<kbd>Cmd</kbd> on Mac)* | **`INNER`** | 🟦 Electric Cyan (`#38bdf8`) | Innermost leaf element under mouse cursor |
| **<kbd>Alt</kbd> + Click** | **Open** | ⚡ Flash Pulse | Opens the currently highlighted element in your IDE |
### Mobile / Touch Devices
- A floating draggable button appears on screens $\le 768\text{px}$.
- Tap the button to toggle **Inspect Mode**.
- Tap any element on screen to highlight it and open its source code in the IDE.
---
## 💻 Supported IDEs & Protocol Fallbacks
When the local CLI is invoked, the system opens your existing active IDE window using `-r -g <filepath>:<line>:<col>`:
- **Antigravity IDE** (`antigravity-ide -r -g`)
- **VS Code / VS Code Insiders** (`code -r -g`)
- **Cursor** (`cursor -r -g`)
If the API route is unreachable (e.g. running inside certain sandboxed iframe environments), the client automatically falls back to browser URL protocol schemes:
- `antigravity://file/<locator>`
- `cursor://file/<locator>`
- `vscode://file/<locator>`
- `webstorm://open?file=<locator>`
- `subl://open?url=file://<locator>`
- `atom://open?url=file://<locator>`
---
## 🛡️ Production Safety & Performance
1. **Compiler Level**: In `NODE_ENV === "production"`, `add-data-locator.cjs` returns early without modifying the AST.
2. **Bundle Level**: In `app/layout.tsx`, `{process.env.NODE_ENV === "development" && <DevClickToComponent />}` evaluates to `false` during `next build`, allowing bundlers to completely tree-shake the client component out of the production bundle.
3. **Runtime Level**: Event listeners and rendering are guarded with `process.env.NODE_ENV !== "development"`.
4. **Security Level**: The `/api/open-in-ide` route returns a `404` in production environments, preventing arbitrary command execution.
---
## 📄 License
MIT

40
add-data-locator.cjs

@ -0,0 +1,40 @@
module.exports = function addDataLocator({ types: t }) {
return {
name: "add-data-locator",
visitor: {
JSXOpeningElement(path, state) {
if (process.env.NODE_ENV !== "development") {
return;
}
const filePath = state.file.opts.filename;
if (!filePath || filePath.includes("node_modules")) {
return;
}
const attributeExists = path.node.attributes.some(
(attribute) =>
t.isJSXAttribute(attribute) &&
t.isJSXIdentifier(attribute.name) &&
attribute.name.name === "data-locator",
);
if (attributeExists) {
return;
}
const lineNumber = path.node.loc?.start.line ?? "unknown";
const columnNumber = path.node.loc?.start.column ?? "unknown";
const locatorValue = `${filePath}:${lineNumber}:${columnNumber}`;
path.node.attributes.push(
t.jsxAttribute(
t.jsxIdentifier("data-locator"),
t.stringLiteral(locatorValue),
),
);
},
},
};
};

577
client/dev-click-to-component.tsx

@ -0,0 +1,577 @@
"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<HoveredTargetState | null>(null);
const [isOpening, setIsOpening] = useState(false);
const currentElementRef = useRef<HTMLElement | null>(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<HTMLElement>("[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<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);
}
};
// 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 && (
<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.06s ease-out, left 0.06s ease-out, width 0.06s ease-out, height 0.06s ease-out, border 0.15s, background-color 0.15s, box-shadow 0.15s",
}}
>
{/* Component Info Tag */}
<div
style={{
position: "absolute",
top: hoveredTarget.rect.top < 30 ? "6px" : "-28px",
left: Math.max(0, -hoveredTarget.rect.left),
display: "inline-flex",
alignItems: "center",
gap: "6px",
backgroundColor: "#020d09",
color: accentColor,
fontSize: "11px",
fontWeight: 600,
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
padding: "3px 8px",
borderRadius: "5px",
whiteSpace: "nowrap",
boxShadow: "0 4px 14px rgba(0,0,0,0.6)",
border: `1px solid ${accentColor}66`,
pointerEvents: "none",
}}
>
{/* Outer / Inner Mode Pill */}
<span
style={{
backgroundColor: hoveredTarget.isInner
? "rgba(56, 189, 248, 0.25)"
: "rgba(45, 217, 164, 0.2)",
color: accentColor,
padding: "1px 5px",
borderRadius: "3px",
fontSize: "10px",
fontWeight: 700,
letterSpacing: "0.03em",
}}
>
{hoveredTarget.isInner ? "INNER" : "OUTER"}
</span>
<span style={{ color: "#ffffff", opacity: 0.65 }}>
&lt;{hoveredTarget.tagName}&gt;
</span>
<span>{formatLocatorLabel(hoveredTarget.locator)}</span>
<span
style={{
color: "#ffffff",
opacity: 0.5,
fontSize: "10px",
fontWeight: 400,
}}
>
{isOpening
? "⚡ Opening IDE..."
: hoveredTarget.hasMultipleLevels
? hoveredTarget.isInner
? "⌥+Click | Release Ctrl for Outer"
: "⌥+Click | Hold Ctrl for Inner"
: "⌥+Click to open"}
</span>
</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 ? "#2dd9a4" : "#020d09",
color: isInspecting ? "#020d09" : "#ffffff",
border: isInspecting
? "2px solid #2dd9a4"
: "1px solid rgba(255,255,255,0.15)",
boxShadow: isInspecting
? "0 0 16px rgba(45, 217, 164, 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;

20
package.json

@ -0,0 +1,20 @@
{
"name": "click-to-component",
"version": "1.0.0",
"description": "Seamless click-to-component and hover inspector for Next.js & React to open source code directly in your IDE.",
"main": "client/dev-click-to-component.tsx",
"keywords": [
"click-to-component",
"react",
"nextjs",
"inspector",
"devtools",
"developer-experience",
"ide",
"antigravity",
"vscode",
"cursor"
],
"author": "",
"license": "MIT"
}

221
read.md

@ -0,0 +1,221 @@
# ⚡ Click-to-Component
A zero-config, high-precision component inspector and **Click-to-Component** developer tool for Next.js and React applications.
Hover over any UI element on your web page while holding <kbd>Alt</kbd> (or <kbd>Option</kbd>) to highlight component boundaries in real time, and **click to instantly jump to the exact file, line, and column in your IDE** (Antigravity IDE, VS Code, Cursor, WebStorm, Sublime Text, etc.).
---
## ✨ Features
- 🎯 **Direct IDE Jump**: Opens your active IDE and focuses the exact file and cursor position (`file.tsx:line:col`).
- 🔍 **Live Hover Highlighting**: Holding <kbd>Alt</kbd> outlines the component under your mouse with dynamic bounding box tracking and smooth scroll support.
- 🔀 **Dual Hierarchy Modes**:
- **Outer Component Mode (<kbd>Alt</kbd>)**: Selects the outer component or section container.
- **Inner Leaf Mode (<kbd>Alt</kbd> + <kbd>Ctrl</kbd> / <kbd>Cmd</kbd>)**: Drills down directly to the exact innermost child element (e.g. `<svg>`, `<button>`, `<span>`).
- **Live Dynamic Toggle**: Press or release <kbd>Ctrl</kbd> while hovering to switch between Outer and Inner mode in real time!
- 🏷️ **Smart Component Badge**: Displays the JSX tag name (e.g. `<section>`, `<button>`), short relative file path, line number, and active inspection mode pill (`OUTER` / `INNER`).
- 📱 **Mobile & Tablet Ready**: Floating draggable inspector button with tap-to-inspect on touch devices.
- 🛡️ **Zero Production Overhead**: Completely disabled, tree-shaken, and stripped out in production builds.
---
## 🏗️ Architecture & How It Works
The system operates across three interconnected layers:
```mermaid
flowchart TD
subgraph BuildTime["1. Build-Time AST Transform"]
A[Babel / Compiler] -->|add-data-locator.cjs| B[Injects data-locator attribute on JSX elements]
end
subgraph ClientSide["2. Client Runtime"]
B --> C[DevClickToComponent]
C -->|Alt + Hover| D[Draws Emerald Bounding Box for Outer Component]
C -->|Alt + Ctrl + Hover| E[Draws Cyan Bounding Box for Inner Element]
C -->|Alt + Click| F[Sends locator to API]
end
subgraph ServerSide["3. Local Server API"]
F -->|POST /api/open-in-ide| G[route.ts]
G -->|CLI Command Execution| H[antigravity-ide / code -r -g filepath:line:col]
H --> I[IDE Opens & Focuses Line]
end
```
---
## 📁 Repository Structure
```
click-to-component/
├── add-data-locator.cjs # Babel AST plugin to inject data-locator attributes
├── .babelrc.example # Babel configuration example
├── client/
│ └── dev-click-to-component.tsx # React Client Component (Inspector UI & Listeners)
├── server/
│ └── route.ts # Next.js App Router API Route (/api/open-in-ide)
├── package.json # Standalone package metadata
└── README.md # Documentation
```
---
## 🚀 Quick Setup & Installation
### Step 1: Install Babel Plugin
Copy `add-data-locator.cjs` to your project root.
Create or update `.babelrc` in your project root:
```json
{
"presets": ["next/babel"],
"env": {
"development": {
"plugins": [
"./add-data-locator.cjs"
]
}
}
}
```
> **Note**: Scoping the plugin inside `"env": { "development": ... }` guarantees that no `data-locator` attributes will be added to your production builds.
---
### Step 2: Add the Server API Route
Create the file `app/api/open-in-ide/route.ts` and copy `server/route.ts` into it:
```ts
import { NextResponse } from "next/server";
import { exec } from "child_process";
import os from "os";
export async function POST(req: Request) {
if (process.env.NODE_ENV !== "development") {
return NextResponse.json(
{ message: "Not available in production" },
{ status: 404 }
);
}
try {
const { locator } = await req.json();
if (!locator) {
return NextResponse.json({ message: "Locator is required" }, { status: 400 });
}
const isWindows = os.platform() === "win32";
const bin = isWindows ? "antigravity-ide.cmd" : "antigravity-ide";
const cmd = `${bin} -r -g "${locator}"`;
return new Promise<Response>((resolve) => {
exec(cmd, (error) => {
if (error) {
const fallbackBin = isWindows ? "code.cmd" : "code";
const fallbackCmd = `${fallbackBin} -r -g "${locator}"`;
exec(fallbackCmd, (fallbackError) => {
if (fallbackError) {
resolve(
NextResponse.json(
{ message: "Failed to open file in IDE", error: error.message },
{ status: 500 }
)
);
} else {
resolve(NextResponse.json({ success: true, via: "code" }));
}
});
return;
}
resolve(NextResponse.json({ success: true, via: "antigravity" }));
});
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json(
{ message: "Internal server error", error: message },
{ status: 500 }
);
}
}
```
---
### Step 3: Add the Client Component
Copy `client/dev-click-to-component.tsx` to `app/components/dev-click-to-component.tsx` (or `components/dev-click-to-component.tsx`).
Include it in your root layout (`app/layout.tsx`):
```tsx
import { DevClickToComponent } from "./components/dev-click-to-component";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
{process.env.NODE_ENV === "development" && <DevClickToComponent />}
</body>
</html>
);
}
```
---
## 🎮 How to Use
### Desktop Shortcuts
| Shortcut | Mode | Highlight Color | Target |
| :--- | :--- | :--- | :--- |
| **<kbd>Alt</kbd> + Hover** | **`OUTER`** | 🟩 Emerald Green (`#2dd9a4`) | Outer Component / Section Container |
| **<kbd>Alt</kbd> + <kbd>Ctrl</kbd> + Hover** *(or <kbd>Alt</kbd>+<kbd>Cmd</kbd> on Mac)* | **`INNER`** | 🟦 Electric Cyan (`#38bdf8`) | Innermost leaf element under mouse cursor |
| **<kbd>Alt</kbd> + Click** | **Open** | ⚡ Flash Pulse | Opens the currently highlighted element in your IDE |
### Mobile / Touch Devices
- A floating draggable button appears on screens $\le 768\text{px}$.
- Tap the button to toggle **Inspect Mode**.
- Tap any element on screen to highlight it and open its source code in the IDE.
---
## 💻 Supported IDEs & Protocol Fallbacks
When the local CLI is invoked, the system opens your existing active IDE window using `-r -g <filepath>:<line>:<col>`:
- **Antigravity IDE** (`antigravity-ide -r -g`)
- **VS Code / VS Code Insiders** (`code -r -g`)
- **Cursor** (`cursor -r -g`)
If the API route is unreachable (e.g. running inside certain sandboxed iframe environments), the client automatically falls back to browser URL protocol schemes:
- `antigravity://file/<locator>`
- `cursor://file/<locator>`
- `vscode://file/<locator>`
- `webstorm://open?file=<locator>`
- `subl://open?url=file://<locator>`
- `atom://open?url=file://<locator>`
---
## 🛡️ Production Safety & Performance
1. **Compiler Level**: In `NODE_ENV === "production"`, `add-data-locator.cjs` returns early without modifying the AST.
2. **Bundle Level**: In `app/layout.tsx`, `{process.env.NODE_ENV === "development" && <DevClickToComponent />}` evaluates to `false` during `next build`, allowing bundlers to completely tree-shake the client component out of the production bundle.
3. **Runtime Level**: Event listeners and rendering are guarded with `process.env.NODE_ENV !== "development"`.
4. **Security Level**: The `/api/open-in-ide` route returns a `404` in production environments, preventing arbitrary command execution.
---
## 📄 License
MIT

67
server/route.ts

@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { exec } from "child_process";
import os from "os";
export async function POST(req: Request) {
// Never run in production
if (process.env.NODE_ENV !== "development") {
return NextResponse.json(
{ message: "Not available in production" },
{ status: 404 }
);
}
try {
const { locator } = await req.json();
if (!locator) {
return NextResponse.json(
{ message: "Locator is required" },
{ status: 400 }
);
}
const isWindows = os.platform() === "win32";
const bin = isWindows ? "antigravity-ide.cmd" : "antigravity-ide";
const cmd = `${bin} -r -g "${locator}"`;
return new Promise<Response>((resolve) => {
exec(cmd, (error) => {
if (error) {
// If primary IDE launcher isn't available, try fallback to code/cursor
const fallbackBin = isWindows ? "code.cmd" : "code";
const fallbackCmd = `${fallbackBin} -r -g "${locator}"`;
exec(fallbackCmd, (fallbackError) => {
if (fallbackError) {
console.error(
`[open-in-ide] Error opening file: ${error.message} / Fallback error: ${fallbackError.message}`
);
resolve(
NextResponse.json(
{
message: "Failed to open file in IDE",
error: error.message,
},
{ status: 500 }
)
);
} else {
resolve(NextResponse.json({ success: true, via: "code" }));
}
});
return;
}
resolve(NextResponse.json({ success: true, via: "antigravity" }));
});
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error("[open-in-ide] Handler error:", err);
return NextResponse.json(
{ message: "Internal server error", error: message },
{ status: 500 }
);
}
}
Loading…
Cancel
Save