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) => { 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/components/toolbar/index.tsx b/packages/react-grab/src/components/toolbar/index.tsx index 8a49f2291..093984c94 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,7 @@ 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 +176,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 +698,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 +791,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; } @@ -1486,9 +1503,7 @@ export const Toolbar: Component = (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( @@ -1731,6 +1748,74 @@ export const Toolbar: Component = (props) => {
+
0, + "pointer-events-none", + ), + )} + > +
+ + + {trailTooltipLabel()} + +
+
{ const [isHistoryHoverOpen, setIsHistoryHoverOpen] = createSignal(false); let historyHoverPreviews: { boxId: string; labelId: string | null }[] = []; + const eventContextStore = createEventContextStore(); + const [eventContextTrailCount, setEventContextTrailCount] = createSignal(0); + const [eventContextCopyStatus, setEventContextCopyStatus] = createSignal< + "idle" | "copied" + >("idle"); + let ambientHoverTimer: ReturnType | null = null; + let eventContextCopyFeedbackTimer: ReturnType | null = + null; + let lastAmbientElement: Element | null = null; + + const clearAmbientHoverTimer = () => { + if (ambientHoverTimer !== null) { + clearTimeout(ambientHoverTimer); + ambientHoverTimer = null; + } + }; + + 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 addAmbientTrailEntry = ( + element: Element, + interactionType: EventContextEntry["interactionType"], + ) => { + const tagName = getTagName(element); + const selector = createElementSelector(element, true); + + const entry: Omit = { + interactionType, + tagName, + componentName: null, + filePath: null, + lineNumber: null, + selector, + timestamp: Date.now(), + }; + + const updatedEntries = eventContextStore.addEntry(entry); + setEventContextTrailCount(updatedEntries.length); + + void getNearestComponentName(element).then((resolvedComponentName) => { + if (!resolvedComponentName) return; + const currentEntries = eventContextStore.getEntries(); + const matchingEntry = currentEntries.find( + (storedEntry) => + storedEntry.selector === selector && + storedEntry.componentName === null, + ); + if (matchingEntry) { + matchingEntry.componentName = resolvedComponentName; + } + }); + + void getStack(element).then((stack) => { + const source = resolveSourceFromStack(stack); + if (!source) return; + const currentEntries = eventContextStore.getEntries(); + const matchingEntry = currentEntries.find( + (storedEntry) => storedEntry.selector === selector, + ); + if (matchingEntry) { + if (matchingEntry.filePath === null) { + matchingEntry.filePath = source.filePath; + matchingEntry.lineNumber = source.lineNumber ?? null; + } + if (matchingEntry.componentName === null && source.componentName) { + matchingEntry.componentName = source.componentName; + } + } + }); + }; + + const handleAmbientPointerMove = (clientX: number, clientY: number) => { + if (isActivated() || !isEnabled()) return; + + const candidate = getElementAtPosition(clientX, clientY); + + if (candidate === lastAmbientElement) return; + + clearAmbientHoverTimer(); + + if (!isAmbientTrackable(candidate)) { + lastAmbientElement = null; + return; + } + + lastAmbientElement = candidate; + + ambientHoverTimer = setTimeout(() => { + if (lastAmbientElement !== candidate) return; + addAmbientTrailEntry(candidate, "hover"); + }, AMBIENT_HOVER_DWELL_MS); + }; + + const handleAmbientClick = (clientX: number, clientY: number) => { + if (isActivated() || !isEnabled()) return; + + const candidate = getElementAtPosition(clientX, clientY); + + if (!isAmbientTrackable(candidate)) return; + + clearAmbientHoverTimer(); + lastAmbientElement = candidate; + addAmbientTrailEntry(candidate, "click"); + }; + + const handleCopyEventContext = () => { + const trailText = eventContextStore.formatTrailForCopy(); + if (!trailText) return; + copyContent(trailText, { componentName: "interactions" }); + setEventContextCopyStatus("copied"); + if (eventContextCopyFeedbackTimer !== null) { + clearTimeout(eventContextCopyFeedbackTimer); + } + eventContextCopyFeedbackTimer = setTimeout(() => { + setEventContextCopyStatus("idle"); + }, AMBIENT_COPY_FEEDBACK_MS); + }; + const getMappedHistoryElements = (historyItemId: string): Element[] => historyElementMap.get(historyItemId) ?? []; @@ -1487,6 +1627,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ); const activateRenderer = () => { + clearAmbientHoverTimer(); const wasInHoldingState = isHoldingKeys(); actions.activate(); // HACK: Only call onActivate if we weren't in holding state. @@ -2715,13 +2856,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 +2923,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 +3019,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 +4196,9 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { handleHistoryClear(); }} onClearHistoryCancel={dismissClearPrompt} + eventContextTrailCount={eventContextTrailCount()} + eventContextCopyStatus={eventContextCopyStatus()} + onCopyEventContext={handleCopyEventContext} /> ); }, rendererRoot); @@ -4155,6 +4320,11 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { cancelHistoryHoverCloseTimeout(); stopTrackingDropdownPosition(); toolbarStateChangeCallbacks.clear(); + clearAmbientHoverTimer(); + eventContextStore.clear(); + if (eventContextCopyFeedbackTimer !== null) { + clearTimeout(eventContextCopyFeedbackTimer); + } dispose(); }, copyElement: copyElementAPI, 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, diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index f62392f3c..ff25067ae 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,9 @@ export interface ReactGrabRendererProps { clearPromptPosition?: DropdownAnchor | null; onClearHistoryConfirm?: () => void; onClearHistoryCancel?: () => void; + eventContextTrailCount?: number; + eventContextCopyStatus?: "idle" | "copied"; + onCopyEventContext?: () => void; } export interface GrabbedBox { 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 new file mode 100644 index 000000000..6e83107e0 --- /dev/null +++ b/packages/react-grab/src/utils/event-context-store.ts @@ -0,0 +1,109 @@ +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, + }; +};