From 9571c990a458ce280e5fd99dcf580f375de5ebee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Mar 2026 02:08:47 +0000 Subject: [PATCH 1/2] feat: add /inspect page for visualizing React Grab clipboard output Co-authored-by: Aiden Bai --- packages/website/app/inspect/layout.tsx | 48 ++++ packages/website/app/inspect/page.tsx | 300 ++++++++++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 packages/website/app/inspect/layout.tsx create mode 100644 packages/website/app/inspect/page.tsx diff --git a/packages/website/app/inspect/layout.tsx b/packages/website/app/inspect/layout.tsx new file mode 100644 index 000000000..44aae8fac --- /dev/null +++ b/packages/website/app/inspect/layout.tsx @@ -0,0 +1,48 @@ +import type { Metadata } from "next"; + +const title = "Inspect"; +const description = + "Paste React Grab output to visualize element context in raw and formatted views."; +const ogImageUrl = `https://react-grab.com/api/og?title=${encodeURIComponent(title)}&subtitle=${encodeURIComponent(description)}`; + +export const metadata: Metadata = { + title: `${title} | React Grab`, + description, + openGraph: { + title: `${title} | React Grab`, + description, + url: "https://react-grab.com/inspect", + siteName: "React Grab", + images: [ + { + url: ogImageUrl, + width: 1200, + height: 630, + alt: `React Grab - ${title}`, + }, + ], + locale: "en_US", + type: "website", + }, + twitter: { + card: "summary_large_image", + title: `${title} | React Grab`, + description, + images: [ogImageUrl], + }, + alternates: { + canonical: "https://react-grab.com/inspect", + }, +}; + +interface InspectLayoutProps { + children: React.ReactNode; +} + +const InspectLayout = (props: InspectLayoutProps) => { + return props.children; +}; + +InspectLayout.displayName = "InspectLayout"; + +export default InspectLayout; diff --git a/packages/website/app/inspect/page.tsx b/packages/website/app/inspect/page.tsx new file mode 100644 index 000000000..a4231ce1f --- /dev/null +++ b/packages/website/app/inspect/page.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { ReactGrabLogo } from "@/components/react-grab-logo"; +import { cn } from "@/utils/cn"; +import { highlightCode } from "@/lib/shiki"; +import { Clipboard, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; +import prettyMs from "pretty-ms"; + +const REACT_GRAB_MIME_TYPE = "application/x-react-grab"; + +const TABS = ["raw", "formatted"] as const; +type Tab = (typeof TABS)[number]; + +interface ReactGrabEntry { + tagName?: string; + componentName?: string; + content: string; + commentText?: string; +} + +interface ReactGrabMetadata { + version: string; + content: string; + entries: ReactGrabEntry[]; + timestamp: number; +} + +const parseReactGrabMetadata = ( + jsonString: string, +): ReactGrabMetadata | null => { + try { + const parsed = JSON.parse(jsonString); + if ( + parsed && + typeof parsed.content === "string" && + Array.isArray(parsed.entries) + ) { + return parsed; + } + return null; + } catch { + return null; + } +}; + +const formatTimestamp = (timestamp: number): string => { + const elapsed = Date.now() - timestamp; + if (elapsed < 1000) return "just now"; + return `${prettyMs(elapsed, { compact: true })} ago`; +}; + +const useIsMac = (): boolean => { + const [isMac, setIsMac] = useState(false); + + useEffect(() => { + setIsMac(navigator.platform.toUpperCase().includes("MAC")); + }, []); + + return isMac; +}; + +interface HighlightedEntry { + entry: ReactGrabEntry; + highlightedHtml: string; +} + +const InspectPage = () => { + const [pastedContent, setPastedContent] = useState(null); + const [metadata, setMetadata] = useState(null); + const [activeTab, setActiveTab] = useState("formatted"); + const [highlightedEntries, setHighlightedEntries] = useState< + HighlightedEntry[] + >([]); + const [highlightedRawContent, setHighlightedRawContent] = useState< + string | null + >(null); + const isMac = useIsMac(); + + const handleClear = useCallback(() => { + setPastedContent(null); + setMetadata(null); + setHighlightedEntries([]); + setHighlightedRawContent(null); + setActiveTab("formatted"); + }, []); + + useEffect(() => { + const handlePaste = (event: ClipboardEvent) => { + const clipboardData = event.clipboardData; + if (!clipboardData) return; + + const reactGrabData = clipboardData.getData(REACT_GRAB_MIME_TYPE); + const plainText = clipboardData.getData("text/plain"); + + if (!reactGrabData && !plainText) return; + + event.preventDefault(); + + const parsedMetadata = reactGrabData + ? parseReactGrabMetadata(reactGrabData) + : null; + + setPastedContent(plainText || parsedMetadata?.content || ""); + setMetadata(parsedMetadata); + setActiveTab("formatted"); + }; + + document.addEventListener("paste", handlePaste); + return () => document.removeEventListener("paste", handlePaste); + }, []); + + useEffect(() => { + if (!metadata?.entries.length && !pastedContent) { + setHighlightedEntries([]); + setHighlightedRawContent(null); + return; + } + + const highlightAsync = async () => { + if (metadata?.entries.length) { + const results = await Promise.all( + metadata.entries.map(async (entry) => { + const html = await highlightCode({ + code: entry.content, + lang: "html", + }); + return { entry, highlightedHtml: html }; + }), + ); + setHighlightedEntries(results); + } else if (pastedContent) { + const html = await highlightCode({ + code: pastedContent, + lang: "html", + }); + setHighlightedRawContent(html); + } + }; + + highlightAsync(); + }, [metadata, pastedContent]); + + const hasContent = Boolean(pastedContent); + + if (!hasContent) { + return ( +
+
+ + + + +
+

Inspect

+

+ Paste React Grab output anywhere on this page +

+
+ +
+ + + {isMac ? "⌘" : "Ctrl"} + V + +
+
+
+ ); + } + + return ( +
+
+ + + + +
+ {TABS.map((tab) => ( + + ))} +
+ +
+ + {metadata && ( +
+ {metadata.version && ( + + v{metadata.version} + + )} + {metadata.timestamp > 0 && ( + {formatTimestamp(metadata.timestamp)} + )} +
+ )} + + +
+ +
+ {activeTab === "raw" && ( +
+
+              {pastedContent}
+            
+
+ )} + + {activeTab === "formatted" && ( +
+ {highlightedEntries.length > 0 + ? highlightedEntries.map((highlightedEntry, index) => ( +
+
+ {highlightedEntry.entry.tagName && ( + + <{highlightedEntry.entry.tagName}> + + )} + {highlightedEntry.entry.componentName && ( + + {highlightedEntry.entry.componentName} + + )} +
+ + {highlightedEntry.entry.commentText && ( +
+ {highlightedEntry.entry.commentText} +
+ )} + +
+
+ )) + : highlightedRawContent && ( +
+
+
+ )} + + {!highlightedEntries.length && !highlightedRawContent && ( +
+ Highlighting… +
+ )} +
+ )} +
+
+ ); +}; + +InspectPage.displayName = "InspectPage"; + +export default InspectPage; From ef577cd2cfb068772e1ac6b1dd3bfa54a0d7c966 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 5 Mar 2026 02:39:36 +0000 Subject: [PATCH 2/2] refactor: redesign inspect page - swap raw/formatted, structured breakdown, homepage-style layout Co-authored-by: Aiden Bai --- packages/website/app/inspect/page.tsx | 426 ++++++++++++++++++-------- 1 file changed, 301 insertions(+), 125 deletions(-) diff --git a/packages/website/app/inspect/page.tsx b/packages/website/app/inspect/page.tsx index a4231ce1f..db5f63eba 100644 --- a/packages/website/app/inspect/page.tsx +++ b/packages/website/app/inspect/page.tsx @@ -4,12 +4,14 @@ import { useState, useEffect, useCallback } from "react"; import { ReactGrabLogo } from "@/components/react-grab-logo"; import { cn } from "@/utils/cn"; import { highlightCode } from "@/lib/shiki"; -import { Clipboard, X } from "lucide-react"; +import { ArrowLeft, Clipboard, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import Link from "next/link"; import prettyMs from "pretty-ms"; const REACT_GRAB_MIME_TYPE = "application/x-react-grab"; +const STACK_FRAME_PATTERN = + /^\s+in\s+(\S+?)(?:\s+\(at\s+([^:)]+?)(?::(\d+))?(?::(\d+))?\))?$/; const TABS = ["raw", "formatted"] as const; type Tab = (typeof TABS)[number]; @@ -28,6 +30,18 @@ interface ReactGrabMetadata { timestamp: number; } +interface ParsedStackFrame { + componentName: string; + filePath: string | null; + lineNumber: number | null; + columnNumber: number | null; +} + +interface ParsedContent { + htmlSnippet: string; + stackFrames: ParsedStackFrame[]; +} + const parseReactGrabMetadata = ( jsonString: string, ): ReactGrabMetadata | null => { @@ -46,6 +60,31 @@ const parseReactGrabMetadata = ( } }; +const parseEntryContent = (content: string): ParsedContent => { + const lines = content.split("\n"); + const snippetLines: string[] = []; + const stackFrames: ParsedStackFrame[] = []; + + for (const line of lines) { + const match = STACK_FRAME_PATTERN.exec(line); + if (match) { + stackFrames.push({ + componentName: match[1], + filePath: match[2] ?? null, + lineNumber: match[3] ? Number(match[3]) : null, + columnNumber: match[4] ? Number(match[4]) : null, + }); + } else { + snippetLines.push(line); + } + } + + return { + htmlSnippet: snippetLines.join("\n").trimEnd(), + stackFrames, + }; +}; + const formatTimestamp = (timestamp: number): string => { const elapsed = Date.now() - timestamp; if (elapsed < 1000) return "just now"; @@ -62,29 +101,159 @@ const useIsMac = (): boolean => { return isMac; }; -interface HighlightedEntry { +const ElementTag = (props: { children: string }) => ( + + {props.children} + +); + +ElementTag.displayName = "ElementTag"; + +const InfoRow = (props: { + label: string; + children: React.ReactNode; + borderTop?: boolean; +}) => ( +
+ + {props.label} + + {props.children} +
+); + +InfoRow.displayName = "InfoRow"; + +interface FormattedEntryCardProps { + entry: ReactGrabEntry; + highlightedSnippet: string; + parsedContent: ParsedContent; + entryIndex: number; + totalEntries: number; +} + +const FormattedEntryCard = (props: FormattedEntryCardProps) => { + const hasStack = props.parsedContent.stackFrames.length > 0; + const firstFrame = hasStack ? props.parsedContent.stackFrames[0] : null; + + return ( +
+ {props.totalEntries > 1 && ( +
+ Entry {props.entryIndex + 1} of {props.totalEntries} +
+ )} + + {props.entry.tagName && ( + + {`<${props.entry.tagName}>`} + + )} + + {props.entry.componentName && ( + + + {props.entry.componentName} + + + )} + + {firstFrame?.filePath && ( + + + {firstFrame.filePath} + {firstFrame.lineNumber !== null && ( + + :{firstFrame.lineNumber} + {firstFrame.columnNumber !== null && + `:${firstFrame.columnNumber}`} + + )} + + + )} + + {props.entry.commentText && ( + + + “{props.entry.commentText}” + + + )} + + {props.parsedContent.htmlSnippet && ( +
+
+ HTML +
+
+
+ )} + + {hasStack && ( +
+
+ Stack +
+
+ {props.parsedContent.stackFrames.map((frame, frameIndex) => ( +
+ + {frame.componentName} + + {frame.filePath && ( + + {frame.filePath} + {frame.lineNumber !== null && `:${frame.lineNumber}`} + {frame.columnNumber !== null && `:${frame.columnNumber}`} + + )} +
+ ))} +
+
+ )} +
+ ); +}; + +FormattedEntryCard.displayName = "FormattedEntryCard"; + +interface HighlightedEntryData { entry: ReactGrabEntry; - highlightedHtml: string; + parsedContent: ParsedContent; + highlightedSnippet: string; } const InspectPage = () => { const [pastedContent, setPastedContent] = useState(null); const [metadata, setMetadata] = useState(null); - const [activeTab, setActiveTab] = useState("formatted"); - const [highlightedEntries, setHighlightedEntries] = useState< - HighlightedEntry[] - >([]); - const [highlightedRawContent, setHighlightedRawContent] = useState< + const [activeTab, setActiveTab] = useState("raw"); + const [highlightedFullContent, setHighlightedFullContent] = useState< string | null >(null); + const [highlightedEntryData, setHighlightedEntryData] = useState< + HighlightedEntryData[] + >([]); const isMac = useIsMac(); const handleClear = useCallback(() => { setPastedContent(null); setMetadata(null); - setHighlightedEntries([]); - setHighlightedRawContent(null); - setActiveTab("formatted"); + setHighlightedFullContent(null); + setHighlightedEntryData([]); + setActiveTab("raw"); }, []); useEffect(() => { @@ -103,9 +272,10 @@ const InspectPage = () => { ? parseReactGrabMetadata(reactGrabData) : null; - setPastedContent(plainText || parsedMetadata?.content || ""); + const content = plainText || parsedMetadata?.content || ""; + setPastedContent(content); setMetadata(parsedMetadata); - setActiveTab("formatted"); + setActiveTab(parsedMetadata ? "formatted" : "raw"); }; document.addEventListener("paste", handlePaste); @@ -113,30 +283,33 @@ const InspectPage = () => { }, []); useEffect(() => { - if (!metadata?.entries.length && !pastedContent) { - setHighlightedEntries([]); - setHighlightedRawContent(null); + if (!pastedContent) { + setHighlightedFullContent(null); + setHighlightedEntryData([]); return; } const highlightAsync = async () => { + const fullHtml = await highlightCode({ + code: pastedContent, + lang: "html", + }); + setHighlightedFullContent(fullHtml); + if (metadata?.entries.length) { const results = await Promise.all( metadata.entries.map(async (entry) => { - const html = await highlightCode({ - code: entry.content, - lang: "html", - }); - return { entry, highlightedHtml: html }; + const parsedContent = parseEntryContent(entry.content); + const highlightedSnippet = parsedContent.htmlSnippet + ? await highlightCode({ + code: parsedContent.htmlSnippet, + lang: "html", + }) + : ""; + return { entry, parsedContent, highlightedSnippet }; }), ); - setHighlightedEntries(results); - } else if (pastedContent) { - const html = await highlightCode({ - code: pastedContent, - lang: "html", - }); - setHighlightedRawContent(html); + setHighlightedEntryData(results); } }; @@ -176,121 +349,124 @@ const InspectPage = () => { } return ( -
-
- - +
+
+ + + Back to home -
- {TABS.map((tab) => ( - - ))} +
+ + +
-
- - {metadata && ( -
- {metadata.version && ( - - v{metadata.version} - - )} - {metadata.timestamp > 0 && ( - {formatTimestamp(metadata.timestamp)} - )} +
+
+ {TABS.map((tab) => ( + + ))}
- )} - -
+ {metadata && ( +
+ {metadata.version && ( + + v{metadata.version} + + )} + {metadata.timestamp > 0 && ( + {formatTimestamp(metadata.timestamp)} + )} +
+ )} + +
+ + +
-
{activeTab === "raw" && ( -
-
-              {pastedContent}
-            
+
+ {highlightedFullContent ? ( +
+ ) : ( +
+                {pastedContent}
+              
+ )}
)} {activeTab === "formatted" && (
- {highlightedEntries.length > 0 - ? highlightedEntries.map((highlightedEntry, index) => ( -
-
- {highlightedEntry.entry.tagName && ( - - <{highlightedEntry.entry.tagName}> - - )} - {highlightedEntry.entry.componentName && ( - - {highlightedEntry.entry.componentName} - - )} -
- - {highlightedEntry.entry.commentText && ( -
- {highlightedEntry.entry.commentText} -
- )} - -
-
- )) - : highlightedRawContent && ( -
-
-
- )} - - {!highlightedEntries.length && !highlightedRawContent && ( + {highlightedEntryData.length > 0 ? ( + highlightedEntryData.map((entryData, index) => ( + + )) + ) : metadata ? (
- Highlighting… + Loading… +
+ ) : ( +
+
+ Paste React Grab output (with{" "} + + application/x-react-grab + {" "} + metadata) for the structured view. +
+
+
+                    {pastedContent}
+                  
+
)}
)} -
+
); };