@thiagobueno/rn-selectable-text 1.0.6 → 1.0.7
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 +141 -53
- package/ios/SelectableTextView.mm +60 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,29 +39,117 @@ import { SelectableTextView } from '@thiagobueno/rn-selectable-text';
|
|
|
39
39
|
|
|
40
40
|
export default function App() {
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
const handleSelection = (event) => {
|
|
43
|
+
const { chosenOption, highlightedText } = event;
|
|
44
|
+
Alert.alert(
|
|
45
|
+
'Selection Event',
|
|
46
|
+
`Option: ${chosenOption}\nSelected Text: ${highlightedText}`
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<View style={{ flex: 1, justifyContent: 'center', padding: 20 }}>
|
|
52
|
+
<SelectableTextView
|
|
53
|
+
menuOptions={['Save', 'Share', 'Copy']}
|
|
54
|
+
onSelection={handleSelection}
|
|
55
|
+
>
|
|
56
|
+
<Text style={{ fontSize: 18, color: '#333' }}>
|
|
57
|
+
Highlight this text to see the custom native menu!
|
|
58
|
+
</Text>
|
|
59
|
+
</SelectableTextView>
|
|
60
|
+
</View>
|
|
47
61
|
);
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
return (
|
|
51
|
-
<View style={{ flex: 1, justifyContent: 'center', padding: 20 }}>
|
|
52
|
-
<SelectableTextView
|
|
53
|
-
menuOptions={['Save', 'Share', 'Copy']}
|
|
54
|
-
onSelection={handleSelection}
|
|
55
|
-
>
|
|
56
|
-
<Text style={{ fontSize: 18, color: '#333' }}>
|
|
57
|
-
Highlight this text to see the custom native menu!
|
|
58
|
-
</Text>
|
|
59
|
-
</SelectableTextView>
|
|
60
|
-
</View>
|
|
61
|
-
);
|
|
62
62
|
}
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
+
### Invisible Custom Mode (ActionSheet & Bottom Sheet Integration)
|
|
66
|
+
|
|
67
|
+
If you want to use modern UIs like Apple's native `ActionSheetIOS` or a custom React Native Bottom Sheet, you can bypass the default horizontal bubble menu entirely.
|
|
68
|
+
|
|
69
|
+
By passing an **empty array (`[]`)** to `menuOptions`, the library enters **Invisible Mode**. It completely suppresses the native iOS/Android bubble menus while still tracking text selection in real-time and emitting the `CUSTOM_MODE` event.
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
import React, { useState } from 'react';
|
|
73
|
+
import { View, Text, Platform, ActionSheetIOS, Modal, Button, StyleSheet } from 'react-native';
|
|
74
|
+
import { SelectableTextView } from '@thiagobueno/rn-selectable-text';
|
|
75
|
+
|
|
76
|
+
export default function InvisibleModeApp() {
|
|
77
|
+
const [androidMenuVisible, setAndroidMenuVisible] = useState(false);
|
|
78
|
+
const [selectedText, setSelectedText] = useState('');
|
|
79
|
+
|
|
80
|
+
const handleSelection = ({ chosenOption, highlightedText }) => {
|
|
81
|
+
// 1. Hide menus if the user taps away and clears the selection
|
|
82
|
+
if (highlightedText.length === 0) {
|
|
83
|
+
setAndroidMenuVisible(false);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 2. Invisible Mode triggers the "CUSTOM_MODE" option
|
|
88
|
+
if (chosenOption === 'CUSTOM_MODE') {
|
|
89
|
+
setSelectedText(highlightedText);
|
|
90
|
+
|
|
91
|
+
if (Platform.OS === 'ios') {
|
|
92
|
+
// Open Apple's Native Bottom Sheet
|
|
93
|
+
ActionSheetIOS.showActionSheetWithOptions(
|
|
94
|
+
{
|
|
95
|
+
title: 'Text Actions',
|
|
96
|
+
options: ['Cancel', 'Share to WhatsApp', 'Save'],
|
|
97
|
+
cancelButtonIndex: 0,
|
|
98
|
+
},
|
|
99
|
+
(buttonIndex) => {
|
|
100
|
+
if (buttonIndex === 1) console.log('WhatsApp:', highlightedText);
|
|
101
|
+
if (buttonIndex === 2) console.log('Save:', highlightedText);
|
|
102
|
+
}
|
|
103
|
+
);
|
|
104
|
+
} else {
|
|
105
|
+
// Open Custom React Native Bottom Sheet for Android
|
|
106
|
+
setAndroidMenuVisible(true);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<View style={{ flex: 1, padding: 20, justifyContent: 'center' }}>
|
|
113
|
+
<SelectableTextView
|
|
114
|
+
menuOptions={[]} // The empty array is the magic key for Invisible Mode
|
|
115
|
+
onSelection={handleSelection}
|
|
116
|
+
>
|
|
117
|
+
<Text style={{ fontSize: 18, color: '#333' }}>
|
|
118
|
+
Select this text. The native bubble menu is suppressed, allowing you
|
|
119
|
+
to render a beautiful Action Sheet or custom Modal instead!
|
|
120
|
+
</Text>
|
|
121
|
+
</SelectableTextView>
|
|
122
|
+
|
|
123
|
+
{Platform.OS === 'android' && (
|
|
124
|
+
<Modal visible={androidMenuVisible} transparent animationType="slide">
|
|
125
|
+
<View style={styles.modalOverlay}>
|
|
126
|
+
<View style={styles.bottomSheet}>
|
|
127
|
+
<Text style={{ marginBottom: 15 }}>Selected: {selectedText}</Text>
|
|
128
|
+
<Button title="Save" onPress={() => setAndroidMenuVisible(false)} />
|
|
129
|
+
<Button title="Close" color="red" onPress={() => setAndroidMenuVisible(false)} />
|
|
130
|
+
</View>
|
|
131
|
+
</View>
|
|
132
|
+
</Modal>
|
|
133
|
+
)}
|
|
134
|
+
</View>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const styles = StyleSheet.create({
|
|
139
|
+
modalOverlay: {
|
|
140
|
+
flex: 1,
|
|
141
|
+
justifyContent: 'flex-end',
|
|
142
|
+
backgroundColor: 'rgba(0,0,0,0.5)'
|
|
143
|
+
},
|
|
144
|
+
bottomSheet: {
|
|
145
|
+
backgroundColor: 'white',
|
|
146
|
+
padding: 20,
|
|
147
|
+
borderTopLeftRadius: 20,
|
|
148
|
+
borderTopRightRadius: 20
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
65
153
|
### Advanced Example (Nested Text & Index Mapping)
|
|
66
154
|
|
|
67
155
|
When dealing with internationalization (i18n) or dynamic menus, it's highly recommended to map your selections by index rather than relying on the translated string.
|
|
@@ -75,38 +163,38 @@ const MENU_OPTIONS = ['Save Note', 'Edit Text', 'Highlight Content'];
|
|
|
75
163
|
|
|
76
164
|
export default function AdvancedApp() {
|
|
77
165
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
166
|
+
const handleSelection = ({ chosenOption, highlightedText }) => {
|
|
167
|
+
const actionIndex = MENU_OPTIONS.indexOf(chosenOption);
|
|
168
|
+
|
|
169
|
+
switch (actionIndex) {
|
|
170
|
+
case 0:
|
|
171
|
+
console.log('Action: Save Note - Text:', highlightedText);
|
|
172
|
+
break;
|
|
173
|
+
case 1:
|
|
174
|
+
console.log('Action: Edit Text - Text:', highlightedText);
|
|
175
|
+
break;
|
|
176
|
+
case 2:
|
|
177
|
+
console.log('Action: Highlight Content - Text:', highlightedText);
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<View style={{ padding: 20 }}>
|
|
184
|
+
<SelectableTextView
|
|
185
|
+
menuOptions={MENU_OPTIONS}
|
|
186
|
+
onSelection={handleSelection}
|
|
187
|
+
>
|
|
188
|
+
<Text style={{ color: 'black', fontSize: 16 }}>
|
|
189
|
+
This text is black, but{' '}
|
|
190
|
+
<Text style={{ fontWeight: 'bold', color: 'blue' }}>
|
|
191
|
+
this part is bold and blue
|
|
192
|
+
</Text>{' '}
|
|
193
|
+
and this is black again. The entire block is selectable!
|
|
194
|
+
</Text>
|
|
195
|
+
</SelectableTextView>
|
|
196
|
+
</View>
|
|
197
|
+
);
|
|
110
198
|
}
|
|
111
199
|
```
|
|
112
200
|
|
|
@@ -127,8 +215,8 @@ The `onSelection` callback receives an event object with:
|
|
|
127
215
|
|
|
128
216
|
```typescript
|
|
129
217
|
interface SelectionEvent {
|
|
130
|
-
|
|
131
|
-
|
|
218
|
+
chosenOption: string; // The exact string of the menu option selected
|
|
219
|
+
highlightedText: string; // The specific text highlighted by the user
|
|
132
220
|
}
|
|
133
221
|
```
|
|
134
222
|
|
|
@@ -12,6 +12,15 @@ using namespace facebook::react;
|
|
|
12
12
|
|
|
13
13
|
@class SelectableTextView;
|
|
14
14
|
|
|
15
|
+
// ====================================================================
|
|
16
|
+
// CRASH PREVENTION: Safe C++ String Conversion
|
|
17
|
+
// ====================================================================
|
|
18
|
+
static std::string safeStdString(NSString *str) {
|
|
19
|
+
if (!str) return "";
|
|
20
|
+
const char *utf8 = [str UTF8String];
|
|
21
|
+
return utf8 ? std::string(utf8) : "";
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
@interface SelectableUITextView : UITextView
|
|
16
25
|
@property (nonatomic, weak) SelectableTextView *parentSelectableTextView;
|
|
17
26
|
@end
|
|
@@ -29,8 +38,6 @@ using namespace facebook::react;
|
|
|
29
38
|
|
|
30
39
|
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender
|
|
31
40
|
{
|
|
32
|
-
// FIX: Remove hardcoded YES. We delegate entirely to the parent logic
|
|
33
|
-
// to allow complete suppression of the menu in Custom Mode.
|
|
34
41
|
if (self.parentSelectableTextView) {
|
|
35
42
|
return [self.parentSelectableTextView canPerformAction:action withSender:sender];
|
|
36
43
|
}
|
|
@@ -232,18 +239,24 @@ using namespace facebook::react;
|
|
|
232
239
|
#pragma mark - UITextViewDelegate
|
|
233
240
|
|
|
234
241
|
// ====================================================================
|
|
235
|
-
// REAL-TIME CUSTOM MODE TRACKING
|
|
242
|
+
// REAL-TIME CUSTOM MODE TRACKING (With Crash Prevention bounds check)
|
|
236
243
|
// ====================================================================
|
|
237
244
|
- (void)textViewDidChangeSelection:(UITextView *)textView
|
|
238
245
|
{
|
|
239
|
-
if (textView.selectedRange.length > 0) {
|
|
246
|
+
if (textView.selectedRange.location != NSNotFound && textView.selectedRange.length > 0) {
|
|
247
|
+
|
|
248
|
+
// SAFE BOUNDS CHECK: Prevents iOS 15/17/18 out-of-bounds crash
|
|
249
|
+
NSString *selectedText = @"";
|
|
250
|
+
if (textView.selectedRange.location + textView.selectedRange.length <= textView.text.length) {
|
|
251
|
+
selectedText = [textView.text substringWithRange:textView.selectedRange];
|
|
252
|
+
}
|
|
253
|
+
|
|
240
254
|
// INVISIBLE MODE (ALL IOS VERSIONS)
|
|
241
255
|
if (_menuOptions.count == 0) {
|
|
242
|
-
NSString *selectedText = [textView.text substringWithRange:textView.selectedRange];
|
|
243
256
|
if (auto eventEmitter = std::static_pointer_cast<const SelectableTextViewEventEmitter>(_eventEmitter)) {
|
|
244
257
|
SelectableTextViewEventEmitter::OnSelection selectionEvent = {
|
|
245
|
-
.chosenOption =
|
|
246
|
-
.highlightedText =
|
|
258
|
+
.chosenOption = "CUSTOM_MODE",
|
|
259
|
+
.highlightedText = safeStdString(selectedText)
|
|
247
260
|
};
|
|
248
261
|
eventEmitter->onSelection(selectionEvent);
|
|
249
262
|
}
|
|
@@ -265,8 +278,8 @@ using namespace facebook::react;
|
|
|
265
278
|
if (_menuOptions.count == 0) {
|
|
266
279
|
if (auto eventEmitter = std::static_pointer_cast<const SelectableTextViewEventEmitter>(_eventEmitter)) {
|
|
267
280
|
SelectableTextViewEventEmitter::OnSelection selectionEvent = {
|
|
268
|
-
.chosenOption =
|
|
269
|
-
.highlightedText =
|
|
281
|
+
.chosenOption = "CUSTOM_MODE",
|
|
282
|
+
.highlightedText = ""
|
|
270
283
|
};
|
|
271
284
|
eventEmitter->onSelection(selectionEvent);
|
|
272
285
|
}
|
|
@@ -280,10 +293,23 @@ using namespace facebook::react;
|
|
|
280
293
|
- (UIMenu *)textView:(UITextView *)textView editMenuForTextInRange:(NSRange)range suggestedActions:(NSArray<UIMenuElement *> *)suggestedActions API_AVAILABLE(ios(16.0)) {
|
|
281
294
|
|
|
282
295
|
if (_menuOptions.count == 0) {
|
|
296
|
+
// SAFE BOUNDS CHECK
|
|
297
|
+
NSString *selectedText = @"";
|
|
298
|
+
if (range.location != NSNotFound && range.location + range.length <= textView.text.length) {
|
|
299
|
+
selectedText = [textView.text substringWithRange:range];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (selectedText.length > 0) {
|
|
303
|
+
if (auto eventEmitter = std::static_pointer_cast<const SelectableTextViewEventEmitter>(_eventEmitter)) {
|
|
304
|
+
SelectableTextViewEventEmitter::OnSelection selectionEvent = {
|
|
305
|
+
.chosenOption = "CUSTOM_MODE",
|
|
306
|
+
.highlightedText = safeStdString(selectedText)
|
|
307
|
+
};
|
|
308
|
+
eventEmitter->onSelection(selectionEvent);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
283
311
|
return [UIMenu menuWithTitle:@"" children:@[]];
|
|
284
312
|
}
|
|
285
|
-
|
|
286
|
-
[textView becomeFirstResponder];
|
|
287
313
|
|
|
288
314
|
NSMutableArray<UIMenuElement *> *customActions = [[NSMutableArray alloc] init];
|
|
289
315
|
|
|
@@ -323,7 +349,8 @@ using namespace facebook::react;
|
|
|
323
349
|
|
|
324
350
|
CGRect selectedRect = [_customTextView firstRectForRange:_customTextView.selectedTextRange];
|
|
325
351
|
|
|
326
|
-
|
|
352
|
+
// CRASH PREVENTION: Validate rect before showing menu (iOS 15 safety)
|
|
353
|
+
if (!CGRectIsEmpty(selectedRect) && !CGRectIsNull(selectedRect) && !CGRectIsInfinite(selectedRect)) {
|
|
327
354
|
CGRect targetRect = [_customTextView convertRect:selectedRect toView:_customTextView];
|
|
328
355
|
[menuController showMenuFromView:_customTextView rect:targetRect];
|
|
329
356
|
}
|
|
@@ -339,7 +366,6 @@ using namespace facebook::react;
|
|
|
339
366
|
// ====================================================================
|
|
340
367
|
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender
|
|
341
368
|
{
|
|
342
|
-
// 100% INVISIBLE MODE: iOS is forbidden to render any bubble menu
|
|
343
369
|
if (_menuOptions.count == 0) {
|
|
344
370
|
return NO;
|
|
345
371
|
}
|
|
@@ -370,22 +396,25 @@ using namespace facebook::react;
|
|
|
370
396
|
NSString *selectorName = NSStringFromSelector(anInvocation.selector);
|
|
371
397
|
|
|
372
398
|
if ([selectorName hasPrefix:@"customAction_"] && [selectorName hasSuffix:@":"]) {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
for (NSString *option in _menuOptions) {
|
|
377
|
-
NSString *clean1 = [option stringByReplacingOccurrencesOfString:@" " withString:@"_"];
|
|
378
|
-
NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"[^a-zA-Z0-9_]" options:0 error:nil];
|
|
379
|
-
NSString *testSelectorName = [regex stringByReplacingMatchesInString:clean1 options:0 range:NSMakeRange(0, clean1.length) withTemplate:@"_"];
|
|
399
|
+
// CRASH PREVENTION: Validate selector length before cutting string
|
|
400
|
+
if (selectorName.length > 14) {
|
|
401
|
+
NSString *cleanedOption = [selectorName substringWithRange:NSMakeRange(13, selectorName.length - 14)];
|
|
380
402
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
403
|
+
NSString *originalOption = nil;
|
|
404
|
+
for (NSString *option in _menuOptions) {
|
|
405
|
+
NSString *clean1 = [option stringByReplacingOccurrencesOfString:@" " withString:@"_"];
|
|
406
|
+
NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"[^a-zA-Z0-9_]" options:0 error:nil];
|
|
407
|
+
NSString *testSelectorName = [regex stringByReplacingMatchesInString:clean1 options:0 range:NSMakeRange(0, clean1.length) withTemplate:@"_"];
|
|
408
|
+
|
|
409
|
+
if ([testSelectorName isEqualToString:cleanedOption]) {
|
|
410
|
+
originalOption = option;
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (originalOption) {
|
|
416
|
+
[self handleMenuSelection:originalOption];
|
|
384
417
|
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
if (originalOption) {
|
|
388
|
-
[self handleMenuSelection:originalOption];
|
|
389
418
|
}
|
|
390
419
|
} else {
|
|
391
420
|
[super forwardInvocation:anInvocation];
|
|
@@ -397,7 +426,8 @@ using namespace facebook::react;
|
|
|
397
426
|
NSRange selectedRange = _customTextView.selectedRange;
|
|
398
427
|
NSString *selectedText = @"";
|
|
399
428
|
|
|
400
|
-
|
|
429
|
+
// SAFE BOUNDS CHECK
|
|
430
|
+
if (selectedRange.location != NSNotFound && selectedRange.length > 0 && (selectedRange.location + selectedRange.length <= _customTextView.text.length)) {
|
|
401
431
|
selectedText = [_customTextView.text substringWithRange:selectedRange];
|
|
402
432
|
}
|
|
403
433
|
|
|
@@ -406,8 +436,8 @@ using namespace facebook::react;
|
|
|
406
436
|
|
|
407
437
|
if (auto eventEmitter = std::static_pointer_cast<const SelectableTextViewEventEmitter>(_eventEmitter)) {
|
|
408
438
|
SelectableTextViewEventEmitter::OnSelection selectionEvent = {
|
|
409
|
-
.chosenOption =
|
|
410
|
-
.highlightedText =
|
|
439
|
+
.chosenOption = safeStdString(selectedOption),
|
|
440
|
+
.highlightedText = safeStdString(selectedText)
|
|
411
441
|
};
|
|
412
442
|
eventEmitter->onSelection(selectionEvent);
|
|
413
443
|
}
|