react-native-enriched-markdown-scaffold 0.7.4-scaffold.8 → 1.0.2-scaffold.1

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 (469) hide show
  1. package/README.md +54 -8
  2. package/ReactNativeEnrichedMarkdown.podspec +82 -73
  3. package/android/build.gradle +191 -1
  4. package/android/consumer-rules.pro +1 -1
  5. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownManagerDelegate.java +9 -0
  6. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownManagerInterface.java +3 -0
  7. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextInputManagerDelegate.java +27 -0
  8. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextInputManagerInterface.java +9 -0
  9. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerDelegate.java +9 -0
  10. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerInterface.java +3 -0
  11. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp +75 -0
  12. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.h +63 -0
  13. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.cpp +40 -0
  14. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +839 -23
  15. package/android/src/main/baseline-prof.txt +1 -1
  16. package/android/src/main/cpp/jni-adapter.cpp +74 -0
  17. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +175 -11
  18. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownInternalText.kt +17 -0
  19. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt +50 -0
  20. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +48 -1
  21. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextLayoutManager.kt +10 -1
  22. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt +42 -0
  23. package/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +82 -4
  24. package/android/src/main/java/com/swmansion/enriched/markdown/events/CopyPressEvent.kt +24 -0
  25. package/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt +62 -4
  26. package/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt +911 -460
  27. package/android/src/main/java/com/swmansion/enriched/markdown/input/MarkdownClipboard.kt +42 -0
  28. package/android/src/main/java/com/swmansion/enriched/markdown/input/editing/BlockEditCoordinator.kt +196 -0
  29. package/android/src/main/java/com/swmansion/enriched/markdown/input/editing/ClipboardCoordinator.kt +73 -0
  30. package/android/src/main/java/com/swmansion/enriched/markdown/input/editing/EditContext.kt +19 -0
  31. package/android/src/main/java/com/swmansion/enriched/markdown/input/editing/EditPipeline.kt +248 -0
  32. package/android/src/main/java/com/swmansion/enriched/markdown/input/editing/EditSession.kt +52 -0
  33. package/android/src/main/java/com/swmansion/enriched/markdown/input/editing/InputConnectionWrapper.kt +101 -7
  34. package/android/src/main/java/com/swmansion/enriched/markdown/input/editing/LinkCoordinator.kt +69 -0
  35. package/android/src/main/java/com/swmansion/enriched/markdown/input/editing/MarkdownTextWatcher.kt +3 -3
  36. package/android/src/main/java/com/swmansion/enriched/markdown/input/editing/MentionCoordinator.kt +118 -0
  37. package/android/src/main/java/com/swmansion/enriched/markdown/input/events/OnChangeStateEvent.kt +26 -0
  38. package/android/src/main/java/com/swmansion/enriched/markdown/input/events/OnContextMenuItemPressEvent.kt +26 -0
  39. package/android/src/main/java/com/swmansion/enriched/markdown/input/events/OnInputKeyPressEvent.kt +24 -0
  40. package/android/src/main/java/com/swmansion/enriched/markdown/input/events/OnPasteEvent.kt +24 -0
  41. package/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/BlockStore.kt +254 -0
  42. package/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/BlockSyntaxSentinel.kt +69 -0
  43. package/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/FormattingStore.kt +80 -87
  44. package/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputFormatter.kt +109 -1
  45. package/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputParser.kt +180 -27
  46. package/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/MarkdownSerializer.kt +167 -38
  47. package/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/RangeEditAdjustment.kt +149 -0
  48. package/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputEventEmitter.kt +66 -5
  49. package/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputMeasurementStore.kt +7 -2
  50. package/android/src/main/java/com/swmansion/enriched/markdown/input/model/BlockRange.kt +27 -0
  51. package/android/src/main/java/com/swmansion/enriched/markdown/input/model/BlockType.kt +48 -0
  52. package/android/src/main/java/com/swmansion/enriched/markdown/input/model/FormattingRange.kt +9 -4
  53. package/android/src/main/java/com/swmansion/enriched/markdown/input/model/InputFormatterStyle.kt +39 -2
  54. package/android/src/main/java/com/swmansion/enriched/markdown/input/model/MathAtom.kt +20 -0
  55. package/android/src/main/java/com/swmansion/enriched/markdown/input/model/MathChipStyle.kt +1 -1
  56. package/android/src/main/java/com/swmansion/enriched/markdown/input/model/MutableRangeBounds.kt +11 -0
  57. package/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputBulletSpan.kt +82 -0
  58. package/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputHeadingSpan.kt +58 -0
  59. package/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputListItemSpacingSpan.kt +37 -0
  60. package/android/src/main/java/com/swmansion/enriched/markdown/input/spans/InputOrderedListMarkerSpan.kt +63 -0
  61. package/android/src/main/java/com/swmansion/enriched/markdown/input/styles/BlockHandler.kt +54 -0
  62. package/android/src/main/java/com/swmansion/enriched/markdown/input/styles/HeadingBlockHandler.kt +27 -0
  63. package/android/src/main/java/com/swmansion/enriched/markdown/input/styles/OrderedListBlockHandler.kt +38 -0
  64. package/android/src/main/java/com/swmansion/enriched/markdown/input/styles/UnorderedListBlockHandler.kt +37 -0
  65. package/android/src/main/java/com/swmansion/enriched/markdown/input/toolbar/InputContextMenu.kt +8 -26
  66. package/android/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt +1 -0
  67. package/android/src/main/java/com/swmansion/enriched/markdown/parser/Parser.kt +1 -0
  68. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockStyleContext.kt +9 -2
  69. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt +100 -12
  70. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/CodeBlockRenderer.kt +16 -1
  71. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/HeadingRenderer.kt +4 -0
  72. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListContextManager.kt +2 -1
  73. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListItemRenderer.kt +74 -18
  74. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListRenderer.kt +43 -4
  75. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt +9 -1
  76. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/SoftBreakRenderer.kt +16 -0
  77. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt +30 -27
  78. package/android/src/main/java/com/swmansion/enriched/markdown/spans/BaseListSpan.kt +24 -0
  79. package/android/src/main/java/com/swmansion/enriched/markdown/spans/BlockquoteSpan.kt +174 -24
  80. package/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBackgroundSpan.kt +27 -3
  81. package/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBlockSpan.kt +13 -16
  82. package/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeSpan.kt +1 -1
  83. package/android/src/main/java/com/swmansion/enriched/markdown/spans/EmphasisSpan.kt +1 -1
  84. package/android/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt +182 -46
  85. package/android/src/main/java/com/swmansion/enriched/markdown/spans/ListItemSpacingSpan.kt +33 -0
  86. package/android/src/main/java/com/swmansion/enriched/markdown/spans/ListMarkerAnchorSpan.kt +38 -0
  87. package/android/src/main/java/com/swmansion/enriched/markdown/spans/OrderedListSpan.kt +2 -0
  88. package/android/src/main/java/com/swmansion/enriched/markdown/spans/StrongSpan.kt +1 -1
  89. package/android/src/main/java/com/swmansion/enriched/markdown/spans/TaskListSpan.kt +3 -2
  90. package/android/src/main/java/com/swmansion/enriched/markdown/spans/UnorderedListSpan.kt +3 -2
  91. package/android/src/main/java/com/swmansion/enriched/markdown/styles/BlockquoteStyle.kt +6 -0
  92. package/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeBlockStyle.kt +26 -0
  93. package/android/src/main/java/com/swmansion/enriched/markdown/styles/ImageStyle.kt +7 -1
  94. package/android/src/main/java/com/swmansion/enriched/markdown/styles/ListStyle.kt +4 -1
  95. package/android/src/main/java/com/swmansion/enriched/markdown/styles/StyleConfig.kt +13 -4
  96. package/android/src/main/java/com/swmansion/enriched/markdown/styles/TableStyle.kt +6 -0
  97. package/android/src/main/java/com/swmansion/enriched/markdown/utils/common/CodeBlockHighlighter.kt +90 -0
  98. package/android/src/main/java/com/swmansion/enriched/markdown/utils/common/CodeBlockNode.kt +57 -0
  99. package/android/src/main/java/com/swmansion/enriched/markdown/utils/common/FeatureFlags.kt +1 -0
  100. package/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownSegment.kt +9 -0
  101. package/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt +33 -7
  102. package/android/src/main/java/com/swmansion/enriched/markdown/utils/common/ReadableMapExtensions.kt +3 -0
  103. package/android/src/main/java/com/swmansion/enriched/markdown/utils/common/RenderedSegment.kt +10 -0
  104. package/android/src/main/java/com/swmansion/enriched/markdown/utils/common/SegmentSignature.kt +1 -0
  105. package/android/src/main/java/com/swmansion/enriched/markdown/utils/common/StreamingMarkdownFilter.kt +83 -5
  106. package/android/src/main/java/com/swmansion/enriched/markdown/utils/common/serialization/MarkdownASTSerializer.kt +4 -0
  107. package/android/src/main/java/com/swmansion/enriched/markdown/utils/input/MarkdownStyleParser.kt +74 -44
  108. package/android/src/main/java/com/swmansion/enriched/markdown/utils/input/PasteTextUtils.kt +20 -0
  109. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/ImageCache.kt +26 -3
  110. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/ImageDownloader.kt +22 -9
  111. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/LocalImageLoader.kt +161 -0
  112. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/extensions/TextPaintExtensions.kt +23 -0
  113. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/interaction/CheckboxTouchHelper.kt +7 -0
  114. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/interaction/TaskListTapUtils.kt +41 -21
  115. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/span/BlockSpacing.kt +15 -8
  116. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkLongPressMovementMethod.kt +58 -27
  117. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/SelectionActionMode.kt +19 -2
  118. package/android/src/main/java/com/swmansion/enriched/markdown/views/CodeBlockContainerView.kt +388 -0
  119. package/android/src/main/java/com/swmansion/enriched/markdown/views/ContextMenuPopup.kt +9 -6
  120. package/android/src/main/java/com/swmansion/enriched/markdown/views/CopyGlyph.kt +101 -0
  121. package/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt +32 -5
  122. package/android/src/main/jni/CMakeLists.txt +53 -0
  123. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.cpp +10 -0
  124. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownContainerShadowNode.h +11 -2
  125. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp +10 -0
  126. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h +12 -2
  127. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextState.cpp +9 -0
  128. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextState.h +24 -0
  129. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h +16 -0
  130. package/android/src/math/java/com/swmansion/enriched/markdown/input/math/MathChipControllerImpl.kt +21 -4
  131. package/android/src/math/java/com/swmansion/enriched/markdown/renderer/MathInlineRenderer.kt +7 -2
  132. package/android/src/math/java/com/swmansion/enriched/markdown/spans/MathChipSpan.kt +1 -1
  133. package/android/src/math/java/com/swmansion/enriched/markdown/views/MathContainerView.kt +2 -0
  134. package/cpp/highlight/CodeBlockHighlighter.cpp +284 -0
  135. package/cpp/highlight/CodeBlockHighlighter.hpp +66 -0
  136. package/cpp/highlight/CodeBlockLanguages.cpp +138 -0
  137. package/cpp/highlight/CodeBlockLanguages.hpp +25 -0
  138. package/cpp/highlight/HighlightGrammars.hpp +28 -0
  139. package/cpp/highlight/code_highlight_podspec.rb +211 -0
  140. package/cpp/highlight/gen-registry.mjs +155 -0
  141. package/cpp/highlight/grammar-versions.json +159 -0
  142. package/cpp/highlight/vendor/generated/generated_queries.h +1665 -0
  143. package/cpp/highlight/vendor/generated/generated_registry.cpp +59 -0
  144. package/cpp/parser/MD4CParser.cpp +173 -61
  145. package/cpp/parser/MD4CParser.hpp +1 -0
  146. package/cpp/parser/MarkdownASTNode.hpp +2 -1
  147. package/cpp/wasm/ASTSerializer.cpp +2 -0
  148. package/cpp/wasm/build.sh +1 -0
  149. package/cpp/wasm/md4c_wasm.cpp +3 -1
  150. package/docs/ACCESSIBILITY.md +14 -2
  151. package/docs/API_REFERENCE.md +231 -8
  152. package/docs/BREAKING_CHANGES.md +129 -0
  153. package/docs/CODE_HIGHLIGHT.md +133 -0
  154. package/docs/COPY_OPTIONS.md +21 -3
  155. package/docs/ELEMENTS_STRUCTURE.md +52 -4
  156. package/docs/IMAGE_CACHING.md +30 -0
  157. package/docs/INPUT.md +146 -5
  158. package/docs/LATEX_MATH.md +87 -45
  159. package/docs/MACOS.md +1 -1
  160. package/docs/MARKDOWN_STREAMING.md +27 -1
  161. package/docs/NATIVE_ASSETS.md +127 -0
  162. package/docs/RTL.md +1 -1
  163. package/docs/STYLES.md +46 -6
  164. package/docs/TESTING.md +112 -0
  165. package/docs/TEXT.md +16 -6
  166. package/docs/WEB.md +3 -1
  167. package/ios/EnrichedMarkdown.h +7 -0
  168. package/ios/EnrichedMarkdown.mm +262 -67
  169. package/ios/EnrichedMarkdownText.h +7 -0
  170. package/ios/EnrichedMarkdownText.mm +97 -68
  171. package/ios/attachments/ENRMImageAttachment.h +9 -0
  172. package/ios/attachments/ENRMImageAttachment.m +143 -40
  173. package/ios/attachments/ENRMMathInlineAttachment.h +2 -0
  174. package/ios/attachments/ENRMMathInlineAttachment.m +48 -81
  175. package/ios/attachments/ENRMMathInlineAttachmentShared.h +2 -0
  176. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.cpp +75 -0
  177. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.h +63 -0
  178. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.cpp +40 -0
  179. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h +839 -23
  180. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/RCTComponentViewHelpers.h +131 -0
  181. package/ios/input/ENRMAutoLinkDetector.h +2 -1
  182. package/ios/input/ENRMAutoLinkDetector.mm +79 -41
  183. package/ios/input/ENRMBlockEditCoordinator.h +71 -0
  184. package/ios/input/ENRMBlockEditCoordinator.mm +195 -0
  185. package/ios/input/ENRMBlockRange.h +28 -0
  186. package/ios/input/ENRMBlockRange.mm +30 -0
  187. package/ios/input/ENRMBlockStore.h +56 -0
  188. package/ios/input/ENRMBlockStore.mm +275 -0
  189. package/ios/input/ENRMBlockSyntaxSentinel.h +25 -0
  190. package/ios/input/ENRMBlockSyntaxSentinel.mm +80 -0
  191. package/ios/input/ENRMClipboardCoordinator.h +45 -0
  192. package/ios/input/ENRMClipboardCoordinator.mm +120 -0
  193. package/ios/input/ENRMDetectorPipeline.h +2 -1
  194. package/ios/input/ENRMEditPipeline.h +76 -0
  195. package/ios/input/ENRMEditPipeline.mm +228 -0
  196. package/ios/input/ENRMEditSession.h +36 -0
  197. package/ios/input/ENRMEditSession.mm +63 -0
  198. package/ios/input/ENRMFormattingStore.h +17 -0
  199. package/ios/input/ENRMFormattingStore.mm +95 -105
  200. package/ios/input/ENRMInputBlockType.h +91 -0
  201. package/ios/input/ENRMInputBlockType.mm +5 -0
  202. package/ios/input/ENRMInputDecorationDrawer.h +18 -0
  203. package/ios/input/ENRMInputEventEmitter.h +56 -0
  204. package/ios/input/ENRMInputEventEmitter.mm +308 -0
  205. package/ios/input/ENRMInputFormatter.h +29 -69
  206. package/ios/input/ENRMInputFormatter.mm +216 -124
  207. package/ios/input/ENRMInputFormatterStyle.h +99 -0
  208. package/ios/input/ENRMInputFormatterStyle.mm +188 -0
  209. package/ios/input/ENRMInputLayoutManager.h +9 -0
  210. package/ios/input/ENRMInputLayoutManager.mm +30 -0
  211. package/ios/input/ENRMInputListMarkerDrawer.h +32 -0
  212. package/ios/input/ENRMInputListMarkerDrawer.mm +426 -0
  213. package/ios/input/ENRMInputParser.h +10 -6
  214. package/ios/input/ENRMInputParser.mm +527 -171
  215. package/ios/input/ENRMInputRemend.mm +2 -2
  216. package/ios/input/ENRMInputStyleStateBuilder.h +40 -0
  217. package/ios/input/ENRMInputStyleStateBuilder.mm +60 -0
  218. package/ios/input/ENRMInputTextView.mm +103 -8
  219. package/ios/input/ENRMInputTypingAttributesController.h +42 -0
  220. package/ios/input/ENRMInputTypingAttributesController.mm +203 -0
  221. package/ios/input/ENRMLinkCoordinator.h +41 -0
  222. package/ios/input/ENRMLinkCoordinator.mm +89 -0
  223. package/ios/input/ENRMMarkdownSerializer.h +29 -5
  224. package/ios/input/ENRMMarkdownSerializer.mm +170 -48
  225. package/ios/input/ENRMMathAtom.h +26 -0
  226. package/ios/input/ENRMMathAtom.mm +12 -0
  227. package/ios/input/ENRMMentionCoordinator.h +45 -0
  228. package/ios/input/ENRMMentionCoordinator.mm +167 -0
  229. package/ios/input/ENRMRangeEditAdjustment.h +38 -0
  230. package/ios/input/ENRMRangeEditAdjustment.mm +91 -0
  231. package/ios/input/ENRMRangeStoreUtils.h +39 -0
  232. package/ios/input/EnrichedMarkdownTextInput+ContextMenu.mm +6 -6
  233. package/ios/input/EnrichedMarkdownTextInput+Internal.h +11 -3
  234. package/ios/input/EnrichedMarkdownTextInput.h +7 -1
  235. package/ios/input/EnrichedMarkdownTextInput.mm +1104 -1024
  236. package/ios/input/InputStylePropsUtils.h +109 -68
  237. package/ios/input/internals/EnrichedMarkdownTextInputShadowNode.mm +2 -1
  238. package/ios/input/styles/ENRMBlockHandler.h +46 -0
  239. package/ios/input/styles/ENRMHeadingBlockHandler.h +15 -0
  240. package/ios/input/styles/ENRMHeadingBlockHandler.mm +38 -0
  241. package/ios/input/styles/ENRMOrderedListBlockHandler.h +14 -0
  242. package/ios/input/styles/ENRMOrderedListBlockHandler.mm +48 -0
  243. package/ios/input/styles/ENRMUnorderedListBlockHandler.h +15 -0
  244. package/ios/input/styles/ENRMUnorderedListBlockHandler.mm +51 -0
  245. package/ios/internals/ENRMViewFreeMeasurement.h +331 -0
  246. package/ios/internals/EnrichedMarkdownShadowNode.h +1 -3
  247. package/ios/internals/EnrichedMarkdownShadowNode.mm +16 -20
  248. package/ios/internals/EnrichedMarkdownTextShadowNode.h +1 -3
  249. package/ios/internals/EnrichedMarkdownTextShadowNode.mm +15 -20
  250. package/ios/internals/MeasurementCache.h +33 -7
  251. package/ios/internals/ShadowMeasurementUtils.h +63 -58
  252. package/ios/math/ENRMMathChipController.h +74 -0
  253. package/ios/math/ENRMMathChipController.mm +476 -0
  254. package/ios/math/ENRMRaTeXBridge.swift +4 -1
  255. package/ios/parser/ENRMMarkdownParser.h +1 -8
  256. package/ios/parser/ENRMMarkdownParser.mm +2 -11
  257. package/ios/parser/MarkdownASTNode.h +2 -1
  258. package/ios/parser/MarkdownParserBridge.mm +4 -0
  259. package/ios/renderer/AttributedRenderer.m +20 -2
  260. package/ios/renderer/BaseRenderer.h +17 -2
  261. package/ios/renderer/BaseRenderer.m +30 -0
  262. package/ios/renderer/BlockquoteRenderer.h +1 -1
  263. package/ios/renderer/BlockquoteRenderer.m +144 -35
  264. package/ios/renderer/CodeBlockRenderer.h +1 -1
  265. package/ios/renderer/CodeBlockRenderer.m +27 -23
  266. package/ios/renderer/CodeRenderer.h +1 -1
  267. package/ios/renderer/CodeRenderer.m +3 -1
  268. package/ios/renderer/ENRMImageRenderer.h +1 -1
  269. package/ios/renderer/ENRMImageRenderer.m +5 -1
  270. package/ios/renderer/ENRMMathInlineRenderer.h +1 -1
  271. package/ios/renderer/ENRMMathInlineRenderer.m +5 -1
  272. package/ios/renderer/ENRMSpoilerRenderer.h +1 -1
  273. package/ios/renderer/ENRMSpoilerRenderer.m +3 -1
  274. package/ios/renderer/EmphasisRenderer.h +1 -1
  275. package/ios/renderer/EmphasisRenderer.m +3 -1
  276. package/ios/renderer/HeadingRenderer.h +1 -1
  277. package/ios/renderer/HeadingRenderer.m +13 -6
  278. package/ios/renderer/HighlightRenderer.h +1 -1
  279. package/ios/renderer/HighlightRenderer.m +3 -1
  280. package/ios/renderer/LinkRenderer.h +1 -1
  281. package/ios/renderer/LinkRenderer.m +3 -1
  282. package/ios/renderer/ListItemRenderer.h +13 -1
  283. package/ios/renderer/ListItemRenderer.m +102 -44
  284. package/ios/renderer/ListRenderer.h +2 -2
  285. package/ios/renderer/ListRenderer.m +63 -25
  286. package/ios/renderer/ParagraphRenderer.h +1 -1
  287. package/ios/renderer/ParagraphRenderer.m +16 -8
  288. package/ios/renderer/RenderContext.h +10 -0
  289. package/ios/renderer/RenderContext.m +44 -0
  290. package/ios/renderer/RendererFactory.m +14 -0
  291. package/ios/renderer/StrikethroughRenderer.h +1 -1
  292. package/ios/renderer/StrikethroughRenderer.m +3 -1
  293. package/ios/renderer/StrongRenderer.h +1 -1
  294. package/ios/renderer/StrongRenderer.m +3 -1
  295. package/ios/renderer/SubscriptRenderer.h +1 -1
  296. package/ios/renderer/SubscriptRenderer.m +3 -1
  297. package/ios/renderer/SuperscriptRenderer.h +1 -1
  298. package/ios/renderer/SuperscriptRenderer.m +3 -1
  299. package/ios/renderer/TextRenderer.h +2 -2
  300. package/ios/renderer/TextRenderer.m +3 -1
  301. package/ios/renderer/ThematicBreakRenderer.h +1 -1
  302. package/ios/renderer/ThematicBreakRenderer.m +3 -1
  303. package/ios/renderer/UnderlineRenderer.h +1 -1
  304. package/ios/renderer/UnderlineRenderer.m +3 -1
  305. package/ios/styles/StyleConfig.h +22 -0
  306. package/ios/styles/StyleConfig.mm +132 -0
  307. package/ios/utils/BlockquoteBorder.h +6 -0
  308. package/ios/utils/BlockquoteBorder.m +249 -52
  309. package/ios/utils/CodeBlockBackground.m +16 -2
  310. package/ios/utils/ENRMAtomicSize.h +65 -0
  311. package/ios/utils/ENRMCodeBlockContent.h +37 -0
  312. package/ios/utils/ENRMCodeBlockContent.mm +79 -0
  313. package/ios/utils/ENRMCodeBlockHighlighter.h +37 -0
  314. package/ios/utils/ENRMCodeBlockHighlighter.mm +51 -0
  315. package/ios/utils/ENRMFeatureFlags.h +7 -0
  316. package/ios/utils/ENRMImageDownloader.h +11 -1
  317. package/ios/utils/ENRMImageDownloader.m +60 -16
  318. package/ios/utils/ENRMLocalImageLoader.h +31 -0
  319. package/ios/utils/ENRMLocalImageLoader.m +68 -0
  320. package/ios/utils/ENRMMathFallback.h +26 -0
  321. package/ios/utils/ENRMMathFallback.m +18 -0
  322. package/ios/utils/ENRMTextViewSetup.h +35 -17
  323. package/ios/utils/EditMenuUtils.m +22 -1
  324. package/ios/utils/ImageRequestHeaderUtils.h +35 -0
  325. package/ios/utils/LastElementUtils.h +1 -0
  326. package/ios/utils/ListMarkerDrawer.m +29 -42
  327. package/ios/utils/MarkdownASTSerializer.m +4 -0
  328. package/ios/utils/ParagraphStyleUtils.h +5 -0
  329. package/ios/utils/ParagraphStyleUtils.m +77 -17
  330. package/ios/utils/RenderedMarkdownSegment.h +13 -1
  331. package/ios/utils/RenderedMarkdownSegment.m +20 -0
  332. package/ios/utils/SegmentRenderer.m +17 -4
  333. package/ios/utils/StreamingMarkdownFilter.h +11 -1
  334. package/ios/utils/StreamingMarkdownFilter.m +98 -2
  335. package/ios/utils/StylePropsUtils.h +63 -0
  336. package/ios/utils/TaskListTapUtils.m +24 -0
  337. package/ios/views/ENRMCodeBlockContainerView.h +46 -0
  338. package/ios/views/ENRMCodeBlockContainerView.m +566 -0
  339. package/ios/views/ENRMMathContainerView.h +12 -0
  340. package/ios/views/ENRMMathContainerView.m +71 -1
  341. package/ios/views/TableContainerView.h +18 -0
  342. package/ios/views/TableContainerView.m +259 -159
  343. package/jest.d.ts +1 -0
  344. package/jest.js +5 -0
  345. package/lib/module/EnrichedMarkdownNativeComponent.ts +65 -0
  346. package/lib/module/EnrichedMarkdownTextInput.js +86 -5
  347. package/lib/module/EnrichedMarkdownTextInput.js.map +1 -1
  348. package/lib/module/EnrichedMarkdownTextInputNativeComponent.ts +89 -5
  349. package/lib/module/EnrichedMarkdownTextNativeComponent.ts +66 -0
  350. package/lib/module/headingDefaults.js +34 -0
  351. package/lib/module/headingDefaults.js.map +1 -0
  352. package/lib/module/index.js.map +1 -1
  353. package/lib/module/jest/index.js +191 -0
  354. package/lib/module/jest/index.js.map +1 -0
  355. package/lib/module/native/EnrichedMarkdownText.js +30 -4
  356. package/lib/module/native/EnrichedMarkdownText.js.map +1 -1
  357. package/lib/module/normalizeMarkdownStyle.js +81 -19
  358. package/lib/module/normalizeMarkdownStyle.js.map +1 -1
  359. package/lib/module/normalizeMarkdownStyle.web.js +56 -18
  360. package/lib/module/normalizeMarkdownStyle.web.js.map +1 -1
  361. package/lib/module/normalizeMarkdownTextInputStyle.js +62 -15
  362. package/lib/module/normalizeMarkdownTextInputStyle.js.map +1 -1
  363. package/lib/module/normalizeMenuItem.js +1 -1
  364. package/lib/module/utils/textInputState.js +26 -0
  365. package/lib/module/utils/textInputState.js.map +1 -0
  366. package/lib/module/web/EnrichedMarkdownText.js +9 -5
  367. package/lib/module/web/EnrichedMarkdownText.js.map +1 -1
  368. package/lib/module/web/parseMarkdown.js +4 -3
  369. package/lib/module/web/parseMarkdown.js.map +1 -1
  370. package/lib/module/web/renderers/InlineRenderers.js +6 -0
  371. package/lib/module/web/renderers/InlineRenderers.js.map +1 -1
  372. package/lib/module/web/renderers/ListRenderers.js +7 -2
  373. package/lib/module/web/renderers/ListRenderers.js.map +1 -1
  374. package/lib/module/web/renderers/index.js +7 -4
  375. package/lib/module/web/renderers/index.js.map +1 -1
  376. package/lib/module/web/styles.js +83 -6
  377. package/lib/module/web/styles.js.map +1 -1
  378. package/lib/module/web/utils.js +1 -0
  379. package/lib/module/web/utils.js.map +1 -1
  380. package/lib/module/web/wasm/md4c.js +0 -0
  381. package/lib/module/web/wasm/md4c.js.map +1 -1
  382. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts +62 -0
  383. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts.map +1 -1
  384. package/lib/typescript/src/EnrichedMarkdownTextInput.d.ts +95 -9
  385. package/lib/typescript/src/EnrichedMarkdownTextInput.d.ts.map +1 -1
  386. package/lib/typescript/src/EnrichedMarkdownTextInputNativeComponent.d.ts +80 -5
  387. package/lib/typescript/src/EnrichedMarkdownTextInputNativeComponent.d.ts.map +1 -1
  388. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +62 -0
  389. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts.map +1 -1
  390. package/lib/typescript/src/headingDefaults.d.ts +9 -0
  391. package/lib/typescript/src/headingDefaults.d.ts.map +1 -0
  392. package/lib/typescript/src/index.d.ts +2 -2
  393. package/lib/typescript/src/index.d.ts.map +1 -1
  394. package/lib/typescript/src/index.web.d.ts +1 -1
  395. package/lib/typescript/src/index.web.d.ts.map +1 -1
  396. package/lib/typescript/src/jest/index.d.ts +6 -0
  397. package/lib/typescript/src/jest/index.d.ts.map +1 -0
  398. package/lib/typescript/src/native/EnrichedMarkdownText.d.ts +3 -3
  399. package/lib/typescript/src/native/EnrichedMarkdownText.d.ts.map +1 -1
  400. package/lib/typescript/src/normalizeMarkdownStyle.d.ts.map +1 -1
  401. package/lib/typescript/src/normalizeMarkdownStyle.web.d.ts.map +1 -1
  402. package/lib/typescript/src/normalizeMarkdownTextInputStyle.d.ts +19 -5
  403. package/lib/typescript/src/normalizeMarkdownTextInputStyle.d.ts.map +1 -1
  404. package/lib/typescript/src/types/MarkdownStyle.d.ts +72 -0
  405. package/lib/typescript/src/types/MarkdownStyle.d.ts.map +1 -1
  406. package/lib/typescript/src/types/MarkdownStyleInternal.d.ts +27 -0
  407. package/lib/typescript/src/types/MarkdownStyleInternal.d.ts.map +1 -1
  408. package/lib/typescript/src/types/MarkdownTextProps.d.ts +65 -1
  409. package/lib/typescript/src/types/MarkdownTextProps.d.ts.map +1 -1
  410. package/lib/typescript/src/types/MarkdownTextProps.web.d.ts +19 -0
  411. package/lib/typescript/src/types/MarkdownTextProps.web.d.ts.map +1 -1
  412. package/lib/typescript/src/types/events.d.ts +19 -0
  413. package/lib/typescript/src/types/events.d.ts.map +1 -1
  414. package/lib/typescript/src/utils/textInputState.d.ts +24 -0
  415. package/lib/typescript/src/utils/textInputState.d.ts.map +1 -0
  416. package/lib/typescript/src/web/EnrichedMarkdownText.d.ts +1 -1
  417. package/lib/typescript/src/web/EnrichedMarkdownText.d.ts.map +1 -1
  418. package/lib/typescript/src/web/parseMarkdown.d.ts +1 -1
  419. package/lib/typescript/src/web/parseMarkdown.d.ts.map +1 -1
  420. package/lib/typescript/src/web/renderers/InlineRenderers.d.ts.map +1 -1
  421. package/lib/typescript/src/web/renderers/ListRenderers.d.ts.map +1 -1
  422. package/lib/typescript/src/web/renderers/index.d.ts +2 -1
  423. package/lib/typescript/src/web/renderers/index.d.ts.map +1 -1
  424. package/lib/typescript/src/web/styles.d.ts +2 -1
  425. package/lib/typescript/src/web/styles.d.ts.map +1 -1
  426. package/lib/typescript/src/web/types.d.ts +6 -2
  427. package/lib/typescript/src/web/types.d.ts.map +1 -1
  428. package/lib/typescript/src/web/utils.d.ts.map +1 -1
  429. package/package.json +36 -9
  430. package/postinstall.mjs +131 -0
  431. package/ratex-version.json +21 -0
  432. package/src/EnrichedMarkdownNativeComponent.ts +65 -0
  433. package/src/EnrichedMarkdownTextInput.tsx +162 -8
  434. package/src/EnrichedMarkdownTextInputNativeComponent.ts +89 -5
  435. package/src/EnrichedMarkdownTextNativeComponent.ts +66 -0
  436. package/src/headingDefaults.ts +21 -0
  437. package/src/index.tsx +9 -0
  438. package/src/index.web.tsx +1 -0
  439. package/src/jest/index.tsx +215 -0
  440. package/src/native/EnrichedMarkdownText.tsx +40 -2
  441. package/src/normalizeMarkdownStyle.ts +102 -16
  442. package/src/normalizeMarkdownStyle.web.ts +63 -15
  443. package/src/normalizeMarkdownTextInputStyle.ts +110 -35
  444. package/src/normalizeMenuItem.ts +1 -1
  445. package/src/types/MarkdownStyle.ts +74 -0
  446. package/src/types/MarkdownStyleInternal.ts +39 -0
  447. package/src/types/MarkdownTextProps.ts +65 -0
  448. package/src/types/MarkdownTextProps.web.ts +19 -0
  449. package/src/types/events.ts +21 -0
  450. package/src/utils/textInputState.ts +35 -0
  451. package/src/web/EnrichedMarkdownText.tsx +16 -2
  452. package/src/web/parseMarkdown.ts +6 -2
  453. package/src/web/renderers/InlineRenderers.tsx +5 -0
  454. package/src/web/renderers/ListRenderers.tsx +14 -2
  455. package/src/web/renderers/index.tsx +6 -2
  456. package/src/web/styles.ts +89 -5
  457. package/src/web/types.ts +6 -1
  458. package/src/web/utils.ts +1 -0
  459. package/src/web/wasm/md4c.js +0 -0
  460. package/vendor-grammars.mjs +511 -0
  461. package/vendor-ratex.mjs +173 -0
  462. package/app.plugin.js +0 -2
  463. package/plugin/build/withAndroidMath.js +0 -19
  464. package/plugin/build/withIosMath.js +0 -28
  465. package/plugin/build/withReactNativeEnrichedMarkdown.js +0 -11
  466. package/plugin/src/withAndroidMath.ts +0 -24
  467. package/plugin/src/withIosMath.ts +0 -35
  468. package/plugin/src/withReactNativeEnrichedMarkdown.ts +0 -17
  469. package/plugin/tsconfig.build.json +0 -16
@@ -1,6 +1,5 @@
1
1
  package com.swmansion.enriched.markdown.input
2
2
 
3
- import android.content.ClipData
4
3
  import android.content.ClipboardManager
5
4
  import android.content.Context
6
5
  import android.graphics.BlendMode
@@ -20,6 +19,9 @@ import android.view.inputmethod.EditorInfo
20
19
  import android.view.inputmethod.InputConnection
21
20
  import android.view.inputmethod.InputMethodManager
22
21
  import androidx.appcompat.widget.AppCompatEditText
22
+ import androidx.core.view.ContentInfoCompat
23
+ import androidx.core.view.OnReceiveContentListener
24
+ import androidx.core.view.ViewCompat
23
25
  import com.facebook.react.common.ReactConstants
24
26
  import com.facebook.react.uimanager.BackgroundStyleApplicator
25
27
  import com.facebook.react.uimanager.PixelUtil
@@ -28,45 +30,55 @@ import com.facebook.react.views.text.ReactTypefaceUtils
28
30
  import com.swmansion.enriched.markdown.input.autolink.AutoLinkDetector
29
31
  import com.swmansion.enriched.markdown.input.autolink.LinkRegexConfig
30
32
  import com.swmansion.enriched.markdown.input.detection.DetectorPipeline
31
- import com.swmansion.enriched.markdown.input.detection.WordsUtils
33
+ import com.swmansion.enriched.markdown.input.editing.BlockEditCoordinator
34
+ import com.swmansion.enriched.markdown.input.editing.ClipboardCoordinator
35
+ import com.swmansion.enriched.markdown.input.editing.EditContext
36
+ import com.swmansion.enriched.markdown.input.editing.EditPhase
37
+ import com.swmansion.enriched.markdown.input.editing.EditPipeline
38
+ import com.swmansion.enriched.markdown.input.editing.EditPipelineHost
39
+ import com.swmansion.enriched.markdown.input.editing.EditSession
32
40
  import com.swmansion.enriched.markdown.input.editing.InputConnectionWrapper
41
+ import com.swmansion.enriched.markdown.input.editing.LinkCoordinator
33
42
  import com.swmansion.enriched.markdown.input.editing.MarkdownEditableFactory
34
43
  import com.swmansion.enriched.markdown.input.editing.MarkdownTextWatcher
44
+ import com.swmansion.enriched.markdown.input.editing.MentionCoordinator
45
+ import com.swmansion.enriched.markdown.input.editing.MentionEvent
46
+ import com.swmansion.enriched.markdown.input.editing.ZWSP
47
+ import com.swmansion.enriched.markdown.input.editing.isLineBreak
48
+ import com.swmansion.enriched.markdown.input.formatting.BlockStore
35
49
  import com.swmansion.enriched.markdown.input.formatting.FormattingStore
36
50
  import com.swmansion.enriched.markdown.input.formatting.InputFormatter
37
51
  import com.swmansion.enriched.markdown.input.formatting.InputParser
38
52
  import com.swmansion.enriched.markdown.input.layout.InputEventEmitter
39
53
  import com.swmansion.enriched.markdown.input.layout.InputLayoutManager
40
54
  import com.swmansion.enriched.markdown.input.math.MathChipController
55
+ import com.swmansion.enriched.markdown.input.model.BlockRange
56
+ import com.swmansion.enriched.markdown.input.model.BlockType
41
57
  import com.swmansion.enriched.markdown.input.model.FormattingRange
42
58
  import com.swmansion.enriched.markdown.input.model.InputFormatterStyle
59
+ import com.swmansion.enriched.markdown.input.model.MathAtom
43
60
  import com.swmansion.enriched.markdown.input.model.MathChipStyle
61
+ import com.swmansion.enriched.markdown.input.model.OBJECT_REPLACEMENT
44
62
  import com.swmansion.enriched.markdown.input.model.StyleType
45
63
  import com.swmansion.enriched.markdown.input.toolbar.InputContextMenu
46
64
  import com.swmansion.enriched.markdown.utils.input.AutoCapitalizeUtils
65
+ import com.swmansion.enriched.markdown.utils.input.PasteTextUtils
47
66
  import kotlin.math.ceil
48
67
 
49
- private fun Char.isLineBreak(): Boolean = this == '\n' || this == '\r' || this == '\u0085' || this == '\u2028' || this == '\u2029'
50
-
51
68
  class EnrichedMarkdownTextInputView(
52
69
  context: Context,
53
70
  ) : AppCompatEditText(context) {
54
71
  private var isComponentReady = false
55
72
 
56
73
  val formattingStore = FormattingStore()
57
- val formatter = InputFormatter()
74
+ val blockStore = BlockStore()
75
+ val formatter = InputFormatter(resources.displayMetrics.density)
58
76
  val pendingStyles = mutableSetOf<StyleType>()
59
77
  val pendingStyleRemovals = mutableSetOf<StyleType>()
60
78
 
61
- var isDuringTransaction = false
62
- private set
63
-
64
- var blockEmitting = false
79
+ val editSession = EditSession()
80
+ val blockCoordinator = BlockEditCoordinator(blockStore)
65
81
 
66
- private var isTextChanging = false
67
- var isProcessingTextChange = false
68
- private set
69
- private var didTextChangeRecently = false
70
82
  private var lastProcessedText: String = ""
71
83
  private var preEditSelectionStart = 0
72
84
  private var preEditSelectionEnd = 0
@@ -85,23 +97,109 @@ class EnrichedMarkdownTextInputView(
85
97
  val eventEmitter = InputEventEmitter(this)
86
98
  private val autoLinkDetector = AutoLinkDetector(formattingStore)
87
99
  private val detectorPipeline = DetectorPipeline()
100
+ private val mentionCoordinator = MentionCoordinator(formattingStore)
101
+ private val linkCoordinator = LinkCoordinator(formattingStore, autoLinkDetector)
88
102
 
89
- val mathChipController: MathChipController? = MathChipController.resolve(context)
90
- private var inputStyle: InputFormatterStyle? = null
91
- private var activeMathChipPosition: Int = -1
92
- private var pendingMathAtoms: List<com.swmansion.enriched.markdown.input.model.MathAtom> = emptyList()
93
- private var lastReportedBlockWidth: Int = -1
103
+ private val editPipelineHost =
104
+ object : EditPipelineHost {
105
+ override val editable: Editable? get() = text
106
+ override val emitMarkdown: Boolean get() = this@EnrichedMarkdownTextInputView.emitMarkdown
107
+
108
+ override fun syncEmptyListAnchor(restamp: Boolean) = this@EnrichedMarkdownTextInputView.syncEmptyListAnchor(restamp)
109
+
110
+ override fun forceScrollToSelection() = this@EnrichedMarkdownTextInputView.forceScrollToSelection()
111
+
112
+ override fun syncCursorSizeWithBlock() = this@EnrichedMarkdownTextInputView.syncCursorSizeWithBlock()
113
+
114
+ override fun updateActiveMention() = this@EnrichedMarkdownTextInputView.dispatchMentionUpdate()
115
+
116
+ override fun runAsATransaction(block: () -> Unit) = this@EnrichedMarkdownTextInputView.runAsATransaction(block)
117
+
118
+ override fun setViewSelection(position: Int) = setSelection(position)
119
+ }
120
+
121
+ val editPipeline =
122
+ EditPipeline(
123
+ formattingStore = formattingStore,
124
+ blockStore = blockStore,
125
+ formatter = formatter,
126
+ detectorPipeline = detectorPipeline,
127
+ eventEmitter = eventEmitter,
128
+ host = editPipelineHost,
129
+ )
94
130
 
95
131
  private var textWatcher: MarkdownTextWatcher? = null
96
132
  private var inputMethodManager: InputMethodManager? = null
97
133
  private var detectScrollMovement = false
98
134
  var scrollEnabled: Boolean = true
99
135
 
100
- private var mentionIndicators: LinkedHashSet<String> = linkedSetOf()
101
- private var activeMentionIndicator: String? = null
102
- private var activeMentionStart = -1
103
- private var activeMentionEnd = -1
104
- private var activeMentionText = ""
136
+ private val clipboardCoordinator = ClipboardCoordinator(formattingStore, blockStore, detectorPipeline, formatter)
137
+
138
+ // region Math chip state
139
+ //
140
+ // Null when the build has math disabled (the `src/math/java` source set is excluded,
141
+ // so `resolve()` finds no implementation). Every math path below returns early on
142
+ // null, which is what makes a math-disabled build a silent no-op instead of a crash.
143
+ val mathChipController: MathChipController? = MathChipController.resolve(context)
144
+
145
+ // The chip spans hold a resolved style object, so the view has to keep the last
146
+ // `markdownStyle` around to re-stamp them; InputFormatter keeps its copy private.
147
+ private var inputStyle: InputFormatterStyle? = null
148
+
149
+ // Chip whose selected styling is currently drawn, or -1. Kept so the caret leaving
150
+ // the chip can clear it — a span cannot observe selection changes itself.
151
+ private var activeMathChipPosition = -1
152
+
153
+ // Atoms parsed before `markdownStyle` arrived. @ReactProp setters fire in undefined
154
+ // order, so `defaultValue` can land first; they are drained by the style setter.
155
+ private var pendingMathAtoms: List<MathAtom> = emptyList()
156
+
157
+ private var lastReportedBlockWidth = -1
158
+ // endregion
159
+
160
+ private var headingOverrideBaseSizePx: Float? = null
161
+ private var baseHintColor: Int? = null
162
+
163
+ // Number of ZWSP empty-list anchors believed live in the buffer. Bumped on insert,
164
+ // made exact on every strip scan (which recounts what it keeps), and reset when the
165
+ // buffer is replaced. Incoming markdown is scrubbed of U+200B at parse time, so in
166
+ // practice only syncEmptyListAnchor's own inserts raise it. When it is 0 there is
167
+ // nothing to strip, letting syncEmptyListAnchor skip its O(document) backward scan
168
+ // on every caret move.
169
+ private var zwspAnchorCount = 0
170
+
171
+ // The consumer-set placeholder, hidden while a bullet is drawn on an empty editor.
172
+ private var userHint: CharSequence? = null
173
+
174
+ // When on, a paste is reported to JS instead of inserted. Native never touches
175
+ // the buffer here (see resolvePaste): if JS dies mid-handler the paste is a
176
+ // visible no-op rather than a selection that silently vanished.
177
+ //
178
+ // The listener is installed only while the prop is on. Registering it
179
+ // unconditionally would route every consumer's paste through AppCompat's
180
+ // receive-content path even with the feature off, which is a behavior change
181
+ // nobody asked for.
182
+ var interceptPaste: Boolean = false
183
+ set(value) {
184
+ if (field == value) return
185
+ field = value
186
+ ViewCompat.setOnReceiveContentListener(
187
+ this,
188
+ if (value) PASTE_MIME_TYPES else null,
189
+ if (value) receiveContentListener else null,
190
+ )
191
+ }
192
+
193
+ // The markdown last handed over by JS, kept so `blockSyntax` can re-parse it. @ReactProp
194
+ // setters fire in undefined order, so `defaultValue` regularly lands before the flag does.
195
+ private var lastMarkdownFromJS: String? = null
196
+
197
+ var blockSyntax: Boolean = true
198
+ set(value) {
199
+ if (field == value) return
200
+ field = value
201
+ lastMarkdownFromJS?.let { setValueFromJS(it) }
202
+ }
105
203
 
106
204
  init {
107
205
  setupDetectorPipeline()
@@ -110,16 +208,6 @@ class EnrichedMarkdownTextInputView(
110
208
  isComponentReady = true
111
209
  }
112
210
 
113
- /**
114
- * Width a full-bleed block math chip can claim. Read late (every layout pass),
115
- * because TextView's width is established after measure. Falls back to 0 when
116
- * we haven't been measured yet — span will then use its intrinsic width.
117
- */
118
- private fun availableBlockWidth(): Int {
119
- val w = width - totalPaddingLeft - totalPaddingRight
120
- return w.coerceAtLeast(0)
121
- }
122
-
123
211
  private fun setupDetectorPipeline() {
124
212
  autoLinkDetector.onLinkDetected = { text, url, start, end ->
125
213
  eventEmitter.emitLinkDetected(text, url, start, end)
@@ -164,6 +252,13 @@ class EnrichedMarkdownTextInputView(
164
252
  runAsATransaction { super.setTextIsSelectable(true) }
165
253
  }
166
254
 
255
+ /**
256
+ * Width a full-bleed block math chip may claim. Read late (on every layout pass)
257
+ * because a TextView's width is only known after measure; 0 before the first
258
+ * measure, which makes the span fall back to its intrinsic width.
259
+ */
260
+ private fun availableBlockWidth(): Int = (width - totalPaddingLeft - totalPaddingRight).coerceAtLeast(0)
261
+
167
262
  override fun onSizeChanged(
168
263
  w: Int,
169
264
  h: Int,
@@ -175,9 +270,9 @@ class EnrichedMarkdownTextInputView(
175
270
  val available = availableBlockWidth()
176
271
  if (available == lastReportedBlockWidth) return
177
272
  lastReportedBlockWidth = available
178
- // First measurement (oldw==0) is the moment block chips get a real width;
179
- // we need to invalidate their cached layout so getSize returns the new
180
- // full-bleed width. Same for rotation / resize.
273
+ // The first measure (oldw == 0) is where block chips first get a real width, so
274
+ // their cached layout has to be dropped for getSize to report the new full-bleed
275
+ // width. Rotation and resize land here too.
181
276
  refreshAllMathChips()
182
277
  }
183
278
 
@@ -198,16 +293,62 @@ class EnrichedMarkdownTextInputView(
198
293
  return InputConnectionWrapper(base, this)
199
294
  }
200
295
 
296
+ // Plain Tab is consumed before the platform's focus navigation so it inserts
297
+ // a tab character and emits onKeyPress, matching iOS. Shift/Ctrl+Tab still
298
+ // navigate focus.
201
299
  override fun onKeyDown(
202
300
  keyCode: Int,
203
301
  event: KeyEvent?,
204
302
  ): Boolean {
303
+ if (keyCode == KeyEvent.KEYCODE_TAB && event?.hasNoModifiers() != false) {
304
+ eventEmitter.emitKeyPress("Tab")
305
+ val start = minOf(selectionStart, selectionEnd).coerceAtLeast(0)
306
+ val end = maxOf(selectionStart, selectionEnd).coerceAtLeast(0)
307
+ text?.replace(start, end, "\t")
308
+ return true
309
+ }
205
310
  if (keyCode == KeyEvent.KEYCODE_DEL && deleteLinkBeforeCursor()) {
206
311
  return true
207
312
  }
313
+ if (handleListKey(keyCode, event)) {
314
+ return true
315
+ }
208
316
  return super.onKeyDown(keyCode, event)
209
317
  }
210
318
 
319
+ /**
320
+ * Hardware-keyboard list editing: Tab indents the current item, Shift+Tab outdents,
321
+ * and Backspace at the start of an item (or on an empty/ZWSP-anchored item) outdents,
322
+ * then un-lists at depth 0. Only fires on a list line; returns true when handled.
323
+ */
324
+ private fun handleListKey(
325
+ keyCode: Int,
326
+ event: KeyEvent?,
327
+ ): Boolean {
328
+ val listBlock = listBlockAtCursor() ?: return false
329
+ val depth = listBlock.level
330
+ when (keyCode) {
331
+ KeyEvent.KEYCODE_TAB -> {
332
+ if (event?.isShiftPressed == true) outdentList() else indentList()
333
+ return true
334
+ }
335
+
336
+ KeyEvent.KEYCODE_DEL -> {
337
+ if (selectionStart == selectionEnd) {
338
+ val editable = text ?: return false
339
+ val ls = blockCoordinator.lineStartOf(editable, selectionStart)
340
+ val le = blockCoordinator.lineEndOf(editable, selectionStart)
341
+ val content = editable.subSequence(ls, le).toString()
342
+ if (selectionStart == ls || content.isEmpty() || content == ZWSP.toString()) {
343
+ if (depth > 0) outdentList() else toggleListType(listBlock.type)
344
+ return true
345
+ }
346
+ }
347
+ }
348
+ }
349
+ return false
350
+ }
351
+
211
352
  // Prevents TextView from deferring its internal layout when a Fabric
212
353
  // state-update (height change) triggers requestLayout(). Without this
213
354
  // override the deferred relayout causes a visible flicker of styled spans.
@@ -234,8 +375,15 @@ class EnrichedMarkdownTextInputView(
234
375
 
235
376
  MotionEvent.ACTION_UP -> {
236
377
  val chipPosition = mathChipPositionAtTouch(ev.x, ev.y)
237
- if (chipPosition >= 0 && handleMathChipTap(chipPosition)) {
238
- return true
378
+ if (chipPosition >= 0) {
379
+ // super FIRST, then the chip handling on top of it. ACTION_UP is where View
380
+ // takes focus and where the editor shows the soft keyboard; consuming it
381
+ // outright would make the first tap on a chip in an unfocused editor open
382
+ // nothing — the Android twin of the iOS tap-to-focus stall fixed in 475ecdab.
383
+ // handleMathChipTap re-seats the caret afterwards, overriding the hit-test
384
+ // position super just set.
385
+ val handledBySuper = super.onTouchEvent(ev)
386
+ return handleMathChipTap(chipPosition) || handledBySuper
239
387
  }
240
388
  }
241
389
  }
@@ -250,6 +398,10 @@ class EnrichedMarkdownTextInputView(
250
398
  x: Int,
251
399
  y: Int,
252
400
  ) {
401
+ if (text.isNullOrEmpty()) {
402
+ super.scrollTo(x, 0)
403
+ return
404
+ }
253
405
  if (!scrollEnabled) return
254
406
  super.scrollTo(x, y)
255
407
  }
@@ -267,17 +419,16 @@ class EnrichedMarkdownTextInputView(
267
419
  }
268
420
 
269
421
  fun runAsATransaction(block: () -> Unit) {
270
- try {
271
- isDuringTransaction = true
422
+ if (editSession.phase != EditPhase.Idle) {
272
423
  block()
273
- } finally {
274
- isDuringTransaction = false
424
+ } else {
425
+ editSession.scoped(EditPhase.Processing) { block() }
275
426
  }
276
427
  }
277
428
 
278
429
  fun onBeforeTextChanged() {
279
- if (isProcessingTextChange) return
280
- isTextChanging = true
430
+ if (editSession.phase != EditPhase.Idle) return
431
+ editSession.isTextChanging = true
281
432
  preEditSelectionStart = selectionStart
282
433
  preEditSelectionEnd = selectionEnd
283
434
  }
@@ -287,32 +438,30 @@ class EnrichedMarkdownTextInputView(
287
438
  deletedLength: Int,
288
439
  insertedLength: Int,
289
440
  ) {
290
- if (isProcessingTextChange) return
441
+ if (editSession.phase != EditPhase.Idle) return
291
442
 
292
443
  val currentText = text?.toString() ?: ""
293
444
  if (currentText == lastProcessedText) return
294
445
 
295
- isProcessingTextChange = true
446
+ editSession.enter(EditPhase.Processing)
296
447
  try {
297
- formattingStore.adjustForEdit(editStart, deletedLength, insertedLength)
298
- applyPendingStyles(editStart, insertedLength)
299
- applyFormatting()
300
-
301
- val editable = text
302
- if (editable != null) {
303
- detectorPipeline.processTextChange(editable, currentText, editStart, insertedLength)
304
- }
305
-
306
- forceScrollToSelection()
307
- eventEmitter.emitChangeText()
308
- if (emitMarkdown) eventEmitter.emitChangeMarkdown()
309
- updateActiveMention()
310
- eventEmitter.emitCaretRectChangeIfNeeded()
311
- isTextChanging = false
312
- didTextChangeRecently = true
313
- lastProcessedText = currentText
448
+ val context =
449
+ EditContext(
450
+ editStart = editStart,
451
+ deletedLength = deletedLength,
452
+ insertedLength = insertedLength,
453
+ preEditText = currentText,
454
+ preEditSelectionStart = preEditSelectionStart,
455
+ preEditSelectionEnd = preEditSelectionEnd,
456
+ pendingStyles = pendingStyles.toSet(),
457
+ pendingStyleRemovals = pendingStyleRemovals.toSet(),
458
+ )
459
+ editPipeline.processTextChange(context)
460
+ editSession.isTextChanging = false
461
+ editSession.didTextChangeRecently = true
462
+ lastProcessedText = text?.toString() ?: currentText
314
463
  } finally {
315
- isProcessingTextChange = false
464
+ editSession.exit()
316
465
  }
317
466
  }
318
467
 
@@ -321,70 +470,107 @@ class EnrichedMarkdownTextInputView(
321
470
  selEnd: Int,
322
471
  ) {
323
472
  super.onSelectionChanged(selStart, selEnd)
324
- if (!isComponentReady || isDuringTransaction) return
473
+ if (!isComponentReady || editSession.shouldSuppressTextWatcher) return
325
474
 
326
- if (!isTextChanging) {
327
- // Links (e.g. mentions) are atomic: snap a partial selection to the whole link, and a caret
328
- // inside a link to its end. Returning lets the recursive onSelectionChanged emit for the result.
475
+ if (!editSession.isTextChanging) {
329
476
  formattingStore.selectionAdjustedForAtomicLinks(selStart, selEnd)?.let { (newStart, newEnd) ->
330
477
  setSelection(newStart, newEnd)
331
478
  return
332
479
  }
333
- if (didTextChangeRecently) {
334
- didTextChangeRecently = false
480
+ if (editSession.didTextChangeRecently) {
481
+ editSession.didTextChangeRecently = false
335
482
  } else {
336
483
  pendingStyles.clear()
337
484
  pendingStyleRemovals.clear()
485
+ seedPendingStylesFromSelection(selStart, selEnd)
338
486
  }
339
487
  }
340
488
 
341
- val isCaretOnChip =
489
+ if (!editSession.isTextChanging && editSession.phase == EditPhase.Idle) {
490
+ // The caret moving on/off an empty bullet line toggles the ZWSP anchor and the
491
+ // placeholder visibility; skip during a text-change pass (handled there).
492
+ syncEmptyListAnchor()
493
+ }
494
+
495
+ // A chip stays highlighted only while the caret sits on either of its two edges;
496
+ // any other caret move (or a real selection) drops the highlight.
497
+ val isCaretOnActiveChip =
342
498
  selStart == selEnd &&
343
499
  activeMathChipPosition >= 0 &&
344
500
  (selStart == activeMathChipPosition || selStart == activeMathChipPosition + 1)
345
- if (!isCaretOnChip) {
501
+ if (!isCaretOnActiveChip) {
346
502
  clearMathChipSelections()
347
503
  activeMathChipPosition = -1
348
504
  }
349
505
 
350
506
  eventEmitter.emitSelection(selStart, selEnd)
351
- updateActiveMention()
507
+ dispatchMentionUpdate()
352
508
  eventEmitter.emitState()
353
509
  eventEmitter.emitCaretRectChangeIfNeeded()
354
510
  }
355
511
 
356
- private fun applyPendingStyles(
357
- editStart: Int,
358
- insertedLength: Int,
512
+ /**
513
+ * Text typed over a non-empty selection inherits the inline styles of the first
514
+ * selected character (mirrors iOS rebuildFromContext and the range-inheritance
515
+ * rule in [com.swmansion.enriched.markdown.input.formatting.RangeEditAdjustment]).
516
+ * Seeding here is the only chance to carry the style through the whole typed
517
+ * run: the post-edit grace period above skips reseeding between keystrokes.
518
+ * LINK is excluded — typing over a selected link replaces it, not extends it.
519
+ */
520
+ private fun seedPendingStylesFromSelection(
521
+ selStart: Int,
522
+ selEnd: Int,
359
523
  ) {
360
- if (insertedLength == 0) return
361
- if (pendingStyles.isEmpty() && pendingStyleRemovals.isEmpty()) return
362
-
363
- val rangeStart = if (preEditSelectionStart != preEditSelectionEnd) preEditSelectionStart else editStart
364
- val rangeEnd = rangeStart + insertedLength
365
-
366
- // Skip applying pending styles when the insertion is only line breaks —
367
- // a phantom range over a bare newline corrupts isStyleActive() at the boundary.
368
- val currentText = text
369
- val insertedHasGlyphContent =
370
- currentText != null &&
371
- rangeEnd <= currentText.length &&
372
- (rangeStart until rangeEnd).any { !currentText[it].isLineBreak() }
373
-
374
- if (insertedHasGlyphContent) {
375
- for (style in pendingStyles) {
376
- formattingStore.addRange(FormattingRange(style, rangeStart, rangeEnd))
524
+ if (selStart == selEnd) return
525
+ for (style in StyleType.entries) {
526
+ if (style == StyleType.LINK) continue
527
+ if (formattingStore.isStyleActive(style, selStart)) {
528
+ pendingStyles.add(style)
377
529
  }
378
530
  }
531
+ }
379
532
 
380
- for (style in pendingStyleRemovals) {
381
- formattingStore.removeType(style, rangeStart, rangeEnd)
533
+ /**
534
+ * Adjusts both [formattingStore] and [blockStore] for a text edit, then prunes
535
+ * orphaned anchors and normalizes block ranges to line bounds. Every code path
536
+ * that mutates the text buffer must call this so block ranges stay in sync —
537
+ * mirrors iOS's `replaceTextInRange:withText:formattingRanges:blockRanges:`.
538
+ */
539
+ private fun adjustStoresForEdit(
540
+ editStart: Int,
541
+ deletedLength: Int,
542
+ insertedLength: Int,
543
+ ) {
544
+ formattingStore.adjustForEdit(editStart, deletedLength, insertedLength)
545
+ blockStore.adjustForEdit(editStart, deletedLength, insertedLength)
546
+ editPipeline.pruneOrphanedAnchors()
547
+ text?.let { blockStore.normalizeToLineBounds(it) }
548
+ }
549
+
550
+ private inline fun replaceTextInRange(
551
+ start: Int,
552
+ end: Int,
553
+ newText: String,
554
+ postAdjust: (Editable) -> Unit = {},
555
+ ) {
556
+ val editable = text ?: return
557
+ editSession.enter(EditPhase.Processing)
558
+ try {
559
+ editable.replace(start, end, newText)
560
+ adjustStoresForEdit(start, end - start, newText.length)
561
+ postAdjust(editable)
562
+ lastProcessedText = editable.toString()
563
+ applyFormattingAndEmit()
564
+ eventEmitter.emitChangeText()
565
+ } finally {
566
+ editSession.exit()
382
567
  }
383
568
  }
384
569
 
385
570
  fun applyFormatting() {
386
571
  val editable = text ?: return
387
572
  formatter.applyFormatting(editable, formattingStore.allRanges)
573
+ formatter.applyBlockFormatting(editable, blockStore.allRanges)
388
574
  }
389
575
 
390
576
  private fun applyFormattingAndEmit() {
@@ -395,6 +581,11 @@ class EnrichedMarkdownTextInputView(
395
581
  }
396
582
 
397
583
  private fun forceScrollToSelection() {
584
+ if (text.isNullOrEmpty()) {
585
+ super.scrollTo(scrollX, 0)
586
+ return
587
+ }
588
+
398
589
  val textLayout = layout ?: return
399
590
  val cursorOffset = selectionStart
400
591
  if (cursorOffset <= 0) return
@@ -423,21 +614,12 @@ class EnrichedMarkdownTextInputView(
423
614
  fun toggleInlineStyle(styleType: StyleType) {
424
615
  val handler = formatter.handlers[styleType] ?: return
425
616
  val mergingConfig = handler.mergingConfig
426
-
427
617
  val selStart = selectionStart
428
618
  val selEnd = selectionEnd
429
619
 
430
- // Check blocking rules: if any blocking style is active, refuse to toggle on.
431
- if (mergingConfig.blockingStyles.isNotEmpty()) {
432
- val isCurrentlyActive = formattingStore.isStyleActive(styleType, selStart)
433
- if (!isCurrentlyActive) {
434
- for (blocker in mergingConfig.blockingStyles) {
435
- if (formattingStore.isStyleActive(blocker, selStart)) {
436
- return
437
- }
438
- }
439
- }
440
- }
620
+ if (formattingStore.isToggleBlocked(styleType, selStart, mergingConfig.blockingStyles)) return
621
+
622
+ val result = formattingStore.toggleStyle(styleType, selStart, selEnd, mergingConfig.conflictingStyles)
441
623
 
442
624
  if (selStart == selEnd) {
443
625
  if (pendingStyleRemovals.contains(styleType)) {
@@ -446,72 +628,419 @@ class EnrichedMarkdownTextInputView(
446
628
  } else if (pendingStyles.contains(styleType)) {
447
629
  pendingStyles.remove(styleType)
448
630
  pendingStyleRemovals.add(styleType)
449
- } else if (formattingStore.isStyleActive(styleType, selStart)) {
631
+ } else if (result == FormattingStore.ToggleResult.WAS_ACTIVE) {
450
632
  pendingStyleRemovals.add(styleType)
451
633
  } else {
452
634
  pendingStyles.add(styleType)
453
635
  }
454
636
  eventEmitter.emitState()
455
637
  } else {
456
- val isActive = formattingStore.isStyleActive(styleType, selStart)
457
- if (isActive) {
458
- formattingStore.removeType(styleType, selStart, selEnd)
459
- } else {
460
- // Remove conflicting styles from the range before applying.
461
- for (conflict in mergingConfig.conflictingStyles) {
462
- formattingStore.removeType(conflict, selStart, selEnd)
638
+ applyFormattingAndEmit()
639
+ pendingStyles.clear()
640
+ pendingStyleRemovals.clear()
641
+ seedPendingStylesFromSelection(selStart, selEnd)
642
+ }
643
+ }
644
+
645
+ private fun listBlockAtCursor(): BlockRange? {
646
+ val editable = text ?: return null
647
+ return blockCoordinator.listBlockAtPosition(editable, selectionStart)
648
+ }
649
+
650
+ fun listStateAtCursor(type: BlockType): Pair<Boolean, Int> {
651
+ val editable = text ?: return false to 0
652
+ return blockCoordinator.listStateAtPosition(editable, selectionStart, type)
653
+ }
654
+
655
+ fun toggleUnorderedList() = toggleListType(BlockType.UNORDERED_LIST_ITEM)
656
+
657
+ fun toggleOrderedList() = toggleListType(BlockType.ORDERED_LIST_ITEM)
658
+
659
+ private fun toggleListType(type: BlockType) {
660
+ val editable = text ?: return
661
+ blockCoordinator.toggleList(editable, type, selectionStart, selectionStart, selectionEnd)
662
+ applyFormattingAndEmit()
663
+ syncEmptyListAnchor()
664
+ }
665
+
666
+ /** Increases the nesting depth of the selected list item(s). QoL: indenting a plain paragraph starts a list. */
667
+ fun indentList() = changeListDepthBy(1)
668
+
669
+ /** Decreases the nesting depth; outdenting at depth 0 removes the list marker. */
670
+ fun outdentList() = changeListDepthBy(-1)
671
+
672
+ private fun changeListDepthBy(delta: Int) {
673
+ val editable = text ?: return
674
+ val result = blockCoordinator.changeDepth(editable, selectionStart, selectionStart, selectionEnd, delta)
675
+ if (result == BlockEditCoordinator.DepthChangeResult.NO_OP) return
676
+ applyFormattingAndEmit()
677
+ syncEmptyListAnchor()
678
+ }
679
+
680
+ /**
681
+ * Keeps an empty bullet line anchored by a ZWSP so its marker draws and the caret
682
+ * indents (a [android.text.style.LeadingMarginSpan] doesn't indent an empty
683
+ * paragraph). Inserts the ZWSP on the caret's empty list line, strips stale ones.
684
+ *
685
+ * @param restamp re-apply block formatting here (selection/command paths); the
686
+ * text-change pass passes false and stamps once afterwards.
687
+ * @return true if an anchor was inserted or stripped (text/ranges mutated).
688
+ *
689
+ * When called from `onAfterTextChanged` this mutates the [Editable] from inside
690
+ * the text-change callback — normally an Android hazard around IME composition,
691
+ * undo/redo, and accessibility. It is safe here, and deliberately synchronous,
692
+ * because: every buffer mutation is wrapped in [runAsATransaction] (which keeps
693
+ * [EditSession.phase] non-idle, so [MarkdownTextWatcher] early-returns and the edit
694
+ * does not re-enter this pass), the whole `onAfterTextChanged` body holds
695
+ * [EditPhase.Processing], and [EditPhase.ManagingAnchors] blocks re-entry via the
696
+ * selection-change path. The mutation must stay synchronous: the single-stamp
697
+ * ordering above, the caret placement past the anchor, and `lastProcessedText`
698
+ * all depend on the buffer reaching its final state within this same callback —
699
+ * deferring the insert/delete (e.g. via `post`) would run it outside every guard
700
+ * and reintroduce the double-bullet and stale-caret bugs.
701
+ */
702
+ private fun syncEmptyListAnchor(restamp: Boolean = true): Boolean {
703
+ if (editSession.shouldSuppressAnchorSync) return false
704
+ val editable = text ?: return false
705
+ editSession.enter(EditPhase.ManagingAnchors)
706
+ var anchorChanged = false
707
+ try {
708
+ // Strip every stale ZWSP first (a line that gained content or stopped being a
709
+ // list). Skip the full-document scan when we've inserted no anchors — there is
710
+ // nothing to strip, so a caret move in a document with no empty list lines is
711
+ // O(1) instead of O(document length). When the scan does run it recounts the
712
+ // anchors it keeps, so the count is exact afterwards — a prior overcount (an
713
+ // anchor the user deleted directly) or an undercount (a foreign ZWSP that stole
714
+ // a decrement) self-heals rather than disabling the fast path or leaking a
715
+ // stale anchor.
716
+ if (zwspAnchorCount > 0) {
717
+ var keptAnchors = 0
718
+ var i = editable.length - 1
719
+ while (i >= 0) {
720
+ if (editable[i] == ZWSP) {
721
+ val ls = blockCoordinator.lineStartOf(editable, i)
722
+ val le = blockCoordinator.lineEndOf(editable, i)
723
+ val onlyZwsp = le - ls == 1 && editable[ls] == ZWSP
724
+ val isEmptyListLine = onlyZwsp && blockCoordinator.listBlockAtLineStart(ls) != null
725
+ if (!isEmptyListLine) {
726
+ runAsATransaction { editable.delete(i, i + 1) }
727
+ blockStore.adjustForEdit(i, 1, 0)
728
+ anchorChanged = true
729
+ } else {
730
+ keptAnchors++
731
+ }
732
+ }
733
+ i--
463
734
  }
464
- formattingStore.addRange(FormattingRange(styleType, selStart, selEnd))
735
+ zwspAnchorCount = keptAnchors
465
736
  }
466
- applyFormattingAndEmit()
737
+
738
+ val caret = selectionStart
739
+ if (selectionStart == selectionEnd) {
740
+ val ls = blockCoordinator.lineStartOf(editable, caret)
741
+ val le = blockCoordinator.lineEndOf(editable, caret)
742
+ val block = blockCoordinator.listBlockAtLineStart(ls)
743
+ if (block != null && le == ls) {
744
+ runAsATransaction { editable.insert(ls, ZWSP.toString()) }
745
+ blockStore.adjustForEdit(ls, 0, 1)
746
+ blockStore.normalizeToLineBounds(editable)
747
+ setSelection(ls + 1)
748
+ zwspAnchorCount++
749
+ anchorChanged = true
750
+ }
751
+ }
752
+
753
+ if (anchorChanged) {
754
+ // Re-snap any range left zero-length by a strip so the next stamp is exact.
755
+ blockStore.normalizeToLineBounds(editable)
756
+ if (restamp) applyFormatting()
757
+ lastProcessedText = editable.toString()
758
+ if (emitMarkdown) eventEmitter.emitChangeMarkdown()
759
+ }
760
+ syncHintVisibility()
761
+ return anchorChanged
762
+ } finally {
763
+ editSession.exit()
467
764
  }
468
765
  }
469
766
 
470
- // Copies the input's plain text to the system clipboard without disturbing
471
- // the current selection. Android's system clipboard round-trip is plain text
472
- // only — inline styles are not preserved for external paste targets.
767
+ /**
768
+ * The hint shows only on a truly empty editor with no block range — a bullet's
769
+ * ZWSP anchor counts as content, so the hint never overlaps a marker. Mirrors iOS.
770
+ */
771
+ private fun syncHintVisibility() {
772
+ val content = text
773
+ val hasRealText = content != null && content.any { it != ZWSP }
774
+ val hasBlock = blockStore.allRanges.isNotEmpty()
775
+ val target: CharSequence? = if (hasRealText || hasBlock) "" else userHint
776
+ if (hint != target) super.setHint(target)
777
+ }
778
+
779
+ fun setUserHint(value: CharSequence?) {
780
+ userHint = value
781
+ syncHintVisibility()
782
+ }
783
+
784
+ // Copies the whole input as markdown without disturbing the current selection,
785
+ // tagged so paste restores formatting and block ranges — mirrors iOS, which
786
+ // stores markdown under its custom pasteboard type.
473
787
  fun copyToClipboard() {
474
788
  val content = text
475
789
  if (content.isNullOrEmpty()) return
476
790
  val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
477
- clipboard.setPrimaryClip(ClipData.newPlainText(null, content))
791
+ val markdown = clipboardCoordinator.serializeFullDocument(content.toString(), content, mathChipsForSerialization())
792
+ clipboard.setPrimaryClip(MarkdownClipboard.newMarkdownClip(markdown, content.toString()))
793
+ }
794
+
795
+ // Copy/cut re-tag the clip with markdown and paste restores it, so formatting
796
+ // and block ranges survive the round trip — mirrors iOS's copy:/cut:/paste:
797
+ // overrides. External clips keep default handling.
798
+ //
799
+ // Paste arrives by two different routes and only the first one is a menu.
800
+ // This override covers the context menu and the hardware Ctrl+V /
801
+ // KEYCODE_PASTE shortcuts; drag-and-drop, autofill and IME `commitContent`
802
+ // reach the view through `onReceiveContent` instead. Hooking only one of them
803
+ // leaves the other pasting straight into the buffer with nobody intercepting.
804
+ override fun onTextContextMenuItem(id: Int): Boolean {
805
+ // Checked before the markdown paste below, which would otherwise insert first.
806
+ if (interceptPaste && (id == android.R.id.paste || id == android.R.id.pasteAsPlainText)) {
807
+ val text = readClipboardText()
808
+ if (text.isNotEmpty()) {
809
+ eventEmitter.emitPaste(text, "")
810
+ // Returning without super keeps this single-emit: AppCompat would route
811
+ // its own paste through the receive-content listener below.
812
+ return true
813
+ }
814
+ // Nothing textual in the clip: fall through to the regular handling,
815
+ // matching what the listener does with the items it does not claim.
816
+ }
817
+ if (id == android.R.id.paste) {
818
+ MarkdownClipboard.markdownFromClipboard(context)?.let { markdown ->
819
+ pasteMarkdown(markdown)
820
+ return true
821
+ }
822
+ // External plain text is treated as markdown so pasted syntax ("- ", "#",
823
+ // "**") formats instead of landing literal; syntax-free text is unchanged.
824
+ // "Paste as plain text" (pasteAsPlainText) keeps the literal default.
825
+ plainTextFromClipboard()?.let { plainText ->
826
+ pasteMarkdown(plainText)
827
+ return true
828
+ }
829
+ }
830
+ if (id == android.R.id.copy || id == android.R.id.cut) {
831
+ val selStart = selectionStart
832
+ val selEnd = selectionEnd
833
+ val plainText = if (selStart < selEnd) text?.substring(selStart, selEnd) else null
834
+ val markdown = markdownForSelectedRange()
835
+ val handled = super.onTextContextMenuItem(id)
836
+ if (handled && markdown != null && plainText != null) {
837
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
838
+ clipboard?.setPrimaryClip(MarkdownClipboard.newMarkdownClip(markdown, plainText))
839
+ }
840
+ return handled
841
+ }
842
+ return super.onTextContextMenuItem(id)
843
+ }
844
+
845
+ fun markdownForSelectedRange(): String? {
846
+ val fullText = text?.toString() ?: return null
847
+ return clipboardCoordinator.serializeSelectedRange(
848
+ fullText,
849
+ selectionStart,
850
+ selectionEnd,
851
+ text,
852
+ mathChipsForSerialization(),
853
+ )
854
+ }
855
+
856
+ private fun plainTextFromClipboard(): String? {
857
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return null
858
+ val item = clipboard.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0) ?: return null
859
+ return item.coerceToText(context)?.toString()?.takeIf { it.isNotEmpty() }
860
+ }
861
+
862
+ /**
863
+ * Replaces the selection with parsed markdown, importing its inline formatting
864
+ * and block ranges (headings etc.) into the stores — mirrors iOS pasteMarkdown.
865
+ *
866
+ * Insertion is literal: the string's characters land at the cursor exactly as
867
+ * given. Markdown parsing consumes leading and trailing newlines as block
868
+ * structure, so they are split off before the parse and re-attached verbatim;
869
+ * without this, insertText("\n- item\n") mid-paragraph would merge the list
870
+ * line with the surrounding text. Callers decide separation by including (or
871
+ * omitting) newlines in the string.
872
+ */
873
+ fun pasteMarkdown(markdown: String) {
874
+ val editable = text ?: return
875
+ val prefix = markdown.takeWhile { it == '\n' }
876
+ val suffix = if (markdown.length > prefix.length) markdown.takeLastWhile { it == '\n' } else ""
877
+ val core = markdown.substring(prefix.length, markdown.length - suffix.length)
878
+ val parsed = InputParser.parseToPlainTextAndRanges(core, blockSyntax)
879
+ val selStart = selectionStart.coerceIn(0, editable.length)
880
+ val selEnd = selectionEnd.coerceIn(selStart, editable.length)
881
+
882
+ val plainText = prefix + parsed.plainText + suffix
883
+ val contentStart = selStart + prefix.length
884
+
885
+ replaceTextInRange(selStart, selEnd, plainText) { editable ->
886
+ for (range in parsed.formattingRanges) {
887
+ formattingStore.addRange(
888
+ FormattingRange(range.type, range.start + contentStart, range.end + contentStart, range.url),
889
+ )
890
+ }
891
+ for (block in parsed.blockRanges) {
892
+ blockStore.setBlock(block.type, block.level, block.start + contentStart, block.end + contentStart, editable)
893
+ }
894
+ applyMathAtoms(editable, parsed.mathAtoms, offset = contentStart)
895
+ setSelection(selStart + plainText.length)
896
+ detectorPipeline.processTextChange(editable, editable.toString(), selStart, plainText.length)
897
+ }
898
+ }
899
+
900
+ /** Toggles a heading (H1-H6) on the cursor's paragraph(s); the active level toggles back to a paragraph. */
901
+ fun toggleHeading(level: Int) {
902
+ val blockType = BlockType.forHeadingLevel(level) ?: return
903
+ toggleBlockType(blockType, level)
904
+ }
905
+
906
+ private fun toggleBlockType(
907
+ type: BlockType,
908
+ level: Int,
909
+ ) {
910
+ val editable = text ?: return
911
+ val selStart = selectionStart.coerceIn(0, editable.length)
912
+ val selEnd = selectionEnd.coerceIn(selStart, editable.length)
913
+ blockCoordinator.toggleBlock(editable, type, level, selStart, selEnd)
914
+ applyFormattingAndEmit()
915
+ syncCursorSizeWithBlock()
916
+ }
917
+
918
+ private fun blockOnParagraphAt(pos: Int): BlockRange? {
919
+ val editable = text ?: return null
920
+ return blockCoordinator.blockAtPosition(editable, pos)
921
+ }
922
+
923
+ private fun listBlockStartingAt(lineStart: Int): BlockRange? = blockCoordinator.listBlockAtLineStart(lineStart)
924
+
925
+ fun headingLevelAtCursor(): Int {
926
+ val editable = text ?: return 0
927
+ return blockCoordinator.headingLevelAtPosition(editable, selectionStart)
928
+ }
929
+
930
+ /**
931
+ * On empty text with a heading block, overrides text size to the heading's
932
+ * font size so the cursor matches heading height. Hides the hint while
933
+ * active. Cleared automatically when text is typed or heading is toggled off.
934
+ */
935
+ private fun syncCursorSizeWithBlock() {
936
+ val editable = text ?: return
937
+ val block = blockOnParagraphAt(selectionStart)
938
+
939
+ if (block != null && block.type in BlockType.HEADINGS && editable.isEmpty()) {
940
+ val headingSizePx = formatter.resolveHeadingFontSizePx(block.level) ?: return
941
+ if (headingOverrideBaseSizePx == null) {
942
+ headingOverrideBaseSizePx = paint.textSize
943
+ baseHintColor = currentHintTextColor
944
+ setHintTextColor(Color.TRANSPARENT)
945
+ }
946
+ if (paint.textSize != headingSizePx) {
947
+ setTextSize(TypedValue.COMPLEX_UNIT_PX, headingSizePx)
948
+ }
949
+ } else {
950
+ headingOverrideBaseSizePx?.let { baseSizePx ->
951
+ setTextSize(TypedValue.COMPLEX_UNIT_PX, baseSizePx)
952
+ headingOverrideBaseSizePx = null
953
+ baseHintColor?.let { setHintTextColor(it) }
954
+ baseHintColor = null
955
+ }
956
+ }
957
+ }
958
+
959
+ private val receiveContentListener =
960
+ OnReceiveContentListener { _, payload ->
961
+ // The listener also sees autofill, drag-and-drop, IME commitContent and
962
+ // PROCESS_TEXT. Without this filter an autofill would turn into a paste
963
+ // event — and a consumer would turn that into an attachment chip.
964
+ if (!interceptPaste || payload.source != ContentInfoCompat.SOURCE_CLIPBOARD) {
965
+ return@OnReceiveContentListener payload
966
+ }
967
+ val parts = payload.partition { it.text != null }
968
+ parts.first?.let { accepted ->
969
+ val clip = accepted.clip
970
+ val text = PasteTextUtils.joinClipTexts((0 until clip.itemCount).map { clip.getItemAt(it).text })
971
+ if (text.isNotEmpty()) {
972
+ eventEmitter.emitPaste(text, "")
973
+ }
974
+ }
975
+ // Hand back only what we did not claim, so the buffer stays untouched.
976
+ parts.second
977
+ }
978
+
979
+ private fun readClipboardText(): String {
980
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return ""
981
+ val clip = clipboard.primaryClip ?: return ""
982
+ // `text`, not `coerceToText`: the listener can only claim items that already
983
+ // carry text, and the two routes have to agree on what counts as a paste.
984
+ return PasteTextUtils.joinClipTexts((0 until clip.itemCount).map { clip.getItemAt(it).text })
985
+ }
986
+
987
+ /**
988
+ * Completes a paste reported through `onPaste`. `pastedText` lands verbatim —
989
+ * not parsed as markdown, unlike the un-intercepted paste — and `markdown` is
990
+ * ignored. Android reports `markdown` as `''` (see `onTextContextMenuItem`),
991
+ * which is the contract 0.7.4 shipped with, although copy/cut here now tag the
992
+ * clip with markdown through [MarkdownClipboard].
993
+ *
994
+ * Goes through [replaceTextInRange] like [pasteMarkdown], so the formatting and
995
+ * block stores and the empty-list anchors are adjusted as for any other edit.
996
+ *
997
+ * Resolving with an empty string and an empty selection is a zero-length edit
998
+ * — it still emits, which keeps the JS contract identical on both platforms.
999
+ */
1000
+ fun resolvePaste(
1001
+ pastedText: String,
1002
+ @Suppress("UNUSED_PARAMETER") markdown: String,
1003
+ ) {
1004
+ val editable = text ?: return
1005
+ val selStart = selectionStart.coerceIn(0, editable.length)
1006
+ val selEnd = selectionEnd.coerceIn(selStart, editable.length)
1007
+
1008
+ replaceTextInRange(selStart, selEnd, pastedText) { ed ->
1009
+ setSelection(selStart + pastedText.length)
1010
+ // Parity with the un-intercepted paste: a pasted URL still autolinks.
1011
+ detectorPipeline.processTextChange(ed, ed.toString(), selStart, pastedText.length)
1012
+ }
1013
+ // The session suppresses onSelectionChanged while the edit runs, so the moved
1014
+ // caret is reported here.
1015
+ eventEmitter.emitSelection(selectionStart, selectionEnd)
1016
+ eventEmitter.emitCaretRectChangeIfNeeded()
478
1017
  }
479
1018
 
480
1019
  fun setLinkForSelection(url: String) {
481
1020
  val selStart = selectionStart
482
1021
  val selEnd = selectionEnd
483
1022
  if (selStart == selEnd) return
484
-
485
- val editable = text
486
- if (editable != null) {
487
- autoLinkDetector.clearAutoLinkInRange(editable, selStart, selEnd)
488
- }
489
- formattingStore.addRange(FormattingRange(StyleType.LINK, selStart, selEnd, url))
1023
+ linkCoordinator.setLinkForRange(url, selStart, selEnd, text)
490
1024
  applyFormattingAndEmit()
491
1025
  }
492
1026
 
1027
+ /** Parses the given markdown and inserts it at the cursor, replacing any selection. */
1028
+ fun insertTextAtCursor(markdown: String) {
1029
+ if (markdown.isEmpty()) return
1030
+ pasteMarkdown(markdown)
1031
+ }
1032
+
493
1033
  fun insertLinkAtCursor(
494
1034
  displayText: String,
495
1035
  url: String,
496
1036
  ) {
497
- val editable = text ?: return
498
1037
  val selStart = selectionStart
499
1038
  val selEnd = selectionEnd
500
1039
  val linkEnd = selStart + displayText.length
501
1040
 
502
- isProcessingTextChange = true
503
- try {
504
- editable.replace(selStart, selEnd, displayText)
505
- formattingStore.adjustForEdit(selStart, selEnd - selStart, displayText.length)
506
- autoLinkDetector.clearAutoLinkInRange(editable, selStart, linkEnd)
507
- formattingStore.addRange(FormattingRange(StyleType.LINK, selStart, linkEnd, sanitizeLinkUrl(url)))
508
- lastProcessedText = editable.toString()
509
-
1041
+ replaceTextInRange(selStart, selEnd, displayText) { editable ->
1042
+ linkCoordinator.addLink(url, selStart, linkEnd, editable)
510
1043
  setSelection(linkEnd)
511
- applyFormattingAndEmit()
512
- eventEmitter.emitChangeText()
513
- } finally {
514
- isProcessingTextChange = false
515
1044
  }
516
1045
  }
517
1046
 
@@ -520,206 +1049,116 @@ class EnrichedMarkdownTextInputView(
520
1049
  url: String,
521
1050
  ) {
522
1051
  if (displayText.isEmpty()) return
523
- val indicator = activeMentionIndicator ?: return
524
- val start = activeMentionStart
525
- val end = activeMentionEnd
1052
+ val indicator = mentionCoordinator.currentIndicator ?: return
1053
+ val start = mentionCoordinator.currentStart
1054
+ val end = mentionCoordinator.currentEnd
526
1055
  val editable = text ?: return
527
1056
  if (start < 0 || end < start || end > editable.length) return
528
1057
 
529
- val sanitizedUrl = sanitizeLinkUrl(url)
530
1058
  val shouldAppendSpace = end >= editable.length || !editable[end].isWhitespace()
531
1059
  val replacement = if (shouldAppendSpace) "$displayText " else displayText
532
1060
  val linkEnd = start + displayText.length
533
1061
 
534
- isProcessingTextChange = true
535
- try {
536
- editable.replace(start, end, replacement)
537
- formattingStore.adjustForEdit(start, end - start, replacement.length)
538
- autoLinkDetector.clearAutoLinkInRange(editable, start, linkEnd)
539
- formattingStore.addRange(FormattingRange(StyleType.LINK, start, linkEnd, sanitizedUrl))
540
- lastProcessedText = editable.toString()
541
-
542
- clearActiveMention(emit = true, indicatorOverride = indicator)
1062
+ replaceTextInRange(start, end, replacement) { ed ->
1063
+ linkCoordinator.addLink(url, start, linkEnd, ed)
1064
+ dispatchMentionEvents(mentionCoordinator.clear(indicatorOverride = indicator))
543
1065
  setSelection(start + replacement.length)
544
- applyFormattingAndEmit()
545
- eventEmitter.emitChangeText()
546
- } finally {
547
- isProcessingTextChange = false
548
1066
  }
549
1067
  }
550
1068
 
551
1069
  fun startMention(indicator: String) {
552
- if (indicator.isEmpty() || indicator !in mentionIndicators) return
553
- val editable = text ?: return
1070
+ if (indicator.isEmpty() || !mentionCoordinator.containsIndicator(indicator)) return
554
1071
  val selStart = selectionStart
555
1072
  val selEnd = selectionEnd
556
1073
 
557
- isProcessingTextChange = true
558
- try {
559
- editable.replace(selStart, selEnd, indicator)
560
- formattingStore.adjustForEdit(selStart, selEnd - selStart, indicator.length)
561
- lastProcessedText = editable.toString()
1074
+ replaceTextInRange(selStart, selEnd, indicator) {
562
1075
  setSelection(selStart + indicator.length)
563
- applyFormattingAndEmit()
564
- eventEmitter.emitChangeText()
565
- updateActiveMention()
566
- } finally {
567
- isProcessingTextChange = false
568
1076
  }
1077
+ dispatchMentionUpdate()
569
1078
  }
570
1079
 
571
1080
  fun removeLinkAtCursor() {
572
- val pos = selectionStart
573
- val linkRange = formattingStore.rangeOfType(StyleType.LINK, pos) ?: return
574
- formattingStore.removeRange(linkRange)
1081
+ if (!linkCoordinator.removeLink(selectionStart)) return
575
1082
  applyFormattingAndEmit()
576
1083
  }
577
1084
 
578
- fun deleteLinkBeforeCursor(): Boolean {
579
- val editable = text ?: return false
580
- val cursorStart = selectionStart
581
- val cursorEnd = selectionEnd
582
- if (cursorStart != cursorEnd || cursorStart <= 0) return false
583
-
584
- val linkRange = formattingStore.rangeOfType(StyleType.LINK, cursorStart - 1) ?: return false
585
-
586
- isProcessingTextChange = true
587
- try {
588
- editable.delete(linkRange.start, linkRange.end)
589
- formattingStore.adjustForEdit(linkRange.start, linkRange.length, 0)
590
- lastProcessedText = editable.toString()
591
- setSelection(linkRange.start.coerceAtMost(editable.length))
592
- applyFormattingAndEmit()
593
- eventEmitter.emitChangeText()
594
- clearActiveMention()
595
- } finally {
596
- isProcessingTextChange = false
597
- }
598
- return true
599
- }
600
-
601
- fun setMentionIndicators(indicators: List<String>) {
602
- val newIndicators = LinkedHashSet(indicators)
603
- if (newIndicators == mentionIndicators) return
604
- mentionIndicators = newIndicators
605
- activeMentionIndicator?.let { indicator ->
606
- if (indicator !in mentionIndicators) {
607
- clearActiveMention(emit = true, indicatorOverride = indicator)
608
- }
609
- }
610
- updateActiveMention()
611
- }
612
-
613
- fun dismissActiveMention() {
614
- clearActiveMention(emit = false)
615
- }
616
-
617
- fun setContextMenuItems(items: List<String>) {
618
- contextMenu.setContextMenuItems(items)
619
- }
620
-
621
- fun setLinkRegex(config: LinkRegexConfig) {
622
- autoLinkDetector.setRegexConfig(config)
623
- }
624
-
625
- fun setAutoLinkStyle(style: InputFormatterStyle) {
626
- autoLinkDetector.style = style
627
- }
628
-
629
- fun allFormattingRangesForSerialization(): List<FormattingRange> {
630
- val editable = text ?: return formattingStore.allRanges
631
- val transientRanges = detectorPipeline.allTransientFormattingRanges(editable)
632
- if (transientRanges.isEmpty()) return formattingStore.allRanges
633
- return formattingStore.allRanges + transientRanges
634
- }
635
-
636
- fun setValueFromJS(markdown: String) {
637
- val parsed = InputParser.parseToPlainTextAndRanges(markdown)
638
- blockEmitting = true
639
- try {
640
- runAsATransaction {
641
- formattingStore.clearAll()
642
- formattingStore.setRanges(parsed.formattingRanges)
643
- setText(parsed.plainText)
644
- val editable = text
645
- if (editable != null && parsed.mathAtoms.isNotEmpty()) {
646
- applyMathAtoms(editable, parsed.mathAtoms)
647
- }
648
- setSelection(text?.length ?: 0)
649
- }
650
- applyFormatting()
651
- forceScrollToSelection()
652
- layoutManager.invalidateLayout()
653
- lastProcessedText = text?.toString() ?: ""
654
- } finally {
655
- blockEmitting = false
656
- }
1085
+ // region Math chip
1086
+ //
1087
+ // A chip is one OBJECT_REPLACEMENT character in the buffer plus a MathChipSpan that
1088
+ // carries the LaTeX and draws the rendered formula. Nothing about the formula lives
1089
+ // in the text, so the caret, the IME and every range in the stores treat it as a
1090
+ // single atomic character. All text mutation goes through the same
1091
+ // replaceTextInRange/EditPhase machinery as links and mentions — the pre-merge fork
1092
+ // hand-rolled its own re-entrancy flags, which no longer exist.
1093
+
1094
+ /** Chips in document order, as plain data for [MarkdownSerializer]. Empty with math off. */
1095
+ fun mathChipsForSerialization(): List<MathChipController.ChipInfo> {
1096
+ val controller = mathChipController ?: return emptyList()
1097
+ val editable = text ?: return emptyList()
1098
+ return controller.allChips(editable)
657
1099
  }
658
1100
 
1101
+ /**
1102
+ * Stamps parsed [atoms] onto placeholder characters already present in [editable],
1103
+ * shifting their positions by [offset] (non-zero when the parse covered an inserted
1104
+ * fragment rather than the whole buffer).
1105
+ */
659
1106
  private fun applyMathAtoms(
660
1107
  editable: Editable,
661
- atoms: List<com.swmansion.enriched.markdown.input.model.MathAtom>,
1108
+ atoms: List<MathAtom>,
1109
+ offset: Int = 0,
662
1110
  ) {
663
1111
  val controller = mathChipController ?: return
1112
+ // Clear first: a math-free import still has to disarm atoms deferred by an earlier
1113
+ // buffer, or they would be stamped onto whatever text replaced them.
1114
+ pendingMathAtoms = emptyList()
1115
+ if (atoms.isEmpty()) return
1116
+ val positioned = if (offset == 0) atoms else atoms.map { it.copy(position = it.position + offset) }
664
1117
  val style = inputStyle
665
1118
  if (style == null) {
666
- // `markdownStyle` hasn't arrived yet — defer until applyInputStyle runs.
667
- // @ReactProp setters fire in undefined order, so defaultValue may run first.
668
- pendingMathAtoms = atoms
1119
+ // `markdownStyle` has not arrived yet. @ReactProp setters fire in undefined order,
1120
+ // so `defaultValue` can be applied first — the only producer that reaches this
1121
+ // branch, which is why a plain assignment (not an append) is correct here.
1122
+ pendingMathAtoms = positioned
669
1123
  return
670
1124
  }
671
- pendingMathAtoms = emptyList()
672
- isProcessingTextChange = true
673
- try {
674
- controller.applyAtoms(editable, atoms, style.math, style.mathBlock)
675
- } finally {
676
- isProcessingTextChange = false
1125
+ controller.applyAtoms(editable, positioned, style.inlineMath, style.math)
1126
+ }
1127
+
1128
+ /**
1129
+ * Pushes a new `markdownStyle` into every live chip, and drains any atoms that were
1130
+ * parsed before the style existed. Both cases move the measured text height, which
1131
+ * Fabric owns — [InputLayoutManager.invalidateLayout] is the only way to change it.
1132
+ */
1133
+ private fun syncMathChipsWithStyle(style: InputFormatterStyle) {
1134
+ val controller = mathChipController ?: return
1135
+ val editable = text ?: return
1136
+ val pending = pendingMathAtoms
1137
+ if (pending.isNotEmpty()) {
1138
+ pendingMathAtoms = emptyList()
1139
+ controller.applyAtoms(editable, pending, style.inlineMath, style.math)
1140
+ } else {
1141
+ // Chips cache the style object they were built with, so a style pass has to
1142
+ // re-stamp them; with no chip in the buffer there is nothing to do and no
1143
+ // reason to make Fabric re-measure.
1144
+ if (controller.allChips(editable).isEmpty()) return
1145
+ controller.refreshAll(editable, style.inlineMath, style.math)
677
1146
  }
1147
+ invalidate()
1148
+ requestLayout()
1149
+ layoutManager.invalidateLayout()
678
1150
  }
679
1151
 
1152
+ /** Re-stamps every chip with the current style — used when the row width changes. */
680
1153
  fun refreshAllMathChips() {
681
1154
  val controller = mathChipController ?: return
682
1155
  val editable = text ?: return
683
1156
  val style = inputStyle ?: return
684
- controller.refreshAll(editable, style.math, style.mathBlock)
1157
+ if (controller.allChips(editable).isEmpty()) return
1158
+ controller.refreshAll(editable, style.inlineMath, style.math)
685
1159
  invalidate()
686
1160
  requestLayout()
687
- }
688
-
689
- fun applyInputStyle(style: InputFormatterStyle): Boolean {
690
- val previous = inputStyle
691
- inputStyle = style
692
- val changed = previous != style
693
- val mathStyleChanged =
694
- previous == null ||
695
- previous.math != style.math ||
696
- previous.mathBlock != style.mathBlock
697
-
698
- val controller = mathChipController
699
- val editable = text
700
- if (controller != null && editable != null && pendingMathAtoms.isNotEmpty()) {
701
- val atoms = pendingMathAtoms
702
- pendingMathAtoms = emptyList()
703
- isProcessingTextChange = true
704
- try {
705
- controller.applyAtoms(editable, atoms, style.math, style.mathBlock)
706
- } finally {
707
- isProcessingTextChange = false
708
- }
709
- invalidate()
710
- requestLayout()
711
- // Chips only reach the text here when `defaultValue` landed before `markdownStyle`
712
- // (@ReactProp order is undefined). requestLayout() cannot change a height Fabric owns —
713
- // the shadow tree has to re-measure text that now carries the spans.
714
- layoutManager.invalidateLayout()
715
- } else if (mathStyleChanged) {
716
- refreshAllMathChips()
717
- // A math font size / padding change resizes every chip, so the measured height moves.
718
- // Colour-only changes end up here too; store() then measures the same size and skips
719
- // the state update.
720
- layoutManager.invalidateLayout()
721
- }
722
- return changed
1161
+ layoutManager.invalidateLayout()
723
1162
  }
724
1163
 
725
1164
  fun insertMath(
@@ -731,42 +1170,32 @@ class EnrichedMarkdownTextInputView(
731
1170
  val style = inputStyle ?: return
732
1171
  val editable = text ?: return
733
1172
 
734
- val selStart = selectionStart.coerceAtLeast(0)
735
- val selEnd = selectionEnd.coerceAtLeast(selStart)
736
- val safeStart = selStart.coerceAtMost(editable.length)
737
- val safeEnd = selEnd.coerceAtMost(editable.length)
738
-
739
- val prefix = StringBuilder()
740
- val suffix = StringBuilder()
741
- if (displayMode) {
742
- val atLineStart = safeStart == 0 || editable[safeStart - 1] == '\n'
743
- val atLineEnd = safeEnd >= editable.length || editable[safeEnd] == '\n'
744
- if (!atLineStart) prefix.append('\n')
745
- if (!atLineEnd) suffix.append('\n')
1173
+ val selStart = selectionStart.coerceIn(0, editable.length)
1174
+ val selEnd = selectionEnd.coerceIn(selStart, editable.length)
1175
+
1176
+ // A block chip claims the full row, so it needs a line of its own: add the
1177
+ // separators the caret position is missing rather than pulling the surrounding
1178
+ // text into the chip's line.
1179
+ val prefix = if (displayMode && selStart > 0 && !editable[selStart - 1].isLineBreak()) "\n" else ""
1180
+ val suffix = if (displayMode && selEnd < editable.length && !editable[selEnd].isLineBreak()) "\n" else ""
1181
+ val replacement = prefix + OBJECT_REPLACEMENT + suffix
1182
+ val chipPosition = selStart + prefix.length
1183
+ // style.inlineMath is `markdownStyle.inlineMath`, style.math is the block key —
1184
+ // they swapped names in the P7 rename and both are chip styles, so a stale read
1185
+ // compiles clean and styles the wrong chip.
1186
+ val chipStyle: MathChipStyle = if (displayMode) style.math else style.inlineMath
1187
+
1188
+ replaceTextInRange(selStart, selEnd, replacement) { updated ->
1189
+ controller.insertChip(updated, chipPosition, latex, displayMode, chipStyle)
1190
+ setSelection((selStart + replacement.length).coerceAtMost(updated.length))
746
1191
  }
747
- val replacement = "$prefix$suffix"
748
- val chipPositionInReplacement = prefix.length
749
1192
 
750
- isProcessingTextChange = true
751
- try {
752
- editable.replace(safeStart, safeEnd, replacement)
753
- val chipPosition = safeStart + chipPositionInReplacement
754
- val chipStyle: MathChipStyle = if (displayMode) style.mathBlock else style.math
755
- controller.insertChip(editable, chipPosition, latex, displayMode, chipStyle)
756
- formattingStore.adjustForEdit(safeStart, safeEnd - safeStart, replacement.length)
757
- lastProcessedText = editable.toString()
758
- setSelection(safeStart + replacement.length)
759
- } finally {
760
- isProcessingTextChange = false
761
- }
762
-
763
- applyFormatting()
764
1193
  invalidate()
765
1194
  requestLayout()
766
- eventEmitter.emitChangeText()
767
- if (emitMarkdown) eventEmitter.emitChangeMarkdown()
1195
+ layoutManager.invalidateLayout()
1196
+ // replaceTextInRange emits text/markdown/state; the selection it set was applied
1197
+ // inside the Processing phase, where onSelectionChanged bails out.
768
1198
  eventEmitter.emitSelection(selectionStart, selectionEnd)
769
- eventEmitter.emitState()
770
1199
  eventEmitter.emitCaretRectChangeIfNeeded()
771
1200
  }
772
1201
 
@@ -778,11 +1207,13 @@ class EnrichedMarkdownTextInputView(
778
1207
  val controller = mathChipController ?: return
779
1208
  val style = inputStyle ?: return
780
1209
  val editable = text ?: return
781
- val chipStyle: MathChipStyle = if (displayMode) style.mathBlock else style.math
782
- val result = controller.updateChip(editable, start, latex, displayMode, chipStyle)
783
- if (!result.applied) return
1210
+ val chipStyle: MathChipStyle = if (displayMode) style.math else style.inlineMath
1211
+ // Formula and mode live on the span, so this changes no text: no store adjustment
1212
+ // and no EditPhase are needed, only a re-render and a re-measure.
1213
+ if (!controller.updateChip(editable, start, latex, displayMode, chipStyle).applied) return
784
1214
  invalidate()
785
1215
  requestLayout()
1216
+ layoutManager.invalidateLayout()
786
1217
  if (emitMarkdown) eventEmitter.emitChangeMarkdown()
787
1218
  }
788
1219
 
@@ -790,22 +1221,32 @@ class EnrichedMarkdownTextInputView(
790
1221
  val controller = mathChipController ?: return
791
1222
  val editable = text ?: return
792
1223
 
793
- isProcessingTextChange = true
794
- val didRemove =
795
- try {
796
- controller.removeChip(editable, start)
797
- } finally {
798
- isProcessingTextChange = false
1224
+ // The controller does the delete because it also drops the chip's AlignmentSpan,
1225
+ // which is SPAN_PARAGRAPH and would otherwise outlive the character and leave the
1226
+ // rest of the paragraph centered.
1227
+ var removed = false
1228
+ editSession.enter(EditPhase.Processing)
1229
+ try {
1230
+ removed = controller.removeChip(editable, start)
1231
+ if (removed) {
1232
+ adjustStoresForEdit(start, 1, 0)
1233
+ lastProcessedText = editable.toString()
1234
+ applyFormattingAndEmit()
1235
+ eventEmitter.emitChangeText()
799
1236
  }
800
- if (!didRemove) return
801
- formattingStore.adjustForEdit(start, 1, 0)
802
- lastProcessedText = editable.toString()
803
- applyFormatting()
804
- eventEmitter.emitChangeText()
805
- if (emitMarkdown) eventEmitter.emitChangeMarkdown()
1237
+ } finally {
1238
+ editSession.exit()
1239
+ }
1240
+ if (!removed) return
1241
+ if (activeMathChipPosition == start) activeMathChipPosition = -1
1242
+ invalidate()
1243
+ requestLayout()
1244
+ layoutManager.invalidateLayout()
806
1245
  eventEmitter.emitSelection(selectionStart, selectionEnd)
1246
+ eventEmitter.emitCaretRectChangeIfNeeded()
807
1247
  }
808
1248
 
1249
+ /** Drops the selected styling from every chip except [except] (all of them when -1). */
809
1250
  private fun clearMathChipSelections(except: Int = -1) {
810
1251
  val controller = mathChipController ?: return
811
1252
  val editable = text ?: return
@@ -815,10 +1256,8 @@ class EnrichedMarkdownTextInputView(
815
1256
  } else {
816
1257
  var didChange = false
817
1258
  for (chip in controller.allChips(editable)) {
818
- if (chip.position == except) continue
819
- if (chip.isSelected) {
820
- if (controller.setSelected(editable, chip.position, false)) didChange = true
821
- }
1259
+ if (chip.position == except || !chip.isSelected) continue
1260
+ if (controller.setSelected(editable, chip.position, false)) didChange = true
822
1261
  }
823
1262
  didChange
824
1263
  }
@@ -829,19 +1268,20 @@ class EnrichedMarkdownTextInputView(
829
1268
  val controller = mathChipController ?: return false
830
1269
  val editable = text ?: return false
831
1270
  val info = controller.chipAt(editable, chipPosition) ?: return false
832
- val layoutNow = layout ?: return false
1271
+ val textLayout = layout ?: return false
833
1272
 
834
1273
  clearMathChipSelections(except = info.position)
835
1274
  controller.setSelected(editable, info.position, true)
836
1275
  invalidate()
1276
+ // Set before setSelection: onSelectionChanged reads it to decide whether the
1277
+ // caret is still on the chip, and would otherwise clear the highlight at once.
837
1278
  activeMathChipPosition = info.position
838
- // Caret-after-chip (length 0). A length-1 selection would make the system
839
- // draw selection handles over the chip glyph, and also trip the
840
- // `selStart == selEnd` guard in onSelectionChanged that keeps the chip's
841
- // selected style alive.
1279
+ // Caret after the chip, not a length-1 selection: a selection would draw the
1280
+ // system's selection handles over the chip glyph and trip the `selStart == selEnd`
1281
+ // guard that keeps the chip's selected styling alive.
842
1282
  setSelection((info.position + 1).coerceAtMost(editable.length))
843
1283
 
844
- val bounds = computeChipBounds(layoutNow, info.position)
1284
+ val bounds = computeChipBounds(textLayout, info.position)
845
1285
  val density = resources.displayMetrics.density.toDouble()
846
1286
  eventEmitter.emitMathPress(
847
1287
  info.latex,
@@ -858,57 +1298,58 @@ class EnrichedMarkdownTextInputView(
858
1298
 
859
1299
  /** Chip glyph rect in [Layout] coordinates — no padding, no scroll offset. */
860
1300
  private fun chipBoundsInLayout(
861
- layout: Layout,
1301
+ textLayout: Layout,
862
1302
  start: Int,
863
1303
  ): Rect {
864
- val line = layout.getLineForOffset(start)
865
- val xStart = layout.getPrimaryHorizontal(start).toInt()
866
- val xEnd = layout.getPrimaryHorizontal(start + 1).toInt()
867
- // Ordered, not raw start/end: in an RTL run xStart > xEnd, which would make an
868
- // inverted Rect that contains() always rejects.
1304
+ val line = textLayout.getLineForOffset(start)
1305
+ val xStart = textLayout.getPrimaryHorizontal(start).toInt()
1306
+ val xEnd = textLayout.getPrimaryHorizontal(start + 1).toInt()
1307
+ // Ordered, not raw start/end: in an RTL run xStart > xEnd, which would build an
1308
+ // inverted Rect whose contains() always rejects.
869
1309
  val left = xStart.coerceAtMost(xEnd)
870
1310
  var right = xStart.coerceAtLeast(xEnd)
871
1311
  if (right <= left) {
872
1312
  // A chip that is the last character has start + 1 == length, where some layouts
873
- // report the same horizontal for both offsets. Fall back to the line's content edge
874
- // so the rect keeps a real width instead of collapsing.
875
- right = layout.getLineRight(line).toInt()
1313
+ // report the same horizontal for both offsets. Fall back to the line's content
1314
+ // edge so the rect keeps a real width instead of collapsing.
1315
+ right = textLayout.getLineRight(line).toInt()
876
1316
  }
877
- return Rect(left, layout.getLineTop(line), right, layout.getLineBottom(line))
1317
+ return Rect(left, textLayout.getLineTop(line), right, textLayout.getLineBottom(line))
878
1318
  }
879
1319
 
880
1320
  /** Chip glyph rect in view coordinates, for reporting to JS. */
881
1321
  private fun computeChipBounds(
882
- layout: Layout,
1322
+ textLayout: Layout,
883
1323
  start: Int,
884
1324
  ): Rect =
885
- chipBoundsInLayout(layout, start).apply {
1325
+ chipBoundsInLayout(textLayout, start).apply {
886
1326
  offset(totalPaddingLeft - scrollX, totalPaddingTop - scrollY)
887
1327
  }
888
1328
 
1329
+ /** Offset of the chip under a touch point, or -1 when the touch missed every chip. */
889
1330
  fun mathChipPositionAtTouch(
890
1331
  x: Float,
891
1332
  y: Float,
892
1333
  ): Int {
893
1334
  val controller = mathChipController ?: return -1
894
1335
  val editable = text ?: return -1
895
- val layoutNow = layout ?: return -1
1336
+ val textLayout = layout ?: return -1
896
1337
  val adjustedX = (x - totalPaddingLeft + scrollX).toInt()
897
1338
  val adjustedY = (y - totalPaddingTop + scrollY).toInt()
898
- // getLineForVertical and getOffsetForHorizontal both clamp into the laid-out text, so
899
- // a touch in the padding or in the blank space below the last line resolves onto the
900
- // nearest character. Bound the touch to the text box, then require it to land inside
901
- // the chip's own rect — otherwise a tap beside a chip reads as a tap on it.
902
- if (adjustedY < 0 || adjustedY > layoutNow.height) return -1
903
- val line = layoutNow.getLineForVertical(adjustedY)
904
- val offset = layoutNow.getOffsetForHorizontal(line, adjustedX.toFloat())
1339
+ // getLineForVertical and getOffsetForHorizontal both clamp into the laid-out text,
1340
+ // so a touch in the padding or in the blank space below the last line resolves onto
1341
+ // the nearest character. Bound the touch to the text box first, then require it to
1342
+ // land inside the chip's own rect — otherwise a tap beside a chip reads as a tap on it.
1343
+ if (adjustedY < 0 || adjustedY > textLayout.height) return -1
1344
+ val line = textLayout.getLineForVertical(adjustedY)
1345
+ val offset = textLayout.getOffsetForHorizontal(line, adjustedX.toFloat())
905
1346
  if (offset < 0) return -1
906
1347
  val candidates = intArrayOf(offset, offset - 1).filter { it in 0 until editable.length }
907
1348
  for (candidate in candidates) {
908
1349
  val range = controller.chipRangeAt(editable, candidate) ?: continue
909
1350
  // An empty rect means the layout could not give us the chip's extent; accept the
910
1351
  // candidate then, rather than making the chip untappable.
911
- val bounds = chipBoundsInLayout(layoutNow, range.first)
1352
+ val bounds = chipBoundsInLayout(textLayout, range.first)
912
1353
  if (!bounds.isEmpty && !bounds.contains(adjustedX, adjustedY)) continue
913
1354
  return range.first
914
1355
  }
@@ -916,13 +1357,13 @@ class EnrichedMarkdownTextInputView(
916
1357
  }
917
1358
 
918
1359
  /**
919
- * Shrink the IME composing region so it never overlaps a math chip. Returns
920
- * the adjusted (start, end) pair, or null if no chip lies in the range. The
921
- * caller should fall back to the original range when null is returned.
1360
+ * Shrinks the IME composing region so it never overlaps a math chip, called from
1361
+ * [com.swmansion.enriched.markdown.input.editing.InputConnectionWrapper.setComposingRegion].
1362
+ * Returns the adjusted (start, end) pair, or null when there is no chip controller —
1363
+ * the caller then keeps the original range.
922
1364
  *
923
- * If the region intersects a chip we collapse to the side furthest from the
924
- * chip so a Korean/Japanese composing block can't accidentally swallow the
925
- * chip's `` placeholder character.
1365
+ * Where the region intersects a chip it collapses to the side furthest from the chip,
1366
+ * so a Hangul/Kana composing block cannot swallow the chip's placeholder character.
926
1367
  */
927
1368
  fun adjustComposingRegionAwayFromChips(
928
1369
  start: Int,
@@ -942,7 +1383,7 @@ class EnrichedMarkdownTextInputView(
942
1383
  i++
943
1384
  continue
944
1385
  }
945
- // Composing region overlaps a chip — push the edge that's nearest to the chip out.
1386
+ // The region overlaps a chip — push out the edge nearest to it.
946
1387
  val distFromStart = range.first - adjustedStart
947
1388
  val distFromEnd = adjustedEnd - (range.last + 1)
948
1389
  if (distFromStart >= distFromEnd) {
@@ -956,6 +1397,80 @@ class EnrichedMarkdownTextInputView(
956
1397
  if (adjustedStart >= adjustedEnd) return adjustedStart to adjustedStart
957
1398
  return adjustedStart to adjustedEnd
958
1399
  }
1400
+ // endregion
1401
+
1402
+ fun deleteLinkBeforeCursor(): Boolean {
1403
+ val cursorStart = selectionStart
1404
+ val cursorEnd = selectionEnd
1405
+ if (cursorStart != cursorEnd || cursorStart <= 0) return false
1406
+ if (text == null) return false
1407
+
1408
+ val linkRange = linkCoordinator.linkRangeForDeletion(cursorStart) ?: return false
1409
+
1410
+ replaceTextInRange(linkRange.start, linkRange.end, "") { editable ->
1411
+ setSelection(linkRange.start.coerceAtMost(editable.length))
1412
+ }
1413
+ dispatchMentionEvents(mentionCoordinator.clear())
1414
+ return true
1415
+ }
1416
+
1417
+ fun setMentionIndicators(indicators: List<String>) {
1418
+ dispatchMentionEvents(mentionCoordinator.setIndicators(indicators))
1419
+ dispatchMentionUpdate()
1420
+ }
1421
+
1422
+ fun dismissActiveMention() {
1423
+ mentionCoordinator.clear(emit = false)
1424
+ }
1425
+
1426
+ fun setContextMenuItems(items: List<String>) {
1427
+ contextMenu.setContextMenuItems(items)
1428
+ }
1429
+
1430
+ fun setLinkRegex(config: LinkRegexConfig) {
1431
+ autoLinkDetector.setRegexConfig(config)
1432
+ }
1433
+
1434
+ fun setAutoLinkStyle(style: InputFormatterStyle) {
1435
+ autoLinkDetector.style = style
1436
+ }
1437
+
1438
+ /**
1439
+ * Applies the parsed `markdownStyle` (which now carries `list.itemSpacing`).
1440
+ * Display density is folded in at [InputFormatter] construction, so the style is
1441
+ * handed to the formatter as-is. Returns true if the effective style changed
1442
+ * (caller re-applies formatting).
1443
+ */
1444
+ fun setMarkdownStyleFromProps(style: InputFormatterStyle): Boolean {
1445
+ setAutoLinkStyle(style)
1446
+ inputStyle = style
1447
+ val changed = formatter.updateStyle(style)
1448
+ syncMathChipsWithStyle(style)
1449
+ return changed
1450
+ }
1451
+
1452
+ fun allFormattingRangesForSerialization(): List<FormattingRange> = clipboardCoordinator.allRangesForSerialization(text)
1453
+
1454
+ fun setValueFromJS(markdown: String) {
1455
+ lastMarkdownFromJS = markdown
1456
+ val parsed = InputParser.parseToPlainTextAndRanges(markdown, blockSyntax)
1457
+ editSession.enter(EditPhase.Importing)
1458
+ try {
1459
+ formattingStore.clearAll()
1460
+ formattingStore.setRanges(parsed.formattingRanges)
1461
+ blockStore.setRanges(parsed.blockRanges)
1462
+ setText(parsed.plainText)
1463
+ text?.let { applyMathAtoms(it, parsed.mathAtoms) }
1464
+ setSelection(text?.length ?: 0)
1465
+ zwspAnchorCount = 0
1466
+ applyFormatting()
1467
+ forceScrollToSelection()
1468
+ layoutManager.invalidateLayout()
1469
+ lastProcessedText = text?.toString() ?: ""
1470
+ } finally {
1471
+ editSession.exit()
1472
+ }
1473
+ }
959
1474
 
960
1475
  override fun setBackgroundColor(color: Int) {
961
1476
  BackgroundStyleApplicator.setBackgroundColor(this, color)
@@ -1055,15 +1570,7 @@ class EnrichedMarkdownTextInputView(
1055
1570
  ) {
1056
1571
  if (start >= end) return
1057
1572
  val handler = formatter.handlers[styleType] ?: return
1058
- val isActive = formattingStore.isStyleActive(styleType, start)
1059
- if (isActive) {
1060
- formattingStore.removeType(styleType, start, end)
1061
- } else {
1062
- for (conflict in handler.mergingConfig.conflictingStyles) {
1063
- formattingStore.removeType(conflict, start, end)
1064
- }
1065
- formattingStore.addRange(FormattingRange(styleType, start, end))
1066
- }
1573
+ formattingStore.toggleStyle(styleType, start, end, handler.mergingConfig.conflictingStyles)
1067
1574
  applyFormattingAndEmit()
1068
1575
  }
1069
1576
 
@@ -1072,81 +1579,25 @@ class EnrichedMarkdownTextInputView(
1072
1579
  start: Int,
1073
1580
  end: Int,
1074
1581
  ) {
1075
- if (start >= end) return
1076
- formattingStore.addRange(FormattingRange(StyleType.LINK, start, end, url))
1582
+ linkCoordinator.addLinkDirect(url, start, end)
1077
1583
  applyFormattingAndEmit()
1078
1584
  }
1079
1585
 
1080
- private fun updateActiveMention() {
1081
- val plainText = text?.toString() ?: return
1082
- val cursor = selectionStart
1083
- if (selectionStart != selectionEnd || cursor < 0 || cursor > plainText.length) {
1084
- clearActiveMention()
1085
- return
1086
- }
1087
-
1088
- val candidate = detectMentionAtCursor(plainText, cursor)
1089
- if (candidate == null) {
1090
- clearActiveMention()
1091
- return
1092
- }
1093
-
1094
- if (activeMentionIndicator != candidate.indicator || activeMentionStart != candidate.start) {
1095
- activeMentionIndicator?.let { eventEmitter.emitEndMention(it) }
1096
- activeMentionIndicator = candidate.indicator
1097
- activeMentionStart = candidate.start
1098
- eventEmitter.emitStartMention(candidate.indicator)
1099
- }
1100
-
1101
- activeMentionEnd = candidate.end
1102
- if (activeMentionText != candidate.text) {
1103
- activeMentionText = candidate.text
1104
- eventEmitter.emitChangeMention(candidate.indicator, candidate.text)
1105
- }
1106
- }
1107
-
1108
- private fun detectMentionAtCursor(
1109
- plainText: String,
1110
- cursor: Int,
1111
- ): MentionCandidate? {
1112
- if (mentionIndicators.isEmpty()) return null
1113
-
1114
- val start = WordsUtils.tokenStart(plainText, cursor)
1115
- val token = plainText.substring(start, cursor)
1116
- val indicator = mentionIndicators.firstOrNull { token.startsWith(it) } ?: return null
1117
- if (formattingStore.rangeOfType(StyleType.LINK, start) != null) return null
1118
-
1119
- return MentionCandidate(
1120
- indicator = indicator,
1121
- start = start,
1122
- end = cursor,
1123
- text = token.substring(indicator.length),
1124
- )
1586
+ private fun dispatchMentionUpdate() {
1587
+ dispatchMentionEvents(mentionCoordinator.update(text?.toString(), selectionStart, selectionEnd))
1125
1588
  }
1126
1589
 
1127
- private fun clearActiveMention(
1128
- emit: Boolean = true,
1129
- indicatorOverride: String? = null,
1130
- ) {
1131
- val indicator = indicatorOverride ?: activeMentionIndicator
1132
- activeMentionIndicator = null
1133
- activeMentionStart = -1
1134
- activeMentionEnd = -1
1135
- activeMentionText = ""
1136
- if (emit && indicator != null) {
1137
- eventEmitter.emitEndMention(indicator)
1590
+ private fun dispatchMentionEvents(events: List<MentionEvent>) {
1591
+ for (event in events) {
1592
+ when (event) {
1593
+ is MentionEvent.Start -> eventEmitter.emitStartMention(event.indicator)
1594
+ is MentionEvent.Change -> eventEmitter.emitChangeMention(event.indicator, event.text)
1595
+ is MentionEvent.End -> eventEmitter.emitEndMention(event.indicator)
1596
+ }
1138
1597
  }
1139
1598
  }
1140
1599
 
1141
- private fun sanitizeLinkUrl(url: String): String =
1142
- url
1143
- .replace("(", "%28")
1144
- .replace(")", "%29")
1145
-
1146
- private data class MentionCandidate(
1147
- val indicator: String,
1148
- val start: Int,
1149
- val end: Int,
1150
- val text: String,
1151
- )
1600
+ private companion object {
1601
+ private val PASTE_MIME_TYPES = arrayOf("text/*")
1602
+ }
1152
1603
  }