react-native-enriched-markdown 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +479 -0
  3. package/ReactNativeEnrichedMarkdown.podspec +27 -0
  4. package/android/build.gradle +101 -0
  5. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerDelegate.java +39 -0
  6. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerInterface.java +21 -0
  7. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/ComponentDescriptors.cpp +22 -0
  8. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/ComponentDescriptors.h +24 -0
  9. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp +24 -0
  10. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.h +25 -0
  11. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.cpp +57 -0
  12. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +1164 -0
  13. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/ShadowNodes.cpp +17 -0
  14. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/ShadowNodes.h +32 -0
  15. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/States.cpp +16 -0
  16. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/States.h +20 -0
  17. package/android/gradle.properties +5 -0
  18. package/android/src/main/AndroidManifest.xml +2 -0
  19. package/android/src/main/baseline-prof.txt +65 -0
  20. package/android/src/main/cpp/jni-adapter.cpp +203 -0
  21. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +153 -0
  22. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextLayoutManager.kt +30 -0
  23. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt +119 -0
  24. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextPackage.kt +17 -0
  25. package/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +165 -0
  26. package/android/src/main/java/com/swmansion/enriched/markdown/events/LinkPressEvent.kt +23 -0
  27. package/android/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt +29 -0
  28. package/android/src/main/java/com/swmansion/enriched/markdown/parser/Parser.kt +48 -0
  29. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockStyleContext.kt +166 -0
  30. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt +89 -0
  31. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/CodeBlockRenderer.kt +105 -0
  32. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/CodeRenderer.kt +35 -0
  33. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/DocumentRenderer.kt +15 -0
  34. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/EmphasisRenderer.kt +26 -0
  35. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/HeadingRenderer.kt +54 -0
  36. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ImageRenderer.kt +52 -0
  37. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/LineBreakRenderer.kt +15 -0
  38. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt +28 -0
  39. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListContextManager.kt +105 -0
  40. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListItemRenderer.kt +58 -0
  41. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListRenderer.kt +69 -0
  42. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt +99 -0
  43. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ParagraphRenderer.kt +66 -0
  44. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/Renderer.kt +95 -0
  45. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt +85 -0
  46. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/StrongRenderer.kt +26 -0
  47. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/TextRenderer.kt +29 -0
  48. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ThematicBreakRenderer.kt +44 -0
  49. package/android/src/main/java/com/swmansion/enriched/markdown/spans/BaseListSpan.kt +136 -0
  50. package/android/src/main/java/com/swmansion/enriched/markdown/spans/BlockquoteSpan.kt +135 -0
  51. package/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBackgroundSpan.kt +180 -0
  52. package/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBlockSpan.kt +196 -0
  53. package/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeSpan.kt +27 -0
  54. package/android/src/main/java/com/swmansion/enriched/markdown/spans/EmphasisSpan.kt +34 -0
  55. package/android/src/main/java/com/swmansion/enriched/markdown/spans/HeadingSpan.kt +38 -0
  56. package/android/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt +320 -0
  57. package/android/src/main/java/com/swmansion/enriched/markdown/spans/LineHeightSpan.kt +36 -0
  58. package/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt +37 -0
  59. package/android/src/main/java/com/swmansion/enriched/markdown/spans/MarginBottomSpan.kt +76 -0
  60. package/android/src/main/java/com/swmansion/enriched/markdown/spans/OrderedListSpan.kt +87 -0
  61. package/android/src/main/java/com/swmansion/enriched/markdown/spans/StrongSpan.kt +37 -0
  62. package/android/src/main/java/com/swmansion/enriched/markdown/spans/TextSpan.kt +26 -0
  63. package/android/src/main/java/com/swmansion/enriched/markdown/spans/ThematicBreakSpan.kt +69 -0
  64. package/android/src/main/java/com/swmansion/enriched/markdown/spans/UnorderedListSpan.kt +69 -0
  65. package/android/src/main/java/com/swmansion/enriched/markdown/styles/BaseBlockStyle.kt +10 -0
  66. package/android/src/main/java/com/swmansion/enriched/markdown/styles/BlockquoteStyle.kt +48 -0
  67. package/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeBlockStyle.kt +51 -0
  68. package/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeStyle.kt +21 -0
  69. package/android/src/main/java/com/swmansion/enriched/markdown/styles/EmphasisStyle.kt +17 -0
  70. package/android/src/main/java/com/swmansion/enriched/markdown/styles/HeadingStyle.kt +29 -0
  71. package/android/src/main/java/com/swmansion/enriched/markdown/styles/ImageStyle.kt +21 -0
  72. package/android/src/main/java/com/swmansion/enriched/markdown/styles/InlineImageStyle.kt +17 -0
  73. package/android/src/main/java/com/swmansion/enriched/markdown/styles/LinkStyle.kt +19 -0
  74. package/android/src/main/java/com/swmansion/enriched/markdown/styles/ListStyle.kt +54 -0
  75. package/android/src/main/java/com/swmansion/enriched/markdown/styles/ParagraphStyle.kt +29 -0
  76. package/android/src/main/java/com/swmansion/enriched/markdown/styles/StrongStyle.kt +17 -0
  77. package/android/src/main/java/com/swmansion/enriched/markdown/styles/StyleConfig.kt +180 -0
  78. package/android/src/main/java/com/swmansion/enriched/markdown/styles/StyleParser.kt +75 -0
  79. package/android/src/main/java/com/swmansion/enriched/markdown/styles/ThematicBreakStyle.kt +23 -0
  80. package/android/src/main/java/com/swmansion/enriched/markdown/utils/AsyncDrawable.kt +91 -0
  81. package/android/src/main/java/com/swmansion/enriched/markdown/utils/HTMLGenerator.kt +809 -0
  82. package/android/src/main/java/com/swmansion/enriched/markdown/utils/MarkdownExtractor.kt +365 -0
  83. package/android/src/main/java/com/swmansion/enriched/markdown/utils/SelectionActionMode.kt +139 -0
  84. package/android/src/main/java/com/swmansion/enriched/markdown/utils/Utils.kt +181 -0
  85. package/android/src/main/jni/CMakeLists.txt +82 -0
  86. package/android/src/main/jni/EnrichedMarkdownTextSpec.cpp +21 -0
  87. package/android/src/main/jni/EnrichedMarkdownTextSpec.h +25 -0
  88. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextComponentDescriptor.h +29 -0
  89. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextMeasurementManager.cpp +45 -0
  90. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextMeasurementManager.h +21 -0
  91. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp +33 -0
  92. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h +49 -0
  93. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextState.cpp +9 -0
  94. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextState.h +25 -0
  95. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h +19 -0
  96. package/cpp/md4c/md4c.c +6492 -0
  97. package/cpp/md4c/md4c.h +402 -0
  98. package/cpp/parser/MD4CParser.cpp +314 -0
  99. package/cpp/parser/MD4CParser.hpp +23 -0
  100. package/cpp/parser/MarkdownASTNode.hpp +49 -0
  101. package/ios/EnrichedMarkdownText.h +18 -0
  102. package/ios/EnrichedMarkdownText.mm +1074 -0
  103. package/ios/attachments/ImageAttachment.h +23 -0
  104. package/ios/attachments/ImageAttachment.m +185 -0
  105. package/ios/attachments/ThematicBreakAttachment.h +15 -0
  106. package/ios/attachments/ThematicBreakAttachment.m +33 -0
  107. package/ios/generated/EnrichedMarkdownTextSpec/ComponentDescriptors.cpp +22 -0
  108. package/ios/generated/EnrichedMarkdownTextSpec/ComponentDescriptors.h +24 -0
  109. package/ios/generated/EnrichedMarkdownTextSpec/EventEmitters.cpp +24 -0
  110. package/ios/generated/EnrichedMarkdownTextSpec/EventEmitters.h +25 -0
  111. package/ios/generated/EnrichedMarkdownTextSpec/Props.cpp +57 -0
  112. package/ios/generated/EnrichedMarkdownTextSpec/Props.h +1164 -0
  113. package/ios/generated/EnrichedMarkdownTextSpec/RCTComponentViewHelpers.h +20 -0
  114. package/ios/generated/EnrichedMarkdownTextSpec/ShadowNodes.cpp +17 -0
  115. package/ios/generated/EnrichedMarkdownTextSpec/ShadowNodes.h +32 -0
  116. package/ios/generated/EnrichedMarkdownTextSpec/States.cpp +16 -0
  117. package/ios/generated/EnrichedMarkdownTextSpec/States.h +20 -0
  118. package/ios/internals/EnrichedMarkdownTextComponentDescriptor.h +19 -0
  119. package/ios/internals/EnrichedMarkdownTextShadowNode.h +43 -0
  120. package/ios/internals/EnrichedMarkdownTextShadowNode.mm +85 -0
  121. package/ios/internals/EnrichedMarkdownTextState.h +24 -0
  122. package/ios/parser/MarkdownASTNode.h +33 -0
  123. package/ios/parser/MarkdownASTNode.m +32 -0
  124. package/ios/parser/MarkdownParser.h +8 -0
  125. package/ios/parser/MarkdownParser.mm +13 -0
  126. package/ios/parser/MarkdownParserBridge.mm +110 -0
  127. package/ios/renderer/AttributedRenderer.h +9 -0
  128. package/ios/renderer/AttributedRenderer.m +119 -0
  129. package/ios/renderer/BlockquoteRenderer.h +7 -0
  130. package/ios/renderer/BlockquoteRenderer.m +159 -0
  131. package/ios/renderer/CodeBlockRenderer.h +10 -0
  132. package/ios/renderer/CodeBlockRenderer.m +89 -0
  133. package/ios/renderer/CodeRenderer.h +10 -0
  134. package/ios/renderer/CodeRenderer.m +60 -0
  135. package/ios/renderer/EmphasisRenderer.h +6 -0
  136. package/ios/renderer/EmphasisRenderer.m +96 -0
  137. package/ios/renderer/HeadingRenderer.h +7 -0
  138. package/ios/renderer/HeadingRenderer.m +98 -0
  139. package/ios/renderer/ImageRenderer.h +12 -0
  140. package/ios/renderer/ImageRenderer.m +62 -0
  141. package/ios/renderer/LinkRenderer.h +7 -0
  142. package/ios/renderer/LinkRenderer.m +69 -0
  143. package/ios/renderer/ListItemRenderer.h +16 -0
  144. package/ios/renderer/ListItemRenderer.m +91 -0
  145. package/ios/renderer/ListRenderer.h +13 -0
  146. package/ios/renderer/ListRenderer.m +67 -0
  147. package/ios/renderer/NodeRenderer.h +8 -0
  148. package/ios/renderer/ParagraphRenderer.h +7 -0
  149. package/ios/renderer/ParagraphRenderer.m +69 -0
  150. package/ios/renderer/RenderContext.h +88 -0
  151. package/ios/renderer/RenderContext.m +248 -0
  152. package/ios/renderer/RendererFactory.h +12 -0
  153. package/ios/renderer/RendererFactory.m +110 -0
  154. package/ios/renderer/StrongRenderer.h +6 -0
  155. package/ios/renderer/StrongRenderer.m +83 -0
  156. package/ios/renderer/TextRenderer.h +6 -0
  157. package/ios/renderer/TextRenderer.m +16 -0
  158. package/ios/renderer/ThematicBreakRenderer.h +5 -0
  159. package/ios/renderer/ThematicBreakRenderer.m +53 -0
  160. package/ios/styles/StyleConfig.h +228 -0
  161. package/ios/styles/StyleConfig.mm +1467 -0
  162. package/ios/utils/BlockquoteBorder.h +20 -0
  163. package/ios/utils/BlockquoteBorder.m +92 -0
  164. package/ios/utils/CodeBackground.h +19 -0
  165. package/ios/utils/CodeBackground.m +191 -0
  166. package/ios/utils/CodeBlockBackground.h +17 -0
  167. package/ios/utils/CodeBlockBackground.m +87 -0
  168. package/ios/utils/EditMenuUtils.h +22 -0
  169. package/ios/utils/EditMenuUtils.m +118 -0
  170. package/ios/utils/FontUtils.h +20 -0
  171. package/ios/utils/FontUtils.m +13 -0
  172. package/ios/utils/HTMLGenerator.h +20 -0
  173. package/ios/utils/HTMLGenerator.m +779 -0
  174. package/ios/utils/LastElementUtils.h +53 -0
  175. package/ios/utils/ListMarkerDrawer.h +15 -0
  176. package/ios/utils/ListMarkerDrawer.m +127 -0
  177. package/ios/utils/MarkdownExtractor.h +17 -0
  178. package/ios/utils/MarkdownExtractor.m +295 -0
  179. package/ios/utils/ParagraphStyleUtils.h +13 -0
  180. package/ios/utils/ParagraphStyleUtils.m +56 -0
  181. package/ios/utils/PasteboardUtils.h +36 -0
  182. package/ios/utils/PasteboardUtils.m +134 -0
  183. package/ios/utils/RTFExportUtils.h +24 -0
  184. package/ios/utils/RTFExportUtils.m +297 -0
  185. package/ios/utils/RuntimeKeys.h +38 -0
  186. package/ios/utils/RuntimeKeys.m +11 -0
  187. package/ios/utils/TextViewLayoutManager.h +14 -0
  188. package/ios/utils/TextViewLayoutManager.mm +113 -0
  189. package/lib/module/EnrichedMarkdownText.js +34 -0
  190. package/lib/module/EnrichedMarkdownText.js.map +1 -0
  191. package/lib/module/EnrichedMarkdownTextNativeComponent.ts +130 -0
  192. package/lib/module/index.js +5 -0
  193. package/lib/module/index.js.map +1 -0
  194. package/lib/module/normalizeMarkdownStyle.js +340 -0
  195. package/lib/module/normalizeMarkdownStyle.js.map +1 -0
  196. package/lib/module/package.json +1 -0
  197. package/lib/typescript/package.json +1 -0
  198. package/lib/typescript/src/EnrichedMarkdownText.d.ts +101 -0
  199. package/lib/typescript/src/EnrichedMarkdownText.d.ts.map +1 -0
  200. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +111 -0
  201. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts.map +1 -0
  202. package/lib/typescript/src/index.d.ts +5 -0
  203. package/lib/typescript/src/index.d.ts.map +1 -0
  204. package/lib/typescript/src/normalizeMarkdownStyle.d.ts +6 -0
  205. package/lib/typescript/src/normalizeMarkdownStyle.d.ts.map +1 -0
  206. package/package.json +186 -1
  207. package/react-native.config.js +13 -0
  208. package/src/EnrichedMarkdownText.tsx +152 -0
  209. package/src/EnrichedMarkdownTextNativeComponent.ts +130 -0
  210. package/src/index.tsx +7 -0
  211. package/src/normalizeMarkdownStyle.ts +377 -0
@@ -0,0 +1,365 @@
1
+ package com.swmansion.enriched.markdown.utils
2
+
3
+ import android.text.Spannable
4
+ import android.widget.TextView
5
+ import com.swmansion.enriched.markdown.EnrichedMarkdownText
6
+ import com.swmansion.enriched.markdown.spans.BlockquoteSpan
7
+ import com.swmansion.enriched.markdown.spans.CodeBlockSpan
8
+ import com.swmansion.enriched.markdown.spans.CodeSpan
9
+ import com.swmansion.enriched.markdown.spans.EmphasisSpan
10
+ import com.swmansion.enriched.markdown.spans.HeadingSpan
11
+ import com.swmansion.enriched.markdown.spans.ImageSpan
12
+ import com.swmansion.enriched.markdown.spans.LinkSpan
13
+ import com.swmansion.enriched.markdown.spans.OrderedListSpan
14
+ import com.swmansion.enriched.markdown.spans.StrongSpan
15
+ import com.swmansion.enriched.markdown.spans.ThematicBreakSpan
16
+ import com.swmansion.enriched.markdown.spans.UnorderedListSpan
17
+
18
+ /** Extracts markdown from styled text (Spannable). */
19
+ object MarkdownExtractor {
20
+ /**
21
+ * Gets markdown for the current text selection.
22
+ * Full selection returns original markdown, partial reconstructs from spans.
23
+ */
24
+ fun getMarkdownForSelection(textView: TextView): String? {
25
+ val start = textView.selectionStart
26
+ val end = textView.selectionEnd
27
+ if (start < 0 || end < 0 || start >= end) return null
28
+
29
+ val spannable = textView.text as? Spannable ?: return null
30
+
31
+ val isFullSelection = start == 0 && end >= textView.text.length - 1
32
+ if (isFullSelection && textView is EnrichedMarkdownText) {
33
+ val original = textView.currentMarkdown
34
+ if (original.isNotEmpty()) return original
35
+ }
36
+
37
+ return extractFromSpannable(spannable, start, end)
38
+ }
39
+
40
+ /** Extracts markdown from a Spannable within a given range. */
41
+ fun extractFromSpannable(
42
+ spannable: Spannable,
43
+ start: Int,
44
+ end: Int,
45
+ ): String {
46
+ val result = StringBuilder()
47
+ val state = ExtractionState()
48
+ val headingAccumulator = HeadingAccumulator()
49
+
50
+ var i = start
51
+ while (i < end) {
52
+ val nextTransition = spannable.nextSpanTransition(i, end, Any::class.java)
53
+ val segmentText = spannable.subSequence(i, nextTransition).toString()
54
+
55
+ val handled =
56
+ processSegment(
57
+ spannable = spannable,
58
+ segmentText = segmentText,
59
+ segmentStart = i,
60
+ segmentEnd = nextTransition,
61
+ result = result,
62
+ state = state,
63
+ headingAccumulator = headingAccumulator,
64
+ )
65
+
66
+ if (!handled) {
67
+ // Regular text segment - apply inline formatting and block prefixes
68
+ appendFormattedSegment(spannable, segmentText, i, nextTransition, result, state)
69
+ }
70
+
71
+ i = nextTransition
72
+ }
73
+
74
+ headingAccumulator.flush(result, state)
75
+ return result.toString()
76
+ }
77
+
78
+ /** Returns true if segment was handled specially. */
79
+ private fun processSegment(
80
+ spannable: Spannable,
81
+ segmentText: String,
82
+ segmentStart: Int,
83
+ segmentEnd: Int,
84
+ result: StringBuilder,
85
+ state: ExtractionState,
86
+ headingAccumulator: HeadingAccumulator,
87
+ ): Boolean {
88
+ // Check for thematic breaks first (uses " \n" as placeholder)
89
+ val thematicBreakSpans = spannable.getSpans(segmentStart, segmentEnd, ThematicBreakSpan::class.java)
90
+ if (thematicBreakSpans.isNotEmpty()) {
91
+ appendThematicBreak(result, state)
92
+ return true
93
+ }
94
+
95
+ if (segmentText == "\uFFFC" || segmentText == "\u200B") {
96
+ val imageSpans = spannable.getSpans(segmentStart, segmentEnd, ImageSpan::class.java)
97
+ if (imageSpans.isNotEmpty()) {
98
+ appendImage(imageSpans[0], result, state)
99
+ return true
100
+ }
101
+ }
102
+
103
+ if (segmentText.isEmpty()) return true
104
+
105
+ if (segmentText == "\n" || segmentText == "\n\n") {
106
+ handleNewline(spannable, segmentStart, segmentEnd, result, state)
107
+ return true
108
+ }
109
+
110
+ val headingSpans = spannable.getSpans(segmentStart, segmentEnd, HeadingSpan::class.java)
111
+ if (headingSpans.isNotEmpty()) {
112
+ headingAccumulator.accumulate(headingSpans[0].level, segmentText, result, state)
113
+ return true
114
+ } else {
115
+ headingAccumulator.flush(result, state)
116
+ }
117
+
118
+ val codeBlockSpans = spannable.getSpans(segmentStart, segmentEnd, CodeBlockSpan::class.java)
119
+ if (codeBlockSpans.isNotEmpty()) {
120
+ appendCodeBlock(segmentText, result, state)
121
+ return true
122
+ }
123
+
124
+ return false
125
+ }
126
+
127
+ private fun appendFormattedSegment(
128
+ spannable: Spannable,
129
+ segmentText: String,
130
+ segmentStart: Int,
131
+ segmentEnd: Int,
132
+ result: StringBuilder,
133
+ state: ExtractionState,
134
+ ) {
135
+ val blockquotePrefix = detectBlockquote(spannable, segmentStart, segmentEnd, state)
136
+ val listPrefix = detectList(spannable, segmentStart, segmentEnd, state)
137
+ var segment = applyInlineFormatting(spannable, segmentText, segmentStart, segmentEnd)
138
+
139
+ if (result.isAtLineStart() && !segmentText.startsWith("\n")) {
140
+ segment = buildBlockPrefix(blockquotePrefix, listPrefix) + segment
141
+ }
142
+
143
+ if (state.needsBlankLine && result.isNotEmpty()) {
144
+ result.ensureBlankLine()
145
+ state.needsBlankLine = false
146
+ }
147
+
148
+ result.append(segment)
149
+ }
150
+
151
+ private fun appendImage(
152
+ img: ImageSpan,
153
+ result: StringBuilder,
154
+ state: ExtractionState,
155
+ ) {
156
+ if (img.isInline) {
157
+ result.append("![image](${img.imageUrl})")
158
+ } else {
159
+ result.ensureBlankLine()
160
+ result.append("![image](${img.imageUrl})\n")
161
+ state.needsBlankLine = true
162
+ state.blockquoteDepth = -1
163
+ state.listDepth = -1
164
+ }
165
+ }
166
+
167
+ private fun appendThematicBreak(
168
+ result: StringBuilder,
169
+ state: ExtractionState,
170
+ ) {
171
+ result.ensureBlankLine()
172
+ result.append("---\n")
173
+ state.needsBlankLine = true
174
+ state.blockquoteDepth = -1
175
+ state.listDepth = -1
176
+ }
177
+
178
+ private fun handleNewline(
179
+ spannable: Spannable,
180
+ start: Int,
181
+ end: Int,
182
+ result: StringBuilder,
183
+ state: ExtractionState,
184
+ ) {
185
+ val inBlockquote = spannable.getSpans(start, end, BlockquoteSpan::class.java).isNotEmpty()
186
+ val inList =
187
+ spannable.getSpans(start, end, OrderedListSpan::class.java).isNotEmpty() ||
188
+ spannable.getSpans(start, end, UnorderedListSpan::class.java).isNotEmpty()
189
+
190
+ when {
191
+ !inBlockquote && state.blockquoteDepth >= 0 -> {
192
+ result.ensureBlankLine()
193
+ state.blockquoteDepth = -1
194
+ }
195
+
196
+ !inList && state.listDepth >= 0 -> {
197
+ result.ensureBlankLine()
198
+ state.listDepth = -1
199
+ }
200
+
201
+ inBlockquote || inList -> {
202
+ if (!result.endsWith("\n")) result.append("\n")
203
+ }
204
+
205
+ else -> {
206
+ result.ensureBlankLine()
207
+ }
208
+ }
209
+ }
210
+
211
+ private fun appendCodeBlock(
212
+ text: String,
213
+ result: StringBuilder,
214
+ state: ExtractionState,
215
+ ) {
216
+ if (state.needsBlankLine) {
217
+ result.ensureBlankLine()
218
+ state.needsBlankLine = false
219
+ }
220
+
221
+ val needsFence = result.isEmpty() || result.endsWith("\n\n")
222
+ if (needsFence) result.append("```\n")
223
+
224
+ result.append(text)
225
+
226
+ if (text.endsWith("\n")) {
227
+ result.append("```\n")
228
+ state.needsBlankLine = true
229
+ }
230
+ }
231
+
232
+ private fun detectBlockquote(
233
+ spannable: Spannable,
234
+ start: Int,
235
+ end: Int,
236
+ state: ExtractionState,
237
+ ): String? {
238
+ val spans = spannable.getSpans(start, end, BlockquoteSpan::class.java)
239
+ val depth = spans.maxOfOrNull { it.depth } ?: -1
240
+
241
+ return if (depth >= 0) {
242
+ state.blockquoteDepth = depth
243
+ "> ".repeat(depth + 1)
244
+ } else {
245
+ if (state.blockquoteDepth >= 0) state.blockquoteDepth = -1
246
+ null
247
+ }
248
+ }
249
+
250
+ private fun detectList(
251
+ spannable: Spannable,
252
+ start: Int,
253
+ end: Int,
254
+ state: ExtractionState,
255
+ ): String? {
256
+ val orderedSpans = spannable.getSpans(start, end, OrderedListSpan::class.java)
257
+ val unorderedSpans = spannable.getSpans(start, end, UnorderedListSpan::class.java)
258
+
259
+ val orderedDepth = orderedSpans.maxOfOrNull { it.depth } ?: -1
260
+ val unorderedDepth = unorderedSpans.maxOfOrNull { it.depth } ?: -1
261
+ val depth = maxOf(orderedDepth, unorderedDepth)
262
+
263
+ return if (depth >= 0) {
264
+ state.listDepth = depth
265
+ val indent = " ".repeat(depth)
266
+ if (orderedSpans.isNotEmpty()) {
267
+ "$indent${orderedSpans[0].itemNumber}. "
268
+ } else {
269
+ "$indent- "
270
+ }
271
+ } else {
272
+ if (state.listDepth >= 0) state.listDepth = -1
273
+ null
274
+ }
275
+ }
276
+
277
+ private fun applyInlineFormatting(
278
+ spannable: Spannable,
279
+ text: String,
280
+ start: Int,
281
+ end: Int,
282
+ ): String {
283
+ val hasStrong = spannable.getSpans(start, end, StrongSpan::class.java).isNotEmpty()
284
+ val hasEmphasis = spannable.getSpans(start, end, EmphasisSpan::class.java).isNotEmpty()
285
+ val hasCode = spannable.getSpans(start, end, CodeSpan::class.java).isNotEmpty()
286
+ val linkSpans = spannable.getSpans(start, end, LinkSpan::class.java)
287
+
288
+ var result = text
289
+
290
+ // Innermost first
291
+ if (hasCode && linkSpans.isEmpty()) {
292
+ result = "`$result`"
293
+ }
294
+ if (hasEmphasis) {
295
+ result = "*$result*"
296
+ }
297
+ if (hasStrong) {
298
+ result = "**$result**"
299
+ }
300
+ if (linkSpans.isNotEmpty()) {
301
+ result = "[$text](${linkSpans[0].url})"
302
+ }
303
+
304
+ return result
305
+ }
306
+
307
+ private fun buildBlockPrefix(
308
+ blockquotePrefix: String?,
309
+ listPrefix: String?,
310
+ ): String =
311
+ buildString {
312
+ blockquotePrefix?.let { append(it) }
313
+ listPrefix?.let { append(it) }
314
+ }
315
+
316
+ private data class ExtractionState(
317
+ var blockquoteDepth: Int = -1,
318
+ var listDepth: Int = -1,
319
+ var needsBlankLine: Boolean = false,
320
+ )
321
+
322
+ /** Accumulates heading content across multiple span segments. */
323
+ private class HeadingAccumulator {
324
+ private var level: Int? = null
325
+ private val content = StringBuilder()
326
+
327
+ fun accumulate(
328
+ newLevel: Int,
329
+ text: String,
330
+ result: StringBuilder,
331
+ state: ExtractionState,
332
+ ) {
333
+ if (level != newLevel) {
334
+ flush(result, state)
335
+ level = newLevel
336
+ }
337
+ content.append(text.trim('\n'))
338
+ }
339
+
340
+ fun flush(
341
+ result: StringBuilder,
342
+ state: ExtractionState,
343
+ ) {
344
+ val currentLevel = level ?: return
345
+ if (content.isEmpty()) return
346
+
347
+ result.ensureBlankLine()
348
+ result.append("#".repeat(currentLevel))
349
+ result.append(" ")
350
+ result.append(content.toString().trim())
351
+ result.append("\n")
352
+
353
+ level = null
354
+ content.clear()
355
+ state.needsBlankLine = true
356
+ }
357
+ }
358
+
359
+ private fun StringBuilder.ensureBlankLine() {
360
+ if (isEmpty() || endsWith("\n\n")) return
361
+ append(if (endsWith("\n")) "\n" else "\n\n")
362
+ }
363
+
364
+ private fun StringBuilder.isAtLineStart(): Boolean = isEmpty() || endsWith("\n")
365
+ }
@@ -0,0 +1,139 @@
1
+ package com.swmansion.enriched.markdown.utils
2
+
3
+ import android.content.ClipData
4
+ import android.content.ClipboardManager
5
+ import android.content.Context
6
+ import android.text.Spannable
7
+ import android.view.ActionMode
8
+ import android.view.Menu
9
+ import android.view.MenuItem
10
+ import android.widget.TextView
11
+ import com.swmansion.enriched.markdown.EnrichedMarkdownText
12
+ import com.swmansion.enriched.markdown.spans.ImageSpan
13
+
14
+ private const val MENU_ITEM_COPY_MARKDOWN = 1000
15
+ private const val MENU_ITEM_COPY_IMAGE_URL = 1001
16
+
17
+ /**
18
+ * Creates an ActionMode.Callback that adds custom copy options and
19
+ * overrides the default "Copy" action to include HTML for rich text support.
20
+ */
21
+ fun createSelectionActionModeCallback(textView: TextView): ActionMode.Callback =
22
+ object : ActionMode.Callback {
23
+ override fun onCreateActionMode(
24
+ mode: ActionMode?,
25
+ menu: Menu?,
26
+ ): Boolean = true
27
+
28
+ override fun onPrepareActionMode(
29
+ mode: ActionMode?,
30
+ menu: Menu?,
31
+ ): Boolean {
32
+ if (menu == null) return false
33
+
34
+ menu.removeItem(MENU_ITEM_COPY_MARKDOWN)
35
+ menu.removeItem(MENU_ITEM_COPY_IMAGE_URL)
36
+
37
+ if (textView.selectionStart >= 0 && textView.selectionEnd > textView.selectionStart) {
38
+ menu.add(Menu.NONE, MENU_ITEM_COPY_MARKDOWN, Menu.NONE, "Copy as Markdown")
39
+ }
40
+
41
+ val imageUrls = textView.getImageUrlsInSelection()
42
+ if (imageUrls.isNotEmpty()) {
43
+ val title =
44
+ if (imageUrls.size == 1) {
45
+ "Copy Image URL"
46
+ } else {
47
+ "Copy ${imageUrls.size} Image URLs"
48
+ }
49
+ menu.add(Menu.NONE, MENU_ITEM_COPY_IMAGE_URL, Menu.NONE, title)
50
+ }
51
+
52
+ return true
53
+ }
54
+
55
+ override fun onActionItemClicked(
56
+ mode: ActionMode?,
57
+ item: MenuItem?,
58
+ ): Boolean {
59
+ when (item?.itemId) {
60
+ android.R.id.copy -> {
61
+ textView.copyWithHTML()
62
+ mode?.finish()
63
+ return true
64
+ }
65
+
66
+ MENU_ITEM_COPY_MARKDOWN -> {
67
+ textView.copyMarkdownToClipboard()
68
+ mode?.finish()
69
+ return true
70
+ }
71
+
72
+ MENU_ITEM_COPY_IMAGE_URL -> {
73
+ textView.copyImageUrlsToClipboard()
74
+ mode?.finish()
75
+ return true
76
+ }
77
+ }
78
+ return false
79
+ }
80
+
81
+ override fun onDestroyActionMode(mode: ActionMode?) {}
82
+ }
83
+
84
+ /** Copies selection as both plain text and HTML with inline styles. */
85
+ private fun TextView.copyWithHTML() {
86
+ val start = selectionStart
87
+ val end = selectionEnd
88
+ if (start < 0 || end < 0 || start >= end) return
89
+
90
+ val spannable = text as? Spannable ?: return
91
+ val selectedText = spannable.subSequence(start, end)
92
+ val plainText = selectedText.toString()
93
+
94
+ val enrichedMarkdownText = this as? EnrichedMarkdownText
95
+ val styleConfig = enrichedMarkdownText?.markdownStyle
96
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
97
+
98
+ if (styleConfig != null && selectedText is Spannable) {
99
+ // Density values convert device pixels back to CSS pixels
100
+ val displayMetrics = context.resources.displayMetrics
101
+ val html =
102
+ HTMLGenerator.generateHTML(
103
+ selectedText,
104
+ styleConfig,
105
+ displayMetrics.scaledDensity,
106
+ displayMetrics.density,
107
+ )
108
+ clipboard.setPrimaryClip(ClipData.newHtmlText("EnrichedMarkdown", plainText, html))
109
+ } else {
110
+ clipboard.setPrimaryClip(ClipData.newPlainText("Text", plainText))
111
+ }
112
+ }
113
+
114
+ private fun TextView.copyMarkdownToClipboard() {
115
+ val markdown = MarkdownExtractor.getMarkdownForSelection(this) ?: return
116
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
117
+ clipboard.setPrimaryClip(ClipData.newPlainText("Markdown", markdown))
118
+ }
119
+
120
+ /** Returns remote image URLs (http/https only) from the current selection. */
121
+ private fun TextView.getImageUrlsInSelection(): List<String> {
122
+ val start = selectionStart
123
+ val end = selectionEnd
124
+ if (start < 0 || end < 0 || start >= end) return emptyList()
125
+
126
+ val spannable = text as? Spannable ?: return emptyList()
127
+ return spannable
128
+ .getSpans(start, end, ImageSpan::class.java)
129
+ .mapNotNull { it.imageUrl }
130
+ .filter { it.startsWith("http://") || it.startsWith("https://") }
131
+ }
132
+
133
+ private fun TextView.copyImageUrlsToClipboard() {
134
+ val urls = getImageUrlsInSelection()
135
+ if (urls.isEmpty()) return
136
+
137
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
138
+ clipboard.setPrimaryClip(ClipData.newPlainText("Image URLs", urls.joinToString("\n")))
139
+ }
@@ -0,0 +1,181 @@
1
+ package com.swmansion.enriched.markdown.utils
2
+
3
+ import android.content.Context
4
+ import android.graphics.Typeface
5
+ import android.os.Build
6
+ import android.text.SpannableString
7
+ import android.text.SpannableStringBuilder
8
+ import android.text.TextPaint
9
+ import com.facebook.react.common.ReactConstants
10
+ import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles
11
+ import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight
12
+ import com.swmansion.enriched.markdown.parser.MarkdownASTNode
13
+ import com.swmansion.enriched.markdown.renderer.BlockStyle
14
+ import com.swmansion.enriched.markdown.spans.LineHeightSpan
15
+ import com.swmansion.enriched.markdown.spans.MarginBottomSpan
16
+ import com.swmansion.enriched.markdown.styles.ParagraphStyle
17
+ import com.swmansion.enriched.markdown.styles.StyleConfig
18
+ import android.text.style.LineHeightSpan as AndroidLineHeightSpan
19
+
20
+ // ============================================================================
21
+ // Constants
22
+ // ============================================================================
23
+
24
+ /**
25
+ * Standard span flags for exclusive span boundaries.
26
+ * Spans with these flags do not expand when text is inserted at their boundaries.
27
+ */
28
+ const val SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE = SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE
29
+
30
+ // ============================================================================
31
+ // TextPaint Extensions
32
+ // ============================================================================
33
+
34
+ /**
35
+ * Applies a color to TextPaint while preserving priority colors (e.g., link color).
36
+ */
37
+ fun TextPaint.applyColorPreserving(
38
+ color: Int,
39
+ vararg preserveColors: Int,
40
+ ) {
41
+ if (this.color !in preserveColors) {
42
+ this.color = color
43
+ }
44
+ }
45
+
46
+ private val typefaceCache = mutableMapOf<String, Typeface>()
47
+ private val fontWeightCache = mutableMapOf<String?, Int>()
48
+
49
+ /**
50
+ * Applies fontFamily and fontWeight from BlockStyle to TextPaint.
51
+ * Uses caching to avoid expensive typeface creation on every call.
52
+ */
53
+ fun TextPaint.applyBlockStyleFont(
54
+ blockStyle: BlockStyle,
55
+ context: Context,
56
+ ) {
57
+ val cacheKey = "${blockStyle.fontFamily}|${blockStyle.fontWeight}"
58
+
59
+ val cachedTypeface = typefaceCache[cacheKey]
60
+ if (cachedTypeface != null) {
61
+ this.typeface = cachedTypeface
62
+ return
63
+ }
64
+
65
+ val fontWeight =
66
+ fontWeightCache.getOrPut(blockStyle.fontWeight) {
67
+ parseFontWeight(blockStyle.fontWeight)
68
+ }
69
+
70
+ // Pass null as base typeface - this matches React Native Text behavior
71
+ // applyStyles will use ReactFontManager to load custom fonts from assets
72
+ val newTypeface =
73
+ applyStyles(
74
+ null, // Let applyStyles handle font loading from assets
75
+ ReactConstants.UNSET,
76
+ fontWeight,
77
+ blockStyle.fontFamily.takeIf { it.isNotEmpty() },
78
+ context.assets,
79
+ )
80
+
81
+ typefaceCache[cacheKey] = newTypeface
82
+ this.typeface = newTypeface
83
+ }
84
+
85
+ // ============================================================================
86
+ // Paragraph/Node Utilities
87
+ // ============================================================================
88
+
89
+ /**
90
+ * Determines if a paragraph contains only a single block image.
91
+ */
92
+ fun MarkdownASTNode.containsBlockImage(): Boolean {
93
+ if (type != MarkdownASTNode.NodeType.Paragraph) return false
94
+ val firstChild = children.firstOrNull()
95
+ return firstChild != null && children.size == 1 && firstChild.type == MarkdownASTNode.NodeType.Image
96
+ }
97
+
98
+ /**
99
+ * Determines the appropriate marginBottom for a paragraph.
100
+ * If paragraph contains only a single block-level element (e.g., image), uses that element's marginBottom.
101
+ * Otherwise, uses paragraph's marginBottom.
102
+ */
103
+ fun getMarginBottomForParagraph(
104
+ paragraph: MarkdownASTNode,
105
+ paragraphStyle: ParagraphStyle,
106
+ style: StyleConfig,
107
+ ): Float {
108
+ // If paragraph contains only a single block-level element, use that element's marginBottom
109
+ // Otherwise, use paragraph's marginBottom
110
+ if (paragraph.children.size == 1) {
111
+ // Paragraph has exactly one child
112
+ when (paragraph.children.first().type) {
113
+ MarkdownASTNode.NodeType.Image -> {
114
+ // Image: use image's marginBottom
115
+ return style.imageStyle.marginBottom
116
+ }
117
+
118
+ // Future: Add other block elements here as they're implemented
119
+ else -> {
120
+ // Not a block element we handle specially
121
+ }
122
+ }
123
+ }
124
+
125
+ // Default: use paragraph's marginBottom
126
+ return paragraphStyle.marginBottom
127
+ }
128
+
129
+ // ============================================================================
130
+ // SpannableStringBuilder Extensions
131
+ // ============================================================================
132
+
133
+ /**
134
+ * Determines if an image should be rendered inline (within text) or as a block element.
135
+ * An image is inline if it's not preceded by a line break or zero-width space.
136
+ */
137
+ fun SpannableStringBuilder.isInlineImage(): Boolean {
138
+ if (isEmpty()) return false
139
+ val lastChar = last()
140
+ return lastChar != '\n' && lastChar != '\u200B'
141
+ }
142
+
143
+ // ============================================================================
144
+ // Span Creation Utilities
145
+ // ============================================================================
146
+
147
+ /**
148
+ * Creates a LineHeightSpan appropriate for the current API level.
149
+ *
150
+ * @param lineHeight The desired line height in pixels
151
+ */
152
+ fun createLineHeightSpan(lineHeight: Float): AndroidLineHeightSpan =
153
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
154
+ AndroidLineHeightSpan.Standard(lineHeight.toInt())
155
+ } else {
156
+ LineHeightSpan(lineHeight)
157
+ }
158
+
159
+ /**
160
+ * Applies marginBottom spacing to a block element.
161
+ * Appends a newline and applies MarginBottomSpan if marginBottom > 0.
162
+ *
163
+ * @param builder The SpannableStringBuilder to modify
164
+ * @param start The start position of the block content (before appending newline)
165
+ * @param marginBottom The spacing value to apply after the block
166
+ */
167
+ fun applyMarginBottom(
168
+ builder: SpannableStringBuilder,
169
+ start: Int,
170
+ marginBottom: Float,
171
+ ) {
172
+ builder.append("\n")
173
+ if (marginBottom > 0) {
174
+ builder.setSpan(
175
+ MarginBottomSpan(marginBottom),
176
+ start,
177
+ builder.length, // Includes the newline we just appended
178
+ SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE,
179
+ )
180
+ }
181
+ }