@mathers101/react-native-render-rich-text 0.1.10 → 0.1.14

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 (327) hide show
  1. package/LICENSE +20 -20
  2. package/README.md +140 -140
  3. package/ReactNativeRenderRichText.podspec +23 -23
  4. package/android/build.gradle +86 -86
  5. package/android/consumer-rules.pro +3 -3
  6. package/android/src/main/AndroidManifest.xml +2 -2
  7. package/android/src/main/java/com/renderrichtext/EnrichedTiptapTextLayoutManager.kt +24 -24
  8. package/android/src/main/java/com/renderrichtext/EnrichedTiptapTextPackage.kt +13 -13
  9. package/android/src/main/java/com/renderrichtext/MeasurementStore.kt +644 -644
  10. package/android/src/main/java/com/renderrichtext/accessibility/AccessibilityLabels.kt +17 -17
  11. package/android/src/main/java/com/renderrichtext/accessibility/AccessibleTiptapTextView.kt +112 -112
  12. package/android/src/main/java/com/renderrichtext/accessibility/TiptapAccessibilityHelper.kt +576 -576
  13. package/android/src/main/java/com/renderrichtext/events/CodeBlockPressEvent.kt +24 -24
  14. package/android/src/main/java/com/renderrichtext/events/ContextMenuItemPressEvent.kt +28 -28
  15. package/android/src/main/java/com/renderrichtext/events/CopyPressEvent.kt +24 -24
  16. package/android/src/main/java/com/renderrichtext/events/ImagePressEvent.kt +25 -25
  17. package/android/src/main/java/com/renderrichtext/events/LatexErrorEvent.kt +29 -29
  18. package/android/src/main/java/com/renderrichtext/events/LinkLongPressEvent.kt +23 -23
  19. package/android/src/main/java/com/renderrichtext/events/LinkPressEvent.kt +23 -23
  20. package/android/src/main/java/com/renderrichtext/events/MentionLongPressEvent.kt +25 -25
  21. package/android/src/main/java/com/renderrichtext/events/MentionPressEvent.kt +25 -25
  22. package/android/src/main/java/com/renderrichtext/events/TaskListItemPressEvent.kt +26 -26
  23. package/android/src/main/java/com/renderrichtext/math/LatexErrorReporter.kt +9 -9
  24. package/android/src/main/java/com/renderrichtext/parser/Parser.kt +59 -59
  25. package/android/src/main/java/com/renderrichtext/parser/TextLinkMatching.kt +35 -35
  26. package/android/src/main/java/com/renderrichtext/renderer/BaselineShiftRenderer.kt +45 -45
  27. package/android/src/main/java/com/renderrichtext/renderer/BlankLineRenderer.kt +39 -39
  28. package/android/src/main/java/com/renderrichtext/renderer/BlockquoteRenderer.kt +243 -243
  29. package/android/src/main/java/com/renderrichtext/renderer/BlockquoteTextRenderer.kt +56 -56
  30. package/android/src/main/java/com/renderrichtext/renderer/CodeBlockRenderer.kt +135 -135
  31. package/android/src/main/java/com/renderrichtext/renderer/CodeRenderer.kt +37 -37
  32. package/android/src/main/java/com/renderrichtext/renderer/EmphasisRenderer.kt +28 -28
  33. package/android/src/main/java/com/renderrichtext/renderer/HeadingRenderer.kt +81 -81
  34. package/android/src/main/java/com/renderrichtext/renderer/HighlightRenderer.kt +26 -26
  35. package/android/src/main/java/com/renderrichtext/renderer/LineBreakRenderer.kt +17 -17
  36. package/android/src/main/java/com/renderrichtext/renderer/LinkRenderer.kt +90 -90
  37. package/android/src/main/java/com/renderrichtext/renderer/ListContextManager.kt +109 -109
  38. package/android/src/main/java/com/renderrichtext/renderer/ListItemRenderer.kt +211 -211
  39. package/android/src/main/java/com/renderrichtext/renderer/ListRenderer.kt +133 -133
  40. package/android/src/main/java/com/renderrichtext/renderer/MentionRenderer.kt +85 -85
  41. package/android/src/main/java/com/renderrichtext/renderer/ParagraphRenderer.kt +91 -91
  42. package/android/src/main/java/com/renderrichtext/renderer/Renderer.kt +148 -148
  43. package/android/src/main/java/com/renderrichtext/renderer/SoftBreakRenderer.kt +17 -17
  44. package/android/src/main/java/com/renderrichtext/renderer/SpanStyleCache.kt +143 -143
  45. package/android/src/main/java/com/renderrichtext/renderer/StrikethroughRenderer.kt +28 -28
  46. package/android/src/main/java/com/renderrichtext/renderer/StrongRenderer.kt +28 -28
  47. package/android/src/main/java/com/renderrichtext/renderer/TextRenderer.kt +31 -31
  48. package/android/src/main/java/com/renderrichtext/renderer/ThematicBreakRenderer.kt +46 -46
  49. package/android/src/main/java/com/renderrichtext/renderer/UnderlineRenderer.kt +28 -28
  50. package/android/src/main/java/com/renderrichtext/spans/BaseListSpan.kt +164 -164
  51. package/android/src/main/java/com/renderrichtext/spans/BaselineShiftSpan.kt +27 -27
  52. package/android/src/main/java/com/renderrichtext/spans/BlockquoteSpan.kt +302 -302
  53. package/android/src/main/java/com/renderrichtext/spans/CodeBackgroundSpan.kt +205 -205
  54. package/android/src/main/java/com/renderrichtext/spans/CodeBlockSpan.kt +216 -216
  55. package/android/src/main/java/com/renderrichtext/spans/CodeSpan.kt +32 -32
  56. package/android/src/main/java/com/renderrichtext/spans/EmphasisSpan.kt +42 -42
  57. package/android/src/main/java/com/renderrichtext/spans/FadeInSpan.kt +29 -29
  58. package/android/src/main/java/com/renderrichtext/spans/HeadingSpan.kt +38 -38
  59. package/android/src/main/java/com/renderrichtext/spans/HighlightSpan.kt +80 -80
  60. package/android/src/main/java/com/renderrichtext/spans/ImageSpan.kt +481 -481
  61. package/android/src/main/java/com/renderrichtext/spans/InlineBackgroundGeometry.kt +42 -42
  62. package/android/src/main/java/com/renderrichtext/spans/LineHeightSpan.kt +39 -39
  63. package/android/src/main/java/com/renderrichtext/spans/LinkPillIconCache.kt +154 -154
  64. package/android/src/main/java/com/renderrichtext/spans/LinkPillSpan.kt +381 -381
  65. package/android/src/main/java/com/renderrichtext/spans/LinkSpan.kt +68 -68
  66. package/android/src/main/java/com/renderrichtext/spans/ListItemSpacingSpan.kt +33 -33
  67. package/android/src/main/java/com/renderrichtext/spans/ListMarkerAnchorSpan.kt +38 -38
  68. package/android/src/main/java/com/renderrichtext/spans/MarginBottomSpan.kt +76 -76
  69. package/android/src/main/java/com/renderrichtext/spans/MentionChipSpan.kt +132 -132
  70. package/android/src/main/java/com/renderrichtext/spans/MentionSpan.kt +46 -46
  71. package/android/src/main/java/com/renderrichtext/spans/OrderedListSpan.kt +127 -127
  72. package/android/src/main/java/com/renderrichtext/spans/SpoilerSpan.kt +33 -33
  73. package/android/src/main/java/com/renderrichtext/spans/StrikethroughSpan.kt +12 -12
  74. package/android/src/main/java/com/renderrichtext/spans/StrongSpan.kt +39 -39
  75. package/android/src/main/java/com/renderrichtext/spans/TaskListSpan.kt +143 -143
  76. package/android/src/main/java/com/renderrichtext/spans/TextSpan.kt +26 -26
  77. package/android/src/main/java/com/renderrichtext/spans/ThematicBreakSpan.kt +69 -69
  78. package/android/src/main/java/com/renderrichtext/spans/UnorderedListSpan.kt +96 -96
  79. package/android/src/main/java/com/renderrichtext/spoiler/SpoilerStubs.kt +46 -46
  80. package/android/src/main/java/com/renderrichtext/styles/BaseBlockStyle.kt +11 -11
  81. package/android/src/main/java/com/renderrichtext/styles/BlockquoteStyle.kt +83 -83
  82. package/android/src/main/java/com/renderrichtext/styles/CodeBlockStyle.kt +80 -80
  83. package/android/src/main/java/com/renderrichtext/styles/CodeStyle.kt +26 -26
  84. package/android/src/main/java/com/renderrichtext/styles/EmphasisStyle.kt +21 -21
  85. package/android/src/main/java/com/renderrichtext/styles/FactoidStyle.kt +21 -21
  86. package/android/src/main/java/com/renderrichtext/styles/HeadingStyle.kt +33 -33
  87. package/android/src/main/java/com/renderrichtext/styles/HighlightStyle.kt +19 -19
  88. package/android/src/main/java/com/renderrichtext/styles/ImageStyle.kt +29 -29
  89. package/android/src/main/java/com/renderrichtext/styles/InlineImageStyle.kt +17 -17
  90. package/android/src/main/java/com/renderrichtext/styles/InlineMathStyle.kt +17 -17
  91. package/android/src/main/java/com/renderrichtext/styles/LinkPillContent.kt +36 -36
  92. package/android/src/main/java/com/renderrichtext/styles/LinkPillStyle.kt +44 -44
  93. package/android/src/main/java/com/renderrichtext/styles/LinkStyle.kt +23 -23
  94. package/android/src/main/java/com/renderrichtext/styles/LinkVariantEntry.kt +34 -34
  95. package/android/src/main/java/com/renderrichtext/styles/ListStyle.kt +65 -65
  96. package/android/src/main/java/com/renderrichtext/styles/MathStyle.kt +29 -29
  97. package/android/src/main/java/com/renderrichtext/styles/MentionStyle.kt +19 -19
  98. package/android/src/main/java/com/renderrichtext/styles/ParagraphStyle.kt +33 -33
  99. package/android/src/main/java/com/renderrichtext/styles/SpoilerStyle.kt +27 -27
  100. package/android/src/main/java/com/renderrichtext/styles/StrikethroughStyle.kt +17 -17
  101. package/android/src/main/java/com/renderrichtext/styles/StrongStyle.kt +21 -21
  102. package/android/src/main/java/com/renderrichtext/styles/StyleConfig.kt +411 -411
  103. package/android/src/main/java/com/renderrichtext/styles/StyleParser.kt +92 -92
  104. package/android/src/main/java/com/renderrichtext/styles/SubscriptStyle.kt +16 -16
  105. package/android/src/main/java/com/renderrichtext/styles/SuperscriptStyle.kt +16 -16
  106. package/android/src/main/java/com/renderrichtext/styles/TableStyle.kt +75 -75
  107. package/android/src/main/java/com/renderrichtext/styles/TaskListStyle.kt +29 -29
  108. package/android/src/main/java/com/renderrichtext/styles/TextAlignment.kt +32 -32
  109. package/android/src/main/java/com/renderrichtext/styles/ThematicBreakStyle.kt +23 -23
  110. package/android/src/main/java/com/renderrichtext/styles/UnderlineStyle.kt +17 -17
  111. package/android/src/main/java/com/renderrichtext/styles/VideoStyle.kt +31 -31
  112. package/android/src/main/java/com/renderrichtext/utils/common/BorderStyleUtils.kt +86 -86
  113. package/android/src/main/java/com/renderrichtext/utils/common/BreakStrategyUtils.kt +32 -32
  114. package/android/src/main/java/com/renderrichtext/utils/common/CodeBlockHighlighter.kt +36 -36
  115. package/android/src/main/java/com/renderrichtext/utils/common/CodeBlockNode.kt +42 -42
  116. package/android/src/main/java/com/renderrichtext/utils/common/EllipsizeUtils.kt +27 -27
  117. package/android/src/main/java/com/renderrichtext/utils/common/EnrichedTiptapAncestorUtils.kt +10 -10
  118. package/android/src/main/java/com/renderrichtext/utils/common/FeatureFlags.kt +9 -9
  119. package/android/src/main/java/com/renderrichtext/utils/common/LinkRecognitionProps.kt +31 -31
  120. package/android/src/main/java/com/renderrichtext/utils/common/LinkRegexConfig.kt +56 -56
  121. package/android/src/main/java/com/renderrichtext/utils/common/MotionPreferences.kt +18 -18
  122. package/android/src/main/java/com/renderrichtext/utils/common/ReadableMapExtensions.kt +28 -28
  123. package/android/src/main/java/com/renderrichtext/utils/common/layout/RTLUtils.kt +6 -6
  124. package/android/src/main/java/com/renderrichtext/utils/common/serialization/TiptapASTSerializer.kt +368 -368
  125. package/android/src/main/java/com/renderrichtext/utils/text/ImageCache.kt +88 -88
  126. package/android/src/main/java/com/renderrichtext/utils/text/ImageDownloader.kt +203 -203
  127. package/android/src/main/java/com/renderrichtext/utils/text/LocalImageLoader.kt +167 -167
  128. package/android/src/main/java/com/renderrichtext/utils/text/TailFadeInAnimator.kt +84 -84
  129. package/android/src/main/java/com/renderrichtext/utils/text/conversion/HTMLGenerator.kt +1105 -1105
  130. package/android/src/main/java/com/renderrichtext/utils/text/conversion/TiptapExtractor.kt +423 -423
  131. package/android/src/main/java/com/renderrichtext/utils/text/extensions/ParagraphUtils.kt +9 -9
  132. package/android/src/main/java/com/renderrichtext/utils/text/extensions/SpannableExtensions.kt +9 -9
  133. package/android/src/main/java/com/renderrichtext/utils/text/extensions/TextPaintExtensions.kt +76 -76
  134. package/android/src/main/java/com/renderrichtext/utils/text/interaction/CheckboxTouchHelper.kt +61 -61
  135. package/android/src/main/java/com/renderrichtext/utils/text/interaction/TaskListTapUtils.kt +286 -286
  136. package/android/src/main/java/com/renderrichtext/utils/text/span/ApplyLineHeight.kt +39 -39
  137. package/android/src/main/java/com/renderrichtext/utils/text/span/BlockSpacing.kt +142 -142
  138. package/android/src/main/java/com/renderrichtext/utils/text/span/SpanFlags.kt +43 -43
  139. package/android/src/main/java/com/renderrichtext/utils/text/span/WidthAwareSpans.kt +30 -30
  140. package/android/src/main/java/com/renderrichtext/utils/text/view/CodeBlockPressHost.kt +15 -15
  141. package/android/src/main/java/com/renderrichtext/utils/text/view/ImagePressHost.kt +19 -19
  142. package/android/src/main/java/com/renderrichtext/utils/text/view/SelectionActionMode.kt +235 -235
  143. package/android/src/main/java/com/renderrichtext/utils/text/view/TextSelectionColors.kt +48 -48
  144. package/android/src/main/java/com/renderrichtext/utils/text/view/TextViewSetup.kt +119 -119
  145. package/android/src/main/java/com/renderrichtext/views/ContextMenuPopup.kt +217 -217
  146. package/android/src/main/java/com/renderrichtext/views/CopyGlyph.kt +101 -101
  147. package/android/src/main/java/com/renderrichtext/views/WrapGlyph.kt +40 -40
  148. package/android/src/main/jni/CMakeLists.txt +52 -52
  149. package/android/src/main/jni/react/renderer/components/RichTextSpec/ComponentDescriptors.h +30 -30
  150. package/android/src/main/jni/react/renderer/components/RichTextSpec/TiptapTextComponentDescriptor.h +29 -29
  151. package/android/src/main/jni/react/renderer/components/RichTextSpec/TiptapTextMeasurementManager.cpp +45 -45
  152. package/android/src/main/jni/react/renderer/components/RichTextSpec/TiptapTextMeasurementManager.h +21 -21
  153. package/android/src/main/jni/react/renderer/components/RichTextSpec/TiptapTextShadowNode.cpp +38 -38
  154. package/android/src/main/jni/react/renderer/components/RichTextSpec/TiptapTextShadowNode.h +53 -53
  155. package/android/src/main/jni/react/renderer/components/RichTextSpec/TiptapTextState.cpp +9 -9
  156. package/android/src/main/jni/react/renderer/components/RichTextSpec/TiptapTextState.h +24 -24
  157. package/android/src/main/jni/react/renderer/components/RichTextSpec/conversions.h +40 -40
  158. package/cpp/parser/TiptapParser.hpp +13 -13
  159. package/cpp/vendor/nlohmann/json.hpp +24765 -24765
  160. package/ios/EnrichedTiptapText.h +27 -27
  161. package/ios/attachments/ENRMImageAttachment.h +51 -51
  162. package/ios/attachments/ENRMImageAttachment.m +398 -398
  163. package/ios/attachments/ENRMLinkPillAttachment.h +47 -47
  164. package/ios/attachments/ENRMLinkPillAttachment.m +324 -324
  165. package/ios/attachments/ENRMLinkPillIconCache.h +28 -28
  166. package/ios/attachments/ENRMLinkPillIconCache.m +181 -181
  167. package/ios/attachments/ENRMLinkPillText.h +27 -27
  168. package/ios/attachments/ENRMLinkPillText.m +118 -118
  169. package/ios/attachments/ENRMMathInlineAttachment.h +7 -7
  170. package/ios/attachments/ENRMMathInlineAttachment.m +4 -4
  171. package/ios/attachments/ThematicBreakAttachment.h +15 -15
  172. package/ios/attachments/ThematicBreakAttachment.m +33 -33
  173. package/ios/internals/ENRMViewFreeMeasurement.h +208 -208
  174. package/ios/internals/EnrichedTiptapTextComponentDescriptor.h +19 -19
  175. package/ios/internals/EnrichedTiptapTextShadowNode.h +43 -43
  176. package/ios/internals/EnrichedTiptapTextShadowNode.mm +69 -69
  177. package/ios/internals/EnrichedTiptapTextState.h +24 -24
  178. package/ios/internals/MeasurementCache.h +308 -308
  179. package/ios/internals/ShadowMeasurementUtils.h +145 -145
  180. package/ios/parser/ENRMTextLinkAttributes.h +7 -7
  181. package/ios/parser/ENRMTiptapParser.h +33 -33
  182. package/ios/parser/ENRMTiptapParser.mm +73 -73
  183. package/ios/parser/TiptapASTNode.m +52 -52
  184. package/ios/renderer/AttributedRenderer.h +23 -23
  185. package/ios/renderer/AttributedRenderer.m +229 -229
  186. package/ios/renderer/BaseRenderer.h +33 -33
  187. package/ios/renderer/BaseRenderer.m +46 -46
  188. package/ios/renderer/BaselineShiftTextAttributes.h +10 -10
  189. package/ios/renderer/BaselineShiftTextAttributes.m +26 -26
  190. package/ios/renderer/BlankLineRenderer.h +4 -4
  191. package/ios/renderer/BlankLineRenderer.m +51 -51
  192. package/ios/renderer/BlockquoteRenderer.h +6 -6
  193. package/ios/renderer/BlockquoteRenderer.m +258 -258
  194. package/ios/renderer/CodeBlockRenderer.h +9 -9
  195. package/ios/renderer/CodeBlockRenderer.m +99 -99
  196. package/ios/renderer/CodeRenderer.h +9 -9
  197. package/ios/renderer/CodeRenderer.m +69 -69
  198. package/ios/renderer/ENRMBlockquoteTextRenderer.h +25 -25
  199. package/ios/renderer/ENRMBlockquoteTextRenderer.m +48 -48
  200. package/ios/renderer/EmphasisRenderer.h +5 -5
  201. package/ios/renderer/EmphasisRenderer.m +99 -99
  202. package/ios/renderer/HeadingRenderer.h +6 -6
  203. package/ios/renderer/HeadingRenderer.m +102 -102
  204. package/ios/renderer/HighlightRenderer.h +7 -7
  205. package/ios/renderer/HighlightRenderer.m +33 -33
  206. package/ios/renderer/LinkRenderer.h +6 -6
  207. package/ios/renderer/LinkRenderer.m +157 -157
  208. package/ios/renderer/ListItemRenderer.h +24 -24
  209. package/ios/renderer/ListItemRenderer.m +283 -283
  210. package/ios/renderer/ListRenderer.h +13 -13
  211. package/ios/renderer/ListRenderer.m +126 -126
  212. package/ios/renderer/MentionRenderer.h +9 -9
  213. package/ios/renderer/MentionRenderer.m +96 -96
  214. package/ios/renderer/NodeRenderer.h +8 -8
  215. package/ios/renderer/ParagraphRenderer.h +6 -6
  216. package/ios/renderer/ParagraphRenderer.m +85 -85
  217. package/ios/renderer/RenderContext.h +145 -145
  218. package/ios/renderer/RenderContext.m +402 -402
  219. package/ios/renderer/RendererFactory.h +12 -12
  220. package/ios/renderer/StrikethroughRenderer.h +5 -5
  221. package/ios/renderer/StrikethroughRenderer.m +30 -30
  222. package/ios/renderer/StrongRenderer.h +5 -5
  223. package/ios/renderer/StrongRenderer.m +87 -87
  224. package/ios/renderer/TextRenderer.h +6 -6
  225. package/ios/renderer/TextRenderer.m +18 -18
  226. package/ios/renderer/ThematicBreakRenderer.h +4 -4
  227. package/ios/renderer/ThematicBreakRenderer.m +41 -41
  228. package/ios/renderer/UnderlineRenderer.h +4 -4
  229. package/ios/renderer/UnderlineRenderer.m +41 -41
  230. package/ios/styles/ENRMFontSlot.h +12 -12
  231. package/ios/styles/ENRMFontSlot.m +24 -24
  232. package/ios/styles/StyleConfig.h +488 -488
  233. package/ios/styles/StyleConfig.mm +2820 -2820
  234. package/ios/utils/AccessibilityInfo.h +35 -35
  235. package/ios/utils/AccessibilityInfo.m +24 -24
  236. package/ios/utils/BlockquoteBorder.h +26 -26
  237. package/ios/utils/BlockquoteBorder.m +294 -294
  238. package/ios/utils/CodeBackground.h +19 -19
  239. package/ios/utils/CodeBackground.m +196 -196
  240. package/ios/utils/CodeBlockBackground.h +17 -17
  241. package/ios/utils/CodeBlockBackground.m +102 -102
  242. package/ios/utils/ContextMenuUtils.h +76 -76
  243. package/ios/utils/ContextMenuUtils.mm +75 -75
  244. package/ios/utils/ENRMAccessibilityLabels.h +40 -40
  245. package/ios/utils/ENRMAccessibilityLabels.m +5 -5
  246. package/ios/utils/ENRMAsyncRenderCoordinator.h +24 -24
  247. package/ios/utils/ENRMAsyncRenderCoordinator.m +37 -37
  248. package/ios/utils/ENRMAtomicSize.h +65 -65
  249. package/ios/utils/ENRMCodeBlockContent.h +37 -37
  250. package/ios/utils/ENRMCodeBlockContent.mm +89 -89
  251. package/ios/utils/ENRMFeatureFlags.h +25 -25
  252. package/ios/utils/ENRMImageDownloader.h +30 -30
  253. package/ios/utils/ENRMImageDownloader.m +168 -168
  254. package/ios/utils/ENRMLatexErrorCoordinator.h +27 -27
  255. package/ios/utils/ENRMLatexErrorCoordinator.mm +75 -75
  256. package/ios/utils/ENRMLatexErrorReporting.h +13 -13
  257. package/ios/utils/ENRMLinkContextMenus.h +93 -93
  258. package/ios/utils/ENRMLinkContextMenus.m +178 -178
  259. package/ios/utils/ENRMLinkRegexConfig.h +30 -30
  260. package/ios/utils/ENRMLinkRegexConfig.mm +104 -104
  261. package/ios/utils/ENRMLinkRegexProps.h +23 -23
  262. package/ios/utils/ENRMLocalImageLoader.h +35 -35
  263. package/ios/utils/ENRMLocalImageLoader.m +111 -111
  264. package/ios/utils/ENRMMenuAction.h +27 -27
  265. package/ios/utils/ENRMMenuAction.m +33 -33
  266. package/ios/utils/ENRMSpoilerOverlayManager.h +15 -15
  267. package/ios/utils/ENRMSpoilerOverlayManager.m +19 -19
  268. package/ios/utils/ENRMSpoilerTapUtils.h +6 -6
  269. package/ios/utils/ENRMSpoilerTapUtils.m +11 -11
  270. package/ios/utils/ENRMTailFadeInAnimator.h +16 -16
  271. package/ios/utils/ENRMTailFadeInAnimator.m +161 -161
  272. package/ios/utils/ENRMTextHitTest.h +23 -23
  273. package/ios/utils/ENRMTextHitTest.m +20 -20
  274. package/ios/utils/ENRMTextInteractionUtils.h +20 -20
  275. package/ios/utils/ENRMTextInteractionUtils.m +15 -15
  276. package/ios/utils/ENRMTextRenderer.h +38 -38
  277. package/ios/utils/ENRMTextRenderer.m +54 -54
  278. package/ios/utils/ENRMTextViewSetup.h +132 -132
  279. package/ios/utils/ENRMUIKit.h +429 -429
  280. package/ios/utils/EditMenuUtils.h +105 -105
  281. package/ios/utils/EditMenuUtils.m +143 -143
  282. package/ios/utils/FontScaleObserver.h +10 -10
  283. package/ios/utils/FontScaleObserver.m +55 -55
  284. package/ios/utils/FontUtils.h +31 -31
  285. package/ios/utils/FontUtils.m +63 -63
  286. package/ios/utils/HTMLGenerator.h +25 -25
  287. package/ios/utils/HTMLGenerator.m +1041 -1041
  288. package/ios/utils/HeightUpdateUtils.h +25 -25
  289. package/ios/utils/ImageRequestHeaderUtils.h +35 -35
  290. package/ios/utils/LastElementUtils.h +97 -97
  291. package/ios/utils/LinkContextMenuUtils.h +55 -55
  292. package/ios/utils/LinkPillContentUtils.h +46 -46
  293. package/ios/utils/ListMarkerDrawer.h +15 -15
  294. package/ios/utils/ListMarkerDrawer.m +239 -239
  295. package/ios/utils/MentionBackground.h +26 -26
  296. package/ios/utils/MentionBackground.m +86 -86
  297. package/ios/utils/ParagraphStyleUtils.h +87 -87
  298. package/ios/utils/ParagraphStyleUtils.m +484 -484
  299. package/ios/utils/PasteboardUtils.h +48 -48
  300. package/ios/utils/PasteboardUtils.m +166 -166
  301. package/ios/utils/RTFExportUtils.h +24 -24
  302. package/ios/utils/RTFExportUtils.m +299 -299
  303. package/ios/utils/SelectionColorUtils.h +14 -14
  304. package/ios/utils/StylePropsUtils.h +1340 -1340
  305. package/ios/utils/TaskListTapUtils.h +6 -6
  306. package/ios/utils/TaskListTapUtils.m +14 -14
  307. package/ios/utils/TextViewLayoutManager.h +14 -14
  308. package/ios/utils/TiptapAccessibilityElementBuilder.h +43 -43
  309. package/ios/utils/TiptapAccessibilityElementBuilder.m +432 -432
  310. package/ios/utils/TiptapExtractor.h +17 -17
  311. package/ios/utils/TiptapExtractor.m +367 -367
  312. package/ios/views/ENRMTiptapTextView.h +25 -25
  313. package/ios/views/ENRMTiptapTextView.m +247 -247
  314. package/package.json +65 -70
  315. package/react-native.config.js +10 -10
  316. package/src/admonitionDefaults.ts +58 -58
  317. package/src/darkModeTextStyle.ts +99 -99
  318. package/src/headingDefaults.ts +21 -21
  319. package/src/index.tsx +4 -4
  320. package/src/linkVariantUtils.ts +120 -120
  321. package/src/normalizeTiptapStyle.ts +397 -397
  322. package/src/rn-globals.d.ts +1 -1
  323. package/src/styleUtils.ts +107 -107
  324. package/src/types/TiptapStyle.ts +532 -532
  325. package/src/types/TiptapStyleInternal.ts +299 -299
  326. package/src/types/linkPill.ts +9 -9
  327. package/src/utils/isDev.ts +4 -4
@@ -1,576 +1,576 @@
1
- package com.renderrichtext.accessibility
2
-
3
- import android.graphics.Rect
4
- import android.os.Bundle
5
- import android.text.Layout
6
- import android.text.Spanned
7
- import android.view.View
8
- import android.view.ViewTreeObserver
9
- import android.widget.TextView
10
- import androidx.core.view.ViewCompat
11
- import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
12
- import androidx.customview.widget.ExploreByTouchHelper
13
- import com.renderrichtext.spans.BaseListSpan
14
- import com.renderrichtext.spans.BlockquoteSpan
15
- import com.renderrichtext.spans.HeadingSpan
16
- import com.renderrichtext.spans.ImageSpan
17
- import com.renderrichtext.spans.LinkPillSpan
18
- import com.renderrichtext.spans.LinkSpan
19
- import com.renderrichtext.spans.OrderedListSpan
20
-
21
- class TiptapAccessibilityHelper(
22
- private val textView: TextView,
23
- ) : ExploreByTouchHelper(textView) {
24
- private var items: List<AccessibilityItem> = emptyList()
25
- private var needsRebuild = false
26
- private var lastLayoutHashCode = 0
27
- private var pendingLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
28
-
29
- var labels: AccessibilityLabels = AccessibilityLabels()
30
- set(value) {
31
- if (field == value) return
32
- field = value
33
- invalidateAccessibilityItems()
34
- }
35
-
36
- data class AccessibilityItem(
37
- val id: Int,
38
- val text: String,
39
- /** Full character range — used for hit-testing so there are no gaps between items. */
40
- val start: Int,
41
- val end: Int,
42
- /** Trimmed character range — used for bounds calculation to avoid whitespace offsets. */
43
- val visibleStart: Int = start,
44
- val visibleEnd: Int = end,
45
- val headingLevel: Int = 0,
46
- val linkUrl: String? = null,
47
- val listInfo: ListItemInfo? = null,
48
- val imageAltText: String? = null,
49
- ) {
50
- val isHeading get() = headingLevel > 0
51
- val isLink get() = linkUrl != null
52
- val isListItem get() = listInfo != null
53
- val isImage get() = imageAltText != null
54
- }
55
-
56
- data class ListItemInfo(
57
- val isOrdered: Boolean,
58
- val itemNumber: Int,
59
- val depth: Int,
60
- )
61
-
62
- private data class SpanRange(
63
- val start: Int,
64
- val end: Int,
65
- val headingLevel: Int = 0,
66
- val linkUrl: String? = null,
67
- val imageAltText: String? = null,
68
- )
69
-
70
- fun invalidateAccessibilityItems() {
71
- needsRebuild = true
72
- if (textView.layout != null) {
73
- rebuildIfNeeded()
74
- invalidateRoot()
75
- } else {
76
- schedulePostLayoutRebuild()
77
- }
78
- }
79
-
80
- private fun schedulePostLayoutRebuild() {
81
- if (pendingLayoutListener != null) return
82
- val observer = textView.viewTreeObserver
83
- if (!observer.isAlive) return
84
- val listener =
85
- ViewTreeObserver.OnGlobalLayoutListener {
86
- removePendingLayoutListener()
87
- if (needsRebuild) {
88
- rebuildIfNeeded()
89
- invalidateRoot()
90
- }
91
- }
92
- pendingLayoutListener = listener
93
- observer.addOnGlobalLayoutListener(listener)
94
- }
95
-
96
- /**
97
- * Drops the pending global-layout listener, if any is still registered.
98
- *
99
- * Must run while the view is attached: `textView.viewTreeObserver` returns
100
- * the window's observer only until detach, and the listener was merged into
101
- * it from the view's floating observer when the view attached.
102
- */
103
- fun cleanup() {
104
- removePendingLayoutListener()
105
- }
106
-
107
- private fun removePendingLayoutListener() {
108
- val listener = pendingLayoutListener ?: return
109
- pendingLayoutListener = null
110
- val observer = textView.viewTreeObserver
111
- if (observer.isAlive) {
112
- observer.removeOnGlobalLayoutListener(listener)
113
- }
114
- }
115
-
116
- /**
117
- * When virtual children exist, prevent the host TextView from appearing as a
118
- * standalone focusable element in TalkBack's swipe order. Without this,
119
- * `setTextIsSelectable(true)` keeps the view focusable and TalkBack reads
120
- * the entire text as one element before entering the virtual hierarchy.
121
- */
122
- private fun updateHostFocusability() {
123
- val hasVirtualChildren = items.isNotEmpty()
124
- ViewCompat.setScreenReaderFocusable(textView, !hasVirtualChildren)
125
- textView.importantForAccessibility =
126
- if (hasVirtualChildren) View.IMPORTANT_FOR_ACCESSIBILITY_YES else View.IMPORTANT_FOR_ACCESSIBILITY_AUTO
127
- }
128
-
129
- private fun rebuildIfNeeded() {
130
- val layout = textView.layout ?: return
131
- if (needsRebuild || lastLayoutHashCode != layout.hashCode()) {
132
- items = buildItems()
133
- needsRebuild = false
134
- lastLayoutHashCode = layout.hashCode()
135
- updateHostFocusability()
136
- }
137
- }
138
-
139
- private fun buildItems(): List<AccessibilityItem> {
140
- val spanned = textView.text as? Spanned ?: return emptyList()
141
- if (spanned.isEmpty()) return emptyList()
142
-
143
- val text = spanned.toString()
144
- val endLimit = visibleTextLength(text.length)
145
- val result = mutableListOf<AccessibilityItem>()
146
- var nextId = 0
147
- val semanticSpans = collectSemanticSpans(spanned)
148
-
149
- var paraStart = 0
150
- while (paraStart < endLimit) {
151
- val newlineIdx = text.indexOf('\n', paraStart)
152
- val paraEnd = minOf(if (newlineIdx == -1) text.length else newlineIdx + 1, endLimit)
153
- val trimmed = text.substring(paraStart, paraEnd).trim()
154
-
155
- if (trimmed.isNotEmpty()) {
156
- val spansInParagraph =
157
- semanticSpans
158
- .filter { it.start < paraEnd && it.end > paraStart }
159
- .map { it.copy(end = minOf(it.end, endLimit)) }
160
-
161
- if (spansInParagraph.isEmpty()) {
162
- result.add(
163
- createTextItem(nextId++, trimmed, paraStart, paraEnd, text, spanned),
164
- )
165
- } else {
166
- nextId = addSegmentedItems(result, spanned, text, paraStart, paraEnd, spansInParagraph, nextId)
167
- }
168
- }
169
- paraStart = paraEnd
170
- }
171
-
172
- return result.ifEmpty {
173
- val visibleText = text.substring(0, endLimit).trim()
174
- if (visibleText.isEmpty()) emptyList() else listOf(AccessibilityItem(0, visibleText, 0, endLimit))
175
- }
176
- }
177
-
178
- /** Last line that is visible; less than lineCount - 1 when height-capped (e.g. `clip`). */
179
- private fun lastVisibleLineIndex(layout: Layout): Int {
180
- val maxLines = textView.maxLines.coerceAtLeast(1)
181
- return (minOf(layout.lineCount, maxLines) - 1).coerceIn(0, layout.lineCount - 1)
182
- }
183
-
184
- /** Ellipsis-aware end offset for a visible line. */
185
- private fun visibleLineEnd(
186
- layout: Layout,
187
- line: Int,
188
- ): Int {
189
- val lineEnd = layout.getLineEnd(line)
190
- val count = layout.getEllipsisCount(line)
191
- if (count == 0) return lineEnd
192
- // Only a tail ellipsis hides text up to the end of the line. Head/middle hide a prefix or an
193
- // inner range, so the line end is still laid out and must stay reachable.
194
- val hiddenStart = layout.getLineStart(line) + layout.getEllipsisStart(line)
195
- return if (hiddenStart + count >= lineEnd) hiddenStart else lineEnd
196
- }
197
-
198
- /** End offset of the laid-out (visible) text; less than the full length when truncated. */
199
- private fun visibleTextLength(fullLength: Int): Int {
200
- val layout = textView.layout ?: return fullLength
201
- if (layout.lineCount == 0) return fullLength
202
- return minOf(fullLength, maxOf(0, visibleLineEnd(layout, lastVisibleLineIndex(layout))))
203
- }
204
-
205
- private fun collectSemanticSpans(spanned: Spanned): List<SpanRange> =
206
- (
207
- spanned.getSpans(0, spanned.length, HeadingSpan::class.java).map {
208
- SpanRange(spanned.getSpanStart(it), spanned.getSpanEnd(it), headingLevel = it.level)
209
- } +
210
- spanned.getSpans(0, spanned.length, LinkSpan::class.java).map {
211
- SpanRange(spanned.getSpanStart(it), spanned.getSpanEnd(it), linkUrl = it.url)
212
- } +
213
- spanned.getSpans(0, spanned.length, ImageSpan::class.java).map {
214
- SpanRange(spanned.getSpanStart(it), spanned.getSpanEnd(it), imageAltText = it.altText)
215
- }
216
- ).sortedBy { it.start }
217
-
218
- private fun addSegmentedItems(
219
- items: MutableList<AccessibilityItem>,
220
- spanned: Spanned,
221
- text: String,
222
- paraStart: Int,
223
- paraEnd: Int,
224
- spans: List<SpanRange>,
225
- startId: Int,
226
- ): Int {
227
- var nextId = startId
228
- var segmentPos = paraStart
229
-
230
- for (span in spans) {
231
- if (span.start < segmentPos) continue
232
-
233
- // Text before the span
234
- if (segmentPos < span.start) {
235
- val beforeText = text.substring(segmentPos, span.start).trim()
236
- if (beforeText.isNotEmpty() && beforeText.any { it.isLetterOrDigit() }) {
237
- items.add(createTextItem(nextId++, beforeText, segmentPos, span.start, text, spanned))
238
- }
239
- }
240
-
241
- // The semantic span itself
242
- val pill =
243
- if (span.linkUrl != null) {
244
- spanned.getSpans(span.start, span.end, LinkPillSpan::class.java).firstOrNull {
245
- spanned.getSpanStart(it) == span.start && spanned.getSpanEnd(it) == span.end
246
- }
247
- } else {
248
- null
249
- }
250
- val content = span.imageAltText ?: pill?.accessibilityText ?: spanned.substring(span.start, span.end).trim()
251
- if (content.isNotEmpty()) {
252
- items.add(createSpanItem(nextId++, content, span, spanned))
253
- }
254
- segmentPos = span.end
255
- }
256
-
257
- // Text after the last span
258
- if (segmentPos < paraEnd) {
259
- val afterText = text.substring(segmentPos, paraEnd).trim()
260
- if (afterText.isNotEmpty() && afterText.any { it.isLetterOrDigit() }) {
261
- items.add(createTextItem(nextId++, afterText, segmentPos, paraEnd, text, spanned))
262
- }
263
- }
264
-
265
- return nextId
266
- }
267
-
268
- private fun createTextItem(
269
- id: Int,
270
- label: String,
271
- start: Int,
272
- end: Int,
273
- text: String,
274
- spanned: Spanned,
275
- ) = AccessibilityItem(
276
- id = id,
277
- text = label,
278
- start = start,
279
- end = end,
280
- visibleStart = text.findFirstNonWhitespace(start, end),
281
- visibleEnd = text.findLastNonWhitespace(start, end),
282
- listInfo = getListInfoAt(spanned, start, requireStart = true),
283
- )
284
-
285
- private fun createSpanItem(
286
- id: Int,
287
- content: String,
288
- span: SpanRange,
289
- spanned: Spanned,
290
- ): AccessibilityItem {
291
- val listContext =
292
- if (span.headingLevel > 0 || span.imageAltText != null) {
293
- null
294
- } else {
295
- getListInfoAt(spanned, span.start, requireStart = span.linkUrl == null)
296
- }
297
- return AccessibilityItem(
298
- id = id,
299
- text = content,
300
- start = span.start,
301
- end = span.end,
302
- headingLevel = span.headingLevel,
303
- linkUrl = span.linkUrl,
304
- listInfo = listContext,
305
- imageAltText = span.imageAltText,
306
- )
307
- }
308
-
309
- override fun getVirtualViewAt(
310
- x: Float,
311
- y: Float,
312
- ): Int {
313
- rebuildIfNeeded()
314
- if (items.isEmpty()) return HOST_ID
315
-
316
- val offset = getCharOffsetAt(x, y)
317
-
318
- val exact =
319
- items
320
- .filter { offset in it.start until it.end }
321
- .minByOrNull { it.hitTestPriority }
322
- if (exact != null) return exact.id
323
-
324
- return items.minByOrNull { it.distanceTo(offset) }?.id ?: HOST_ID
325
- }
326
-
327
- override fun getVisibleVirtualViews(ids: MutableList<Int>) {
328
- rebuildIfNeeded()
329
- items.forEach { ids.add(it.id) }
330
- }
331
-
332
- override fun onPopulateNodeForHost(host: AccessibilityNodeInfoCompat) {
333
- super.onPopulateNodeForHost(host)
334
- rebuildIfNeeded()
335
- if (items.isNotEmpty()) {
336
- host.isScreenReaderFocusable = false
337
- host.isFocusable = false
338
- // Prevent TalkBack from reading the full text when swiping onto the host.
339
- // Without this, the host node retains the TextView's text and TalkBack
340
- // announces it as a single element before entering the virtual children.
341
- host.text = null
342
- host.contentDescription = null
343
- }
344
- }
345
-
346
- override fun onPopulateNodeForVirtualView(
347
- id: Int,
348
- node: AccessibilityNodeInfoCompat,
349
- ) {
350
- val item = items.getOrNull(id)
351
- if (item == null) {
352
- node.contentDescription = ""
353
- node.setBoundsInParent(Rect())
354
- return
355
- }
356
- node.apply {
357
- text = item.text
358
- contentDescription = item.text
359
- isFocusable = true
360
- isScreenReaderFocusable = true
361
- setBoundsInParent(boundsForItem(item))
362
- applySemantics(item)
363
- }
364
- }
365
-
366
- override fun onPerformActionForVirtualView(
367
- id: Int,
368
- action: Int,
369
- args: Bundle?,
370
- ): Boolean {
371
- val item = items.getOrNull(id) ?: return false
372
- if (action == AccessibilityNodeInfoCompat.ACTION_CLICK && item.isLink) {
373
- val spanned = textView.text as? Spanned ?: return false
374
- val linkSpan = spanned.getSpans(item.start, item.end, LinkSpan::class.java).firstOrNull() ?: return false
375
- linkSpan.onClick(textView)
376
- return true
377
- }
378
- return false
379
- }
380
-
381
- private fun AccessibilityNodeInfoCompat.applySemantics(item: AccessibilityItem) {
382
- item.listInfo?.let { info ->
383
- setCollectionItemInfo(
384
- AccessibilityNodeInfoCompat.CollectionItemInfoCompat.obtain(info.itemNumber - 1, 1, 0, 1, false, false),
385
- )
386
- }
387
-
388
- val blockquoteAnnouncement = blockquoteAnnouncementFor(item)
389
- when {
390
- item.isHeading -> {
391
- isHeading = true
392
- if (blockquoteAnnouncement != null) {
393
- roleDescription = blockquoteAnnouncement
394
- }
395
- }
396
-
397
- item.isImage -> {
398
- roleDescription = appendBlockquote("image", blockquoteAnnouncement)
399
- }
400
-
401
- item.isLink -> {
402
- isClickable = true
403
- addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK)
404
- val base = item.listInfo?.let { "link, ${it.listAnnouncement}" } ?: "link"
405
- roleDescription = appendBlockquote(base, blockquoteAnnouncement)
406
- }
407
-
408
- item.isListItem -> {
409
- roleDescription = appendBlockquote(item.listInfo!!.listAnnouncement, blockquoteAnnouncement)
410
- }
411
-
412
- blockquoteAnnouncement != null -> {
413
- roleDescription = blockquoteAnnouncement
414
- }
415
- }
416
- }
417
-
418
- private fun appendBlockquote(
419
- base: String,
420
- blockquoteAnnouncement: String?,
421
- ): String = if (blockquoteAnnouncement != null) "$base, $blockquoteAnnouncement" else base
422
-
423
- private fun blockquoteAnnouncementFor(item: AccessibilityItem): String? {
424
- val spanned = textView.text as? Spanned ?: return null
425
- if (spanned.isEmpty()) return null
426
- val pos = item.start.coerceIn(0, spanned.length - 1).coerceAtLeast(0)
427
- if (pos >= spanned.length) return null
428
- val spans = spanned.getSpans(pos, pos + 1, BlockquoteSpan::class.java)
429
- if (spans.isEmpty()) return null
430
- val maxDepth = spans.maxOf { it.depth }
431
- return if (maxDepth >= 1) labels.nestedBlockquote else labels.blockquote
432
- }
433
-
434
- private val ListItemInfo.listAnnouncement: String
435
- get() {
436
- val nested = depth > 0
437
- return if (isOrdered) {
438
- val template = if (nested) labels.nestedOrderedItem else labels.orderedItem
439
- template.replace("{n}", itemNumber.toString())
440
- } else {
441
- if (nested) labels.nestedBulletPoint else labels.bulletPoint
442
- }
443
- }
444
-
445
- private fun boundsForItem(item: AccessibilityItem): Rect {
446
- val layout = textView.layout ?: return Rect()
447
- if (layout.lineCount == 0) return Rect()
448
- val maxOffset = visibleLineEnd(layout, lastVisibleLineIndex(layout))
449
- val vs = item.visibleStart.coerceIn(0, maxOffset)
450
- val ve = item.visibleEnd.coerceIn(vs, maxOffset)
451
-
452
- val startLine = layout.getLineForOffset(vs)
453
- val endLine = layout.getLineForOffset(maxOf(vs, ve - 1))
454
-
455
- if (startLine == endLine) {
456
- return singleLineBounds(vs, ve, startLine)
457
- }
458
-
459
- return multiLineBounds(vs, startLine, endLine)
460
- }
461
-
462
- private fun singleLineBounds(
463
- start: Int,
464
- end: Int,
465
- line: Int,
466
- ): Rect {
467
- val layout = textView.layout
468
- val left = layout.getPrimaryHorizontal(start).toInt() + textView.paddingLeft
469
- val rawRight = layout.getPrimaryHorizontal(end).toInt() + textView.paddingLeft
470
- val right = if (rawRight <= left) layout.getLineRight(line).toInt() + textView.paddingLeft else rawRight
471
-
472
- return Rect(
473
- left,
474
- layout.getLineTop(line) + textView.paddingTop,
475
- right,
476
- layout.getLineBottom(line) + textView.paddingTop,
477
- )
478
- }
479
-
480
- private fun multiLineBounds(
481
- visibleStart: Int,
482
- startLine: Int,
483
- endLine: Int,
484
- ): Rect {
485
- val layout = textView.layout
486
- val padLeft = textView.paddingLeft
487
-
488
- val firstLineLeft = layout.getPrimaryHorizontal(visibleStart).toInt() + padLeft
489
- val firstLineRight = layout.getLineRight(startLine).toInt() + padLeft
490
-
491
- var minLeft = firstLineLeft
492
- var maxRight = firstLineRight
493
- for (line in (startLine + 1)..endLine) {
494
- minLeft = minOf(minLeft, layout.getLineLeft(line).toInt() + padLeft)
495
- maxRight = maxOf(maxRight, layout.getLineRight(line).toInt() + padLeft)
496
- }
497
-
498
- return Rect(
499
- minOf(firstLineLeft, minLeft),
500
- layout.getLineTop(startLine) + textView.paddingTop,
501
- maxRight,
502
- layout.getLineBottom(endLine) + textView.paddingTop,
503
- )
504
- }
505
-
506
- private fun getCharOffsetAt(
507
- x: Float,
508
- y: Float,
509
- ): Int {
510
- val layout = textView.layout ?: return 0
511
- val line = layout.getLineForVertical(y.toInt()).coerceIn(0, layout.lineCount - 1)
512
- return layout.getOffsetForHorizontal(line, x)
513
- }
514
-
515
- private val AccessibilityItem.hitTestPriority: Int
516
- get() =
517
- when {
518
- isLink -> 0
519
- isImage -> 1
520
- isHeading -> 2
521
- isListItem -> 3
522
- else -> 4
523
- }
524
-
525
- private fun AccessibilityItem.distanceTo(offset: Int): Int =
526
- when {
527
- offset < start -> start - offset
528
- offset >= end -> offset - end + 1
529
- else -> 0
530
- }
531
-
532
- private fun getListInfoAt(
533
- spanned: Spanned,
534
- position: Int,
535
- requireStart: Boolean,
536
- ): ListItemInfo? {
537
- val deepest =
538
- spanned
539
- .getSpans(position, position + 1, BaseListSpan::class.java)
540
- .maxByOrNull { it.depth } ?: return null
541
-
542
- if (requireStart) {
543
- val spanStart = spanned.getSpanStart(deepest)
544
- val firstChar =
545
- (spanStart until minOf(spanStart + 10, spanned.length))
546
- .firstOrNull { !spanned[it].isWhitespace() } ?: spanStart
547
- if (position > firstChar + 1) return null
548
- }
549
-
550
- return ListItemInfo(
551
- isOrdered = deepest is OrderedListSpan,
552
- itemNumber = (deepest as? OrderedListSpan)?.itemNumber ?: 0,
553
- depth = deepest.depth,
554
- )
555
- }
556
- }
557
-
558
- private fun String.findFirstNonWhitespace(
559
- from: Int,
560
- to: Int,
561
- ): Int {
562
- for (i in from until to) {
563
- if (!this[i].isWhitespace()) return i
564
- }
565
- return from
566
- }
567
-
568
- private fun String.findLastNonWhitespace(
569
- from: Int,
570
- to: Int,
571
- ): Int {
572
- for (i in (to - 1) downTo from) {
573
- if (!this[i].isWhitespace()) return i + 1
574
- }
575
- return to
576
- }
1
+ package com.renderrichtext.accessibility
2
+
3
+ import android.graphics.Rect
4
+ import android.os.Bundle
5
+ import android.text.Layout
6
+ import android.text.Spanned
7
+ import android.view.View
8
+ import android.view.ViewTreeObserver
9
+ import android.widget.TextView
10
+ import androidx.core.view.ViewCompat
11
+ import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
12
+ import androidx.customview.widget.ExploreByTouchHelper
13
+ import com.renderrichtext.spans.BaseListSpan
14
+ import com.renderrichtext.spans.BlockquoteSpan
15
+ import com.renderrichtext.spans.HeadingSpan
16
+ import com.renderrichtext.spans.ImageSpan
17
+ import com.renderrichtext.spans.LinkPillSpan
18
+ import com.renderrichtext.spans.LinkSpan
19
+ import com.renderrichtext.spans.OrderedListSpan
20
+
21
+ class TiptapAccessibilityHelper(
22
+ private val textView: TextView,
23
+ ) : ExploreByTouchHelper(textView) {
24
+ private var items: List<AccessibilityItem> = emptyList()
25
+ private var needsRebuild = false
26
+ private var lastLayoutHashCode = 0
27
+ private var pendingLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
28
+
29
+ var labels: AccessibilityLabels = AccessibilityLabels()
30
+ set(value) {
31
+ if (field == value) return
32
+ field = value
33
+ invalidateAccessibilityItems()
34
+ }
35
+
36
+ data class AccessibilityItem(
37
+ val id: Int,
38
+ val text: String,
39
+ /** Full character range — used for hit-testing so there are no gaps between items. */
40
+ val start: Int,
41
+ val end: Int,
42
+ /** Trimmed character range — used for bounds calculation to avoid whitespace offsets. */
43
+ val visibleStart: Int = start,
44
+ val visibleEnd: Int = end,
45
+ val headingLevel: Int = 0,
46
+ val linkUrl: String? = null,
47
+ val listInfo: ListItemInfo? = null,
48
+ val imageAltText: String? = null,
49
+ ) {
50
+ val isHeading get() = headingLevel > 0
51
+ val isLink get() = linkUrl != null
52
+ val isListItem get() = listInfo != null
53
+ val isImage get() = imageAltText != null
54
+ }
55
+
56
+ data class ListItemInfo(
57
+ val isOrdered: Boolean,
58
+ val itemNumber: Int,
59
+ val depth: Int,
60
+ )
61
+
62
+ private data class SpanRange(
63
+ val start: Int,
64
+ val end: Int,
65
+ val headingLevel: Int = 0,
66
+ val linkUrl: String? = null,
67
+ val imageAltText: String? = null,
68
+ )
69
+
70
+ fun invalidateAccessibilityItems() {
71
+ needsRebuild = true
72
+ if (textView.layout != null) {
73
+ rebuildIfNeeded()
74
+ invalidateRoot()
75
+ } else {
76
+ schedulePostLayoutRebuild()
77
+ }
78
+ }
79
+
80
+ private fun schedulePostLayoutRebuild() {
81
+ if (pendingLayoutListener != null) return
82
+ val observer = textView.viewTreeObserver
83
+ if (!observer.isAlive) return
84
+ val listener =
85
+ ViewTreeObserver.OnGlobalLayoutListener {
86
+ removePendingLayoutListener()
87
+ if (needsRebuild) {
88
+ rebuildIfNeeded()
89
+ invalidateRoot()
90
+ }
91
+ }
92
+ pendingLayoutListener = listener
93
+ observer.addOnGlobalLayoutListener(listener)
94
+ }
95
+
96
+ /**
97
+ * Drops the pending global-layout listener, if any is still registered.
98
+ *
99
+ * Must run while the view is attached: `textView.viewTreeObserver` returns
100
+ * the window's observer only until detach, and the listener was merged into
101
+ * it from the view's floating observer when the view attached.
102
+ */
103
+ fun cleanup() {
104
+ removePendingLayoutListener()
105
+ }
106
+
107
+ private fun removePendingLayoutListener() {
108
+ val listener = pendingLayoutListener ?: return
109
+ pendingLayoutListener = null
110
+ val observer = textView.viewTreeObserver
111
+ if (observer.isAlive) {
112
+ observer.removeOnGlobalLayoutListener(listener)
113
+ }
114
+ }
115
+
116
+ /**
117
+ * When virtual children exist, prevent the host TextView from appearing as a
118
+ * standalone focusable element in TalkBack's swipe order. Without this,
119
+ * `setTextIsSelectable(true)` keeps the view focusable and TalkBack reads
120
+ * the entire text as one element before entering the virtual hierarchy.
121
+ */
122
+ private fun updateHostFocusability() {
123
+ val hasVirtualChildren = items.isNotEmpty()
124
+ ViewCompat.setScreenReaderFocusable(textView, !hasVirtualChildren)
125
+ textView.importantForAccessibility =
126
+ if (hasVirtualChildren) View.IMPORTANT_FOR_ACCESSIBILITY_YES else View.IMPORTANT_FOR_ACCESSIBILITY_AUTO
127
+ }
128
+
129
+ private fun rebuildIfNeeded() {
130
+ val layout = textView.layout ?: return
131
+ if (needsRebuild || lastLayoutHashCode != layout.hashCode()) {
132
+ items = buildItems()
133
+ needsRebuild = false
134
+ lastLayoutHashCode = layout.hashCode()
135
+ updateHostFocusability()
136
+ }
137
+ }
138
+
139
+ private fun buildItems(): List<AccessibilityItem> {
140
+ val spanned = textView.text as? Spanned ?: return emptyList()
141
+ if (spanned.isEmpty()) return emptyList()
142
+
143
+ val text = spanned.toString()
144
+ val endLimit = visibleTextLength(text.length)
145
+ val result = mutableListOf<AccessibilityItem>()
146
+ var nextId = 0
147
+ val semanticSpans = collectSemanticSpans(spanned)
148
+
149
+ var paraStart = 0
150
+ while (paraStart < endLimit) {
151
+ val newlineIdx = text.indexOf('\n', paraStart)
152
+ val paraEnd = minOf(if (newlineIdx == -1) text.length else newlineIdx + 1, endLimit)
153
+ val trimmed = text.substring(paraStart, paraEnd).trim()
154
+
155
+ if (trimmed.isNotEmpty()) {
156
+ val spansInParagraph =
157
+ semanticSpans
158
+ .filter { it.start < paraEnd && it.end > paraStart }
159
+ .map { it.copy(end = minOf(it.end, endLimit)) }
160
+
161
+ if (spansInParagraph.isEmpty()) {
162
+ result.add(
163
+ createTextItem(nextId++, trimmed, paraStart, paraEnd, text, spanned),
164
+ )
165
+ } else {
166
+ nextId = addSegmentedItems(result, spanned, text, paraStart, paraEnd, spansInParagraph, nextId)
167
+ }
168
+ }
169
+ paraStart = paraEnd
170
+ }
171
+
172
+ return result.ifEmpty {
173
+ val visibleText = text.substring(0, endLimit).trim()
174
+ if (visibleText.isEmpty()) emptyList() else listOf(AccessibilityItem(0, visibleText, 0, endLimit))
175
+ }
176
+ }
177
+
178
+ /** Last line that is visible; less than lineCount - 1 when height-capped (e.g. `clip`). */
179
+ private fun lastVisibleLineIndex(layout: Layout): Int {
180
+ val maxLines = textView.maxLines.coerceAtLeast(1)
181
+ return (minOf(layout.lineCount, maxLines) - 1).coerceIn(0, layout.lineCount - 1)
182
+ }
183
+
184
+ /** Ellipsis-aware end offset for a visible line. */
185
+ private fun visibleLineEnd(
186
+ layout: Layout,
187
+ line: Int,
188
+ ): Int {
189
+ val lineEnd = layout.getLineEnd(line)
190
+ val count = layout.getEllipsisCount(line)
191
+ if (count == 0) return lineEnd
192
+ // Only a tail ellipsis hides text up to the end of the line. Head/middle hide a prefix or an
193
+ // inner range, so the line end is still laid out and must stay reachable.
194
+ val hiddenStart = layout.getLineStart(line) + layout.getEllipsisStart(line)
195
+ return if (hiddenStart + count >= lineEnd) hiddenStart else lineEnd
196
+ }
197
+
198
+ /** End offset of the laid-out (visible) text; less than the full length when truncated. */
199
+ private fun visibleTextLength(fullLength: Int): Int {
200
+ val layout = textView.layout ?: return fullLength
201
+ if (layout.lineCount == 0) return fullLength
202
+ return minOf(fullLength, maxOf(0, visibleLineEnd(layout, lastVisibleLineIndex(layout))))
203
+ }
204
+
205
+ private fun collectSemanticSpans(spanned: Spanned): List<SpanRange> =
206
+ (
207
+ spanned.getSpans(0, spanned.length, HeadingSpan::class.java).map {
208
+ SpanRange(spanned.getSpanStart(it), spanned.getSpanEnd(it), headingLevel = it.level)
209
+ } +
210
+ spanned.getSpans(0, spanned.length, LinkSpan::class.java).map {
211
+ SpanRange(spanned.getSpanStart(it), spanned.getSpanEnd(it), linkUrl = it.url)
212
+ } +
213
+ spanned.getSpans(0, spanned.length, ImageSpan::class.java).map {
214
+ SpanRange(spanned.getSpanStart(it), spanned.getSpanEnd(it), imageAltText = it.altText)
215
+ }
216
+ ).sortedBy { it.start }
217
+
218
+ private fun addSegmentedItems(
219
+ items: MutableList<AccessibilityItem>,
220
+ spanned: Spanned,
221
+ text: String,
222
+ paraStart: Int,
223
+ paraEnd: Int,
224
+ spans: List<SpanRange>,
225
+ startId: Int,
226
+ ): Int {
227
+ var nextId = startId
228
+ var segmentPos = paraStart
229
+
230
+ for (span in spans) {
231
+ if (span.start < segmentPos) continue
232
+
233
+ // Text before the span
234
+ if (segmentPos < span.start) {
235
+ val beforeText = text.substring(segmentPos, span.start).trim()
236
+ if (beforeText.isNotEmpty() && beforeText.any { it.isLetterOrDigit() }) {
237
+ items.add(createTextItem(nextId++, beforeText, segmentPos, span.start, text, spanned))
238
+ }
239
+ }
240
+
241
+ // The semantic span itself
242
+ val pill =
243
+ if (span.linkUrl != null) {
244
+ spanned.getSpans(span.start, span.end, LinkPillSpan::class.java).firstOrNull {
245
+ spanned.getSpanStart(it) == span.start && spanned.getSpanEnd(it) == span.end
246
+ }
247
+ } else {
248
+ null
249
+ }
250
+ val content = span.imageAltText ?: pill?.accessibilityText ?: spanned.substring(span.start, span.end).trim()
251
+ if (content.isNotEmpty()) {
252
+ items.add(createSpanItem(nextId++, content, span, spanned))
253
+ }
254
+ segmentPos = span.end
255
+ }
256
+
257
+ // Text after the last span
258
+ if (segmentPos < paraEnd) {
259
+ val afterText = text.substring(segmentPos, paraEnd).trim()
260
+ if (afterText.isNotEmpty() && afterText.any { it.isLetterOrDigit() }) {
261
+ items.add(createTextItem(nextId++, afterText, segmentPos, paraEnd, text, spanned))
262
+ }
263
+ }
264
+
265
+ return nextId
266
+ }
267
+
268
+ private fun createTextItem(
269
+ id: Int,
270
+ label: String,
271
+ start: Int,
272
+ end: Int,
273
+ text: String,
274
+ spanned: Spanned,
275
+ ) = AccessibilityItem(
276
+ id = id,
277
+ text = label,
278
+ start = start,
279
+ end = end,
280
+ visibleStart = text.findFirstNonWhitespace(start, end),
281
+ visibleEnd = text.findLastNonWhitespace(start, end),
282
+ listInfo = getListInfoAt(spanned, start, requireStart = true),
283
+ )
284
+
285
+ private fun createSpanItem(
286
+ id: Int,
287
+ content: String,
288
+ span: SpanRange,
289
+ spanned: Spanned,
290
+ ): AccessibilityItem {
291
+ val listContext =
292
+ if (span.headingLevel > 0 || span.imageAltText != null) {
293
+ null
294
+ } else {
295
+ getListInfoAt(spanned, span.start, requireStart = span.linkUrl == null)
296
+ }
297
+ return AccessibilityItem(
298
+ id = id,
299
+ text = content,
300
+ start = span.start,
301
+ end = span.end,
302
+ headingLevel = span.headingLevel,
303
+ linkUrl = span.linkUrl,
304
+ listInfo = listContext,
305
+ imageAltText = span.imageAltText,
306
+ )
307
+ }
308
+
309
+ override fun getVirtualViewAt(
310
+ x: Float,
311
+ y: Float,
312
+ ): Int {
313
+ rebuildIfNeeded()
314
+ if (items.isEmpty()) return HOST_ID
315
+
316
+ val offset = getCharOffsetAt(x, y)
317
+
318
+ val exact =
319
+ items
320
+ .filter { offset in it.start until it.end }
321
+ .minByOrNull { it.hitTestPriority }
322
+ if (exact != null) return exact.id
323
+
324
+ return items.minByOrNull { it.distanceTo(offset) }?.id ?: HOST_ID
325
+ }
326
+
327
+ override fun getVisibleVirtualViews(ids: MutableList<Int>) {
328
+ rebuildIfNeeded()
329
+ items.forEach { ids.add(it.id) }
330
+ }
331
+
332
+ override fun onPopulateNodeForHost(host: AccessibilityNodeInfoCompat) {
333
+ super.onPopulateNodeForHost(host)
334
+ rebuildIfNeeded()
335
+ if (items.isNotEmpty()) {
336
+ host.isScreenReaderFocusable = false
337
+ host.isFocusable = false
338
+ // Prevent TalkBack from reading the full text when swiping onto the host.
339
+ // Without this, the host node retains the TextView's text and TalkBack
340
+ // announces it as a single element before entering the virtual children.
341
+ host.text = null
342
+ host.contentDescription = null
343
+ }
344
+ }
345
+
346
+ override fun onPopulateNodeForVirtualView(
347
+ id: Int,
348
+ node: AccessibilityNodeInfoCompat,
349
+ ) {
350
+ val item = items.getOrNull(id)
351
+ if (item == null) {
352
+ node.contentDescription = ""
353
+ node.setBoundsInParent(Rect())
354
+ return
355
+ }
356
+ node.apply {
357
+ text = item.text
358
+ contentDescription = item.text
359
+ isFocusable = true
360
+ isScreenReaderFocusable = true
361
+ setBoundsInParent(boundsForItem(item))
362
+ applySemantics(item)
363
+ }
364
+ }
365
+
366
+ override fun onPerformActionForVirtualView(
367
+ id: Int,
368
+ action: Int,
369
+ args: Bundle?,
370
+ ): Boolean {
371
+ val item = items.getOrNull(id) ?: return false
372
+ if (action == AccessibilityNodeInfoCompat.ACTION_CLICK && item.isLink) {
373
+ val spanned = textView.text as? Spanned ?: return false
374
+ val linkSpan = spanned.getSpans(item.start, item.end, LinkSpan::class.java).firstOrNull() ?: return false
375
+ linkSpan.onClick(textView)
376
+ return true
377
+ }
378
+ return false
379
+ }
380
+
381
+ private fun AccessibilityNodeInfoCompat.applySemantics(item: AccessibilityItem) {
382
+ item.listInfo?.let { info ->
383
+ setCollectionItemInfo(
384
+ AccessibilityNodeInfoCompat.CollectionItemInfoCompat.obtain(info.itemNumber - 1, 1, 0, 1, false, false),
385
+ )
386
+ }
387
+
388
+ val blockquoteAnnouncement = blockquoteAnnouncementFor(item)
389
+ when {
390
+ item.isHeading -> {
391
+ isHeading = true
392
+ if (blockquoteAnnouncement != null) {
393
+ roleDescription = blockquoteAnnouncement
394
+ }
395
+ }
396
+
397
+ item.isImage -> {
398
+ roleDescription = appendBlockquote("image", blockquoteAnnouncement)
399
+ }
400
+
401
+ item.isLink -> {
402
+ isClickable = true
403
+ addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK)
404
+ val base = item.listInfo?.let { "link, ${it.listAnnouncement}" } ?: "link"
405
+ roleDescription = appendBlockquote(base, blockquoteAnnouncement)
406
+ }
407
+
408
+ item.isListItem -> {
409
+ roleDescription = appendBlockquote(item.listInfo!!.listAnnouncement, blockquoteAnnouncement)
410
+ }
411
+
412
+ blockquoteAnnouncement != null -> {
413
+ roleDescription = blockquoteAnnouncement
414
+ }
415
+ }
416
+ }
417
+
418
+ private fun appendBlockquote(
419
+ base: String,
420
+ blockquoteAnnouncement: String?,
421
+ ): String = if (blockquoteAnnouncement != null) "$base, $blockquoteAnnouncement" else base
422
+
423
+ private fun blockquoteAnnouncementFor(item: AccessibilityItem): String? {
424
+ val spanned = textView.text as? Spanned ?: return null
425
+ if (spanned.isEmpty()) return null
426
+ val pos = item.start.coerceIn(0, spanned.length - 1).coerceAtLeast(0)
427
+ if (pos >= spanned.length) return null
428
+ val spans = spanned.getSpans(pos, pos + 1, BlockquoteSpan::class.java)
429
+ if (spans.isEmpty()) return null
430
+ val maxDepth = spans.maxOf { it.depth }
431
+ return if (maxDepth >= 1) labels.nestedBlockquote else labels.blockquote
432
+ }
433
+
434
+ private val ListItemInfo.listAnnouncement: String
435
+ get() {
436
+ val nested = depth > 0
437
+ return if (isOrdered) {
438
+ val template = if (nested) labels.nestedOrderedItem else labels.orderedItem
439
+ template.replace("{n}", itemNumber.toString())
440
+ } else {
441
+ if (nested) labels.nestedBulletPoint else labels.bulletPoint
442
+ }
443
+ }
444
+
445
+ private fun boundsForItem(item: AccessibilityItem): Rect {
446
+ val layout = textView.layout ?: return Rect()
447
+ if (layout.lineCount == 0) return Rect()
448
+ val maxOffset = visibleLineEnd(layout, lastVisibleLineIndex(layout))
449
+ val vs = item.visibleStart.coerceIn(0, maxOffset)
450
+ val ve = item.visibleEnd.coerceIn(vs, maxOffset)
451
+
452
+ val startLine = layout.getLineForOffset(vs)
453
+ val endLine = layout.getLineForOffset(maxOf(vs, ve - 1))
454
+
455
+ if (startLine == endLine) {
456
+ return singleLineBounds(vs, ve, startLine)
457
+ }
458
+
459
+ return multiLineBounds(vs, startLine, endLine)
460
+ }
461
+
462
+ private fun singleLineBounds(
463
+ start: Int,
464
+ end: Int,
465
+ line: Int,
466
+ ): Rect {
467
+ val layout = textView.layout
468
+ val left = layout.getPrimaryHorizontal(start).toInt() + textView.paddingLeft
469
+ val rawRight = layout.getPrimaryHorizontal(end).toInt() + textView.paddingLeft
470
+ val right = if (rawRight <= left) layout.getLineRight(line).toInt() + textView.paddingLeft else rawRight
471
+
472
+ return Rect(
473
+ left,
474
+ layout.getLineTop(line) + textView.paddingTop,
475
+ right,
476
+ layout.getLineBottom(line) + textView.paddingTop,
477
+ )
478
+ }
479
+
480
+ private fun multiLineBounds(
481
+ visibleStart: Int,
482
+ startLine: Int,
483
+ endLine: Int,
484
+ ): Rect {
485
+ val layout = textView.layout
486
+ val padLeft = textView.paddingLeft
487
+
488
+ val firstLineLeft = layout.getPrimaryHorizontal(visibleStart).toInt() + padLeft
489
+ val firstLineRight = layout.getLineRight(startLine).toInt() + padLeft
490
+
491
+ var minLeft = firstLineLeft
492
+ var maxRight = firstLineRight
493
+ for (line in (startLine + 1)..endLine) {
494
+ minLeft = minOf(minLeft, layout.getLineLeft(line).toInt() + padLeft)
495
+ maxRight = maxOf(maxRight, layout.getLineRight(line).toInt() + padLeft)
496
+ }
497
+
498
+ return Rect(
499
+ minOf(firstLineLeft, minLeft),
500
+ layout.getLineTop(startLine) + textView.paddingTop,
501
+ maxRight,
502
+ layout.getLineBottom(endLine) + textView.paddingTop,
503
+ )
504
+ }
505
+
506
+ private fun getCharOffsetAt(
507
+ x: Float,
508
+ y: Float,
509
+ ): Int {
510
+ val layout = textView.layout ?: return 0
511
+ val line = layout.getLineForVertical(y.toInt()).coerceIn(0, layout.lineCount - 1)
512
+ return layout.getOffsetForHorizontal(line, x)
513
+ }
514
+
515
+ private val AccessibilityItem.hitTestPriority: Int
516
+ get() =
517
+ when {
518
+ isLink -> 0
519
+ isImage -> 1
520
+ isHeading -> 2
521
+ isListItem -> 3
522
+ else -> 4
523
+ }
524
+
525
+ private fun AccessibilityItem.distanceTo(offset: Int): Int =
526
+ when {
527
+ offset < start -> start - offset
528
+ offset >= end -> offset - end + 1
529
+ else -> 0
530
+ }
531
+
532
+ private fun getListInfoAt(
533
+ spanned: Spanned,
534
+ position: Int,
535
+ requireStart: Boolean,
536
+ ): ListItemInfo? {
537
+ val deepest =
538
+ spanned
539
+ .getSpans(position, position + 1, BaseListSpan::class.java)
540
+ .maxByOrNull { it.depth } ?: return null
541
+
542
+ if (requireStart) {
543
+ val spanStart = spanned.getSpanStart(deepest)
544
+ val firstChar =
545
+ (spanStart until minOf(spanStart + 10, spanned.length))
546
+ .firstOrNull { !spanned[it].isWhitespace() } ?: spanStart
547
+ if (position > firstChar + 1) return null
548
+ }
549
+
550
+ return ListItemInfo(
551
+ isOrdered = deepest is OrderedListSpan,
552
+ itemNumber = (deepest as? OrderedListSpan)?.itemNumber ?: 0,
553
+ depth = deepest.depth,
554
+ )
555
+ }
556
+ }
557
+
558
+ private fun String.findFirstNonWhitespace(
559
+ from: Int,
560
+ to: Int,
561
+ ): Int {
562
+ for (i in from until to) {
563
+ if (!this[i].isWhitespace()) return i
564
+ }
565
+ return from
566
+ }
567
+
568
+ private fun String.findLastNonWhitespace(
569
+ from: Int,
570
+ to: Int,
571
+ ): Int {
572
+ for (i in (to - 1) downTo from) {
573
+ if (!this[i].isWhitespace()) return i + 1
574
+ }
575
+ return to
576
+ }