react-native-enriched-markdown-scaffold 1.0.2-scaffold.6 → 1.0.2-scaffold.8

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 (64) hide show
  1. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp +2 -0
  2. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.h +2 -0
  3. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +24 -0
  4. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownInternalText.kt +12 -0
  5. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +12 -0
  6. package/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +8 -4
  7. package/android/src/main/java/com/swmansion/enriched/markdown/events/SelectionChangeEvent.kt +1 -0
  8. package/android/src/main/java/com/swmansion/enriched/markdown/styles/InlineMathStyle.kt +16 -1
  9. package/android/src/main/java/com/swmansion/enriched/markdown/styles/MathStyle.kt +3 -0
  10. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/HeadlessActionMode.kt +76 -0
  11. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/SelectionChangeReporter.kt +2 -0
  12. package/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt +1 -1
  13. package/android/src/math/java/com/swmansion/enriched/markdown/renderer/MathInlineRenderer.kt +1 -0
  14. package/android/src/math/java/com/swmansion/enriched/markdown/spans/MathInlineSpan.kt +13 -6
  15. package/android/src/math/java/com/swmansion/enriched/markdown/spans/MathMeasureHelper.kt +2 -1
  16. package/android/src/math/java/com/swmansion/enriched/markdown/views/MathContainerView.kt +11 -4
  17. package/docs/API_REFERENCE.md +4 -2
  18. package/ios/EnrichedMarkdown.mm +23 -0
  19. package/ios/EnrichedMarkdownText.mm +21 -0
  20. package/ios/attachments/ENRMMathInlineAttachment+macOS.m +2 -1
  21. package/ios/attachments/ENRMMathInlineAttachment.h +3 -0
  22. package/ios/attachments/ENRMMathInlineAttachment.m +2 -1
  23. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.cpp +2 -0
  24. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.h +2 -0
  25. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h +24 -0
  26. package/ios/internals/MeasurementCache.h +3 -2
  27. package/ios/math/ENRMRaTeXBridge.swift +23 -5
  28. package/ios/renderer/ENRMMathInlineRenderer.m +1 -0
  29. package/ios/styles/StyleConfig.h +4 -0
  30. package/ios/styles/StyleConfig.mm +24 -0
  31. package/ios/utils/ENRMLinkChip.m +2 -1
  32. package/ios/utils/ENRMSelectionChange.h +24 -0
  33. package/ios/utils/ENRMSelectionChange.m +78 -0
  34. package/ios/utils/StylePropsUtils.h +10 -0
  35. package/ios/views/ENRMMathContainerView.m +4 -2
  36. package/lib/module/EnrichedMarkdownNativeComponent.ts +3 -0
  37. package/lib/module/EnrichedMarkdownTextNativeComponent.ts +3 -0
  38. package/lib/module/normalizeMarkdownStyle.js +4 -2
  39. package/lib/module/normalizeMarkdownStyle.js.map +1 -1
  40. package/lib/module/normalizeMarkdownStyle.web.js +4 -2
  41. package/lib/module/normalizeMarkdownStyle.web.js.map +1 -1
  42. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts +3 -0
  43. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts.map +1 -1
  44. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +3 -0
  45. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts.map +1 -1
  46. package/lib/typescript/src/normalizeMarkdownStyle.d.ts.map +1 -1
  47. package/lib/typescript/src/normalizeMarkdownStyle.web.d.ts.map +1 -1
  48. package/lib/typescript/src/types/MarkdownStyle.d.ts +22 -0
  49. package/lib/typescript/src/types/MarkdownStyle.d.ts.map +1 -1
  50. package/lib/typescript/src/types/MarkdownStyleInternal.d.ts +2 -0
  51. package/lib/typescript/src/types/MarkdownStyleInternal.d.ts.map +1 -1
  52. package/lib/typescript/src/types/MarkdownTextProps.d.ts +8 -2
  53. package/lib/typescript/src/types/MarkdownTextProps.d.ts.map +1 -1
  54. package/lib/typescript/src/types/events.d.ts +8 -0
  55. package/lib/typescript/src/types/events.d.ts.map +1 -1
  56. package/package.json +1 -1
  57. package/src/EnrichedMarkdownNativeComponent.ts +3 -0
  58. package/src/EnrichedMarkdownTextNativeComponent.ts +3 -0
  59. package/src/normalizeMarkdownStyle.ts +2 -0
  60. package/src/normalizeMarkdownStyle.web.ts +2 -0
  61. package/src/types/MarkdownStyle.ts +22 -0
  62. package/src/types/MarkdownStyleInternal.ts +2 -0
  63. package/src/types/MarkdownTextProps.ts +8 -2
  64. package/src/types/events.ts +8 -0
@@ -87,6 +87,7 @@ payload.setProperty(runtime, "whole", event.whole);
87
87
  endRect.setProperty(runtime, "height", event.endRect.height);
88
88
  payload.setProperty(runtime, "endRect", endRect);
89
89
  }
90
+ payload.setProperty(runtime, "adjusting", event.adjusting);
90
91
  return payload;
91
92
  });
92
93
  }
@@ -487,6 +488,7 @@ payload.setProperty(runtime, "whole", event.whole);
487
488
  endRect.setProperty(runtime, "height", event.endRect.height);
488
489
  payload.setProperty(runtime, "endRect", endRect);
489
490
  }
491
+ payload.setProperty(runtime, "adjusting", event.adjusting);
490
492
  return payload;
491
493
  });
492
494
  }
@@ -69,6 +69,7 @@ class EnrichedMarkdownEventEmitter : public ViewEventEmitter {
69
69
  OnSelectionChangeRect rect;
70
70
  OnSelectionChangeStartRect startRect;
71
71
  OnSelectionChangeEndRect endRect;
72
+ bool adjusting;
72
73
  };
73
74
 
74
75
  struct OnTaskListItemPress {
@@ -395,6 +396,7 @@ class EnrichedMarkdownTextEventEmitter : public ViewEventEmitter {
395
396
  OnSelectionChangeRect rect;
396
397
  OnSelectionChangeStartRect startRect;
397
398
  OnSelectionChangeEndRect endRect;
399
+ bool adjusting;
398
400
  };
399
401
 
400
402
  struct OnTaskListItemPress {
@@ -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
@@ -128,6 +129,17 @@ class EnrichedMarkdownInternalText
128
129
  onSelectionChangedCallback?.invoke(this, selEnd > selStart)
129
130
  }
130
131
 
132
+ // See HeadlessActionMode: with the menu hidden, no toolbar window is created.
133
+ override fun startActionMode(
134
+ callback: ActionMode.Callback?,
135
+ type: Int,
136
+ ): ActionMode? {
137
+ if (contextMenuHidden && type == ActionMode.TYPE_FLOATING && callback != null) {
138
+ return HeadlessActionMode.start(this, callback)
139
+ }
140
+ return super.startActionMode(callback, type)
141
+ }
142
+
131
143
  /** Clears the selection and ends its ActionMode. */
132
144
  fun clearSelection() {
133
145
  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
@@ -336,6 +337,17 @@ class EnrichedMarkdownText
336
337
  selectionReporter?.enabled = enabled
337
338
  }
338
339
 
340
+ // See HeadlessActionMode: with the menu hidden, no toolbar window is created.
341
+ override fun startActionMode(
342
+ callback: ActionMode.Callback?,
343
+ type: Int,
344
+ ): ActionMode? {
345
+ if (contextMenuHidden && type == ActionMode.TYPE_FLOATING && callback != null) {
346
+ return HeadlessActionMode.start(this, callback)
347
+ }
348
+ return super.startActionMode(callback, type)
349
+ }
350
+
339
351
  /** Clears the selection (ending its ActionMode) and reports `active: false`. */
340
352
  fun clearSelection() {
341
353
  clearTextSelection()
@@ -337,9 +337,12 @@ object MeasurementStore {
337
337
  tryRenderMarkdown(markdown, styleMap, context, md4cFlags, allowFontScaling, maxFontSizeMultiplier, imageRequestHeaders)
338
338
  // Placeholders must be built with the same inlineMath.paddingVertical the real spans use,
339
339
  // or the measured line box won't match the drawn one.
340
+ val inlineMathMap = styleMap.getMapOrNull("inlineMath")
341
+ val inlineMathOverflowDip = inlineMathMap.getFloatOrDefault("glyphOverflow", 0f)
340
342
  spannable?.replaceMathSpansWithPlaceholders(
341
343
  context,
342
- PixelUtil.toPixelFromDIP(styleMap.getMapOrNull("inlineMath").getFloatOrDefault("paddingVertical", 0f)),
344
+ PixelUtil.toPixelFromDIP(inlineMathMap.getFloatOrDefault("paddingVertical", 0f)) +
345
+ if (inlineMathOverflowDip > 0f) ceil(PixelUtil.toPixelFromDIP(inlineMathOverflowDip)) else 0f,
343
346
  )
344
347
  val textToMeasure = spannable ?: markdown
345
348
  val (size, _) = measureWithLayout(width, textToMeasure, measurePaint, id)
@@ -456,7 +459,8 @@ object MeasurementStore {
456
459
  for (i in mathSegmentIndices.indices) {
457
460
  val metrics = mathResults[i]
458
461
  mathHeightByIndex[mathSegmentIndices[i]] =
459
- ceil(metrics.ascent + metrics.descent).toInt() + (style.mathStyle.padding * 2)
462
+ ceil(metrics.ascent + metrics.descent).toInt() + (style.mathStyle.padding * 2) +
463
+ (style.mathStyle.glyphOverflow * 2)
460
464
  }
461
465
  }
462
466
 
@@ -471,7 +475,7 @@ object MeasurementStore {
471
475
 
472
476
  when (segment) {
473
477
  is RenderedSegment.Text -> {
474
- segment.styledText.replaceMathSpansWithPlaceholders(context, style.inlineMathStyle.paddingVertical)
478
+ segment.styledText.replaceMathSpansWithPlaceholders(context, style.inlineMathStyle.boxPaddingVertical)
475
479
 
476
480
  val layout = createStaticLayout(segment.styledText, fontSize, widthPx, id)
477
481
  totalHeightPx += layout.height
@@ -579,7 +583,7 @@ object MeasurementStore {
579
583
  style.imageRequestHeaders = imageRequestHeaders
580
584
  measureRenderer.configure(style, context)
581
585
  measureRenderer.renderDocument(ast, null).apply {
582
- replaceMathSpansWithPlaceholders(context, style.inlineMathStyle.paddingVertical)
586
+ replaceMathSpansWithPlaceholders(context, style.inlineMathStyle.boxPaddingVertical)
583
587
  }
584
588
  } catch (e: Exception) {
585
589
  Log.w(TAG, "Failed to render markdown for measurement, falling back to raw text", e)
@@ -26,6 +26,7 @@ class SelectionChangeEvent(
26
26
  putMap("rect", snapshot.rect.toWritableMap())
27
27
  putMap("startRect", snapshot.startRect.toWritableMap())
28
28
  putMap("endRect", snapshot.endRect.toWritableMap())
29
+ putBoolean("adjusting", snapshot.adjusting)
29
30
  }
30
31
 
31
32
  private fun SelectionRect.toWritableMap(): WritableMap =
@@ -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,76 @@
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
+ }
@@ -46,6 +46,8 @@ data class SelectionSnapshot(
46
46
  val rect: SelectionRect,
47
47
  val startRect: SelectionRect,
48
48
  val endRect: SelectionRect,
49
+ // Not tracked on Android: the selection is reported continuously while a handle is dragged.
50
+ val adjusting: Boolean = false,
49
51
  ) {
50
52
  companion object {
51
53
  val INACTIVE =
@@ -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"
@@ -248,13 +248,15 @@ interface SelectionChangeEvent {
248
248
  /** Selected part of the first / last line — anchor a menu above the start or below the end. */
249
249
  startRect: SelectionRect;
250
250
  endRect: SelectionRect;
251
+ /** `true` while a selection handle is dragged (iOS; always `false` on Android). */
252
+ adjusting: boolean;
251
253
  }
252
254
  ```
253
255
 
254
256
  - Fires at most once per frame while the selection changes (payloads are deduplicated), and with `active: false` right away once it is gone — including when new `markdown` replaces the text.
255
257
  - Rects are in dp, relative to the component's native view: add the origin from `ref.current.measureInWindow` to get window coordinates. Re-measure after scrolling instead of waiting for a new event.
256
258
  - With `contextMenuHidden`, a long press on a table, math or code block (`flavor="github"`) fires the event with `source` set to the block kind, `whole: true` and every rect set to the block's frame. These blocks have no text selection, so without this they would offer no way to copy. Without `contextMenuHidden` the block menu shows as usual and no event fires.
257
- - There is no "dragging" flag. Debounce before showing your menu, so it doesn't flicker while a handle is dragged.
259
+ - On iOS the selection isn't reported while a handle is dragged: the event fires with `adjusting: true` (and the selection as it was) when the drag begins, and with `adjusting: false` and the new selection when the finger lifts. Hide your menu while `adjusting` is `true`. Android has no such flag (`adjusting` is always `false`) but reports every step of the drag, so debounce before showing the menu there too.
258
260
  - Within one component only one part holds a selection at a time: selecting in another text segment clears the previous one. To keep a single selection across several components, call `clearSelection()` on the previous one.
259
261
  - Ignored on macOS and web.
260
262
 
@@ -268,7 +270,7 @@ const ref = useRef<EnrichedMarkdownTextInstance>(null);
268
270
  markdown={content}
269
271
  contextMenuHidden
270
272
  onSelectionChange={(e) => {
271
- if (!e.active) return hideMenu();
273
+ if (!e.active || e.adjusting) return hideMenu();
272
274
  ref.current?.measureInWindow((x, y) =>
273
275
  showMenuDebounced({ x: x + e.startRect.x, y: y + e.startRect.y }, e.markdown)
274
276
  );
@@ -131,6 +131,9 @@ static char kENRMSegmentFadeAnimatorKey;
131
131
  NSArray<NSDictionary *> *_lastLinkLayout;
132
132
 
133
133
  ENRMSelectionChangeReporter *_selectionReporter;
134
+ #if !TARGET_OS_OSX
135
+ ENRMSelectionGestureTracker *_selectionGestures;
136
+ #endif
134
137
  // At most one segment holds a selection; a new one clears the previous.
135
138
  __weak ENRMPlatformTextView *_selectionOwner;
136
139
  // A block long-pressed while contextMenuHidden is on, reported as the selection.
@@ -225,6 +228,20 @@ static char kENRMSegmentFadeAnimatorKey;
225
228
  emitter->onSelectionChange(
226
229
  ENRMSelectionChangeEventFromPayload<EnrichedMarkdownEventEmitter::OnSelectionChange>(payload));
227
230
  }];
231
+ #if !TARGET_OS_OSX
232
+ // A handle drag starting or ending is reported right away, even when the
233
+ // selection itself is unchanged.
234
+ _selectionGestures = [[ENRMSelectionGestureTracker alloc] initWithChange:^{
235
+ EnrichedMarkdown *strongSelf = weakSelf;
236
+ if (!strongSelf)
237
+ return;
238
+ UITextView *owner = strongSelf->_selectionOwner;
239
+ if (owner)
240
+ [strongSelf->_selectionGestures trackTextView:owner];
241
+ [strongSelf->_selectionReporter forgetLastReported];
242
+ [strongSelf->_selectionReporter flushNow];
243
+ }];
244
+ #endif
228
245
  }
229
246
  return self;
230
247
  }
@@ -262,6 +279,7 @@ static char kENRMSegmentFadeAnimatorKey;
262
279
  payload[ENRMSelectionSourceKey] = @"selection";
263
280
  payload[ENRMSelectionMarkdownKey] = markdown.length > 0 ? markdown : text;
264
281
  payload[ENRMSelectionWholeKey] = @(whole);
282
+ payload[ENRMSelectionAdjustingKey] = @(_selectionGestures.adjusting);
265
283
  return payload;
266
284
  #else
267
285
  return ENRMInactiveSelectionPayload();
@@ -915,6 +933,7 @@ static char kENRMSegmentFadeAnimatorKey;
915
933
 
916
934
  #if !TARGET_OS_OSX
917
935
  view.textView.delegate = self;
936
+ [_selectionGestures trackTextView:view.textView];
918
937
  #else
919
938
  __weak EnrichedMarkdown *weakSelf = self;
920
939
  [view setContextMenuProvider:^NSMenu *_Nullable(NSMenu *baseMenu, NSTextView *textView) {
@@ -1312,6 +1331,9 @@ static char kENRMSegmentFadeAnimatorKey;
1312
1331
  _emitLinkLayout = NO;
1313
1332
  _lastLinkLayout = nil;
1314
1333
  _selectionReporter.enabled = NO;
1334
+ #if !TARGET_OS_OSX
1335
+ [_selectionGestures reset];
1336
+ #endif
1315
1337
  _selectionOwner = nil;
1316
1338
  [self clearActiveBlock];
1317
1339
  _md4cFlags = [EnrichedMarkdown flagsFromProps:resetProps->md4cFlags];
@@ -1538,6 +1560,7 @@ Class<RCTComponentViewProtocol> EnrichedMarkdownCls(void)
1538
1560
  #if !TARGET_OS_OSX
1539
1561
  - (void)textViewDidChangeSelection:(UITextView *)textView
1540
1562
  {
1563
+ [_selectionGestures trackTextView:textView];
1541
1564
  ENRMDismissSelectionEditMenuIfMoved(textView);
1542
1565
  if (textView.selectedRange.length > 0) {
1543
1566
  // Take ownership first so the previous owner's own "cleared" callback is ignored.