react-native-enriched-markdown-scaffold 1.0.2-scaffold.7 → 1.0.2-scaffold.9

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 (50) hide show
  1. package/android/build.gradle +2 -0
  2. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +24 -0
  3. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownInternalText.kt +22 -0
  4. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +22 -0
  5. package/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +46 -8
  6. package/android/src/main/java/com/swmansion/enriched/markdown/styles/InlineMathStyle.kt +16 -1
  7. package/android/src/main/java/com/swmansion/enriched/markdown/styles/MathStyle.kt +3 -0
  8. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/HeadlessActionMode.kt +88 -0
  9. package/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt +1 -1
  10. package/android/src/math/java/com/swmansion/enriched/markdown/renderer/MathInlineRenderer.kt +1 -0
  11. package/android/src/math/java/com/swmansion/enriched/markdown/spans/MathInlineSpan.kt +13 -6
  12. package/android/src/math/java/com/swmansion/enriched/markdown/spans/MathMeasureHelper.kt +2 -1
  13. package/android/src/math/java/com/swmansion/enriched/markdown/views/MathContainerView.kt +11 -4
  14. package/ios/attachments/ENRMMathInlineAttachment+macOS.m +2 -1
  15. package/ios/attachments/ENRMMathInlineAttachment.h +3 -0
  16. package/ios/attachments/ENRMMathInlineAttachment.m +2 -1
  17. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h +24 -0
  18. package/ios/internals/MeasurementCache.h +3 -2
  19. package/ios/math/ENRMRaTeXBridge.swift +23 -5
  20. package/ios/renderer/ENRMMathInlineRenderer.m +1 -0
  21. package/ios/styles/StyleConfig.h +4 -0
  22. package/ios/styles/StyleConfig.mm +24 -0
  23. package/ios/utils/StylePropsUtils.h +10 -0
  24. package/ios/views/ENRMMathContainerView.m +4 -2
  25. package/lib/module/EnrichedMarkdownNativeComponent.ts +2 -0
  26. package/lib/module/EnrichedMarkdownTextNativeComponent.ts +2 -0
  27. package/lib/module/normalizeMarkdownStyle.js +4 -2
  28. package/lib/module/normalizeMarkdownStyle.js.map +1 -1
  29. package/lib/module/normalizeMarkdownStyle.web.js +4 -2
  30. package/lib/module/normalizeMarkdownStyle.web.js.map +1 -1
  31. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts +2 -0
  32. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts.map +1 -1
  33. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +2 -0
  34. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts.map +1 -1
  35. package/lib/typescript/src/normalizeMarkdownStyle.d.ts.map +1 -1
  36. package/lib/typescript/src/normalizeMarkdownStyle.web.d.ts.map +1 -1
  37. package/lib/typescript/src/types/MarkdownStyle.d.ts +22 -0
  38. package/lib/typescript/src/types/MarkdownStyle.d.ts.map +1 -1
  39. package/lib/typescript/src/types/MarkdownStyleInternal.d.ts +2 -0
  40. package/lib/typescript/src/types/MarkdownStyleInternal.d.ts.map +1 -1
  41. package/lib/typescript/src/types/MarkdownTextProps.d.ts +4 -0
  42. package/lib/typescript/src/types/MarkdownTextProps.d.ts.map +1 -1
  43. package/package.json +1 -1
  44. package/src/EnrichedMarkdownNativeComponent.ts +2 -0
  45. package/src/EnrichedMarkdownTextNativeComponent.ts +2 -0
  46. package/src/normalizeMarkdownStyle.ts +2 -0
  47. package/src/normalizeMarkdownStyle.web.ts +2 -0
  48. package/src/types/MarkdownStyle.ts +22 -0
  49. package/src/types/MarkdownStyleInternal.ts +2 -0
  50. package/src/types/MarkdownTextProps.ts +4 -0
@@ -270,6 +270,8 @@ dependencies {
270
270
  implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
271
271
  // Baseline Profile installer for AOT compilation hints
272
272
  implementation "androidx.profileinstaller:profileinstaller:1.3.1"
273
+ // AppCompatTextView draws emoji through EmojiCompat; measurement must apply it too
274
+ implementation "androidx.emoji2:emoji2:1.4.0"
273
275
  // LaTeX math rendering (optional — set enrichedMarkdown.enableMath=false in gradle.properties to exclude)
274
276
  if (enableMath) {
275
277
  implementation("io.github.erweixin:ratex-android:0.1.10")
@@ -1761,6 +1761,7 @@ struct EnrichedMarkdownMarkdownStyleMathStruct {
1761
1761
  Float marginBottom{0.0};
1762
1762
  std::string textAlign{};
1763
1763
  std::string fontWeight{};
1764
+ Float glyphOverflow{0.0};
1764
1765
 
1765
1766
 
1766
1767
  #ifdef RN_SERIALIZABLE_STATE
@@ -1776,6 +1777,7 @@ struct EnrichedMarkdownMarkdownStyleMathStruct {
1776
1777
  result["marginBottom"] = marginBottom;
1777
1778
  result["textAlign"] = textAlign;
1778
1779
  result["fontWeight"] = fontWeight;
1780
+ result["glyphOverflow"] = glyphOverflow;
1779
1781
  return result;
1780
1782
  }
1781
1783
  #endif
@@ -1816,6 +1818,10 @@ static inline void fromRawValue(const PropsParserContext& context, const RawValu
1816
1818
  if (tmp_fontWeight != map.end()) {
1817
1819
  fromRawValue(context, tmp_fontWeight->second, result.fontWeight);
1818
1820
  }
1821
+ auto tmp_glyphOverflow = map.find("glyphOverflow");
1822
+ if (tmp_glyphOverflow != map.end()) {
1823
+ fromRawValue(context, tmp_glyphOverflow->second, result.glyphOverflow);
1824
+ }
1819
1825
  }
1820
1826
 
1821
1827
  static inline std::string toString(const EnrichedMarkdownMarkdownStyleMathStruct &value) {
@@ -1832,6 +1838,7 @@ struct EnrichedMarkdownMarkdownStyleInlineMathStruct {
1832
1838
  SharedColor color{};
1833
1839
  Float paddingVertical{0.0};
1834
1840
  std::string fontWeight{};
1841
+ Float glyphOverflow{0.0};
1835
1842
 
1836
1843
 
1837
1844
  #ifdef RN_SERIALIZABLE_STATE
@@ -1842,6 +1849,7 @@ struct EnrichedMarkdownMarkdownStyleInlineMathStruct {
1842
1849
  result["color"] = ::facebook::react::toDynamic(color);
1843
1850
  result["paddingVertical"] = paddingVertical;
1844
1851
  result["fontWeight"] = fontWeight;
1852
+ result["glyphOverflow"] = glyphOverflow;
1845
1853
  return result;
1846
1854
  }
1847
1855
  #endif
@@ -1862,6 +1870,10 @@ static inline void fromRawValue(const PropsParserContext& context, const RawValu
1862
1870
  if (tmp_fontWeight != map.end()) {
1863
1871
  fromRawValue(context, tmp_fontWeight->second, result.fontWeight);
1864
1872
  }
1873
+ auto tmp_glyphOverflow = map.find("glyphOverflow");
1874
+ if (tmp_glyphOverflow != map.end()) {
1875
+ fromRawValue(context, tmp_glyphOverflow->second, result.glyphOverflow);
1876
+ }
1865
1877
  }
1866
1878
 
1867
1879
  static inline std::string toString(const EnrichedMarkdownMarkdownStyleInlineMathStruct &value) {
@@ -5807,6 +5819,7 @@ struct EnrichedMarkdownTextMarkdownStyleMathStruct {
5807
5819
  Float marginBottom{0.0};
5808
5820
  std::string textAlign{};
5809
5821
  std::string fontWeight{};
5822
+ Float glyphOverflow{0.0};
5810
5823
 
5811
5824
 
5812
5825
  #ifdef RN_SERIALIZABLE_STATE
@@ -5822,6 +5835,7 @@ struct EnrichedMarkdownTextMarkdownStyleMathStruct {
5822
5835
  result["marginBottom"] = marginBottom;
5823
5836
  result["textAlign"] = textAlign;
5824
5837
  result["fontWeight"] = fontWeight;
5838
+ result["glyphOverflow"] = glyphOverflow;
5825
5839
  return result;
5826
5840
  }
5827
5841
  #endif
@@ -5862,6 +5876,10 @@ static inline void fromRawValue(const PropsParserContext& context, const RawValu
5862
5876
  if (tmp_fontWeight != map.end()) {
5863
5877
  fromRawValue(context, tmp_fontWeight->second, result.fontWeight);
5864
5878
  }
5879
+ auto tmp_glyphOverflow = map.find("glyphOverflow");
5880
+ if (tmp_glyphOverflow != map.end()) {
5881
+ fromRawValue(context, tmp_glyphOverflow->second, result.glyphOverflow);
5882
+ }
5865
5883
  }
5866
5884
 
5867
5885
  static inline std::string toString(const EnrichedMarkdownTextMarkdownStyleMathStruct &value) {
@@ -5878,6 +5896,7 @@ struct EnrichedMarkdownTextMarkdownStyleInlineMathStruct {
5878
5896
  SharedColor color{};
5879
5897
  Float paddingVertical{0.0};
5880
5898
  std::string fontWeight{};
5899
+ Float glyphOverflow{0.0};
5881
5900
 
5882
5901
 
5883
5902
  #ifdef RN_SERIALIZABLE_STATE
@@ -5888,6 +5907,7 @@ struct EnrichedMarkdownTextMarkdownStyleInlineMathStruct {
5888
5907
  result["color"] = ::facebook::react::toDynamic(color);
5889
5908
  result["paddingVertical"] = paddingVertical;
5890
5909
  result["fontWeight"] = fontWeight;
5910
+ result["glyphOverflow"] = glyphOverflow;
5891
5911
  return result;
5892
5912
  }
5893
5913
  #endif
@@ -5908,6 +5928,10 @@ static inline void fromRawValue(const PropsParserContext& context, const RawValu
5908
5928
  if (tmp_fontWeight != map.end()) {
5909
5929
  fromRawValue(context, tmp_fontWeight->second, result.fontWeight);
5910
5930
  }
5931
+ auto tmp_glyphOverflow = map.find("glyphOverflow");
5932
+ if (tmp_glyphOverflow != map.end()) {
5933
+ fromRawValue(context, tmp_glyphOverflow->second, result.glyphOverflow);
5934
+ }
5911
5935
  }
5912
5936
 
5913
5937
  static inline std::string toString(const EnrichedMarkdownTextMarkdownStyleInlineMathStruct &value) {
@@ -13,6 +13,7 @@ import com.swmansion.enriched.markdown.spoiler.SpoilerCapable
13
13
  import com.swmansion.enriched.markdown.spoiler.SpoilerOverlay
14
14
  import com.swmansion.enriched.markdown.spoiler.SpoilerOverlayDrawer
15
15
  import com.swmansion.enriched.markdown.utils.text.interaction.CheckboxTouchHelper
16
+ import com.swmansion.enriched.markdown.utils.text.view.HeadlessActionMode
16
17
  import com.swmansion.enriched.markdown.utils.text.view.LinkLongPressMovementMethod
17
18
  import com.swmansion.enriched.markdown.utils.text.view.SelectionMenuConfig
18
19
  import com.swmansion.enriched.markdown.utils.text.view.applySelectableState
@@ -20,6 +21,7 @@ import com.swmansion.enriched.markdown.utils.text.view.cancelJSTouchForCheckboxT
20
21
  import com.swmansion.enriched.markdown.utils.text.view.cancelJSTouchForLinkTap
21
22
  import com.swmansion.enriched.markdown.utils.text.view.clearTextSelection
22
23
  import com.swmansion.enriched.markdown.utils.text.view.createSelectionActionModeCallback
24
+ import com.swmansion.enriched.markdown.utils.text.view.isRomSelectionPopupShower
23
25
  import com.swmansion.enriched.markdown.utils.text.view.reallowParentInterceptIfLinkReleased
24
26
  import com.swmansion.enriched.markdown.utils.text.view.setupAsMarkdownTextView
25
27
  import com.swmansion.enriched.markdown.views.BlockSegmentView
@@ -128,6 +130,26 @@ class EnrichedMarkdownInternalText
128
130
  onSelectionChangedCallback?.invoke(this, selEnd > selStart)
129
131
  }
130
132
 
133
+ // See HeadlessActionMode: with the menu hidden, no toolbar window is created.
134
+ override fun startActionMode(
135
+ callback: ActionMode.Callback?,
136
+ type: Int,
137
+ ): ActionMode? {
138
+ if (contextMenuHidden && type == ActionMode.TYPE_FLOATING && callback != null) {
139
+ return HeadlessActionMode.start(this, callback)
140
+ }
141
+ return super.startActionMode(callback, type)
142
+ }
143
+
144
+ // See isRomSelectionPopupShower: MIUI shows its own selection popup this way.
145
+ override fun postDelayed(
146
+ action: Runnable?,
147
+ delayMillis: Long,
148
+ ): Boolean {
149
+ if (contextMenuHidden && isRomSelectionPopupShower(action)) return false
150
+ return super.postDelayed(action, delayMillis)
151
+ }
152
+
131
153
  /** Clears the selection and ends its ActionMode. */
132
154
  fun clearSelection() {
133
155
  clearTextSelection()
@@ -28,6 +28,7 @@ import com.swmansion.enriched.markdown.utils.common.emitLinkLayout
28
28
  import com.swmansion.enriched.markdown.utils.common.emitSelectionChange
29
29
  import com.swmansion.enriched.markdown.utils.text.TailFadeInAnimator
30
30
  import com.swmansion.enriched.markdown.utils.text.interaction.CheckboxTouchHelper
31
+ import com.swmansion.enriched.markdown.utils.text.view.HeadlessActionMode
31
32
  import com.swmansion.enriched.markdown.utils.text.view.LinkLayoutCollector
32
33
  import com.swmansion.enriched.markdown.utils.text.view.LinkLayoutFragment
33
34
  import com.swmansion.enriched.markdown.utils.text.view.LinkLongPressMovementMethod
@@ -43,6 +44,7 @@ import com.swmansion.enriched.markdown.utils.text.view.createSelectionActionMode
43
44
  import com.swmansion.enriched.markdown.utils.text.view.currentTextSelection
44
45
  import com.swmansion.enriched.markdown.utils.text.view.emitLinkLongPressEvent
45
46
  import com.swmansion.enriched.markdown.utils.text.view.emitLinkPressEvent
47
+ import com.swmansion.enriched.markdown.utils.text.view.isRomSelectionPopupShower
46
48
  import com.swmansion.enriched.markdown.utils.text.view.reallowParentInterceptIfLinkReleased
47
49
  import com.swmansion.enriched.markdown.utils.text.view.setupAsMarkdownTextView
48
50
  import java.util.concurrent.Executors
@@ -336,6 +338,26 @@ class EnrichedMarkdownText
336
338
  selectionReporter?.enabled = enabled
337
339
  }
338
340
 
341
+ // See HeadlessActionMode: with the menu hidden, no toolbar window is created.
342
+ override fun startActionMode(
343
+ callback: ActionMode.Callback?,
344
+ type: Int,
345
+ ): ActionMode? {
346
+ if (contextMenuHidden && type == ActionMode.TYPE_FLOATING && callback != null) {
347
+ return HeadlessActionMode.start(this, callback)
348
+ }
349
+ return super.startActionMode(callback, type)
350
+ }
351
+
352
+ // See isRomSelectionPopupShower: MIUI shows its own selection popup this way.
353
+ override fun postDelayed(
354
+ action: Runnable?,
355
+ delayMillis: Long,
356
+ ): Boolean {
357
+ if (contextMenuHidden && isRomSelectionPopupShower(action)) return false
358
+ return super.postDelayed(action, delayMillis)
359
+ }
360
+
339
361
  /** Clears the selection (ending its ActionMode) and reports `active: false`. */
340
362
  fun clearSelection() {
341
363
  clearTextSelection()
@@ -7,6 +7,7 @@ import android.text.SpannableString
7
7
  import android.text.StaticLayout
8
8
  import android.text.TextPaint
9
9
  import android.util.Log
10
+ import androidx.emoji2.text.EmojiCompat
10
11
  import com.facebook.react.bridge.ReadableMap
11
12
  import com.facebook.react.uimanager.PixelUtil
12
13
  import com.facebook.yoga.YogaMeasureMode
@@ -92,6 +93,38 @@ object MeasurementStore {
92
93
  }
93
94
 
94
95
  private val measurePaint = TextPaint()
96
+
97
+ @Volatile
98
+ private var emojiInitCallbackRegistered = false
99
+
100
+ // A view measured before EmojiCompat loaded keeps a size without its emoji glyphs.
101
+ private val emojiInitCallback =
102
+ object : EmojiCompat.InitCallback() {
103
+ override fun onInitialized() {
104
+ data.clear()
105
+ }
106
+ }
107
+
108
+ // AppCompatTextView draws emoji the system font lacks through EmojiCompat, which can be
109
+ // wider than the fallback glyph and wrap a line earlier; measure the same glyphs.
110
+ private fun withEmojiSpans(text: CharSequence): CharSequence {
111
+ if (text.isEmpty() || !EmojiCompat.isConfigured()) return text
112
+ val emojiCompat = EmojiCompat.get()
113
+ if (emojiCompat.loadState != EmojiCompat.LOAD_STATE_SUCCEEDED) {
114
+ if (!emojiInitCallbackRegistered) {
115
+ emojiInitCallbackRegistered = true
116
+ emojiCompat.registerInitCallback(emojiInitCallback)
117
+ }
118
+ return text
119
+ }
120
+ return try {
121
+ emojiCompat.process(SpannableString(text)) ?: text
122
+ } catch (e: IllegalStateException) {
123
+ Log.w(TAG, "EmojiCompat processing failed, measuring raw text", e)
124
+ text
125
+ }
126
+ }
127
+
95
128
  private val measureRenderer = Renderer()
96
129
 
97
130
  @Volatile
@@ -337,9 +370,12 @@ object MeasurementStore {
337
370
  tryRenderMarkdown(markdown, styleMap, context, md4cFlags, allowFontScaling, maxFontSizeMultiplier, imageRequestHeaders)
338
371
  // Placeholders must be built with the same inlineMath.paddingVertical the real spans use,
339
372
  // or the measured line box won't match the drawn one.
373
+ val inlineMathMap = styleMap.getMapOrNull("inlineMath")
374
+ val inlineMathOverflowDip = inlineMathMap.getFloatOrDefault("glyphOverflow", 0f)
340
375
  spannable?.replaceMathSpansWithPlaceholders(
341
376
  context,
342
- PixelUtil.toPixelFromDIP(styleMap.getMapOrNull("inlineMath").getFloatOrDefault("paddingVertical", 0f)),
377
+ PixelUtil.toPixelFromDIP(inlineMathMap.getFloatOrDefault("paddingVertical", 0f)) +
378
+ if (inlineMathOverflowDip > 0f) ceil(PixelUtil.toPixelFromDIP(inlineMathOverflowDip)) else 0f,
343
379
  )
344
380
  val textToMeasure = spannable ?: markdown
345
381
  val (size, _) = measureWithLayout(width, textToMeasure, measurePaint, id)
@@ -456,7 +492,8 @@ object MeasurementStore {
456
492
  for (i in mathSegmentIndices.indices) {
457
493
  val metrics = mathResults[i]
458
494
  mathHeightByIndex[mathSegmentIndices[i]] =
459
- ceil(metrics.ascent + metrics.descent).toInt() + (style.mathStyle.padding * 2)
495
+ ceil(metrics.ascent + metrics.descent).toInt() + (style.mathStyle.padding * 2) +
496
+ (style.mathStyle.glyphOverflow * 2)
460
497
  }
461
498
  }
462
499
 
@@ -471,7 +508,7 @@ object MeasurementStore {
471
508
 
472
509
  when (segment) {
473
510
  is RenderedSegment.Text -> {
474
- segment.styledText.replaceMathSpansWithPlaceholders(context, style.inlineMathStyle.paddingVertical)
511
+ segment.styledText.replaceMathSpansWithPlaceholders(context, style.inlineMathStyle.boxPaddingVertical)
475
512
 
476
513
  val layout = createStaticLayout(segment.styledText, fontSize, widthPx, id)
477
514
  totalHeightPx += layout.height
@@ -546,9 +583,10 @@ object MeasurementStore {
546
583
  viewId: Int?,
547
584
  ): StaticLayout {
548
585
  measurePaint.textSize = fontSize
549
- prepareImageSpansForMeasurement(text, widthPx)
586
+ val content = withEmojiSpans(text)
587
+ prepareImageSpansForMeasurement(content, widthPx)
550
588
  return StaticLayout.Builder
551
- .obtain(text, 0, text.length, measurePaint, widthPx)
589
+ .obtain(content, 0, content.length, measurePaint, widthPx)
552
590
  .setIncludePad(false)
553
591
  .setLineSpacing(0f, 1f)
554
592
  .apply {
@@ -579,7 +617,7 @@ object MeasurementStore {
579
617
  style.imageRequestHeaders = imageRequestHeaders
580
618
  measureRenderer.configure(style, context)
581
619
  measureRenderer.renderDocument(ast, null).apply {
582
- replaceMathSpansWithPlaceholders(context, style.inlineMathStyle.paddingVertical)
620
+ replaceMathSpansWithPlaceholders(context, style.inlineMathStyle.boxPaddingVertical)
583
621
  }
584
622
  } catch (e: Exception) {
585
623
  Log.w(TAG, "Failed to render markdown for measurement, falling back to raw text", e)
@@ -628,7 +666,7 @@ object MeasurementStore {
628
666
  paint: TextPaint,
629
667
  viewId: Int?,
630
668
  ): Long {
631
- val content = text ?: ""
669
+ val content = withEmojiSpans(text ?: "")
632
670
  val safeWidth = ceil(maxWidth).toInt().coerceAtLeast(1)
633
671
  prepareImageSpansForMeasurement(content, safeWidth)
634
672
 
@@ -668,7 +706,7 @@ object MeasurementStore {
668
706
  paint: TextPaint,
669
707
  viewId: Int?,
670
708
  ): Pair<Long, StaticLayout> {
671
- val content = text ?: ""
709
+ val content = withEmojiSpans(text ?: "")
672
710
  val widthPx = ceil(maxWidth).toInt().coerceAtLeast(1)
673
711
  prepareImageSpansForMeasurement(content, widthPx)
674
712
 
@@ -1,12 +1,17 @@
1
1
  package com.swmansion.enriched.markdown.styles
2
2
 
3
3
  import com.facebook.react.bridge.ReadableMap
4
+ import kotlin.math.ceil
4
5
 
5
6
  data class InlineMathStyle(
6
7
  val color: Int,
7
8
  val paddingVertical: Float,
8
9
  val fontWeight: String,
10
+ val glyphOverflow: Float = 0f,
9
11
  ) {
12
+ /** Line-box padding the span reports: author padding plus the ink headroom. */
13
+ val boxPaddingVertical: Float get() = paddingVertical + glyphOverflow
14
+
10
15
  companion object {
11
16
  fun fromReadableMap(
12
17
  map: ReadableMap,
@@ -15,7 +20,17 @@ data class InlineMathStyle(
15
20
  val color = parser.parseColor(map, "color")
16
21
  val paddingVertical = parser.toPixelFromDIP(map.getDouble("paddingVertical").toFloat())
17
22
  val fontWeight = parser.parseString(map, "fontWeight", "normal")
18
- return InlineMathStyle(color, paddingVertical, fontWeight)
23
+ val glyphOverflow = glyphOverflowPx(parser.parseOptionalDouble(map, "glyphOverflow").toFloat(), parser)
24
+ return InlineMathStyle(color, paddingVertical, fontWeight, glyphOverflow)
19
25
  }
20
26
  }
21
27
  }
28
+
29
+ /**
30
+ * `glyphOverflow` in whole pixels. Rounded up so a measured box and the bitmap or
31
+ * canvas it holds always grow by the same integer amount on each side.
32
+ */
33
+ internal fun glyphOverflowPx(
34
+ dip: Float,
35
+ parser: StyleParser,
36
+ ): Float = if (dip <= 0f) 0f else ceil(parser.toPixelFromDIP(dip))
@@ -11,6 +11,7 @@ data class MathStyle(
11
11
  val marginBottom: Float,
12
12
  val textAlign: String,
13
13
  val fontWeight: String,
14
+ val glyphOverflow: Float = 0f,
14
15
  ) {
15
16
  companion object {
16
17
  fun fromReadableMap(
@@ -25,6 +26,7 @@ data class MathStyle(
25
26
  val marginBottom = parser.toPixelFromDIP(map.getDouble("marginBottom").toFloat())
26
27
  val textAlign = parser.parseString(map, "textAlign", "center")
27
28
  val fontWeight = parser.parseString(map, "fontWeight", "normal")
29
+ val glyphOverflow = glyphOverflowPx(parser.parseOptionalDouble(map, "glyphOverflow").toFloat(), parser)
28
30
  return MathStyle(
29
31
  fontSize,
30
32
  color,
@@ -34,6 +36,7 @@ data class MathStyle(
34
36
  marginBottom,
35
37
  textAlign,
36
38
  fontWeight,
39
+ glyphOverflow,
37
40
  )
38
41
  }
39
42
  }
@@ -0,0 +1,88 @@
1
+ package com.swmansion.enriched.markdown.utils.text.view
2
+
3
+ import android.view.ActionMode
4
+ import android.view.Menu
5
+ import android.view.MenuInflater
6
+ import android.view.View
7
+ import android.widget.PopupMenu
8
+
9
+ /**
10
+ * Stands in for the floating text-selection toolbar while `contextMenuHidden`
11
+ * is on. An empty menu only hides the AOSP FloatingToolbar; ROMs that draw
12
+ * their own selection popup (MIUI) still show it. Answering the Editor's
13
+ * startActionMode with this mode means no toolbar window is ever created.
14
+ *
15
+ * The callback is the Editor's TextActionModeCallback wrapping ours, so it
16
+ * still gets the full create/prepare/destroy lifecycle: Editor keeps the
17
+ * selection handles and its transient-state bookkeeping, and finish() clears
18
+ * the selection exactly as dismissing the real toolbar would.
19
+ */
20
+ internal class HeadlessActionMode private constructor(
21
+ private val view: View,
22
+ private val callback: ActionMode.Callback,
23
+ ) : ActionMode() {
24
+ // A real framework menu: Editor populates it with MenuItem calls
25
+ // (setAlphabeticShortcut, setShowAsAction, setIntent) a stub can't take.
26
+ private val menu: Menu = PopupMenu(view.context, view).menu
27
+ private var finished = false
28
+
29
+ override fun setTitle(title: CharSequence?) = Unit
30
+
31
+ override fun setTitle(resId: Int) = Unit
32
+
33
+ override fun setSubtitle(subtitle: CharSequence?) = Unit
34
+
35
+ override fun setSubtitle(resId: Int) = Unit
36
+
37
+ override fun setCustomView(view: View?) = Unit
38
+
39
+ override fun invalidate() {
40
+ if (!finished) callback.onPrepareActionMode(this, menu)
41
+ }
42
+
43
+ override fun invalidateContentRect() = Unit
44
+
45
+ override fun hide(duration: Long) = Unit
46
+
47
+ override fun finish() {
48
+ if (finished) return
49
+ finished = true
50
+ callback.onDestroyActionMode(this)
51
+ }
52
+
53
+ override fun getMenu(): Menu = menu
54
+
55
+ override fun getTitle(): CharSequence? = null
56
+
57
+ override fun getSubtitle(): CharSequence? = null
58
+
59
+ override fun getCustomView(): View? = null
60
+
61
+ override fun getMenuInflater(): MenuInflater = MenuInflater(view.context)
62
+
63
+ companion object {
64
+ /** Mirrors the framework: null when the callback declines to start. */
65
+ fun start(
66
+ view: View,
67
+ callback: ActionMode.Callback,
68
+ ): ActionMode? {
69
+ val mode = HeadlessActionMode(view, callback)
70
+ mode.type = TYPE_FLOATING
71
+ if (!callback.onCreateActionMode(mode, mode.menu)) return null
72
+ callback.onPrepareActionMode(mode, mode.menu)
73
+ return mode
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * MIUI 12 (Android 10) replaces android.widget.Editor: text selection never reaches
80
+ * startActionMode or the custom ActionMode callback, so HeadlessActionMode is never used.
81
+ * Its own popup is shown by Editor$HandleView.showActionPopupWindow, which posts a runnable
82
+ * on the TextView (Editor$HandleView$2). Dropping that runnable is the only hook left while
83
+ * `contextMenuHidden` is on.
84
+ */
85
+ private const val ROM_SELECTION_POPUP_SHOWER_PREFIX = "android.widget.Editor\$HandleView"
86
+
87
+ internal fun isRomSelectionPopupShower(action: Runnable?): Boolean =
88
+ action != null && action.javaClass.name.startsWith(ROM_SELECTION_POPUP_SHOWER_PREFIX)
@@ -506,7 +506,7 @@ class TableContainerView(
506
506
  .apply { configure(config, context) }
507
507
  .renderDocument(MarkdownASTNode(NodeType.Document, children = listOf(paragraph)), null, null)
508
508
  }
509
- styledText.replaceMathSpansWithPlaceholders(context, config.inlineMathStyle.paddingVertical)
509
+ styledText.replaceMathSpansWithPlaceholders(context, config.inlineMathStyle.boxPaddingVertical)
510
510
  if (isHeader && styledText.isNotEmpty()) {
511
511
  styledText.setSpan(HeaderTypefaceSpan(headerTypeface), 0, styledText.length, 33)
512
512
  }
@@ -37,6 +37,7 @@ class MathInlineRenderer(
37
37
  textColor = config.style.inlineMathStyle.color,
38
38
  paddingVertical = config.style.inlineMathStyle.paddingVertical,
39
39
  fontWeight = config.style.inlineMathStyle.fontWeight,
40
+ glyphOverflow = config.style.inlineMathStyle.glyphOverflow,
40
41
  )
41
42
 
42
43
  builder.setSpan(span, start, end, SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE)
@@ -16,6 +16,7 @@ class MathInlineSpan(
16
16
  private val textColor: Int,
17
17
  private val paddingVertical: Float,
18
18
  private val fontWeight: String = "normal",
19
+ private val glyphOverflow: Float = 0f,
19
20
  ) : ReplacementSpan() {
20
21
  private var cachedBitmap: Bitmap? = null
21
22
  private var cachedWidth = 0
@@ -31,18 +32,24 @@ class MathInlineSpan(
31
32
  val displayList = RaTeXEngine.parseBlocking(latex, displayMode = false, color = textColor)
32
33
  val renderer = RaTeXRenderer(displayList, fontSize, ratexTypefaceLoader(fontWeight))
33
34
 
34
- cachedWidth = renderer.widthPx.toInt().coerceAtLeast(1)
35
+ cachedWidth = ceil(renderer.widthPx).toInt().coerceAtLeast(1)
35
36
  mathAscent = renderer.heightPx
36
37
  mathDescent = renderer.depthPx
37
38
 
39
+ // RaTeX reports TeX metric boxes, which digit and capital outlines (and the
40
+ // fake-bold stroke) overshoot; the bitmap keeps glyphOverflow px of headroom
41
+ // on both sides so that ink is not cut off.
42
+ val overflowPx = glyphOverflow.toInt()
38
43
  val bitmap =
39
44
  Bitmap.createBitmap(
40
45
  cachedWidth,
41
- ceil(renderer.totalHeightPx).toInt().coerceAtLeast(1),
46
+ ceil(renderer.totalHeightPx).toInt().coerceAtLeast(1) + overflowPx * 2,
42
47
  Bitmap.Config.ARGB_8888,
43
48
  )
44
49
 
45
- renderer.draw(Canvas(bitmap))
50
+ val canvas = Canvas(bitmap)
51
+ canvas.translate(0f, overflowPx.toFloat())
52
+ renderer.draw(canvas)
46
53
  cachedBitmap = bitmap
47
54
  } catch (_: Exception) {
48
55
  renderFailed = true
@@ -66,9 +73,9 @@ class MathInlineSpan(
66
73
  // Must stay identical to MathInlinePlaceholderSpan.getSize: that span measures
67
74
  // this one off the main thread, and a box that disagrees by even a pixel either
68
75
  // clips the formula or eats the padding this field is here to add.
69
- ascent = -ceil(mathAscent + paddingVertical).toInt()
76
+ ascent = -ceil(mathAscent + (paddingVertical + glyphOverflow)).toInt()
70
77
  top = ascent
71
- descent = ceil(mathDescent + paddingVertical).toInt()
78
+ descent = ceil(mathDescent + (paddingVertical + glyphOverflow)).toInt()
72
79
  bottom = descent
73
80
  }
74
81
 
@@ -88,7 +95,7 @@ class MathInlineSpan(
88
95
  ) {
89
96
  prepareResources()
90
97
  cachedBitmap?.let {
91
- val bitmapY = y - ceil(mathAscent)
98
+ val bitmapY = y - ceil(mathAscent) - glyphOverflow
92
99
  canvas.drawBitmap(it, x, bitmapY, paint)
93
100
  }
94
101
  }
@@ -5,6 +5,7 @@ import android.util.Log
5
5
  import io.ratex.RaTeXEngine
6
6
  import io.ratex.RaTeXFontLoader
7
7
  import io.ratex.RaTeXRenderer
8
+ import kotlin.math.ceil
8
9
 
9
10
  object MathMeasureHelper {
10
11
  @JvmStatic
@@ -37,7 +38,7 @@ object MathMeasureHelper {
37
38
  }
38
39
 
39
40
  return MathMetrics(
40
- renderer.widthPx.toInt(),
41
+ ceil(renderer.widthPx).toInt().coerceAtLeast(1),
41
42
  renderer.heightPx,
42
43
  renderer.depthPx,
43
44
  )
@@ -58,7 +58,7 @@ class MathContainerView(
58
58
  else -> Gravity.CENTER_HORIZONTAL
59
59
  }
60
60
 
61
- private val mathView = RaTeXCanvasView(context)
61
+ private val mathView = RaTeXCanvasView(context, mathStyle.glyphOverflow)
62
62
 
63
63
  init {
64
64
  setBackgroundColor(mathStyle.backgroundColor)
@@ -139,8 +139,11 @@ class MathContainerView(
139
139
  }
140
140
  }
141
141
 
142
+ // glyphOverflow px of headroom above and below the RaTeX box: digit and capital
143
+ // outlines (and the fake-bold stroke) overshoot it, and this view clips to its size.
142
144
  private class RaTeXCanvasView(
143
145
  context: Context,
146
+ private val glyphOverflow: Float,
144
147
  ) : View(context) {
145
148
  var renderer: RaTeXRenderer? = null
146
149
 
@@ -153,12 +156,16 @@ class MathContainerView(
153
156
  ?: return setMeasuredDimension(0, 0)
154
157
  setMeasuredDimension(
155
158
  ceil(currentRenderer.widthPx).toInt().coerceAtLeast(1),
156
- ceil(currentRenderer.totalHeightPx).toInt().coerceAtLeast(1),
159
+ ceil(currentRenderer.totalHeightPx).toInt().coerceAtLeast(1) + (glyphOverflow * 2).toInt(),
157
160
  )
158
161
  }
159
162
 
160
163
  override fun onDraw(canvas: Canvas) {
161
- renderer?.draw(canvas)
164
+ val currentRenderer = renderer ?: return
165
+ canvas.save()
166
+ canvas.translate(0f, glyphOverflow)
167
+ currentRenderer.draw(canvas)
168
+ canvas.restore()
162
169
  }
163
170
  }
164
171
 
@@ -175,7 +182,7 @@ class MathContainerView(
175
182
  mode = MathRenderMode.Display,
176
183
  )
177
184
  val metrics = MathMeasureHelper.measure(context, listOf(request)).first()
178
- return ceil(metrics.ascent + metrics.descent).toInt() + (mathStyle.padding * 2)
185
+ return ceil(metrics.ascent + metrics.descent).toInt() + (mathStyle.padding * 2) + (mathStyle.glyphOverflow * 2)
179
186
  }
180
187
 
181
188
  private const val TAG = "MathContainerView"
@@ -29,7 +29,8 @@
29
29
  displayMode:NO
30
30
  fontSize:self.fontSize
31
31
  color:color
32
- bold:self.bold];
32
+ bold:self.bold
33
+ glyphOverflow:self.glyphOverflow];
33
34
  if (!result)
34
35
  return;
35
36
 
@@ -14,6 +14,9 @@ NS_ASSUME_NONNULL_BEGIN
14
14
  /// Typeset the glyphs at bold weight (synthetic — see ENRMRaTeXBridge).
15
15
  @property (nonatomic, assign) BOOL bold;
16
16
 
17
+ /// Ink headroom above and below the formula box (`inlineMath.glyphOverflow`, points).
18
+ @property (nonatomic, assign) CGFloat glyphOverflow;
19
+
17
20
  /// YES if block math (`$$...$$`), NO for inline (`$...$`).
18
21
  @property (nonatomic, assign) BOOL displayMode;
19
22
 
@@ -42,7 +42,8 @@
42
42
  displayMode:NO
43
43
  fontSize:self.fontSize
44
44
  color:color
45
- bold:self.bold];
45
+ bold:self.bold
46
+ glyphOverflow:self.glyphOverflow];
46
47
  if (!result) {
47
48
  _fallbackSource = ENRMMathFallbackString(self.latex, @"$", self.fontSize, color);
48
49
  UIFont *font = [_fallbackSource attribute:NSFontAttributeName atIndex:0 effectiveRange:NULL];