@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 +141 -53
- package/ios/SelectableTextView.mm +81 -45
- 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,41 +239,31 @@ using namespace facebook::react;
|
|
|
232
239
|
#pragma mark - UITextViewDelegate
|
|
233
240
|
|
|
234
241
|
// ====================================================================
|
|
235
|
-
//
|
|
242
|
+
// TOUCH-UP DETECTION & DEBOUNCE LOGIC
|
|
236
243
|
// ====================================================================
|
|
237
244
|
- (void)textViewDidChangeSelection:(UITextView *)textView
|
|
238
245
|
{
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
|
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 =
|
|
269
|
-
.highlightedText =
|
|
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
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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 =
|
|
410
|
-
.highlightedText =
|
|
445
|
+
.chosenOption = safeStdString(selectedOption),
|
|
446
|
+
.highlightedText = safeStdString(selectedText)
|
|
411
447
|
};
|
|
412
448
|
eventEmitter->onSelection(selectionEvent);
|
|
413
449
|
}
|