@thiagobueno/rn-selectable-text 1.0.5 → 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
@@ -20,8 +29,6 @@ using namespace facebook::react;
20
29
 
21
30
  - (void)didMoveToWindow {
22
31
  [super didMoveToWindow];
23
- // FIX: iOS 16+ text selection bug after navigation.
24
- // When returning to the screen, we force iOS to re-evaluate the interaction state.
25
32
  if (self.window) {
26
33
  BOOL wasSelectable = self.selectable;
27
34
  self.selectable = NO;
@@ -31,12 +38,6 @@ using namespace facebook::react;
31
38
 
32
39
  - (BOOL)canPerformAction:(SEL)action withSender:(id)sender
33
40
  {
34
- // FIX: We explicitly tell iOS that we can perform standard actions.
35
- // This prevents the "[UIKitCore] The edit menu did not have performable commands" warning.
36
- if (action == @selector(copy:) || action == @selector(selectAll:)) {
37
- return YES;
38
- }
39
-
40
41
  if (self.parentSelectableTextView) {
41
42
  return [self.parentSelectableTextView canPerformAction:action withSender:sender];
42
43
  }
@@ -63,7 +64,6 @@ using namespace facebook::react;
63
64
  }
64
65
  }
65
66
 
66
- // Intercepts physical keyboard "Copy" and nullifies the action to keep it custom
67
67
  - (void)copy:(id)sender
68
68
  {
69
69
  // Silently blocked
@@ -142,7 +142,6 @@ using namespace facebook::react;
142
142
  - (void)prepareForRecycle {
143
143
  [super prepareForRecycle];
144
144
 
145
- // FIX: Force drop focus when leaving screen
146
145
  [_customTextView resignFirstResponder];
147
146
 
148
147
  [[UIMenuController sharedMenuController] hideMenuFromView:_customTextView];
@@ -151,7 +150,6 @@ using namespace facebook::react;
151
150
  _customTextView.text = nil;
152
151
  _customTextView.selectedTextRange = nil;
153
152
 
154
- // CRITICAL FIX: Do NOT clear _menuOptions here. Fabric's prop diffing will handle updates.
155
153
  [self unhideAllViews:self];
156
154
  }
157
155
 
@@ -240,29 +238,76 @@ using namespace facebook::react;
240
238
 
241
239
  #pragma mark - UITextViewDelegate
242
240
 
241
+ // ====================================================================
242
+ // REAL-TIME CUSTOM MODE TRACKING (With Crash Prevention bounds check)
243
+ // ====================================================================
244
+ - (void)textViewDidChangeSelection:(UITextView *)textView
245
+ {
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
+
254
+ // INVISIBLE MODE (ALL IOS VERSIONS)
255
+ if (_menuOptions.count == 0) {
256
+ if (auto eventEmitter = std::static_pointer_cast<const SelectableTextViewEventEmitter>(_eventEmitter)) {
257
+ SelectableTextViewEventEmitter::OnSelection selectionEvent = {
258
+ .chosenOption = "CUSTOM_MODE",
259
+ .highlightedText = safeStdString(selectedText)
260
+ };
261
+ eventEmitter->onSelection(selectionEvent);
262
+ }
263
+ return;
264
+ }
265
+
266
+ // STANDARD MODE
267
+ if (@available(iOS 16.0, *)) {
268
+ return;
269
+ } else {
270
+ dispatch_async(dispatch_get_main_queue(), ^{
271
+ [self showCustomMenu];
272
+ });
273
+ }
274
+ } else {
275
+ [[UIMenuController sharedMenuController] hideMenuFromView:_customTextView];
276
+
277
+ // IF SELECTION IS CLEARED, NOTIFY JS TO HIDE THE BOTTOM SHEET
278
+ if (_menuOptions.count == 0) {
279
+ if (auto eventEmitter = std::static_pointer_cast<const SelectableTextViewEventEmitter>(_eventEmitter)) {
280
+ SelectableTextViewEventEmitter::OnSelection selectionEvent = {
281
+ .chosenOption = "CUSTOM_MODE",
282
+ .highlightedText = ""
283
+ };
284
+ eventEmitter->onSelection(selectionEvent);
285
+ }
286
+ }
287
+ }
288
+ }
289
+
243
290
  // ====================================================================
244
291
  // THE NEW IOS 16+ API
245
292
  // ====================================================================
246
293
  - (UIMenu *)textView:(UITextView *)textView editMenuForTextInRange:(NSRange)range suggestedActions:(NSArray<UIMenuElement *> *)suggestedActions API_AVAILABLE(ios(16.0)) {
247
294
 
248
- // FORCING THE FOCUS: ensures the system doesn't dismiss our menu unexpectedly
249
- [textView becomeFirstResponder];
250
-
251
- // ====================================================================
252
- // CUSTOM INVISIBLE MODE: Suppress native menu if array is empty
253
- // ====================================================================
254
295
  if (_menuOptions.count == 0) {
255
- NSString *selectedText = [textView.text substringWithRange:range];
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
+
256
302
  if (selectedText.length > 0) {
257
303
  if (auto eventEmitter = std::static_pointer_cast<const SelectableTextViewEventEmitter>(_eventEmitter)) {
258
304
  SelectableTextViewEventEmitter::OnSelection selectionEvent = {
259
- .chosenOption = std::string("CUSTOM_MODE"),
260
- .highlightedText = std::string([selectedText UTF8String])
305
+ .chosenOption = "CUSTOM_MODE",
306
+ .highlightedText = safeStdString(selectedText)
261
307
  };
262
308
  eventEmitter->onSelection(selectionEvent);
263
309
  }
264
310
  }
265
- // Returns an empty menu, effectively hiding the native UI
266
311
  return [UIMenu menuWithTitle:@"" children:@[]];
267
312
  }
268
313
 
@@ -278,36 +323,6 @@ using namespace facebook::react;
278
323
  return [UIMenu menuWithTitle:@"" children:customActions];
279
324
  }
280
325
 
281
- - (void)textViewDidChangeSelection:(UITextView *)textView
282
- {
283
- if (@available(iOS 16.0, *)) {
284
- return;
285
- } else {
286
- if (textView.selectedRange.length > 0) {
287
-
288
- // CUSTOM INVISIBLE MODE FOR IOS < 16
289
- if (_menuOptions.count == 0) {
290
- NSString *selectedText = [textView.text substringWithRange:textView.selectedRange];
291
- if (auto eventEmitter = std::static_pointer_cast<const SelectableTextViewEventEmitter>(_eventEmitter)) {
292
- SelectableTextViewEventEmitter::OnSelection selectionEvent = {
293
- .chosenOption = std::string("CUSTOM_MODE"),
294
- .highlightedText = std::string([selectedText UTF8String])
295
- };
296
- eventEmitter->onSelection(selectionEvent);
297
- }
298
- [[UIMenuController sharedMenuController] hideMenuFromView:_customTextView];
299
- return;
300
- }
301
-
302
- dispatch_async(dispatch_get_main_queue(), ^{
303
- [self showCustomMenu];
304
- });
305
- } else {
306
- [[UIMenuController sharedMenuController] hideMenuFromView:_customTextView];
307
- }
308
- }
309
- }
310
-
311
326
  - (void)showCustomMenu
312
327
  {
313
328
  if (![_customTextView canBecomeFirstResponder]) return;
@@ -334,7 +349,8 @@ using namespace facebook::react;
334
349
 
335
350
  CGRect selectedRect = [_customTextView firstRectForRange:_customTextView.selectedTextRange];
336
351
 
337
- if (!CGRectIsEmpty(selectedRect)) {
352
+ // CRASH PREVENTION: Validate rect before showing menu (iOS 15 safety)
353
+ if (!CGRectIsEmpty(selectedRect) && !CGRectIsNull(selectedRect) && !CGRectIsInfinite(selectedRect)) {
338
354
  CGRect targetRect = [_customTextView convertRect:selectedRect toView:_customTextView];
339
355
  [menuController showMenuFromView:_customTextView rect:targetRect];
340
356
  }
@@ -346,17 +362,19 @@ using namespace facebook::react;
346
362
  }
347
363
 
348
364
  // ====================================================================
349
- // THE TRICK TO FORCE THE MENU TO OPEN AND PREVENT UIKIT WARNINGS
365
+ // SUPPRESS SYSTEM MENU IN CUSTOM MODE
350
366
  // ====================================================================
351
367
  - (BOOL)canPerformAction:(SEL)action withSender:(id)sender
352
368
  {
369
+ if (_menuOptions.count == 0) {
370
+ return NO;
371
+ }
372
+
353
373
  NSString *selectorName = NSStringFromSelector(action);
354
374
  if ([selectorName hasPrefix:@"customAction_"] && [selectorName hasSuffix:@":"]) {
355
375
  return YES;
356
376
  }
357
377
 
358
- // We tell iOS that we can perform standard actions.
359
- // This convinces the system not to abort the menu rendering.
360
378
  if (action == @selector(copy:) || action == @selector(selectAll:)) {
361
379
  return YES;
362
380
  }
@@ -378,23 +396,26 @@ using namespace facebook::react;
378
396
  NSString *selectorName = NSStringFromSelector(anInvocation.selector);
379
397
 
380
398
  if ([selectorName hasPrefix:@"customAction_"] && [selectorName hasSuffix:@":"]) {
381
- NSString *cleanedOption = [selectorName substringWithRange:NSMakeRange(13, selectorName.length - 14)];
382
-
383
- NSString *originalOption = nil;
384
- for (NSString *option in _menuOptions) {
385
- NSString *clean1 = [option stringByReplacingOccurrencesOfString:@" " withString:@"_"];
386
- NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"[^a-zA-Z0-9_]" options:0 error:nil];
387
- 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)];
402
+
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
+ }
388
414
 
389
- if ([testSelectorName isEqualToString:cleanedOption]) {
390
- originalOption = option;
391
- break;
415
+ if (originalOption) {
416
+ [self handleMenuSelection:originalOption];
392
417
  }
393
418
  }
394
-
395
- if (originalOption) {
396
- [self handleMenuSelection:originalOption];
397
- }
398
419
  } else {
399
420
  [super forwardInvocation:anInvocation];
400
421
  }
@@ -405,7 +426,8 @@ using namespace facebook::react;
405
426
  NSRange selectedRange = _customTextView.selectedRange;
406
427
  NSString *selectedText = @"";
407
428
 
408
- 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)) {
409
431
  selectedText = [_customTextView.text substringWithRange:selectedRange];
410
432
  }
411
433
 
@@ -414,8 +436,8 @@ using namespace facebook::react;
414
436
 
415
437
  if (auto eventEmitter = std::static_pointer_cast<const SelectableTextViewEventEmitter>(_eventEmitter)) {
416
438
  SelectableTextViewEventEmitter::OnSelection selectionEvent = {
417
- .chosenOption = std::string([selectedOption UTF8String]),
418
- .highlightedText = std::string([selectedText UTF8String])
439
+ .chosenOption = safeStdString(selectedOption),
440
+ .highlightedText = safeStdString(selectedText)
419
441
  };
420
442
  eventEmitter->onSelection(selectionEvent);
421
443
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thiagobueno/rn-selectable-text",
3
- "version": "1.0.5",
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",