react-native-platform-components 0.5.5 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +295 -83
- package/android/src/main/java/com/platformcomponents/PCContextMenuView.kt +419 -0
- package/android/src/main/java/com/platformcomponents/PCContextMenuViewManager.kt +200 -0
- package/android/src/main/java/com/platformcomponents/PlatformComponentsPackage.kt +1 -0
- package/ios/PCContextMenu.h +12 -0
- package/ios/PCContextMenu.mm +247 -0
- package/ios/PCContextMenu.swift +346 -0
- package/lib/module/ContextMenu.js +111 -0
- package/lib/module/ContextMenu.js.map +1 -0
- package/lib/module/ContextMenuNativeComponent.ts +141 -0
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/ContextMenu.d.ts +79 -0
- package/lib/typescript/src/ContextMenu.d.ts.map +1 -0
- package/lib/typescript/src/ContextMenuNativeComponent.d.ts +122 -0
- package/lib/typescript/src/ContextMenuNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +1 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +4 -2
- package/src/ContextMenu.tsx +209 -0
- package/src/ContextMenuNativeComponent.ts +141 -0
- package/src/index.tsx +1 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// PCContextMenu.mm
|
|
2
|
+
|
|
3
|
+
#import "PCContextMenu.h"
|
|
4
|
+
|
|
5
|
+
#import <React/RCTComponentViewFactory.h>
|
|
6
|
+
#import <React/RCTConversions.h>
|
|
7
|
+
#import <React/RCTFabricComponentsPlugins.h>
|
|
8
|
+
|
|
9
|
+
#import <react/renderer/components/PlatformComponentsViewSpec/ComponentDescriptors.h>
|
|
10
|
+
#import <react/renderer/components/PlatformComponentsViewSpec/EventEmitters.h>
|
|
11
|
+
#import <react/renderer/components/PlatformComponentsViewSpec/Props.h>
|
|
12
|
+
|
|
13
|
+
#if __has_include(<PlatformComponents/PlatformComponents-Swift.h>)
|
|
14
|
+
#import <PlatformComponents/PlatformComponents-Swift.h>
|
|
15
|
+
#else
|
|
16
|
+
#import "PlatformComponents-Swift.h"
|
|
17
|
+
#endif
|
|
18
|
+
|
|
19
|
+
using namespace facebook::react;
|
|
20
|
+
|
|
21
|
+
namespace {
|
|
22
|
+
// Helper to convert subaction struct to NSDictionary
|
|
23
|
+
static NSDictionary *SubactionToDict(const PCContextMenuActionsSubactionsStruct &action) {
|
|
24
|
+
NSMutableDictionary *dict = [NSMutableDictionary new];
|
|
25
|
+
|
|
26
|
+
dict[@"id"] = action.id.empty() ? @"" : [NSString stringWithUTF8String:action.id.c_str()];
|
|
27
|
+
dict[@"title"] = action.title.empty() ? @"" : [NSString stringWithUTF8String:action.title.c_str()];
|
|
28
|
+
|
|
29
|
+
if (!action.subtitle.empty()) {
|
|
30
|
+
dict[@"subtitle"] = [NSString stringWithUTF8String:action.subtitle.c_str()];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!action.image.empty()) {
|
|
34
|
+
dict[@"image"] = [NSString stringWithUTF8String:action.image.c_str()];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!action.imageColor.empty()) {
|
|
38
|
+
dict[@"imageColor"] = [NSString stringWithUTF8String:action.imageColor.c_str()];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check if any attributes field is non-empty
|
|
42
|
+
if (!action.attributes.destructive.empty() ||
|
|
43
|
+
!action.attributes.disabled.empty() ||
|
|
44
|
+
!action.attributes.hidden.empty()) {
|
|
45
|
+
NSMutableDictionary *attrs = [NSMutableDictionary new];
|
|
46
|
+
attrs[@"destructive"] = action.attributes.destructive.empty() ? @"false" :
|
|
47
|
+
[NSString stringWithUTF8String:action.attributes.destructive.c_str()];
|
|
48
|
+
attrs[@"disabled"] = action.attributes.disabled.empty() ? @"false" :
|
|
49
|
+
[NSString stringWithUTF8String:action.attributes.disabled.c_str()];
|
|
50
|
+
attrs[@"hidden"] = action.attributes.hidden.empty() ? @"false" :
|
|
51
|
+
[NSString stringWithUTF8String:action.attributes.hidden.c_str()];
|
|
52
|
+
dict[@"attributes"] = attrs;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!action.state.empty()) {
|
|
56
|
+
dict[@"state"] = [NSString stringWithUTF8String:action.state.c_str()];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return dict;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Helper to convert action struct to NSDictionary
|
|
63
|
+
static NSDictionary *ActionToDict(const PCContextMenuActionsStruct &action) {
|
|
64
|
+
NSMutableDictionary *dict = [NSMutableDictionary new];
|
|
65
|
+
|
|
66
|
+
dict[@"id"] = action.id.empty() ? @"" : [NSString stringWithUTF8String:action.id.c_str()];
|
|
67
|
+
dict[@"title"] = action.title.empty() ? @"" : [NSString stringWithUTF8String:action.title.c_str()];
|
|
68
|
+
|
|
69
|
+
if (!action.subtitle.empty()) {
|
|
70
|
+
dict[@"subtitle"] = [NSString stringWithUTF8String:action.subtitle.c_str()];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!action.image.empty()) {
|
|
74
|
+
dict[@"image"] = [NSString stringWithUTF8String:action.image.c_str()];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!action.imageColor.empty()) {
|
|
78
|
+
dict[@"imageColor"] = [NSString stringWithUTF8String:action.imageColor.c_str()];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Check if any attributes field is non-empty
|
|
82
|
+
if (!action.attributes.destructive.empty() ||
|
|
83
|
+
!action.attributes.disabled.empty() ||
|
|
84
|
+
!action.attributes.hidden.empty()) {
|
|
85
|
+
NSMutableDictionary *attrs = [NSMutableDictionary new];
|
|
86
|
+
attrs[@"destructive"] = action.attributes.destructive.empty() ? @"false" :
|
|
87
|
+
[NSString stringWithUTF8String:action.attributes.destructive.c_str()];
|
|
88
|
+
attrs[@"disabled"] = action.attributes.disabled.empty() ? @"false" :
|
|
89
|
+
[NSString stringWithUTF8String:action.attributes.disabled.c_str()];
|
|
90
|
+
attrs[@"hidden"] = action.attributes.hidden.empty() ? @"false" :
|
|
91
|
+
[NSString stringWithUTF8String:action.attributes.hidden.c_str()];
|
|
92
|
+
dict[@"attributes"] = attrs;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!action.state.empty()) {
|
|
96
|
+
dict[@"state"] = [NSString stringWithUTF8String:action.state.c_str()];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Convert subactions (vector, not optional)
|
|
100
|
+
if (!action.subactions.empty()) {
|
|
101
|
+
NSMutableArray *subs = [NSMutableArray new];
|
|
102
|
+
for (const auto &sub : action.subactions) {
|
|
103
|
+
[subs addObject:SubactionToDict(sub)];
|
|
104
|
+
}
|
|
105
|
+
dict[@"subactions"] = subs;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return dict;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
static bool ActionsEqual(
|
|
112
|
+
const std::vector<PCContextMenuActionsStruct> &a,
|
|
113
|
+
const std::vector<PCContextMenuActionsStruct> &b) {
|
|
114
|
+
if (a.size() != b.size()) return false;
|
|
115
|
+
for (size_t i = 0; i < a.size(); i++) {
|
|
116
|
+
if (a[i].id != b[i].id) return false;
|
|
117
|
+
if (a[i].title != b[i].title) return false;
|
|
118
|
+
// Simplified comparison - in production, compare all fields
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
} // namespace
|
|
123
|
+
|
|
124
|
+
@implementation PCContextMenu {
|
|
125
|
+
PCContextMenuView *_view;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
+ (ComponentDescriptorProvider)componentDescriptorProvider {
|
|
129
|
+
return concreteComponentDescriptorProvider<PCContextMenuComponentDescriptor>();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
- (instancetype)initWithFrame:(CGRect)frame {
|
|
133
|
+
if (self = [super initWithFrame:frame]) {
|
|
134
|
+
_view = [PCContextMenuView new];
|
|
135
|
+
self.contentView = _view;
|
|
136
|
+
|
|
137
|
+
__weak __typeof(self) weakSelf = self;
|
|
138
|
+
|
|
139
|
+
_view.onPressAction = ^(NSString *actionId, NSString *actionTitle) {
|
|
140
|
+
__typeof(self) strongSelf = weakSelf;
|
|
141
|
+
if (!strongSelf) return;
|
|
142
|
+
|
|
143
|
+
auto eventEmitter =
|
|
144
|
+
std::static_pointer_cast<const PCContextMenuEventEmitter>(
|
|
145
|
+
strongSelf->_eventEmitter);
|
|
146
|
+
if (!eventEmitter) return;
|
|
147
|
+
|
|
148
|
+
PCContextMenuEventEmitter::OnPressAction payload = {
|
|
149
|
+
.actionId = actionId.UTF8String,
|
|
150
|
+
.actionTitle = actionTitle.UTF8String,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
eventEmitter->onPressAction(payload);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
_view.onMenuOpen = ^{
|
|
157
|
+
__typeof(self) strongSelf = weakSelf;
|
|
158
|
+
if (!strongSelf) return;
|
|
159
|
+
|
|
160
|
+
auto eventEmitter =
|
|
161
|
+
std::static_pointer_cast<const PCContextMenuEventEmitter>(
|
|
162
|
+
strongSelf->_eventEmitter);
|
|
163
|
+
if (!eventEmitter) return;
|
|
164
|
+
|
|
165
|
+
eventEmitter->onMenuOpen({});
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
_view.onMenuClose = ^{
|
|
169
|
+
__typeof(self) strongSelf = weakSelf;
|
|
170
|
+
if (!strongSelf) return;
|
|
171
|
+
|
|
172
|
+
auto eventEmitter =
|
|
173
|
+
std::static_pointer_cast<const PCContextMenuEventEmitter>(
|
|
174
|
+
strongSelf->_eventEmitter);
|
|
175
|
+
if (!eventEmitter) return;
|
|
176
|
+
|
|
177
|
+
eventEmitter->onMenuClose({});
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return self;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
- (void)updateProps:(Props::Shared const &)props
|
|
184
|
+
oldProps:(Props::Shared const &)oldProps {
|
|
185
|
+
const auto &newProps =
|
|
186
|
+
*std::static_pointer_cast<const PCContextMenuProps>(props);
|
|
187
|
+
const auto prevProps =
|
|
188
|
+
std::static_pointer_cast<const PCContextMenuProps>(oldProps);
|
|
189
|
+
|
|
190
|
+
// title
|
|
191
|
+
if (!prevProps || newProps.title != prevProps->title) {
|
|
192
|
+
if (!newProps.title.empty()) {
|
|
193
|
+
_view.menuTitle = [NSString stringWithUTF8String:newProps.title.c_str()];
|
|
194
|
+
} else {
|
|
195
|
+
_view.menuTitle = nil;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// actions
|
|
200
|
+
if (!prevProps || !ActionsEqual(newProps.actions, prevProps->actions)) {
|
|
201
|
+
NSMutableArray *arr = [NSMutableArray new];
|
|
202
|
+
for (const auto &action : newProps.actions) {
|
|
203
|
+
[arr addObject:ActionToDict(action)];
|
|
204
|
+
}
|
|
205
|
+
_view.actions = arr;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// interactivity: "enabled" | "disabled"
|
|
209
|
+
if (!prevProps || newProps.interactivity != prevProps->interactivity) {
|
|
210
|
+
if (!newProps.interactivity.empty()) {
|
|
211
|
+
_view.interactivity =
|
|
212
|
+
[NSString stringWithUTF8String:newProps.interactivity.c_str()];
|
|
213
|
+
} else {
|
|
214
|
+
_view.interactivity = @"enabled";
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// trigger: "longPress" | "tap"
|
|
219
|
+
if (!prevProps || newProps.trigger != prevProps->trigger) {
|
|
220
|
+
if (!newProps.trigger.empty()) {
|
|
221
|
+
_view.trigger =
|
|
222
|
+
[NSString stringWithUTF8String:newProps.trigger.c_str()];
|
|
223
|
+
} else {
|
|
224
|
+
_view.trigger = @"longPress";
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// iOS-specific props
|
|
229
|
+
const auto &newIOS = newProps.ios;
|
|
230
|
+
const auto &oldIOS = prevProps ? prevProps->ios : PCContextMenuIosStruct{};
|
|
231
|
+
if (!prevProps || newIOS.enablePreview != oldIOS.enablePreview) {
|
|
232
|
+
if (!newIOS.enablePreview.empty()) {
|
|
233
|
+
_view.enablePreview =
|
|
234
|
+
[NSString stringWithUTF8String:newIOS.enablePreview.c_str()];
|
|
235
|
+
} else {
|
|
236
|
+
_view.enablePreview = @"false";
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
[super updateProps:props oldProps:oldProps];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
@end
|
|
244
|
+
|
|
245
|
+
Class<RCTComponentViewProtocol> PCContextMenuCls(void) {
|
|
246
|
+
return PCContextMenu.class;
|
|
247
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import os.log
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
private let logger = Logger(subsystem: "com.platformcomponents", category: "ContextMenu")
|
|
5
|
+
|
|
6
|
+
// MARK: - Action model (bridged from ObjC++ as dictionaries)
|
|
7
|
+
|
|
8
|
+
struct PCContextMenuAction {
|
|
9
|
+
let id: String
|
|
10
|
+
let title: String
|
|
11
|
+
let subtitle: String?
|
|
12
|
+
let image: String?
|
|
13
|
+
let imageColor: String?
|
|
14
|
+
let destructive: Bool
|
|
15
|
+
let disabled: Bool
|
|
16
|
+
let hidden: Bool
|
|
17
|
+
let state: String? // "off" | "on" | "mixed"
|
|
18
|
+
let subactions: [PCContextMenuAction]
|
|
19
|
+
|
|
20
|
+
init(from dict: [String: Any]) {
|
|
21
|
+
self.id = (dict["id"] as? String) ?? ""
|
|
22
|
+
self.title = (dict["title"] as? String) ?? ""
|
|
23
|
+
self.subtitle = dict["subtitle"] as? String
|
|
24
|
+
self.image = dict["image"] as? String
|
|
25
|
+
self.imageColor = dict["imageColor"] as? String
|
|
26
|
+
|
|
27
|
+
// Parse attributes
|
|
28
|
+
if let attrs = dict["attributes"] as? [String: Any] {
|
|
29
|
+
self.destructive = (attrs["destructive"] as? String) == "true"
|
|
30
|
+
self.disabled = (attrs["disabled"] as? String) == "true"
|
|
31
|
+
self.hidden = (attrs["hidden"] as? String) == "true"
|
|
32
|
+
} else {
|
|
33
|
+
self.destructive = false
|
|
34
|
+
self.disabled = false
|
|
35
|
+
self.hidden = false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
self.state = dict["state"] as? String
|
|
39
|
+
|
|
40
|
+
// Parse subactions recursively
|
|
41
|
+
if let subs = dict["subactions"] as? [[String: Any]] {
|
|
42
|
+
self.subactions = subs.map { PCContextMenuAction(from: $0) }
|
|
43
|
+
} else {
|
|
44
|
+
self.subactions = []
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// MARK: - Main View
|
|
50
|
+
|
|
51
|
+
@objcMembers
|
|
52
|
+
public final class PCContextMenuView: UIView, UIContextMenuInteractionDelegate {
|
|
53
|
+
// MARK: - Props (set from ObjC++)
|
|
54
|
+
|
|
55
|
+
/// Menu title (shown as header on iOS)
|
|
56
|
+
public var menuTitle: String? { didSet { sync() } }
|
|
57
|
+
|
|
58
|
+
/// ObjC++ sets this as an array of dictionaries
|
|
59
|
+
public var actions: [Any] = [] { didSet { sync() } }
|
|
60
|
+
|
|
61
|
+
/// "enabled" | "disabled"
|
|
62
|
+
public var interactivity: String = "enabled" {
|
|
63
|
+
didSet {
|
|
64
|
+
updateEnabled()
|
|
65
|
+
sync()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// "longPress" | "tap"
|
|
70
|
+
public var trigger: String = "longPress" { didSet { updateTrigger() } }
|
|
71
|
+
|
|
72
|
+
/// iOS-specific: enable preview
|
|
73
|
+
public var enablePreview: String = "false"
|
|
74
|
+
|
|
75
|
+
// MARK: - Events back to ObjC++
|
|
76
|
+
|
|
77
|
+
public var onPressAction: ((String, String) -> Void)? // (id, title)
|
|
78
|
+
public var onMenuOpen: (() -> Void)?
|
|
79
|
+
public var onMenuClose: (() -> Void)?
|
|
80
|
+
|
|
81
|
+
// MARK: - Internal
|
|
82
|
+
|
|
83
|
+
private var contextMenuInteraction: UIContextMenuInteraction?
|
|
84
|
+
private var parsedActions: [PCContextMenuAction] {
|
|
85
|
+
(actions as? [[String: Any]])?.map { PCContextMenuAction(from: $0) } ?? []
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Tap mode: UIButton with UIMenu for tap-to-show
|
|
89
|
+
private var tapMenuButton: UIButton?
|
|
90
|
+
|
|
91
|
+
// MARK: - Init
|
|
92
|
+
|
|
93
|
+
public override init(frame: CGRect) {
|
|
94
|
+
super.init(frame: frame)
|
|
95
|
+
setup()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public required init?(coder: NSCoder) {
|
|
99
|
+
super.init(coder: coder)
|
|
100
|
+
setup()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private func setup() {
|
|
104
|
+
backgroundColor = .clear
|
|
105
|
+
updateEnabled()
|
|
106
|
+
updateTrigger()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
public override func layoutSubviews() {
|
|
110
|
+
super.layoutSubviews()
|
|
111
|
+
// Ensure tap button stays on top of React Native children
|
|
112
|
+
if let button = tapMenuButton {
|
|
113
|
+
bringSubviewToFront(button)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private func updateEnabled() {
|
|
118
|
+
let disabled = (interactivity == "disabled")
|
|
119
|
+
alpha = disabled ? 0.5 : 1.0
|
|
120
|
+
isUserInteractionEnabled = !disabled
|
|
121
|
+
accessibilityTraits = disabled ? [.notEnabled] : []
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private func sync() {
|
|
125
|
+
// Re-setup trigger mode if needed
|
|
126
|
+
updateTrigger()
|
|
127
|
+
// Update tap menu content if in tap mode
|
|
128
|
+
if trigger == "tap" {
|
|
129
|
+
updateTapMenuButton()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// MARK: - Trigger Mode
|
|
134
|
+
|
|
135
|
+
private func updateTrigger() {
|
|
136
|
+
if trigger == "longPress" {
|
|
137
|
+
// Install context menu interaction for long-press
|
|
138
|
+
installContextMenuInteraction()
|
|
139
|
+
removeTapMenuButton()
|
|
140
|
+
} else {
|
|
141
|
+
// Tap mode: use UIButton with UIMenu for tap-to-show
|
|
142
|
+
removeContextMenuInteraction()
|
|
143
|
+
installTapMenuButton()
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private func installContextMenuInteraction() {
|
|
148
|
+
guard contextMenuInteraction == nil else { return }
|
|
149
|
+
let interaction = UIContextMenuInteraction(delegate: self)
|
|
150
|
+
addInteraction(interaction)
|
|
151
|
+
contextMenuInteraction = interaction
|
|
152
|
+
logger.debug("Installed UIContextMenuInteraction")
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private func removeContextMenuInteraction() {
|
|
156
|
+
guard let interaction = contextMenuInteraction else { return }
|
|
157
|
+
removeInteraction(interaction)
|
|
158
|
+
contextMenuInteraction = nil
|
|
159
|
+
logger.debug("Removed UIContextMenuInteraction")
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// MARK: - Tap Mode Button
|
|
163
|
+
|
|
164
|
+
private func installTapMenuButton() {
|
|
165
|
+
guard tapMenuButton == nil else {
|
|
166
|
+
updateTapMenuButton()
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let button = UIButton(type: .custom)
|
|
171
|
+
button.backgroundColor = .clear
|
|
172
|
+
button.showsMenuAsPrimaryAction = true
|
|
173
|
+
button.translatesAutoresizingMaskIntoConstraints = false
|
|
174
|
+
|
|
175
|
+
// Add context menu interaction to track menu lifecycle
|
|
176
|
+
let interaction = UIContextMenuInteraction(delegate: self)
|
|
177
|
+
button.addInteraction(interaction)
|
|
178
|
+
|
|
179
|
+
addSubview(button)
|
|
180
|
+
NSLayoutConstraint.activate([
|
|
181
|
+
button.topAnchor.constraint(equalTo: topAnchor),
|
|
182
|
+
button.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
183
|
+
button.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
184
|
+
button.trailingAnchor.constraint(equalTo: trailingAnchor)
|
|
185
|
+
])
|
|
186
|
+
|
|
187
|
+
tapMenuButton = button
|
|
188
|
+
updateTapMenuButton()
|
|
189
|
+
logger.debug("Installed tap menu button")
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private func removeTapMenuButton() {
|
|
193
|
+
tapMenuButton?.removeFromSuperview()
|
|
194
|
+
tapMenuButton = nil
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private func updateTapMenuButton() {
|
|
198
|
+
guard let button = tapMenuButton else { return }
|
|
199
|
+
|
|
200
|
+
let actions = parsedActions.filter { !$0.hidden }
|
|
201
|
+
guard !actions.isEmpty else {
|
|
202
|
+
button.menu = nil
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let menu = buildMenu(from: actions, title: menuTitle)
|
|
207
|
+
button.menu = menu
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// MARK: - UIContextMenuInteractionDelegate
|
|
211
|
+
|
|
212
|
+
public func contextMenuInteraction(
|
|
213
|
+
_ interaction: UIContextMenuInteraction,
|
|
214
|
+
configurationForMenuAtLocation location: CGPoint
|
|
215
|
+
) -> UIContextMenuConfiguration? {
|
|
216
|
+
guard interactivity != "disabled" else { return nil }
|
|
217
|
+
|
|
218
|
+
let actions = parsedActions.filter { !$0.hidden }
|
|
219
|
+
guard !actions.isEmpty else { return nil }
|
|
220
|
+
|
|
221
|
+
logger.debug("contextMenuInteraction: creating configuration with \(actions.count) actions")
|
|
222
|
+
|
|
223
|
+
return UIContextMenuConfiguration(
|
|
224
|
+
identifier: nil,
|
|
225
|
+
previewProvider: enablePreview == "true" ? { [weak self] in
|
|
226
|
+
// Return a preview controller showing the content
|
|
227
|
+
guard let self else { return nil }
|
|
228
|
+
let preview = UIViewController()
|
|
229
|
+
preview.view.backgroundColor = .clear
|
|
230
|
+
|
|
231
|
+
// Snapshot the current view for preview
|
|
232
|
+
let snapshot = self.snapshotView(afterScreenUpdates: false)
|
|
233
|
+
if let snap = snapshot {
|
|
234
|
+
snap.frame = CGRect(origin: .zero, size: self.bounds.size)
|
|
235
|
+
preview.view.addSubview(snap)
|
|
236
|
+
preview.preferredContentSize = self.bounds.size
|
|
237
|
+
}
|
|
238
|
+
return preview
|
|
239
|
+
} : nil,
|
|
240
|
+
actionProvider: { [weak self] suggestedActions in
|
|
241
|
+
guard let self else { return nil }
|
|
242
|
+
return self.buildMenu(from: actions, title: self.menuTitle)
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
public func contextMenuInteraction(
|
|
248
|
+
_ interaction: UIContextMenuInteraction,
|
|
249
|
+
willDisplayMenuFor configuration: UIContextMenuConfiguration,
|
|
250
|
+
animator: UIContextMenuInteractionAnimating?
|
|
251
|
+
) {
|
|
252
|
+
logger.debug("contextMenuInteraction: willDisplayMenu")
|
|
253
|
+
onMenuOpen?()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
public func contextMenuInteraction(
|
|
257
|
+
_ interaction: UIContextMenuInteraction,
|
|
258
|
+
willEndFor configuration: UIContextMenuConfiguration,
|
|
259
|
+
animator: UIContextMenuInteractionAnimating?
|
|
260
|
+
) {
|
|
261
|
+
logger.debug("contextMenuInteraction: willEnd")
|
|
262
|
+
onMenuClose?()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// MARK: - Menu Building
|
|
266
|
+
|
|
267
|
+
private func buildMenu(from actions: [PCContextMenuAction], title: String?) -> UIMenu {
|
|
268
|
+
let menuElements = actions.compactMap { buildMenuElement(from: $0) }
|
|
269
|
+
|
|
270
|
+
return UIMenu(
|
|
271
|
+
title: title ?? "",
|
|
272
|
+
children: menuElements
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private func buildMenuElement(from action: PCContextMenuAction) -> UIMenuElement? {
|
|
277
|
+
guard !action.hidden else { return nil }
|
|
278
|
+
|
|
279
|
+
// If has subactions, create a submenu
|
|
280
|
+
if !action.subactions.isEmpty {
|
|
281
|
+
let children = action.subactions.compactMap { buildMenuElement(from: $0) }
|
|
282
|
+
return UIMenu(
|
|
283
|
+
title: action.title,
|
|
284
|
+
image: imageForAction(action),
|
|
285
|
+
children: children
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Build action attributes
|
|
290
|
+
var attributes: UIMenuElement.Attributes = []
|
|
291
|
+
if action.destructive { attributes.insert(.destructive) }
|
|
292
|
+
if action.disabled { attributes.insert(.disabled) }
|
|
293
|
+
|
|
294
|
+
// Build state
|
|
295
|
+
let state: UIMenuElement.State
|
|
296
|
+
switch action.state {
|
|
297
|
+
case "on": state = .on
|
|
298
|
+
case "mixed": state = .mixed
|
|
299
|
+
default: state = .off
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let uiAction = UIAction(
|
|
303
|
+
title: action.title,
|
|
304
|
+
subtitle: action.subtitle,
|
|
305
|
+
image: imageForAction(action),
|
|
306
|
+
attributes: attributes,
|
|
307
|
+
state: state
|
|
308
|
+
) { [weak self] _ in
|
|
309
|
+
logger.debug("UIAction selected: id=\(action.id), title=\(action.title)")
|
|
310
|
+
self?.onPressAction?(action.id, action.title)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return uiAction
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private func imageForAction(_ action: PCContextMenuAction) -> UIImage? {
|
|
317
|
+
guard let imageName = action.image, !imageName.isEmpty else { return nil }
|
|
318
|
+
|
|
319
|
+
var image = UIImage(systemName: imageName)
|
|
320
|
+
|
|
321
|
+
// Apply tint color if specified
|
|
322
|
+
if let colorStr = action.imageColor, !colorStr.isEmpty, let color = colorFromString(colorStr) {
|
|
323
|
+
image = image?.withTintColor(color, renderingMode: .alwaysOriginal)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return image
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private func colorFromString(_ str: String) -> UIColor? {
|
|
330
|
+
// Support hex colors like "#FF0000" or "FF0000"
|
|
331
|
+
var hex = str.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
332
|
+
if hex.hasPrefix("#") {
|
|
333
|
+
hex = String(hex.dropFirst())
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
guard hex.count == 6, let rgbValue = UInt64(hex, radix: 16) else {
|
|
337
|
+
return nil
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let r = CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0
|
|
341
|
+
let g = CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0
|
|
342
|
+
let b = CGFloat(rgbValue & 0x0000FF) / 255.0
|
|
343
|
+
|
|
344
|
+
return UIColor(red: r, green: g, blue: b, alpha: 1.0)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// ContextMenu.tsx
|
|
4
|
+
import React, { useCallback, useMemo } from 'react';
|
|
5
|
+
import NativeContextMenu from './ContextMenuNativeComponent';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Attributes for a context menu action.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A single action in the context menu.
|
|
13
|
+
*/
|
|
14
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
15
|
+
/**
|
|
16
|
+
* Convert user-friendly subaction to native format (no further nesting).
|
|
17
|
+
*/
|
|
18
|
+
function normalizeSubaction(action) {
|
|
19
|
+
return {
|
|
20
|
+
id: action.id,
|
|
21
|
+
title: action.title,
|
|
22
|
+
subtitle: action.subtitle,
|
|
23
|
+
image: action.image,
|
|
24
|
+
imageColor: action.imageColor,
|
|
25
|
+
attributes: action.attributes ? {
|
|
26
|
+
destructive: action.attributes.destructive ? 'true' : 'false',
|
|
27
|
+
disabled: action.attributes.disabled ? 'true' : 'false',
|
|
28
|
+
hidden: action.attributes.hidden ? 'true' : 'false'
|
|
29
|
+
} : undefined,
|
|
30
|
+
state: action.state
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Convert user-friendly action to native format.
|
|
36
|
+
* Note: Only one level of nesting is supported by the native component.
|
|
37
|
+
*/
|
|
38
|
+
function normalizeAction(action) {
|
|
39
|
+
return {
|
|
40
|
+
id: action.id,
|
|
41
|
+
title: action.title,
|
|
42
|
+
subtitle: action.subtitle,
|
|
43
|
+
image: action.image,
|
|
44
|
+
imageColor: action.imageColor,
|
|
45
|
+
attributes: action.attributes ? {
|
|
46
|
+
destructive: action.attributes.destructive ? 'true' : 'false',
|
|
47
|
+
disabled: action.attributes.disabled ? 'true' : 'false',
|
|
48
|
+
hidden: action.attributes.hidden ? 'true' : 'false'
|
|
49
|
+
} : undefined,
|
|
50
|
+
state: action.state,
|
|
51
|
+
subactions: action.subactions?.map(normalizeSubaction)
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function ContextMenu(props) {
|
|
55
|
+
const {
|
|
56
|
+
style,
|
|
57
|
+
title,
|
|
58
|
+
actions,
|
|
59
|
+
disabled,
|
|
60
|
+
trigger = 'longPress',
|
|
61
|
+
onPressAction,
|
|
62
|
+
onMenuOpen,
|
|
63
|
+
onMenuClose,
|
|
64
|
+
children,
|
|
65
|
+
ios,
|
|
66
|
+
android,
|
|
67
|
+
...viewProps
|
|
68
|
+
} = props;
|
|
69
|
+
const nativeActions = useMemo(() => actions.map(normalizeAction), [actions]);
|
|
70
|
+
const handlePressAction = useCallback(e => {
|
|
71
|
+
const {
|
|
72
|
+
actionId,
|
|
73
|
+
actionTitle
|
|
74
|
+
} = e.nativeEvent;
|
|
75
|
+
onPressAction?.(actionId, actionTitle);
|
|
76
|
+
}, [onPressAction]);
|
|
77
|
+
const handleMenuOpen = useCallback(() => {
|
|
78
|
+
onMenuOpen?.();
|
|
79
|
+
}, [onMenuOpen]);
|
|
80
|
+
const handleMenuClose = useCallback(() => {
|
|
81
|
+
onMenuClose?.();
|
|
82
|
+
}, [onMenuClose]);
|
|
83
|
+
const nativeIOS = useMemo(() => {
|
|
84
|
+
if (!ios) return undefined;
|
|
85
|
+
return {
|
|
86
|
+
enablePreview: ios.enablePreview ? 'true' : 'false'
|
|
87
|
+
};
|
|
88
|
+
}, [ios]);
|
|
89
|
+
const nativeAndroid = useMemo(() => {
|
|
90
|
+
if (!android) return undefined;
|
|
91
|
+
return {
|
|
92
|
+
anchorPosition: android.anchorPosition,
|
|
93
|
+
visible: android.visible ? 'open' : 'closed'
|
|
94
|
+
};
|
|
95
|
+
}, [android]);
|
|
96
|
+
return /*#__PURE__*/_jsx(NativeContextMenu, {
|
|
97
|
+
style: style,
|
|
98
|
+
title: title,
|
|
99
|
+
actions: nativeActions,
|
|
100
|
+
interactivity: disabled ? 'disabled' : 'enabled',
|
|
101
|
+
trigger: trigger,
|
|
102
|
+
onPressAction: onPressAction ? handlePressAction : undefined,
|
|
103
|
+
onMenuOpen: onMenuOpen ? handleMenuOpen : undefined,
|
|
104
|
+
onMenuClose: onMenuClose ? handleMenuClose : undefined,
|
|
105
|
+
ios: nativeIOS,
|
|
106
|
+
android: nativeAndroid,
|
|
107
|
+
...viewProps,
|
|
108
|
+
children: children
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=ContextMenu.js.map
|