From 38bbafe441b67e532261fa107151ec426edac859 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Feb 2026 01:49:51 +0000 Subject: [PATCH 01/10] feat: add event context types, constants, trail store, and ambient copy badge component Co-authored-by: Aiden Bai --- .../src/components/ambient-copy-badge.tsx | 204 ++++++++++++++++++ packages/react-grab/src/constants.ts | 9 + packages/react-grab/src/types.ts | 21 ++ .../src/utils/event-context-store.ts | 114 ++++++++++ 4 files changed, 348 insertions(+) create mode 100644 packages/react-grab/src/components/ambient-copy-badge.tsx create mode 100644 packages/react-grab/src/utils/event-context-store.ts diff --git a/packages/react-grab/src/components/ambient-copy-badge.tsx b/packages/react-grab/src/components/ambient-copy-badge.tsx new file mode 100644 index 000000000..4140a0577 --- /dev/null +++ b/packages/react-grab/src/components/ambient-copy-badge.tsx @@ -0,0 +1,204 @@ +import { Show, createSignal, createEffect, on, onCleanup } from "solid-js"; +import type { Component } from "solid-js"; +import type { OverlayBounds } from "../types.js"; +import { + AMBIENT_BADGE_OFFSET_PX, + AMBIENT_BADGE_FADE_MS, + VIEWPORT_MARGIN_PX, + PANEL_STYLES, +} from "../constants.js"; +import { cn } from "../utils/cn.js"; +import { IconCopy } from "./icons/icon-copy.jsx"; +import { IconCheck } from "./icons/icon-check.jsx"; +import { IconOpen } from "./icons/icon-open.jsx"; + +interface AmbientCopyBadgeProps { + visible?: boolean; + bounds?: OverlayBounds; + tagName?: string; + componentName?: string; + hasFilePath?: boolean; + trailCount?: number; + copyStatus?: "idle" | "copied"; + onCopy?: () => void; + onOpenFile?: () => void; + onHoverChange?: (isHovered: boolean) => void; +} + +export const AmbientCopyBadge: Component = (props) => { + let badgeRef: HTMLDivElement | undefined; + + const [shouldMount, setShouldMount] = createSignal(false); + const [isAnimatedIn, setIsAnimatedIn] = createSignal(false); + const [isNameHovered, setIsNameHovered] = createSignal(false); + + let exitTimeout: ReturnType | undefined; + let enterFrameId: number | undefined; + + createEffect( + on( + () => props.visible, + (isVisible) => { + if (isVisible) { + clearTimeout(exitTimeout); + setShouldMount(true); + if (enterFrameId !== undefined) cancelAnimationFrame(enterFrameId); + enterFrameId = requestAnimationFrame(() => { + void badgeRef?.offsetHeight; + setIsAnimatedIn(true); + }); + } else { + if (enterFrameId !== undefined) cancelAnimationFrame(enterFrameId); + setIsAnimatedIn(false); + exitTimeout = setTimeout(() => { + setShouldMount(false); + }, AMBIENT_BADGE_FADE_MS); + } + }, + ), + ); + + onCleanup(() => { + clearTimeout(exitTimeout); + if (enterFrameId !== undefined) cancelAnimationFrame(enterFrameId); + }); + + const badgePosition = () => { + const bounds = props.bounds; + if (!bounds) return { x: -9999, y: -9999, isBelow: false }; + + const estimatedBadgeWidth = 200; + const estimatedBadgeHeight = 28; + + const hasRoomAbove = + bounds.y - estimatedBadgeHeight - AMBIENT_BADGE_OFFSET_PX > + VIEWPORT_MARGIN_PX; + + const rawX = + bounds.x + bounds.width - estimatedBadgeWidth - AMBIENT_BADGE_OFFSET_PX; + const clampedX = Math.max( + VIEWPORT_MARGIN_PX, + Math.min( + rawX, + window.innerWidth - estimatedBadgeWidth - VIEWPORT_MARGIN_PX, + ), + ); + + const positionY = hasRoomAbove + ? bounds.y - estimatedBadgeHeight - AMBIENT_BADGE_OFFSET_PX + : bounds.y + bounds.height + AMBIENT_BADGE_OFFSET_PX; + + return { x: clampedX, y: positionY, isBelow: !hasRoomAbove }; + }; + + const displayName = () => { + if (props.componentName) { + return ( + <> + {props.componentName} + .{props.tagName} + + ); + } + return <{props.tagName}>; + }; + + const handleBadgeMouseEnter = () => { + props.onHoverChange?.(true); + }; + + const handleBadgeMouseLeave = () => { + props.onHoverChange?.(false); + }; + + const handleCopyClick = (event: MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + props.onCopy?.(); + }; + + const handleNameClick = (event: MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + if (props.hasFilePath) { + props.onOpenFile?.(); + } + }; + + return ( + +
event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + > +
+
setIsNameHovered(true)} + onMouseLeave={() => setIsNameHovered(false)} + > + + {displayName()} + + + + +
+ 0}> + + ({props.trailCount}) + + + +
+
+
+ ); +}; diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index b5f4026b4..a898b6942 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -163,6 +163,15 @@ export const NEXTJS_REVALIDATION_DELAY_MS = 1000; export const IME_COMPOSING_KEY_CODE = 229; export const SELECTION_LABEL_OFFSCREEN_PX = -9999; +export const MAX_EVENT_CONTEXT_ENTRIES = 10; +export const AMBIENT_HOVER_DWELL_MS = 400; +export const AMBIENT_DEDUP_THRESHOLD_MS = 100; +export const AMBIENT_BADGE_OFFSET_PX = 6; +export const AMBIENT_BADGE_FADE_MS = 150; +export const AMBIENT_TRAIL_STALENESS_MS = 60_000; +export const AMBIENT_COPY_FEEDBACK_MS = 1500; +export const AMBIENT_BADGE_DISMISS_GRACE_MS = 200; + export const RELEVANT_CSS_PROPERTIES = new Set([ "display", "position", diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index f62392f3c..99b1f5782 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -468,6 +468,17 @@ export interface HistoryItem { timestamp: number; } +export interface EventContextEntry { + id: string; + interactionType: "hover" | "click"; + tagName: string; + componentName: string | null; + filePath: string | null; + lineNumber: number | null; + selector: string | null; + timestamp: number; +} + export interface ReactGrabRendererProps { selectionVisible?: boolean; selectionBounds?: OverlayBounds; @@ -570,6 +581,16 @@ export interface ReactGrabRendererProps { clearPromptPosition?: DropdownAnchor | null; onClearHistoryConfirm?: () => void; onClearHistoryCancel?: () => void; + ambientBadgeVisible?: boolean; + ambientBadgeBounds?: OverlayBounds; + ambientBadgeTagName?: string; + ambientBadgeComponentName?: string; + ambientBadgeHasFilePath?: boolean; + ambientBadgeTrailCount?: number; + ambientBadgeCopyStatus?: "idle" | "copied"; + onAmbientBadgeCopy?: () => void; + onAmbientBadgeOpenFile?: () => void; + onAmbientBadgeHoverChange?: (isHovered: boolean) => void; } export interface GrabbedBox { diff --git a/packages/react-grab/src/utils/event-context-store.ts b/packages/react-grab/src/utils/event-context-store.ts new file mode 100644 index 000000000..b967edccd --- /dev/null +++ b/packages/react-grab/src/utils/event-context-store.ts @@ -0,0 +1,114 @@ +import { + MAX_EVENT_CONTEXT_ENTRIES, + AMBIENT_DEDUP_THRESHOLD_MS, + AMBIENT_TRAIL_STALENESS_MS, +} from "../constants.js"; +import type { EventContextEntry } from "../types.js"; + +const generateEntryId = (): string => + `event-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + +const formatEntryLine = ( + entry: EventContextEntry, + index: number, +): string => { + let line = `${index + 1}. [${entry.interactionType}] <${entry.tagName}>`; + + if (entry.componentName) { + line += ` in ${entry.componentName}`; + } + + if (entry.filePath) { + line += ` (${entry.filePath}`; + if (entry.lineNumber !== null) { + line += `:${entry.lineNumber}`; + } + line += `)`; + } + + return line; +}; + +export const createEventContextStore = () => { + let entries: EventContextEntry[] = []; + + const isDuplicate = ( + incoming: Omit, + ): boolean => { + if (entries.length === 0) return false; + const mostRecent = entries[0]; + const timeDelta = incoming.timestamp - mostRecent.timestamp; + if (timeDelta > AMBIENT_DEDUP_THRESHOLD_MS) return false; + return ( + mostRecent.selector !== null && + mostRecent.selector === incoming.selector && + mostRecent.interactionType === incoming.interactionType + ); + }; + + const shouldSubsumeHover = ( + incoming: Omit, + ): boolean => { + if (entries.length === 0) return false; + if (incoming.interactionType !== "click") return false; + const mostRecent = entries[0]; + if (mostRecent.interactionType !== "hover") return false; + const timeDelta = incoming.timestamp - mostRecent.timestamp; + if (timeDelta > AMBIENT_DEDUP_THRESHOLD_MS) return false; + return ( + mostRecent.selector !== null && mostRecent.selector === incoming.selector + ); + }; + + const addEntry = ( + entry: Omit, + ): EventContextEntry[] => { + if (isDuplicate(entry)) return entries; + + const newEntry: EventContextEntry = { + ...entry, + id: generateEntryId(), + }; + + if (shouldSubsumeHover(entry)) { + entries = [newEntry, ...entries.slice(1)].slice( + 0, + MAX_EVENT_CONTEXT_ENTRIES, + ); + } else { + entries = [newEntry, ...entries].slice(0, MAX_EVENT_CONTEXT_ENTRIES); + } + + return entries; + }; + + const getEntries = (): EventContextEntry[] => entries; + + const getFreshEntries = (): EventContextEntry[] => { + const cutoff = Date.now() - AMBIENT_TRAIL_STALENESS_MS; + return entries.filter((entry) => entry.timestamp >= cutoff); + }; + + const clear = (): void => { + entries = []; + }; + + const formatTrailForCopy = (): string => { + const freshEntries = getFreshEntries(); + if (freshEntries.length === 0) return ""; + + const lines = freshEntries.map((entry, index) => + formatEntryLine(entry, index), + ); + + return `Recent user interactions (most recent first):\n\n${lines.join("\n")}`; + }; + + return { + addEntry, + getEntries, + getFreshEntries, + clear, + formatTrailForCopy, + }; +}; From 111fe3278f1477707fc86ba42ae0e71f557f4645 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Feb 2026 01:53:42 +0000 Subject: [PATCH 02/10] feat: add ambient event tracking, trail auto-copy on blur, wire renderer props Co-authored-by: Aiden Bai --- packages/react-grab/src/core/index.tsx | 283 ++++++++++++++++++++++++- 1 file changed, 282 insertions(+), 1 deletion(-) diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 192354ea0..a7a127f0d 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -76,6 +76,9 @@ import { PREVIEW_TEXT_MAX_LENGTH, DEFERRED_EXECUTION_DELAY_MS, NEXTJS_REVALIDATION_DELAY_MS, + AMBIENT_HOVER_DWELL_MS, + AMBIENT_COPY_FEEDBACK_MS, + AMBIENT_BADGE_DISMISS_GRACE_MS, } from "../constants.js"; import { getBoundsCenter } from "../utils/get-bounds-center.js"; import { isCLikeKey } from "../utils/is-c-like-key.js"; @@ -108,6 +111,7 @@ import type { ToolbarState, HistoryItem, DropdownAnchor, + EventContextEntry, } from "../types.js"; import { DEFAULT_THEME } from "./theme.js"; import { createPluginRegistry } from "./plugin-registry.js"; @@ -151,6 +155,7 @@ import { } from "../utils/history-storage.js"; import { copyContent } from "../utils/copy-content.js"; import { joinSnippets } from "../utils/join-snippets.js"; +import { createEventContextStore } from "../utils/event-context-store.js"; const builtInPlugins = [ copyPlugin, @@ -290,6 +295,245 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const [isHistoryHoverOpen, setIsHistoryHoverOpen] = createSignal(false); let historyHoverPreviews: { boxId: string; labelId: string | null }[] = []; + const eventContextStore = createEventContextStore(); + const [ambientElement, setAmbientElement] = + createSignal(null); + const [ambientBounds, setAmbientBounds] = + createSignal(null); + const [ambientComponentName, setAmbientComponentName] = + createSignal(null); + const [ambientFilePath, setAmbientFilePath] = + createSignal(null); + const [ambientLineNumber, setAmbientLineNumber] = + createSignal(null); + const [ambientTagName, setAmbientTagName] = createSignal(""); + const [ambientBadgeVisible, setAmbientBadgeVisible] = createSignal(false); + const [ambientBadgeCopyStatus, setAmbientBadgeCopyStatus] = + createSignal<"idle" | "copied">("idle"); + const [ambientTrailCount, setAmbientTrailCount] = createSignal(0); + let ambientHoverTimer: ReturnType | null = null; + let ambientDismissTimer: ReturnType | null = null; + let ambientCopyFeedbackTimer: ReturnType | null = null; + let lastAmbientElement: Element | null = null; + let isAmbientBadgeHovered = false; + + const clearAmbientHoverTimer = () => { + if (ambientHoverTimer !== null) { + clearTimeout(ambientHoverTimer); + ambientHoverTimer = null; + } + }; + + const clearAmbientDismissTimer = () => { + if (ambientDismissTimer !== null) { + clearTimeout(ambientDismissTimer); + ambientDismissTimer = null; + } + }; + + const hideAmbientBadge = () => { + setAmbientBadgeVisible(false); + setAmbientElement(null); + lastAmbientElement = null; + }; + + const hideAmbientBadgeWithGrace = () => { + if (isAmbientBadgeHovered) return; + clearAmbientDismissTimer(); + ambientDismissTimer = setTimeout(() => { + if (!isAmbientBadgeHovered) { + hideAmbientBadge(); + } + }, AMBIENT_BADGE_DISMISS_GRACE_MS); + }; + + const resolveAndShowAmbientBadge = (element: Element) => { + const bounds = createElementBounds(element); + const tagName = getTagName(element); + setAmbientBounds(bounds); + setAmbientTagName(tagName); + setAmbientElement(element); + setAmbientBadgeVisible(true); + + setAmbientComponentName(null); + setAmbientFilePath(null); + setAmbientLineNumber(null); + + void getNearestComponentName(element).then( + (resolvedComponentName) => { + if (ambientElement() !== element) return; + setAmbientComponentName(resolvedComponentName); + }, + ); + + void getStack(element).then((stack) => { + if (ambientElement() !== element) return; + const source = resolveSourceFromStack(stack); + if (source) { + setAmbientFilePath(source.filePath); + setAmbientLineNumber(source.lineNumber ?? null); + if (source.componentName) { + setAmbientComponentName(source.componentName); + } + } + }); + }; + + const addAmbientTrailEntry = ( + element: Element, + interactionType: EventContextEntry["interactionType"], + ) => { + const tagName = getTagName(element); + const selector = createElementSelector(element, true); + + const entry: Omit = { + interactionType, + tagName, + componentName: ambientComponentName(), + filePath: ambientFilePath(), + lineNumber: ambientLineNumber(), + selector, + timestamp: Date.now(), + }; + + const updatedEntries = eventContextStore.addEntry(entry); + setAmbientTrailCount(updatedEntries.length); + + void getNearestComponentName(element).then( + (resolvedComponentName) => { + if (!resolvedComponentName) return; + const currentEntries = eventContextStore.getEntries(); + const latestEntry = currentEntries[0]; + if ( + latestEntry && + latestEntry.selector === selector && + latestEntry.componentName === null + ) { + latestEntry.componentName = resolvedComponentName; + } + }, + ); + + void getStack(element).then((stack) => { + const source = resolveSourceFromStack(stack); + if (!source) return; + const currentEntries = eventContextStore.getEntries(); + const latestEntry = currentEntries[0]; + if (latestEntry && latestEntry.selector === selector) { + if (latestEntry.filePath === null) { + latestEntry.filePath = source.filePath; + latestEntry.lineNumber = source.lineNumber ?? null; + } + if (latestEntry.componentName === null && source.componentName) { + latestEntry.componentName = source.componentName; + } + } + }); + }; + + const handleAmbientPointerMove = ( + clientX: number, + clientY: number, + ) => { + if (isActivated() || !isEnabled()) return; + + const candidate = getElementAtPosition(clientX, clientY); + + if (candidate === lastAmbientElement) return; + + clearAmbientHoverTimer(); + + if ( + !candidate || + isRootElement(candidate) || + isEventFromOverlay( + { target: candidate } as unknown as Event, + "data-react-grab-ignore-events", + ) + ) { + lastAmbientElement = null; + hideAmbientBadgeWithGrace(); + return; + } + + lastAmbientElement = candidate; + clearAmbientDismissTimer(); + + ambientHoverTimer = setTimeout(() => { + if (lastAmbientElement !== candidate) return; + resolveAndShowAmbientBadge(candidate); + addAmbientTrailEntry(candidate, "hover"); + }, AMBIENT_HOVER_DWELL_MS); + }; + + const handleAmbientClick = ( + clientX: number, + clientY: number, + ) => { + if (isActivated() || !isEnabled()) return; + + const candidate = getElementAtPosition(clientX, clientY); + + if ( + !candidate || + isRootElement(candidate) || + isEventFromOverlay( + { target: candidate } as unknown as Event, + "data-react-grab-ignore-events", + ) + ) { + return; + } + + clearAmbientHoverTimer(); + clearAmbientDismissTimer(); + lastAmbientElement = candidate; + resolveAndShowAmbientBadge(candidate); + addAmbientTrailEntry(candidate, "click"); + }; + + const handleAmbientBadgeCopy = () => { + const trailText = eventContextStore.formatTrailForCopy(); + if (!trailText) return; + copyContent(trailText, { + componentName: + ambientComponentName() ?? ambientTagName() ?? "interactions", + }); + setAmbientBadgeCopyStatus("copied"); + if (ambientCopyFeedbackTimer !== null) { + clearTimeout(ambientCopyFeedbackTimer); + } + ambientCopyFeedbackTimer = setTimeout(() => { + setAmbientBadgeCopyStatus("idle"); + }, AMBIENT_COPY_FEEDBACK_MS); + }; + + const handleAmbientBadgeOpenFile = () => { + const filePath = ambientFilePath(); + if (filePath) { + openFile( + filePath, + ambientLineNumber() ?? undefined, + pluginRegistry.hooks, + ); + } + }; + + const handleAmbientBadgeHoverChange = (isHovered: boolean) => { + isAmbientBadgeHovered = isHovered; + if (!isHovered) { + hideAmbientBadgeWithGrace(); + } else { + clearAmbientDismissTimer(); + } + }; + + const clearAmbientState = () => { + clearAmbientHoverTimer(); + clearAmbientDismissTimer(); + hideAmbientBadge(); + }; + const getMappedHistoryElements = (historyItemId: string): Element[] => historyElementMap.get(historyItemId) ?? []; @@ -1487,6 +1731,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ); const activateRenderer = () => { + clearAmbientState(); const wasInHoldingState = isHoldingKeys(); actions.activate(); // HACK: Only call onActivate if we weren't in holding state. @@ -2715,13 +2960,19 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { actions.setTouchMode(isTouchPointer); if (isEventFromOverlay(event, "data-react-grab-ignore-events")) return; if (store.contextMenuPosition !== null) return; - if (isTouchPointer && !isHoldingKeys() && !isActivated()) return; + if (isTouchPointer && !isHoldingKeys() && !isActivated()) { + handleAmbientPointerMove(event.clientX, event.clientY); + return; + } const isActiveState = isTouchPointer ? isHoldingKeys() : isActivated(); if (isActiveState && !isPromptMode() && isToggleFrozen()) { actions.unfreeze(); arrowNavigator.clearHistory(); } handlePointerMove(event.clientX, event.clientY); + if (!isActiveState) { + handleAmbientPointerMove(event.clientX, event.clientY); + } }, { passive: true }, ); @@ -2776,6 +3027,14 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { { capture: true }, ); + eventListenerManager.addWindowListener( + "click", + (event: MouseEvent) => { + handleAmbientClick(event.clientX, event.clientY); + }, + { passive: true }, + ); + eventListenerManager.addWindowListener( "contextmenu", (event: MouseEvent) => { @@ -2864,6 +3123,13 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { actions.release(); resetCopyConfirmation(); } + + if (isEnabled() && eventContextStore.getEntries().length > 0) { + const trailText = eventContextStore.formatTrailForCopy(); + if (trailText) { + copyContent(trailText, { componentName: "interactions" }); + } + } }); eventListenerManager.addWindowListener("focus", () => { @@ -4034,6 +4300,16 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { handleHistoryClear(); }} onClearHistoryCancel={dismissClearPrompt} + ambientBadgeVisible={ambientBadgeVisible()} + ambientBadgeBounds={ambientBounds() ?? undefined} + ambientBadgeTagName={ambientTagName()} + ambientBadgeComponentName={ambientComponentName() ?? undefined} + ambientBadgeHasFilePath={Boolean(ambientFilePath())} + ambientBadgeTrailCount={ambientTrailCount()} + ambientBadgeCopyStatus={ambientBadgeCopyStatus()} + onAmbientBadgeCopy={handleAmbientBadgeCopy} + onAmbientBadgeOpenFile={handleAmbientBadgeOpenFile} + onAmbientBadgeHoverChange={handleAmbientBadgeHoverChange} /> ); }, rendererRoot); @@ -4155,6 +4431,11 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { cancelHistoryHoverCloseTimeout(); stopTrackingDropdownPosition(); toolbarStateChangeCallbacks.clear(); + clearAmbientState(); + eventContextStore.clear(); + if (ambientCopyFeedbackTimer !== null) { + clearTimeout(ambientCopyFeedbackTimer); + } dispose(); }, copyElement: copyElementAPI, From dd75136e7672ff4668cebc27375ff23484ddc5e1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Feb 2026 01:54:25 +0000 Subject: [PATCH 03/10] feat: wire AmbientCopyBadge into renderer Co-authored-by: Aiden Bai --- packages/react-grab/src/components/renderer.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/react-grab/src/components/renderer.tsx b/packages/react-grab/src/components/renderer.tsx index 97905f86d..89d14df2f 100644 --- a/packages/react-grab/src/components/renderer.tsx +++ b/packages/react-grab/src/components/renderer.tsx @@ -15,6 +15,7 @@ import { ToolbarMenu } from "./toolbar/toolbar-menu.js"; import { ContextMenu } from "./context-menu.js"; import { HistoryDropdown } from "./history-dropdown.js"; import { ClearHistoryPrompt } from "./clear-history-prompt.js"; +import { AmbientCopyBadge } from "./ambient-copy-badge.js"; export const ReactGrabRenderer: Component = (props) => { return ( @@ -53,6 +54,19 @@ export const ReactGrabRenderer: Component = (props) => { }} /> + + Date: Sat, 28 Feb 2026 01:56:48 +0000 Subject: [PATCH 04/10] fix: apply formatter, fix openFile type signature Co-authored-by: Aiden Bai --- .../src/components/context-menu.tsx | 5 +- .../src/components/toolbar/index.tsx | 8 +- packages/react-grab/src/core/index.tsx | 73 +++++++++---------- .../src/utils/create-menu-highlight.ts | 14 ++-- .../src/utils/event-context-store.ts | 9 +-- 5 files changed, 49 insertions(+), 60 deletions(-) diff --git a/packages/react-grab/src/components/context-menu.tsx b/packages/react-grab/src/components/context-menu.tsx index cf47b6078..f0961a65b 100644 --- a/packages/react-grab/src/components/context-menu.tsx +++ b/packages/react-grab/src/components/context-menu.tsx @@ -305,7 +305,10 @@ export const ContextMenu: Component = (props) => { /> -
+
= (props) => { "transform-origin": getTransformOrigin(), }} onPointerDown={handlePointerDown} - onMouseEnter={() => - !isCollapsed() && props.onSelectHoverChange?.(true) - } + onMouseEnter={() => !isCollapsed() && props.onSelectHoverChange?.(true)} onMouseLeave={() => props.onSelectHoverChange?.(false)} >
= (props) => { data-react-grab-ignore-events data-react-grab-toolbar-toggle aria-label={ - props.isActive ? "Stop selecting element" : "Select element" + props.isActive + ? "Stop selecting element" + : "Select element" } aria-pressed={Boolean(props.isActive)} class={cn( diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index a7a127f0d..a8a6ee01a 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -296,20 +296,25 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { let historyHoverPreviews: { boxId: string; labelId: string | null }[] = []; const eventContextStore = createEventContextStore(); - const [ambientElement, setAmbientElement] = - createSignal(null); + const [ambientElement, setAmbientElement] = createSignal( + null, + ); const [ambientBounds, setAmbientBounds] = createSignal(null); - const [ambientComponentName, setAmbientComponentName] = - createSignal(null); - const [ambientFilePath, setAmbientFilePath] = - createSignal(null); - const [ambientLineNumber, setAmbientLineNumber] = - createSignal(null); + const [ambientComponentName, setAmbientComponentName] = createSignal< + string | null + >(null); + const [ambientFilePath, setAmbientFilePath] = createSignal( + null, + ); + const [ambientLineNumber, setAmbientLineNumber] = createSignal< + number | null + >(null); const [ambientTagName, setAmbientTagName] = createSignal(""); const [ambientBadgeVisible, setAmbientBadgeVisible] = createSignal(false); - const [ambientBadgeCopyStatus, setAmbientBadgeCopyStatus] = - createSignal<"idle" | "copied">("idle"); + const [ambientBadgeCopyStatus, setAmbientBadgeCopyStatus] = createSignal< + "idle" | "copied" + >("idle"); const [ambientTrailCount, setAmbientTrailCount] = createSignal(0); let ambientHoverTimer: ReturnType | null = null; let ambientDismissTimer: ReturnType | null = null; @@ -359,12 +364,10 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { setAmbientFilePath(null); setAmbientLineNumber(null); - void getNearestComponentName(element).then( - (resolvedComponentName) => { - if (ambientElement() !== element) return; - setAmbientComponentName(resolvedComponentName); - }, - ); + void getNearestComponentName(element).then((resolvedComponentName) => { + if (ambientElement() !== element) return; + setAmbientComponentName(resolvedComponentName); + }); void getStack(element).then((stack) => { if (ambientElement() !== element) return; @@ -399,20 +402,18 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const updatedEntries = eventContextStore.addEntry(entry); setAmbientTrailCount(updatedEntries.length); - void getNearestComponentName(element).then( - (resolvedComponentName) => { - if (!resolvedComponentName) return; - const currentEntries = eventContextStore.getEntries(); - const latestEntry = currentEntries[0]; - if ( - latestEntry && - latestEntry.selector === selector && - latestEntry.componentName === null - ) { - latestEntry.componentName = resolvedComponentName; - } - }, - ); + void getNearestComponentName(element).then((resolvedComponentName) => { + if (!resolvedComponentName) return; + const currentEntries = eventContextStore.getEntries(); + const latestEntry = currentEntries[0]; + if ( + latestEntry && + latestEntry.selector === selector && + latestEntry.componentName === null + ) { + latestEntry.componentName = resolvedComponentName; + } + }); void getStack(element).then((stack) => { const source = resolveSourceFromStack(stack); @@ -431,10 +432,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }); }; - const handleAmbientPointerMove = ( - clientX: number, - clientY: number, - ) => { + const handleAmbientPointerMove = (clientX: number, clientY: number) => { if (isActivated() || !isEnabled()) return; const candidate = getElementAtPosition(clientX, clientY); @@ -466,10 +464,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }, AMBIENT_HOVER_DWELL_MS); }; - const handleAmbientClick = ( - clientX: number, - clientY: number, - ) => { + const handleAmbientClick = (clientX: number, clientY: number) => { if (isActivated() || !isEnabled()) return; const candidate = getElementAtPosition(clientX, clientY); @@ -514,7 +509,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { openFile( filePath, ambientLineNumber() ?? undefined, - pluginRegistry.hooks, + pluginRegistry.hooks.transformOpenFileUrl, ); } }; diff --git a/packages/react-grab/src/utils/create-menu-highlight.ts b/packages/react-grab/src/utils/create-menu-highlight.ts index ecddf3d27..ae38e1b93 100644 --- a/packages/react-grab/src/utils/create-menu-highlight.ts +++ b/packages/react-grab/src/utils/create-menu-highlight.ts @@ -20,12 +20,10 @@ interface MenuHighlightController { const DEFAULT_HIDDEN_OPACITY = "0"; const DEFAULT_VISIBLE_OPACITY = "1"; -export const createAnimatedBoundsFollower = ( - { - hiddenOpacity = DEFAULT_HIDDEN_OPACITY, - visibleOpacity = DEFAULT_VISIBLE_OPACITY, - }: AnimatedBoundsFollowerOptions = {}, -): AnimatedBoundsFollowerController => { +export const createAnimatedBoundsFollower = ({ + hiddenOpacity = DEFAULT_HIDDEN_OPACITY, + visibleOpacity = DEFAULT_VISIBLE_OPACITY, +}: AnimatedBoundsFollowerOptions = {}): AnimatedBoundsFollowerController => { let containerElement: HTMLElement | undefined; let followerElement: HTMLElement | undefined; @@ -34,9 +32,7 @@ export const createAnimatedBoundsFollower = ( followerElement.style.opacity = hiddenOpacity; }; - const followElement = ( - targetElement: HTMLElement | undefined, - ): void => { + const followElement = (targetElement: HTMLElement | undefined): void => { if (!followerElement || !containerElement) return; if (!targetElement) { hideFollower(); diff --git a/packages/react-grab/src/utils/event-context-store.ts b/packages/react-grab/src/utils/event-context-store.ts index b967edccd..6e83107e0 100644 --- a/packages/react-grab/src/utils/event-context-store.ts +++ b/packages/react-grab/src/utils/event-context-store.ts @@ -8,10 +8,7 @@ import type { EventContextEntry } from "../types.js"; const generateEntryId = (): string => `event-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; -const formatEntryLine = ( - entry: EventContextEntry, - index: number, -): string => { +const formatEntryLine = (entry: EventContextEntry, index: number): string => { let line = `${index + 1}. [${entry.interactionType}] <${entry.tagName}>`; if (entry.componentName) { @@ -32,9 +29,7 @@ const formatEntryLine = ( export const createEventContextStore = () => { let entries: EventContextEntry[] = []; - const isDuplicate = ( - incoming: Omit, - ): boolean => { + const isDuplicate = (incoming: Omit): boolean => { if (entries.length === 0) return false; const mostRecent = entries[0]; const timeDelta = incoming.timestamp - mostRecent.timestamp; From 590977601db5e6634a0d57a9f59ae7f9a5a71cb7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Feb 2026 02:04:18 +0000 Subject: [PATCH 05/10] fix: replace fake event object with direct element check for overlay detection Co-authored-by: Aiden Bai --- packages/react-grab/src/core/index.tsx | 40 ++++++++++++++------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index a8a6ee01a..ca8178bd8 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -432,6 +432,26 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }); }; + const isOverlayElement = (element: Element): boolean => { + let current: Element | null = element; + while (current) { + if ( + current instanceof HTMLElement && + current.hasAttribute("data-react-grab-ignore-events") + ) { + return true; + } + current = + current.parentElement ?? + (current.getRootNode() as ShadowRoot).host ?? + null; + } + return false; + }; + + const isAmbientTrackable = (element: Element | null): element is Element => + element !== null && !isRootElement(element) && !isOverlayElement(element); + const handleAmbientPointerMove = (clientX: number, clientY: number) => { if (isActivated() || !isEnabled()) return; @@ -441,14 +461,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { clearAmbientHoverTimer(); - if ( - !candidate || - isRootElement(candidate) || - isEventFromOverlay( - { target: candidate } as unknown as Event, - "data-react-grab-ignore-events", - ) - ) { + if (!isAmbientTrackable(candidate)) { lastAmbientElement = null; hideAmbientBadgeWithGrace(); return; @@ -469,16 +482,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const candidate = getElementAtPosition(clientX, clientY); - if ( - !candidate || - isRootElement(candidate) || - isEventFromOverlay( - { target: candidate } as unknown as Event, - "data-react-grab-ignore-events", - ) - ) { - return; - } + if (!isAmbientTrackable(candidate)) return; clearAmbientHoverTimer(); clearAmbientDismissTimer(); From 427499691433e4fa22d8a9ae233970db20a7871c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Feb 2026 02:04:57 +0000 Subject: [PATCH 06/10] feat: export EventContextEntry type from package index Co-authored-by: Aiden Bai --- packages/react-grab/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-grab/src/index.ts b/packages/react-grab/src/index.ts index aa7893916..a5feffd05 100644 --- a/packages/react-grab/src/index.ts +++ b/packages/react-grab/src/index.ts @@ -38,6 +38,7 @@ export type { PluginAction, ActionContext, ActionContextHooks, + EventContextEntry, Plugin, PluginConfig, PluginHooks, From 23ef5a6b940ca0a10a51158ebba11aa0262f308f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Feb 2026 03:18:18 +0000 Subject: [PATCH 07/10] refactor: remove ambient copy badge, replace with simpler toolbar props Co-authored-by: Aiden Bai --- .../src/components/ambient-copy-badge.tsx | 204 ------------------ .../react-grab/src/components/renderer.tsx | 17 +- packages/react-grab/src/constants.ts | 3 - packages/react-grab/src/types.ts | 13 +- 4 files changed, 6 insertions(+), 231 deletions(-) delete mode 100644 packages/react-grab/src/components/ambient-copy-badge.tsx diff --git a/packages/react-grab/src/components/ambient-copy-badge.tsx b/packages/react-grab/src/components/ambient-copy-badge.tsx deleted file mode 100644 index 4140a0577..000000000 --- a/packages/react-grab/src/components/ambient-copy-badge.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { Show, createSignal, createEffect, on, onCleanup } from "solid-js"; -import type { Component } from "solid-js"; -import type { OverlayBounds } from "../types.js"; -import { - AMBIENT_BADGE_OFFSET_PX, - AMBIENT_BADGE_FADE_MS, - VIEWPORT_MARGIN_PX, - PANEL_STYLES, -} from "../constants.js"; -import { cn } from "../utils/cn.js"; -import { IconCopy } from "./icons/icon-copy.jsx"; -import { IconCheck } from "./icons/icon-check.jsx"; -import { IconOpen } from "./icons/icon-open.jsx"; - -interface AmbientCopyBadgeProps { - visible?: boolean; - bounds?: OverlayBounds; - tagName?: string; - componentName?: string; - hasFilePath?: boolean; - trailCount?: number; - copyStatus?: "idle" | "copied"; - onCopy?: () => void; - onOpenFile?: () => void; - onHoverChange?: (isHovered: boolean) => void; -} - -export const AmbientCopyBadge: Component = (props) => { - let badgeRef: HTMLDivElement | undefined; - - const [shouldMount, setShouldMount] = createSignal(false); - const [isAnimatedIn, setIsAnimatedIn] = createSignal(false); - const [isNameHovered, setIsNameHovered] = createSignal(false); - - let exitTimeout: ReturnType | undefined; - let enterFrameId: number | undefined; - - createEffect( - on( - () => props.visible, - (isVisible) => { - if (isVisible) { - clearTimeout(exitTimeout); - setShouldMount(true); - if (enterFrameId !== undefined) cancelAnimationFrame(enterFrameId); - enterFrameId = requestAnimationFrame(() => { - void badgeRef?.offsetHeight; - setIsAnimatedIn(true); - }); - } else { - if (enterFrameId !== undefined) cancelAnimationFrame(enterFrameId); - setIsAnimatedIn(false); - exitTimeout = setTimeout(() => { - setShouldMount(false); - }, AMBIENT_BADGE_FADE_MS); - } - }, - ), - ); - - onCleanup(() => { - clearTimeout(exitTimeout); - if (enterFrameId !== undefined) cancelAnimationFrame(enterFrameId); - }); - - const badgePosition = () => { - const bounds = props.bounds; - if (!bounds) return { x: -9999, y: -9999, isBelow: false }; - - const estimatedBadgeWidth = 200; - const estimatedBadgeHeight = 28; - - const hasRoomAbove = - bounds.y - estimatedBadgeHeight - AMBIENT_BADGE_OFFSET_PX > - VIEWPORT_MARGIN_PX; - - const rawX = - bounds.x + bounds.width - estimatedBadgeWidth - AMBIENT_BADGE_OFFSET_PX; - const clampedX = Math.max( - VIEWPORT_MARGIN_PX, - Math.min( - rawX, - window.innerWidth - estimatedBadgeWidth - VIEWPORT_MARGIN_PX, - ), - ); - - const positionY = hasRoomAbove - ? bounds.y - estimatedBadgeHeight - AMBIENT_BADGE_OFFSET_PX - : bounds.y + bounds.height + AMBIENT_BADGE_OFFSET_PX; - - return { x: clampedX, y: positionY, isBelow: !hasRoomAbove }; - }; - - const displayName = () => { - if (props.componentName) { - return ( - <> - {props.componentName} - .{props.tagName} - - ); - } - return <{props.tagName}>; - }; - - const handleBadgeMouseEnter = () => { - props.onHoverChange?.(true); - }; - - const handleBadgeMouseLeave = () => { - props.onHoverChange?.(false); - }; - - const handleCopyClick = (event: MouseEvent) => { - event.stopPropagation(); - event.preventDefault(); - props.onCopy?.(); - }; - - const handleNameClick = (event: MouseEvent) => { - event.stopPropagation(); - event.preventDefault(); - if (props.hasFilePath) { - props.onOpenFile?.(); - } - }; - - return ( - -
event.stopPropagation()} - onMouseDown={(event) => event.stopPropagation()} - > -
-
setIsNameHovered(true)} - onMouseLeave={() => setIsNameHovered(false)} - > - - {displayName()} - - - - -
- 0}> - - ({props.trailCount}) - - - -
-
-
- ); -}; diff --git a/packages/react-grab/src/components/renderer.tsx b/packages/react-grab/src/components/renderer.tsx index 89d14df2f..d8656681f 100644 --- a/packages/react-grab/src/components/renderer.tsx +++ b/packages/react-grab/src/components/renderer.tsx @@ -15,7 +15,6 @@ import { ToolbarMenu } from "./toolbar/toolbar-menu.js"; import { ContextMenu } from "./context-menu.js"; import { HistoryDropdown } from "./history-dropdown.js"; import { ClearHistoryPrompt } from "./clear-history-prompt.js"; -import { AmbientCopyBadge } from "./ambient-copy-badge.js"; export const ReactGrabRenderer: Component = (props) => { return ( @@ -54,19 +53,6 @@ export const ReactGrabRenderer: Component = (props) => { }} /> - - = (props) => { onToggleMenu={props.onToggleMenu} isMenuOpen={Boolean(props.toolbarMenuPosition)} isClearPromptOpen={Boolean(props.clearPromptPosition)} + eventContextTrailCount={props.eventContextTrailCount} + eventContextCopyStatus={props.eventContextCopyStatus} + onCopyEventContext={props.onCopyEventContext} /> diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index a898b6942..49cbc6984 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -166,11 +166,8 @@ export const SELECTION_LABEL_OFFSCREEN_PX = -9999; export const MAX_EVENT_CONTEXT_ENTRIES = 10; export const AMBIENT_HOVER_DWELL_MS = 400; export const AMBIENT_DEDUP_THRESHOLD_MS = 100; -export const AMBIENT_BADGE_OFFSET_PX = 6; -export const AMBIENT_BADGE_FADE_MS = 150; export const AMBIENT_TRAIL_STALENESS_MS = 60_000; export const AMBIENT_COPY_FEEDBACK_MS = 1500; -export const AMBIENT_BADGE_DISMISS_GRACE_MS = 200; export const RELEVANT_CSS_PROPERTIES = new Set([ "display", diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index 99b1f5782..ff25067ae 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -581,16 +581,9 @@ export interface ReactGrabRendererProps { clearPromptPosition?: DropdownAnchor | null; onClearHistoryConfirm?: () => void; onClearHistoryCancel?: () => void; - ambientBadgeVisible?: boolean; - ambientBadgeBounds?: OverlayBounds; - ambientBadgeTagName?: string; - ambientBadgeComponentName?: string; - ambientBadgeHasFilePath?: boolean; - ambientBadgeTrailCount?: number; - ambientBadgeCopyStatus?: "idle" | "copied"; - onAmbientBadgeCopy?: () => void; - onAmbientBadgeOpenFile?: () => void; - onAmbientBadgeHoverChange?: (isHovered: boolean) => void; + eventContextTrailCount?: number; + eventContextCopyStatus?: "idle" | "copied"; + onCopyEventContext?: () => void; } export interface GrabbedBox { From 64034e9b5952bf1ca6ecc3c05b1f367a1bdd8e71 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Feb 2026 03:20:56 +0000 Subject: [PATCH 08/10] =?UTF-8?q?refactor:=20simplify=20core=20tracking=20?= =?UTF-8?q?=E2=80=94=20strip=20badge=20signals,=20keep=20trail=20+=20hover?= =?UTF-8?q?/click?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Aiden Bai --- packages/react-grab/src/core/index.tsx | 217 ++++++------------------- 1 file changed, 53 insertions(+), 164 deletions(-) diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index ca8178bd8..02917228a 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -78,7 +78,6 @@ import { NEXTJS_REVALIDATION_DELAY_MS, AMBIENT_HOVER_DWELL_MS, AMBIENT_COPY_FEEDBACK_MS, - AMBIENT_BADGE_DISMISS_GRACE_MS, } from "../constants.js"; import { getBoundsCenter } from "../utils/get-bounds-center.js"; import { isCLikeKey } from "../utils/is-c-like-key.js"; @@ -296,31 +295,14 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { let historyHoverPreviews: { boxId: string; labelId: string | null }[] = []; const eventContextStore = createEventContextStore(); - const [ambientElement, setAmbientElement] = createSignal( - null, - ); - const [ambientBounds, setAmbientBounds] = - createSignal(null); - const [ambientComponentName, setAmbientComponentName] = createSignal< - string | null - >(null); - const [ambientFilePath, setAmbientFilePath] = createSignal( - null, - ); - const [ambientLineNumber, setAmbientLineNumber] = createSignal< - number | null - >(null); - const [ambientTagName, setAmbientTagName] = createSignal(""); - const [ambientBadgeVisible, setAmbientBadgeVisible] = createSignal(false); - const [ambientBadgeCopyStatus, setAmbientBadgeCopyStatus] = createSignal< + const [eventContextTrailCount, setEventContextTrailCount] = createSignal(0); + const [eventContextCopyStatus, setEventContextCopyStatus] = createSignal< "idle" | "copied" >("idle"); - const [ambientTrailCount, setAmbientTrailCount] = createSignal(0); let ambientHoverTimer: ReturnType | null = null; - let ambientDismissTimer: ReturnType | null = null; - let ambientCopyFeedbackTimer: ReturnType | null = null; + let eventContextCopyFeedbackTimer: ReturnType | null = + null; let lastAmbientElement: Element | null = null; - let isAmbientBadgeHovered = false; const clearAmbientHoverTimer = () => { if (ambientHoverTimer !== null) { @@ -329,58 +311,24 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { } }; - const clearAmbientDismissTimer = () => { - if (ambientDismissTimer !== null) { - clearTimeout(ambientDismissTimer); - ambientDismissTimer = null; - } - }; - - const hideAmbientBadge = () => { - setAmbientBadgeVisible(false); - setAmbientElement(null); - lastAmbientElement = null; - }; - - const hideAmbientBadgeWithGrace = () => { - if (isAmbientBadgeHovered) return; - clearAmbientDismissTimer(); - ambientDismissTimer = setTimeout(() => { - if (!isAmbientBadgeHovered) { - hideAmbientBadge(); + const isOverlayElement = (element: Element): boolean => { + let current: Element | null = element; + while (current) { + if ( + current instanceof HTMLElement && + current.hasAttribute("data-react-grab-ignore-events") + ) { + return true; } - }, AMBIENT_BADGE_DISMISS_GRACE_MS); + current = + current.parentElement ?? + ((current.getRootNode() as ShadowRoot).host ?? null); + } + return false; }; - const resolveAndShowAmbientBadge = (element: Element) => { - const bounds = createElementBounds(element); - const tagName = getTagName(element); - setAmbientBounds(bounds); - setAmbientTagName(tagName); - setAmbientElement(element); - setAmbientBadgeVisible(true); - - setAmbientComponentName(null); - setAmbientFilePath(null); - setAmbientLineNumber(null); - - void getNearestComponentName(element).then((resolvedComponentName) => { - if (ambientElement() !== element) return; - setAmbientComponentName(resolvedComponentName); - }); - - void getStack(element).then((stack) => { - if (ambientElement() !== element) return; - const source = resolveSourceFromStack(stack); - if (source) { - setAmbientFilePath(source.filePath); - setAmbientLineNumber(source.lineNumber ?? null); - if (source.componentName) { - setAmbientComponentName(source.componentName); - } - } - }); - }; + const isAmbientTrackable = (element: Element | null): element is Element => + element !== null && !isRootElement(element) && !isOverlayElement(element); const addAmbientTrailEntry = ( element: Element, @@ -392,26 +340,26 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const entry: Omit = { interactionType, tagName, - componentName: ambientComponentName(), - filePath: ambientFilePath(), - lineNumber: ambientLineNumber(), + componentName: null, + filePath: null, + lineNumber: null, selector, timestamp: Date.now(), }; const updatedEntries = eventContextStore.addEntry(entry); - setAmbientTrailCount(updatedEntries.length); + setEventContextTrailCount(updatedEntries.length); void getNearestComponentName(element).then((resolvedComponentName) => { if (!resolvedComponentName) return; const currentEntries = eventContextStore.getEntries(); - const latestEntry = currentEntries[0]; - if ( - latestEntry && - latestEntry.selector === selector && - latestEntry.componentName === null - ) { - latestEntry.componentName = resolvedComponentName; + const matchingEntry = currentEntries.find( + (storedEntry) => + storedEntry.selector === selector && + storedEntry.componentName === null, + ); + if (matchingEntry) { + matchingEntry.componentName = resolvedComponentName; } }); @@ -419,39 +367,21 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const source = resolveSourceFromStack(stack); if (!source) return; const currentEntries = eventContextStore.getEntries(); - const latestEntry = currentEntries[0]; - if (latestEntry && latestEntry.selector === selector) { - if (latestEntry.filePath === null) { - latestEntry.filePath = source.filePath; - latestEntry.lineNumber = source.lineNumber ?? null; + const matchingEntry = currentEntries.find( + (storedEntry) => storedEntry.selector === selector, + ); + if (matchingEntry) { + if (matchingEntry.filePath === null) { + matchingEntry.filePath = source.filePath; + matchingEntry.lineNumber = source.lineNumber ?? null; } - if (latestEntry.componentName === null && source.componentName) { - latestEntry.componentName = source.componentName; + if (matchingEntry.componentName === null && source.componentName) { + matchingEntry.componentName = source.componentName; } } }); }; - const isOverlayElement = (element: Element): boolean => { - let current: Element | null = element; - while (current) { - if ( - current instanceof HTMLElement && - current.hasAttribute("data-react-grab-ignore-events") - ) { - return true; - } - current = - current.parentElement ?? - (current.getRootNode() as ShadowRoot).host ?? - null; - } - return false; - }; - - const isAmbientTrackable = (element: Element | null): element is Element => - element !== null && !isRootElement(element) && !isOverlayElement(element); - const handleAmbientPointerMove = (clientX: number, clientY: number) => { if (isActivated() || !isEnabled()) return; @@ -463,16 +393,13 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { if (!isAmbientTrackable(candidate)) { lastAmbientElement = null; - hideAmbientBadgeWithGrace(); return; } lastAmbientElement = candidate; - clearAmbientDismissTimer(); ambientHoverTimer = setTimeout(() => { if (lastAmbientElement !== candidate) return; - resolveAndShowAmbientBadge(candidate); addAmbientTrailEntry(candidate, "hover"); }, AMBIENT_HOVER_DWELL_MS); }; @@ -485,54 +412,23 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { if (!isAmbientTrackable(candidate)) return; clearAmbientHoverTimer(); - clearAmbientDismissTimer(); lastAmbientElement = candidate; - resolveAndShowAmbientBadge(candidate); addAmbientTrailEntry(candidate, "click"); }; - const handleAmbientBadgeCopy = () => { + const handleCopyEventContext = () => { const trailText = eventContextStore.formatTrailForCopy(); if (!trailText) return; - copyContent(trailText, { - componentName: - ambientComponentName() ?? ambientTagName() ?? "interactions", - }); - setAmbientBadgeCopyStatus("copied"); - if (ambientCopyFeedbackTimer !== null) { - clearTimeout(ambientCopyFeedbackTimer); + copyContent(trailText, { componentName: "interactions" }); + setEventContextCopyStatus("copied"); + if (eventContextCopyFeedbackTimer !== null) { + clearTimeout(eventContextCopyFeedbackTimer); } - ambientCopyFeedbackTimer = setTimeout(() => { - setAmbientBadgeCopyStatus("idle"); + eventContextCopyFeedbackTimer = setTimeout(() => { + setEventContextCopyStatus("idle"); }, AMBIENT_COPY_FEEDBACK_MS); }; - const handleAmbientBadgeOpenFile = () => { - const filePath = ambientFilePath(); - if (filePath) { - openFile( - filePath, - ambientLineNumber() ?? undefined, - pluginRegistry.hooks.transformOpenFileUrl, - ); - } - }; - - const handleAmbientBadgeHoverChange = (isHovered: boolean) => { - isAmbientBadgeHovered = isHovered; - if (!isHovered) { - hideAmbientBadgeWithGrace(); - } else { - clearAmbientDismissTimer(); - } - }; - - const clearAmbientState = () => { - clearAmbientHoverTimer(); - clearAmbientDismissTimer(); - hideAmbientBadge(); - }; - const getMappedHistoryElements = (historyItemId: string): Element[] => historyElementMap.get(historyItemId) ?? []; @@ -1730,7 +1626,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ); const activateRenderer = () => { - clearAmbientState(); + clearAmbientHoverTimer(); const wasInHoldingState = isHoldingKeys(); actions.activate(); // HACK: Only call onActivate if we weren't in holding state. @@ -4299,16 +4195,9 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { handleHistoryClear(); }} onClearHistoryCancel={dismissClearPrompt} - ambientBadgeVisible={ambientBadgeVisible()} - ambientBadgeBounds={ambientBounds() ?? undefined} - ambientBadgeTagName={ambientTagName()} - ambientBadgeComponentName={ambientComponentName() ?? undefined} - ambientBadgeHasFilePath={Boolean(ambientFilePath())} - ambientBadgeTrailCount={ambientTrailCount()} - ambientBadgeCopyStatus={ambientBadgeCopyStatus()} - onAmbientBadgeCopy={handleAmbientBadgeCopy} - onAmbientBadgeOpenFile={handleAmbientBadgeOpenFile} - onAmbientBadgeHoverChange={handleAmbientBadgeHoverChange} + eventContextTrailCount={eventContextTrailCount()} + eventContextCopyStatus={eventContextCopyStatus()} + onCopyEventContext={handleCopyEventContext} /> ); }, rendererRoot); @@ -4430,10 +4319,10 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { cancelHistoryHoverCloseTimeout(); stopTrackingDropdownPosition(); toolbarStateChangeCallbacks.clear(); - clearAmbientState(); + clearAmbientHoverTimer(); eventContextStore.clear(); - if (ambientCopyFeedbackTimer !== null) { - clearTimeout(ambientCopyFeedbackTimer); + if (eventContextCopyFeedbackTimer !== null) { + clearTimeout(eventContextCopyFeedbackTimer); } dispose(); }, From c1c7063994a8c02d01275b7b140e13449c6cb68f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 28 Feb 2026 03:24:40 +0000 Subject: [PATCH 09/10] feat: add trail copy button to toolbar with count badge and copy feedback Co-authored-by: Aiden Bai --- .../src/components/toolbar/index.tsx | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/packages/react-grab/src/components/toolbar/index.tsx b/packages/react-grab/src/components/toolbar/index.tsx index 727bea26b..9f34a547f 100644 --- a/packages/react-grab/src/components/toolbar/index.tsx +++ b/packages/react-grab/src/components/toolbar/index.tsx @@ -20,6 +20,7 @@ import { IconSelect } from "../icons/icon-select.jsx"; import { IconChevron } from "../icons/icon-chevron.jsx"; import { IconClock } from "../icons/icon-clock.jsx"; import { IconCopy } from "../icons/icon-copy.jsx"; +import { IconCheck } from "../icons/icon-check.jsx"; import { IconEllipsis } from "../icons/icon-ellipsis.jsx"; import { createSafePolygonTracker, @@ -89,6 +90,9 @@ interface ToolbarProps { toolbarActions?: ToolbarMenuAction[]; onToggleMenu?: () => void; isMenuOpen?: boolean; + eventContextTrailCount?: number; + eventContextCopyStatus?: "idle" | "copied"; + onCopyEventContext?: () => void; } interface FreezeHandlersOptions { @@ -162,6 +166,8 @@ export const Toolbar: Component = (props) => { const [isMenuTooltipVisible, setIsMenuTooltipVisible] = createSignal(false); const [isCopyAllTooltipVisible, setIsCopyAllTooltipVisible] = createSignal(false); + const [isTrailTooltipVisible, setIsTrailTooltipVisible] = + createSignal(false); let clockFlashRef: HTMLSpanElement | undefined; const hasToolbarActions = () => (props.toolbarActions ?? []).length > 0; @@ -171,6 +177,11 @@ export const Toolbar: Component = (props) => { return count > 0 ? `History (${count})` : "History"; }; + const trailTooltipLabel = () => { + const count = props.eventContextTrailCount ?? 0; + return count > 0 ? `Copy trail (${count})` : "Copy trail"; + }; + const historyIconClass = () => cn( "transition-colors", @@ -688,6 +699,10 @@ export const Toolbar: Component = (props) => { const handleCopyAll = createDragAwareHandler(() => props.onCopyAll?.()); + const handleCopyTrail = createDragAwareHandler( + () => props.onCopyEventContext?.(), + ); + const handleToggleMenu = createDragAwareHandler(() => props.onToggleMenu?.()); const handleToggleCollapse = createDragAwareHandler(() => { @@ -777,6 +792,9 @@ export const Toolbar: Component = (props) => { if (child.querySelector("[data-react-grab-toolbar-copy-all]")) { return Boolean(props.isHistoryDropdownOpen); } + if (child.querySelector("[data-react-grab-toolbar-trail]")) { + return (props.eventContextTrailCount ?? 0) > 0; + } if (child.querySelector("[data-react-grab-toolbar-menu]")) { return hasMenuActions; } @@ -1731,6 +1749,74 @@ export const Toolbar: Component = (props) => {
+
0, + "pointer-events-none", + ), + )} + > +
+ + + {trailTooltipLabel()} + +
+
Date: Sat, 28 Feb 2026 03:25:42 +0000 Subject: [PATCH 10/10] chore: apply formatter Co-authored-by: Aiden Bai --- packages/react-grab/src/components/toolbar/index.tsx | 7 +++---- packages/react-grab/src/core/index.tsx | 3 ++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/react-grab/src/components/toolbar/index.tsx b/packages/react-grab/src/components/toolbar/index.tsx index 9f34a547f..093984c94 100644 --- a/packages/react-grab/src/components/toolbar/index.tsx +++ b/packages/react-grab/src/components/toolbar/index.tsx @@ -166,8 +166,7 @@ export const Toolbar: Component = (props) => { const [isMenuTooltipVisible, setIsMenuTooltipVisible] = createSignal(false); const [isCopyAllTooltipVisible, setIsCopyAllTooltipVisible] = createSignal(false); - const [isTrailTooltipVisible, setIsTrailTooltipVisible] = - createSignal(false); + const [isTrailTooltipVisible, setIsTrailTooltipVisible] = createSignal(false); let clockFlashRef: HTMLSpanElement | undefined; const hasToolbarActions = () => (props.toolbarActions ?? []).length > 0; @@ -699,8 +698,8 @@ export const Toolbar: Component = (props) => { const handleCopyAll = createDragAwareHandler(() => props.onCopyAll?.()); - const handleCopyTrail = createDragAwareHandler( - () => props.onCopyEventContext?.(), + const handleCopyTrail = createDragAwareHandler(() => + props.onCopyEventContext?.(), ); const handleToggleMenu = createDragAwareHandler(() => props.onToggleMenu?.()); diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 02917228a..4da1597a6 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -322,7 +322,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { } current = current.parentElement ?? - ((current.getRootNode() as ShadowRoot).host ?? null); + (current.getRootNode() as ShadowRoot).host ?? + null; } return false; };