react-native-enriched-markdown-scaffold 0.7.4-scaffold.9 → 1.0.2-scaffold.10

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