react-native-enriched-markdown 0.1.0 → 0.2.0

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 (226) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +551 -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 +54 -0
  6. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerInterface.java +26 -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 +33 -0
  10. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.h +31 -0
  11. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.cpp +82 -0
  12. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +1388 -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 +220 -0
  21. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +270 -0
  22. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextLayoutManager.kt +15 -0
  23. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt +173 -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 +385 -0
  26. package/android/src/main/java/com/swmansion/enriched/markdown/accessibility/MarkdownAccessibilityHelper.kt +279 -0
  27. package/android/src/main/java/com/swmansion/enriched/markdown/events/LinkLongPressEvent.kt +23 -0
  28. package/android/src/main/java/com/swmansion/enriched/markdown/events/LinkPressEvent.kt +23 -0
  29. package/android/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt +31 -0
  30. package/android/src/main/java/com/swmansion/enriched/markdown/parser/Parser.kt +62 -0
  31. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockStyleContext.kt +166 -0
  32. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt +84 -0
  33. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/CodeBlockRenderer.kt +104 -0
  34. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/CodeRenderer.kt +36 -0
  35. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/DocumentRenderer.kt +16 -0
  36. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/EmphasisRenderer.kt +27 -0
  37. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/HeadingRenderer.kt +70 -0
  38. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ImageRenderer.kt +68 -0
  39. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/LineBreakRenderer.kt +16 -0
  40. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt +29 -0
  41. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListContextManager.kt +105 -0
  42. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListItemRenderer.kt +59 -0
  43. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListRenderer.kt +76 -0
  44. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt +103 -0
  45. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ParagraphRenderer.kt +80 -0
  46. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/Renderer.kt +109 -0
  47. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt +86 -0
  48. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/StrikethroughRenderer.kt +27 -0
  49. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/StrongRenderer.kt +27 -0
  50. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/TextRenderer.kt +30 -0
  51. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ThematicBreakRenderer.kt +45 -0
  52. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/UnderlineRenderer.kt +27 -0
  53. package/android/src/main/java/com/swmansion/enriched/markdown/spans/BaseListSpan.kt +136 -0
  54. package/android/src/main/java/com/swmansion/enriched/markdown/spans/BlockquoteSpan.kt +135 -0
  55. package/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBackgroundSpan.kt +180 -0
  56. package/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBlockSpan.kt +196 -0
  57. package/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeSpan.kt +27 -0
  58. package/android/src/main/java/com/swmansion/enriched/markdown/spans/EmphasisSpan.kt +34 -0
  59. package/android/src/main/java/com/swmansion/enriched/markdown/spans/HeadingSpan.kt +38 -0
  60. package/android/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt +321 -0
  61. package/android/src/main/java/com/swmansion/enriched/markdown/spans/LineHeightSpan.kt +27 -0
  62. package/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt +51 -0
  63. package/android/src/main/java/com/swmansion/enriched/markdown/spans/MarginBottomSpan.kt +76 -0
  64. package/android/src/main/java/com/swmansion/enriched/markdown/spans/OrderedListSpan.kt +87 -0
  65. package/android/src/main/java/com/swmansion/enriched/markdown/spans/StrikethroughSpan.kt +12 -0
  66. package/android/src/main/java/com/swmansion/enriched/markdown/spans/StrongSpan.kt +37 -0
  67. package/android/src/main/java/com/swmansion/enriched/markdown/spans/TextSpan.kt +26 -0
  68. package/android/src/main/java/com/swmansion/enriched/markdown/spans/ThematicBreakSpan.kt +69 -0
  69. package/android/src/main/java/com/swmansion/enriched/markdown/spans/UnorderedListSpan.kt +69 -0
  70. package/android/src/main/java/com/swmansion/enriched/markdown/styles/BaseBlockStyle.kt +11 -0
  71. package/android/src/main/java/com/swmansion/enriched/markdown/styles/BlockquoteStyle.kt +51 -0
  72. package/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeBlockStyle.kt +54 -0
  73. package/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeStyle.kt +21 -0
  74. package/android/src/main/java/com/swmansion/enriched/markdown/styles/EmphasisStyle.kt +17 -0
  75. package/android/src/main/java/com/swmansion/enriched/markdown/styles/HeadingStyle.kt +33 -0
  76. package/android/src/main/java/com/swmansion/enriched/markdown/styles/ImageStyle.kt +23 -0
  77. package/android/src/main/java/com/swmansion/enriched/markdown/styles/InlineImageStyle.kt +17 -0
  78. package/android/src/main/java/com/swmansion/enriched/markdown/styles/LinkStyle.kt +19 -0
  79. package/android/src/main/java/com/swmansion/enriched/markdown/styles/ListStyle.kt +57 -0
  80. package/android/src/main/java/com/swmansion/enriched/markdown/styles/ParagraphStyle.kt +33 -0
  81. package/android/src/main/java/com/swmansion/enriched/markdown/styles/StrikethroughStyle.kt +17 -0
  82. package/android/src/main/java/com/swmansion/enriched/markdown/styles/StrongStyle.kt +17 -0
  83. package/android/src/main/java/com/swmansion/enriched/markdown/styles/StyleConfig.kt +211 -0
  84. package/android/src/main/java/com/swmansion/enriched/markdown/styles/StyleParser.kt +92 -0
  85. package/android/src/main/java/com/swmansion/enriched/markdown/styles/TextAlignment.kt +32 -0
  86. package/android/src/main/java/com/swmansion/enriched/markdown/styles/ThematicBreakStyle.kt +23 -0
  87. package/android/src/main/java/com/swmansion/enriched/markdown/styles/UnderlineStyle.kt +17 -0
  88. package/android/src/main/java/com/swmansion/enriched/markdown/utils/AsyncDrawable.kt +91 -0
  89. package/android/src/main/java/com/swmansion/enriched/markdown/utils/HTMLGenerator.kt +827 -0
  90. package/android/src/main/java/com/swmansion/enriched/markdown/utils/LinkLongPressMovementMethod.kt +121 -0
  91. package/android/src/main/java/com/swmansion/enriched/markdown/utils/MarkdownExtractor.kt +375 -0
  92. package/android/src/main/java/com/swmansion/enriched/markdown/utils/SelectionActionMode.kt +139 -0
  93. package/android/src/main/java/com/swmansion/enriched/markdown/utils/Utils.kt +183 -0
  94. package/android/src/main/jni/CMakeLists.txt +70 -0
  95. package/android/src/main/jni/EnrichedMarkdownTextSpec.cpp +21 -0
  96. package/android/src/main/jni/EnrichedMarkdownTextSpec.h +25 -0
  97. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextComponentDescriptor.h +29 -0
  98. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextMeasurementManager.cpp +45 -0
  99. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextMeasurementManager.h +21 -0
  100. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp +20 -0
  101. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h +37 -0
  102. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h +22 -0
  103. package/cpp/md4c/md4c.c +6492 -0
  104. package/cpp/md4c/md4c.h +402 -0
  105. package/cpp/parser/MD4CParser.cpp +327 -0
  106. package/cpp/parser/MD4CParser.hpp +27 -0
  107. package/cpp/parser/MarkdownASTNode.hpp +51 -0
  108. package/ios/EnrichedMarkdownText.h +18 -0
  109. package/ios/EnrichedMarkdownText.mm +1401 -0
  110. package/ios/attachments/EnrichedMarkdownImageAttachment.h +23 -0
  111. package/ios/attachments/EnrichedMarkdownImageAttachment.m +185 -0
  112. package/ios/attachments/ThematicBreakAttachment.h +15 -0
  113. package/ios/attachments/ThematicBreakAttachment.m +33 -0
  114. package/ios/generated/EnrichedMarkdownTextSpec/ComponentDescriptors.cpp +22 -0
  115. package/ios/generated/EnrichedMarkdownTextSpec/ComponentDescriptors.h +24 -0
  116. package/ios/generated/EnrichedMarkdownTextSpec/EventEmitters.cpp +33 -0
  117. package/ios/generated/EnrichedMarkdownTextSpec/EventEmitters.h +31 -0
  118. package/ios/generated/EnrichedMarkdownTextSpec/Props.cpp +82 -0
  119. package/ios/generated/EnrichedMarkdownTextSpec/Props.h +1388 -0
  120. package/ios/generated/EnrichedMarkdownTextSpec/RCTComponentViewHelpers.h +20 -0
  121. package/ios/generated/EnrichedMarkdownTextSpec/ShadowNodes.cpp +17 -0
  122. package/ios/generated/EnrichedMarkdownTextSpec/ShadowNodes.h +32 -0
  123. package/ios/generated/EnrichedMarkdownTextSpec/States.cpp +16 -0
  124. package/ios/generated/EnrichedMarkdownTextSpec/States.h +20 -0
  125. package/ios/internals/EnrichedMarkdownTextComponentDescriptor.h +19 -0
  126. package/ios/internals/EnrichedMarkdownTextShadowNode.h +43 -0
  127. package/ios/internals/EnrichedMarkdownTextShadowNode.mm +85 -0
  128. package/ios/internals/EnrichedMarkdownTextState.h +24 -0
  129. package/ios/parser/MarkdownASTNode.h +35 -0
  130. package/ios/parser/MarkdownASTNode.m +32 -0
  131. package/ios/parser/MarkdownParser.h +17 -0
  132. package/ios/parser/MarkdownParser.mm +42 -0
  133. package/ios/parser/MarkdownParserBridge.mm +120 -0
  134. package/ios/renderer/AttributedRenderer.h +11 -0
  135. package/ios/renderer/AttributedRenderer.m +152 -0
  136. package/ios/renderer/BlockquoteRenderer.h +7 -0
  137. package/ios/renderer/BlockquoteRenderer.m +160 -0
  138. package/ios/renderer/CodeBlockRenderer.h +10 -0
  139. package/ios/renderer/CodeBlockRenderer.m +90 -0
  140. package/ios/renderer/CodeRenderer.h +10 -0
  141. package/ios/renderer/CodeRenderer.m +60 -0
  142. package/ios/renderer/EmphasisRenderer.h +6 -0
  143. package/ios/renderer/EmphasisRenderer.m +96 -0
  144. package/ios/renderer/HeadingRenderer.h +7 -0
  145. package/ios/renderer/HeadingRenderer.m +105 -0
  146. package/ios/renderer/ImageRenderer.h +12 -0
  147. package/ios/renderer/ImageRenderer.m +83 -0
  148. package/ios/renderer/LinkRenderer.h +7 -0
  149. package/ios/renderer/LinkRenderer.m +69 -0
  150. package/ios/renderer/ListItemRenderer.h +16 -0
  151. package/ios/renderer/ListItemRenderer.m +103 -0
  152. package/ios/renderer/ListRenderer.h +13 -0
  153. package/ios/renderer/ListRenderer.m +70 -0
  154. package/ios/renderer/NodeRenderer.h +8 -0
  155. package/ios/renderer/ParagraphRenderer.h +7 -0
  156. package/ios/renderer/ParagraphRenderer.m +80 -0
  157. package/ios/renderer/RenderContext.h +105 -0
  158. package/ios/renderer/RenderContext.m +312 -0
  159. package/ios/renderer/RendererFactory.h +12 -0
  160. package/ios/renderer/RendererFactory.m +116 -0
  161. package/ios/renderer/StrikethroughRenderer.h +6 -0
  162. package/ios/renderer/StrikethroughRenderer.m +40 -0
  163. package/ios/renderer/StrongRenderer.h +6 -0
  164. package/ios/renderer/StrongRenderer.m +83 -0
  165. package/ios/renderer/TextRenderer.h +6 -0
  166. package/ios/renderer/TextRenderer.m +16 -0
  167. package/ios/renderer/ThematicBreakRenderer.h +5 -0
  168. package/ios/renderer/ThematicBreakRenderer.m +53 -0
  169. package/ios/renderer/UnderlineRenderer.h +6 -0
  170. package/ios/renderer/UnderlineRenderer.m +39 -0
  171. package/ios/styles/StyleConfig.h +274 -0
  172. package/ios/styles/StyleConfig.mm +1806 -0
  173. package/ios/utils/AccessibilityInfo.h +35 -0
  174. package/ios/utils/AccessibilityInfo.m +24 -0
  175. package/ios/utils/BlockquoteBorder.h +20 -0
  176. package/ios/utils/BlockquoteBorder.m +92 -0
  177. package/ios/utils/CodeBackground.h +19 -0
  178. package/ios/utils/CodeBackground.m +191 -0
  179. package/ios/utils/CodeBlockBackground.h +17 -0
  180. package/ios/utils/CodeBlockBackground.m +82 -0
  181. package/ios/utils/EditMenuUtils.h +22 -0
  182. package/ios/utils/EditMenuUtils.m +118 -0
  183. package/ios/utils/FontUtils.h +25 -0
  184. package/ios/utils/FontUtils.m +27 -0
  185. package/ios/utils/HTMLGenerator.h +20 -0
  186. package/ios/utils/HTMLGenerator.m +793 -0
  187. package/ios/utils/LastElementUtils.h +53 -0
  188. package/ios/utils/ListMarkerDrawer.h +15 -0
  189. package/ios/utils/ListMarkerDrawer.m +127 -0
  190. package/ios/utils/MarkdownAccessibilityElementBuilder.h +45 -0
  191. package/ios/utils/MarkdownAccessibilityElementBuilder.m +323 -0
  192. package/ios/utils/MarkdownExtractor.h +17 -0
  193. package/ios/utils/MarkdownExtractor.m +308 -0
  194. package/ios/utils/ParagraphStyleUtils.h +21 -0
  195. package/ios/utils/ParagraphStyleUtils.m +111 -0
  196. package/ios/utils/PasteboardUtils.h +36 -0
  197. package/ios/utils/PasteboardUtils.m +134 -0
  198. package/ios/utils/RTFExportUtils.h +24 -0
  199. package/ios/utils/RTFExportUtils.m +297 -0
  200. package/ios/utils/RuntimeKeys.h +38 -0
  201. package/ios/utils/RuntimeKeys.m +11 -0
  202. package/ios/utils/TextViewLayoutManager.h +14 -0
  203. package/ios/utils/TextViewLayoutManager.mm +113 -0
  204. package/lib/module/EnrichedMarkdownText.js +65 -0
  205. package/lib/module/EnrichedMarkdownText.js.map +1 -0
  206. package/lib/module/EnrichedMarkdownTextNativeComponent.ts +210 -0
  207. package/lib/module/index.js +4 -0
  208. package/lib/module/index.js.map +1 -0
  209. package/lib/module/normalizeMarkdownStyle.js +384 -0
  210. package/lib/module/normalizeMarkdownStyle.js.map +1 -0
  211. package/lib/module/package.json +1 -0
  212. package/lib/typescript/package.json +1 -0
  213. package/lib/typescript/src/EnrichedMarkdownText.d.ts +183 -0
  214. package/lib/typescript/src/EnrichedMarkdownText.d.ts.map +1 -0
  215. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +185 -0
  216. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts.map +1 -0
  217. package/lib/typescript/src/index.d.ts +4 -0
  218. package/lib/typescript/src/index.d.ts.map +1 -0
  219. package/lib/typescript/src/normalizeMarkdownStyle.d.ts +6 -0
  220. package/lib/typescript/src/normalizeMarkdownStyle.d.ts.map +1 -0
  221. package/package.json +186 -1
  222. package/react-native.config.js +13 -0
  223. package/src/EnrichedMarkdownText.tsx +280 -0
  224. package/src/EnrichedMarkdownTextNativeComponent.ts +210 -0
  225. package/src/index.tsx +10 -0
  226. package/src/normalizeMarkdownStyle.ts +423 -0
@@ -0,0 +1,121 @@
1
+ package com.swmansion.enriched.markdown.utils
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import android.text.Selection
6
+ import android.text.Spannable
7
+ import android.text.method.LinkMovementMethod
8
+ import android.view.MotionEvent
9
+ import android.view.ViewConfiguration
10
+ import android.widget.TextView
11
+ import com.swmansion.enriched.markdown.spans.LinkSpan
12
+ import kotlin.math.abs
13
+
14
+ /**
15
+ * Custom MovementMethod that handles both link clicks and long presses.
16
+ * Extends LinkMovementMethod to maintain click functionality while adding long press support.
17
+ */
18
+ class LinkLongPressMovementMethod : LinkMovementMethod() {
19
+ private val handler = Handler(Looper.getMainLooper())
20
+ private var longPressRunnable: Runnable? = null
21
+
22
+ private var startX = 0f
23
+ private var startY = 0f
24
+
25
+ override fun onTouchEvent(
26
+ widget: TextView,
27
+ buffer: Spannable,
28
+ event: MotionEvent,
29
+ ): Boolean {
30
+ when (event.action) {
31
+ MotionEvent.ACTION_DOWN -> {
32
+ startX = event.x
33
+ startY = event.y
34
+
35
+ // Identify if a LinkSpan exists at the touch coordinates
36
+ findLinkSpan(widget, buffer, event)?.let { span ->
37
+ scheduleLongPress(widget, span)
38
+ }
39
+ }
40
+
41
+ MotionEvent.ACTION_MOVE -> {
42
+ val config = ViewConfiguration.get(widget.context)
43
+ // Cancel if the finger moves beyond the standard system touch slop
44
+ if (abs(event.x - startX) > config.scaledTouchSlop ||
45
+ abs(event.y - startY) > config.scaledTouchSlop
46
+ ) {
47
+ cancelLongPress()
48
+ }
49
+ }
50
+
51
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
52
+ cancelLongPress()
53
+ // Clear text selection to prevent the "stuck" highlight look
54
+ if (widget.hasSelection()) {
55
+ Selection.removeSelection(buffer)
56
+ }
57
+ }
58
+ }
59
+
60
+ // Let the parent LinkMovementMethod handle the standard click logic
61
+ val result = super.onTouchEvent(widget, buffer, event)
62
+
63
+ // LinkMovementMethod sets a Selection highlight around the link on ACTION_DOWN,
64
+ // which causes a visible selection color on the link text while pressed.
65
+ // We remove that selection immediately so the user never sees it.
66
+ if (event.action == MotionEvent.ACTION_DOWN) {
67
+ Selection.removeSelection(buffer)
68
+ }
69
+
70
+ return result
71
+ }
72
+
73
+ private fun scheduleLongPress(
74
+ widget: TextView,
75
+ span: LinkSpan,
76
+ ) {
77
+ cancelLongPress()
78
+
79
+ longPressRunnable =
80
+ Runnable {
81
+ if (widget.hasSelection()) {
82
+ Selection.removeSelection(widget.text as Spannable)
83
+ }
84
+ // Execute the long click logic on the span
85
+ if (span.onLongClick(widget)) {
86
+ // If consumed, cancel the system's own long-press logic (like context menus)
87
+ widget.cancelLongPress()
88
+ }
89
+ longPressRunnable = null
90
+ }.also {
91
+ handler.postDelayed(it, ViewConfiguration.getLongPressTimeout().toLong())
92
+ }
93
+ }
94
+
95
+ private fun cancelLongPress() {
96
+ longPressRunnable?.let(handler::removeCallbacks)
97
+ longPressRunnable = null
98
+ }
99
+
100
+ private fun findLinkSpan(
101
+ widget: TextView,
102
+ buffer: Spannable,
103
+ event: MotionEvent,
104
+ ): LinkSpan? {
105
+ // Adjust coordinates for padding and scroll
106
+ val x = event.x.toInt() - widget.totalPaddingLeft + widget.scrollX
107
+ val y = event.y.toInt() - widget.totalPaddingTop + widget.scrollY
108
+
109
+ val layout = widget.layout ?: return null
110
+ val line = layout.getLineForVertical(y)
111
+ val offset = layout.getOffsetForHorizontal(line, x.toFloat())
112
+
113
+ // Ensure the touch is within the character bounds
114
+ return buffer.getSpans(offset, offset, LinkSpan::class.java).firstOrNull()
115
+ }
116
+
117
+ companion object {
118
+ @JvmStatic
119
+ fun createInstance(): LinkLongPressMovementMethod = LinkLongPressMovementMethod()
120
+ }
121
+ }
@@ -0,0 +1,375 @@
1
+ package com.swmansion.enriched.markdown.utils
2
+
3
+ import android.text.Spannable
4
+ import android.text.style.UnderlineSpan
5
+ import android.widget.TextView
6
+ import com.swmansion.enriched.markdown.EnrichedMarkdownText
7
+ import com.swmansion.enriched.markdown.spans.BlockquoteSpan
8
+ import com.swmansion.enriched.markdown.spans.CodeBlockSpan
9
+ import com.swmansion.enriched.markdown.spans.CodeSpan
10
+ import com.swmansion.enriched.markdown.spans.EmphasisSpan
11
+ import com.swmansion.enriched.markdown.spans.HeadingSpan
12
+ import com.swmansion.enriched.markdown.spans.ImageSpan
13
+ import com.swmansion.enriched.markdown.spans.LinkSpan
14
+ import com.swmansion.enriched.markdown.spans.OrderedListSpan
15
+ import com.swmansion.enriched.markdown.spans.StrikethroughSpan
16
+ import com.swmansion.enriched.markdown.spans.StrongSpan
17
+ import com.swmansion.enriched.markdown.spans.ThematicBreakSpan
18
+ import com.swmansion.enriched.markdown.spans.UnorderedListSpan
19
+
20
+ /** Extracts markdown from styled text (Spannable). */
21
+ object MarkdownExtractor {
22
+ /**
23
+ * Gets markdown for the current text selection.
24
+ * Full selection returns original markdown, partial reconstructs from spans.
25
+ */
26
+ fun getMarkdownForSelection(textView: TextView): String? {
27
+ val start = textView.selectionStart
28
+ val end = textView.selectionEnd
29
+ if (start < 0 || end < 0 || start >= end) return null
30
+
31
+ val spannable = textView.text as? Spannable ?: return null
32
+
33
+ val isFullSelection = start == 0 && end >= textView.text.length - 1
34
+ if (isFullSelection && textView is EnrichedMarkdownText) {
35
+ val original = textView.currentMarkdown
36
+ if (original.isNotEmpty()) return original
37
+ }
38
+
39
+ return extractFromSpannable(spannable, start, end)
40
+ }
41
+
42
+ /** Extracts markdown from a Spannable within a given range. */
43
+ fun extractFromSpannable(
44
+ spannable: Spannable,
45
+ start: Int,
46
+ end: Int,
47
+ ): String {
48
+ val result = StringBuilder()
49
+ val state = ExtractionState()
50
+ val headingAccumulator = HeadingAccumulator()
51
+
52
+ var i = start
53
+ while (i < end) {
54
+ val nextTransition = spannable.nextSpanTransition(i, end, Any::class.java)
55
+ val segmentText = spannable.subSequence(i, nextTransition).toString()
56
+
57
+ val handled =
58
+ processSegment(
59
+ spannable = spannable,
60
+ segmentText = segmentText,
61
+ segmentStart = i,
62
+ segmentEnd = nextTransition,
63
+ result = result,
64
+ state = state,
65
+ headingAccumulator = headingAccumulator,
66
+ )
67
+
68
+ if (!handled) {
69
+ // Regular text segment - apply inline formatting and block prefixes
70
+ appendFormattedSegment(spannable, segmentText, i, nextTransition, result, state)
71
+ }
72
+
73
+ i = nextTransition
74
+ }
75
+
76
+ headingAccumulator.flush(result, state)
77
+ return result.toString()
78
+ }
79
+
80
+ /** Returns true if segment was handled specially. */
81
+ private fun processSegment(
82
+ spannable: Spannable,
83
+ segmentText: String,
84
+ segmentStart: Int,
85
+ segmentEnd: Int,
86
+ result: StringBuilder,
87
+ state: ExtractionState,
88
+ headingAccumulator: HeadingAccumulator,
89
+ ): Boolean {
90
+ // Check for thematic breaks first (uses " \n" as placeholder)
91
+ val thematicBreakSpans = spannable.getSpans(segmentStart, segmentEnd, ThematicBreakSpan::class.java)
92
+ if (thematicBreakSpans.isNotEmpty()) {
93
+ appendThematicBreak(result, state)
94
+ return true
95
+ }
96
+
97
+ if (segmentText == "\uFFFC" || segmentText == "\u200B") {
98
+ val imageSpans = spannable.getSpans(segmentStart, segmentEnd, ImageSpan::class.java)
99
+ if (imageSpans.isNotEmpty()) {
100
+ appendImage(imageSpans[0], result, state)
101
+ return true
102
+ }
103
+ }
104
+
105
+ if (segmentText.isEmpty()) return true
106
+
107
+ if (segmentText == "\n" || segmentText == "\n\n") {
108
+ handleNewline(spannable, segmentStart, segmentEnd, result, state)
109
+ return true
110
+ }
111
+
112
+ val headingSpans = spannable.getSpans(segmentStart, segmentEnd, HeadingSpan::class.java)
113
+ if (headingSpans.isNotEmpty()) {
114
+ headingAccumulator.accumulate(headingSpans[0].level, segmentText, result, state)
115
+ return true
116
+ } else {
117
+ headingAccumulator.flush(result, state)
118
+ }
119
+
120
+ val codeBlockSpans = spannable.getSpans(segmentStart, segmentEnd, CodeBlockSpan::class.java)
121
+ if (codeBlockSpans.isNotEmpty()) {
122
+ appendCodeBlock(segmentText, result, state)
123
+ return true
124
+ }
125
+
126
+ return false
127
+ }
128
+
129
+ private fun appendFormattedSegment(
130
+ spannable: Spannable,
131
+ segmentText: String,
132
+ segmentStart: Int,
133
+ segmentEnd: Int,
134
+ result: StringBuilder,
135
+ state: ExtractionState,
136
+ ) {
137
+ val blockquotePrefix = detectBlockquote(spannable, segmentStart, segmentEnd, state)
138
+ val listPrefix = detectList(spannable, segmentStart, segmentEnd, state)
139
+ var segment = applyInlineFormatting(spannable, segmentText, segmentStart, segmentEnd)
140
+
141
+ if (result.isAtLineStart() && !segmentText.startsWith("\n")) {
142
+ segment = buildBlockPrefix(blockquotePrefix, listPrefix) + segment
143
+ }
144
+
145
+ if (state.needsBlankLine && result.isNotEmpty()) {
146
+ result.ensureBlankLine()
147
+ state.needsBlankLine = false
148
+ }
149
+
150
+ result.append(segment)
151
+ }
152
+
153
+ private fun appendImage(
154
+ img: ImageSpan,
155
+ result: StringBuilder,
156
+ state: ExtractionState,
157
+ ) {
158
+ if (img.isInline) {
159
+ result.append("![image](${img.imageUrl})")
160
+ } else {
161
+ result.ensureBlankLine()
162
+ result.append("![image](${img.imageUrl})\n")
163
+ state.needsBlankLine = true
164
+ state.blockquoteDepth = -1
165
+ state.listDepth = -1
166
+ }
167
+ }
168
+
169
+ private fun appendThematicBreak(
170
+ result: StringBuilder,
171
+ state: ExtractionState,
172
+ ) {
173
+ result.ensureBlankLine()
174
+ result.append("---\n")
175
+ state.needsBlankLine = true
176
+ state.blockquoteDepth = -1
177
+ state.listDepth = -1
178
+ }
179
+
180
+ private fun handleNewline(
181
+ spannable: Spannable,
182
+ start: Int,
183
+ end: Int,
184
+ result: StringBuilder,
185
+ state: ExtractionState,
186
+ ) {
187
+ val inBlockquote = spannable.getSpans(start, end, BlockquoteSpan::class.java).isNotEmpty()
188
+ val inList =
189
+ spannable.getSpans(start, end, OrderedListSpan::class.java).isNotEmpty() ||
190
+ spannable.getSpans(start, end, UnorderedListSpan::class.java).isNotEmpty()
191
+
192
+ when {
193
+ !inBlockquote && state.blockquoteDepth >= 0 -> {
194
+ result.ensureBlankLine()
195
+ state.blockquoteDepth = -1
196
+ }
197
+
198
+ !inList && state.listDepth >= 0 -> {
199
+ result.ensureBlankLine()
200
+ state.listDepth = -1
201
+ }
202
+
203
+ inBlockquote || inList -> {
204
+ if (!result.endsWith("\n")) result.append("\n")
205
+ }
206
+
207
+ else -> {
208
+ result.ensureBlankLine()
209
+ }
210
+ }
211
+ }
212
+
213
+ private fun appendCodeBlock(
214
+ text: String,
215
+ result: StringBuilder,
216
+ state: ExtractionState,
217
+ ) {
218
+ if (state.needsBlankLine) {
219
+ result.ensureBlankLine()
220
+ state.needsBlankLine = false
221
+ }
222
+
223
+ val needsFence = result.isEmpty() || result.endsWith("\n\n")
224
+ if (needsFence) result.append("```\n")
225
+
226
+ result.append(text)
227
+
228
+ if (text.endsWith("\n")) {
229
+ result.append("```\n")
230
+ state.needsBlankLine = true
231
+ }
232
+ }
233
+
234
+ private fun detectBlockquote(
235
+ spannable: Spannable,
236
+ start: Int,
237
+ end: Int,
238
+ state: ExtractionState,
239
+ ): String? {
240
+ val spans = spannable.getSpans(start, end, BlockquoteSpan::class.java)
241
+ val depth = spans.maxOfOrNull { it.depth } ?: -1
242
+
243
+ return if (depth >= 0) {
244
+ state.blockquoteDepth = depth
245
+ "> ".repeat(depth + 1)
246
+ } else {
247
+ if (state.blockquoteDepth >= 0) state.blockquoteDepth = -1
248
+ null
249
+ }
250
+ }
251
+
252
+ private fun detectList(
253
+ spannable: Spannable,
254
+ start: Int,
255
+ end: Int,
256
+ state: ExtractionState,
257
+ ): String? {
258
+ val orderedSpans = spannable.getSpans(start, end, OrderedListSpan::class.java)
259
+ val unorderedSpans = spannable.getSpans(start, end, UnorderedListSpan::class.java)
260
+
261
+ val orderedDepth = orderedSpans.maxOfOrNull { it.depth } ?: -1
262
+ val unorderedDepth = unorderedSpans.maxOfOrNull { it.depth } ?: -1
263
+ val depth = maxOf(orderedDepth, unorderedDepth)
264
+
265
+ return if (depth >= 0) {
266
+ state.listDepth = depth
267
+ val indent = " ".repeat(depth)
268
+ if (orderedSpans.isNotEmpty()) {
269
+ "$indent${orderedSpans[0].itemNumber}. "
270
+ } else {
271
+ "$indent- "
272
+ }
273
+ } else {
274
+ if (state.listDepth >= 0) state.listDepth = -1
275
+ null
276
+ }
277
+ }
278
+
279
+ private fun applyInlineFormatting(
280
+ spannable: Spannable,
281
+ text: String,
282
+ start: Int,
283
+ end: Int,
284
+ ): String {
285
+ val hasStrong = spannable.getSpans(start, end, StrongSpan::class.java).isNotEmpty()
286
+ val hasEmphasis = spannable.getSpans(start, end, EmphasisSpan::class.java).isNotEmpty()
287
+ val hasCode = spannable.getSpans(start, end, CodeSpan::class.java).isNotEmpty()
288
+ val hasStrikethrough = spannable.getSpans(start, end, StrikethroughSpan::class.java).isNotEmpty()
289
+ val hasUnderline = spannable.getSpans(start, end, UnderlineSpan::class.java).isNotEmpty()
290
+ val linkSpans = spannable.getSpans(start, end, LinkSpan::class.java)
291
+
292
+ var result = text
293
+
294
+ // Innermost first
295
+ if (hasCode && linkSpans.isEmpty()) {
296
+ result = "`$result`"
297
+ }
298
+ if (hasStrikethrough) {
299
+ result = "~~$result~~"
300
+ }
301
+ if (hasUnderline && linkSpans.isEmpty()) {
302
+ result = "<u>$result</u>"
303
+ }
304
+ if (hasEmphasis) {
305
+ result = "*$result*"
306
+ }
307
+ if (hasStrong) {
308
+ result = "**$result**"
309
+ }
310
+ if (linkSpans.isNotEmpty()) {
311
+ result = "[$text](${linkSpans[0].url})"
312
+ }
313
+
314
+ return result
315
+ }
316
+
317
+ private fun buildBlockPrefix(
318
+ blockquotePrefix: String?,
319
+ listPrefix: String?,
320
+ ): String =
321
+ buildString {
322
+ blockquotePrefix?.let { append(it) }
323
+ listPrefix?.let { append(it) }
324
+ }
325
+
326
+ private data class ExtractionState(
327
+ var blockquoteDepth: Int = -1,
328
+ var listDepth: Int = -1,
329
+ var needsBlankLine: Boolean = false,
330
+ )
331
+
332
+ /** Accumulates heading content across multiple span segments. */
333
+ private class HeadingAccumulator {
334
+ private var level: Int? = null
335
+ private val content = StringBuilder()
336
+
337
+ fun accumulate(
338
+ newLevel: Int,
339
+ text: String,
340
+ result: StringBuilder,
341
+ state: ExtractionState,
342
+ ) {
343
+ if (level != newLevel) {
344
+ flush(result, state)
345
+ level = newLevel
346
+ }
347
+ content.append(text.trim('\n'))
348
+ }
349
+
350
+ fun flush(
351
+ result: StringBuilder,
352
+ state: ExtractionState,
353
+ ) {
354
+ val currentLevel = level ?: return
355
+ if (content.isEmpty()) return
356
+
357
+ result.ensureBlankLine()
358
+ result.append("#".repeat(currentLevel))
359
+ result.append(" ")
360
+ result.append(content.toString().trim())
361
+ result.append("\n")
362
+
363
+ level = null
364
+ content.clear()
365
+ state.needsBlankLine = true
366
+ }
367
+ }
368
+
369
+ private fun StringBuilder.ensureBlankLine() {
370
+ if (isEmpty() || endsWith("\n\n")) return
371
+ append(if (endsWith("\n")) "\n" else "\n\n")
372
+ }
373
+
374
+ private fun StringBuilder.isAtLineStart(): Boolean = isEmpty() || endsWith("\n")
375
+ }
@@ -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
+ }