@thiagobueno/rn-selectable-text 1.0.6 → 1.0.8

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,41 +239,31 @@ using namespace facebook::react;
232
239
  #pragma mark - UITextViewDelegate
233
240
 
234
241
  // ====================================================================
235
- // REAL-TIME CUSTOM MODE TRACKING
242
+ // TOUCH-UP DETECTION & DEBOUNCE LOGIC
236
243
  // ====================================================================
237
244
  - (void)textViewDidChangeSelection:(UITextView *)textView
238
245
  {
239
- if (textView.selectedRange.length > 0) {
240
- // INVISIBLE MODE (ALL IOS VERSIONS)
241
- if (_menuOptions.count == 0) {
242
- NSString *selectedText = [textView.text substringWithRange:textView.selectedRange];
243
- if (auto eventEmitter = std::static_pointer_cast<const SelectableTextViewEventEmitter>(_eventEmitter)) {
244
- SelectableTextViewEventEmitter::OnSelection selectionEvent = {
245
- .chosenOption = std::string("CUSTOM_MODE"),
246
- .highlightedText = std::string([selectedText UTF8String])
247
- };
248
- eventEmitter->onSelection(selectionEvent);
249
- }
250
- return;
251
- }
246
+ // CANCEL PREVIOUS TIMERS: If user is still dragging, reset the clock
247
+ [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(emitCustomModeEventOrShowMenu) object:nil];
248
+
249
+ if (textView.selectedRange.location != NSNotFound && textView.selectedRange.length > 0) {
252
250
 
253
- // STANDARD MODE
254
251
  if (@available(iOS 16.0, *)) {
252
+ // iOS 16+: Do nothing during drag. The event will fire perfectly on 'Touch Up' inside editMenuForTextInRange.
255
253
  return;
256
254
  } else {
257
- dispatch_async(dispatch_get_main_queue(), ^{
258
- [self showCustomMenu];
259
- });
255
+ // iOS 15: Debounce timer. Waits 0.4s after the user stops dragging before firing the event.
256
+ [self performSelector:@selector(emitCustomModeEventOrShowMenu) withObject:nil afterDelay:0.4];
260
257
  }
261
258
  } else {
262
259
  [[UIMenuController sharedMenuController] hideMenuFromView:_customTextView];
263
260
 
264
- // IF SELECTION IS CLEARED, NOTIFY JS TO HIDE THE BOTTOM SHEET
261
+ // IF SELECTION IS CLEARED, NOTIFY JS IMMEDIATELY
265
262
  if (_menuOptions.count == 0) {
266
263
  if (auto eventEmitter = std::static_pointer_cast<const SelectableTextViewEventEmitter>(_eventEmitter)) {
267
264
  SelectableTextViewEventEmitter::OnSelection selectionEvent = {
268
- .chosenOption = std::string("CUSTOM_MODE"),
269
- .highlightedText = std::string("")
265
+ .chosenOption = "CUSTOM_MODE",
266
+ .highlightedText = ""
270
267
  };
271
268
  eventEmitter->onSelection(selectionEvent);
272
269
  }
@@ -274,16 +271,54 @@ using namespace facebook::react;
274
271
  }
275
272
  }
276
273
 
274
+ - (void)emitCustomModeEventOrShowMenu {
275
+ if (_customTextView.selectedRange.location == NSNotFound || _customTextView.selectedRange.length == 0) {
276
+ return;
277
+ }
278
+
279
+ if (_menuOptions.count == 0) {
280
+ // INVISIBLE MODE: Emit event on touch up
281
+ NSString *selectedText = @"";
282
+ if (_customTextView.selectedRange.location + _customTextView.selectedRange.length <= _customTextView.text.length) {
283
+ selectedText = [_customTextView.text substringWithRange:_customTextView.selectedRange];
284
+ }
285
+
286
+ if (auto eventEmitter = std::static_pointer_cast<const SelectableTextViewEventEmitter>(_eventEmitter)) {
287
+ SelectableTextViewEventEmitter::OnSelection selectionEvent = {
288
+ .chosenOption = "CUSTOM_MODE",
289
+ .highlightedText = safeStdString(selectedText)
290
+ };
291
+ eventEmitter->onSelection(selectionEvent);
292
+ }
293
+ } else {
294
+ // NATIVE MODE: Show the old UIMenuController
295
+ [self showCustomMenu];
296
+ }
297
+ }
298
+
277
299
  // ====================================================================
278
- // THE NEW IOS 16+ API
300
+ // THE NEW IOS 16+ API (FIRES ONLY ON TOUCH UP)
279
301
  // ====================================================================
280
302
  - (UIMenu *)textView:(UITextView *)textView editMenuForTextInRange:(NSRange)range suggestedActions:(NSArray<UIMenuElement *> *)suggestedActions API_AVAILABLE(ios(16.0)) {
281
303
 
282
304
  if (_menuOptions.count == 0) {
305
+ // SAFE BOUNDS CHECK
306
+ NSString *selectedText = @"";
307
+ if (range.location != NSNotFound && range.location + range.length <= textView.text.length) {
308
+ selectedText = [textView.text substringWithRange:range];
309
+ }
310
+
311
+ if (selectedText.length > 0) {
312
+ if (auto eventEmitter = std::static_pointer_cast<const SelectableTextViewEventEmitter>(_eventEmitter)) {
313
+ SelectableTextViewEventEmitter::OnSelection selectionEvent = {
314
+ .chosenOption = "CUSTOM_MODE",
315
+ .highlightedText = safeStdString(selectedText)
316
+ };
317
+ eventEmitter->onSelection(selectionEvent);
318
+ }
319
+ }
283
320
  return [UIMenu menuWithTitle:@"" children:@[]];
284
321
  }
285
-
286
- [textView becomeFirstResponder];
287
322
 
288
323
  NSMutableArray<UIMenuElement *> *customActions = [[NSMutableArray alloc] init];
289
324
 
@@ -323,7 +358,7 @@ using namespace facebook::react;
323
358
 
324
359
  CGRect selectedRect = [_customTextView firstRectForRange:_customTextView.selectedTextRange];
325
360
 
326
- if (!CGRectIsEmpty(selectedRect)) {
361
+ if (!CGRectIsEmpty(selectedRect) && !CGRectIsNull(selectedRect) && !CGRectIsInfinite(selectedRect)) {
327
362
  CGRect targetRect = [_customTextView convertRect:selectedRect toView:_customTextView];
328
363
  [menuController showMenuFromView:_customTextView rect:targetRect];
329
364
  }
@@ -339,7 +374,6 @@ using namespace facebook::react;
339
374
  // ====================================================================
340
375
  - (BOOL)canPerformAction:(SEL)action withSender:(id)sender
341
376
  {
342
- // 100% INVISIBLE MODE: iOS is forbidden to render any bubble menu
343
377
  if (_menuOptions.count == 0) {
344
378
  return NO;
345
379
  }
@@ -370,22 +404,24 @@ using namespace facebook::react;
370
404
  NSString *selectorName = NSStringFromSelector(anInvocation.selector);
371
405
 
372
406
  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:@"_"];
407
+ if (selectorName.length > 14) {
408
+ NSString *cleanedOption = [selectorName substringWithRange:NSMakeRange(13, selectorName.length - 14)];
380
409
 
381
- if ([testSelectorName isEqualToString:cleanedOption]) {
382
- originalOption = option;
383
- break;
410
+ NSString *originalOption = nil;
411
+ for (NSString *option in _menuOptions) {
412
+ NSString *clean1 = [option stringByReplacingOccurrencesOfString:@" " withString:@"_"];
413
+ NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"[^a-zA-Z0-9_]" options:0 error:nil];
414
+ NSString *testSelectorName = [regex stringByReplacingMatchesInString:clean1 options:0 range:NSMakeRange(0, clean1.length) withTemplate:@"_"];
415
+
416
+ if ([testSelectorName isEqualToString:cleanedOption]) {
417
+ originalOption = option;
418
+ break;
419
+ }
420
+ }
421
+
422
+ if (originalOption) {
423
+ [self handleMenuSelection:originalOption];
384
424
  }
385
- }
386
-
387
- if (originalOption) {
388
- [self handleMenuSelection:originalOption];
389
425
  }
390
426
  } else {
391
427
  [super forwardInvocation:anInvocation];
@@ -397,7 +433,7 @@ using namespace facebook::react;
397
433
  NSRange selectedRange = _customTextView.selectedRange;
398
434
  NSString *selectedText = @"";
399
435
 
400
- if (selectedRange.location != NSNotFound && selectedRange.length > 0) {
436
+ if (selectedRange.location != NSNotFound && selectedRange.length > 0 && (selectedRange.location + selectedRange.length <= _customTextView.text.length)) {
401
437
  selectedText = [_customTextView.text substringWithRange:selectedRange];
402
438
  }
403
439
 
@@ -406,8 +442,8 @@ using namespace facebook::react;
406
442
 
407
443
  if (auto eventEmitter = std::static_pointer_cast<const SelectableTextViewEventEmitter>(_eventEmitter)) {
408
444
  SelectableTextViewEventEmitter::OnSelection selectionEvent = {
409
- .chosenOption = std::string([selectedOption UTF8String]),
410
- .highlightedText = std::string([selectedText UTF8String])
445
+ .chosenOption = safeStdString(selectedOption),
446
+ .highlightedText = safeStdString(selectedText)
411
447
  };
412
448
  eventEmitter->onSelection(selectionEvent);
413
449
  }
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.8",
4
4
  "type": "module",
5
5
  "description": "A library for custom text selection menus",
6
6
  "main": "./lib/module/index.js",