diff --git a/packages/react-grab/src/core/agent/manager.ts b/packages/react-grab/src/core/agent/manager.ts index 8785dd560..d63a0ec11 100644 --- a/packages/react-grab/src/core/agent/manager.ts +++ b/packages/react-grab/src/core/agent/manager.ts @@ -5,6 +5,7 @@ import type { AgentSession, AgentOptions, OverlayBounds, + SettableOptions, } from "../../types.js"; import { createSession, @@ -68,6 +69,7 @@ export interface AgentManager { export const createAgentManager = ( initialAgentOptions: AgentOptions | undefined, + getPluginOptions?: () => SettableOptions | undefined, ): AgentManager => { const [sessions, setSessions] = createSignal>( new Map(), @@ -339,7 +341,10 @@ export const createAgentManager = ( const content = existingSession ? existingSession.context.content - : await generateSnippet(elements, { maxLines: Infinity }); + : await generateSnippet(elements, { + maxLines: Infinity, + ignoreComponents: getPluginOptions?.()?.ignoreComponents, + }); const context: AgentContext = { content, @@ -367,7 +372,10 @@ export const createAgentManager = ( const componentName = elements.length > 1 ? undefined - : (await getNearestComponentName(firstElement)) || undefined; + : (await getNearestComponentName( + firstElement, + getPluginOptions?.()?.ignoreComponents, + )) || undefined; session = createSession( context, diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 3c91a1099..433ccee01 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -13,6 +13,7 @@ import { traverseFiber, } from "bippy"; import { MAX_HTML_FALLBACK_LENGTH } from "../constants.js"; +import { IgnoreComponentsOption } from "../types.js"; const NEXT_INTERNAL_COMPONENT_NAMES = new Set([ "InnerLayoutRouter", @@ -65,12 +66,36 @@ export const checkIsInternalComponentName = (name: string): boolean => { return false; }; -export const checkIsSourceComponentName = (name: string): boolean => { +const checkIsIgnored = ( + name: string, + ignoreComponents?: IgnoreComponentsOption, +): boolean => { + if (!ignoreComponents) return false; + if (typeof ignoreComponents === "function") { + return ignoreComponents(name); + } + for (const pattern of ignoreComponents) { + if (typeof pattern === "string") { + if (name === pattern) return true; + } else if (pattern.test(name)) { + return true; + } + } + return false; +}; + +export const checkIsSourceComponentName = ( + name: string, + ignoreComponents?: IgnoreComponentsOption, +): boolean => { if (name.length <= 1) return false; if (checkIsInternalComponentName(name)) return false; if (!isCapitalized(name)) return false; if (name.startsWith("Primitive.")) return false; - if (name.includes("Provider") && name.includes("Context")) return false; + + if (checkIsIgnored(name, ignoreComponents)) return false; + + if (name.includes("Provider") || name.includes("Context")) return false; return true; }; @@ -90,13 +115,17 @@ export const getStack = async ( export const getNearestComponentName = async ( element: Element, + ignoreComponents?: IgnoreComponentsOption, ): Promise => { if (!isInstrumentationActive()) return null; const stack = await getStack(element); if (!stack) return null; for (const frame of stack) { - if (frame.functionName && checkIsSourceComponentName(frame.functionName)) { + if ( + frame.functionName && + checkIsSourceComponentName(frame.functionName, ignoreComponents) + ) { return frame.functionName; } } @@ -104,15 +133,22 @@ export const getNearestComponentName = async ( return null; }; -const isUsefulComponentName = (name: string): boolean => { +const isUsefulComponentName = ( + name: string, + ignoreComponents?: IgnoreComponentsOption, +): boolean => { if (!name) return false; if (checkIsInternalComponentName(name)) return false; if (name.startsWith("Primitive.")) return false; if (name === "SlotClone" || name === "Slot") return false; + if (checkIsIgnored(name, ignoreComponents)) return false; return true; }; -export const getComponentDisplayName = (element: Element): string | null => { +export const getComponentDisplayName = ( + element: Element, + ignoreComponents?: IgnoreComponentsOption, +): string | null => { if (!isInstrumentationActive()) return null; const fiber = getFiberFromHostInstance(element); if (!fiber) return null; @@ -121,7 +157,7 @@ export const getComponentDisplayName = (element: Element): string | null => { while (currentFiber) { if (isCompositeFiber(currentFiber)) { const name = getDisplayName(currentFiber.type); - if (name && isUsefulComponentName(name)) { + if (name && isUsefulComponentName(name, ignoreComponents)) { return name; } } @@ -133,6 +169,7 @@ export const getComponentDisplayName = (element: Element): string | null => { interface GetElementContextOptions { maxLines?: number; + ignoreComponents?: IgnoreComponentsOption; } const hasSourceFiles = (stack: StackFrame[] | null): boolean => { @@ -146,6 +183,7 @@ const hasSourceFiles = (stack: StackFrame[] | null): boolean => { const getComponentNamesFromFiber = ( element: Element, maxCount: number, + ignoreComponents?: IgnoreComponentsOption, ): string[] => { if (!isInstrumentationActive()) return []; const fiber = getFiberFromHostInstance(element); @@ -158,7 +196,7 @@ const getComponentNamesFromFiber = ( if (componentNames.length >= maxCount) return true; if (isCompositeFiber(currentFiber)) { const name = getDisplayName(currentFiber.type); - if (name && isUsefulComponentName(name)) { + if (name && isUsefulComponentName(name, ignoreComponents)) { componentNames.push(name); } } @@ -196,7 +234,10 @@ export const getElementContext = async ( if ( frame.isServer && (!frame.functionName || - checkIsSourceComponentName(frame.functionName)) + checkIsSourceComponentName( + frame.functionName, + options.ignoreComponents, + )) ) { stackContext.push( `\n in ${frame.functionName || ""} (at Server)`, @@ -207,7 +248,10 @@ export const getElementContext = async ( let line = "\n in "; const hasComponentName = frame.functionName && - checkIsSourceComponentName(frame.functionName); + checkIsSourceComponentName( + frame.functionName, + options.ignoreComponents, + ); if (hasComponentName) { line += `${frame.functionName} (at `; @@ -232,7 +276,11 @@ export const getElementContext = async ( return `${html}${stackContext.join("")}`; } - const componentNames = getComponentNamesFromFiber(element, maxLines); + const componentNames = getComponentNamesFromFiber( + element, + maxLines, + options.ignoreComponents, + ); if (componentNames.length > 0) { const componentContext = componentNames .map((name) => `\n in ${name}`) diff --git a/packages/react-grab/src/core/copy.ts b/packages/react-grab/src/core/copy.ts index a378854d2..00d8fd760 100644 --- a/packages/react-grab/src/core/copy.ts +++ b/packages/react-grab/src/core/copy.ts @@ -1,9 +1,11 @@ import { copyContent } from "../utils/copy-content.js"; import { generateSnippet } from "../utils/generate-snippet.js"; +import { IgnoreComponentsOption } from "../types.js"; interface CopyOptions { maxContextLines?: number; getContent?: (elements: Element[]) => Promise | string; + ignoreComponents?: IgnoreComponentsOption; } interface CopyHooks { @@ -37,6 +39,7 @@ export const tryCopyWithFallback = async ( } else { const snippets = await generateSnippet(elements, { maxLines: options.maxContextLines, + ignoreComponents: options.ignoreComponents, }); const combinedSnippets = snippets.join("\n\n"); diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index cecef0287..7517d4936 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -146,6 +146,10 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { return createRoot((dispose) => { const pluginRegistry = createPluginRegistry(settableOptions); + // Helper to get ignoreComponents option - reduces repetitive access + const getIgnoreComponents = () => + pluginRegistry.store.options.ignoreComponents; + const getAgentFromActions = () => { for (const action of pluginRegistry.store.actions) { if (action.agent?.provider) { @@ -394,7 +398,10 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { for (const frame of stack) { const hasSourceComponentName = frame.functionName && - checkIsSourceComponentName(frame.functionName); + checkIsSourceComponentName( + frame.functionName, + getIgnoreComponents(), + ); const hasSourceFile = frame.fileName && isSourceFile(frame.fileName); @@ -413,7 +420,10 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { } if (!componentName) { - componentName = getComponentDisplayName(element); + componentName = getComponentDisplayName( + element, + getIgnoreComponents(), + ); } const textContent = @@ -551,14 +561,14 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const instanceId = bounds && tagName ? createLabelInstance( - bounds, - tagName, - componentName, - "copying", - element, - positionX, - elements, - ) + bounds, + tagName, + componentName, + "copying", + element, + positionX, + elements, + ) : null; await operation().finally(() => { @@ -590,6 +600,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { { maxContextLines: pluginRegistry.store.options.maxContextLines, getContent: pluginRegistry.store.options.getContent, + ignoreComponents: getIgnoreComponents(), }, { onBeforeCopy: pluginRegistry.hooks.onBeforeCopy, @@ -660,7 +671,10 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { : positionX; const tagName = getTagName(element); - void getNearestComponentName(element).then((componentName) => { + void getNearestComponentName( + element, + getIgnoreComponents(), + ).then((componentName) => { void executeCopyOperation( labelPositionX, positionY, @@ -952,19 +966,19 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ]) => { const isSelectionBoxVisible = Boolean( themeEnabled && - selectionBoxEnabled && - active && - !copying && - !justCopied && - !dragging && - effectiveTarget != null, + selectionBoxEnabled && + active && + !copying && + !justCopied && + !dragging && + effectiveTarget != null, ); const isDragBoxVisible = Boolean( themeEnabled && - dragBoxEnabled && - active && - !copying && - draggingBeyondThreshold, + dragBoxEnabled && + active && + !copying && + draggingBeyondThreshold, ); pluginRegistry.hooks.onStateChange({ isActive: active, @@ -977,11 +991,11 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { targetElement: target, dragBounds: drag ? { - x: drag.x, - y: drag.y, - width: drag.width, - height: drag.height, - } + x: drag.x, + y: drag.y, + width: drag.width, + height: drag.height, + } : null, grabbedBoxes: grabbedBoxes.map((box) => ({ id: box.id, @@ -1194,7 +1208,10 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { return wrapAgentWithCallbacks(agent); }; - const agentManager = createAgentManager(getAgentOptionsWithCallbacks()); + const agentManager = createAgentManager( + getAgentOptionsWithCallbacks(), + () => pluginRegistry.store.options, + ); const handleInputChange = (value: string) => { actions.setInputText(value); @@ -1462,10 +1479,10 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { elements.length > 0 ? elements : getElementsInDrag( - dragSelectionRect, - isValidGrabbableElement, - false, - ); + dragSelectionRect, + isValidGrabbableElement, + false, + ); if (selectedElements.length === 0) return; @@ -1998,15 +2015,15 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ? !event.metaKey : !event.ctrlKey : (requiredModifiers.shiftKey && !event.shiftKey) || - (requiredModifiers.altKey && !event.altKey); + (requiredModifiers.altKey && !event.altKey); const isReleasingActivationKey = pluginRegistry.store.options .activationKey ? typeof pluginRegistry.store.options.activationKey === "function" ? pluginRegistry.store.options.activationKey(event) : parseActivationKey(pluginRegistry.store.options.activationKey)( - event, - ) + event, + ) : isCLikeKey(event.key, event.code); if (didJustCopy() || inToggleFeedbackPeriod) { @@ -2751,7 +2768,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { } else { try { await navigator.clipboard.writeText(html); - } catch {} + } catch { } } if (shouldDeactivate) { @@ -3113,7 +3130,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { lineNumber: frame.lineNumber ?? null, componentName: frame.functionName && - checkIsSourceComponentName(frame.functionName) + checkIsSourceComponentName(frame.functionName) ? frame.functionName : null, }; @@ -3151,7 +3168,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { syncAgentFromRegistry(); }, getPlugins: () => pluginRegistry.getPluginNames(), - getDisplayName: getComponentDisplayName, + getDisplayName: (element: Element) => + getComponentDisplayName(element, getIgnoreComponents()), }; return api; diff --git a/packages/react-grab/src/core/plugin-registry.ts b/packages/react-grab/src/core/plugin-registry.ts index fb12d0465..3b48225fc 100644 --- a/packages/react-grab/src/core/plugin-registry.ts +++ b/packages/react-grab/src/core/plugin-registry.ts @@ -15,6 +15,7 @@ import type { ActivationMode, ActivationKey, SettableOptions, + IgnoreComponentsOption, } from "../types.js"; import { DEFAULT_THEME, deepMergeTheme } from "./theme.js"; import { DEFAULT_KEY_HOLD_DURATION_MS } from "../constants.js"; @@ -31,6 +32,7 @@ interface OptionsState { maxContextLines: number; activationKey: ActivationKey | undefined; getContent: ((elements: Element[]) => Promise | string) | undefined; + ignoreComponents: IgnoreComponentsOption | undefined; freezeReactUpdates: boolean; } @@ -41,6 +43,7 @@ const DEFAULT_OPTIONS: OptionsState = { maxContextLines: 3, activationKey: undefined, getContent: undefined, + ignoreComponents: undefined, freezeReactUpdates: true, }; diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index 3fa20bbc4..d95fb78df 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -275,6 +275,10 @@ export interface Plugin { setup?: (api: ReactGrabAPI) => PluginConfig | void; } +export type IgnoreComponentsOption = + | Array + | ((name: string) => boolean); + export interface Options { enabled?: boolean; activationMode?: ActivationMode; @@ -283,6 +287,10 @@ export interface Options { maxContextLines?: number; activationKey?: ActivationKey; getContent?: (elements: Element[]) => Promise | string; + /** + * Component names to ignore when searching for the nearest component. + */ + ignoreComponents?: IgnoreComponentsOption; /** * Whether to freeze React state updates while React Grab is active. * This prevents UI changes from interfering with element selection. diff --git a/packages/react-grab/src/utils/generate-snippet.ts b/packages/react-grab/src/utils/generate-snippet.ts index a2d320560..e5c4188fa 100644 --- a/packages/react-grab/src/utils/generate-snippet.ts +++ b/packages/react-grab/src/utils/generate-snippet.ts @@ -1,7 +1,9 @@ import { getElementContext } from "../core/context.js"; +import { IgnoreComponentsOption } from "../types.js"; interface GenerateSnippetOptions { maxLines?: number; + ignoreComponents?: IgnoreComponentsOption; } export const generateSnippet = async (