@azsxdc12356/react-native-sync-format-edittext 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 +130 -0
- package/SyncFormatEdittext.podspec +20 -0
- package/android/build.gradle +105 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/CMakeLists.txt +28 -0
- package/android/src/main/cpp/FormatHostObject.cpp +117 -0
- package/android/src/main/cpp/FormatHostObject.h +38 -0
- package/android/src/main/cpp/FormatModuleJNI.cpp +80 -0
- package/android/src/main/java/com/syncformatedittext/FormatModuleImpl.kt +77 -0
- package/android/src/main/java/com/syncformatedittext/SyncFormatEdittextView.kt +70 -0
- package/android/src/newarch/java/com/syncformatedittext/FormatModule.kt +19 -0
- package/android/src/newarch/java/com/syncformatedittext/SyncFormatEditTextChangeEvent.kt +22 -0
- package/android/src/newarch/java/com/syncformatedittext/SyncFormatEdittextPackage.kt +37 -0
- package/android/src/newarch/java/com/syncformatedittext/SyncFormatEdittextViewManager.kt +61 -0
- package/android/src/oldarch/java/com/syncformatedittext/FormatModule.kt +23 -0
- package/android/src/oldarch/java/com/syncformatedittext/SyncFormatEditTextChangeEvent.kt +21 -0
- package/android/src/oldarch/java/com/syncformatedittext/SyncFormatEdittextPackage.kt +18 -0
- package/android/src/oldarch/java/com/syncformatedittext/SyncFormatEdittextViewManager.kt +60 -0
- package/ios/SyncFormatEdittextView.h +14 -0
- package/ios/SyncFormatEdittextView.mm +48 -0
- package/lib/module/NativeFormatModule.js +5 -0
- package/lib/module/NativeFormatModule.js.map +1 -0
- package/lib/module/SyncFormatEdittextView.js +14 -0
- package/lib/module/SyncFormatEdittextView.js.map +1 -0
- package/lib/module/SyncFormatEdittextView.native.js +49 -0
- package/lib/module/SyncFormatEdittextView.native.js.map +1 -0
- package/lib/module/SyncFormatEdittextViewNativeComponent.ts +198 -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/NativeFormatModule.d.ts +7 -0
- package/lib/typescript/src/NativeFormatModule.d.ts.map +1 -0
- package/lib/typescript/src/SyncFormatEdittextView.d.ts +3 -0
- package/lib/typescript/src/SyncFormatEdittextView.d.ts.map +1 -0
- package/lib/typescript/src/SyncFormatEdittextView.native.d.ts +12 -0
- package/lib/typescript/src/SyncFormatEdittextView.native.d.ts.map +1 -0
- package/lib/typescript/src/SyncFormatEdittextViewNativeComponent.d.ts +139 -0
- package/lib/typescript/src/SyncFormatEdittextViewNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +3 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/package.json +181 -0
- package/src/NativeFormatModule.ts +8 -0
- package/src/SyncFormatEdittextView.native.tsx +81 -0
- package/src/SyncFormatEdittextView.tsx +6 -0
- package/src/SyncFormatEdittextViewNativeComponent.ts +198 -0
- package/src/index.tsx +2 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 azsxdc12356
|
|
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,130 @@
|
|
|
1
|
+
# react-native-sync-format-edittext
|
|
2
|
+
|
|
3
|
+
React Native 同步格式化输入框 — 无闪烁的实时文本格式化
|
|
4
|
+
|
|
5
|
+
## 功能介绍
|
|
6
|
+
|
|
7
|
+
在 React Native 中对 TextInput 做实时格式化(如手机号 `138-0013-8000`),通常需要监听 `onChangeText`,在 JS 层格式化后再通过 `value` 回写。这个过程中文本要经过异步桥接往返,导致输入时出现明显的闪烁和光标跳动。
|
|
8
|
+
|
|
9
|
+
本库通过 **JSI 同步调用**,让原生层在文本变化时直接同步调用 JS 的格式化函数,格式化结果立即生效,彻底消除闪烁。同时继承自原生 TextInput,支持所有原生 props。
|
|
10
|
+
|
|
11
|
+
### 平台支持
|
|
12
|
+
|
|
13
|
+
| 平台 | 状态 |
|
|
14
|
+
|---|---|
|
|
15
|
+
| Android | ✅ 已支持 |
|
|
16
|
+
| iOS | 🚧 开发中 |
|
|
17
|
+
|
|
18
|
+
## 演示效果
|
|
19
|
+
|
|
20
|
+
**原生 TextInput 格式化 — 闪烁明显**
|
|
21
|
+
|
|
22
|
+

|
|
23
|
+
|
|
24
|
+
**SyncFormatEditText — 无闪烁**
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
## 安装
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npm install @azsxdc12356/react-native-sync-format-edittext
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
支持新架构和旧架构,autolink 自动完成链接。
|
|
35
|
+
|
|
36
|
+
## 使用
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import { SyncFormatEdittextView } from '@azsxdc12356/react-native-sync-format-edittext';
|
|
40
|
+
|
|
41
|
+
// 只允许输入数字
|
|
42
|
+
function formatDigits(text: string, cursorPos: number) {
|
|
43
|
+
const digits = text.replace(/\D/g, '');
|
|
44
|
+
const removedBeforeCursor = text.slice(0, cursorPos).replace(/\d/g, '').length;
|
|
45
|
+
return {
|
|
46
|
+
text: digits,
|
|
47
|
+
cursorPos: cursorPos - removedBeforeCursor,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
<SyncFormatEdittextView
|
|
52
|
+
value={code}
|
|
53
|
+
format={formatDigits}
|
|
54
|
+
onChangeText={setCode}
|
|
55
|
+
placeholder="请输入验证码"
|
|
56
|
+
style={styles.input}
|
|
57
|
+
/>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## API
|
|
61
|
+
|
|
62
|
+
### Props
|
|
63
|
+
|
|
64
|
+
继承所有 `TextInputProps`,额外支持以下 props:
|
|
65
|
+
|
|
66
|
+
| Prop | 类型 | 说明 |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `format` | `(text: string, cursorPos: number) => { text: string; cursorPos: number }` | 格式化函数,接收当前文本和光标位置,返回格式化后的文本和调整后的光标位置 |
|
|
69
|
+
| `onSyncFormatChange` | `(text: string, cursorPos: number) => void` | 格式化完成后的回调,返回格式化后的文本和光标位置 |
|
|
70
|
+
|
|
71
|
+
### `format` 函数详解
|
|
72
|
+
|
|
73
|
+
`cursorPos` 是字符在字符串中的索引(从 0 开始)。格式化后文本长度可能改变,光标位置需要相应调整,否则会跳到错误位置。
|
|
74
|
+
|
|
75
|
+
**原理**:`cursorPos` 表示"光标在第几个字符前面"。格式化后,你需要计算光标在格式化文本中的对应位置。如果格式化只是过滤字符(文本变短),光标位置 = 原位置 - 光标前被过滤掉的字符数;如果格式化插入了分隔符(文本变长),还需要再加上光标前新增的分隔符数量。
|
|
76
|
+
|
|
77
|
+
**示例 1:只过滤,不插入字符**
|
|
78
|
+
|
|
79
|
+
输入 `a1b2`,光标在末尾(cursorPos=4)。过滤非数字后得到 `12`,光标前被过滤了 2 个字符,所以 cursorPos=4-2=2。
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
function formatDigits(text: string, cursorPos: number) {
|
|
83
|
+
const digits = text.replace(/\D/g, '');
|
|
84
|
+
const removedBeforeCursor = text.slice(0, cursorPos).replace(/\d/g, '').length;
|
|
85
|
+
return {
|
|
86
|
+
text: digits,
|
|
87
|
+
cursorPos: cursorPos - removedBeforeCursor,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
过滤不改变字符顺序,光标位置 = 原位置 - 光标前被过滤掉的字符数。
|
|
93
|
+
|
|
94
|
+
**示例 2:插入分隔符**
|
|
95
|
+
|
|
96
|
+
输入 `1380013`,光标在末尾(cursorPos=7)。格式化为 `138-0013`,光标前多了一个 `-`,所以 cursorPos=8。
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
function formatPhone(text: string, cursorPos: number) {
|
|
100
|
+
const beforeCursor = text.slice(0, cursorPos);
|
|
101
|
+
const removedBeforeCursor = beforeCursor.replace(/\d/g, '').length;
|
|
102
|
+
const adjustedPos = cursorPos - removedBeforeCursor;
|
|
103
|
+
|
|
104
|
+
const digits = text.replace(/\D/g, '').slice(0, 11);
|
|
105
|
+
let formatted = '';
|
|
106
|
+
let newCursorPos = adjustedPos;
|
|
107
|
+
if (digits.length <= 3) {
|
|
108
|
+
formatted = digits;
|
|
109
|
+
} else if (digits.length <= 7) {
|
|
110
|
+
formatted = `${digits.slice(0, 3)}-${digits.slice(3)}`;
|
|
111
|
+
if (adjustedPos > 3) newCursorPos = adjustedPos + 1;
|
|
112
|
+
} else {
|
|
113
|
+
formatted = `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
|
|
114
|
+
if (adjustedPos > 3) newCursorPos = adjustedPos + 1;
|
|
115
|
+
if (adjustedPos > 7) newCursorPos = adjustedPos + 2;
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
text: formatted,
|
|
119
|
+
cursorPos: Math.min(newCursorPos, formatted.length),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
关键逻辑:先用 `cursorPos - removedBeforeCursor` 得到纯数字中的位置,再根据分隔符偏移。光标每跨过一个分隔符位置,`cursorPos` 就 +1。
|
|
125
|
+
|
|
126
|
+
更多示例参见 [example](./example/src/App.tsx)。
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT
|
|
@@ -0,0 +1,20 @@
|
|
|
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 = "SyncFormatEdittext"
|
|
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/junjie-z666/react-native-sync-format-edittext.git", :tag => "#{s.version}" }
|
|
15
|
+
|
|
16
|
+
s.source_files = "ios/**/*.{h,m,mm,swift,cpp}"
|
|
17
|
+
s.private_header_files = "ios/**/*.h"
|
|
18
|
+
|
|
19
|
+
install_modules_dependencies(s)
|
|
20
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
buildscript {
|
|
2
|
+
ext.SyncFormatEdittext = [
|
|
3
|
+
kotlinVersion: "2.0.21",
|
|
4
|
+
minSdkVersion: 24,
|
|
5
|
+
compileSdkVersion: 36,
|
|
6
|
+
targetSdkVersion: 36
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
ext.getExtOrDefault = { prop ->
|
|
10
|
+
if (rootProject.ext.has(prop)) {
|
|
11
|
+
return rootProject.ext.get(prop)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return SyncFormatEdittext[prop]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
repositories {
|
|
18
|
+
google()
|
|
19
|
+
mavenCentral()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
dependencies {
|
|
23
|
+
classpath "com.android.tools.build:gradle:8.7.2"
|
|
24
|
+
// noinspection DifferentKotlinGradleVersion
|
|
25
|
+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
apply plugin: "com.android.library"
|
|
31
|
+
apply plugin: "kotlin-android"
|
|
32
|
+
|
|
33
|
+
apply plugin: "com.facebook.react"
|
|
34
|
+
|
|
35
|
+
android {
|
|
36
|
+
namespace "com.syncformatedittext"
|
|
37
|
+
|
|
38
|
+
compileSdkVersion getExtOrDefault("compileSdkVersion")
|
|
39
|
+
|
|
40
|
+
defaultConfig {
|
|
41
|
+
minSdkVersion getExtOrDefault("minSdkVersion")
|
|
42
|
+
targetSdkVersion getExtOrDefault("targetSdkVersion")
|
|
43
|
+
|
|
44
|
+
externalNativeBuild {
|
|
45
|
+
cmake {
|
|
46
|
+
arguments "-DANDROID_STL=c++_shared"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
buildFeatures {
|
|
52
|
+
buildConfig true
|
|
53
|
+
prefab true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
buildTypes {
|
|
57
|
+
release {
|
|
58
|
+
minifyEnabled false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
lint {
|
|
63
|
+
disable "GradleCompatible"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
compileOptions {
|
|
67
|
+
sourceCompatibility JavaVersion.VERSION_1_8
|
|
68
|
+
targetCompatibility JavaVersion.VERSION_1_8
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
sourceSets {
|
|
72
|
+
if (rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true") {
|
|
73
|
+
main.java.srcDirs += "src/newarch/java"
|
|
74
|
+
} else {
|
|
75
|
+
main.java.srcDirs += "src/oldarch/java"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
externalNativeBuild {
|
|
80
|
+
cmake {
|
|
81
|
+
path "src/main/cpp/CMakeLists.txt"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
packaging {
|
|
86
|
+
jniLibs {
|
|
87
|
+
excludes += [
|
|
88
|
+
"**/libfbjni.so",
|
|
89
|
+
"**/libjsi.so",
|
|
90
|
+
"**/libhermes.so",
|
|
91
|
+
"**/libreactnativejni.so",
|
|
92
|
+
"**/libreactnative.so",
|
|
93
|
+
"**/libjscexecutor.so",
|
|
94
|
+
"**/libhermestooling.so",
|
|
95
|
+
"**/libfolly_runtime.so",
|
|
96
|
+
"**/libglog.so",
|
|
97
|
+
"**/libturbomodulejsijni.so"
|
|
98
|
+
]
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
dependencies {
|
|
104
|
+
implementation "com.facebook.react:react-android"
|
|
105
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
cmake_minimum_required(VERSION 3.13)
|
|
2
|
+
project(syncformatedittext)
|
|
3
|
+
|
|
4
|
+
set(CMAKE_VERBOSE_MAKEFILE on)
|
|
5
|
+
set(CMAKE_CXX_STANDARD 20)
|
|
6
|
+
|
|
7
|
+
# Find React Native Prefab packages
|
|
8
|
+
find_package(fbjni REQUIRED CONFIG)
|
|
9
|
+
find_package(ReactAndroid REQUIRED CONFIG)
|
|
10
|
+
|
|
11
|
+
# Source files
|
|
12
|
+
add_library(syncformatedittext SHARED
|
|
13
|
+
FormatHostObject.cpp
|
|
14
|
+
FormatModuleJNI.cpp
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
target_include_directories(syncformatedittext PRIVATE
|
|
18
|
+
${CMAKE_CURRENT_SOURCE_DIR}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
target_link_libraries(syncformatedittext
|
|
22
|
+
fbjni::fbjni
|
|
23
|
+
ReactAndroid::jsi
|
|
24
|
+
ReactAndroid::reactnative
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
target_compile_options(syncformatedittext PRIVATE -fvisibility=hidden)
|
|
28
|
+
target_link_options(syncformatedittext PRIVATE "-Wl,-z,max-page-size=16384")
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#include "FormatHostObject.h"
|
|
2
|
+
#include <sstream>
|
|
3
|
+
#include <vector>
|
|
4
|
+
|
|
5
|
+
namespace facebook::react {
|
|
6
|
+
|
|
7
|
+
FormatHostObject* FormatHostObject::instance_ = nullptr;
|
|
8
|
+
|
|
9
|
+
FormatHostObject::FormatHostObject(std::shared_ptr<CallInvoker> callInvoker)
|
|
10
|
+
: callInvoker_(std::move(callInvoker)) {}
|
|
11
|
+
|
|
12
|
+
jsi::Value FormatHostObject::get(jsi::Runtime& rt, const jsi::PropNameID& name) {
|
|
13
|
+
auto methodName = name.utf8(rt);
|
|
14
|
+
|
|
15
|
+
if (methodName == "setFormat") {
|
|
16
|
+
auto self = shared_from_this();
|
|
17
|
+
return jsi::Function::createFromHostFunction(
|
|
18
|
+
rt, name, 2,
|
|
19
|
+
[self](jsi::Runtime& rt, const jsi::Value&, const jsi::Value* args, size_t count) -> jsi::Value {
|
|
20
|
+
if (count < 2) {
|
|
21
|
+
throw jsi::JSError(rt, "setFormat requires 2 arguments: viewTag, formatFn");
|
|
22
|
+
}
|
|
23
|
+
int viewTag = static_cast<int>(args[0].asNumber());
|
|
24
|
+
auto fn = std::make_shared<jsi::Function>(args[1].asObject(rt).asFunction(rt));
|
|
25
|
+
|
|
26
|
+
std::lock_guard<std::mutex> lock(self->mutex_);
|
|
27
|
+
self->formatFns_[viewTag] = std::move(fn);
|
|
28
|
+
return jsi::Value::undefined();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (methodName == "removeFormat") {
|
|
33
|
+
auto self = shared_from_this();
|
|
34
|
+
return jsi::Function::createFromHostFunction(
|
|
35
|
+
rt, name, 1,
|
|
36
|
+
[self](jsi::Runtime& rt, const jsi::Value&, const jsi::Value* args, size_t count) -> jsi::Value {
|
|
37
|
+
if (count < 1) {
|
|
38
|
+
throw jsi::JSError(rt, "removeFormat requires 1 argument: viewTag");
|
|
39
|
+
}
|
|
40
|
+
int viewTag = static_cast<int>(args[0].asNumber());
|
|
41
|
+
self->removeFormat(viewTag);
|
|
42
|
+
return jsi::Value::undefined();
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return jsi::Value::undefined();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
void FormatHostObject::set(jsi::Runtime&, const jsi::PropNameID&, const jsi::Value&) {}
|
|
50
|
+
|
|
51
|
+
void FormatHostObject::removeFormat(int viewTag) {
|
|
52
|
+
std::lock_guard<std::mutex> lock(mutex_);
|
|
53
|
+
formatFns_.erase(viewTag);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
bool FormatHostObject::hasFormat(int viewTag) {
|
|
57
|
+
std::lock_guard<std::mutex> lock(mutex_);
|
|
58
|
+
return formatFns_.find(viewTag) != formatFns_.end();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
std::string FormatHostObject::formatText(int viewTag, const std::string& text, int cursorPos) {
|
|
62
|
+
// Copy shared_ptr OUTSIDE invokeSync to avoid deadlock
|
|
63
|
+
std::shared_ptr<jsi::Function> fnPtr;
|
|
64
|
+
{
|
|
65
|
+
std::lock_guard<std::mutex> lock(mutex_);
|
|
66
|
+
auto it = formatFns_.find(viewTag);
|
|
67
|
+
if (it != formatFns_.end()) {
|
|
68
|
+
fnPtr = it->second;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// mutex_ released here — JS thread won't block
|
|
72
|
+
|
|
73
|
+
if (!fnPtr) {
|
|
74
|
+
std::ostringstream oss;
|
|
75
|
+
oss << "{\"text\":\"" << text << "\",\"cursorPos\":" << cursorPos << "}";
|
|
76
|
+
return oss.str();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
std::string resultJson;
|
|
80
|
+
|
|
81
|
+
callInvoker_->invokeSync([&resultJson, &text, cursorPos, fnPtr](jsi::Runtime& runtime) {
|
|
82
|
+
try {
|
|
83
|
+
auto result = fnPtr->call(runtime, {
|
|
84
|
+
jsi::String::createFromUtf8(runtime, text),
|
|
85
|
+
jsi::Value(cursorPos)});
|
|
86
|
+
|
|
87
|
+
if (result.isObject()) {
|
|
88
|
+
auto obj = result.asObject(runtime);
|
|
89
|
+
auto resultText = obj.getProperty(runtime, "text").asString(runtime).utf8(runtime);
|
|
90
|
+
auto resultCursor = static_cast<int>(obj.getProperty(runtime, "cursorPos").asNumber());
|
|
91
|
+
|
|
92
|
+
std::ostringstream oss;
|
|
93
|
+
oss << "{\"text\":\"";
|
|
94
|
+
for (char c : resultText) {
|
|
95
|
+
switch (c) {
|
|
96
|
+
case '"': oss << "\\\""; break;
|
|
97
|
+
case '\\': oss << "\\\\"; break;
|
|
98
|
+
case '\n': oss << "\\n"; break;
|
|
99
|
+
case '\r': oss << "\\r"; break;
|
|
100
|
+
case '\t': oss << "\\t"; break;
|
|
101
|
+
default: oss << c;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
oss << "\",\"cursorPos\":" << resultCursor << "}";
|
|
105
|
+
resultJson = oss.str();
|
|
106
|
+
} else {
|
|
107
|
+
resultJson = "{\"text\":\"" + text + "\",\"cursorPos\":" + std::to_string(cursorPos) + "}";
|
|
108
|
+
}
|
|
109
|
+
} catch (...) {
|
|
110
|
+
resultJson = "{\"text\":\"" + text + "\",\"cursorPos\":" + std::to_string(cursorPos) + "}";
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return resultJson;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
} // namespace facebook::react
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#pragma once
|
|
2
|
+
|
|
3
|
+
#include <jsi/jsi.h>
|
|
4
|
+
#include <ReactCommon/CallInvoker.h>
|
|
5
|
+
#include <unordered_map>
|
|
6
|
+
#include <string>
|
|
7
|
+
#include <mutex>
|
|
8
|
+
#include <memory>
|
|
9
|
+
|
|
10
|
+
namespace facebook::react {
|
|
11
|
+
|
|
12
|
+
class FormatHostObject : public jsi::HostObject,
|
|
13
|
+
public std::enable_shared_from_this<FormatHostObject> {
|
|
14
|
+
public:
|
|
15
|
+
FormatHostObject(std::shared_ptr<CallInvoker> callInvoker);
|
|
16
|
+
~FormatHostObject() override { if (instance_ == this) instance_ = nullptr; }
|
|
17
|
+
|
|
18
|
+
jsi::Value get(jsi::Runtime& rt, const jsi::PropNameID& name) override;
|
|
19
|
+
void set(jsi::Runtime& rt, const jsi::PropNameID& name, const jsi::Value& value) override;
|
|
20
|
+
|
|
21
|
+
// Called from JNI (UI thread)
|
|
22
|
+
std::string formatText(int viewTag, const std::string& text, int cursorPos);
|
|
23
|
+
void removeFormat(int viewTag);
|
|
24
|
+
bool hasFormat(int viewTag);
|
|
25
|
+
|
|
26
|
+
private:
|
|
27
|
+
std::shared_ptr<CallInvoker> callInvoker_;
|
|
28
|
+
// Use shared_ptr so we can copy out without holding lock during invokeSync
|
|
29
|
+
std::unordered_map<int, std::shared_ptr<jsi::Function>> formatFns_;
|
|
30
|
+
std::mutex mutex_;
|
|
31
|
+
|
|
32
|
+
static FormatHostObject* instance_;
|
|
33
|
+
public:
|
|
34
|
+
static void setInstance(FormatHostObject* inst) { instance_ = inst; }
|
|
35
|
+
static FormatHostObject* getInstance() { return instance_; }
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
} // namespace facebook::react
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#include "FormatHostObject.h"
|
|
2
|
+
#include <jni.h>
|
|
3
|
+
#include <fbjni/fbjni.h>
|
|
4
|
+
#include <jsi/jsi.h>
|
|
5
|
+
#include <ReactCommon/CallInvoker.h>
|
|
6
|
+
#include <ReactCommon/CallInvokerHolder.h>
|
|
7
|
+
|
|
8
|
+
namespace facebook::react {
|
|
9
|
+
|
|
10
|
+
static void installFormatModule(jsi::Runtime& runtime, const std::shared_ptr<CallInvoker>& callInvoker) {
|
|
11
|
+
auto hostObject = std::make_shared<FormatHostObject>(callInvoker);
|
|
12
|
+
FormatHostObject::setInstance(hostObject.get());
|
|
13
|
+
|
|
14
|
+
runtime.global().setProperty(
|
|
15
|
+
runtime,
|
|
16
|
+
"__formatModule",
|
|
17
|
+
jsi::Object::createFromHostObject(runtime, std::move(hostObject)));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
} // namespace facebook::react
|
|
21
|
+
|
|
22
|
+
extern "C" {
|
|
23
|
+
|
|
24
|
+
JNIEXPORT void JNICALL
|
|
25
|
+
Java_com_syncformatedittext_FormatModuleImpl_nativeInstall(
|
|
26
|
+
JNIEnv* env,
|
|
27
|
+
jclass clazz,
|
|
28
|
+
jlong jsiRuntimeRef,
|
|
29
|
+
jobject callInvokerHolder) {
|
|
30
|
+
auto runtime = reinterpret_cast<facebook::jsi::Runtime*>(jsiRuntimeRef);
|
|
31
|
+
auto invoker = facebook::jni::alias_ref<facebook::react::CallInvokerHolder::javaobject>{
|
|
32
|
+
reinterpret_cast<facebook::react::CallInvokerHolder::javaobject>(callInvokerHolder)};
|
|
33
|
+
auto callInvoker = invoker->cthis()->getCallInvoker();
|
|
34
|
+
facebook::react::installFormatModule(*runtime, callInvoker);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
JNIEXPORT jstring JNICALL
|
|
38
|
+
Java_com_syncformatedittext_FormatModuleImpl_nativeFormatText(
|
|
39
|
+
JNIEnv* env,
|
|
40
|
+
jclass clazz,
|
|
41
|
+
jint viewTag,
|
|
42
|
+
jstring text,
|
|
43
|
+
jint cursorPos) {
|
|
44
|
+
auto* instance = facebook::react::FormatHostObject::getInstance();
|
|
45
|
+
const char* textStr = env->GetStringUTFChars(text, nullptr);
|
|
46
|
+
std::string textCpp(textStr);
|
|
47
|
+
env->ReleaseStringUTFChars(text, textStr);
|
|
48
|
+
|
|
49
|
+
std::string result;
|
|
50
|
+
if (instance) {
|
|
51
|
+
result = instance->formatText(static_cast<int>(viewTag), textCpp, static_cast<int>(cursorPos));
|
|
52
|
+
} else {
|
|
53
|
+
result = "{\"text\":\"" + textCpp + "\",\"cursorPos\":" + std::to_string(cursorPos) + "}";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return env->NewStringUTF(result.c_str());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
JNIEXPORT void JNICALL
|
|
60
|
+
Java_com_syncformatedittext_FormatModuleImpl_nativeRemoveFormat(
|
|
61
|
+
JNIEnv* env,
|
|
62
|
+
jclass clazz,
|
|
63
|
+
jint viewTag) {
|
|
64
|
+
auto* instance = facebook::react::FormatHostObject::getInstance();
|
|
65
|
+
if (instance) {
|
|
66
|
+
instance->removeFormat(static_cast<int>(viewTag));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
JNIEXPORT jboolean JNICALL
|
|
71
|
+
Java_com_syncformatedittext_FormatModuleImpl_nativeHasFormat(
|
|
72
|
+
JNIEnv* env,
|
|
73
|
+
jclass clazz,
|
|
74
|
+
jint viewTag) {
|
|
75
|
+
auto* instance = facebook::react::FormatHostObject::getInstance();
|
|
76
|
+
if (!instance) return JNI_FALSE;
|
|
77
|
+
return instance->hasFormat(static_cast<int>(viewTag)) ? JNI_TRUE : JNI_FALSE;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
} // extern "C"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
package com.syncformatedittext
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
4
|
+
import com.facebook.react.turbomodule.core.interfaces.CallInvokerHolder
|
|
5
|
+
|
|
6
|
+
data class FormatResult(val text: String, val cursorPos: Int)
|
|
7
|
+
|
|
8
|
+
class FormatModuleImpl(private val reactContext: ReactApplicationContext) {
|
|
9
|
+
|
|
10
|
+
fun install() {
|
|
11
|
+
if (!nativeLibLoaded) return
|
|
12
|
+
val runtimeRef = reactContext.javaScriptContextHolder!!.get()
|
|
13
|
+
val callInvokerHolder = reactContext.jsCallInvokerHolder!!
|
|
14
|
+
nativeInstall(runtimeRef, callInvokerHolder)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
fun formatText(viewTag: Int, text: String, cursorPos: Int): FormatResult {
|
|
18
|
+
if (!nativeLibLoaded) return FormatResult(text, cursorPos)
|
|
19
|
+
val json = nativeFormatText(viewTag, text, cursorPos)
|
|
20
|
+
return parseFormatResult(json, text, cursorPos)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
fun hasFormat(viewTag: Int): Boolean {
|
|
24
|
+
if (!nativeLibLoaded) return false
|
|
25
|
+
return nativeHasFormat(viewTag)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
fun removeFormat(viewTag: Int) {
|
|
29
|
+
if (!nativeLibLoaded) return
|
|
30
|
+
nativeRemoveFormat(viewTag)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
companion object {
|
|
34
|
+
@JvmStatic
|
|
35
|
+
var instance: FormatModuleImpl? = null
|
|
36
|
+
|
|
37
|
+
private var nativeLibLoaded = false
|
|
38
|
+
|
|
39
|
+
init {
|
|
40
|
+
try {
|
|
41
|
+
System.loadLibrary("syncformatedittext")
|
|
42
|
+
nativeLibLoaded = true
|
|
43
|
+
} catch (_: UnsatisfiedLinkError) {
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@JvmStatic
|
|
48
|
+
external fun nativeInstall(runtimeRef: Long, callInvokerHolder: CallInvokerHolder)
|
|
49
|
+
|
|
50
|
+
@JvmStatic
|
|
51
|
+
external fun nativeFormatText(viewTag: Int, text: String, cursorPos: Int): String
|
|
52
|
+
|
|
53
|
+
@JvmStatic
|
|
54
|
+
external fun nativeRemoveFormat(viewTag: Int)
|
|
55
|
+
|
|
56
|
+
@JvmStatic
|
|
57
|
+
external fun nativeHasFormat(viewTag: Int): Boolean
|
|
58
|
+
|
|
59
|
+
private fun parseFormatResult(json: String, fallbackText: String, fallbackCursor: Int): FormatResult {
|
|
60
|
+
return try {
|
|
61
|
+
val textMatch = Regex("\"text\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"").find(json)
|
|
62
|
+
val cursorMatch = Regex("\"cursorPos\"\\s*:\\s*(\\d+)").find(json)
|
|
63
|
+
val parsedText = textMatch?.groupValues?.get(1)
|
|
64
|
+
?.replace("\\\"", "\"")
|
|
65
|
+
?.replace("\\\\", "\\")
|
|
66
|
+
?.replace("\\n", "\n")
|
|
67
|
+
?.replace("\\r", "\r")
|
|
68
|
+
?.replace("\\t", "\t")
|
|
69
|
+
?: fallbackText
|
|
70
|
+
val parsedCursor = cursorMatch?.groupValues?.get(1)?.toIntOrNull() ?: fallbackCursor
|
|
71
|
+
FormatResult(parsedText, parsedCursor)
|
|
72
|
+
} catch (e: Exception) {
|
|
73
|
+
FormatResult(fallbackText, fallbackCursor)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
package com.syncformatedittext
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.text.Editable
|
|
5
|
+
import android.text.TextWatcher
|
|
6
|
+
import android.util.Log
|
|
7
|
+
import com.facebook.react.views.textinput.ReactEditText
|
|
8
|
+
|
|
9
|
+
class SyncFormatEdittextView(context: Context) : ReactEditText(context) {
|
|
10
|
+
private var rawCursorPos = 0
|
|
11
|
+
private var isFormatting = false
|
|
12
|
+
private var lastFormattedText = ""
|
|
13
|
+
private var lastFormattedCursorPos = 0
|
|
14
|
+
private var onFormatListener: ((String, Int) -> Unit)? = null
|
|
15
|
+
var formatModule: FormatModuleImpl? = null
|
|
16
|
+
|
|
17
|
+
init {
|
|
18
|
+
addTextChangedListener(object : TextWatcher {
|
|
19
|
+
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
|
20
|
+
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
|
21
|
+
if (isFormatting) return
|
|
22
|
+
rawCursorPos = if (count > 0) start + count else start
|
|
23
|
+
}
|
|
24
|
+
override fun afterTextChanged(s: Editable?) {
|
|
25
|
+
if (isFormatting) return
|
|
26
|
+
val currentText = s?.toString() ?: ""
|
|
27
|
+
|
|
28
|
+
// Skip if text matches our last formatted result (e.g. from JS value prop update)
|
|
29
|
+
if (currentText == lastFormattedText && selectionStart == lastFormattedCursorPos) {
|
|
30
|
+
onFormatListener?.invoke(currentText, lastFormattedCursorPos)
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
val module = formatModule
|
|
35
|
+
val viewTag = id
|
|
36
|
+
|
|
37
|
+
if (module != null && viewTag > 0 && module.hasFormat(viewTag)) {
|
|
38
|
+
try {
|
|
39
|
+
val result = module.formatText(viewTag, currentText, rawCursorPos)
|
|
40
|
+
if (result.text == currentText) {
|
|
41
|
+
val pos = result.cursorPos.coerceIn(0, currentText.length)
|
|
42
|
+
if (selectionStart != pos) {
|
|
43
|
+
setSelection(pos)
|
|
44
|
+
}
|
|
45
|
+
lastFormattedText = result.text
|
|
46
|
+
lastFormattedCursorPos = pos
|
|
47
|
+
onFormatListener?.invoke(result.text, pos)
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
val newCursorPos = result.cursorPos.coerceIn(0, result.text.length)
|
|
51
|
+
isFormatting = true
|
|
52
|
+
s?.replace(0, s.length, result.text)
|
|
53
|
+
setSelection(newCursorPos)
|
|
54
|
+
rawCursorPos = newCursorPos
|
|
55
|
+
isFormatting = false
|
|
56
|
+
lastFormattedText = result.text
|
|
57
|
+
lastFormattedCursorPos = newCursorPos
|
|
58
|
+
onFormatListener?.invoke(result.text, newCursorPos)
|
|
59
|
+
} catch (e: Exception) {
|
|
60
|
+
onFormatListener?.invoke(currentText, selectionEnd.coerceAtLeast(0))
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
fun setOnFormatListener(listener: (String, Int) -> Unit) {
|
|
68
|
+
onFormatListener = listener
|
|
69
|
+
}
|
|
70
|
+
}
|