@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.
Files changed (48) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +130 -0
  3. package/SyncFormatEdittext.podspec +20 -0
  4. package/android/build.gradle +105 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/cpp/CMakeLists.txt +28 -0
  7. package/android/src/main/cpp/FormatHostObject.cpp +117 -0
  8. package/android/src/main/cpp/FormatHostObject.h +38 -0
  9. package/android/src/main/cpp/FormatModuleJNI.cpp +80 -0
  10. package/android/src/main/java/com/syncformatedittext/FormatModuleImpl.kt +77 -0
  11. package/android/src/main/java/com/syncformatedittext/SyncFormatEdittextView.kt +70 -0
  12. package/android/src/newarch/java/com/syncformatedittext/FormatModule.kt +19 -0
  13. package/android/src/newarch/java/com/syncformatedittext/SyncFormatEditTextChangeEvent.kt +22 -0
  14. package/android/src/newarch/java/com/syncformatedittext/SyncFormatEdittextPackage.kt +37 -0
  15. package/android/src/newarch/java/com/syncformatedittext/SyncFormatEdittextViewManager.kt +61 -0
  16. package/android/src/oldarch/java/com/syncformatedittext/FormatModule.kt +23 -0
  17. package/android/src/oldarch/java/com/syncformatedittext/SyncFormatEditTextChangeEvent.kt +21 -0
  18. package/android/src/oldarch/java/com/syncformatedittext/SyncFormatEdittextPackage.kt +18 -0
  19. package/android/src/oldarch/java/com/syncformatedittext/SyncFormatEdittextViewManager.kt +60 -0
  20. package/ios/SyncFormatEdittextView.h +14 -0
  21. package/ios/SyncFormatEdittextView.mm +48 -0
  22. package/lib/module/NativeFormatModule.js +5 -0
  23. package/lib/module/NativeFormatModule.js.map +1 -0
  24. package/lib/module/SyncFormatEdittextView.js +14 -0
  25. package/lib/module/SyncFormatEdittextView.js.map +1 -0
  26. package/lib/module/SyncFormatEdittextView.native.js +49 -0
  27. package/lib/module/SyncFormatEdittextView.native.js.map +1 -0
  28. package/lib/module/SyncFormatEdittextViewNativeComponent.ts +198 -0
  29. package/lib/module/index.js +4 -0
  30. package/lib/module/index.js.map +1 -0
  31. package/lib/module/package.json +1 -0
  32. package/lib/typescript/package.json +1 -0
  33. package/lib/typescript/src/NativeFormatModule.d.ts +7 -0
  34. package/lib/typescript/src/NativeFormatModule.d.ts.map +1 -0
  35. package/lib/typescript/src/SyncFormatEdittextView.d.ts +3 -0
  36. package/lib/typescript/src/SyncFormatEdittextView.d.ts.map +1 -0
  37. package/lib/typescript/src/SyncFormatEdittextView.native.d.ts +12 -0
  38. package/lib/typescript/src/SyncFormatEdittextView.native.d.ts.map +1 -0
  39. package/lib/typescript/src/SyncFormatEdittextViewNativeComponent.d.ts +139 -0
  40. package/lib/typescript/src/SyncFormatEdittextViewNativeComponent.d.ts.map +1 -0
  41. package/lib/typescript/src/index.d.ts +3 -0
  42. package/lib/typescript/src/index.d.ts.map +1 -0
  43. package/package.json +181 -0
  44. package/src/NativeFormatModule.ts +8 -0
  45. package/src/SyncFormatEdittextView.native.tsx +81 -0
  46. package/src/SyncFormatEdittextView.tsx +6 -0
  47. package/src/SyncFormatEdittextViewNativeComponent.ts +198 -0
  48. 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
+ ![原生格式化](./docs/native-format.gif)
23
+
24
+ **SyncFormatEditText — 无闪烁**
25
+
26
+ ![同步格式化](./docs/sync-format.gif)
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,2 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ </manifest>
@@ -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
+ }