diff --git a/_package-export/CLAUDE.md b/_package-export/CLAUDE.md index 47256f2..8907277 100644 --- a/_package-export/CLAUDE.md +++ b/_package-export/CLAUDE.md @@ -40,6 +40,10 @@ The component exposes these callback props (added in 1.2.0): **API stability**: These are public contracts. Changing signatures or removing callbacks is a breaking change requiring a major version bump. +**1.4.0 additions:** +- `sourceAttribute` prop - configurable source detection attribute +- `sourceFile`, `sourceComponent` on Annotation type + **Expansion ideas** (for future consideration): - `onActivate` / `onDeactivate` - toolbar state changes - `getAnnotations()` ref method - programmatic access diff --git a/_package-export/README.md b/_package-export/README.md index 08544bc..47ce5a0 100644 --- a/_package-export/README.md +++ b/_package-export/README.md @@ -33,6 +33,7 @@ The toolbar appears in the bottom-right corner. Click to activate, then click an - **Area selection** – Drag to annotate any region, even empty space - **Animation pause** – Freeze CSS animations to capture specific states - **Structured output** – Copy markdown with selectors, positions, and context +- **Source detection** – Shows file:line for React components (dev mode with TanStack Devtools) - **Programmatic access** – Callback prop for direct integration with tools - **Dark/light mode** – Matches your preference or set manually - **Zero dependencies** – Pure CSS animations, no runtime libraries @@ -47,6 +48,7 @@ The toolbar appears in the bottom-right corner. Click to activate, then click an | `onAnnotationsClear` | `(annotations: Annotation[]) => void` | - | Called when all annotations are cleared | | `onCopy` | `(markdown: string) => void` | - | Callback with markdown output when copy is clicked | | `copyToClipboard` | `boolean` | `true` | Set to false to prevent writing to clipboard | +| `sourceAttribute` | `string` | `"data-tsd-source"` | Data attribute to read source file path from | ### Programmatic Integration @@ -102,6 +104,10 @@ type Annotation = { accessibility?: string; isMultiSelect?: boolean; isFixed?: boolean; + + // Source location (dev mode only) + sourceFile?: string; // e.g., "src/components/Button.tsx:42" + sourceComponent?: string; // Component name if available }; ``` diff --git a/_package-export/src/components/page-toolbar-css/index.tsx b/_package-export/src/components/page-toolbar-css/index.tsx index b42c53c..7ded40b 100644 --- a/_package-export/src/components/page-toolbar-css/index.tsx +++ b/_package-export/src/components/page-toolbar-css/index.tsx @@ -51,6 +51,11 @@ import { saveAnnotations, getStorageKey, } from "../../utils/storage"; +import { + getSourceLocation, + formatSourceLocation, + DEFAULT_SOURCE_ATTRIBUTE, +} from "../../utils/source-location"; import type { Annotation } from "../../types"; import styles from "./styles.module.scss"; @@ -179,6 +184,13 @@ function generateOutput( if (a.isMultiSelect && a.fullPath) { output += `*Forensic data shown for first element of selection*\n`; } + if (a.sourceFile) { + output += `**Source:** ${a.sourceFile}`; + if (a.sourceComponent) { + output += ` (component: ${a.sourceComponent})`; + } + output += `\n`; + } if (a.fullPath) { output += `**Full DOM Path:** ${a.fullPath}\n`; } @@ -208,6 +220,13 @@ function generateOutput( } else { // Standard and detailed modes output += `### ${i + 1}. ${a.element}\n`; + if (a.sourceFile) { + output += `**Source:** ${a.sourceFile}`; + if (a.sourceComponent) { + output += ` (${a.sourceComponent})`; + } + output += `\n`; + } output += `**Location:** ${a.elementPath}\n`; if (detailLevel === "detailed") { @@ -261,6 +280,8 @@ export type PageFeedbackToolbarCSSProps = { onCopy?: (markdown: string) => void; /** Whether to copy to clipboard when the copy button is clicked. Defaults to true. */ copyToClipboard?: boolean; + /** Data attribute name to read source location from. Defaults to "data-tsd-source" (TanStack Devtools). */ + sourceAttribute?: string; }; /** Alias for PageFeedbackToolbarCSSProps */ @@ -280,6 +301,7 @@ export function PageFeedbackToolbarCSS({ onAnnotationsClear, onCopy, copyToClipboard = true, + sourceAttribute = DEFAULT_SOURCE_ATTRIBUTE, }: PageFeedbackToolbarCSSProps = {}) { const [isActive, setIsActive] = useState(false); const [annotations, setAnnotations] = useState([]); @@ -307,6 +329,8 @@ export function PageFeedbackToolbarCSS({ computedStyles?: string; computedStylesObj?: Record; nearbyElements?: string; + sourceFile?: string; + sourceComponent?: string; } | null>(null); const [copied, setCopied] = useState(false); const [cleared, setCleared] = useState(false); @@ -499,6 +523,7 @@ export function PageFeedbackToolbarCSS({ const rect = element.getBoundingClientRect(); const { name, path } = identifyElement(element); + const sourceResult = getSourceLocation(element, sourceAttribute); const newAnnotation: Annotation = { id: `demo-${Date.now()}-${index}`, @@ -517,6 +542,11 @@ export function PageFeedbackToolbarCSS({ }, nearbyText: getNearbyText(element), cssClasses: getElementClasses(element), + sourceFile: + sourceResult.found && sourceResult.source + ? formatSourceLocation(sourceResult.source, "path") + : undefined, + sourceComponent: sourceResult.source?.componentName, }; setAnnotations((prev) => [...prev, newAnnotation]); @@ -764,6 +794,14 @@ export function PageFeedbackToolbarCSS({ const computedStylesObj = getDetailedComputedStyles(elementUnder); const computedStylesStr = getForensicComputedStyles(elementUnder); + // Capture source location for React components + const sourceResult = getSourceLocation(elementUnder, sourceAttribute); + const sourceFile = + sourceResult.found && sourceResult.source + ? formatSourceLocation(sourceResult.source, "path") + : undefined; + const sourceComponent = sourceResult.source?.componentName; + setPendingAnnotation({ x, y, @@ -785,6 +823,8 @@ export function PageFeedbackToolbarCSS({ computedStyles: computedStylesStr, computedStylesObj, nearbyElements: getNearbyElements(elementUnder), + sourceFile, + sourceComponent, }); setHoverInfo(null); }; @@ -1163,6 +1203,14 @@ export function PageFeedbackToolbarCSS({ getDetailedComputedStyles(firstElement); const firstElementComputedStylesStr = getForensicComputedStyles(firstElement); + // Capture source location from first element + const sourceResult = getSourceLocation(firstElement, sourceAttribute); + const sourceFile = + sourceResult.found && sourceResult.source + ? formatSourceLocation(sourceResult.source, "path") + : undefined; + const sourceComponent = sourceResult.source?.componentName; + setPendingAnnotation({ x, y, @@ -1184,6 +1232,8 @@ export function PageFeedbackToolbarCSS({ nearbyElements: getNearbyElements(firstElement), cssClasses: getElementClasses(firstElement), nearbyText: getNearbyText(firstElement), + sourceFile, + sourceComponent, }); } else { // No elements selected, but allow annotation on empty area @@ -1249,6 +1299,8 @@ export function PageFeedbackToolbarCSS({ accessibility: pendingAnnotation.accessibility, computedStyles: pendingAnnotation.computedStyles, nearbyElements: pendingAnnotation.nearbyElements, + sourceFile: pendingAnnotation.sourceFile, + sourceComponent: pendingAnnotation.sourceComponent, }; setAnnotations((prev) => [...prev, newAnnotation]); diff --git a/_package-export/src/types.ts b/_package-export/src/types.ts index 042b617..59a598e 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) + /** Source file path with line number (dev mode only). e.g., "src/components/Button.tsx:42" */ + sourceFile?: string; + /** React component name. e.g., "Button" */ + sourceComponent?: 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..85d57d8 100644 --- a/_package-export/src/utils/source-location.ts +++ b/_package-export/src/utils/source-location.ts @@ -375,25 +375,52 @@ function findDebugSourceReact19( } /** - * Gets the source file location for a DOM element in a React application + * Default data attribute for source location detection. + * TanStack Devtools uses "data-tsd-source" by default. + */ +export const DEFAULT_SOURCE_ATTRIBUTE = "data-tsd-source"; + +/** + * Gets the source file location for a DOM element in a React application. * * This function attempts to extract the source file path and line number * where a React component is defined. This only works in development mode * as production builds strip debug information. * * @param element - DOM element to get source location for + * @param sourceAttribute - Data attribute to check for source info (default: "data-tsd-source") * @returns SourceLocationResult with location info or reason for failure - * - * @example - * ```ts - * const result = getSourceLocation(element); - * if (result.found && result.source) { - * console.log(`${result.source.fileName}:${result.source.lineNumber}`); - * // Output: "/src/components/Button.tsx:42" - * } - * ``` */ -export function getSourceLocation(element: HTMLElement): SourceLocationResult { +export function getSourceLocation( + element: HTMLElement, + sourceAttribute: string = DEFAULT_SOURCE_ATTRIBUTE +): SourceLocationResult { + // First, check for source attribute on DOM elements (e.g., data-tsd-source from TanStack Devtools) + // This can be customized via the sourceAttribute parameter + let current: HTMLElement | null = element; + while (current) { + const sourceValue = current.getAttribute(sourceAttribute); + if (sourceValue) { + // Format: "/src/components/Button.tsx:42:5" or "src/components/Button.tsx:42" + const match = sourceValue.match(/^(.+):(\d+)(?::(\d+))?$/); + if (match) { + const [, fileName, lineStr, colStr] = match; + return { + found: true, + source: { + fileName: fileName, + lineNumber: parseInt(lineStr, 10), + columnNumber: colStr ? parseInt(colStr, 10) : undefined, + componentName: undefined, + }, + isReactApp: true, + isProduction: false, + }; + } + } + current = current.parentElement; + } + // Detect React environment const reactInfo = detectReactApp();