Skip to content

Commit 1a67c23

Browse files
Add support for macOS context menus (#8)
* Context menu module * Add consumers * Add checked support * Add support for context menu to timeline * Update app/utils/useContextMenu.tsx Co-authored-by: Jamon Holmgren <code@jamon.dev> Signed-off-by: Sean Barker <43788519+SeanBarker182@users.noreply.github.com> * Use one liner for code consistency Co-authored-by: Jamon Holmgren <code@jamon.dev> Signed-off-by: Sean Barker <43788519+SeanBarker182@users.noreply.github.com> * Use a ternary to increase legibility Co-authored-by: Jamon Holmgren <code@jamon.dev> Signed-off-by: Sean Barker <43788519+SeanBarker182@users.noreply.github.com> * Decrease indentation for better legibility Co-authored-by: Jamon Holmgren <code@jamon.dev> Signed-off-by: Sean Barker <43788519+SeanBarker182@users.noreply.github.com> * Code consistency * Delete header file and allow it to autogenerate * Rename to action menu * Rename to PressableWithRightClick * useLayoutEffect to build the ref before paint (eliminates small window where it could be out of sync) * podfile updates --------- Signed-off-by: Sean Barker <43788519+SeanBarker182@users.noreply.github.com> Co-authored-by: Jamon Holmgren <code@jamon.dev>
1 parent dd329d7 commit 1a67c23

File tree

8 files changed

+442
-19
lines changed

8 files changed

+442
-19
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import React, { useCallback } from "react"
2+
import { Pressable, type PressableProps, type GestureResponderEvent } from "react-native"
3+
import { useActionMenu, type ActionMenuConfig } from "../utils/useActionMenu" // adjust path
4+
5+
/**
6+
* # PressableWithRightClick (macOS)
7+
*
8+
* A minimal wrapper around `Pressable` that also supports a native
9+
* action menu on right-click or ctrl-click. It wires into your
10+
* `useActionMenu` hook and opens the menu at the cursor location.
11+
*
12+
* ## Props
13+
* - `items`: The action menu structure (including nested children and `SEPARATOR`).
14+
* - All other props are forwarded to the underlying `Pressable`.
15+
*
16+
* ## Behavior
17+
* - **Primary click** behaves like a normal `Pressable` (`onPress`).
18+
* - **Right-click** or **ctrl-click** opens the action menu.
19+
* - Menu actions are resolved via the `useActionMenu` hook.
20+
*
21+
* ## Example
22+
* ```tsx
23+
* import { SEPARATOR } from "./useActionMenu"
24+
*
25+
* <PressableWithRightClick
26+
* items={[
27+
* { label: "Open", action: () => console.log("Open") },
28+
* SEPARATOR,
29+
* {
30+
* label: "Share",
31+
* children: [
32+
* { label: "Copy Link", action: () => console.log("Copy Link") },
33+
* { label: "Email…", action: () => console.log("Email") },
34+
* ],
35+
* },
36+
* { label: "Delete", enabled: true, action: () => console.log("Delete") },
37+
* ]}
38+
* onPress={() => console.log("Primary press")}
39+
* style={{ padding: 12 }}
40+
* >
41+
* {children}
42+
* </PressableWithRightClick>
43+
* ```
44+
*/
45+
46+
export interface PressableWithRightClickProps extends PressableProps {
47+
items: ActionMenuConfig["items"]
48+
}
49+
50+
export function PressableWithRightClick(props: PressableWithRightClickProps) {
51+
const { items, onPress, ...rest } = props
52+
const { open } = useActionMenu({ items })
53+
54+
const handlePress = useCallback(
55+
(e: GestureResponderEvent) => {
56+
if ((e.nativeEvent as any).button === 2) return open()
57+
if (onPress) onPress(e)
58+
},
59+
[open, onPress],
60+
)
61+
62+
return <Pressable onPress={handlePress} {...rest} />
63+
}
64+
65+
export default PressableWithRightClick

app/components/TimelineItem.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { Text, View, type ViewStyle, type TextStyle, Pressable } from "react-native"
1+
import { Text, View, type ViewStyle, type TextStyle } from "react-native"
22
import { useThemeName, withTheme } from "../theme/theme"
3+
import PressableWithRightClick from "./PressableWithRightClick"
4+
import { MenuListEntry } from "../utils/useActionMenu"
35

46
/**
57
* A single item in the timeline.
@@ -20,6 +22,7 @@ type TimelineItemProps = {
2022
responseStatusCode?: number
2123
isSelected?: boolean
2224
onSelect?: () => void
25+
actionMenu?: MenuListEntry[]
2326
}
2427

2528
export function TimelineItem({
@@ -33,6 +36,7 @@ export function TimelineItem({
3336
responseStatusCode,
3437
isSelected = false,
3538
onSelect,
39+
actionMenu,
3640
}: TimelineItemProps) {
3741
const [themeName] = useThemeName()
3842

@@ -44,7 +48,11 @@ export function TimelineItem({
4448

4549
return (
4650
<View style={[$container(themeName), isSelected && $containerSelected(themeName)]}>
47-
<Pressable style={$pressableContainer(themeName)} onPress={handlePress}>
51+
<PressableWithRightClick
52+
style={$pressableContainer(themeName)}
53+
onPress={handlePress}
54+
items={actionMenu ?? []}
55+
>
4856
{/* Top Row: Title + Status + Time */}
4957
<View style={$topRow}>
5058
<View style={$leftSection}>
@@ -76,7 +84,7 @@ export function TimelineItem({
7684
</Text>
7785
{!!deltaTime && <Text style={$deltaText(themeName)}>+{deltaTime}ms</Text>}
7886
</View>
79-
</Pressable>
87+
</PressableWithRightClick>
8088
</View>
8189
)
8290
}

app/components/TimelineLogItem.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { MenuListEntry } from "app/utils/useActionMenu"
12
import { TimelineItemLog } from "../types"
23
import { TimelineItem } from "./TimelineItem"
4+
import IRClipboard from "../native/IRClipboard/NativeIRClipboard"
35

46
type TimelineLogItemProps = {
57
item: TimelineItemLog
@@ -32,6 +34,13 @@ export function TimelineLogItem({ item, isSelected = false, onSelect }: Timeline
3234
const preview =
3335
message.toString().substring(0, 100) + (message.toString().length > 100 ? "..." : "")
3436

37+
const actionMenu: MenuListEntry[] = [
38+
{
39+
label: "Copy Message",
40+
action: () => IRClipboard.setString(message),
41+
},
42+
]
43+
3544
return (
3645
<TimelineItem
3746
title={level}
@@ -42,6 +51,7 @@ export function TimelineLogItem({ item, isSelected = false, onSelect }: Timeline
4251
isTagged={important}
4352
isSelected={isSelected}
4453
onSelect={onSelect}
54+
actionMenu={actionMenu}
4555
/>
4656
)
4757
}

app/components/TimelineNetworkItem.tsx

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { TimelineItemNetwork } from "../types"
22
import { TimelineItem } from "./TimelineItem"
33
import { useTheme, useThemeName } from "../theme/theme"
4+
import type { MenuListEntry } from "../utils/useActionMenu"
5+
import IRClipboard from "../native/IRClipboard/NativeIRClipboard"
46

57
type TimelineNetworkItemProps = {
68
item: TimelineItemNetwork
@@ -64,21 +66,21 @@ export function TimelineNetworkItem({
6466
? `${payload?.response?.status || ""} ${payload?.response?.statusText || ""}`
6567
: "UNKNOWN"
6668

67-
// TODO: move this into a context menu
68-
// const toolbar = [
69-
// {
70-
// icon: ({ size }: { size: number }) => <Text style={{ fontSize: size }}>📋</Text>,
71-
// tip: "Copy to clipboard",
72-
// onClick: () => {
73-
// IRClipboard.setString(JSON.stringify(payload))
74-
// },
75-
// },
76-
// {
77-
// icon: ({ size }: { size: number }) => <Text style={{ fontSize: size }}>🔍</Text>,
78-
// tip: "Search similar",
79-
// onClick: () => console.log("Search similar"),
80-
// },
81-
// ]
69+
const actionMenu: MenuListEntry[] = [
70+
{
71+
label: "Copy",
72+
children: [
73+
{
74+
label: "Copy Request",
75+
action: () => IRClipboard.setString(JSON.stringify(payload?.request)),
76+
},
77+
{
78+
label: "Copy Response",
79+
action: () => IRClipboard.setString(JSON.stringify(payload?.response)),
80+
},
81+
],
82+
},
83+
]
8284

8385
return (
8486
<TimelineItem
@@ -92,6 +94,7 @@ export function TimelineNetworkItem({
9294
responseStatusCode={responseStatusCode}
9395
isSelected={isSelected}
9496
onSelect={onSelect}
97+
actionMenu={actionMenu}
9598
/>
9699
)
97100
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
#import "IRActionMenuManager.h"
2+
#import <Cocoa/Cocoa.h>
3+
#import <React/RCTUtils.h>
4+
5+
static NSString * const kSeparatorString = @"menu-item-separator";
6+
7+
@implementation IRActionMenuManager
8+
9+
RCT_EXPORT_MODULE()
10+
11+
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params {
12+
return std::make_shared<facebook::react::NativeIRActionMenuManagerSpecJSI>(params);
13+
}
14+
15+
#pragma mark - API
16+
17+
- (void)showActionMenu:(NSArray *)items {
18+
RCTExecuteOnMainQueue(^{
19+
[self presentActionMenuWithItems:items];
20+
});
21+
}
22+
23+
#pragma mark - Helpers
24+
25+
- (void)presentActionMenuWithItems:(NSArray *)items {
26+
NSMenu *menu = [[NSMenu alloc] initWithTitle:@""];
27+
28+
[self buildMenu:menu fromEntries:items path:@[]];
29+
30+
NSEvent *event = [NSEvent mouseEventWithType:NSEventTypeRightMouseDown
31+
location:[self currentMouseLocation]
32+
modifierFlags:0
33+
timestamp:[[NSProcessInfo processInfo] systemUptime]
34+
windowNumber:[[NSApp keyWindow] windowNumber]
35+
context:nil
36+
eventNumber:0
37+
clickCount:1
38+
pressure:1.0
39+
];
40+
41+
[NSMenu popUpContextMenu:menu withEvent:event forView:[[NSApp keyWindow] contentView]];
42+
}
43+
44+
- (NSPoint)currentMouseLocation {
45+
NSPoint screenPoint = [NSEvent mouseLocation];
46+
47+
// Convert screen -> window coordinates for the key window
48+
NSWindow *window = [NSApp keyWindow];
49+
if (!window) {
50+
return screenPoint; // best effort
51+
}
52+
53+
NSRect screenRect = NSMakeRect(screenPoint.x, screenPoint.y, 0, 0);
54+
NSPoint windowPoint = [window convertRectFromScreen:screenRect].origin;
55+
return windowPoint;
56+
}
57+
58+
- (void)buildMenu:(NSMenu *)menu fromEntries:(NSArray *)entries path:(NSArray<NSString *> *)path {
59+
for (id entry in entries) {
60+
if ([entry isKindOfClass:[NSString class]] && [(NSString *)entry isEqualToString:kSeparatorString]) {
61+
[menu addItem:[NSMenuItem separatorItem]];
62+
continue;
63+
}
64+
65+
if (![entry isKindOfClass:[NSDictionary class]]) continue;
66+
NSDictionary *item = (NSDictionary *)entry;
67+
NSString *label = item[@"label"] ?: @"";
68+
BOOL enabled = item[@"enabled"] != nil ? [item[@"enabled"] boolValue] : YES;
69+
BOOL checked = item[@"checked"] != nil ? [item[@"checked"] boolValue] : NO;
70+
NSString *shortcut = item[@"shortcut"] ?: @"";
71+
NSArray *children = item[@"children"];
72+
73+
NSMenuItem *menuItem = [[NSMenuItem alloc] initWithTitle:label action:enabled ? @selector(_ir_menuItemPressed:) : nil keyEquivalent:@""];
74+
menuItem.target = enabled ? self : nil;
75+
menuItem.enabled = enabled;
76+
menuItem.state = checked ? NSControlStateValueOn : NSControlStateValueOff;
77+
if (shortcut.length > 0) {
78+
[self applyShortcut:shortcut toItem:menuItem];
79+
}
80+
81+
NSArray<NSString *> *currentPath = label ? [path arrayByAddingObject:label] : [path copy];
82+
menuItem.representedObject = currentPath;
83+
84+
if ([children isKindOfClass:[NSArray class]] && children.count > 0) {
85+
NSMenu *submenu = [[NSMenu alloc] initWithTitle:label];
86+
[self buildMenu:submenu fromEntries:children path:currentPath];
87+
menuItem.submenu = submenu;
88+
}
89+
90+
[menu addItem:menuItem];
91+
}
92+
}
93+
94+
- (NSString *)keyEquivalentForKeyName:(NSString *)keyName {
95+
// Map common key names to their NSMenuItem key equivalents
96+
static NSDictionary *keyMap = nil;
97+
if (!keyMap) {
98+
keyMap = @{
99+
@"enter": @"\r",
100+
@"return": @"\r",
101+
@"space": @" ",
102+
@"tab": @"\t",
103+
@"delete": @"\x08",
104+
@"backspace": @"\x08",
105+
@"escape": @"\x1b",
106+
@"esc": @"\x1b",
107+
@"up": @"\uF700",
108+
@"down": @"\uF701",
109+
@"left": @"\uF702",
110+
@"right": @"\uF703",
111+
@"f1": @"\uF704",
112+
@"f2": @"\uF705",
113+
@"f3": @"\uF706",
114+
@"f4": @"\uF707",
115+
@"f5": @"\uF708",
116+
@"f6": @"\uF709",
117+
@"f7": @"\uF70A",
118+
@"f8": @"\uF70B",
119+
@"f9": @"\uF70C",
120+
@"f10": @"\uF70D",
121+
@"f11": @"\uF70E",
122+
@"f12": @"\uF70F"
123+
};
124+
}
125+
126+
// Check if it's a special key
127+
NSString *mapped = keyMap[keyName];
128+
if (mapped) return mapped;
129+
130+
// For single character keys, return as-is if it's a valid single character
131+
if (keyName.length == 1) return keyName;
132+
133+
// Return empty string for unknown keys
134+
return @"";
135+
}
136+
137+
- (void)applyShortcut:(NSString *)shortcut toItem:(NSMenuItem *)item {
138+
if (shortcut.length == 0) return;
139+
NSArray<NSString *> *parts = [[shortcut lowercaseString] componentsSeparatedByString:@"+"];
140+
if (parts.count == 0) return;
141+
142+
NSString *keyName = [parts lastObject] ?: @"";
143+
NSEventModifierFlags mask = 0;
144+
for (NSString *p in parts) {
145+
if ([p isEqualToString:@"cmd"] || [p isEqualToString:@"command"]) mask |= NSEventModifierFlagCommand;
146+
else if ([p isEqualToString:@"shift"]) mask |= NSEventModifierFlagShift;
147+
else if ([p isEqualToString:@"alt"] || [p isEqualToString:@"option"]) mask |= NSEventModifierFlagOption;
148+
else if ([p isEqualToString:@"ctrl"] || [p isEqualToString:@"control"]) mask |= NSEventModifierFlagControl;
149+
}
150+
151+
// Map key names to actual key equivalents
152+
NSString *keyEquivalent = [self keyEquivalentForKeyName:keyName];
153+
if (keyEquivalent.length > 0) {
154+
item.keyEquivalent = keyEquivalent;
155+
item.keyEquivalentModifierMask = mask;
156+
}
157+
}
158+
159+
- (void)_ir_menuItemPressed:(NSMenuItem *)sender {
160+
NSArray<NSString *> *path = sender.representedObject;
161+
if (![path isKindOfClass:[NSArray class]]) return;
162+
[self emitOnActionMenuItemPressed:@{ @"menuPath": path }];
163+
}
164+
165+
@end
166+
167+
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { EventEmitter } from "react-native/Libraries/Types/CodegenTypes"
2+
import type { TurboModule } from "react-native"
3+
import { TurboModuleRegistry } from "react-native"
4+
5+
// Keep this constant identical to the menu bar manager for familiarity
6+
export const SEPARATOR = "menu-item-separator" as const
7+
8+
export interface ActionMenuItemPressedEvent {
9+
// Path of labels leading to the clicked item, e.g. ["Copy", "As JSON"]
10+
menuPath: string[]
11+
}
12+
13+
// JS description of an action menu item (no functions passed to native)
14+
export interface ActionMenuItem {
15+
label: string
16+
shortcut?: string
17+
enabled?: boolean
18+
checked?: boolean
19+
children?: ActionMenuListEntry[]
20+
}
21+
22+
export type ActionMenuListEntry = ActionMenuItem | typeof SEPARATOR
23+
24+
export interface Spec extends TurboModule {
25+
// Show a native action menu at current mouse location
26+
showActionMenu(items: ActionMenuListEntry[]): void
27+
28+
readonly onActionMenuItemPressed: EventEmitter<ActionMenuItemPressedEvent>
29+
}
30+
31+
export default TurboModuleRegistry.getEnforcing<Spec>("IRActionMenuManager")
32+
33+

0 commit comments

Comments
 (0)