react-native-enriched-markdown 0.1.1 → 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 (127) hide show
  1. package/README.md +80 -8
  2. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerDelegate.java +17 -2
  3. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerInterface.java +6 -1
  4. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp +9 -0
  5. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.h +6 -0
  6. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.cpp +28 -3
  7. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +225 -1
  8. package/android/src/main/cpp/jni-adapter.cpp +28 -11
  9. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +132 -15
  10. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextLayoutManager.kt +1 -16
  11. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt +67 -13
  12. package/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +241 -21
  13. package/android/src/main/java/com/swmansion/enriched/markdown/accessibility/MarkdownAccessibilityHelper.kt +279 -0
  14. package/android/src/main/java/com/swmansion/enriched/markdown/events/LinkLongPressEvent.kt +23 -0
  15. package/android/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt +2 -0
  16. package/android/src/main/java/com/swmansion/enriched/markdown/parser/Parser.kt +17 -3
  17. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/BlockquoteRenderer.kt +13 -18
  18. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/CodeBlockRenderer.kt +23 -24
  19. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/CodeRenderer.kt +1 -0
  20. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/DocumentRenderer.kt +2 -1
  21. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/EmphasisRenderer.kt +2 -1
  22. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/HeadingRenderer.kt +18 -2
  23. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ImageRenderer.kt +22 -6
  24. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/LineBreakRenderer.kt +1 -0
  25. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt +3 -2
  26. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListItemRenderer.kt +2 -1
  27. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ListRenderer.kt +16 -9
  28. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt +5 -1
  29. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ParagraphRenderer.kt +23 -9
  30. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/Renderer.kt +24 -10
  31. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt +1 -0
  32. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/StrikethroughRenderer.kt +27 -0
  33. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/StrongRenderer.kt +2 -1
  34. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/TextRenderer.kt +1 -0
  35. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/ThematicBreakRenderer.kt +1 -0
  36. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/UnderlineRenderer.kt +27 -0
  37. package/android/src/main/java/com/swmansion/enriched/markdown/spans/ImageSpan.kt +1 -0
  38. package/android/src/main/java/com/swmansion/enriched/markdown/spans/LineHeightSpan.kt +8 -17
  39. package/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt +19 -5
  40. package/android/src/main/java/com/swmansion/enriched/markdown/spans/MarginBottomSpan.kt +1 -1
  41. package/android/src/main/java/com/swmansion/enriched/markdown/spans/StrikethroughSpan.kt +12 -0
  42. package/android/src/main/java/com/swmansion/enriched/markdown/styles/BaseBlockStyle.kt +1 -0
  43. package/android/src/main/java/com/swmansion/enriched/markdown/styles/BlockquoteStyle.kt +3 -0
  44. package/android/src/main/java/com/swmansion/enriched/markdown/styles/CodeBlockStyle.kt +3 -0
  45. package/android/src/main/java/com/swmansion/enriched/markdown/styles/HeadingStyle.kt +5 -1
  46. package/android/src/main/java/com/swmansion/enriched/markdown/styles/ImageStyle.kt +3 -1
  47. package/android/src/main/java/com/swmansion/enriched/markdown/styles/ListStyle.kt +3 -0
  48. package/android/src/main/java/com/swmansion/enriched/markdown/styles/ParagraphStyle.kt +5 -1
  49. package/android/src/main/java/com/swmansion/enriched/markdown/styles/StrikethroughStyle.kt +17 -0
  50. package/android/src/main/java/com/swmansion/enriched/markdown/styles/StyleConfig.kt +32 -1
  51. package/android/src/main/java/com/swmansion/enriched/markdown/styles/StyleParser.kt +22 -5
  52. package/android/src/main/java/com/swmansion/enriched/markdown/styles/TextAlignment.kt +32 -0
  53. package/android/src/main/java/com/swmansion/enriched/markdown/styles/UnderlineStyle.kt +17 -0
  54. package/android/src/main/java/com/swmansion/enriched/markdown/utils/HTMLGenerator.kt +23 -5
  55. package/android/src/main/java/com/swmansion/enriched/markdown/utils/LinkLongPressMovementMethod.kt +121 -0
  56. package/android/src/main/java/com/swmansion/enriched/markdown/utils/MarkdownExtractor.kt +10 -0
  57. package/android/src/main/java/com/swmansion/enriched/markdown/utils/Utils.kt +58 -56
  58. package/android/src/main/jni/CMakeLists.txt +1 -13
  59. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.cpp +0 -13
  60. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextShadowNode.h +2 -14
  61. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/conversions.h +3 -0
  62. package/cpp/parser/MD4CParser.cpp +21 -8
  63. package/cpp/parser/MD4CParser.hpp +5 -1
  64. package/cpp/parser/MarkdownASTNode.hpp +2 -0
  65. package/ios/EnrichedMarkdownText.mm +356 -29
  66. package/ios/attachments/{ImageAttachment.h → EnrichedMarkdownImageAttachment.h} +1 -1
  67. package/ios/attachments/{ImageAttachment.m → EnrichedMarkdownImageAttachment.m} +4 -4
  68. package/ios/generated/EnrichedMarkdownTextSpec/EventEmitters.cpp +9 -0
  69. package/ios/generated/EnrichedMarkdownTextSpec/EventEmitters.h +6 -0
  70. package/ios/generated/EnrichedMarkdownTextSpec/Props.cpp +28 -3
  71. package/ios/generated/EnrichedMarkdownTextSpec/Props.h +225 -1
  72. package/ios/parser/MarkdownASTNode.h +2 -0
  73. package/ios/parser/MarkdownParser.h +9 -0
  74. package/ios/parser/MarkdownParser.mm +31 -2
  75. package/ios/parser/MarkdownParserBridge.mm +13 -3
  76. package/ios/renderer/AttributedRenderer.h +2 -0
  77. package/ios/renderer/AttributedRenderer.m +52 -19
  78. package/ios/renderer/BlockquoteRenderer.m +7 -6
  79. package/ios/renderer/CodeBlockRenderer.m +9 -8
  80. package/ios/renderer/HeadingRenderer.m +31 -24
  81. package/ios/renderer/ImageRenderer.m +31 -10
  82. package/ios/renderer/ListItemRenderer.m +51 -39
  83. package/ios/renderer/ListRenderer.m +21 -18
  84. package/ios/renderer/ParagraphRenderer.m +27 -16
  85. package/ios/renderer/RenderContext.h +17 -0
  86. package/ios/renderer/RenderContext.m +66 -2
  87. package/ios/renderer/RendererFactory.m +6 -0
  88. package/ios/renderer/StrikethroughRenderer.h +6 -0
  89. package/ios/renderer/StrikethroughRenderer.m +40 -0
  90. package/ios/renderer/UnderlineRenderer.h +6 -0
  91. package/ios/renderer/UnderlineRenderer.m +39 -0
  92. package/ios/styles/StyleConfig.h +46 -0
  93. package/ios/styles/StyleConfig.mm +351 -12
  94. package/ios/utils/AccessibilityInfo.h +35 -0
  95. package/ios/utils/AccessibilityInfo.m +24 -0
  96. package/ios/utils/CodeBlockBackground.m +4 -9
  97. package/ios/utils/FontUtils.h +5 -0
  98. package/ios/utils/FontUtils.m +14 -0
  99. package/ios/utils/HTMLGenerator.m +21 -7
  100. package/ios/utils/MarkdownAccessibilityElementBuilder.h +45 -0
  101. package/ios/utils/MarkdownAccessibilityElementBuilder.m +323 -0
  102. package/ios/utils/MarkdownExtractor.m +18 -5
  103. package/ios/utils/ParagraphStyleUtils.h +10 -2
  104. package/ios/utils/ParagraphStyleUtils.m +57 -2
  105. package/ios/utils/PasteboardUtils.h +1 -1
  106. package/ios/utils/PasteboardUtils.m +3 -3
  107. package/lib/module/EnrichedMarkdownText.js +33 -2
  108. package/lib/module/EnrichedMarkdownText.js.map +1 -1
  109. package/lib/module/EnrichedMarkdownTextNativeComponent.ts +83 -3
  110. package/lib/module/index.js +0 -1
  111. package/lib/module/index.js.map +1 -1
  112. package/lib/module/normalizeMarkdownStyle.js +58 -14
  113. package/lib/module/normalizeMarkdownStyle.js.map +1 -1
  114. package/lib/typescript/src/EnrichedMarkdownText.d.ts +85 -3
  115. package/lib/typescript/src/EnrichedMarkdownText.d.ts.map +1 -1
  116. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +75 -1
  117. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts.map +1 -1
  118. package/lib/typescript/src/index.d.ts +2 -3
  119. package/lib/typescript/src/index.d.ts.map +1 -1
  120. package/lib/typescript/src/normalizeMarkdownStyle.d.ts.map +1 -1
  121. package/package.json +1 -1
  122. package/src/EnrichedMarkdownText.tsx +133 -5
  123. package/src/EnrichedMarkdownTextNativeComponent.ts +83 -3
  124. package/src/index.tsx +5 -2
  125. package/src/normalizeMarkdownStyle.ts +46 -0
  126. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextState.cpp +0 -9
  127. package/android/src/main/jni/react/renderer/components/EnrichedMarkdownTextSpec/MarkdownTextState.h +0 -25
@@ -6,18 +6,28 @@ import android.graphics.text.LineBreaker
6
6
  import android.os.Build
7
7
  import android.text.StaticLayout
8
8
  import android.text.TextPaint
9
+ import android.util.Log
9
10
  import com.facebook.react.bridge.ReadableMap
10
11
  import com.facebook.react.uimanager.PixelUtil
11
12
  import com.facebook.yoga.YogaMeasureMode
12
13
  import com.facebook.yoga.YogaMeasureOutput
14
+ import com.swmansion.enriched.markdown.parser.Md4cFlags
15
+ import com.swmansion.enriched.markdown.parser.Parser
16
+ import com.swmansion.enriched.markdown.renderer.Renderer
17
+ import com.swmansion.enriched.markdown.styles.StyleConfig
18
+ import com.swmansion.enriched.markdown.utils.getBooleanOrDefault
19
+ import com.swmansion.enriched.markdown.utils.getMapOrNull
20
+ import com.swmansion.enriched.markdown.utils.getStringOrDefault
13
21
  import java.util.concurrent.ConcurrentHashMap
14
22
  import kotlin.math.ceil
15
23
 
16
24
  /**
17
25
  * Manages text measurements for ShadowNode layout.
18
- * Initial estimate uses raw markdown; accurate measurement after rendering via store().
26
+ * Parses and renders markdown to Spannable at measure time for accurate height calculation.
19
27
  */
20
28
  object MeasurementStore {
29
+ private const val TAG = "MeasurementStore"
30
+
21
31
  private data class PaintParams(
22
32
  val typeface: Typeface,
23
33
  val fontSize: Float,
@@ -28,11 +38,41 @@ object MeasurementStore {
28
38
  val cachedSize: Long,
29
39
  val spannable: CharSequence?,
30
40
  val paintParams: PaintParams,
41
+ val markdownHash: Int,
31
42
  )
32
43
 
33
44
  private val data = ConcurrentHashMap<Int, MeasurementParams>()
34
45
 
46
+ // Store font scaling settings per view ID
47
+ private data class FontScalingSettings(
48
+ val allowFontScaling: Boolean = true,
49
+ val maxFontSizeMultiplier: Float = 0f,
50
+ )
51
+
52
+ private val fontScalingSettings = ConcurrentHashMap<Int, FontScalingSettings>()
53
+
54
+ private fun resolveFontScalingSettings(
55
+ viewId: Int?,
56
+ props: ReadableMap?,
57
+ ): FontScalingSettings {
58
+ val stored = viewId?.let { fontScalingSettings[it] }
59
+ return FontScalingSettings(
60
+ allowFontScaling =
61
+ props?.takeIf { it.hasKey("allowFontScaling") }?.getBoolean("allowFontScaling")
62
+ ?: stored?.allowFontScaling
63
+ ?: true,
64
+ maxFontSizeMultiplier =
65
+ props?.takeIf { it.hasKey("maxFontSizeMultiplier") }?.getDouble("maxFontSizeMultiplier")?.toFloat()
66
+ ?: stored?.maxFontSizeMultiplier
67
+ ?: 0f,
68
+ )
69
+ }
70
+
35
71
  private val measurePaint = TextPaint()
72
+ private val measureRenderer = Renderer()
73
+
74
+ @Volatile
75
+ private var lastKnownFontScale: Float = 1.0f
36
76
 
37
77
  /** Updates measurement with rendered Spannable. Returns true if height changed. */
38
78
  fun store(
@@ -43,10 +83,11 @@ object MeasurementStore {
43
83
  val cached = data[id]
44
84
  val width = cached?.cachedWidth ?: 0f
45
85
  val oldSize = cached?.cachedSize ?: 0L
86
+ val existingHash = cached?.markdownHash ?: 0
46
87
  val paintParams = PaintParams(paint.typeface ?: Typeface.DEFAULT, paint.textSize)
47
88
 
48
89
  val newSize = measure(width, spannable, paint)
49
- data[id] = MeasurementParams(width, newSize, spannable, paintParams)
90
+ data[id] = MeasurementParams(width, newSize, spannable, paintParams, existingHash)
50
91
  return oldSize != newSize
51
92
  }
52
93
 
@@ -63,7 +104,13 @@ object MeasurementStore {
63
104
  heightMode: YogaMeasureMode?,
64
105
  props: ReadableMap?,
65
106
  ): Long {
66
- val size = getMeasureByIdInternal(id, width, props)
107
+ // Early exit for empty markdown
108
+ val markdown = props.getStringOrDefault("markdown", "")
109
+ if (markdown.isEmpty()) {
110
+ return YogaMeasureOutput.make(PixelUtil.toDIPFromPixel(width), 0f)
111
+ }
112
+
113
+ val size = getMeasureByIdInternal(context, id, width, props)
67
114
  val resultHeight = YogaMeasureOutput.getHeight(size)
68
115
 
69
116
  if (heightMode === YogaMeasureMode.AT_MOST) {
@@ -78,47 +125,178 @@ object MeasurementStore {
78
125
  return size
79
126
  }
80
127
 
128
+ fun updateFontScalingSettings(
129
+ viewId: Int,
130
+ allowFontScaling: Boolean,
131
+ maxFontSizeMultiplier: Float,
132
+ ) {
133
+ fontScalingSettings[viewId] = FontScalingSettings(allowFontScaling, maxFontSizeMultiplier)
134
+ }
135
+
136
+ fun clearFontScalingSettings(viewId: Int) {
137
+ fontScalingSettings.remove(viewId)
138
+ }
139
+
81
140
  private fun getMeasureByIdInternal(
141
+ context: Context,
82
142
  id: Int?,
83
143
  width: Float,
84
144
  props: ReadableMap?,
85
145
  ): Long {
86
- val safeId = id ?: return initialMeasure(null, width, props)
87
- val cached = data[safeId] ?: return initialMeasure(safeId, width, props)
146
+ val (allowFontScaling, maxFontSizeMultiplier) = resolveFontScalingSettings(id, props)
88
147
 
89
- // Width changed or not yet measured - re-measure with cached content
90
- if (cached.cachedWidth != width || cached.cachedSize == 0L) {
148
+ val fontScale = checkAndUpdateFontScale(context, allowFontScaling, maxFontSizeMultiplier)
149
+
150
+ val safeId = id ?: return measureAndCache(context, null, width, props, allowFontScaling, fontScale, maxFontSizeMultiplier)
151
+ val cached = data[safeId] ?: return measureAndCache(context, safeId, width, props, allowFontScaling, fontScale, maxFontSizeMultiplier)
152
+
153
+ val currentHash = computePropsHash(props, allowFontScaling, fontScale, maxFontSizeMultiplier)
154
+
155
+ if (cached.markdownHash != currentHash) {
156
+ return measureAndCache(context, safeId, width, props, allowFontScaling, fontScale, maxFontSizeMultiplier)
157
+ }
158
+
159
+ // Width changed - re-measure with cached spannable
160
+ if (cached.cachedWidth != width) {
91
161
  val newSize = measure(width, cached.spannable, cached.paintParams)
92
- data[safeId] = MeasurementParams(width, newSize, cached.spannable, cached.paintParams)
162
+ data[safeId] = cached.copy(cachedWidth = width, cachedSize = newSize)
93
163
  return newSize
94
164
  }
95
165
 
96
166
  return cached.cachedSize
97
167
  }
98
168
 
99
- /** Fast estimate using raw markdown text. */
100
- private fun initialMeasure(
169
+ private fun computePropsHash(
170
+ props: ReadableMap?,
171
+ allowFontScaling: Boolean,
172
+ fontScale: Float,
173
+ maxFontSizeMultiplier: Float,
174
+ ): Int {
175
+ val markdown = props.getStringOrDefault("markdown", "")
176
+ val styleMap = props.getMapOrNull("markdownStyle")
177
+ val md4cFlagsMap = props.getMapOrNull("md4cFlags")
178
+ val allowTrailingMargin = props.getBooleanOrDefault("allowTrailingMargin", false)
179
+ var result = markdown.hashCode()
180
+ result = 31 * result + (styleMap?.hashCode() ?: 0)
181
+ result = 31 * result + (md4cFlagsMap?.hashCode() ?: 0)
182
+ result = 31 * result + fontScale.toBits()
183
+ result = 31 * result + allowFontScaling.hashCode()
184
+ result = 31 * result + maxFontSizeMultiplier.toBits()
185
+ result = 31 * result + allowTrailingMargin.hashCode()
186
+ return result
187
+ }
188
+
189
+ private fun checkAndUpdateFontScale(
190
+ context: Context,
191
+ allowFontScaling: Boolean,
192
+ maxFontSizeMultiplier: Float,
193
+ ): Float {
194
+ if (!allowFontScaling) {
195
+ // Clear cache if we switched from scaling to non-scaling
196
+ if (lastKnownFontScale != 1.0f) {
197
+ lastKnownFontScale = 1.0f
198
+ data.clear()
199
+ }
200
+ return 1.0f
201
+ }
202
+
203
+ var currentFontScale = context.resources.configuration.fontScale
204
+
205
+ if (maxFontSizeMultiplier >= 1.0f && currentFontScale > maxFontSizeMultiplier) {
206
+ currentFontScale = maxFontSizeMultiplier
207
+ }
208
+ if (currentFontScale != lastKnownFontScale) {
209
+ lastKnownFontScale = currentFontScale
210
+ data.clear()
211
+ }
212
+ return currentFontScale
213
+ }
214
+
215
+ private fun measureAndCache(
216
+ context: Context,
101
217
  id: Int?,
102
218
  width: Float,
103
219
  props: ReadableMap?,
220
+ allowFontScaling: Boolean,
221
+ fontScale: Float,
222
+ maxFontSizeMultiplier: Float,
104
223
  ): Long {
105
- val markdown = props?.getString("markdown")?.ifEmpty { "I" } ?: "I"
106
- val fontSize = getInitialFontSize(props)
107
- val paintParams = PaintParams(Typeface.DEFAULT, fontSize)
224
+ // 1. Extract Props & Setup
225
+ val markdown = props.getStringOrDefault("markdown", "")
226
+ val styleMap = props.getMapOrNull("markdownStyle")
227
+ val md4cFlags = Md4cFlags(underline = props.getMapOrNull("md4cFlags").getBooleanOrDefault("underline", false))
228
+
229
+ val fontSize = getInitialFontSize(styleMap, context, allowFontScaling, fontScale, maxFontSizeMultiplier)
230
+ val propsHash = computePropsHash(props, allowFontScaling, fontScale, maxFontSizeMultiplier)
231
+
232
+ // 2. Render & Measure
233
+ val spannable = tryRenderMarkdown(markdown, styleMap, context, md4cFlags, allowFontScaling, maxFontSizeMultiplier)
234
+ val textToMeasure = spannable ?: markdown
235
+ val (size, _) = measureWithLayout(width, textToMeasure, measurePaint)
108
236
 
109
- val size = measure(width, markdown, paintParams)
237
+ // 3. Calculate Margin
238
+ val allowTrailingMargin = props.getBooleanOrDefault("allowTrailingMargin", false)
239
+ val marginBottom =
240
+ if (allowTrailingMargin && spannable != null) {
241
+ PixelUtil.toDIPFromPixel(measureRenderer.getLastElementMarginBottom())
242
+ } else {
243
+ 0f
244
+ }
245
+
246
+ // 4. Finalize Height
247
+ val currentWidth = YogaMeasureOutput.getWidth(size)
248
+ val currentHeight = YogaMeasureOutput.getHeight(size)
249
+ val adjustedSize = YogaMeasureOutput.make(currentWidth, currentHeight + marginBottom)
110
250
 
111
251
  if (id != null) {
112
- data[id] = MeasurementParams(width, size, markdown, paintParams)
252
+ data[id] = MeasurementParams(width, adjustedSize, textToMeasure, PaintParams(Typeface.DEFAULT, fontSize), propsHash)
113
253
  }
114
254
 
115
- return size
255
+ return adjustedSize
116
256
  }
117
257
 
118
- private fun getInitialFontSize(props: ReadableMap?): Float {
119
- val styleMap = props?.getMap("markdownStyle")
120
- val fontSize = styleMap?.getMap("paragraph")?.getDouble("fontSize")?.toFloat() ?: 16f
121
- return ceil(PixelUtil.toPixelFromSP(fontSize))
258
+ private fun tryRenderMarkdown(
259
+ markdown: String,
260
+ styleMap: ReadableMap?,
261
+ context: Context,
262
+ md4cFlags: Md4cFlags,
263
+ allowFontScaling: Boolean,
264
+ maxFontSizeMultiplier: Float,
265
+ ): CharSequence? {
266
+ if (styleMap == null) return null
267
+
268
+ return try {
269
+ val ast = Parser.shared.parseMarkdown(markdown, md4cFlags) ?: return null
270
+ val style = StyleConfig(styleMap, context, allowFontScaling, maxFontSizeMultiplier)
271
+ measureRenderer.configure(style, context)
272
+ measureRenderer.renderDocument(ast, null)
273
+ } catch (e: Exception) {
274
+ Log.w(TAG, "Failed to render markdown for measurement, falling back to raw text", e)
275
+ null
276
+ }
277
+ }
278
+
279
+ private fun getInitialFontSize(
280
+ styleMap: ReadableMap?,
281
+ context: Context,
282
+ allowFontScaling: Boolean,
283
+ fontScale: Float,
284
+ maxFontSizeMultiplier: Float,
285
+ ): Float {
286
+ val fontSizeSp = styleMap?.getMap("paragraph")?.getDouble("fontSize")?.toFloat() ?: 16f
287
+ val density = context.resources.displayMetrics.density
288
+
289
+ if (!allowFontScaling) {
290
+ return ceil(fontSizeSp * density)
291
+ }
292
+
293
+ val cappedFontScale =
294
+ if (maxFontSizeMultiplier >= 1.0f && fontScale > maxFontSizeMultiplier) {
295
+ maxFontSizeMultiplier
296
+ } else {
297
+ fontScale
298
+ }
299
+ return ceil(fontSizeSp * cappedFontScale * density)
122
300
  }
123
301
 
124
302
  private fun measure(
@@ -157,9 +335,51 @@ object MeasurementStore {
157
335
  val layout = builder.build()
158
336
  val measuredHeight = layout.height.toFloat()
159
337
 
338
+ // Calculate actual content width (widest line)
339
+ val measuredWidth = (0 until layout.lineCount).maxOfOrNull { layout.getLineWidth(it) } ?: 0f
340
+
160
341
  return YogaMeasureOutput.make(
161
- PixelUtil.toDIPFromPixel(maxWidth),
342
+ PixelUtil.toDIPFromPixel(ceil(measuredWidth)),
162
343
  PixelUtil.toDIPFromPixel(measuredHeight),
163
344
  )
164
345
  }
346
+
347
+ /**
348
+ * Measures text and returns both the size and the layout for calculating last line descent.
349
+ */
350
+ private fun measureWithLayout(
351
+ maxWidth: Float,
352
+ text: CharSequence?,
353
+ paint: TextPaint,
354
+ ): Pair<Long, StaticLayout> {
355
+ val content = text ?: ""
356
+ val widthPx = ceil(maxWidth).toInt().coerceAtLeast(1)
357
+
358
+ val layout =
359
+ StaticLayout.Builder
360
+ .obtain(content, 0, content.length, paint, widthPx)
361
+ .setIncludePad(false)
362
+ .setLineSpacing(0f, 1f)
363
+ .apply {
364
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
365
+ setBreakStrategy(LineBreaker.BREAK_STRATEGY_HIGH_QUALITY)
366
+ }
367
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
368
+ setUseLineSpacingFromFallbacks(true)
369
+ }
370
+ }.build()
371
+
372
+ // Find the widest line to get the actual content width
373
+ val maxLineWidth =
374
+ (0 until layout.lineCount)
375
+ .maxOfOrNull { layout.getLineWidth(it) } ?: 0f
376
+
377
+ val size =
378
+ YogaMeasureOutput.make(
379
+ PixelUtil.toDIPFromPixel(ceil(maxLineWidth)),
380
+ PixelUtil.toDIPFromPixel(layout.height.toFloat()),
381
+ )
382
+
383
+ return size to layout
384
+ }
165
385
  }
@@ -0,0 +1,279 @@
1
+ package com.swmansion.enriched.markdown.accessibility
2
+
3
+ import android.graphics.Rect
4
+ import android.os.Bundle
5
+ import android.text.Spanned
6
+ import android.widget.TextView
7
+ import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
8
+ import androidx.customview.widget.ExploreByTouchHelper
9
+ import com.swmansion.enriched.markdown.spans.BaseListSpan
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.UnorderedListSpan
15
+
16
+ class MarkdownAccessibilityHelper(
17
+ private val textView: TextView,
18
+ ) : ExploreByTouchHelper(textView) {
19
+ private var accessibilityItems: List<AccessibilityItem> = emptyList()
20
+ private var needsRebuild = false
21
+ private var lastLayoutHashCode = 0
22
+
23
+ data class AccessibilityItem(
24
+ val id: Int,
25
+ val text: String,
26
+ val start: Int,
27
+ val end: Int,
28
+ val headingLevel: Int = 0,
29
+ val linkUrl: String? = null,
30
+ val listInfo: ListItemInfo? = null,
31
+ val imageAltText: String? = null,
32
+ ) {
33
+ val isHeading get() = headingLevel > 0
34
+ val isLink get() = linkUrl != null
35
+ val isListItem get() = listInfo != null
36
+ val isImage get() = imageAltText != null
37
+ }
38
+
39
+ data class ListItemInfo(
40
+ val isOrdered: Boolean,
41
+ val itemNumber: Int,
42
+ val depth: Int,
43
+ )
44
+
45
+ private data class SpanRange(
46
+ val start: Int,
47
+ val end: Int,
48
+ val headingLevel: Int = 0,
49
+ val linkUrl: String? = null,
50
+ val imageAltText: String? = null,
51
+ )
52
+
53
+ fun invalidateAccessibilityItems() {
54
+ needsRebuild = true
55
+ rebuildIfNeeded()
56
+ invalidateRoot()
57
+ }
58
+
59
+ private fun rebuildIfNeeded() {
60
+ val layout = textView.layout ?: return
61
+ if (needsRebuild || lastLayoutHashCode != layout.hashCode()) {
62
+ accessibilityItems = buildAccessibilityItems()
63
+ needsRebuild = false
64
+ lastLayoutHashCode = layout.hashCode()
65
+ }
66
+ }
67
+
68
+ private fun buildAccessibilityItems(): List<AccessibilityItem> {
69
+ val spanned = textView.text as? Spanned ?: return emptyList()
70
+ if (spanned.isEmpty()) return emptyList()
71
+
72
+ val items = mutableListOf<AccessibilityItem>()
73
+ var nextId = 0
74
+
75
+ // Consolidated span collection using functional mapping
76
+ val semanticSpans =
77
+ (
78
+ spanned.getSpans(0, spanned.length, HeadingSpan::class.java).map {
79
+ SpanRange(spanned.getSpanStart(it), spanned.getSpanEnd(it), headingLevel = it.level)
80
+ } +
81
+ spanned.getSpans(0, spanned.length, LinkSpan::class.java).map {
82
+ SpanRange(spanned.getSpanStart(it), spanned.getSpanEnd(it), linkUrl = it.url)
83
+ } +
84
+ spanned.getSpans(0, spanned.length, ImageSpan::class.java).map {
85
+ SpanRange(spanned.getSpanStart(it), spanned.getSpanEnd(it), imageAltText = it.altText)
86
+ }
87
+ ).sortedBy { it.start }
88
+
89
+ var currentPos = 0
90
+ for (span in semanticSpans) {
91
+ if (span.start < currentPos) continue
92
+
93
+ if (currentPos < span.start) {
94
+ nextId = addTextSegments(items, spanned, currentPos, span.start, nextId)
95
+ }
96
+
97
+ val content = span.imageAltText?.ifEmpty { "Image" } ?: spanned.substring(span.start, span.end).trim()
98
+
99
+ if (content.isNotEmpty()) {
100
+ val listContext =
101
+ if (span.headingLevel > 0 || span.imageAltText != null) {
102
+ null
103
+ } else {
104
+ getListInfoAt(spanned, span.start, span.linkUrl == null)
105
+ }
106
+ items.add(
107
+ AccessibilityItem(
108
+ nextId++,
109
+ content,
110
+ span.start,
111
+ span.end,
112
+ span.headingLevel,
113
+ span.linkUrl,
114
+ listContext,
115
+ span.imageAltText,
116
+ ),
117
+ )
118
+ }
119
+ currentPos = span.end
120
+ }
121
+
122
+ if (currentPos < spanned.length) addTextSegments(items, spanned, currentPos, spanned.length, nextId)
123
+ return items.ifEmpty { listOf(AccessibilityItem(0, spanned.toString().trim(), 0, spanned.length)) }
124
+ }
125
+
126
+ private fun getListInfoAt(
127
+ spanned: Spanned,
128
+ position: Int,
129
+ requireStart: Boolean,
130
+ ): ListItemInfo? {
131
+ val deepest = spanned.getSpans(position, position + 1, BaseListSpan::class.java).maxByOrNull { it.depth } ?: return null
132
+ if (requireStart) {
133
+ val start = spanned.getSpanStart(deepest)
134
+ val firstChar = (start until minOf(start + 10, spanned.length)).firstOrNull { !spanned[it].isWhitespace() } ?: start
135
+ if (position > firstChar + 1) return null
136
+ }
137
+ return ListItemInfo(deepest is OrderedListSpan, (deepest as? OrderedListSpan)?.itemNumber ?: 0, deepest.depth)
138
+ }
139
+
140
+ private fun addTextSegments(
141
+ items: MutableList<AccessibilityItem>,
142
+ spanned: Spanned,
143
+ start: Int,
144
+ end: Int,
145
+ startId: Int,
146
+ ): Int {
147
+ var cid = startId
148
+ val layout = textView.layout ?: return cid
149
+ for (line in layout.getLineForOffset(start)..layout.getLineForOffset(end)) {
150
+ val s = maxOf(start, layout.getLineStart(line))
151
+ val e = minOf(end, layout.getLineEnd(line))
152
+ if (s >= e) continue
153
+
154
+ val raw = spanned.substring(s, e)
155
+ val first = raw.indexOfFirst { !it.isWhitespace() }
156
+ if (first != -1) {
157
+ val last = raw.indexOfLast { !it.isWhitespace() }
158
+ val absoluteStart = s + first
159
+ items.add(
160
+ AccessibilityItem(cid++, raw.trim(), absoluteStart, s + last + 1, listInfo = getListInfoAt(spanned, absoluteStart, true)),
161
+ )
162
+ }
163
+ }
164
+ return cid
165
+ }
166
+
167
+ override fun getVirtualViewAt(
168
+ x: Float,
169
+ y: Float,
170
+ ): Int {
171
+ rebuildIfNeeded()
172
+ val offset = getOffsetForPosition(x, y)
173
+ return accessibilityItems
174
+ .filter { offset in it.start until it.end }
175
+ .minByOrNull {
176
+ when {
177
+ it.isLink -> 0
178
+ it.isImage -> 1
179
+ it.isHeading -> 2
180
+ it.isListItem -> 3
181
+ else -> 4
182
+ }
183
+ }?.id ?: HOST_ID
184
+ }
185
+
186
+ override fun getVisibleVirtualViews(ids: MutableList<Int>) {
187
+ rebuildIfNeeded()
188
+ accessibilityItems.forEach { ids.add(it.id) }
189
+ }
190
+
191
+ override fun onPopulateNodeForVirtualView(
192
+ id: Int,
193
+ node: AccessibilityNodeInfoCompat,
194
+ ) {
195
+ val item = accessibilityItems.find { it.id == id } ?: return
196
+ node.apply {
197
+ text = item.text
198
+ contentDescription = item.text
199
+ isFocusable = true
200
+ isScreenReaderFocusable = true
201
+ setBoundsInParent(getBoundsForRange(item.start, item.end))
202
+
203
+ item.listInfo?.let { info ->
204
+ setCollectionItemInfo(
205
+ AccessibilityNodeInfoCompat.CollectionItemInfoCompat.obtain(info.itemNumber - 1, 1, 0, 1, false, false),
206
+ )
207
+ }
208
+
209
+ val prefix = if (item.listInfo?.depth ?: 0 > 0) "nested " else ""
210
+ val listText = if (item.listInfo?.isOrdered == true) "list item ${item.listInfo.itemNumber}" else "bullet point"
211
+
212
+ when {
213
+ item.isHeading -> {
214
+ isHeading = true
215
+ contentDescription = "${item.text}, heading level ${item.headingLevel}"
216
+ }
217
+
218
+ item.isImage -> {
219
+ roleDescription = "image"
220
+ }
221
+
222
+ item.isLink -> {
223
+ isClickable = true
224
+ addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK)
225
+ roleDescription = item.listInfo?.let { "link, $prefix$listText" } ?: "link"
226
+ }
227
+
228
+ item.isListItem -> {
229
+ roleDescription = "$prefix$listText"
230
+ }
231
+ }
232
+ }
233
+ }
234
+
235
+ override fun onPerformActionForVirtualView(
236
+ id: Int,
237
+ action: Int,
238
+ args: Bundle?,
239
+ ): Boolean {
240
+ val item = accessibilityItems.find { it.id == id } ?: return false
241
+ if (action == AccessibilityNodeInfoCompat.ACTION_CLICK && item.isLink) {
242
+ (textView.text as? Spanned)?.getSpans(item.start, item.end, LinkSpan::class.java)?.firstOrNull()?.onClick(textView)
243
+ ?: return false
244
+ return true
245
+ }
246
+ return false
247
+ }
248
+
249
+ private fun getOffsetForPosition(
250
+ x: Float,
251
+ y: Float,
252
+ ): Int {
253
+ val layout = textView.layout ?: return 0
254
+ return layout.getOffsetForHorizontal(layout.getLineForVertical(y.toInt()).coerceIn(0, layout.lineCount - 1), x)
255
+ }
256
+
257
+ private fun getBoundsForRange(
258
+ start: Int,
259
+ end: Int,
260
+ ): Rect {
261
+ val layout = textView.layout ?: return Rect()
262
+ val line = layout.getLineForOffset(start)
263
+ val left = layout.getPrimaryHorizontal(start).toInt() + textView.paddingLeft
264
+ val right =
265
+ if (layout.getPrimaryHorizontal(end) <=
266
+ layout.getPrimaryHorizontal(start)
267
+ ) {
268
+ layout.getLineRight(line).toInt() + textView.paddingLeft
269
+ } else {
270
+ layout.getPrimaryHorizontal(end).toInt() + textView.paddingLeft
271
+ }
272
+ return Rect(
273
+ left,
274
+ layout.getLineTop(line) + textView.paddingTop,
275
+ right,
276
+ layout.getLineBottom(layout.getLineForOffset(end)) + textView.paddingTop,
277
+ )
278
+ }
279
+ }
@@ -0,0 +1,23 @@
1
+ package com.swmansion.enriched.markdown.events
2
+
3
+ import com.facebook.react.bridge.Arguments
4
+ import com.facebook.react.bridge.WritableMap
5
+ import com.facebook.react.uimanager.events.Event
6
+
7
+ class LinkLongPressEvent(
8
+ surfaceId: Int,
9
+ viewId: Int,
10
+ private val url: String,
11
+ ) : Event<LinkLongPressEvent>(surfaceId, viewId) {
12
+ override fun getEventName(): String = EVENT_NAME
13
+
14
+ override fun getEventData(): WritableMap {
15
+ val eventData: WritableMap = Arguments.createMap()
16
+ eventData.putString("url", url)
17
+ return eventData
18
+ }
19
+
20
+ companion object {
21
+ const val EVENT_NAME: String = "onLinkLongPress"
22
+ }
23
+ }
@@ -15,6 +15,8 @@ data class MarkdownASTNode(
15
15
  LineBreak,
16
16
  Strong,
17
17
  Emphasis,
18
+ Strikethrough,
19
+ Underline,
18
20
  Code,
19
21
  Image,
20
22
  Blockquote,
@@ -2,6 +2,14 @@ package com.swmansion.enriched.markdown.parser
2
2
 
3
3
  import android.util.Log
4
4
 
5
+ data class Md4cFlags(
6
+ val underline: Boolean = false,
7
+ ) {
8
+ companion object {
9
+ val DEFAULT = Md4cFlags()
10
+ }
11
+ }
12
+
5
13
  class Parser {
6
14
  companion object {
7
15
  init {
@@ -17,7 +25,10 @@ class Parser {
17
25
  }
18
26
 
19
27
  @JvmStatic
20
- private external fun nativeParseMarkdown(markdown: String): MarkdownASTNode?
28
+ private external fun nativeParseMarkdown(
29
+ markdown: String,
30
+ flags: Md4cFlags,
31
+ ): MarkdownASTNode?
21
32
 
22
33
  /**
23
34
  * Shared parser instance. Parser is stateless and thread-safe, so it can be reused
@@ -26,13 +37,16 @@ class Parser {
26
37
  val shared: Parser = Parser()
27
38
  }
28
39
 
29
- fun parseMarkdown(markdown: String): MarkdownASTNode? {
40
+ fun parseMarkdown(
41
+ markdown: String,
42
+ flags: Md4cFlags = Md4cFlags.DEFAULT,
43
+ ): MarkdownASTNode? {
30
44
  if (markdown.isBlank()) {
31
45
  return null
32
46
  }
33
47
 
34
48
  try {
35
- val ast = nativeParseMarkdown(markdown)
49
+ val ast = nativeParseMarkdown(markdown, flags)
36
50
 
37
51
  if (ast != null) {
38
52
  return ast