{"_id":"@azsxdc12356/react-native-sync-format-edittext","_rev":"2-45007ae6d47ee0f11a36864139657fe2","name":"@azsxdc12356/react-native-sync-format-edittext","dist-tags":{"latest":"1.0.1"},"versions":{"1.0.0":{"name":"@azsxdc12356/react-native-sync-format-edittext","version":"1.0.0","keywords":["react-native","ios","android"],"author":{"name":"junjie-z666"},"license":"MIT","_id":"@azsxdc12356/react-native-sync-format-edittext@1.0.0","maintainers":[{"name":"azsxdc12356","email":"527759787@qq.com"}],"homepage":"https://github.com/junjie-z666/react-native-sync-format-edittext#readme","bugs":{"url":"https://github.com/junjie-z666/react-native-sync-format-edittext/issues"},"dist":{"shasum":"8c467b07eb7fb35683c47dfee7a8d452c4c92c8a","tarball":"https://registry.npmjs.org/@azsxdc12356/react-native-sync-format-edittext/-/react-native-sync-format-edittext-1.0.0.tgz","fileCount":48,"integrity":"sha512-/xp6Ebm2f1KrFx/SJpes0h9M6+hi6uUr0zcxNRVO0XzJLSc8d06g7tXsJjQoOD1JgR2CDjOgJnituQm7xmPETg==","signatures":[{"sig":"MEUCIQDoi2xMX3D7oop7GlHY596SNWzotijyjjTUzqHktH8oLwIgLYTyE49TFoofTnnm5TZXb7uTspboLUspQ5F6z2JaCE8=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":69972},"jest":{"preset":"react-native","modulePathIgnorePatterns":["<rootDir>/example/node_modules","<rootDir>/lib/"]},"main":"./lib/module/index.js","types":"./lib/typescript/src/index.d.ts","exports":{".":{"types":"./lib/typescript/src/index.d.ts","source":"./src/index.tsx","default":"./lib/module/index.js"},"./package.json":"./package.json"},"gitHead":"4f3a77fe351b76c148f7d3ae8c2c0715618e4b26","scripts":{"web":"vite","lint":"eslint \"**/*.{js,ts,tsx}\"","test":"jest","clean":"del-cli android/build example/android/build example/android/app/build example/ios/build lib","example":"yarn workspace react-native-sync-format-edittext-example","prepare":"bob build","release":"release-it --only-version","build:web":"vite build","typecheck":"tsc"},"_npmUser":{"name":"azsxdc12356","email":"527759787@qq.com"},"prettier":{"useTabs":false,"tabWidth":2,"quoteProps":"consistent","singleQuote":true,"trailingComma":"es5"},"commitlint":{"extends":["@commitlint/config-conventional"]},"release-it":{"git":{"tagName":"v${version}","commitMessage":"chore: release ${version}"},"npm":{"publish":true},"github":{"release":true},"plugins":{"@release-it/conventional-changelog":{"preset":{"name":"angular"}}}},"repository":{"url":"git+https://github.com/junjie-z666/react-native-sync-format-edittext.git","type":"git"},"workspaces":["example"],"_npmVersion":"10.1.0","description":"React Native sync format input — flicker-free real-time text formatting via JSI synchronous calls","directories":{},"_nodeVersion":"22.14.0","codegenConfig":{"ios":{"components":{"SyncFormatEdittextView":{"className":"SyncFormatEdittextView"}}},"name":"SyncFormatEdittextViewSpec","type":"all","android":{"javaPackageName":"com.syncformatedittext"},"jsSrcsDir":"src"},"publishConfig":{"registry":"https://registry.npmjs.org/"},"_hasShrinkwrap":false,"packageManager":"yarn@4.11.0","devDependencies":{"jest":"^30.3.0","react":"19.1.4","turbo":"^2.8.21","eslint":"^9.39.4","del-cli":"^7.0.0","lefthook":"^2.1.4","prettier":"^3.8.1","@eslint/js":"^10.0.1","commitlint":"^20.5.0","release-it":"^19.2.4","typescript":"^6.0.2","@types/react":"^19.2.0","react-native":"0.81.6","@jest/globals":"^30.0.0","@eslint/compat":"^2.0.3","@eslint/eslintrc":"^3.3.5","react-native-web":"~0.21.1","eslint-plugin-ft-flow":"^3.0.11","eslint-config-prettier":"^10.1.8","eslint-plugin-prettier":"^5.5.5","react-native-builder-bob":"^0.41.0","@react-native/babel-preset":"0.81.6","@react-native/eslint-config":"0.81.6","@commitlint/config-conventional":"^20.5.0","@release-it/conventional-changelog":"^10.0.6"},"peerDependencies":{"react":"*","react-native":"*"},"_npmOperationalInternal":{"tmp":"tmp/react-native-sync-format-edittext_1.0.0_1779632658231_0.5444796736991613","host":"s3://npm-registry-packages-npm-production"},"react-native-builder-bob":{"output":"lib","source":"src","targets":[["module",{"esm":true}],["typescript",{"project":"tsconfig.build.json"}]]},"create-react-native-library":{"type":"fabric-view","tools":["eslint","jest","lefthook","release-it","vite"],"version":"0.62.0","languages":"kotlin-objc"}},"1.0.1":{"name":"@azsxdc12356/react-native-sync-format-edittext","version":"1.0.1","description":"React Native sync format input — flicker-free real-time text formatting via JSI synchronous calls","main":"./lib/module/index.js","types":"./lib/typescript/src/index.d.ts","exports":{".":{"source":"./src/index.tsx","types":"./lib/typescript/src/index.d.ts","default":"./lib/module/index.js"},"./package.json":"./package.json"},"scripts":{"example":"yarn workspace react-native-sync-format-edittext-example","clean":"del-cli android/build example/android/build example/android/app/build example/ios/build lib","prepare":"bob build","typecheck":"tsc","lint":"eslint \"**/*.{js,ts,tsx}\"","test":"jest","release":"release-it --only-version","web":"vite","build:web":"vite build"},"keywords":["react-native","ios","android"],"repository":{"type":"git","url":"git+https://github.com/junjie-z666/react-native-sync-format-edittext.git"},"author":{"name":"junjie-z666"},"license":"MIT","bugs":{"url":"https://github.com/junjie-z666/react-native-sync-format-edittext/issues"},"homepage":"https://github.com/junjie-z666/react-native-sync-format-edittext#readme","publishConfig":{"registry":"https://registry.npmjs.org/"},"devDependencies":{"@commitlint/config-conventional":"^20.5.0","@eslint/compat":"^2.0.3","@eslint/eslintrc":"^3.3.5","@eslint/js":"^10.0.1","@jest/globals":"^30.0.0","@react-native/babel-preset":"0.81.6","@react-native/eslint-config":"0.81.6","@release-it/conventional-changelog":"^10.0.6","@types/react":"^19.2.0","commitlint":"^20.5.0","del-cli":"^7.0.0","eslint":"^9.39.4","eslint-config-prettier":"^10.1.8","eslint-plugin-ft-flow":"^3.0.11","eslint-plugin-prettier":"^5.5.5","jest":"^30.3.0","lefthook":"^2.1.4","prettier":"^3.8.1","react":"19.1.4","react-native":"0.81.6","react-native-builder-bob":"^0.41.0","react-native-web":"~0.21.1","release-it":"^19.2.4","turbo":"^2.8.21","typescript":"^6.0.2"},"peerDependencies":{"react":"*","react-native":"*"},"workspaces":["example"],"packageManager":"yarn@4.11.0","react-native-builder-bob":{"source":"src","output":"lib","targets":[["module",{"esm":true}],["typescript",{"project":"tsconfig.build.json"}]]},"codegenConfig":{"name":"SyncFormatEdittextViewSpec","type":"all","jsSrcsDir":"src","android":{"javaPackageName":"com.syncformatedittext"},"ios":{"components":{"SyncFormatEdittextView":{"className":"SyncFormatEdittextView"}}}},"prettier":{"quoteProps":"consistent","singleQuote":true,"tabWidth":2,"trailingComma":"es5","useTabs":false},"jest":{"preset":"react-native","modulePathIgnorePatterns":["<rootDir>/example/node_modules","<rootDir>/lib/"]},"commitlint":{"extends":["@commitlint/config-conventional"]},"release-it":{"git":{"commitMessage":"chore: release ${version}","tagName":"v${version}"},"npm":{"publish":true},"github":{"release":true},"plugins":{"@release-it/conventional-changelog":{"preset":{"name":"angular"}}}},"create-react-native-library":{"type":"fabric-view","languages":"kotlin-objc","tools":["eslint","jest","lefthook","release-it","vite"],"version":"0.62.0"},"_id":"@azsxdc12356/react-native-sync-format-edittext@1.0.1","gitHead":"1a34bb4e8d86c05d9c90b6f0ad6cf343ea81bfb4","_nodeVersion":"22.14.0","_npmVersion":"10.1.0","dist":{"integrity":"sha512-TLLTMnolfiOn58WGMu+BLo3z+rIGnDuHnJmXwUoq2tSslsr9wn7R8aJkW9txM3Q4e9SF0iA6NLr0s8FuZsD/sA==","shasum":"992b952c863c9206203480696a9853d218de85a6","tarball":"https://registry.npmjs.org/@azsxdc12356/react-native-sync-format-edittext/-/react-native-sync-format-edittext-1.0.1.tgz","fileCount":48,"unpackedSize":77479,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQCtQTHOHrjWxD+AcowkUBgt9/Nep1T6n+1y/NTNJr9SwAIhAIk9xeFYZnb4yc/VeSXM33E55JUrV24pZZ9GDXeqSfoo"}]},"_npmUser":{"name":"azsxdc12356","email":"527759787@qq.com"},"directories":{},"maintainers":[{"name":"azsxdc12356","email":"527759787@qq.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/react-native-sync-format-edittext_1.0.1_1779903615218_0.3116623021254994"},"_hasShrinkwrap":false}},"time":{"created":"2026-05-24T14:24:18.084Z","modified":"2026-05-27T17:40:15.490Z","1.0.0":"2026-05-24T14:24:18.361Z","1.0.1":"2026-05-27T17:40:15.367Z"},"bugs":{"url":"https://github.com/junjie-z666/react-native-sync-format-edittext/issues"},"author":{"name":"junjie-z666"},"license":"MIT","homepage":"https://github.com/junjie-z666/react-native-sync-format-edittext#readme","keywords":["react-native","ios","android"],"repository":{"type":"git","url":"git+https://github.com/junjie-z666/react-native-sync-format-edittext.git"},"description":"React Native sync format input — flicker-free real-time text formatting via JSI synchronous calls","maintainers":[{"name":"azsxdc12356","email":"527759787@qq.com"}],"readme":"# react-native-sync-format-edittext\n\nReact Native 同步格式化输入框 — 无闪烁的实时文本格式化\n\n## 功能介绍\n\n在 React Native 中对 TextInput 做实时格式化（如手机号 `138-0013-8000`），通常需要监听 `onChangeText`，在 JS 层格式化后再通过 `value` 回写。这个过程中文本要经过异步桥接往返，导致输入时出现明显的闪烁和光标跳动。\n\n本库通过 **JSI 同步调用**，让原生层在文本变化时直接同步调用 JS 的格式化函数，格式化结果立即生效，彻底消除闪烁。同时继承自原生 TextInput，支持所有原生 props。\n\n### 平台支持\n\n| 平台 | 状态 |\n|---|---|\n| Android | ✅ 已支持 |\n| iOS | 🚧 开发中 |\n\n> ⚠️ 需要 React Native **0.76.2** 及以上版本 ，不然codegen会导致无法编译\n\n## 演示效果\n\n| **原生 TextInput 格式化 — 闪烁明显** | **SyncFormatEditText — 无闪烁** |\n|---|---|\n| ![原生格式化](./docs/native-format.gif) | ![同步格式化](./docs/sync-format.gif) |\n\n## 安装\n\n```sh\nnpm install @azsxdc12356/react-native-sync-format-edittext\n```\n\n支持新架构和旧架构，autolink 自动完成链接。\n\n## 使用\n\n```tsx\nimport { SyncFormatEdittextView } from '@azsxdc12356/react-native-sync-format-edittext';\n\n// 只允许输入数字\nfunction formatDigits(text: string, cursorPos: number) {\n  const digits = text.replace(/\\D/g, '');\n  const removedBeforeCursor = text.slice(0, cursorPos).replace(/\\d/g, '').length;\n  return {\n    text: digits,\n    cursorPos: cursorPos - removedBeforeCursor,\n  };\n}\n\n<SyncFormatEdittextView\n  value={code}\n  format={formatDigits}\n  onChangeText={setCode}\n  placeholder=\"请输入验证码\"\n  style={styles.input}\n/>\n```\n## 待办\n\n- [ ] 另一个分支feat_use_event是不使用jsi的方式，用纯事件的异步方式来做，现在只能做到不闪烁，但是还有很多bug\n\n## 整体流程架构\n\n- 详细介绍可以看我的[掘金文章](https://juejin.cn/user/712139263718983)\n\n### 初始化链路\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant JS as JS层\n    participant TM as TurboModule\n    participant KT as Kotlin层\n    participant CP as C++层\n\n    JS->>TM: TurboModuleRegistry.getEnforcing('FormatModule').install()\n    TM->>KT: 获取JSI Runtime和CallInvokerHolder\n    KT->>CP: JNI调用nativeInstall()\n    CP->>CP: 保存callInvoker，并注册__formatModule\n    Note over JS,CP: 组件mount后\n    JS->>CP: __formatModule.setFormat(tag, fn)\n    CP->>CP: 存入formatFns_[viewTag]\n```\n\n关键点：\n\n*   `install()` 只在 App 生命周期执行一次，通过模块级 `installPromise` 保证\n*   `setFormat` 在组件 mount 时注册，组件 unmount 时通过 `removeFormat` 清理\n*   C++ 层用 `unordered_map<int, shared_ptr<jsi::Function>>` 存储函数，key 是 viewTag\n\n### 运行时链路\n\n用户输入时，数据从原生到 JS 再返回原生的完整流程：\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant U as 用户\n    participant V as View视图\n    participant KT as Kotlin层\n    participant CP as C++层\n    participant JSF as JS格式化函数\n\n    U->>V: 输入文字\n    V->>KT: TextWatcher.onTextChanged\n    KT->>KT: 记录光标位置\n    V->>KT: afterTextChanged触发格式化\n    KT->>CP: nativeFormatText JNI调用\n    CP->>JSF: invokeSync JSI同步调用\n    JSF-->>CP: 返回{text, cursorPos}\n    CP-->>KT: 返回JSON字符串\n    KT-->>V: 返回FormatResult\n    V->>V: s.replace()替换文本\n    V->>U: 显示格式化后的文本\n```\n\n整条链路是**同步的**。从 TextWatcher 触发到文本替换完成，都在同一个 UI 线程事件循环中，没有异步等待，所以不会闪烁。\n\n\n**防递归机制**：`afterTextChanged` 中调用 `s.replace()` 会再次触发 TextWatcher。C++ 层通过 `isFormatting` 标志位阻止递归：\n\n```kotlin\noverride fun afterTextChanged(s: Editable?) {\n    if (isFormatting) return  // 正在格式化，跳过递归\n    // ... 执行格式化 ...\n    isFormatting = true\n    s?.replace(0, s.length, result.text)  // 会再次触发 TextWatcher\n    // 但因为 isFormatting == true，递归调用直接 return\n    isFormatting = false\n}\n```\n\n**防循环机制**：JS 层通过 `value` prop 回写时，`lastFormattedText` 跳过重复值：\n\n```kotlin\nif (currentText == lastFormattedText && selectionStart == lastFormattedCursorPos) {\n    onFormatListener?.invoke(currentText, lastFormattedCursorPos)\n    return\n}\n```\n\n\n\n### 新老架构兼容\n\nReact Native 从 0.68 开始引入新架构（Fabric + TurboModule）最低应该能支持到0.76（因为codeGen在这里有一次改版），但很多项目仍在使用老架构。本组件通过 `sourceSets` 实现同时支持：\n\n```groovy\n// android/build.gradle\nsourceSets {\n  if (rootProject.hasProperty(\"newArchEnabled\") &&\n      rootProject.getProperty(\"newArchEnabled\") == \"true\") {\n    main.java.srcDirs += \"src/newarch/java\"\n  } else {\n    main.java.srcDirs += \"src/oldarch/java\"\n  }\n}\n```\n\n核心差异：\n\n*   **新架构**：`FormatModule` 继承 CodeGen 生成的 `NativeFormatModuleSpec`，`ViewManager` 使用 `UIManagerHelper` 派发事件\n*   **老架构**：`FormatModule` 继承 `ReactContextBaseJavaModule`，`ViewManager` 使用 `UIManagerModule` 获取 dispatcher\n\n共享的 `FormatModuleImpl` 和 `SyncFormatEdittextView` 放在 `src/main/java/` 下，不区分架构。\n\n## API\n\n### Props\n\n继承所有 `TextInputProps`，额外支持以下 props：\n\n| Prop | 类型 | 说明 |\n|---|---|---|\n| `format` | `(text: string, cursorPos: number) => { text: string; cursorPos: number }` | 格式化函数，接收当前文本和光标位置，返回格式化后的文本和调整后的光标位置 |\n| `onSyncFormatChange` | `(text: string, cursorPos: number) => void` | 格式化完成后的回调，返回格式化后的文本和光标位置 |\n\n### `format` 函数详解\n\n`cursorPos` 是字符在字符串中的索引（从 0 开始）。格式化后文本长度可能改变，光标位置需要相应调整，否则会跳到错误位置。\n\n**原理**：`cursorPos` 表示\"光标在第几个字符前面\"。格式化后，你需要计算光标在格式化文本中的对应位置。如果格式化只是过滤字符（文本变短），光标位置 = 原位置 - 光标前被过滤掉的字符数；如果格式化插入了分隔符（文本变长），还需要再加上光标前新增的分隔符数量。\n\n**示例 1：只过滤，不插入字符**\n\n输入 `a1b2`，光标在末尾（cursorPos=4）。过滤非数字后得到 `12`，光标前被过滤了 2 个字符，所以 cursorPos=4-2=2。\n\n```ts\nfunction formatDigits(text: string, cursorPos: number) {\n  const digits = text.replace(/\\D/g, '');\n  const removedBeforeCursor = text.slice(0, cursorPos).replace(/\\d/g, '').length;\n  return {\n    text: digits,\n    cursorPos: cursorPos - removedBeforeCursor,\n  };\n}\n```\n\n过滤不改变字符顺序，光标位置 = 原位置 - 光标前被过滤掉的字符数。\n\n**示例 2：插入分隔符**\n\n输入 `1380013`，光标在末尾（cursorPos=7）。格式化为 `138-0013`，光标前多了一个 `-`，所以 cursorPos=8。\n\n```ts\nfunction formatPhone(text: string, cursorPos: number) {\n  const beforeCursor = text.slice(0, cursorPos);\n  const removedBeforeCursor = beforeCursor.replace(/\\d/g, '').length;\n  const adjustedPos = cursorPos - removedBeforeCursor;\n\n  const digits = text.replace(/\\D/g, '').slice(0, 11);\n  let formatted = '';\n  let newCursorPos = adjustedPos;\n  if (digits.length <= 3) {\n    formatted = digits;\n  } else if (digits.length <= 7) {\n    formatted = `${digits.slice(0, 3)}-${digits.slice(3)}`;\n    if (adjustedPos > 3) newCursorPos = adjustedPos + 1;\n  } else {\n    formatted = `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;\n    if (adjustedPos > 3) newCursorPos = adjustedPos + 1;\n    if (adjustedPos > 7) newCursorPos = adjustedPos + 2;\n  }\n  return {\n    text: formatted,\n    cursorPos: Math.min(newCursorPos, formatted.length),\n  };\n}\n```\n\n关键逻辑：先用 `cursorPos - removedBeforeCursor` 得到纯数字中的位置，再根据分隔符偏移。光标每跨过一个分隔符位置，`cursorPos` 就 +1。\n\n更多示例参见 [example](./example/src/App.tsx)。\n\n## License\n\nMIT\n","readmeFilename":"README.md"}