Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 140 additions & 20 deletions src/react-native/containers.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,164 @@
import { useSyncExternalStore } from "react";
import { findNodeHandle, type ReactNativeElement } from "react-native";
import type { ReactNativeShadowNode } from "./types";
import { getFabricUIManager } from "./fabric";

let focusedScreenShadowNode: ReactNativeShadowNode | null = null;
let appRootShadowNode: ReactNativeShadowNode | null = null;
export type GrabSelectionOwnerKind = "root" | "screen";

export const setFocusedScreenRef = (ref: ReactNativeElement) => {
// @ts-expect-error - findNodeHandle is not typed correctly
const nativeTag = findNodeHandle(ref);
export type GrabSelectionOwner = {
id: string;
kind: GrabSelectionOwnerKind;
shadowNode: ReactNativeShadowNode;
registrationOrder: number;
};

if (!nativeTag) {
throw new Error("Failed to find native tag for focused screen");
type SelectionOwnersStoreSnapshot = {
owners: Map<string, GrabSelectionOwner>;
focusedScreenOwnerId: string | null;
};

let ownerIdCounter = 0;
let registrationOrder = 0;
let focusedScreenOwnerId: string | null = null;
const owners = new Map<string, GrabSelectionOwner>();
const listeners = new Set<() => void>();

const notify = () => {
for (const listener of listeners) {
listener();
}
};

focusedScreenShadowNode = getFabricUIManager().findShadowNodeByTag_DEPRECATED(nativeTag);
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};

export const setAppRootRef = (ref: ReactNativeElement) => {
const getSnapshot = (): SelectionOwnersStoreSnapshot => ({
owners: new Map(owners),
focusedScreenOwnerId,
});

const getOwnerShadowNode = (ref: ReactNativeElement, errorMessage: string) => {
// @ts-expect-error - findNodeHandle is not typed correctly
const nativeTag = findNodeHandle(ref);

if (!nativeTag) {
throw new Error("Failed to find native tag for app root");
throw new Error(errorMessage);
}

return getFabricUIManager().findShadowNodeByTag_DEPRECATED(nativeTag);
};

const getFallbackRootOwner = () => {
const rootOwners = Array.from(owners.values()).filter((owner) => owner.kind === "root");
rootOwners.sort((left, right) => right.registrationOrder - left.registrationOrder);
return rootOwners[0] ?? null;
};

export const createGrabSelectionOwnerId = (kind: GrabSelectionOwnerKind) => {
ownerIdCounter += 1;
return `react-native-grab-${kind}-${ownerIdCounter}`;
};

export const registerGrabSelectionOwner = (
id: string,
kind: GrabSelectionOwnerKind,
ref: ReactNativeElement,
) => {
const shadowNode = getOwnerShadowNode(
ref,
kind === "root"
? "Failed to find native tag for app root"
: "Failed to find native tag for screen",
);

registrationOrder += 1;
owners.set(id, {
id,
kind,
shadowNode,
registrationOrder,
});
notify();
};

export const unregisterGrabSelectionOwner = (id: string) => {
const removedOwner = owners.get(id);
if (!removedOwner) {
return;
}

appRootShadowNode = getFabricUIManager().findShadowNodeByTag_DEPRECATED(nativeTag);
owners.delete(id);

if (focusedScreenOwnerId === id) {
focusedScreenOwnerId = null;
}

notify();
};

export const setGrabSelectionOwnerFocused = (id: string, isFocused: boolean) => {
const owner = owners.get(id);
if (!owner || owner.kind !== "screen") {
return;
}

if (isFocused) {
focusedScreenOwnerId = id;
} else if (focusedScreenOwnerId === id) {
focusedScreenOwnerId = null;
}

notify();
};

export const getAppRootShadowNode = (): ReactNativeShadowNode => {
if (!appRootShadowNode) {
throw new Error("You seem to forgot to wrap your app root with ReactNativeGrabRoot.");
export const clearGrabSelectionOwnerFocus = (id: string) => {
if (focusedScreenOwnerId !== id) {
return;
}

return appRootShadowNode;
focusedScreenOwnerId = null;
notify();
};

export const getGrabSelectionOwner = (id: string): GrabSelectionOwner | null => {
return owners.get(id) ?? null;
};
export const getFocusedScreenShadowNode = () => {
if (!focusedScreenShadowNode) {
// No native screens, so there will be only the app root.
return getAppRootShadowNode();

export const getResolvedGrabSelectionOwner = (): GrabSelectionOwner | null => {
if (focusedScreenOwnerId) {
const focusedOwner = owners.get(focusedScreenOwnerId);
if (focusedOwner) {
return focusedOwner;
}
}

return focusedScreenShadowNode;
return getFallbackRootOwner();
};

export const getResolvedGrabSelectionOwnerId = (): string | null => {
return getResolvedGrabSelectionOwner()?.id ?? null;
};

export const useResolvedGrabSelectionOwnerId = () => {
return useSyncExternalStore(
subscribe,
() => getResolvedGrabSelectionOwnerId(),
() => null,
);
};

export const useIsResolvedGrabSelectionOwner = (id: string) => {
return useSyncExternalStore(
subscribe,
() => getResolvedGrabSelectionOwnerId() === id,
() => false,
);
};

export const useSelectionOwnersStore = () => {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
};
58 changes: 27 additions & 31 deletions src/react-native/context-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export type ContextMenuCutout = {
height: number;
};

export type ContextMenuBounds = {
width: number;
height: number;
};

export type ContextMenuHorizontalAlignment = "left" | "center" | "right";
export type ContextMenuVerticalAlignment = "top" | "center" | "bottom";
export type ContextMenuOffset = {
Expand All @@ -49,6 +54,7 @@ const ContextMenuContext = createContext<ContextMenuContextValue | null>(null);

export type ContextMenuProps = {
anchor: ContextMenuAnchor | null;
bounds?: ContextMenuBounds | null;
children?: ReactNode;
cutout?: ContextMenuCutout | null;
horizontalAlignment?: ContextMenuHorizontalAlignment;
Expand Down Expand Up @@ -104,8 +110,9 @@ const getMenuPosition = (
horizontalAlignment: ContextMenuHorizontalAlignment,
verticalAlignment: ContextMenuVerticalAlignment,
offset: ContextMenuOffset,
bounds: ContextMenuBounds | null,
) => {
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
const { width: screenWidth, height: screenHeight } = bounds ?? Dimensions.get("window");
const preferredLeft = getAlignedLeft(anchor.x, menuWidth, horizontalAlignment) + offset.x;
const preferredTop = getAlignedTop(anchor.y, menuHeight, verticalAlignment) + offset.y;

Expand Down Expand Up @@ -154,6 +161,7 @@ const ContextMenuItem = ({

export const ContextMenu = ({
anchor,
bounds = null,
children,
cutout = null,
horizontalAlignment = "center",
Expand Down Expand Up @@ -211,8 +219,10 @@ export const ContextMenu = ({
horizontalAlignment,
verticalAlignment,
offset,
bounds,
);
}, [
bounds,
horizontalAlignment,
menuSize.height,
menuSize.width,
Expand All @@ -226,8 +236,8 @@ export const ContextMenu = ({
[children],
);

const backdropRegions = useMemo(() => {
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
const dismissalRegions = useMemo(() => {
const { width: screenWidth, height: screenHeight } = bounds ?? Dimensions.get("window");

if (!cutout) {
return [
Expand Down Expand Up @@ -285,31 +295,15 @@ export const ContextMenu = ({
},
},
];
}, [cutout]);
}, [bounds, cutout]);

if (!isRendered || !renderedAnchor || renderedItems.length === 0) {
return null;
}

return (
<View pointerEvents="box-none" style={styles.overlay}>
{backdropRegions.map((region) => (
<Animated.View
key={`backdrop-${region.key}`}
pointerEvents="none"
style={[
region.style,
styles.backdrop,
{
opacity: animation.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
}),
},
]}
/>
))}
{backdropRegions.map((region) => (
{dismissalRegions.map((region) => (
<Pressable
key={`pressable-${region.key}`}
accessibilityLabel="Close context menu"
Expand Down Expand Up @@ -343,11 +337,13 @@ export const ContextMenu = ({
},
]}
>
{renderedItems.map((child, index) => (
<View key={index} style={index > 0 ? styles.itemBorder : undefined}>
{child}
</View>
))}
<View style={styles.menuContent}>
{renderedItems.map((child, index) => (
<View key={index} style={index > 0 ? styles.itemBorder : undefined}>
{child}
</View>
))}
</View>
</Animated.View>
</ContextMenuContext.Provider>
</View>
Expand All @@ -362,22 +358,22 @@ const styles = StyleSheet.create({
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,
},
menuContent: {
borderRadius: 14,
backgroundColor: "#FFFFFF",
overflow: "hidden",
},
item: {
paddingHorizontal: 14,
paddingVertical: 12,
Expand Down
3 changes: 3 additions & 0 deletions src/react-native/grab-colors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const GRAB_PRIMARY = "#8232FF";
export const GRAB_HIGHLIGHT_FILL = "rgba(130, 50, 255, 0.2)";
export const GRAB_BADGE_BACKGROUND = "rgba(130, 50, 255, 0.92)";
Loading
Loading