@thiagobueno/rn-selectable-text 1.0.0
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/LICENSE +20 -0
- package/README.md +123 -0
- package/android/build.gradle +77 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/selectabletext/SelectableTextPackage.kt +19 -0
- package/android/src/main/java/com/selectabletext/SelectableTextView.kt +97 -0
- package/android/src/main/java/com/selectabletext/SelectableTextViewManager.kt +53 -0
- package/ios/SelectableTextView.h +16 -0
- package/ios/SelectableTextView.mm +367 -0
- package/lib/module/SelectableTextView.js +43 -0
- package/lib/module/SelectableTextView.js.map +1 -0
- package/lib/module/SelectableTextViewNativeComponent.ts +14 -0
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/SelectableTextView.d.ts +12 -0
- package/lib/typescript/src/SelectableTextView.d.ts.map +1 -0
- package/lib/typescript/src/SelectableTextViewNativeComponent.d.ts +13 -0
- package/lib/typescript/src/SelectableTextViewNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +2 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/package.json +170 -0
- package/rn-selectable-text.podspec +21 -0
- package/src/SelectableTextView.tsx +67 -0
- package/src/SelectableTextViewNativeComponent.ts +14 -0
- package/src/index.tsx +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Robert Sherling
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
5
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
6
|
+
in the Software without restriction, including without limitation the rights
|
|
7
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
9
|
+
furnished to do so, subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all
|
|
12
|
+
copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
15
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
16
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
17
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
18
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
19
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
20
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Disclaimer -
|
|
2
|
+
|
|
3
|
+
I tested this code in my own projects, but this code has been with heavy assistance from Claude Code. If you see a problem - submit a ticket!
|
|
4
|
+
|
|
5
|
+
# rn-selectable-text
|
|
6
|
+
|
|
7
|
+
A React Native library for custom text selection menus, redesigned from the ground up for React Native 0.81.1 with full support for the new architecture (Fabric).
|
|
8
|
+
|
|
9
|
+
The `SelectableTextView` component wraps your text content and provides custom menu options that appear when users select text. It supports nested text styling and cross-platform event handling.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- Cross-platform support (iOS & Android)
|
|
14
|
+
- Support for nested text with different styles
|
|
15
|
+
- Custom menu options with callback handling
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
yarn add @thiagobueno/rn-selectable-text
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
For iOS, run pod install:
|
|
24
|
+
```sh
|
|
25
|
+
cd ios && pod install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
### Basic Example
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import React, { useState } from 'react';
|
|
34
|
+
import { View, Text, Alert } from 'react-native';
|
|
35
|
+
import { SelectableTextView } from '@thiagobueno/rn-selectable-text';
|
|
36
|
+
|
|
37
|
+
export default function App() {
|
|
38
|
+
|
|
39
|
+
const handleSelection = (event) => {
|
|
40
|
+
const { chosenOption, highlightedText } = event;
|
|
41
|
+
Alert.alert(
|
|
42
|
+
'Selection Event',
|
|
43
|
+
`Option: ${chosenOption}\nSelected Text: ${highlightedText}`
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<View>
|
|
49
|
+
|
|
50
|
+
<SelectableTextView
|
|
51
|
+
menuOptions={['look up', 'copy', 'share']}
|
|
52
|
+
onSelection={handleSelection}
|
|
53
|
+
style={{ margin: 20 }}
|
|
54
|
+
>
|
|
55
|
+
<Text>This is simple selectable text</Text>
|
|
56
|
+
</SelectableTextView>
|
|
57
|
+
</View>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Advanced Example with Nested Text Styling
|
|
63
|
+
|
|
64
|
+
```jsx
|
|
65
|
+
<SelectableTextView
|
|
66
|
+
menuOptions={['Action 1', 'Action 2', 'Custom Action']}
|
|
67
|
+
onSelection={handleSelection}
|
|
68
|
+
style={{ marginHorizontal: 20 }}
|
|
69
|
+
>
|
|
70
|
+
<Text style={{ color: 'black', fontSize: 16 }}>
|
|
71
|
+
This text is black{' '}
|
|
72
|
+
<Text style={{ textDecorationLine: 'underline', color: 'red' }}>
|
|
73
|
+
this part is underlined and red
|
|
74
|
+
</Text>{' '}
|
|
75
|
+
and this is black again. All of it is selectable with custom menu options!
|
|
76
|
+
</Text>
|
|
77
|
+
</SelectableTextView>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## API Reference
|
|
81
|
+
|
|
82
|
+
### SelectableTextView Props
|
|
83
|
+
|
|
84
|
+
| Prop | Type | Required | Description |
|
|
85
|
+
| ------------- | --------------------------------- | -------- | ------------------------------------------- |
|
|
86
|
+
| `children` | `React.ReactNode` | Yes | Text components to make selectable |
|
|
87
|
+
| `menuOptions` | `string[]` | Yes | Array of menu option strings |
|
|
88
|
+
| `onSelection` | `(event: SelectionEvent) => void` | No | Callback fired when menu option is selected |
|
|
89
|
+
| `style` | `ViewStyle` | No | Style object for the container |
|
|
90
|
+
|
|
91
|
+
### SelectionEvent
|
|
92
|
+
|
|
93
|
+
The `onSelection` callback receives an event object with:
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
interface SelectionEvent {
|
|
97
|
+
chosenOption: string; // The menu option that was selected
|
|
98
|
+
highlightedText: string; // The text that was highlighted by the user
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Requirements
|
|
103
|
+
|
|
104
|
+
- React Native 0.81.1+
|
|
105
|
+
- iOS 11.0+
|
|
106
|
+
- Android API level 21+
|
|
107
|
+
- React Native's new architecture (Fabric) enabled
|
|
108
|
+
|
|
109
|
+
## Platform Differences
|
|
110
|
+
|
|
111
|
+
The library handles platform differences internally:
|
|
112
|
+
- **iOS**: Uses direct event handlers for optimal performance
|
|
113
|
+
- **Android**: Uses DeviceEventEmitter for reliable event delivery
|
|
114
|
+
|
|
115
|
+
Both platforms provide the same API and functionality.
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
buildscript {
|
|
2
|
+
ext.getExtOrDefault = {name ->
|
|
3
|
+
return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['SelectableText_' + name]
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
repositories {
|
|
7
|
+
google()
|
|
8
|
+
mavenCentral()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
dependencies {
|
|
12
|
+
classpath "com.android.tools.build:gradle:8.7.2"
|
|
13
|
+
// noinspection DifferentKotlinGradleVersion
|
|
14
|
+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
apply plugin: "com.android.library"
|
|
20
|
+
apply plugin: "kotlin-android"
|
|
21
|
+
|
|
22
|
+
apply plugin: "com.facebook.react"
|
|
23
|
+
|
|
24
|
+
def getExtOrIntegerDefault(name) {
|
|
25
|
+
return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["SelectableText_" + name]).toInteger()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
android {
|
|
29
|
+
namespace "com.selectabletext"
|
|
30
|
+
|
|
31
|
+
compileSdkVersion getExtOrIntegerDefault("compileSdkVersion")
|
|
32
|
+
|
|
33
|
+
defaultConfig {
|
|
34
|
+
minSdkVersion getExtOrIntegerDefault("minSdkVersion")
|
|
35
|
+
targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
buildFeatures {
|
|
39
|
+
buildConfig true
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
buildTypes {
|
|
43
|
+
release {
|
|
44
|
+
minifyEnabled false
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
lintOptions {
|
|
49
|
+
disable "GradleCompatible"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
compileOptions {
|
|
53
|
+
sourceCompatibility JavaVersion.VERSION_1_8
|
|
54
|
+
targetCompatibility JavaVersion.VERSION_1_8
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
sourceSets {
|
|
58
|
+
main {
|
|
59
|
+
java.srcDirs += [
|
|
60
|
+
"generated/java",
|
|
61
|
+
"generated/jni"
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
repositories {
|
|
68
|
+
mavenCentral()
|
|
69
|
+
google()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
def kotlin_version = getExtOrDefault("kotlinVersion")
|
|
73
|
+
|
|
74
|
+
dependencies {
|
|
75
|
+
implementation "com.facebook.react:react-android"
|
|
76
|
+
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
|
77
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
package com.selectabletext
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.ReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.uimanager.ViewManager
|
|
7
|
+
import java.util.ArrayList
|
|
8
|
+
|
|
9
|
+
class SelectableTextViewPackage : ReactPackage {
|
|
10
|
+
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
|
11
|
+
val viewManagers: MutableList<ViewManager<*, *>> = ArrayList()
|
|
12
|
+
viewManagers.add(SelectableTextViewManager())
|
|
13
|
+
return viewManagers
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
|
17
|
+
return emptyList()
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
package com.selectabletext
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.util.AttributeSet
|
|
5
|
+
import android.view.ActionMode
|
|
6
|
+
import android.view.Menu
|
|
7
|
+
import android.view.MenuItem
|
|
8
|
+
import android.widget.FrameLayout
|
|
9
|
+
import android.widget.TextView
|
|
10
|
+
import com.facebook.react.bridge.Arguments
|
|
11
|
+
import com.facebook.react.bridge.ReactContext
|
|
12
|
+
import com.facebook.react.bridge.WritableMap
|
|
13
|
+
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
14
|
+
|
|
15
|
+
class SelectableTextView : FrameLayout {
|
|
16
|
+
private var menuOptions: Array<String> = emptyArray()
|
|
17
|
+
private var textView: TextView? = null
|
|
18
|
+
|
|
19
|
+
constructor(context: Context?) : super(context!!)
|
|
20
|
+
constructor(context: Context?, attrs: AttributeSet?) : super(context!!, attrs)
|
|
21
|
+
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(
|
|
22
|
+
context!!,
|
|
23
|
+
attrs,
|
|
24
|
+
defStyleAttr
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
fun setMenuOptions(options: Array<String>) {
|
|
28
|
+
this.menuOptions = options
|
|
29
|
+
setupTextView()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private fun setupTextView() {
|
|
33
|
+
// Find the first TextView child
|
|
34
|
+
for (i in 0 until childCount) {
|
|
35
|
+
val child = getChildAt(i)
|
|
36
|
+
if (child is TextView) {
|
|
37
|
+
textView = child
|
|
38
|
+
setupSelectionCallback(child)
|
|
39
|
+
break
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private fun setupSelectionCallback(textView: TextView) {
|
|
45
|
+
textView.setTextIsSelectable(true)
|
|
46
|
+
textView.customSelectionActionModeCallback = object : ActionMode.Callback {
|
|
47
|
+
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
|
|
52
|
+
menu?.clear()
|
|
53
|
+
menuOptions.forEachIndexed { index, option ->
|
|
54
|
+
menu?.add(0, index, 0, option)
|
|
55
|
+
}
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
|
|
60
|
+
val selectionStart = textView.selectionStart
|
|
61
|
+
val selectionEnd = textView.selectionEnd
|
|
62
|
+
val selectedText = textView.text.toString().substring(selectionStart, selectionEnd)
|
|
63
|
+
val chosenOption = menuOptions[item?.itemId ?: 0]
|
|
64
|
+
|
|
65
|
+
// Send event to React Native
|
|
66
|
+
onSelectionEvent(chosenOption, selectedText)
|
|
67
|
+
|
|
68
|
+
mode?.finish()
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
override fun onDestroyActionMode(mode: ActionMode?) {
|
|
73
|
+
// Called when action mode is destroyed
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private fun onSelectionEvent(chosenOption: String, highlightedText: String) {
|
|
79
|
+
val reactContext = context as ReactContext
|
|
80
|
+
val params = Arguments.createMap().apply {
|
|
81
|
+
putInt("viewTag", id)
|
|
82
|
+
putString("chosenOption", chosenOption)
|
|
83
|
+
putString("highlightedText", highlightedText)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
reactContext
|
|
87
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
88
|
+
.emit("SelectableTextSelection", params)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
92
|
+
super.onLayout(changed, left, top, right, bottom)
|
|
93
|
+
if (changed && textView == null) {
|
|
94
|
+
setupTextView()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
package com.selectabletext
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.ReadableArray
|
|
4
|
+
import com.facebook.react.common.MapBuilder
|
|
5
|
+
import com.facebook.react.module.annotations.ReactModule
|
|
6
|
+
import com.facebook.react.uimanager.ViewGroupManager
|
|
7
|
+
import com.facebook.react.uimanager.ThemedReactContext
|
|
8
|
+
import com.facebook.react.uimanager.ViewManagerDelegate
|
|
9
|
+
import com.facebook.react.uimanager.annotations.ReactProp
|
|
10
|
+
import com.facebook.react.viewmanagers.SelectableTextViewManagerInterface
|
|
11
|
+
import com.facebook.react.viewmanagers.SelectableTextViewManagerDelegate
|
|
12
|
+
|
|
13
|
+
@ReactModule(name = SelectableTextViewManager.NAME)
|
|
14
|
+
class SelectableTextViewManager : ViewGroupManager<SelectableTextView>(),
|
|
15
|
+
SelectableTextViewManagerInterface<SelectableTextView> {
|
|
16
|
+
private val mDelegate: ViewManagerDelegate<SelectableTextView>
|
|
17
|
+
|
|
18
|
+
init {
|
|
19
|
+
mDelegate = SelectableTextViewManagerDelegate(this)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
override fun getDelegate(): ViewManagerDelegate<SelectableTextView>? {
|
|
23
|
+
return mDelegate
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
override fun getName(): String {
|
|
27
|
+
return NAME
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public override fun createViewInstance(context: ThemedReactContext): SelectableTextView {
|
|
31
|
+
return SelectableTextView(context)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@ReactProp(name = "menuOptions")
|
|
35
|
+
override fun setMenuOptions(view: SelectableTextView, menuOptions: ReadableArray?) {
|
|
36
|
+
if (menuOptions != null) {
|
|
37
|
+
val options = Array(menuOptions.size()) { i ->
|
|
38
|
+
menuOptions.getString(i) ?: ""
|
|
39
|
+
}
|
|
40
|
+
view.setMenuOptions(options)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
override fun getExportedCustomDirectEventTypeConstants(): Map<String, Any>? {
|
|
45
|
+
return MapBuilder.builder<String, Any>()
|
|
46
|
+
.put("topSelection", MapBuilder.of("registrationName", "onSelection"))
|
|
47
|
+
.build()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
companion object {
|
|
51
|
+
const val NAME = "SelectableTextView"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#import <React/RCTViewComponentView.h>
|
|
2
|
+
#import <UIKit/UIKit.h>
|
|
3
|
+
|
|
4
|
+
#ifndef SelectableTextViewNativeComponent_h
|
|
5
|
+
#define SelectableTextViewNativeComponent_h
|
|
6
|
+
|
|
7
|
+
NS_ASSUME_NONNULL_BEGIN
|
|
8
|
+
|
|
9
|
+
@interface SelectableTextView : RCTViewComponentView <UITextViewDelegate>
|
|
10
|
+
@property (nonatomic, strong) UITextView *textView;
|
|
11
|
+
@property (nonatomic, strong) NSArray<NSString *> *menuOptions;
|
|
12
|
+
@end
|
|
13
|
+
|
|
14
|
+
NS_ASSUME_NONNULL_END
|
|
15
|
+
|
|
16
|
+
#endif /* SelectableTextViewNativeComponent_h */
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
#import "SelectableTextView.h"
|
|
2
|
+
|
|
3
|
+
#import <react/renderer/components/SelectableTextViewSpec/ComponentDescriptors.h>
|
|
4
|
+
#import <react/renderer/components/SelectableTextViewSpec/EventEmitters.h>
|
|
5
|
+
#import <react/renderer/components/SelectableTextViewSpec/Props.h>
|
|
6
|
+
#import <react/renderer/components/SelectableTextViewSpec/RCTComponentViewHelpers.h>
|
|
7
|
+
|
|
8
|
+
#import "RCTFabricComponentsPlugins.h"
|
|
9
|
+
#import <React/RCTConversions.h>
|
|
10
|
+
|
|
11
|
+
using namespace facebook::react;
|
|
12
|
+
|
|
13
|
+
@class SelectableTextView;
|
|
14
|
+
|
|
15
|
+
@interface SelectableUITextView : UITextView
|
|
16
|
+
@property (nonatomic, weak) SelectableTextView *parentSelectableTextView;
|
|
17
|
+
@end
|
|
18
|
+
|
|
19
|
+
@implementation SelectableUITextView
|
|
20
|
+
|
|
21
|
+
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender
|
|
22
|
+
{
|
|
23
|
+
if (self.parentSelectableTextView) {
|
|
24
|
+
return [self.parentSelectableTextView canPerformAction:action withSender:sender];
|
|
25
|
+
}
|
|
26
|
+
return [super canPerformAction:action withSender:sender];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector
|
|
30
|
+
{
|
|
31
|
+
if (self.parentSelectableTextView) {
|
|
32
|
+
NSMethodSignature *signature = [self.parentSelectableTextView methodSignatureForSelector:aSelector];
|
|
33
|
+
if (signature) {
|
|
34
|
+
return signature;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return [super methodSignatureForSelector:aSelector];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
- (void)forwardInvocation:(NSInvocation *)anInvocation
|
|
41
|
+
{
|
|
42
|
+
if (self.parentSelectableTextView) {
|
|
43
|
+
[self.parentSelectableTextView forwardInvocation:anInvocation];
|
|
44
|
+
} else {
|
|
45
|
+
[super forwardInvocation:anInvocation];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
- (void)copy:(id)sender
|
|
50
|
+
{
|
|
51
|
+
// Bloqueado
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@end
|
|
55
|
+
|
|
56
|
+
@interface SelectableTextView () <RCTSelectableTextViewViewProtocol, UITextViewDelegate>
|
|
57
|
+
- (void)unhideAllViews:(UIView *)view;
|
|
58
|
+
@end
|
|
59
|
+
|
|
60
|
+
@implementation SelectableTextView {
|
|
61
|
+
std::vector<std::string> _menuOptionsVector;
|
|
62
|
+
SelectableUITextView *_customTextView;
|
|
63
|
+
NSArray<NSString *> *_menuOptions;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
+ (ComponentDescriptorProvider)componentDescriptorProvider
|
|
67
|
+
{
|
|
68
|
+
return concreteComponentDescriptorProvider<SelectableTextViewComponentDescriptor>();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
- (instancetype)initWithFrame:(CGRect)frame
|
|
72
|
+
{
|
|
73
|
+
if (self = [super initWithFrame:frame]) {
|
|
74
|
+
static const auto defaultProps = std::make_shared<const SelectableTextViewProps>();
|
|
75
|
+
_props = defaultProps;
|
|
76
|
+
|
|
77
|
+
_customTextView = [[SelectableUITextView alloc] init];
|
|
78
|
+
_customTextView.parentSelectableTextView = self;
|
|
79
|
+
_customTextView.delegate = self;
|
|
80
|
+
_customTextView.editable = NO;
|
|
81
|
+
_customTextView.selectable = YES;
|
|
82
|
+
_customTextView.scrollEnabled = NO;
|
|
83
|
+
_customTextView.backgroundColor = [UIColor clearColor];
|
|
84
|
+
_customTextView.textContainerInset = UIEdgeInsetsZero;
|
|
85
|
+
_customTextView.textContainer.lineFragmentPadding = 0;
|
|
86
|
+
_customTextView.userInteractionEnabled = YES;
|
|
87
|
+
|
|
88
|
+
_customTextView.allowsEditingTextAttributes = NO;
|
|
89
|
+
_customTextView.dataDetectorTypes = UIDataDetectorTypeNone;
|
|
90
|
+
|
|
91
|
+
_customTextView.text = @"";
|
|
92
|
+
_menuOptions = @[];
|
|
93
|
+
|
|
94
|
+
self.contentView = _customTextView;
|
|
95
|
+
self.userInteractionEnabled = YES;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return self;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
- (void)updateLayoutMetrics:(const facebook::react::LayoutMetrics &)layoutMetrics
|
|
102
|
+
oldLayoutMetrics:(const facebook::react::LayoutMetrics &)oldLayoutMetrics {
|
|
103
|
+
[super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
|
|
104
|
+
|
|
105
|
+
CGRect frame = RCTCGRectFromRect(layoutMetrics.getContentFrame());
|
|
106
|
+
_customTextView.frame = frame;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
- (void)unhideAllViews:(UIView *)view {
|
|
110
|
+
if (view != _customTextView && view != self) {
|
|
111
|
+
view.hidden = NO;
|
|
112
|
+
}
|
|
113
|
+
for (UIView *subview in view.subviews) {
|
|
114
|
+
[self unhideAllViews:subview];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
- (void)unmountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index
|
|
119
|
+
{
|
|
120
|
+
[self unhideAllViews:childComponentView];
|
|
121
|
+
[super unmountChildComponentView:childComponentView index:index];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
- (void)prepareForRecycle {
|
|
125
|
+
[super prepareForRecycle];
|
|
126
|
+
|
|
127
|
+
[[UIMenuController sharedMenuController] hideMenuFromView:_customTextView];
|
|
128
|
+
[UIMenuController sharedMenuController].menuItems = nil;
|
|
129
|
+
|
|
130
|
+
_customTextView.text = nil;
|
|
131
|
+
_customTextView.selectedTextRange = nil;
|
|
132
|
+
_menuOptions = @[];
|
|
133
|
+
_menuOptionsVector.clear();
|
|
134
|
+
|
|
135
|
+
[self unhideAllViews:self];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
- (void)dealloc {
|
|
139
|
+
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
|
140
|
+
[UIMenuController sharedMenuController].menuItems = nil;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
|
|
144
|
+
{
|
|
145
|
+
const auto &oldViewProps = *std::static_pointer_cast<SelectableTextViewProps const>(_props);
|
|
146
|
+
const auto &newViewProps = *std::static_pointer_cast<SelectableTextViewProps const>(props);
|
|
147
|
+
|
|
148
|
+
if (oldViewProps.menuOptions != newViewProps.menuOptions) {
|
|
149
|
+
_menuOptionsVector = newViewProps.menuOptions;
|
|
150
|
+
|
|
151
|
+
NSMutableArray<NSString *> *options = [[NSMutableArray alloc] init];
|
|
152
|
+
for (const auto& option : _menuOptionsVector) {
|
|
153
|
+
[options addObject:[NSString stringWithUTF8String:option.c_str()]];
|
|
154
|
+
}
|
|
155
|
+
_menuOptions = options;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
[super updateProps:props oldProps:oldProps];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
- (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index
|
|
162
|
+
{
|
|
163
|
+
[super mountChildComponentView:childComponentView index:index];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
- (void)layoutSubviews
|
|
167
|
+
{
|
|
168
|
+
[super layoutSubviews];
|
|
169
|
+
[self updateTextViewContent];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
- (void)updateTextViewContent
|
|
173
|
+
{
|
|
174
|
+
NSMutableAttributedString *combinedAttributedText = [[NSMutableAttributedString alloc] init];
|
|
175
|
+
[self extractStyledTextFromView:self intoAttributedString:combinedAttributedText hideViews:YES];
|
|
176
|
+
_customTextView.attributedText = combinedAttributedText;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
- (void)extractStyledTextFromView:(UIView *)view intoAttributedString:(NSMutableAttributedString *)attributedString hideViews:(BOOL)hideViews
|
|
180
|
+
{
|
|
181
|
+
BOOL foundText = NO;
|
|
182
|
+
|
|
183
|
+
if ([view respondsToSelector:@selector(attributedText)]) {
|
|
184
|
+
NSAttributedString *attributedText = [view performSelector:@selector(attributedText)];
|
|
185
|
+
if (attributedText && attributedText.length > 0) {
|
|
186
|
+
[attributedString appendAttributedString:attributedText];
|
|
187
|
+
foundText = YES;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else if ([view isKindOfClass:[UILabel class]]) {
|
|
191
|
+
UILabel *label = (UILabel *)view;
|
|
192
|
+
if (label.attributedText && label.attributedText.length > 0) {
|
|
193
|
+
[attributedString appendAttributedString:label.attributedText];
|
|
194
|
+
foundText = YES;
|
|
195
|
+
} else if (label.text && label.text.length > 0) {
|
|
196
|
+
NSAttributedString *plainText = [[NSAttributedString alloc] initWithString:label.text];
|
|
197
|
+
[attributedString appendAttributedString:plainText];
|
|
198
|
+
foundText = YES;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else if ([view respondsToSelector:@selector(text)]) {
|
|
202
|
+
NSString *text = [view performSelector:@selector(text)];
|
|
203
|
+
if (text && text.length > 0) {
|
|
204
|
+
NSAttributedString *plainText = [[NSAttributedString alloc] initWithString:text];
|
|
205
|
+
[attributedString appendAttributedString:plainText];
|
|
206
|
+
foundText = YES;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (foundText && hideViews && view != _customTextView && view != self) {
|
|
211
|
+
view.hidden = YES;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (UIView *subview in view.subviews) {
|
|
215
|
+
if (subview != _customTextView && subview != self.contentView) {
|
|
216
|
+
[self extractStyledTextFromView:subview intoAttributedString:attributedString hideViews:hideViews];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#pragma mark - UITextViewDelegate
|
|
222
|
+
|
|
223
|
+
// ====================================================================
|
|
224
|
+
// A NOVA API DO IOS 16+ (Estável, Rápida e Imune a Crashes)
|
|
225
|
+
// ====================================================================
|
|
226
|
+
- (UIMenu *)textView:(UITextView *)textView editMenuForTextInRange:(NSRange)range suggestedActions:(NSArray<UIMenuElement *> *)suggestedActions API_AVAILABLE(ios(16.0)) {
|
|
227
|
+
NSMutableArray<UIMenuElement *> *customActions = [[NSMutableArray alloc] init];
|
|
228
|
+
|
|
229
|
+
for (NSString *option in _menuOptions) {
|
|
230
|
+
// Mapeia o botão diretamente para nossa função sem precisar de hacks de string
|
|
231
|
+
UIAction *action = [UIAction actionWithTitle:option image:nil identifier:nil handler:^(__kindof UIAction * _Nonnull action) {
|
|
232
|
+
[self handleMenuSelection:option];
|
|
233
|
+
}];
|
|
234
|
+
[customActions addObject:action];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Retorna APENAS o nosso menu customizado, esmagando as opções padrões da Apple
|
|
238
|
+
return [UIMenu menuWithTitle:@"" children:customActions];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
- (void)textViewDidChangeSelection:(UITextView *)textView
|
|
242
|
+
{
|
|
243
|
+
if (@available(iOS 16.0, *)) {
|
|
244
|
+
// No iOS 16+, a Apple cuida da exibição do menu automaticamente via delegate
|
|
245
|
+
return;
|
|
246
|
+
} else {
|
|
247
|
+
// Fallback legado para iPhones antigos (iOS 15 ou menor)
|
|
248
|
+
if (textView.selectedRange.length > 0 && _menuOptions.count > 0) {
|
|
249
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
250
|
+
[self showCustomMenu];
|
|
251
|
+
});
|
|
252
|
+
} else {
|
|
253
|
+
[[UIMenuController sharedMenuController] hideMenuFromView:_customTextView];
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Fallback apenas para iOS 15 e inferiores
|
|
259
|
+
- (void)showCustomMenu
|
|
260
|
+
{
|
|
261
|
+
if (![_customTextView canBecomeFirstResponder]) return;
|
|
262
|
+
|
|
263
|
+
[_customTextView becomeFirstResponder];
|
|
264
|
+
|
|
265
|
+
UIMenuController *menuController = [UIMenuController sharedMenuController];
|
|
266
|
+
menuController.menuItems = nil;
|
|
267
|
+
|
|
268
|
+
NSMutableArray<UIMenuItem *> *menuItems = [[NSMutableArray alloc] init];
|
|
269
|
+
|
|
270
|
+
for (NSString *option in _menuOptions) {
|
|
271
|
+
NSString *clean1 = [option stringByReplacingOccurrencesOfString:@" " withString:@"_"];
|
|
272
|
+
NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"[^a-zA-Z0-9_]" options:0 error:nil];
|
|
273
|
+
NSString *selectorName = [regex stringByReplacingMatchesInString:clean1 options:0 range:NSMakeRange(0, clean1.length) withTemplate:@"_"];
|
|
274
|
+
|
|
275
|
+
SEL action = NSSelectorFromString([NSString stringWithFormat:@"customAction_%@:", selectorName]);
|
|
276
|
+
UIMenuItem *menuItem = [[UIMenuItem alloc] initWithTitle:option action:action];
|
|
277
|
+
[menuItems addObject:menuItem];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
menuController.menuItems = menuItems;
|
|
281
|
+
[menuController update];
|
|
282
|
+
|
|
283
|
+
CGRect selectedRect = [_customTextView firstRectForRange:_customTextView.selectedTextRange];
|
|
284
|
+
|
|
285
|
+
if (!CGRectIsEmpty(selectedRect)) {
|
|
286
|
+
CGRect targetRect = [_customTextView convertRect:selectedRect toView:_customTextView];
|
|
287
|
+
[menuController showMenuFromView:_customTextView rect:targetRect];
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
- (BOOL)canBecomeFirstResponder
|
|
292
|
+
{
|
|
293
|
+
return YES;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender
|
|
297
|
+
{
|
|
298
|
+
NSString *selectorName = NSStringFromSelector(action);
|
|
299
|
+
if ([selectorName hasPrefix:@"customAction_"] && [selectorName hasSuffix:@":"]) {
|
|
300
|
+
return YES;
|
|
301
|
+
}
|
|
302
|
+
return NO;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector
|
|
306
|
+
{
|
|
307
|
+
NSString *selectorName = NSStringFromSelector(aSelector);
|
|
308
|
+
if ([selectorName hasPrefix:@"customAction_"] && [selectorName hasSuffix:@":"]) {
|
|
309
|
+
return [NSMethodSignature signatureWithObjCTypes:"v@:@"];
|
|
310
|
+
}
|
|
311
|
+
return [super methodSignatureForSelector:aSelector];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
- (void)forwardInvocation:(NSInvocation *)anInvocation
|
|
315
|
+
{
|
|
316
|
+
NSString *selectorName = NSStringFromSelector(anInvocation.selector);
|
|
317
|
+
|
|
318
|
+
if ([selectorName hasPrefix:@"customAction_"] && [selectorName hasSuffix:@":"]) {
|
|
319
|
+
NSString *cleanedOption = [selectorName substringWithRange:NSMakeRange(13, selectorName.length - 14)];
|
|
320
|
+
|
|
321
|
+
NSString *originalOption = nil;
|
|
322
|
+
for (NSString *option in _menuOptions) {
|
|
323
|
+
NSString *clean1 = [option stringByReplacingOccurrencesOfString:@" " withString:@"_"];
|
|
324
|
+
NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"[^a-zA-Z0-9_]" options:0 error:nil];
|
|
325
|
+
NSString *testSelectorName = [regex stringByReplacingMatchesInString:clean1 options:0 range:NSMakeRange(0, clean1.length) withTemplate:@"_"];
|
|
326
|
+
|
|
327
|
+
if ([testSelectorName isEqualToString:cleanedOption]) {
|
|
328
|
+
originalOption = option;
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (originalOption) {
|
|
334
|
+
[self handleMenuSelection:originalOption];
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
[super forwardInvocation:anInvocation];
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
- (void)handleMenuSelection:(NSString *)selectedOption
|
|
342
|
+
{
|
|
343
|
+
NSRange selectedRange = _customTextView.selectedRange;
|
|
344
|
+
NSString *selectedText = @"";
|
|
345
|
+
|
|
346
|
+
if (selectedRange.location != NSNotFound && selectedRange.length > 0) {
|
|
347
|
+
selectedText = [_customTextView.text substringWithRange:selectedRange];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
_customTextView.selectedRange = NSMakeRange(0, 0);
|
|
351
|
+
[[UIMenuController sharedMenuController] hideMenuFromView:_customTextView];
|
|
352
|
+
|
|
353
|
+
if (auto eventEmitter = std::static_pointer_cast<const SelectableTextViewEventEmitter>(_eventEmitter)) {
|
|
354
|
+
SelectableTextViewEventEmitter::OnSelection selectionEvent = {
|
|
355
|
+
.chosenOption = std::string([selectedOption UTF8String]),
|
|
356
|
+
.highlightedText = std::string([selectedText UTF8String])
|
|
357
|
+
};
|
|
358
|
+
eventEmitter->onSelection(selectionEvent);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
Class<RCTComponentViewProtocol> SelectableTextViewCls(void)
|
|
363
|
+
{
|
|
364
|
+
return SelectableTextView.class;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
@end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import React, { useRef, useEffect } from 'react';
|
|
4
|
+
import { Platform, findNodeHandle, DeviceEventEmitter } from 'react-native';
|
|
5
|
+
import SelectableTextViewNativeComponent from './SelectableTextViewNativeComponent';
|
|
6
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
7
|
+
export const SelectableTextView = ({
|
|
8
|
+
children,
|
|
9
|
+
menuOptions,
|
|
10
|
+
onSelection,
|
|
11
|
+
style
|
|
12
|
+
}) => {
|
|
13
|
+
const viewRef = useRef(null);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (Platform.OS === 'android' && onSelection) {
|
|
16
|
+
const subscription = DeviceEventEmitter.addListener('SelectableTextSelection', eventData => {
|
|
17
|
+
const viewTag = findNodeHandle(viewRef.current);
|
|
18
|
+
if (viewTag === eventData.viewTag) {
|
|
19
|
+
onSelection({
|
|
20
|
+
chosenOption: eventData.chosenOption,
|
|
21
|
+
highlightedText: eventData.highlightedText
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
return () => subscription.remove();
|
|
26
|
+
}
|
|
27
|
+
return () => {};
|
|
28
|
+
}, [onSelection]);
|
|
29
|
+
const handleSelection = event => {
|
|
30
|
+
if (Platform.OS === 'ios' && onSelection) {
|
|
31
|
+
console.log('SelectableTextView - Direct event received:', event.nativeEvent);
|
|
32
|
+
onSelection(event.nativeEvent);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
return /*#__PURE__*/_jsx(SelectableTextViewNativeComponent, {
|
|
36
|
+
ref: viewRef,
|
|
37
|
+
style: style,
|
|
38
|
+
menuOptions: menuOptions,
|
|
39
|
+
onSelection: Platform.OS === 'ios' ? handleSelection : undefined,
|
|
40
|
+
children: children
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
//# sourceMappingURL=SelectableTextView.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"names":["React","useRef","useEffect","Platform","findNodeHandle","DeviceEventEmitter","SelectableTextViewNativeComponent","jsx","_jsx","SelectableTextView","children","menuOptions","onSelection","style","viewRef","OS","subscription","addListener","eventData","viewTag","current","chosenOption","highlightedText","remove","handleSelection","event","console","log","nativeEvent","ref","undefined"],"sourceRoot":"../../src","sources":["SelectableTextView.tsx"],"mappings":";;AAAA,OAAOA,KAAK,IAAIC,MAAM,EAAEC,SAAS,QAAQ,OAAO;AAEhD,SAASC,QAAQ,EAAEC,cAAc,EAAEC,kBAAkB,QAAQ,cAAc;AAC3E,OAAOC,iCAAiC,MAEjC,qCAAqC;AAAC,SAAAC,GAAA,IAAAC,IAAA;AAS7C,OAAO,MAAMC,kBAAqD,GAAGA,CAAC;EAClEC,QAAQ;EACRC,WAAW;EACXC,WAAW;EACXC;AACJ,CAAC,KAAK;EACF,MAAMC,OAAO,GAAGb,MAAM,CAAM,IAAI,CAAC;EAEjCC,SAAS,CAAC,MAAM;IACZ,IAAIC,QAAQ,CAACY,EAAE,KAAK,SAAS,IAAIH,WAAW,EAAE;MAC1C,MAAMI,YAAY,GAAGX,kBAAkB,CAACY,WAAW,CAC/C,yBAAyB,EACxBC,SAIA,IAAK;QACF,MAAMC,OAAO,GAAGf,cAAc,CAACU,OAAO,CAACM,OAAO,CAAC;QAC/C,IAAID,OAAO,KAAKD,SAAS,CAACC,OAAO,EAAE;UAC/BP,WAAW,CAAC;YACRS,YAAY,EAAEH,SAAS,CAACG,YAAY;YACpCC,eAAe,EAAEJ,SAAS,CAACI;UAC/B,CAAC,CAAC;QACN;MACJ,CACJ,CAAC;MAED,OAAO,MAAMN,YAAY,CAACO,MAAM,CAAC,CAAC;IACtC;IACA,OAAO,MAAM,CAAE,CAAC;EACpB,CAAC,EAAE,CAACX,WAAW,CAAC,CAAC;EAEjB,MAAMY,eAAe,GAAIC,KAA2C,IAAK;IACrE,IAAItB,QAAQ,CAACY,EAAE,KAAK,KAAK,IAAIH,WAAW,EAAE;MACtCc,OAAO,CAACC,GAAG,CACP,6CAA6C,EAC7CF,KAAK,CAACG,WACV,CAAC;MACDhB,WAAW,CAACa,KAAK,CAACG,WAAW,CAAC;IAClC;EACJ,CAAC;EAED,oBACIpB,IAAA,CAACF,iCAAiC;IAC9BuB,GAAG,EAAEf,OAAQ;IACbD,KAAK,EAAEA,KAAM;IACbF,WAAW,EAAEA,WAAY;IACzBC,WAAW,EAAET,QAAQ,CAACY,EAAE,KAAK,KAAK,GAAGS,eAAe,GAAGM,SAAU;IAAApB,QAAA,EAEhEA;EAAQ,CACsB,CAAC;AAE5C,CAAC","ignoreList":[]}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { codegenNativeComponent, type ViewProps } from 'react-native';
|
|
2
|
+
import type { DirectEventHandler } from 'react-native/Libraries/Types/CodegenTypesNamespace';
|
|
3
|
+
|
|
4
|
+
export interface SelectionEvent {
|
|
5
|
+
chosenOption: string;
|
|
6
|
+
highlightedText: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface NativeProps extends ViewProps {
|
|
10
|
+
menuOptions: readonly string[];
|
|
11
|
+
onSelection?: DirectEventHandler<SelectionEvent>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default codegenNativeComponent<NativeProps>('SelectableTextView');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"names":["SelectableTextView"],"sourceRoot":"../../src","sources":["index.tsx"],"mappings":";;AAAA,SAASA,kBAAkB,QAAQ,yBAAsB","ignoreList":[]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"module"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"module"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { ViewStyle } from 'react-native';
|
|
3
|
+
import { type SelectionEvent } from './SelectableTextViewNativeComponent';
|
|
4
|
+
interface SelectableTextViewProps {
|
|
5
|
+
children: React.ReactNode;
|
|
6
|
+
menuOptions: string[];
|
|
7
|
+
onSelection?: (event: SelectionEvent) => void;
|
|
8
|
+
style?: ViewStyle;
|
|
9
|
+
}
|
|
10
|
+
export declare const SelectableTextView: React.FC<SelectableTextViewProps>;
|
|
11
|
+
export {};
|
|
12
|
+
//# sourceMappingURL=SelectableTextView.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SelectableTextView.d.ts","sourceRoot":"","sources":["../../../src/SelectableTextView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4B,MAAM,OAAO,CAAC;AACjD,OAAO,KAAK,EAAE,SAAS,EAAwB,MAAM,cAAc,CAAC;AAEpE,OAA0C,EACtC,KAAK,cAAc,EACtB,MAAM,qCAAqC,CAAC;AAE7C,UAAU,uBAAuB;IAC7B,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,WAAW,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;IAC9C,KAAK,CAAC,EAAE,SAAS,CAAC;CACrB;AAED,eAAO,MAAM,kBAAkB,EAAE,KAAK,CAAC,EAAE,CAAC,uBAAuB,CAoDhE,CAAC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ViewProps } from 'react-native';
|
|
2
|
+
import type { DirectEventHandler } from 'react-native/Libraries/Types/CodegenTypesNamespace';
|
|
3
|
+
export interface SelectionEvent {
|
|
4
|
+
chosenOption: string;
|
|
5
|
+
highlightedText: string;
|
|
6
|
+
}
|
|
7
|
+
interface NativeProps extends ViewProps {
|
|
8
|
+
menuOptions: readonly string[];
|
|
9
|
+
onSelection?: DirectEventHandler<SelectionEvent>;
|
|
10
|
+
}
|
|
11
|
+
declare const _default: import("react-native/types_generated/Libraries/Utilities/codegenNativeComponent").NativeComponentType<NativeProps>;
|
|
12
|
+
export default _default;
|
|
13
|
+
//# sourceMappingURL=SelectableTextViewNativeComponent.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SelectableTextViewNativeComponent.d.ts","sourceRoot":"","sources":["../../../src/SelectableTextViewNativeComponent.ts"],"names":[],"mappings":"AAAA,OAAO,EAA0B,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AACtE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,oDAAoD,CAAC;AAE7F,MAAM,WAAW,cAAc;IAC3B,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;CAC3B;AAED,UAAU,WAAY,SAAQ,SAAS;IACnC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IAC/B,WAAW,CAAC,EAAE,kBAAkB,CAAC,cAAc,CAAC,CAAC;CACpD;;AAED,wBAAyE"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thiagobueno/rn-selectable-text",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "A library for custom text selection menus",
|
|
6
|
+
"main": "./lib/module/index.js",
|
|
7
|
+
"types": "./lib/typescript/src/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"source": "./src/index.tsx",
|
|
11
|
+
"types": "./lib/typescript/src/index.d.ts",
|
|
12
|
+
"default": "./lib/module/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"lib",
|
|
19
|
+
"android",
|
|
20
|
+
"ios",
|
|
21
|
+
"cpp",
|
|
22
|
+
"*.podspec",
|
|
23
|
+
"react-native.config.js",
|
|
24
|
+
"!ios/build",
|
|
25
|
+
"!android/build",
|
|
26
|
+
"!android/gradle",
|
|
27
|
+
"!android/gradlew",
|
|
28
|
+
"!android/gradlew.bat",
|
|
29
|
+
"!android/local.properties",
|
|
30
|
+
"!**/__tests__",
|
|
31
|
+
"!**/__fixtures__",
|
|
32
|
+
"!**/__mocks__",
|
|
33
|
+
"!**/.*"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"example": "yarn workspace rn-selectable-text-example",
|
|
37
|
+
"test": "jest",
|
|
38
|
+
"typecheck": "tsc",
|
|
39
|
+
"lint": "eslint \"**/*.{js,ts,tsx}\"",
|
|
40
|
+
"clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib",
|
|
41
|
+
"prepare": "bob build",
|
|
42
|
+
"release": "release-it --only-version"
|
|
43
|
+
},
|
|
44
|
+
"keywords": [
|
|
45
|
+
"react-native",
|
|
46
|
+
"ios",
|
|
47
|
+
"android"
|
|
48
|
+
],
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/thiagobueno/rn-selectable-text.git"
|
|
52
|
+
},
|
|
53
|
+
"author": "Thiago Bueno (https://github.com/thiagobueno)",
|
|
54
|
+
"license": "MIT",
|
|
55
|
+
"bugs": {
|
|
56
|
+
"url": "https://github.com/thiagobueno/rn-selectable-text/issues"
|
|
57
|
+
},
|
|
58
|
+
"homepage": "https://github.com/thiagobueno/rn-selectable-text#readme",
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"registry": "https://registry.npmjs.org/"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@commitlint/config-conventional": "^19.8.1",
|
|
64
|
+
"@eslint/compat": "^1.3.2",
|
|
65
|
+
"@eslint/eslintrc": "^3.3.1",
|
|
66
|
+
"@eslint/js": "^9.35.0",
|
|
67
|
+
"@evilmartians/lefthook": "^1.12.3",
|
|
68
|
+
"@react-native-community/cli": "20.0.1",
|
|
69
|
+
"@react-native/babel-preset": "0.81.1",
|
|
70
|
+
"@react-native/eslint-config": "^0.81.1",
|
|
71
|
+
"@release-it/conventional-changelog": "^10.0.1",
|
|
72
|
+
"@types/jest": "^29.5.14",
|
|
73
|
+
"@types/react": "^19.1.0",
|
|
74
|
+
"commitlint": "^19.8.1",
|
|
75
|
+
"del-cli": "^6.0.0",
|
|
76
|
+
"eslint": "^9.35.0",
|
|
77
|
+
"eslint-config-prettier": "^10.1.8",
|
|
78
|
+
"eslint-plugin-prettier": "^5.5.4",
|
|
79
|
+
"jest": "^29.7.0",
|
|
80
|
+
"prettier": "^3.6.2",
|
|
81
|
+
"react": "19.1.0",
|
|
82
|
+
"react-native": "0.81.1",
|
|
83
|
+
"react-native-builder-bob": "^0.40.13",
|
|
84
|
+
"release-it": "^19.0.4",
|
|
85
|
+
"turbo": "^2.5.6",
|
|
86
|
+
"typescript": "^5.9.2"
|
|
87
|
+
},
|
|
88
|
+
"peerDependencies": {
|
|
89
|
+
"react": "*",
|
|
90
|
+
"react-native": "*"
|
|
91
|
+
},
|
|
92
|
+
"workspaces": [
|
|
93
|
+
"example"
|
|
94
|
+
],
|
|
95
|
+
"packageManager": "yarn@3.6.1",
|
|
96
|
+
"jest": {
|
|
97
|
+
"preset": "react-native",
|
|
98
|
+
"modulePathIgnorePatterns": [
|
|
99
|
+
"<rootDir>/example/node_modules",
|
|
100
|
+
"<rootDir>/lib/"
|
|
101
|
+
]
|
|
102
|
+
},
|
|
103
|
+
"commitlint": {
|
|
104
|
+
"extends": [
|
|
105
|
+
"@commitlint/config-conventional"
|
|
106
|
+
]
|
|
107
|
+
},
|
|
108
|
+
"release-it": {
|
|
109
|
+
"git": {
|
|
110
|
+
"commitMessage": "chore: release ${version}",
|
|
111
|
+
"tagName": "v${version}"
|
|
112
|
+
},
|
|
113
|
+
"npm": {
|
|
114
|
+
"publish": true
|
|
115
|
+
},
|
|
116
|
+
"github": {
|
|
117
|
+
"release": true
|
|
118
|
+
},
|
|
119
|
+
"plugins": {
|
|
120
|
+
"@release-it/conventional-changelog": {
|
|
121
|
+
"preset": {
|
|
122
|
+
"name": "angular"
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
"prettier": {
|
|
128
|
+
"quoteProps": "consistent",
|
|
129
|
+
"singleQuote": true,
|
|
130
|
+
"tabWidth": 2,
|
|
131
|
+
"trailingComma": "es5",
|
|
132
|
+
"useTabs": false
|
|
133
|
+
},
|
|
134
|
+
"react-native-builder-bob": {
|
|
135
|
+
"source": "src",
|
|
136
|
+
"output": "lib",
|
|
137
|
+
"targets": [
|
|
138
|
+
[
|
|
139
|
+
"module",
|
|
140
|
+
{
|
|
141
|
+
"esm": true
|
|
142
|
+
}
|
|
143
|
+
],
|
|
144
|
+
[
|
|
145
|
+
"typescript",
|
|
146
|
+
{
|
|
147
|
+
"project": "tsconfig.build.json"
|
|
148
|
+
}
|
|
149
|
+
]
|
|
150
|
+
]
|
|
151
|
+
},
|
|
152
|
+
"codegenConfig": {
|
|
153
|
+
"name": "SelectableTextViewSpec",
|
|
154
|
+
"type": "all",
|
|
155
|
+
"jsSrcsDir": "src",
|
|
156
|
+
"android": {
|
|
157
|
+
"javaPackageName": "com.selectabletext"
|
|
158
|
+
},
|
|
159
|
+
"ios": {
|
|
160
|
+
"componentProvider": {
|
|
161
|
+
"SelectableTextView": "SelectableTextView"
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
"create-react-native-library": {
|
|
166
|
+
"languages": "kotlin-objc",
|
|
167
|
+
"type": "fabric-view",
|
|
168
|
+
"version": "0.54.2"
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = "rn-selectable-text"
|
|
7
|
+
s.version = package["version"]
|
|
8
|
+
s.summary = package["description"]
|
|
9
|
+
s.homepage = package["homepage"]
|
|
10
|
+
s.license = package["license"]
|
|
11
|
+
s.authors = package["author"]
|
|
12
|
+
|
|
13
|
+
s.platforms = { :ios => min_ios_version_supported }
|
|
14
|
+
s.source = { :git => "https://github.com/thiagobueno/rn-selectable-text.git", :tag => "#{s.version}" }
|
|
15
|
+
|
|
16
|
+
s.source_files = "ios/**/*.{h,m,mm,cpp}"
|
|
17
|
+
s.private_header_files = "ios/**/*.h"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
install_modules_dependencies(s)
|
|
21
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import React, { useRef, useEffect } from 'react';
|
|
2
|
+
import type { ViewStyle, NativeSyntheticEvent } from 'react-native';
|
|
3
|
+
import { Platform, findNodeHandle, DeviceEventEmitter } from 'react-native';
|
|
4
|
+
import SelectableTextViewNativeComponent, {
|
|
5
|
+
type SelectionEvent,
|
|
6
|
+
} from './SelectableTextViewNativeComponent';
|
|
7
|
+
|
|
8
|
+
interface SelectableTextViewProps {
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
menuOptions: string[];
|
|
11
|
+
onSelection?: (event: SelectionEvent) => void;
|
|
12
|
+
style?: ViewStyle;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const SelectableTextView: React.FC<SelectableTextViewProps> = ({
|
|
16
|
+
children,
|
|
17
|
+
menuOptions,
|
|
18
|
+
onSelection,
|
|
19
|
+
style,
|
|
20
|
+
}) => {
|
|
21
|
+
const viewRef = useRef<any>(null);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (Platform.OS === 'android' && onSelection) {
|
|
25
|
+
const subscription = DeviceEventEmitter.addListener(
|
|
26
|
+
'SelectableTextSelection',
|
|
27
|
+
(eventData: {
|
|
28
|
+
viewTag: number;
|
|
29
|
+
chosenOption: string;
|
|
30
|
+
highlightedText: string;
|
|
31
|
+
}) => {
|
|
32
|
+
const viewTag = findNodeHandle(viewRef.current);
|
|
33
|
+
if (viewTag === eventData.viewTag) {
|
|
34
|
+
onSelection({
|
|
35
|
+
chosenOption: eventData.chosenOption,
|
|
36
|
+
highlightedText: eventData.highlightedText,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return () => subscription.remove();
|
|
43
|
+
}
|
|
44
|
+
return () => { };
|
|
45
|
+
}, [onSelection]);
|
|
46
|
+
|
|
47
|
+
const handleSelection = (event: NativeSyntheticEvent<SelectionEvent>) => {
|
|
48
|
+
if (Platform.OS === 'ios' && onSelection) {
|
|
49
|
+
console.log(
|
|
50
|
+
'SelectableTextView - Direct event received:',
|
|
51
|
+
event.nativeEvent
|
|
52
|
+
);
|
|
53
|
+
onSelection(event.nativeEvent);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<SelectableTextViewNativeComponent
|
|
59
|
+
ref={viewRef}
|
|
60
|
+
style={style}
|
|
61
|
+
menuOptions={menuOptions}
|
|
62
|
+
onSelection={Platform.OS === 'ios' ? handleSelection : undefined}
|
|
63
|
+
>
|
|
64
|
+
{children}
|
|
65
|
+
</SelectableTextViewNativeComponent>
|
|
66
|
+
);
|
|
67
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { codegenNativeComponent, type ViewProps } from 'react-native';
|
|
2
|
+
import type { DirectEventHandler } from 'react-native/Libraries/Types/CodegenTypesNamespace';
|
|
3
|
+
|
|
4
|
+
export interface SelectionEvent {
|
|
5
|
+
chosenOption: string;
|
|
6
|
+
highlightedText: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface NativeProps extends ViewProps {
|
|
10
|
+
menuOptions: readonly string[];
|
|
11
|
+
onSelection?: DirectEventHandler<SelectionEvent>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default codegenNativeComponent<NativeProps>('SelectableTextView');
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { SelectableTextView } from './SelectableTextView';
|