diff --git a/packages/react-grab/src/components/devtools-panel.tsx b/packages/react-grab/src/components/devtools-panel.tsx new file mode 100644 index 000000000..6de167071 --- /dev/null +++ b/packages/react-grab/src/components/devtools-panel.tsx @@ -0,0 +1,313 @@ +import { For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js"; +import { render } from "solid-js/web"; +import type { EventLog } from "../core/event-log.js"; +import type { ReactGrabAPI, ReactGrabLoggedEvent, ReactGrabSession } from "../types.js"; + +interface DevtoolsPanelProps { + eventLog: EventLog; + api: ReactGrabAPI; +} + +const MAX_VISIBLE_EVENTS = 200; + +const panelContainerStyle = ` + position: fixed; + bottom: 12px; + right: 12px; + width: 360px; + max-height: 70vh; + z-index: 2147483646; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + color: #e6e6e6; + background: rgba(20, 20, 24, 0.94); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + display: flex; + flex-direction: column; + overflow: hidden; +`; + +const headerStyle = ` + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + user-select: none; + cursor: pointer; +`; + +const titleStyle = ` + font-weight: 600; + letter-spacing: 0.02em; + flex: 1; +`; + +const badgeStyle = ` + padding: 1px 6px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + font-size: 10px; + font-variant-numeric: tabular-nums; +`; + +const recordingDotStyle = (isRecording: boolean): string => ` + width: 7px; + height: 7px; + border-radius: 50%; + background: ${isRecording ? "#ff4d4d" : "#888"}; + box-shadow: ${isRecording ? "0 0 6px rgba(255, 77, 77, 0.6)" : "none"}; +`; + +const toolbarStyle = ` + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 6px 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +`; + +const buttonStyle = ` + appearance: none; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); + color: inherit; + padding: 3px 7px; + border-radius: 4px; + font: inherit; + cursor: pointer; +`; + +const eventListStyle = ` + overflow: auto; + flex: 1; + min-height: 80px; +`; + +const eventRowStyle = ` + display: grid; + grid-template-columns: 56px 1fr auto; + align-items: baseline; + gap: 6px; + padding: 3px 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); +`; + +const timestampStyle = ` + color: #888; + font-variant-numeric: tabular-nums; +`; + +const nameStyle = ` + color: #b9e3ff; +`; + +const countStyle = ` + color: #888; + font-variant-numeric: tabular-nums; +`; + +const formatRelativeMs = (eventTimestamp: number, anchorTimestamp: number | null): string => { + if (anchorTimestamp === null) return "0ms"; + const delta = eventTimestamp - anchorTimestamp; + if (delta < 1000) return `${delta}ms`; + return `${(delta / 1000).toFixed(2)}s`; +}; + +const summarizeArgs = (args: readonly unknown[]): string => { + if (args.length === 0) return ""; + try { + const truncated = args.map((arg) => { + if (arg === null || arg === undefined) return String(arg); + if (typeof arg === "object") { + if ("__rgHandle" in (arg as Record)) { + return `<${(arg as { __rgHandle: string }).__rgHandle}>`; + } + return JSON.stringify(arg).slice(0, 60); + } + return JSON.stringify(arg); + }); + return truncated.join(", "); + } catch { + return ""; + } +}; + +const DevtoolsPanel = (props: DevtoolsPanelProps): import("solid-js").JSX.Element => { + const [events, setEvents] = createSignal(props.eventLog.getEvents()); + const [isCollapsed, setIsCollapsed] = createSignal(false); + const [isRecording, setIsRecording] = createSignal(props.eventLog.isRecording()); + + onMount(() => { + const unsubscribe = props.eventLog.subscribe(() => { + setEvents(props.eventLog.getEvents()); + }); + onCleanup(unsubscribe); + }); + + const firstTimestamp = createMemo(() => { + const allEvents = events(); + return allEvents.length > 0 ? allEvents[0].t : null; + }); + + const visibleEvents = createMemo(() => events().slice(-MAX_VISIBLE_EVENTS).reverse()); + + const handleCopySession = async () => { + const session = props.api.getSession(); + try { + await navigator.clipboard.writeText(JSON.stringify(session, null, 2)); + } catch {} + }; + + const handleDownloadSession = () => { + const session = props.api.getSession(); + const blob = new Blob([JSON.stringify(session, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `react-grab-session-${new Date().toISOString().replace(/[:.]/g, "-")}.json`; + anchor.click(); + URL.revokeObjectURL(url); + }; + + const handleReplayFromClipboard = async () => { + try { + const text = await navigator.clipboard.readText(); + const session = JSON.parse(text) as ReactGrabSession; + await props.api.replaySession(session); + } catch (error) { + console.warn("[react-grab devtools] replay failed:", error); + } + }; + + const handleToggleRecording = () => { + const next = !isRecording(); + props.api.setEventLogRecording(next); + setIsRecording(next); + }; + + const handleClear = () => { + props.api.clearEventLog(); + setEvents([]); + }; + + return ( +
+
setIsCollapsed((value) => !value)}> + + react-grab event log + {events().length} + {isCollapsed() ? "▾" : "▴"} +
+ +
+ + + + + +
+
+ + {(loggedEvent) => ( +
+ + {formatRelativeMs(loggedEvent.t, firstTimestamp())} + + + {loggedEvent.name} + 0}> + ({summarizeArgs(loggedEvent.args)}) + + + 1}> + ×{loggedEvent.coalescedCount} + +
+ )} +
+
+
+
+ ); +}; + +const mountDevtoolsPanel = (eventLog: EventLog, api: ReactGrabAPI): (() => void) => { + if (typeof document === "undefined") return () => {}; + + const host = document.createElement("div"); + host.setAttribute("data-react-grab-devtools", ""); + host.style.position = "fixed"; + host.style.inset = "0"; + host.style.pointerEvents = "none"; + host.style.zIndex = "2147483646"; + + const shadow = host.attachShadow({ mode: "open" }); + + const styleElement = document.createElement("style"); + styleElement.textContent = ` + :host { all: initial; } + * { box-sizing: border-box; } + button { pointer-events: auto; } + [data-rg-panel] { pointer-events: auto; } + `; + shadow.appendChild(styleElement); + + const container = document.createElement("div"); + container.setAttribute("data-rg-panel", ""); + shadow.appendChild(container); + + document.body.appendChild(host); + + const disposeRender = render(() => , container); + + return () => { + disposeRender(); + host.remove(); + }; +}; + +export { mountDevtoolsPanel, DevtoolsPanel }; diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index 738ba14d9..e2208e974 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -190,6 +190,9 @@ export const SHIFT_SELECTION_LABEL_MIN_ANCHOR_RATIO = 0; export const SHIFT_SELECTION_LABEL_MAX_ANCHOR_RATIO = 1; export const SHIFT_SELECTION_LABEL_FALLBACK_ANCHOR_RATIO = 0; +export const EVENT_LOG_RING_BUFFER_SIZE = 5000; +export const EVENT_LOG_SCHEMA_VERSION = 1; + export const RELEVANT_CSS_PROPERTIES = new Set([ "display", "position", diff --git a/packages/react-grab/src/core/event-log.ts b/packages/react-grab/src/core/event-log.ts new file mode 100644 index 000000000..487f7bbb2 --- /dev/null +++ b/packages/react-grab/src/core/event-log.ts @@ -0,0 +1,382 @@ +import { EVENT_LOG_RING_BUFFER_SIZE, EVENT_LOG_SCHEMA_VERSION } from "../constants.js"; +import { createElementSelector } from "../utils/create-element-selector.js"; +import { isElementConnected } from "../utils/is-element-connected.js"; +import type { + ReactGrabLoggedEvent, + ReactGrabRegisteredElement, + ReactGrabReplayOptions, + ReactGrabSession, + ReactGrabSessionViewport, +} from "../types.js"; + +interface SerializedElementHandle { + __rgHandle: string; +} + +type RegisteredElement = ReactGrabRegisteredElement; +type LoggedEvent = ReactGrabLoggedEvent; +type Session = ReactGrabSession; +type ReplayOptions = ReactGrabReplayOptions; +type SessionViewport = ReactGrabSessionViewport; + +interface ElementHandleRegistry { + toHandle: (element: Element) => string; + resolve: (handle: string) => Element | null; + registered: () => RegisteredElement[]; + reset: () => void; + hydrate: (entries: RegisteredElement[]) => void; +} + +interface EventLog { + dispatch: (name: string, args: Args, timestamp?: number) => void; + getEvents: () => LoggedEvent[]; + getSession: () => Session; + clear: () => void; + isRecording: () => boolean; + setRecording: (value: boolean) => void; + subscribe: (listener: (event: LoggedEvent) => void) => () => void; + resolveHandle: (handle: string) => Element | null; + hydrateRegistry: (entries: RegisteredElement[]) => void; +} + +const HANDLE_PREFIX = "rg-el"; + +const COLLAPSIBLE_ACTIONS: ReadonlySet = new Set([ + "setPointer", + "setDetectedElement", + "setInputText", + "setFrozenDragRect", + "updateContextMenuPosition", + "incrementViewportVersion", +]); + +const argsEqual = (firstArgs: readonly unknown[], secondArgs: readonly unknown[]): boolean => { + if (firstArgs === secondArgs) return true; + if (firstArgs.length !== secondArgs.length) return false; + for (let index = 0; index < firstArgs.length; index += 1) { + if (!shallowDeepEqual(firstArgs[index], secondArgs[index])) return false; + } + return true; +}; + +const shallowDeepEqual = (firstValue: unknown, secondValue: unknown): boolean => { + if (firstValue === secondValue) return true; + if (firstValue === null || secondValue === null) return false; + if (typeof firstValue !== typeof secondValue) return false; + if (typeof firstValue !== "object") return false; + if (Array.isArray(firstValue) !== Array.isArray(secondValue)) return false; + if (Array.isArray(firstValue) && Array.isArray(secondValue)) { + if (firstValue.length !== secondValue.length) return false; + for (let index = 0; index < firstValue.length; index += 1) { + if (!shallowDeepEqual(firstValue[index], secondValue[index])) return false; + } + return true; + } + const firstObject = firstValue as Record; + const secondObject = secondValue as Record; + const firstKeys = Object.keys(firstObject); + if (firstKeys.length !== Object.keys(secondObject).length) return false; + for (const key of firstKeys) { + if (!shallowDeepEqual(firstObject[key], secondObject[key])) return false; + } + return true; +}; + +const createElementHandleRegistry = (): ElementHandleRegistry => { + const elementToHandle = new WeakMap(); + const handleToWeakRef = new Map>(); + const handleToMetadata = new Map(); + let nextHandleId = 0; + + const mintHandle = (): string => { + nextHandleId += 1; + return `${HANDLE_PREFIX}-${nextHandleId}`; + }; + + const captureMetadata = (handle: string, element: Element): RegisteredElement => { + let selector = ""; + try { + selector = createElementSelector(element); + } catch { + selector = ""; + } + const entry: RegisteredElement = { + handle, + selector, + tagName: element.tagName?.toLowerCase?.() ?? "unknown", + }; + handleToMetadata.set(handle, entry); + return entry; + }; + + const toHandle: ElementHandleRegistry["toHandle"] = (element) => { + const existing = elementToHandle.get(element); + if (existing) return existing; + const handle = mintHandle(); + elementToHandle.set(element, handle); + handleToWeakRef.set(handle, new WeakRef(element)); + captureMetadata(handle, element); + return handle; + }; + + const resolve: ElementHandleRegistry["resolve"] = (handle) => { + const weakRef = handleToWeakRef.get(handle); + const cached = weakRef?.deref(); + if (cached && isElementConnected(cached)) return cached; + + const metadata = handleToMetadata.get(handle); + if (!metadata || !metadata.selector) return null; + try { + const found = document.querySelector(metadata.selector); + if (found instanceof Element) { + elementToHandle.set(found, handle); + handleToWeakRef.set(handle, new WeakRef(found)); + return found; + } + } catch {} + return null; + }; + + const registered: ElementHandleRegistry["registered"] = () => + Array.from(handleToMetadata.values()); + + const reset: ElementHandleRegistry["reset"] = () => { + handleToWeakRef.clear(); + handleToMetadata.clear(); + nextHandleId = 0; + }; + + const hydrate: ElementHandleRegistry["hydrate"] = (entries) => { + for (const entry of entries) { + handleToMetadata.set(entry.handle, entry); + const numericPart = Number(entry.handle.slice(HANDLE_PREFIX.length + 1)); + if (Number.isFinite(numericPart) && numericPart > nextHandleId) { + nextHandleId = numericPart; + } + } + }; + + return { toHandle, resolve, registered, reset, hydrate }; +}; + +const isSerializedHandle = (value: unknown): value is SerializedElementHandle => + typeof value === "object" && + value !== null && + typeof (value as SerializedElementHandle).__rgHandle === "string"; + +const serializeValue = (value: unknown, registry: ElementHandleRegistry): unknown => { + if (value === null || value === undefined) return value; + if (typeof value === "boolean" || typeof value === "number" || typeof value === "string") { + return value; + } + if (typeof value === "function" || typeof value === "symbol") return undefined; + if (typeof Element !== "undefined" && value instanceof Element) { + return { __rgHandle: registry.toHandle(value) }; + } + if (Array.isArray(value)) { + return value.map((entry) => serializeValue(entry, registry)); + } + if (typeof value === "object") { + const clone: Record = {}; + for (const key of Object.keys(value as Record)) { + const serialized = serializeValue((value as Record)[key], registry); + if (serialized !== undefined) clone[key] = serialized; + } + return clone; + } + return undefined; +}; + +type HandleResolver = (handle: string) => Element | null; + +const deserializeValue = (value: unknown, resolveHandle: HandleResolver): unknown => { + if (value === null || value === undefined) return value; + if (typeof value === "boolean" || typeof value === "number" || typeof value === "string") { + return value; + } + if (isSerializedHandle(value)) { + return resolveHandle(value.__rgHandle); + } + if (Array.isArray(value)) { + return value.map((entry) => deserializeValue(entry, resolveHandle)); + } + if (typeof value === "object") { + const clone: Record = {}; + for (const key of Object.keys(value as Record)) { + clone[key] = deserializeValue((value as Record)[key], resolveHandle); + } + return clone; + } + return value; +}; + +const captureViewport = (): SessionViewport => { + if (typeof window === "undefined") { + return { width: 0, height: 0, scrollX: 0, scrollY: 0, devicePixelRatio: 1 }; + } + return { + width: window.innerWidth, + height: window.innerHeight, + scrollX: window.scrollX, + scrollY: window.scrollY, + devicePixelRatio: window.devicePixelRatio || 1, + }; +}; + +const createEventLog = (): EventLog => { + const registry = createElementHandleRegistry(); + const ringBuffer: LoggedEvent[] = []; + const listeners = new Set<(event: LoggedEvent) => void>(); + let isRecordingFlag = true; + let firstEventTimestamp: number | null = null; + let lastEventTimestamp: number | null = null; + const createdAt = Date.now(); + + const dispatch: EventLog["dispatch"] = (name, args, timestamp) => { + if (!isRecordingFlag) return; + const t = timestamp ?? Date.now(); + const serializedArgs = Array.from(args).map((arg) => serializeValue(arg, registry)); + + const lastEntry = ringBuffer[ringBuffer.length - 1]; + if (lastEntry && lastEntry.name === name) { + const canCollapseAdjacent = COLLAPSIBLE_ACTIONS.has(name); + const isExactRepeat = argsEqual(lastEntry.args, serializedArgs); + if (canCollapseAdjacent || isExactRepeat) { + lastEntry.t = t; + lastEntry.args = serializedArgs; + lastEntry.coalescedCount = (lastEntry.coalescedCount ?? 1) + 1; + if (firstEventTimestamp === null) firstEventTimestamp = t; + lastEventTimestamp = t; + for (const listener of listeners) { + try { + listener(lastEntry); + } catch {} + } + return; + } + } + + const entry: LoggedEvent = { t, name, args: serializedArgs }; + ringBuffer.push(entry); + if (ringBuffer.length > EVENT_LOG_RING_BUFFER_SIZE) { + ringBuffer.shift(); + } + if (firstEventTimestamp === null) firstEventTimestamp = t; + lastEventTimestamp = t; + for (const listener of listeners) { + try { + listener(entry); + } catch {} + } + }; + + const getEvents: EventLog["getEvents"] = () => ringBuffer.slice(); + + const getSession: EventLog["getSession"] = () => { + const snapshot: Session = { + version: EVENT_LOG_SCHEMA_VERSION, + createdAt, + startedAt: firstEventTimestamp, + endedAt: lastEventTimestamp, + userAgent: typeof navigator !== "undefined" ? navigator.userAgent : "", + href: typeof location !== "undefined" ? location.href : "", + viewport: captureViewport(), + elements: registry.registered().map((entry) => ({ ...entry })), + events: ringBuffer.map((entry) => ({ + t: entry.t, + name: entry.name, + args: entry.args.slice(), + ...(entry.coalescedCount === undefined ? {} : { coalescedCount: entry.coalescedCount }), + })), + }; + return JSON.parse(JSON.stringify(snapshot)) as Session; + }; + + const clear: EventLog["clear"] = () => { + ringBuffer.length = 0; + firstEventTimestamp = null; + lastEventTimestamp = null; + registry.reset(); + }; + + const subscribe: EventLog["subscribe"] = (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }; + + return { + dispatch, + getEvents, + getSession, + clear, + isRecording: () => isRecordingFlag, + setRecording: (value) => { + isRecordingFlag = value; + }, + subscribe, + resolveHandle: registry.resolve, + hydrateRegistry: registry.hydrate, + }; +}; + +const RESET_BEFORE_REPLAY_ACTION_NAMES = [ + "deactivate", + "clearGrabbedBoxes", + "clearLabelInstances", + "hideContextMenu", + "clearInputText", +] as const; + +const replaySessionInto = async ( + session: Session, + log: EventLog, + actions: object, + options: ReplayOptions = {}, +): Promise => { + log.setRecording(false); + try { + const actionsByName = actions as Record unknown>; + for (const resetActionName of RESET_BEFORE_REPLAY_ACTION_NAMES) { + const resetAction = actionsByName[resetActionName]; + if (typeof resetAction === "function") { + try { + resetAction(); + } catch {} + } + } + + log.clear(); + log.hydrateRegistry(session.elements); + + const startedAt = session.startedAt ?? 0; + const replayStartedAt = Date.now(); + + for (let index = 0; index < session.events.length; index += 1) { + const event = session.events[index]; + const action = actionsByName[event.name]; + if (typeof action !== "function") continue; + + if (options.realtime && startedAt > 0) { + const elapsedInSession = event.t - startedAt; + const elapsedSinceReplay = Date.now() - replayStartedAt; + const waitMs = elapsedInSession - elapsedSinceReplay; + if (waitMs > 0) { + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + } + + const args = event.args.map((arg) => deserializeValue(arg, log.resolveHandle)); + try { + action(...args); + } catch {} + options.onEvent?.(event, index); + } + } finally { + log.setRecording(true); + } +}; + +export { createEventLog, replaySessionInto }; +export type { EventLog }; diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index f753d4e98..d1a82fe9d 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -12,6 +12,7 @@ import { } from "solid-js"; import { render } from "solid-js/web"; import { createGrabStore } from "./store.js"; +import { createEventLog, replaySessionInto } from "./event-log.js"; import { isKeyboardEventTriggeredByInput, hasTextSelectionInInput, @@ -203,9 +204,12 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const pluginRegistry = createPluginRegistry(settableOptions); + const eventLog = createEventLog(); + const { store, actions, pointer, viewportVersion, current } = createGrabStore({ theme: DEFAULT_THEME, keyHoldDuration: pluginRegistry.store.options.keyHoldDuration ?? DEFAULT_KEY_HOLD_DURATION_MS, + eventLog, }); const isHoldingKeys = createMemo(() => current().state === "holding"); @@ -3696,6 +3700,11 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }, getPlugins: () => pluginRegistry.getPluginNames(), getDisplayName: getComponentDisplayName, + getSession: () => eventLog.getSession(), + replaySession: (session, replayOptions) => + replaySessionInto(session, eventLog, actions, replayOptions), + clearEventLog: () => eventLog.clear(), + setEventLogRecording: (value: boolean) => eventLog.setRecording(value), }; for (const plugin of builtInPlugins) { @@ -3706,6 +3715,19 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { checkIsNextProject(true); }, NEXTJS_REVALIDATION_DELAY_MS); + if (typeof window !== "undefined" && window.__REACT_GRAB_DEVTOOLS__) { + let disposeDevtools: (() => void) | undefined; + void import("../components/devtools-panel.js") + .then(({ mountDevtoolsPanel }) => { + if (disposed) return; + disposeDevtools = mountDevtoolsPanel(eventLog, api); + }) + .catch((error) => { + console.warn("[react-grab] devtools panel failed to mount:", error); + }); + onCleanup(() => disposeDevtools?.()); + } + return api; }); }; diff --git a/packages/react-grab/src/core/noop-api.ts b/packages/react-grab/src/core/noop-api.ts index 2c0d8bd54..22f582fd9 100644 --- a/packages/react-grab/src/core/noop-api.ts +++ b/packages/react-grab/src/core/noop-api.ts @@ -36,4 +36,18 @@ export const createNoopApi = (): ReactGrabAPI => ({ unregisterPlugin: NOOP, getPlugins: () => [], getDisplayName: () => null, + getSession: () => ({ + version: 1, + createdAt: 0, + startedAt: null, + endedAt: null, + userAgent: "", + href: "", + viewport: { width: 0, height: 0, scrollX: 0, scrollY: 0, devicePixelRatio: 1 }, + elements: [], + events: [], + }), + replaySession: () => Promise.resolve(), + clearEventLog: NOOP, + setEventLogRecording: NOOP, }); diff --git a/packages/react-grab/src/core/store.ts b/packages/react-grab/src/core/store.ts index 29ddf485e..a2c6871f7 100644 --- a/packages/react-grab/src/core/store.ts +++ b/packages/react-grab/src/core/store.ts @@ -5,6 +5,7 @@ import { OFFSCREEN_POSITION } from "../constants.js"; import { createElementBounds } from "../utils/create-element-bounds.js"; import { getBoundsCenter } from "../utils/get-bounds-center.js"; import { isElementConnected } from "../utils/is-element-connected.js"; +import type { EventLog } from "./event-log.js"; interface FrozenDragRect { pageX: number; @@ -68,6 +69,7 @@ interface GrabStore { interface GrabStoreInput { theme: Required; keyHoldDuration: number; + eventLog?: EventLog; } const createInitialStore = (input: GrabStoreInput): GrabStore => ({ @@ -620,7 +622,29 @@ const createGrabStore = (input: GrabStoreInput) => { }, }; - return { store, actions, pointer, viewportVersion, current }; + const wrappedActions = wrapActionsForLogging(actions, input.eventLog); + + return { store, actions: wrappedActions, pointer, viewportVersion, current }; +}; + +const wrapActionsForLogging = ( + actions: GrabActions, + eventLog: EventLog | undefined, +): GrabActions => { + if (!eventLog) return actions; + const wrapped: Record = {}; + for (const key of Object.keys(actions) as Array) { + const original = actions[key]; + if (typeof original !== "function") { + wrapped[key] = original; + continue; + } + wrapped[key] = (...args: unknown[]) => { + eventLog.dispatch(key, args); + return (original as (...args: unknown[]) => unknown)(...args); + }; + } + return wrapped as unknown as GrabActions; }; export { createGrabStore }; diff --git a/packages/react-grab/src/index.ts b/packages/react-grab/src/index.ts index e764e02d8..2cebcaaa9 100644 --- a/packages/react-grab/src/index.ts +++ b/packages/react-grab/src/index.ts @@ -43,6 +43,7 @@ declare global { interface Window { __REACT_GRAB__?: ReactGrabAPI; __REACT_GRAB_DISABLED__?: boolean; + __REACT_GRAB_DEVTOOLS__?: boolean; } } diff --git a/packages/react-grab/src/types.ts b/packages/react-grab/src/types.ts index 421d6af6d..2ed59fd14 100644 --- a/packages/react-grab/src/types.ts +++ b/packages/react-grab/src/types.ts @@ -277,6 +277,44 @@ export interface DropdownAnchor { toolbarWidth: number; } +export interface ReactGrabSessionViewport { + width: number; + height: number; + scrollX: number; + scrollY: number; + devicePixelRatio: number; +} + +export interface ReactGrabRegisteredElement { + handle: string; + selector: string; + tagName: string; +} + +export interface ReactGrabLoggedEvent { + t: number; + name: string; + args: unknown[]; + coalescedCount?: number; +} + +export interface ReactGrabSession { + version: number; + createdAt: number; + startedAt: number | null; + endedAt: number | null; + userAgent: string; + href: string; + viewport: ReactGrabSessionViewport; + elements: ReactGrabRegisteredElement[]; + events: ReactGrabLoggedEvent[]; +} + +export interface ReactGrabReplayOptions { + realtime?: boolean; + onEvent?: (event: ReactGrabLoggedEvent, index: number) => void; +} + export interface ReactGrabAPI { activate: () => void; deactivate: () => void; @@ -298,6 +336,10 @@ export interface ReactGrabAPI { unregisterPlugin: (name: string) => void; getPlugins: () => string[]; getDisplayName: (element: Element) => string | null; + getSession: () => ReactGrabSession; + replaySession: (session: ReactGrabSession, options?: ReactGrabReplayOptions) => Promise; + clearEventLog: () => void; + setEventLogRecording: (value: boolean) => void; } export interface OverlayBounds {