Skip to content

Commit ba7c5d4

Browse files
authored
fix: misaligned highlighting (#7)
Fix misaligned grab highlights by mounting the overlay per screen, so grab lines up correctly with native tab layouts (e.g. bottom tabs).
1 parent 4c3f5f7 commit ba7c5d4

11 files changed

Lines changed: 776 additions & 396 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-native-grab": patch
3+
---
4+
5+
Fix misaligned grab highlights by mounting the overlay per screen, so grab lines up correctly with native tab layouts (e.g. bottom tabs).

src/react-native/containers.ts

Lines changed: 140 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,164 @@
1+
import { useSyncExternalStore } from "react";
12
import { findNodeHandle, type ReactNativeElement } from "react-native";
23
import type { ReactNativeShadowNode } from "./types";
34
import { getFabricUIManager } from "./fabric";
45

5-
let focusedScreenShadowNode: ReactNativeShadowNode | null = null;
6-
let appRootShadowNode: ReactNativeShadowNode | null = null;
6+
export type GrabSelectionOwnerKind = "root" | "screen";
77

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

12-
if (!nativeTag) {
13-
throw new Error("Failed to find native tag for focused screen");
15+
type SelectionOwnersStoreSnapshot = {
16+
owners: Map<string, GrabSelectionOwner>;
17+
focusedScreenOwnerId: string | null;
18+
};
19+
20+
let ownerIdCounter = 0;
21+
let registrationOrder = 0;
22+
let focusedScreenOwnerId: string | null = null;
23+
const owners = new Map<string, GrabSelectionOwner>();
24+
const listeners = new Set<() => void>();
25+
26+
const notify = () => {
27+
for (const listener of listeners) {
28+
listener();
1429
}
30+
};
1531

16-
focusedScreenShadowNode = getFabricUIManager().findShadowNodeByTag_DEPRECATED(nativeTag);
32+
const subscribe = (listener: () => void) => {
33+
listeners.add(listener);
34+
return () => {
35+
listeners.delete(listener);
36+
};
1737
};
1838

19-
export const setAppRootRef = (ref: ReactNativeElement) => {
39+
const getSnapshot = (): SelectionOwnersStoreSnapshot => ({
40+
owners: new Map(owners),
41+
focusedScreenOwnerId,
42+
});
43+
44+
const getOwnerShadowNode = (ref: ReactNativeElement, errorMessage: string) => {
2045
// @ts-expect-error - findNodeHandle is not typed correctly
2146
const nativeTag = findNodeHandle(ref);
2247

2348
if (!nativeTag) {
24-
throw new Error("Failed to find native tag for app root");
49+
throw new Error(errorMessage);
50+
}
51+
52+
return getFabricUIManager().findShadowNodeByTag_DEPRECATED(nativeTag);
53+
};
54+
55+
const getFallbackRootOwner = () => {
56+
const rootOwners = Array.from(owners.values()).filter((owner) => owner.kind === "root");
57+
rootOwners.sort((left, right) => right.registrationOrder - left.registrationOrder);
58+
return rootOwners[0] ?? null;
59+
};
60+
61+
export const createGrabSelectionOwnerId = (kind: GrabSelectionOwnerKind) => {
62+
ownerIdCounter += 1;
63+
return `react-native-grab-${kind}-${ownerIdCounter}`;
64+
};
65+
66+
export const registerGrabSelectionOwner = (
67+
id: string,
68+
kind: GrabSelectionOwnerKind,
69+
ref: ReactNativeElement,
70+
) => {
71+
const shadowNode = getOwnerShadowNode(
72+
ref,
73+
kind === "root"
74+
? "Failed to find native tag for app root"
75+
: "Failed to find native tag for screen",
76+
);
77+
78+
registrationOrder += 1;
79+
owners.set(id, {
80+
id,
81+
kind,
82+
shadowNode,
83+
registrationOrder,
84+
});
85+
notify();
86+
};
87+
88+
export const unregisterGrabSelectionOwner = (id: string) => {
89+
const removedOwner = owners.get(id);
90+
if (!removedOwner) {
91+
return;
2592
}
2693

27-
appRootShadowNode = getFabricUIManager().findShadowNodeByTag_DEPRECATED(nativeTag);
94+
owners.delete(id);
95+
96+
if (focusedScreenOwnerId === id) {
97+
focusedScreenOwnerId = null;
98+
}
99+
100+
notify();
101+
};
102+
103+
export const setGrabSelectionOwnerFocused = (id: string, isFocused: boolean) => {
104+
const owner = owners.get(id);
105+
if (!owner || owner.kind !== "screen") {
106+
return;
107+
}
108+
109+
if (isFocused) {
110+
focusedScreenOwnerId = id;
111+
} else if (focusedScreenOwnerId === id) {
112+
focusedScreenOwnerId = null;
113+
}
114+
115+
notify();
28116
};
29117

30-
export const getAppRootShadowNode = (): ReactNativeShadowNode => {
31-
if (!appRootShadowNode) {
32-
throw new Error("You seem to forgot to wrap your app root with ReactNativeGrabRoot.");
118+
export const clearGrabSelectionOwnerFocus = (id: string) => {
119+
if (focusedScreenOwnerId !== id) {
120+
return;
33121
}
34122

35-
return appRootShadowNode;
123+
focusedScreenOwnerId = null;
124+
notify();
125+
};
126+
127+
export const getGrabSelectionOwner = (id: string): GrabSelectionOwner | null => {
128+
return owners.get(id) ?? null;
36129
};
37-
export const getFocusedScreenShadowNode = () => {
38-
if (!focusedScreenShadowNode) {
39-
// No native screens, so there will be only the app root.
40-
return getAppRootShadowNode();
130+
131+
export const getResolvedGrabSelectionOwner = (): GrabSelectionOwner | null => {
132+
if (focusedScreenOwnerId) {
133+
const focusedOwner = owners.get(focusedScreenOwnerId);
134+
if (focusedOwner) {
135+
return focusedOwner;
136+
}
41137
}
42138

43-
return focusedScreenShadowNode;
139+
return getFallbackRootOwner();
140+
};
141+
142+
export const getResolvedGrabSelectionOwnerId = (): string | null => {
143+
return getResolvedGrabSelectionOwner()?.id ?? null;
144+
};
145+
146+
export const useResolvedGrabSelectionOwnerId = () => {
147+
return useSyncExternalStore(
148+
subscribe,
149+
() => getResolvedGrabSelectionOwnerId(),
150+
() => null,
151+
);
152+
};
153+
154+
export const useIsResolvedGrabSelectionOwner = (id: string) => {
155+
return useSyncExternalStore(
156+
subscribe,
157+
() => getResolvedGrabSelectionOwnerId() === id,
158+
() => false,
159+
);
160+
};
161+
162+
export const useSelectionOwnersStore = () => {
163+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
44164
};

src/react-native/context-menu.tsx

Lines changed: 27 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ export type ContextMenuCutout = {
3434
height: number;
3535
};
3636

37+
export type ContextMenuBounds = {
38+
width: number;
39+
height: number;
40+
};
41+
3742
export type ContextMenuHorizontalAlignment = "left" | "center" | "right";
3843
export type ContextMenuVerticalAlignment = "top" | "center" | "bottom";
3944
export type ContextMenuOffset = {
@@ -49,6 +54,7 @@ const ContextMenuContext = createContext<ContextMenuContextValue | null>(null);
4954

5055
export type ContextMenuProps = {
5156
anchor: ContextMenuAnchor | null;
57+
bounds?: ContextMenuBounds | null;
5258
children?: ReactNode;
5359
cutout?: ContextMenuCutout | null;
5460
horizontalAlignment?: ContextMenuHorizontalAlignment;
@@ -104,8 +110,9 @@ const getMenuPosition = (
104110
horizontalAlignment: ContextMenuHorizontalAlignment,
105111
verticalAlignment: ContextMenuVerticalAlignment,
106112
offset: ContextMenuOffset,
113+
bounds: ContextMenuBounds | null,
107114
) => {
108-
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
115+
const { width: screenWidth, height: screenHeight } = bounds ?? Dimensions.get("window");
109116
const preferredLeft = getAlignedLeft(anchor.x, menuWidth, horizontalAlignment) + offset.x;
110117
const preferredTop = getAlignedTop(anchor.y, menuHeight, verticalAlignment) + offset.y;
111118

@@ -154,6 +161,7 @@ const ContextMenuItem = ({
154161

155162
export const ContextMenu = ({
156163
anchor,
164+
bounds = null,
157165
children,
158166
cutout = null,
159167
horizontalAlignment = "center",
@@ -211,8 +219,10 @@ export const ContextMenu = ({
211219
horizontalAlignment,
212220
verticalAlignment,
213221
offset,
222+
bounds,
214223
);
215224
}, [
225+
bounds,
216226
horizontalAlignment,
217227
menuSize.height,
218228
menuSize.width,
@@ -226,8 +236,8 @@ export const ContextMenu = ({
226236
[children],
227237
);
228238

229-
const backdropRegions = useMemo(() => {
230-
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
239+
const dismissalRegions = useMemo(() => {
240+
const { width: screenWidth, height: screenHeight } = bounds ?? Dimensions.get("window");
231241

232242
if (!cutout) {
233243
return [
@@ -285,31 +295,15 @@ export const ContextMenu = ({
285295
},
286296
},
287297
];
288-
}, [cutout]);
298+
}, [bounds, cutout]);
289299

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

294304
return (
295305
<View pointerEvents="box-none" style={styles.overlay}>
296-
{backdropRegions.map((region) => (
297-
<Animated.View
298-
key={`backdrop-${region.key}`}
299-
pointerEvents="none"
300-
style={[
301-
region.style,
302-
styles.backdrop,
303-
{
304-
opacity: animation.interpolate({
305-
inputRange: [0, 1],
306-
outputRange: [0, 1],
307-
}),
308-
},
309-
]}
310-
/>
311-
))}
312-
{backdropRegions.map((region) => (
306+
{dismissalRegions.map((region) => (
313307
<Pressable
314308
key={`pressable-${region.key}`}
315309
accessibilityLabel="Close context menu"
@@ -343,11 +337,13 @@ export const ContextMenu = ({
343337
},
344338
]}
345339
>
346-
{renderedItems.map((child, index) => (
347-
<View key={index} style={index > 0 ? styles.itemBorder : undefined}>
348-
{child}
349-
</View>
350-
))}
340+
<View style={styles.menuContent}>
341+
{renderedItems.map((child, index) => (
342+
<View key={index} style={index > 0 ? styles.itemBorder : undefined}>
343+
{child}
344+
</View>
345+
))}
346+
</View>
351347
</Animated.View>
352348
</ContextMenuContext.Provider>
353349
</View>
@@ -362,22 +358,22 @@ const styles = StyleSheet.create({
362358
zIndex: 10,
363359
elevation: 10,
364360
},
365-
backdrop: {
366-
backgroundColor: "rgba(0, 0, 0, 0.06)",
367-
},
368361
menu: {
369362
position: "absolute",
370363
zIndex: 11,
371364
minWidth: 176,
372365
borderRadius: 14,
373-
backgroundColor: "#FFFFFF",
374-
overflow: "hidden",
375366
shadowColor: "#000000",
376367
shadowOffset: { width: 0, height: 10 },
377368
shadowOpacity: 0.16,
378369
shadowRadius: 24,
379370
elevation: 10,
380371
},
372+
menuContent: {
373+
borderRadius: 14,
374+
backgroundColor: "#FFFFFF",
375+
overflow: "hidden",
376+
},
381377
item: {
382378
paddingHorizontal: 14,
383379
paddingVertical: 12,

src/react-native/grab-colors.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const GRAB_PRIMARY = "#8232FF";
2+
export const GRAB_HIGHLIGHT_FILL = "rgba(130, 50, 255, 0.2)";
3+
export const GRAB_BADGE_BACKGROUND = "rgba(130, 50, 255, 0.92)";

0 commit comments

Comments
 (0)