diff --git a/src/react-native/context-menu.tsx b/src/react-native/context-menu.tsx new file mode 100644 index 0000000..3d867b4 --- /dev/null +++ b/src/react-native/context-menu.tsx @@ -0,0 +1,403 @@ +import { + Children, + createContext, + isValidElement, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { + Animated, + Dimensions, + Easing, + Pressable, + StyleSheet, + Text, + View, + type LayoutChangeEvent, +} from "react-native"; + +const SCREEN_EDGE_MARGIN = 8; + +export type ContextMenuAnchor = { + x: number; + y: number; +}; + +export type ContextMenuCutout = { + x: number; + y: number; + width: number; + height: number; +}; + +export type ContextMenuHorizontalAlignment = "left" | "center" | "right"; +export type ContextMenuVerticalAlignment = "top" | "center" | "bottom"; +export type ContextMenuOffset = { + x: number; + y: number; +}; + +type ContextMenuContextValue = { + onClose: () => void; +}; + +const ContextMenuContext = createContext(null); + +export type ContextMenuProps = { + anchor: ContextMenuAnchor | null; + children?: ReactNode; + cutout?: ContextMenuCutout | null; + horizontalAlignment?: ContextMenuHorizontalAlignment; + offset?: ContextMenuOffset; + onClose: () => void; + verticalAlignment?: ContextMenuVerticalAlignment; + visible: boolean; +}; + +export type ContextMenuItemProps = { + children: ReactNode; + destructive?: boolean; + disabled?: boolean; + onPress: () => void; +}; + +const getAlignedLeft = ( + anchorX: number, + menuWidth: number, + horizontalAlignment: ContextMenuHorizontalAlignment, +) => { + switch (horizontalAlignment) { + case "left": + return anchorX; + case "right": + return anchorX - menuWidth; + case "center": + default: + return anchorX - menuWidth / 2; + } +}; + +const getAlignedTop = ( + anchorY: number, + menuHeight: number, + verticalAlignment: ContextMenuVerticalAlignment, +) => { + switch (verticalAlignment) { + case "center": + return anchorY - menuHeight / 2; + case "bottom": + return anchorY - menuHeight; + case "top": + default: + return anchorY; + } +}; + +const getMenuPosition = ( + anchor: ContextMenuAnchor, + menuWidth: number, + menuHeight: number, + horizontalAlignment: ContextMenuHorizontalAlignment, + verticalAlignment: ContextMenuVerticalAlignment, + offset: ContextMenuOffset, +) => { + const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); + const preferredLeft = getAlignedLeft(anchor.x, menuWidth, horizontalAlignment) + offset.x; + const preferredTop = getAlignedTop(anchor.y, menuHeight, verticalAlignment) + offset.y; + + return { + left: Math.min( + Math.max(SCREEN_EDGE_MARGIN, preferredLeft), + screenWidth - menuWidth - SCREEN_EDGE_MARGIN, + ), + top: Math.min( + Math.max(SCREEN_EDGE_MARGIN, preferredTop), + screenHeight - menuHeight - SCREEN_EDGE_MARGIN, + ), + }; +}; + +const ContextMenuItem = ({ + children, + destructive = false, + disabled = false, + onPress, +}: ContextMenuItemProps) => { + const context = useContext(ContextMenuContext); + + if (!context) { + throw new Error("ContextMenu.Item must be rendered inside ContextMenu."); + } + + return ( + { + context.onClose(); + onPress(); + }} + style={({ pressed }) => [ + styles.item, + pressed && !disabled && styles.itemPressed, + disabled && styles.itemDisabled, + ]} + > + {children} + + ); +}; + +export const ContextMenu = ({ + anchor, + children, + cutout = null, + horizontalAlignment = "center", + offset = { x: 0, y: 10 }, + onClose, + verticalAlignment = "top", + visible, +}: ContextMenuProps) => { + const [isRendered, setIsRendered] = useState(visible); + const [menuSize, setMenuSize] = useState({ width: 0, height: 0 }); + const [renderedAnchor, setRenderedAnchor] = useState(anchor); + const animation = useRef(new Animated.Value(visible ? 1 : 0)).current; + + useEffect(() => { + if (visible) { + setIsRendered(true); + } + + if (anchor) { + setRenderedAnchor(anchor); + } + + Animated.timing(animation, { + toValue: visible ? 1 : 0, + duration: visible ? 180 : 140, + easing: visible ? Easing.out(Easing.cubic) : Easing.in(Easing.cubic), + useNativeDriver: true, + }).start(({ finished }) => { + if (finished && !visible) { + setIsRendered(false); + setRenderedAnchor(null); + } + }); + }, [anchor, animation, visible]); + + const handleLayout = (event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + + if (width === menuSize.width && height === menuSize.height) { + return; + } + + setMenuSize({ width, height }); + }; + + const position = useMemo(() => { + if (!renderedAnchor) { + return { left: SCREEN_EDGE_MARGIN, top: SCREEN_EDGE_MARGIN }; + } + + return getMenuPosition( + renderedAnchor, + menuSize.width, + menuSize.height, + horizontalAlignment, + verticalAlignment, + offset, + ); + }, [ + horizontalAlignment, + menuSize.height, + menuSize.width, + offset, + renderedAnchor, + verticalAlignment, + ]); + + const renderedItems = useMemo( + () => Children.toArray(children).filter((child) => isValidElement(child)), + [children], + ); + + const backdropRegions = useMemo(() => { + const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); + + if (!cutout) { + return [ + { + key: "full", + style: StyleSheet.absoluteFillObject, + }, + ]; + } + + const left = Math.max(0, cutout.x); + const top = Math.max(0, cutout.y); + const right = Math.min(screenWidth, cutout.x + cutout.width); + const bottom = Math.min(screenHeight, cutout.y + cutout.height); + + return [ + { + key: "top", + style: { + position: "absolute" as const, + top: 0, + left: 0, + right: 0, + height: top, + }, + }, + { + key: "left", + style: { + position: "absolute" as const, + top, + left: 0, + width: left, + height: Math.max(0, bottom - top), + }, + }, + { + key: "right", + style: { + position: "absolute" as const, + top, + left: right, + right: 0, + height: Math.max(0, bottom - top), + }, + }, + { + key: "bottom", + style: { + position: "absolute" as const, + top: bottom, + left: 0, + right: 0, + bottom: 0, + }, + }, + ]; + }, [cutout]); + + if (!isRendered || !renderedAnchor || renderedItems.length === 0) { + return null; + } + + return ( + + {backdropRegions.map((region) => ( + + ))} + {backdropRegions.map((region) => ( + + ))} + + + + {renderedItems.map((child, index) => ( + 0 ? styles.itemBorder : undefined}> + {child} + + ))} + + + + ); +}; + +ContextMenu.Item = ContextMenuItem; + +const styles = StyleSheet.create({ + overlay: { + ...StyleSheet.absoluteFillObject, + zIndex: 10, + elevation: 10, + }, + backdrop: { + backgroundColor: "rgba(0, 0, 0, 0.06)", + }, + menu: { + position: "absolute", + zIndex: 11, + minWidth: 176, + borderRadius: 14, + backgroundColor: "#FFFFFF", + overflow: "hidden", + shadowColor: "#000000", + shadowOffset: { width: 0, height: 10 }, + shadowOpacity: 0.16, + shadowRadius: 24, + elevation: 10, + }, + item: { + paddingHorizontal: 14, + paddingVertical: 12, + }, + itemBorder: { + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: "rgba(17, 17, 17, 0.12)", + }, + itemPressed: { + backgroundColor: "rgba(17, 17, 17, 0.06)", + }, + itemDisabled: { + opacity: 0.5, + }, + itemText: { + color: "#111111", + fontSize: 14, + fontWeight: "500", + }, + destructiveText: { + color: "#C43D2F", + }, +}); diff --git a/src/react-native/grab-control-bar.tsx b/src/react-native/grab-control-bar.tsx new file mode 100644 index 0000000..4941d91 --- /dev/null +++ b/src/react-native/grab-control-bar.tsx @@ -0,0 +1,185 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Animated, + Easing, + GestureResponderHandlers, + Image, + Pressable, + StyleSheet, + View, + type StyleProp, + type ViewStyle, +} from "react-native"; + +const BAR_HEIGHT = 36; +const BAR_WIDTH = 108; +const SLOT_WIDTH = 36; + +// Icons from https://lucide.dev/ +const DRAG_ICON_IMAGE_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAsElEQVR4AdySMQqAMAxFxUWv6eiJHL2mbr5AyfAhQ0PaQUnhhzR5pvx1Gfz9D3DwYk87ppEelnfX9Ikuxm3tmEZ6WN5dU4BPqxIKOBn8tmMa6WF5d00BN+P2dkwjPSzvrinAp1WJ6YCUFdk27NMNUlYEEPYpgLu1oYCUFfmlsE8BKSsCCPsUwN3amA4I7cZeqZpuENoNQKqmAObUhgJCu4FN1RQQ2g1AqqYA5tTGcMAHAAD//+qsAJ8AAAAGSURBVAMAklJIMadtfagAAAAASUVORK5CYII="; +const HIDE_ICON_IMAGE_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAABMElEQVR4AezRy20CMRAGYJQiUkcuSQu5pNxc0kJySR1Uwf+hNQ+tx14hIS4gDzue/7UML7s7f54B0wU/bEW/eTX1mufs4OCqFXf0C97D/k4xyKN7YDi4XUIV8BX2X4qQAaNcr44ZDAeX5orgUgXsAxIQMmDEMOPj0ZvBcHBpjuDlVxWAQ0DIgBFDxkpvBsPBpVnVKACZkAEjhj8ZKr0ZDCfj/pkFUDFg9J/L21J6M1hG9dkSUKs3IFsC2s69vTdXev8DbBgzC2DAqO38M27K/s1gOBn3zyiAkAEjhm3n9q43g+HgdhOqAAJCBowYMm4mejMYDi5Nw0/PKoCAkAEjhifR0pjBcHBpFuj8qAIwCBkwcu8VDAe3h++qgI+wFYO0w4ODq1bEKmBFvHXwDJhu7gAAAP//FX4TdAAAAAZJREFUAwAkFUAxInh9owAAAABJRU5ErkJggg=="; +const INSPECT_ICON_IMAGE_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAABNUlEQVR4AezUYU7DMAwFYMPFKCcDTgacDPxFc5ROZazRNu3Hor46cWy/vDTpc1y53ZxgSUGfiZ9JyH3P3P6MCkwIQNIDdnbkvmUOmyZiJHhpnoiPtE+TkJupgYRdESzNE0FJTLbKXSp/VFC+i9oHwb/beXdb5JS4KyxsKXBUoc3tVeCuOILOObjxx0TGMEXQkvL1dUCadqkQlTK+jr0KuvSs8HpA+UoZsmkFVp51QzEwVsyvBRFENttH0epXkf6zHkUFKsICMt8HjOHba+8WyalVKmrMWi0LFgCUTStQoIorqs9X34XlWxEI4GzMOidQKmwTCOWrGsYN4xYJ4JTgJGyhyKtQrV5e+fQ7RgIBSNgecKIzxsnbDB0JBFih/XPstmBeHIhTGEa/uY5jgj5xZkdh+DP8FwAA//94DFv3AAAABklEQVQDANreSTEI+d7fAAAAAElFTkSuQmCC"; + +export type GrabControlBarProps = { + dragHandlePanHandlers?: GestureResponderHandlers; + isSessionEnabled: boolean; + isVisible: boolean; + onHidden?: () => void; + onPressHide: () => void; + onPressSelect: () => void; + containerStyle?: StyleProp; + style?: StyleProp; +}; + +export const GrabControlBar = ({ + dragHandlePanHandlers, + isSessionEnabled, + isVisible, + onHidden, + onPressHide, + onPressSelect, + containerStyle, + style, +}: GrabControlBarProps) => { + const [isRendered, setIsRendered] = useState(isVisible); + const visibilityProgress = useRef(new Animated.Value(isVisible ? 1 : 0)).current; + + useEffect(() => { + if (isVisible) { + setIsRendered(true); + } + + Animated.timing(visibilityProgress, { + toValue: isVisible ? 1 : 0, + duration: 180, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }).start(({ finished }) => { + if (finished && !isVisible) { + setIsRendered(false); + onHidden?.(); + } + }); + }, [isVisible, onHidden, visibilityProgress]); + + const containerAnimatedStyle = useMemo( + () => ({ + opacity: visibilityProgress, + transform: [ + { + translateY: visibilityProgress.interpolate({ + inputRange: [0, 1], + outputRange: [-10, 0], + }), + }, + { + scale: visibilityProgress.interpolate({ + inputRange: [0, 1], + outputRange: [0.92, 1], + }), + }, + ], + }), + [visibilityProgress], + ); + + if (!isRendered) { + return null; + } + + return ( + + + + + + + + + + [styles.slot, pressed && styles.pressedButton]} + > + + + + + + [styles.slot, pressed && styles.pressedButton]} + > + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + height: BAR_HEIGHT, + width: BAR_WIDTH, + borderRadius: BAR_HEIGHT / 2, + backgroundColor: "#FFFFFF", + flexDirection: "row", + alignItems: "center", + overflow: "hidden", + shadowColor: "#000000", + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.18, + shadowRadius: 16, + elevation: 8, + }, + slot: { + width: SLOT_WIDTH, + height: BAR_HEIGHT, + alignItems: "center", + justifyContent: "center", + }, + divider: { + width: StyleSheet.hairlineWidth, + height: 14, + backgroundColor: "rgba(17, 17, 17, 0.14)", + }, + pressedButton: { + backgroundColor: "rgba(17, 17, 17, 0.08)", + }, + dragIcon: { + width: 12, + height: 12, + }, + inspectIcon: { + width: 16, + height: 16, + }, + inspectIconActive: { + opacity: 0.72, + }, + arrowIcon: { + width: 16, + height: 16, + }, +}); diff --git a/src/react-native/grab-controller.ts b/src/react-native/grab-controller.ts index 2deff25..b498b68 100644 --- a/src/react-native/grab-controller.ts +++ b/src/react-native/grab-controller.ts @@ -1,11 +1,17 @@ type EnableGrabbingHandler = () => void; +type ToggleGrabMenuHandler = () => void; let enableGrabbingHandler: EnableGrabbingHandler | null = null; +let toggleGrabMenuHandler: ToggleGrabMenuHandler | null = null; export const setEnableGrabbingHandler = (handler: EnableGrabbingHandler | null) => { enableGrabbingHandler = handler; }; +export const setToggleGrabMenuHandler = (handler: ToggleGrabMenuHandler | null) => { + toggleGrabMenuHandler = handler; +}; + export const enableGrabbing = () => { if (!enableGrabbingHandler) { console.error( @@ -16,3 +22,12 @@ export const enableGrabbing = () => { enableGrabbingHandler(); }; + +export const toggleGrabMenu = () => { + if (!toggleGrabMenuHandler) { + console.error("[react-native-grab] Cannot toggle menu. Ensure ReactNativeGrabRoot is mounted."); + return; + } + + toggleGrabMenuHandler(); +}; diff --git a/src/react-native/grab-overlay.tsx b/src/react-native/grab-overlay.tsx index 25abbfa..1dfa1cc 100644 --- a/src/react-native/grab-overlay.tsx +++ b/src/react-native/grab-overlay.tsx @@ -1,5 +1,14 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { NativeTouchEvent, PanResponder, StyleSheet, Text, View } from "react-native"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Animated, + Dimensions, + NativeTouchEvent, + PanResponder, + Pressable, + StyleSheet, + Text, + View, +} from "react-native"; import { getAppRootShadowNode, getFocusedScreenShadowNode } from "./containers"; import { findNodeAtPoint, measureInWindow } from "./measure"; import type { BoundingClientRect, ReactNativeFiberNode } from "./types"; @@ -7,24 +16,49 @@ import { useDevMenu } from "./dev-menu"; import { getDescription } from "./description"; import { copyViaMetro } from "./copy"; import { FullScreenOverlay } from "./full-screen-overlay"; -import { setEnableGrabbingHandler } from "./grab-controller"; +import { setEnableGrabbingHandler, setToggleGrabMenuHandler } from "./grab-controller"; +import { GrabControlBar } from "./grab-control-bar"; +import { ContextMenu } from "./context-menu"; +import { getRenderedBy, type RenderedByFrame } from "./get-rendered-by"; +import { openStackFrameInEditor } from "./open"; type GrabResult = { fiberNode: ReactNativeFiberNode; rect: BoundingClientRect; }; +type SelectedGrabResult = { + description: string; + elementName: string; + frame: RenderedByFrame | null; + result: GrabResult; +}; + const COPY_BADGE_DURATION_MS = 1600; const CALLSTACK_PRIMARY = "#8232FF"; const HIGHLIGHT_FILL = "rgba(130, 50, 255, 0.2)"; const BADGE_BACKGROUND = "rgba(130, 50, 255, 0.92)"; +const BAR_HEIGHT = 36; +const BAR_WIDTH = 108; +const INITIAL_BAR_POSITION = { + x: (Dimensions.get("window").width - BAR_WIDTH) / 2, + y: 72, +}; + +const clamp = (value: number, min: number, max: number) => { + return Math.min(Math.max(value, min), max); +}; export const ReactNativeGrabOverlay = () => { const copyBadgeTimeoutRef = useRef | null>(null); + const controlBarPosition = useRef(new Animated.ValueXY(INITIAL_BAR_POSITION)).current; + const shouldResetControlBarPositionRef = useRef(false); const [state, setState] = useState({ + isMenuVisible: false, isSessionEnabled: false, grabbedElement: null as GrabResult | null, isCopyBadgeVisible: false, + selectedElement: null as SelectedGrabResult | null, }); const startSession = useCallback(() => { @@ -32,17 +66,31 @@ export const ReactNativeGrabOverlay = () => { ...prev, grabbedElement: null, isSessionEnabled: true, + selectedElement: null, })); }, []); const stopSession = useCallback(() => { setState((prev) => ({ ...prev, - grabbedElement: null, isSessionEnabled: false, })); }, []); + const toggleMenuVisibility = useCallback(() => { + setState((prev) => { + shouldResetControlBarPositionRef.current = prev.isMenuVisible; + + return { + ...prev, + isMenuVisible: !prev.isMenuVisible, + isSessionEnabled: !prev.isMenuVisible ? prev.isSessionEnabled : false, + grabbedElement: !prev.isMenuVisible ? prev.grabbedElement : null, + selectedElement: !prev.isMenuVisible ? prev.selectedElement : null, + }; + }); + }, []); + useEffect(() => { return () => { if (copyBadgeTimeoutRef.current) { @@ -58,6 +106,14 @@ export const ReactNativeGrabOverlay = () => { })); }, []); + const closeSelectedElementMenu = useCallback(() => { + setState((prev) => ({ + ...prev, + selectedElement: null, + grabbedElement: null, + })); + }, []); + const showCopiedBadge = useCallback(() => { if (copyBadgeTimeoutRef.current) { clearTimeout(copyBadgeTimeoutRef.current); @@ -71,14 +127,25 @@ export const ReactNativeGrabOverlay = () => { }, COPY_BADGE_DURATION_MS); }, []); + const resetControlBarPosition = useCallback(() => { + if (!shouldResetControlBarPositionRef.current) { + return; + } + + shouldResetControlBarPositionRef.current = false; + controlBarPosition.setValue(INITIAL_BAR_POSITION); + }, [controlBarPosition]); + useEffect(() => { setEnableGrabbingHandler(startSession); + setToggleGrabMenuHandler(toggleMenuVisibility); return () => { setEnableGrabbingHandler(null); + setToggleGrabMenuHandler(null); }; - }, [startSession]); + }, [startSession, toggleMenuVisibility]); - useDevMenu(startSession); + useDevMenu(toggleMenuVisibility); const getElementAtPoint = ( nativePageX: number, @@ -121,11 +188,65 @@ export const ReactNativeGrabOverlay = () => { }; const handleGrabbing = async (result: GrabResult): Promise => { - const description = await getDescription(result.fiberNode); - console.log("[react-native-grab] Description:", description); - await copyViaMetro(description); + const [description, renderedBy] = await Promise.all([ + getDescription(result.fiberNode), + getRenderedBy(result.fiberNode), + ]); + + const firstFrame = renderedBy.find((frame) => Boolean(frame.file)) ?? null; + const elementNameMatch = description.match(/<([A-Za-z0-9_$.:-]+)/); + const elementName = elementNameMatch?.[1] ?? firstFrame?.name ?? "Selected element"; + + setState((prev) => ({ + ...prev, + grabbedElement: result, + selectedElement: { + description, + elementName, + frame: firstFrame, + result, + }, + })); }; + const handleCopySelectedElement = useCallback(async () => { + const selectedElement = state.selectedElement; + + if (!selectedElement) { + return; + } + + closeSelectedElementMenu(); + + try { + await copyViaMetro(selectedElement.description); + showCopiedBadge(); + } catch { + console.error( + "[react-native-grab] Copying failed. Ensure your Metro config is wrapped with withReactNativeGrab(...) and Metro has been restarted.", + ); + } + }, [closeSelectedElementMenu, showCopiedBadge, state.selectedElement]); + + const handleOpenSelectedElement = useCallback(async () => { + const frame = state.selectedElement?.frame; + + if (!frame?.file || frame.line == null) { + return; + } + + closeSelectedElementMenu(); + + try { + await openStackFrameInEditor({ + file: frame.file, + lineNumber: frame.line, + }); + } catch { + console.error("[react-native-grab] Opening editor failed."); + } + }, [closeSelectedElementMenu, state.selectedElement]); + const panResponder = useRef( PanResponder.create({ onStartShouldSetPanResponderCapture: () => true, @@ -142,11 +263,8 @@ export const ReactNativeGrabOverlay = () => { } await handleGrabbing(result); - showCopiedBadge(); } catch { - console.error( - "[react-native-grab] Copying failed. Ensure your Metro config is wrapped with withReactNativeGrab(...) and Metro has been restarted.", - ); + console.error("[react-native-grab] Grabbing failed."); } finally { stopSession(); } @@ -155,11 +273,69 @@ export const ReactNativeGrabOverlay = () => { }), ).current; + const dragHandlePanResponder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: (_, gestureState) => + Math.abs(gestureState.dx) > 2 || Math.abs(gestureState.dy) > 2, + onPanResponderGrant: () => { + controlBarPosition.stopAnimation((value) => { + controlBarPosition.setOffset(value); + controlBarPosition.setValue({ x: 0, y: 0 }); + }); + }, + onPanResponderMove: Animated.event( + [null, { dx: controlBarPosition.x, dy: controlBarPosition.y }], + { useNativeDriver: false }, + ), + onPanResponderRelease: () => { + controlBarPosition.flattenOffset(); + controlBarPosition.stopAnimation((value) => { + const { width, height } = Dimensions.get("window"); + + controlBarPosition.setValue({ + x: clamp(value.x, 0, Math.max(0, width - BAR_WIDTH)), + y: clamp(value.y, 0, Math.max(0, height - BAR_HEIGHT)), + }); + }); + }, + onPanResponderTerminate: () => { + controlBarPosition.flattenOffset(); + controlBarPosition.stopAnimation((value) => { + const { width, height } = Dimensions.get("window"); + + controlBarPosition.setValue({ + x: clamp(value.x, 0, Math.max(0, width - BAR_WIDTH)), + y: clamp(value.y, 0, Math.max(0, height - BAR_HEIGHT)), + }); + }); + }, + }), + ).current; + + const selectedElementMenuAnchor = useMemo(() => { + if (!state.selectedElement) { + return null; + } + + const rect = state.selectedElement.result.rect; + return { + x: rect[0] + rect[2] / 2, + y: rect[1] + rect[3], + }; + }, [state.selectedElement]); + + const isControlBarVisible = + state.isMenuVisible && !state.isSessionEnabled && state.selectedElement === null; + return ( {state.isSessionEnabled && ( - + )} {state.isSessionEnabled && ( @@ -193,6 +369,78 @@ export const ReactNativeGrabOverlay = () => { ]} /> )} + + + + + + + {state.selectedElement?.elementName} + + + + void handleCopySelectedElement()} + style={({ pressed }) => [ + styles.selectionMenuActionButton, + pressed && styles.selectionMenuActionButtonPressed, + ]} + > + Copy + + + void handleOpenSelectedElement()} + style={({ pressed }) => [ + styles.selectionMenuActionButton, + pressed && + state.selectedElement?.frame?.file && + styles.selectionMenuActionButtonPressed, + ]} + > + + Open + + + + ); @@ -203,6 +451,15 @@ const styles = StyleSheet.create({ ...StyleSheet.absoluteFillObject, zIndex: 9999, }, + sessionCapture: { + zIndex: 1, + }, + controlBar: { + position: "absolute", + top: 0, + left: 0, + zIndex: 2, + }, topBadge: { position: "absolute", top: 52, @@ -217,4 +474,38 @@ const styles = StyleSheet.create({ fontSize: 13, fontWeight: "600", }, + selectionMenuHeader: { + paddingHorizontal: 14, + paddingVertical: 12, + }, + selectionMenuTitle: { + color: "#111111", + fontSize: 14, + fontWeight: "600", + }, + selectionMenuActions: { + flexDirection: "row", + }, + selectionMenuActionButton: { + flex: 1, + }, + selectionMenuActionButtonPressed: { + backgroundColor: "rgba(17, 17, 17, 0.06)", + }, + selectionMenuActionDivider: { + width: StyleSheet.hairlineWidth, + backgroundColor: "rgba(17, 17, 17, 0.12)", + }, + selectionMenuActionText: { + flex: 1, + paddingHorizontal: 14, + paddingVertical: 12, + color: "#111111", + fontSize: 14, + fontWeight: "500", + textAlign: "center", + }, + selectionMenuActionTextDisabled: { + opacity: 0.4, + }, }); diff --git a/src/react-native/open.ts b/src/react-native/open.ts new file mode 100644 index 0000000..29afa6a --- /dev/null +++ b/src/react-native/open.ts @@ -0,0 +1,20 @@ +import getDevServer from "react-native/Libraries/Core/Devtools/getDevServer"; + +type OpenFramePayload = { + file: string; + lineNumber: number; +}; + +export const openStackFrameInEditor = async (payload: OpenFramePayload): Promise => { + const response = await fetch(`${getDevServer().url}open-stack-frame`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Open stack frame request failed with status ${response.status}`); + } +};