commit
e340d55f7e
7 changed files with 1156 additions and 0 deletions
-
10.babelrc.example
-
221README.md
-
40add-data-locator.cjs
-
577client/dev-click-to-component.tsx
-
20package.json
-
221read.md
-
67server/route.ts
@ -0,0 +1,10 @@ |
|||
{ |
|||
"presets": ["next/babel"], |
|||
"env": { |
|||
"development": { |
|||
"plugins": [ |
|||
"./add-data-locator.cjs" |
|||
] |
|||
} |
|||
} |
|||
} |
|||
@ -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 |
|||
@ -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), |
|||
), |
|||
); |
|||
}, |
|||
}, |
|||
}; |
|||
}; |
|||
@ -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 }}> |
|||
<{hoveredTarget.tagName}> |
|||
</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; |
|||
@ -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" |
|||
} |
|||
@ -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 |
|||
@ -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 } |
|||
); |
|||
} |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue