diff --git a/_package-export/src/components/annotation-popup-css/index.tsx b/_package-export/src/components/annotation-popup-css/index.tsx index 919124b..b991048 100644 --- a/_package-export/src/components/annotation-popup-css/index.tsx +++ b/_package-export/src/components/annotation-popup-css/index.tsx @@ -34,6 +34,12 @@ export interface AnnotationPopupCSSProps { lightMode?: boolean; /** Computed styles for the selected element */ computedStyles?: Record; + /** React component name */ + reactComponent?: string; + /** React component hierarchy */ + reactHierarchy?: string[]; + /** React source file location */ + reactSource?: string; } export interface AnnotationPopupCSSHandle { @@ -71,6 +77,8 @@ export const AnnotationPopupCSS = forwardRef{timestamp}} + {/* React Component Metadata */} + {reactComponent && ( +
+
+ + + + + {reactComponent} +
+ {reactHierarchy && reactHierarchy.length > 1 && ( +
+ {reactHierarchy.slice(1, 4).join(" < ")} +
+ )} +
+ )} + {/* Collapsible computed styles section - uses grid-template-rows for smooth animation */} {computedStyles && Object.keys(computedStyles).length > 0 && (
diff --git a/_package-export/src/components/annotation-popup-css/styles.module.scss b/_package-export/src/components/annotation-popup-css/styles.module.scss index d27e590..19134b7 100644 --- a/_package-export/src/components/annotation-popup-css/styles.module.scss +++ b/_package-export/src/components/annotation-popup-css/styles.module.scss @@ -203,6 +203,42 @@ $green: #34c759; flex-shrink: 0; } +// ============================================================================= +// React Metadata +// ============================================================================= + +.reactInfo { + margin-bottom: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.reactComponent { + display: flex; + align-items: center; + font-size: 0.75rem; + font-weight: 600; + color: #fff; + line-height: 1; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.reactHierarchy { + font-size: 0.625rem; + color: rgba(255, 255, 255, 0.4); + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-left: 16px; // Align with component name icon +} + // ============================================================================= // Quote // ============================================================================= diff --git a/_package-export/src/components/page-toolbar-css/index.tsx b/_package-export/src/components/page-toolbar-css/index.tsx index b42c53c..6a77087 100644 --- a/_package-export/src/components/page-toolbar-css/index.tsx +++ b/_package-export/src/components/page-toolbar-css/index.tsx @@ -46,6 +46,10 @@ import { getAccessibilityInfo, getNearbyElements, } from "../../utils/element-identification"; +import { + getSourceLocation, + getComponentHierarchy, +} from "../../utils/source-location"; import { loadAnnotations, saveAnnotations, @@ -66,6 +70,7 @@ type HoverInfo = { element: string; elementPath: string; rect: DOMRect | null; + reactComponent?: string; }; type OutputDetailLevel = "compact" | "standard" | "detailed" | "forensic"; @@ -204,10 +209,23 @@ function generateOutput( if (a.nearbyElements) { output += `**Nearby Elements:** ${a.nearbyElements}\n`; } + // React Forensic Data + if (a.reactComponent) { + output += `**React Component:** ${a.reactComponent}\n`; + } + if (a.reactHierarchy) { + output += `**React Hierarchy:** ${a.reactHierarchy.join(" > ")}\n`; + } + if (a.reactSource) { + output += `**React Source:** ${a.reactSource}\n`; + } output += `**Feedback:** ${a.comment}\n\n`; } else { // Standard and detailed modes output += `### ${i + 1}. ${a.element}\n`; + if (a.reactComponent) { + output += `**Component:** ${a.reactComponent}\n`; + } output += `**Location:** ${a.elementPath}\n`; if (detailLevel === "detailed") { @@ -307,6 +325,9 @@ export function PageFeedbackToolbarCSS({ computedStyles?: string; computedStylesObj?: Record; nearbyElements?: string; + reactComponent?: string; + reactHierarchy?: string[]; + reactSource?: string; } | null>(null); const [copied, setCopied] = useState(false); const [cleared, setCleared] = useState(false); @@ -686,7 +707,15 @@ export function PageFeedbackToolbarCSS({ const { name, path } = identifyElement(elementUnder); const rect = elementUnder.getBoundingClientRect(); - setHoverInfo({ element: name, elementPath: path, rect }); + // Capture React Metadata for hover tooltip + const reactInfo = getSourceLocation(elementUnder); + + setHoverInfo({ + element: name, + elementPath: path, + rect, + reactComponent: reactInfo.found ? reactInfo.source?.componentName : undefined + }); setHoverPosition({ x: e.clientX, y: e.clientY }); }; @@ -764,6 +793,14 @@ export function PageFeedbackToolbarCSS({ const computedStylesObj = getDetailedComputedStyles(elementUnder); const computedStylesStr = getForensicComputedStyles(elementUnder); + // Capture React Metadata + const reactInfo = getSourceLocation(elementUnder); + const reactHierarchy = getComponentHierarchy(elementUnder); + + const hierarchyNames = reactHierarchy + .map((h) => h.componentName) + .filter(Boolean) as string[]; + setPendingAnnotation({ x, y, @@ -785,6 +822,9 @@ export function PageFeedbackToolbarCSS({ computedStyles: computedStylesStr, computedStylesObj, nearbyElements: getNearbyElements(elementUnder), + reactComponent: reactInfo.found ? reactInfo.source?.componentName : undefined, + reactHierarchy: hierarchyNames.length > 0 ? hierarchyNames : undefined, + reactSource: reactInfo.found ? reactInfo.source?.fileName : undefined, }); setHoverInfo(null); }; @@ -1249,6 +1289,9 @@ export function PageFeedbackToolbarCSS({ accessibility: pendingAnnotation.accessibility, computedStyles: pendingAnnotation.computedStyles, nearbyElements: pendingAnnotation.nearbyElements, + reactComponent: pendingAnnotation.reactComponent, + reactHierarchy: pendingAnnotation.reactHierarchy, + reactSource: pendingAnnotation.reactSource, }; setAnnotations((prev) => [...prev, newAnnotation]); @@ -2311,6 +2354,13 @@ export function PageFeedbackToolbarCSS({ top: Math.max(hoverPosition.y - 32, 8), }} > + {hoverInfo.reactComponent && ( + + / + {hoverInfo.reactComponent} + + + )} {hoverInfo.element}
)} @@ -2355,6 +2405,9 @@ export function PageFeedbackToolbarCSS({ element={pendingAnnotation.element} selectedText={pendingAnnotation.selectedText} computedStyles={pendingAnnotation.computedStylesObj} + reactComponent={pendingAnnotation.reactComponent} + reactHierarchy={pendingAnnotation.reactHierarchy} + reactSource={pendingAnnotation.reactSource} placeholder={ pendingAnnotation.element === "Area selection" ? "What should change in this area?" @@ -2417,6 +2470,9 @@ export function PageFeedbackToolbarCSS({ element={editingAnnotation.element} selectedText={editingAnnotation.selectedText} computedStyles={parseComputedStylesString(editingAnnotation.computedStyles)} + reactComponent={editingAnnotation.reactComponent} + reactHierarchy={editingAnnotation.reactHierarchy} + reactSource={editingAnnotation.reactSource} placeholder="Edit your feedback..." initialValue={editingAnnotation.comment} submitLabel="Save" diff --git a/_package-export/src/components/page-toolbar-css/styles.module.scss b/_package-export/src/components/page-toolbar-css/styles.module.scss index 833ca2d..57cfd49 100644 --- a/_package-export/src/components/page-toolbar-css/styles.module.scss +++ b/_package-export/src/components/page-toolbar-css/styles.module.scss @@ -624,6 +624,22 @@ $green: #34c759; } } +.hoverReact { + opacity: 0.9; + margin-right: 4px; +} + +.hoverReactSlash { + color: #AF52DE; // Purple accent for React info + font-weight: bold; + margin-right: 2px; +} + +.hoverDivider { + margin: 0 4px; + opacity: 0.4; +} + // ============================================================================= // Markers Layer // ============================================================================= diff --git a/_package-export/src/types.ts b/_package-export/src/types.ts index 042b617..a8d135c 100644 --- a/_package-export/src/types.ts +++ b/_package-export/src/types.ts @@ -20,6 +20,10 @@ export type Annotation = { accessibility?: string; isMultiSelect?: boolean; // true if created via drag selection isFixed?: boolean; // true if element has fixed/sticky positioning (marker stays fixed) + // Framework Metadata + reactComponent?: string; + reactHierarchy?: string[]; + reactSource?: string; }; // TODO: Add configuration types when abstracting config diff --git a/_package-export/src/utils/source-location.ts b/_package-export/src/utils/source-location.ts index f2c9ff7..d717c3c 100644 --- a/_package-export/src/utils/source-location.ts +++ b/_package-export/src/utils/source-location.ts @@ -210,29 +210,37 @@ export function getFiberFromElement(element: HTMLElement): ReactFiber | null { return null; } + // Find any property that starts with the React fiber/instance prefix const keys = Object.keys(element); - - // React 18+ uses __reactFiber$ prefix - const fiberKey = keys.find((key) => key.startsWith("__reactFiber$")); - if (fiberKey) { - return (element as unknown as Record)[fiberKey] || null; - } - - // React 16-17 uses __reactInternalInstance$ prefix - const instanceKey = keys.find((key) => key.startsWith("__reactInternalInstance$")); - if (instanceKey) { - return (element as unknown as Record)[instanceKey] || null; + + // React 17+ pattern: __reactFiber$randomid + // React 16 pattern: __reactInternalInstance$randomid + const reactKey = keys.find(key => + key.startsWith("__reactFiber$") || + key.startsWith("__reactInternalInstance$") || + key.startsWith("__reactProps$") // Sometimes props are sibling to fiber + ); + + if (reactKey) { + // If it's the props key, we might need to look for the fiber key specifically + if (reactKey.startsWith("__reactProps$")) { + const fiberKey = keys.find(k => k.startsWith("__reactFiber$")); + if (fiberKey) { + return (element as any)[fiberKey]; + } + } + return (element as any)[reactKey]; } - // React 19 may use different patterns - check for any fiber-like object + // Fallback for React 19 / Future versions const possibleFiberKey = keys.find((key) => { if (!key.startsWith("__react")) return false; - const value = (element as unknown as Record)[key]; - return value && typeof value === "object" && "_debugSource" in (value as object); + const value = (element as any)[key]; + return value && typeof value === "object" && ("_debugSource" in value || "return" in value); }); if (possibleFiberKey) { - return (element as unknown as Record)[possibleFiberKey] || null; + return (element as any)[possibleFiberKey]; } return null; @@ -406,25 +414,45 @@ export function getSourceLocation(element: HTMLElement): SourceLocationResult { }; } - if (reactInfo.isProduction) { + // Get fiber from element + const fiber = getFiberFromElement(element); + + if (!fiber) { return { found: false, - reason: "production-build", + reason: "no-fiber", isReactApp: true, - isProduction: true, + isProduction: reactInfo.isProduction, }; } - // Get fiber from element - const fiber = getFiberFromElement(element); + // Attempt to find the component name even if we don't have debug source + // We'll walk up a bit to find the first non-host component (the React component) + let currentFiber: ReactFiber | null | undefined = fiber; + let detectedComponentName: string | null = null; + let depth = 0; + + while (currentFiber && depth < 20) { + const name = getComponentName(currentFiber); + if (name) { + detectedComponentName = name; + break; + } + currentFiber = currentFiber.return; + depth++; + } - if (!fiber) { - // Element might not be part of React tree (e.g., injected by extension) + if (reactInfo.isProduction) { return { - found: false, - reason: "no-fiber", + found: !!detectedComponentName, + source: detectedComponentName ? { + fileName: "unknown (production)", + lineNumber: 0, + componentName: detectedComponentName, + } : undefined, + reason: "production-build", isReactApp: true, - isProduction: false, + isProduction: true, }; } @@ -437,7 +465,21 @@ export function getSourceLocation(element: HTMLElement): SourceLocationResult { } if (!debugInfo || !debugInfo.source) { - // Check if this might be React 19 with changed internals + // FALLBACK: If we have a component name but no source location, it's still better than nothing + if (detectedComponentName) { + return { + found: true, + source: { + fileName: "Source location hidden (check bundler settings)", + lineNumber: 0, + componentName: detectedComponentName, + reactVersion: reactInfo.version, + }, + isReactApp: true, + isProduction: false, + }; + } + const majorVersion = reactInfo.version?.split(".")[0]; if (majorVersion === "19") { return { @@ -462,7 +504,7 @@ export function getSourceLocation(element: HTMLElement): SourceLocationResult { fileName: debugInfo.source.fileName, lineNumber: debugInfo.source.lineNumber, columnNumber: debugInfo.source.columnNumber, - componentName: debugInfo.componentName || undefined, + componentName: debugInfo.componentName || detectedComponentName || undefined, reactVersion: reactInfo.version, }, isReactApp: true,