@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 CHANGED
@@ -39,29 +39,117 @@ import { SelectableTextView } from '@thiagobueno/rn-selectable-text';
39
39
 
40
40
  export default function App() {
41
41
 
42
- const handleSelection = (event) => {
43
- const { chosenOption, highlightedText } = event;
44
- Alert.alert(
45
- 'Selection Event',
46
- `Option: ${chosenOption}\nSelected Text: ${highlightedText}`
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
- const handleSelection = ({ chosenOption, highlightedText }) => {
79
- const actionIndex = MENU_OPTIONS.indexOf(chosenOption);
80
-
81
- switch(actionIndex) {
82
- case 0:
83
- console.log('Action: Save Note - Text:', highlightedText);
84
- break;
85
- case 1:
86
- console.log('Action: Edit Text - Text:', highlightedText);
87
- break;
88
- case 2:
89
- console.log('Action: Highlight Content - Text:', highlightedText);
90
- break;
91
- }
92
- };
93
-
94
- return (
95
- <View style={{ padding: 20 }}>
96
- <SelectableTextView
97
- menuOptions={MENU_OPTIONS}
98
- onSelection={handleSelection}
99
- >
100
- <Text style={{ color: 'black', fontSize: 16 }}>
101
- This text is black, but{' '}
102
- <Text style={{ fontWeight: 'bold', color: 'blue' }}>
103
- this part is bold and blue
104
- </Text>{' '}
105
- and this is black again. The entire block is selectable!
106
- </Text>
107
- </SelectableTextView>
108
- </View>
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
- chosenOption: string; // The exact string of the menu option selected
131
- highlightedText: string; // The specific text highlighted by the user
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 = std::string("CUSTOM_MODE"),
246
- .highlightedText = std::string([selectedText UTF8String])
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 = std::string("CUSTOM_MODE"),
269
- .highlightedText = std::string("")
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
- if (!CGRectIsEmpty(selectedRect)) {
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
- NSString *cleanedOption = [selectorName substringWithRange:NSMakeRange(13, selectorName.length - 14)];
374
-
375
- NSString *originalOption = nil;
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
- if ([testSelectorName isEqualToString:cleanedOption]) {
382
- originalOption = option;
383
- break;
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
- if (selectedRange.location != NSNotFound && selectedRange.length > 0) {
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 = std::string([selectedOption UTF8String]),
410
- .highlightedText = std::string([selectedText UTF8String])
439
+ .chosenOption = safeStdString(selectedOption),
440
+ .highlightedText = safeStdString(selectedText)
411
441
  };
412
442
  eventEmitter->onSelection(selectionEvent);
413
443
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thiagobueno/rn-selectable-text",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "type": "module",
5
5
  "description": "A library for custom text selection menus",
6
6
  "main": "./lib/module/index.js",