react-native-enriched-markdown-scaffold 0.6.0-scaffold.8 → 0.7.4-scaffold.2

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 (150) hide show
  1. package/ReactNativeEnrichedMarkdown.podspec +62 -11
  2. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownManagerDelegate.java +3 -0
  3. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownManagerInterface.java +1 -0
  4. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextInputManagerDelegate.java +9 -0
  5. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextInputManagerInterface.java +3 -0
  6. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerDelegate.java +3 -0
  7. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerInterface.java +1 -0
  8. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.cpp +20 -0
  9. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +744 -0
  10. package/android/src/main/baseline-prof.txt +75 -2
  11. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +62 -0
  12. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownInternalText.kt +10 -2
  13. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt +10 -0
  14. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +9 -2
  15. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt +10 -0
  16. package/android/src/main/java/com/swmansion/enriched/markdown/accessibility/AccessibilityLabels.kt +15 -0
  17. package/android/src/main/java/com/swmansion/enriched/markdown/accessibility/MarkdownAccessibilityHelper.kt +44 -7
  18. package/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt +52 -0
  19. package/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt +89 -11
  20. package/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/FormattingStore.kt +19 -0
  21. package/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputParser.kt +22 -3
  22. package/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputRemend.kt +1 -0
  23. package/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputMeasurementStore.kt +7 -1
  24. package/android/src/main/java/com/swmansion/enriched/markdown/input/toolbar/InputContextMenu.kt +54 -13
  25. package/android/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt +17 -0
  26. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt +4 -4
  27. package/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt +30 -0
  28. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkEvents.kt +22 -0
  29. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/SelectionActionMode.kt +36 -7
  30. package/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt +73 -3
  31. package/android/src/main/jni/CMakeLists.txt +15 -1
  32. package/android/src/math/java/com/swmansion/enriched/markdown/views/MathContainerView.kt +24 -2
  33. package/app.plugin.js +2 -1
  34. package/cpp/parser/MD4CParser.cpp +1 -1
  35. package/cpp/wasm/build.sh +9 -9
  36. package/docs/ACCESSIBILITY.md +172 -0
  37. package/docs/API_REFERENCE.md +948 -0
  38. package/docs/COPY_OPTIONS.md +127 -0
  39. package/docs/ELEMENTS_STRUCTURE.md +112 -0
  40. package/docs/IMAGE_CACHING.md +25 -0
  41. package/docs/INPUT.md +233 -0
  42. package/docs/LATEX_MATH.md +136 -0
  43. package/docs/MACOS.md +19 -0
  44. package/docs/MARKDOWN_STREAMING.md +36 -0
  45. package/docs/MENTIONS.md +97 -0
  46. package/docs/RTL.md +82 -0
  47. package/docs/STYLES.md +519 -0
  48. package/docs/TEXT.md +110 -0
  49. package/docs/WEB.md +49 -0
  50. package/ios/EnrichedMarkdown.mm +82 -25
  51. package/ios/EnrichedMarkdownText.mm +37 -5
  52. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.cpp +20 -0
  53. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h +744 -0
  54. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/RCTComponentViewHelpers.h +15 -0
  55. package/ios/input/ENRMFormattingStore.h +5 -0
  56. package/ios/input/ENRMFormattingStore.mm +34 -0
  57. package/ios/input/ENRMInputTextView.h +2 -0
  58. package/ios/input/ENRMInputTextView.mm +8 -17
  59. package/ios/input/ENRMMarkdownSerializer.mm +1 -1
  60. package/ios/input/EnrichedMarkdownTextInput+ContextMenu.mm +71 -53
  61. package/ios/input/EnrichedMarkdownTextInput+Internal.h +26 -0
  62. package/ios/input/EnrichedMarkdownTextInput.h +1 -0
  63. package/ios/input/EnrichedMarkdownTextInput.mm +230 -29
  64. package/ios/input/styles/ENRMBoldStyleHandler.mm +3 -2
  65. package/ios/input/styles/ENRMItalicStyleHandler.mm +3 -2
  66. package/ios/renderer/AttributedRenderer.m +51 -6
  67. package/ios/renderer/ENRMMathInlineRenderer.m +5 -1
  68. package/ios/styles/ENRMFontSlot.h +1 -1
  69. package/ios/utils/ENRMAccessibilityLabels.h +36 -0
  70. package/ios/utils/ENRMAccessibilityLabels.m +5 -0
  71. package/ios/utils/EditMenuUtils+macOS.m +4 -5
  72. package/ios/utils/EditMenuUtils.h +70 -0
  73. package/ios/utils/EditMenuUtils.m +14 -11
  74. package/ios/utils/LastElementUtils.h +39 -2
  75. package/ios/utils/MarkdownAccessibilityElementBuilder.h +18 -16
  76. package/ios/utils/MarkdownAccessibilityElementBuilder.m +64 -32
  77. package/ios/utils/ParagraphStyleUtils.m +21 -2
  78. package/ios/views/ENRMMathContainerView.h +8 -0
  79. package/ios/views/ENRMMathContainerView.m +7 -5
  80. package/ios/views/ENRMTableIOSGridView.h +40 -0
  81. package/ios/views/ENRMTableIOSGridView.m +216 -0
  82. package/ios/views/EnrichedMarkdownInternalText.h +2 -0
  83. package/ios/views/EnrichedMarkdownInternalText.m +2 -0
  84. package/ios/views/TableContainerView.h +8 -0
  85. package/ios/views/TableContainerView.m +72 -128
  86. package/lib/module/EnrichedMarkdownNativeComponent.ts +37 -0
  87. package/lib/module/EnrichedMarkdownTextInput.js +48 -1
  88. package/lib/module/EnrichedMarkdownTextInput.js.map +1 -1
  89. package/lib/module/EnrichedMarkdownTextInputNativeComponent.ts +35 -0
  90. package/lib/module/EnrichedMarkdownTextNativeComponent.ts +37 -0
  91. package/lib/module/accessibilityLabelDefaults.js +64 -0
  92. package/lib/module/accessibilityLabelDefaults.js.map +1 -0
  93. package/lib/module/index.js +1 -0
  94. package/lib/module/index.js.map +1 -1
  95. package/lib/module/native/EnrichedMarkdownText.js +72 -4
  96. package/lib/module/native/EnrichedMarkdownText.js.map +1 -1
  97. package/lib/module/normalizeMenuItem.js +38 -0
  98. package/lib/module/normalizeMenuItem.js.map +1 -0
  99. package/lib/module/types/AccessibilityLabels.js +2 -0
  100. package/lib/module/types/AccessibilityLabels.js.map +1 -0
  101. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts +34 -0
  102. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts.map +1 -1
  103. package/lib/typescript/src/EnrichedMarkdownTextInput.d.ts +55 -1
  104. package/lib/typescript/src/EnrichedMarkdownTextInput.d.ts.map +1 -1
  105. package/lib/typescript/src/EnrichedMarkdownTextInputNativeComponent.d.ts +30 -0
  106. package/lib/typescript/src/EnrichedMarkdownTextInputNativeComponent.d.ts.map +1 -1
  107. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +34 -0
  108. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts.map +1 -1
  109. package/lib/typescript/src/accessibilityLabelDefaults.d.ts +16 -0
  110. package/lib/typescript/src/accessibilityLabelDefaults.d.ts.map +1 -0
  111. package/lib/typescript/src/index.d.ts +4 -2
  112. package/lib/typescript/src/index.d.ts.map +1 -1
  113. package/lib/typescript/src/native/EnrichedMarkdownText.d.ts +3 -3
  114. package/lib/typescript/src/native/EnrichedMarkdownText.d.ts.map +1 -1
  115. package/lib/typescript/src/normalizeMenuItem.d.ts +7 -0
  116. package/lib/typescript/src/normalizeMenuItem.d.ts.map +1 -0
  117. package/lib/typescript/src/types/AccessibilityLabels.d.ts +114 -0
  118. package/lib/typescript/src/types/AccessibilityLabels.d.ts.map +1 -0
  119. package/lib/typescript/src/types/MarkdownTextProps.d.ts +78 -9
  120. package/lib/typescript/src/types/MarkdownTextProps.d.ts.map +1 -1
  121. package/package.json +4 -2
  122. package/plugin/build/withAndroidMath.js +19 -0
  123. package/plugin/build/withIosMath.js +28 -0
  124. package/plugin/build/withReactNativeEnrichedMarkdown.js +11 -0
  125. package/plugin/tsconfig.build.json +16 -0
  126. package/src/EnrichedMarkdownNativeComponent.ts +37 -0
  127. package/src/EnrichedMarkdownTextInput.tsx +117 -1
  128. package/src/EnrichedMarkdownTextInputNativeComponent.ts +35 -0
  129. package/src/EnrichedMarkdownTextNativeComponent.ts +37 -0
  130. package/src/accessibilityLabelDefaults.ts +83 -0
  131. package/src/index.tsx +11 -0
  132. package/src/native/EnrichedMarkdownText.tsx +120 -6
  133. package/src/normalizeMenuItem.ts +52 -0
  134. package/src/types/AccessibilityLabels.ts +118 -0
  135. package/src/types/MarkdownTextProps.ts +74 -9
  136. package/lib/module/plugin/withAndroidMath.js +0 -20
  137. package/lib/module/plugin/withAndroidMath.js.map +0 -1
  138. package/lib/module/plugin/withIosMath.js +0 -23
  139. package/lib/module/plugin/withIosMath.js.map +0 -1
  140. package/lib/module/plugin/withReactNativeEnrichedMarkdown.js +0 -16
  141. package/lib/module/plugin/withReactNativeEnrichedMarkdown.js.map +0 -1
  142. package/lib/typescript/src/plugin/withAndroidMath.d.ts +0 -5
  143. package/lib/typescript/src/plugin/withAndroidMath.d.ts.map +0 -1
  144. package/lib/typescript/src/plugin/withIosMath.d.ts +0 -5
  145. package/lib/typescript/src/plugin/withIosMath.d.ts.map +0 -1
  146. package/lib/typescript/src/plugin/withReactNativeEnrichedMarkdown.d.ts +0 -6
  147. package/lib/typescript/src/plugin/withReactNativeEnrichedMarkdown.d.ts.map +0 -1
  148. /package/{src/plugin → plugin/src}/withAndroidMath.ts +0 -0
  149. /package/{src/plugin → plugin/src}/withIosMath.ts +0 -0
  150. /package/{src/plugin → plugin/src}/withReactNativeEnrichedMarkdown.ts +0 -0
@@ -32,6 +32,8 @@ import com.swmansion.enriched.markdown.input.events.OnRequestMarkdownResultEvent
32
32
  import com.swmansion.enriched.markdown.input.events.OnStartMentionEvent
33
33
  import com.swmansion.enriched.markdown.input.layout.InputMeasurementStore
34
34
  import com.swmansion.enriched.markdown.input.model.StyleType
35
+ import com.swmansion.enriched.markdown.input.toolbar.FormatMenuConfig
36
+ import com.swmansion.enriched.markdown.input.toolbar.InputSelectionMenuConfig
35
37
  import com.swmansion.enriched.markdown.utils.input.BorderPropsApplicator
36
38
  import com.swmansion.enriched.markdown.utils.input.MarkdownStyleParser
37
39
 
@@ -266,6 +268,52 @@ class EnrichedMarkdownTextInputManager :
266
268
  view.setContextMenuItems(items)
267
269
  }
268
270
 
271
+ @ReactProp(name = "selectionMenuConfig")
272
+ override fun setSelectionMenuConfig(
273
+ view: EnrichedMarkdownTextInputView?,
274
+ value: ReadableMap?,
275
+ ) {
276
+ if (view == null) return
277
+ view.contextMenu.selectionMenuConfig =
278
+ if (value == null) {
279
+ InputSelectionMenuConfig()
280
+ } else {
281
+ InputSelectionMenuConfig(
282
+ format = value.getBoolean("format"),
283
+ formatLabel = value.getString("formatLabel") ?: "",
284
+ copyAsMarkdown = value.getBoolean("copyAsMarkdown"),
285
+ copyAsMarkdownLabel = value.getString("copyAsMarkdownLabel") ?: "",
286
+ )
287
+ }
288
+ }
289
+
290
+ @ReactProp(name = "formatMenuConfig")
291
+ override fun setFormatMenuConfig(
292
+ view: EnrichedMarkdownTextInputView?,
293
+ value: ReadableMap?,
294
+ ) {
295
+ if (view == null) return
296
+ view.contextMenu.formatMenuConfig =
297
+ if (value == null) {
298
+ FormatMenuConfig()
299
+ } else {
300
+ FormatMenuConfig(
301
+ bold = value.getBoolean("bold"),
302
+ boldLabel = value.getString("boldLabel") ?: "",
303
+ italic = value.getBoolean("italic"),
304
+ italicLabel = value.getString("italicLabel") ?: "",
305
+ underline = value.getBoolean("underline"),
306
+ underlineLabel = value.getString("underlineLabel") ?: "",
307
+ strikethrough = value.getBoolean("strikethrough"),
308
+ strikethroughLabel = value.getString("strikethroughLabel") ?: "",
309
+ spoiler = value.getBoolean("spoiler"),
310
+ spoilerLabel = value.getString("spoilerLabel") ?: "",
311
+ link = value.getBoolean("link"),
312
+ linkLabel = value.getString("linkLabel") ?: "",
313
+ )
314
+ }
315
+ }
316
+
269
317
  @ReactProp(name = "linkRegex")
270
318
  override fun setLinkRegex(
271
319
  view: EnrichedMarkdownTextInputView?,
@@ -445,6 +493,10 @@ class EnrichedMarkdownTextInputManager :
445
493
  view?.removeMath(start)
446
494
  }
447
495
 
496
+ override fun copyToClipboard(view: EnrichedMarkdownTextInputView?) {
497
+ view?.copyToClipboard()
498
+ }
499
+
448
500
  override fun requestMarkdown(
449
501
  view: EnrichedMarkdownTextInputView?,
450
502
  requestId: Int,
@@ -1,5 +1,7 @@
1
1
  package com.swmansion.enriched.markdown.input
2
2
 
3
+ import android.content.ClipData
4
+ import android.content.ClipboardManager
3
5
  import android.content.Context
4
6
  import android.graphics.BlendMode
5
7
  import android.graphics.BlendModeColorFilter
@@ -44,6 +46,8 @@ import com.swmansion.enriched.markdown.input.toolbar.InputContextMenu
44
46
  import com.swmansion.enriched.markdown.utils.input.AutoCapitalizeUtils
45
47
  import kotlin.math.ceil
46
48
 
49
+ private fun Char.isLineBreak(): Boolean = this == '\n' || this == '\r' || this == '\u0085' || this == '\u2028' || this == '\u2029'
50
+
47
51
  class EnrichedMarkdownTextInputView(
48
52
  context: Context,
49
53
  ) : AppCompatEditText(context) {
@@ -71,6 +75,7 @@ class EnrichedMarkdownTextInputView(
71
75
  var autoFocusRequested = false
72
76
  var stateWrapper: StateWrapper? = null
73
77
  val layoutManager = InputLayoutManager(this)
78
+ private var pendingAutoFocusKeyboard = false
74
79
 
75
80
  private var typefaceDirty = false
76
81
  private var fontFamilyValue: String? = null
@@ -176,6 +181,13 @@ class EnrichedMarkdownTextInputView(
176
181
  refreshAllMathChips()
177
182
  }
178
183
 
184
+ override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
185
+ super.onWindowFocusChanged(hasWindowFocus)
186
+ // The autofocus keyboard request may run before the window has IME focus (e.g. while a modal is
187
+ // still presenting), where showSoftInput() is dropped.
188
+ showAutoFocusKeyboardIfPending()
189
+ }
190
+
179
191
  override fun clearFocus() {
180
192
  super.clearFocus()
181
193
  inputMethodManager?.hideSoftInputFromWindow(windowToken, 0)
@@ -312,6 +324,12 @@ class EnrichedMarkdownTextInputView(
312
324
  if (!isComponentReady || isDuringTransaction) return
313
325
 
314
326
  if (!isTextChanging) {
327
+ // Links (e.g. mentions) are atomic: snap a partial selection to the whole link, and a caret
328
+ // inside a link to its end. Returning lets the recursive onSelectionChanged emit for the result.
329
+ formattingStore.selectionAdjustedForAtomicLinks(selStart, selEnd)?.let { (newStart, newEnd) ->
330
+ setSelection(newStart, newEnd)
331
+ return
332
+ }
315
333
  if (didTextChangeRecently) {
316
334
  didTextChangeRecently = false
317
335
  } else {
@@ -345,8 +363,18 @@ class EnrichedMarkdownTextInputView(
345
363
  val rangeStart = if (preEditSelectionStart != preEditSelectionEnd) preEditSelectionStart else editStart
346
364
  val rangeEnd = rangeStart + insertedLength
347
365
 
348
- for (style in pendingStyles) {
349
- formattingStore.addRange(FormattingRange(style, rangeStart, rangeEnd))
366
+ // Skip applying pending styles when the insertion is only line breaks —
367
+ // a phantom range over a bare newline corrupts isStyleActive() at the boundary.
368
+ val currentText = text
369
+ val insertedHasGlyphContent =
370
+ currentText != null &&
371
+ rangeEnd <= currentText.length &&
372
+ (rangeStart until rangeEnd).any { !currentText[it].isLineBreak() }
373
+
374
+ if (insertedHasGlyphContent) {
375
+ for (style in pendingStyles) {
376
+ formattingStore.addRange(FormattingRange(style, rangeStart, rangeEnd))
377
+ }
350
378
  }
351
379
 
352
380
  for (style in pendingStyleRemovals) {
@@ -439,6 +467,16 @@ class EnrichedMarkdownTextInputView(
439
467
  }
440
468
  }
441
469
 
470
+ // Copies the input's plain text to the system clipboard without disturbing
471
+ // the current selection. Android's system clipboard round-trip is plain text
472
+ // only — inline styles are not preserved for external paste targets.
473
+ fun copyToClipboard() {
474
+ val content = text
475
+ if (content.isNullOrEmpty()) return
476
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
477
+ clipboard.setPrimaryClip(ClipData.newPlainText(null, content))
478
+ }
479
+
442
480
  fun setLinkForSelection(url: String) {
443
481
  val selStart = selectionStart
444
482
  val selEnd = selectionEnd
@@ -810,18 +848,36 @@ class EnrichedMarkdownTextInputView(
810
848
  return true
811
849
  }
812
850
 
813
- private fun computeChipBounds(
851
+ /** Chip glyph rect in [Layout] coordinates — no padding, no scroll offset. */
852
+ private fun chipBoundsInLayout(
814
853
  layout: Layout,
815
854
  start: Int,
816
855
  ): Rect {
817
856
  val line = layout.getLineForOffset(start)
818
- val xStart = (layout.getPrimaryHorizontal(start)).toInt() + totalPaddingLeft - scrollX
819
- val xEnd = (layout.getPrimaryHorizontal(start + 1)).toInt() + totalPaddingLeft - scrollX
820
- val top = layout.getLineTop(line) + totalPaddingTop - scrollY
821
- val bottom = layout.getLineBottom(line) + totalPaddingTop - scrollY
822
- return Rect(xStart, top, xEnd, bottom)
857
+ val xStart = layout.getPrimaryHorizontal(start).toInt()
858
+ val xEnd = layout.getPrimaryHorizontal(start + 1).toInt()
859
+ // Ordered, not raw start/end: in an RTL run xStart > xEnd, which would make an
860
+ // inverted Rect that contains() always rejects.
861
+ val left = xStart.coerceAtMost(xEnd)
862
+ var right = xStart.coerceAtLeast(xEnd)
863
+ if (right <= left) {
864
+ // A chip that is the last character has start + 1 == length, where some layouts
865
+ // report the same horizontal for both offsets. Fall back to the line's content edge
866
+ // so the rect keeps a real width instead of collapsing.
867
+ right = layout.getLineRight(line).toInt()
868
+ }
869
+ return Rect(left, layout.getLineTop(line), right, layout.getLineBottom(line))
823
870
  }
824
871
 
872
+ /** Chip glyph rect in view coordinates, for reporting to JS. */
873
+ private fun computeChipBounds(
874
+ layout: Layout,
875
+ start: Int,
876
+ ): Rect =
877
+ chipBoundsInLayout(layout, start).apply {
878
+ offset(totalPaddingLeft - scrollX, totalPaddingTop - scrollY)
879
+ }
880
+
825
881
  fun mathChipPositionAtTouch(
826
882
  x: Float,
827
883
  y: Float,
@@ -831,13 +887,21 @@ class EnrichedMarkdownTextInputView(
831
887
  val layoutNow = layout ?: return -1
832
888
  val adjustedX = (x - totalPaddingLeft + scrollX).toInt()
833
889
  val adjustedY = (y - totalPaddingTop + scrollY).toInt()
834
- if (adjustedY < 0) return -1
890
+ // getLineForVertical and getOffsetForHorizontal both clamp into the laid-out text, so
891
+ // a touch in the padding or in the blank space below the last line resolves onto the
892
+ // nearest character. Bound the touch to the text box, then require it to land inside
893
+ // the chip's own rect — otherwise a tap beside a chip reads as a tap on it.
894
+ if (adjustedY < 0 || adjustedY > layoutNow.height) return -1
835
895
  val line = layoutNow.getLineForVertical(adjustedY)
836
896
  val offset = layoutNow.getOffsetForHorizontal(line, adjustedX.toFloat())
837
- if (offset < 0 || offset >= editable.length) return -1
897
+ if (offset < 0) return -1
838
898
  val candidates = intArrayOf(offset, offset - 1).filter { it in 0 until editable.length }
839
899
  for (candidate in candidates) {
840
900
  val range = controller.chipRangeAt(editable, candidate) ?: continue
901
+ // An empty rect means the layout could not give us the chip's extent; accept the
902
+ // candidate then, rather than making the chip untappable.
903
+ val bounds = chipBoundsInLayout(layoutNow, range.first)
904
+ if (!bounds.isEmpty && !bounds.contains(adjustedX, adjustedY)) continue
841
905
  return range.first
842
906
  }
843
907
  return -1
@@ -954,11 +1018,25 @@ class EnrichedMarkdownTextInputView(
954
1018
  setSelection(selectionStart.coerceAtLeast(0))
955
1019
  }
956
1020
 
1021
+ private fun showAutoFocusKeyboardIfPending() {
1022
+ if (!pendingAutoFocusKeyboard || !hasWindowFocus()) return
1023
+ pendingAutoFocusKeyboard = false
1024
+ inputMethodManager?.showSoftInput(this, 0)
1025
+ }
1026
+
957
1027
  fun afterUpdateTransaction() {
958
1028
  updateTypeface()
959
1029
  if (autoFocusRequested) {
960
1030
  autoFocusRequested = false
961
- requestFocusProgrammatically()
1031
+ pendingAutoFocusKeyboard = true
1032
+ post {
1033
+ // afterUpdateTransaction runs before onAttachedToWindow, where requestFocus()/showSoftInput()
1034
+ // are dropped and setTextIsSelectable(true) would reset the caret to 0. Defer to the next loop
1035
+ // so focus sticks and the caret lands at end (matching iOS).
1036
+ requestFocus()
1037
+ setSelection(text?.length ?: 0)
1038
+ showAutoFocusKeyboardIfPending()
1039
+ }
962
1040
  }
963
1041
  }
964
1042
 
@@ -34,6 +34,25 @@ class FormattingStore {
34
34
  end: Int,
35
35
  ): Boolean = ranges.any { it.type == type && it.start < end && it.end > start }
36
36
 
37
+ /**
38
+ * Snaps a selection so it never partially overlaps an atomic link: a partial selection expands to
39
+ * the whole link, a caret inside a link moves to its end. Returns the adjusted (start, end) or null.
40
+ */
41
+ fun selectionAdjustedForAtomicLinks(
42
+ start: Int,
43
+ end: Int,
44
+ ): Pair<Int, Int>? {
45
+ if (start != end) {
46
+ var newStart = start
47
+ var newEnd = end
48
+ rangeOfType(StyleType.LINK, newStart)?.let { newStart = minOf(newStart, it.start) }
49
+ if (newEnd > 0) rangeOfType(StyleType.LINK, newEnd - 1)?.let { newEnd = maxOf(newEnd, it.end) }
50
+ return if (newStart != start || newEnd != end) Pair(newStart, newEnd) else null
51
+ }
52
+ val link = rangeOfType(StyleType.LINK, start)
53
+ return if (link != null && start > link.start && start < link.end) Pair(link.end, link.end) else null
54
+ }
55
+
37
56
  fun addRange(newRange: FormattingRange) {
38
57
  var mergedStart = newRange.start
39
58
  var mergedEnd = newRange.end
@@ -7,6 +7,7 @@ import com.swmansion.enriched.markdown.parser.MarkdownASTNode
7
7
  import com.swmansion.enriched.markdown.parser.MarkdownASTNode.NodeType
8
8
  import com.swmansion.enriched.markdown.parser.Md4cFlags
9
9
  import com.swmansion.enriched.markdown.parser.Parser
10
+ import com.swmansion.enriched.markdown.parser.isTopLevelBlock
10
11
  import com.swmansion.enriched.markdown.utils.common.FeatureFlags
11
12
 
12
13
  data class ParseResult(
@@ -18,6 +19,9 @@ data class ParseResult(
18
19
  object InputParser {
19
20
  private const val ATTACHMENT_CHAR = ''
20
21
 
22
+ // A source paragraph break: a newline followed by one or more blank lines.
23
+ private val PARAGRAPH_BREAK_RE = Regex("\\n[ \\t]*(?:\\n[ \\t]*)+")
24
+
21
25
  fun parseToPlainTextAndRanges(markdown: String): ParseResult {
22
26
  if (markdown.isEmpty()) {
23
27
  return ParseResult("", emptyList())
@@ -36,7 +40,17 @@ object InputParser {
36
40
  val ranges = mutableListOf<FormattingRange>()
37
41
  val mathAtoms = mutableListOf<MathAtom>()
38
42
 
39
- walkNode(ast, plainText, ranges, mathAtoms, ArrayDeque())
43
+ // md4c collapses any blank-line run into a single break, unlike iOS which keeps them.
44
+ // Re-read the real runs so each break replays its original number of newlines.
45
+ val blankRuns =
46
+ ArrayDeque(
47
+ PARAGRAPH_BREAK_RE
48
+ .findAll(completed.trim())
49
+ .map { match -> match.value.count { it == '\n' } }
50
+ .toList(),
51
+ )
52
+
53
+ walkNode(ast, plainText, ranges, mathAtoms, ArrayDeque(), blankRuns)
40
54
 
41
55
  return ParseResult(plainText.toString(), ranges, mathAtoms)
42
56
  }
@@ -47,6 +61,7 @@ object InputParser {
47
61
  ranges: MutableList<FormattingRange>,
48
62
  mathAtoms: MutableList<MathAtom>,
49
63
  activeStyles: ArrayDeque<ActiveStyle>,
64
+ blankRuns: ArrayDeque<Int>,
50
65
  ) {
51
66
  if (node.type == NodeType.LatexMathInline || node.type == NodeType.LatexMathDisplay) {
52
67
  val latex = extractLatex(node)
@@ -75,8 +90,12 @@ object InputParser {
75
90
  plainText.append("\n")
76
91
  }
77
92
 
78
- for (child in node.children) {
79
- walkNode(child, plainText, ranges, mathAtoms, activeStyles)
93
+ for ((index, child) in node.children.withIndex()) {
94
+ // Keep the source's blank lines between top-level blocks (md4c drops them, iOS keeps them).
95
+ if (index > 0 && child.type.isTopLevelBlock() && plainText.isNotEmpty()) {
96
+ plainText.append("\n".repeat(blankRuns.removeFirstOrNull() ?: 2))
97
+ }
98
+ walkNode(child, plainText, ranges, mathAtoms, activeStyles, blankRuns)
80
99
  }
81
100
 
82
101
  if (styleType != null) {
@@ -9,6 +9,7 @@ object InputRemend {
9
9
 
10
10
  private val DELIMITER_PAIRS =
11
11
  arrayOf(
12
+ DelimiterPair("***", "***", true),
12
13
  DelimiterPair("**", "**", true),
13
14
  DelimiterPair("*", "*", true),
14
15
  DelimiterPair("_", "_", true),
@@ -9,6 +9,7 @@ import com.facebook.react.bridge.ReadableMap
9
9
  import com.facebook.react.uimanager.PixelUtil
10
10
  import com.facebook.yoga.YogaMeasureMode
11
11
  import com.facebook.yoga.YogaMeasureOutput
12
+ import com.swmansion.enriched.markdown.input.formatting.InputParser
12
13
  import java.util.concurrent.ConcurrentHashMap
13
14
 
14
15
  object InputMeasurementStore {
@@ -93,7 +94,12 @@ object InputMeasurementStore {
93
94
  width: Float,
94
95
  props: ReadableMap?,
95
96
  ): Long {
96
- val text = props?.getString("defaultValue") ?: props?.getString("placeholder") ?: "I"
97
+ // Measure the rendered plain text, not the raw markdown. A mention link such as
98
+ // [Label](placeholder://x) hides its URL in the source, so measuring the markdown counts those
99
+ // invisible characters and over-estimates the height into extra lines, until the next text
100
+ // change forces a re-measure. Parsing first matches what the editor actually renders.
101
+ val markdown = props?.getString("defaultValue") ?: props?.getString("placeholder") ?: "I"
102
+ val text = InputParser.parseToPlainTextAndRanges(markdown).plainText
97
103
  val fontSize = props?.getDouble("fontSize")?.toFloat() ?: 16f
98
104
  val spSize = kotlin.math.ceil(PixelUtil.toPixelFromSP(fontSize).toDouble()).toFloat()
99
105
 
@@ -14,12 +14,34 @@ import com.swmansion.enriched.markdown.input.formatting.MarkdownSerializer
14
14
  import com.swmansion.enriched.markdown.input.model.FormattingRange
15
15
  import com.swmansion.enriched.markdown.input.model.StyleType
16
16
 
17
- // TODO: Wrap all user-facing strings for localization support.
17
+ data class InputSelectionMenuConfig(
18
+ val format: Boolean = true,
19
+ val formatLabel: String = "",
20
+ val copyAsMarkdown: Boolean = true,
21
+ val copyAsMarkdownLabel: String = "",
22
+ )
23
+
24
+ data class FormatMenuConfig(
25
+ val bold: Boolean = true,
26
+ val boldLabel: String = "",
27
+ val italic: Boolean = true,
28
+ val italicLabel: String = "",
29
+ val underline: Boolean = true,
30
+ val underlineLabel: String = "",
31
+ val strikethrough: Boolean = true,
32
+ val strikethroughLabel: String = "",
33
+ val spoiler: Boolean = true,
34
+ val spoilerLabel: String = "",
35
+ val link: Boolean = true,
36
+ val linkLabel: String = "",
37
+ )
18
38
 
19
39
  class InputContextMenu(
20
40
  private val view: EnrichedMarkdownTextInputView,
21
41
  ) {
22
42
  private var customItemTexts: List<String> = emptyList()
43
+ var selectionMenuConfig: InputSelectionMenuConfig = InputSelectionMenuConfig()
44
+ var formatMenuConfig: FormatMenuConfig = FormatMenuConfig()
23
45
 
24
46
  fun setContextMenuItems(items: List<String>) {
25
47
  customItemTexts = items
@@ -42,13 +64,20 @@ class InputContextMenu(
42
64
  menu.removeGroup(FORMAT_MENU_GROUP_ID)
43
65
  menu.removeGroup(CUSTOM_MENU_GROUP_ID)
44
66
 
45
- val formatSubMenu = menu.addSubMenu(FORMAT_MENU_GROUP_ID, MENU_FORMAT_ID, 100, "Format")
46
- FORMAT_ITEMS.forEachIndexed { index, (title, _) ->
47
- formatSubMenu.add(Menu.NONE, MENU_FORMAT_ITEM_BASE + index, index, title)
67
+ if (selectionMenuConfig.format) {
68
+ val formatSubMenu =
69
+ menu.addSubMenu(FORMAT_MENU_GROUP_ID, MENU_FORMAT_ID, 100, selectionMenuConfig.formatLabel)
70
+ FORMAT_ITEMS.forEachIndexed { index, (styleType, labelOf) ->
71
+ if (isFormatItemVisible(styleType)) {
72
+ formatSubMenu.add(Menu.NONE, MENU_FORMAT_ITEM_BASE + index, index, labelOf(formatMenuConfig))
73
+ }
74
+ }
48
75
  }
49
76
 
50
77
  if (view.selectionStart < view.selectionEnd) {
51
- menu.add(FORMAT_MENU_GROUP_ID, MENU_COPY_MARKDOWN_ID, 101, "Copy as Markdown")
78
+ if (selectionMenuConfig.copyAsMarkdown) {
79
+ menu.add(FORMAT_MENU_GROUP_ID, MENU_COPY_MARKDOWN_ID, 101, selectionMenuConfig.copyAsMarkdownLabel)
80
+ }
52
81
 
53
82
  customItemTexts.forEachIndexed { index, text ->
54
83
  menu
@@ -74,7 +103,7 @@ class InputContextMenu(
74
103
 
75
104
  val formatIndex = itemId - MENU_FORMAT_ITEM_BASE
76
105
  if (formatIndex in FORMAT_ITEMS.indices) {
77
- applyFormat(FORMAT_ITEMS[formatIndex].second)
106
+ applyFormat(FORMAT_ITEMS[formatIndex].first)
78
107
  mode.finish()
79
108
  return true
80
109
  }
@@ -96,6 +125,16 @@ class InputContextMenu(
96
125
  }
97
126
  }
98
127
 
128
+ private fun isFormatItemVisible(styleType: StyleType): Boolean =
129
+ when (styleType) {
130
+ StyleType.BOLD -> formatMenuConfig.bold
131
+ StyleType.ITALIC -> formatMenuConfig.italic
132
+ StyleType.UNDERLINE -> formatMenuConfig.underline
133
+ StyleType.STRIKETHROUGH -> formatMenuConfig.strikethrough
134
+ StyleType.SPOILER -> formatMenuConfig.spoiler
135
+ StyleType.LINK -> formatMenuConfig.link
136
+ }
137
+
99
138
  private fun applyFormat(styleType: StyleType) {
100
139
  val start = view.selectionStart
101
140
  val end = view.selectionEnd
@@ -168,14 +207,16 @@ class InputContextMenu(
168
207
  private const val CUSTOM_MENU_GROUP_ID = 2000
169
208
  private const val MENU_CUSTOM_BASE = 2001
170
209
 
171
- private val FORMAT_ITEMS =
210
+ // Order here defines submenu order and the index used by
211
+ // `MENU_FORMAT_ITEM_BASE + index` when handling clicks.
212
+ private val FORMAT_ITEMS: List<Pair<StyleType, (FormatMenuConfig) -> String>> =
172
213
  listOf(
173
- "Bold" to StyleType.BOLD,
174
- "Italic" to StyleType.ITALIC,
175
- "Underline" to StyleType.UNDERLINE,
176
- "Strikethrough" to StyleType.STRIKETHROUGH,
177
- "Spoiler" to StyleType.SPOILER,
178
- "Link" to StyleType.LINK,
214
+ StyleType.BOLD to { it.boldLabel },
215
+ StyleType.ITALIC to { it.italicLabel },
216
+ StyleType.UNDERLINE to { it.underlineLabel },
217
+ StyleType.STRIKETHROUGH to { it.strikethroughLabel },
218
+ StyleType.SPOILER to { it.spoilerLabel },
219
+ StyleType.LINK to { it.linkLabel },
179
220
  )
180
221
  }
181
222
  }
@@ -41,3 +41,20 @@ data class MarkdownASTNode(
41
41
 
42
42
  fun getAttribute(key: String): String? = attributes[key]
43
43
  }
44
+
45
+ // A node type that md4c emits as a standalone block stacked vertically, as opposed to an inline span.
46
+ internal fun MarkdownASTNode.NodeType.isTopLevelBlock(): Boolean =
47
+ when (this) {
48
+ MarkdownASTNode.NodeType.Paragraph,
49
+ MarkdownASTNode.NodeType.Heading,
50
+ MarkdownASTNode.NodeType.Blockquote,
51
+ MarkdownASTNode.NodeType.UnorderedList,
52
+ MarkdownASTNode.NodeType.OrderedList,
53
+ MarkdownASTNode.NodeType.CodeBlock,
54
+ MarkdownASTNode.NodeType.ThematicBreak,
55
+ MarkdownASTNode.NodeType.Table,
56
+ MarkdownASTNode.NodeType.LatexMathDisplay,
57
+ -> true
58
+
59
+ else -> false
60
+ }
@@ -52,10 +52,10 @@ class SpanStyleCache(
52
52
  val paragraphColor = style.paragraphStyle.color
53
53
  return buildList {
54
54
  style.strongStyle.color
55
- ?.takeIf { it != 0 }
55
+ ?.takeIf { it != 0 && it != paragraphColor }
56
56
  ?.let { add(it) }
57
57
  style.emphasisStyle.color
58
- ?.takeIf { it != 0 }
58
+ ?.takeIf { it != 0 && it != paragraphColor }
59
59
  ?.let { add(it) }
60
60
  style.linkStyle.color
61
61
  .takeIf { it != 0 && it != paragraphColor }
@@ -68,10 +68,10 @@ class SpanStyleCache(
68
68
  style
69
69
  .codeStyle
70
70
  .color
71
- .takeIf { it != 0 }
71
+ .takeIf { it != 0 && it != paragraphColor }
72
72
  ?.let { add(it) }
73
73
  style.taskListStyle.checkedTextColor
74
- .takeIf { it != 0 }
74
+ .takeIf { it != 0 && it != paragraphColor }
75
75
  ?.let { add(it) }
76
76
  }.toIntArray()
77
77
  }
@@ -4,6 +4,7 @@ import android.view.View
4
4
  import com.facebook.react.bridge.ReadableArray
5
5
  import com.facebook.react.bridge.ReadableMap
6
6
  import com.facebook.react.uimanager.UIManagerHelper
7
+ import com.swmansion.enriched.markdown.accessibility.AccessibilityLabels
7
8
  import com.swmansion.enriched.markdown.events.ContextMenuItemPressEvent
8
9
  import com.swmansion.enriched.markdown.events.LinkLongPressEvent
9
10
  import com.swmansion.enriched.markdown.events.LinkPressEvent
@@ -96,5 +97,34 @@ fun parseSelectionMenuConfig(value: ReadableMap?): SelectionMenuConfig {
96
97
  return SelectionMenuConfig(
97
98
  copyAsMarkdown = value.getBoolean("copyAsMarkdown"),
98
99
  copyImageUrl = value.getBoolean("copyImageUrl"),
100
+ copyLabel = value.getString("copyLabel") ?: "",
101
+ copyAsMarkdownLabel = value.getString("copyAsMarkdownLabel") ?: "",
102
+ copyImageUrlLabel = value.getString("copyImageUrlLabel") ?: "",
103
+ copyImageUrlsLabel = value.getString("copyImageUrlsLabel") ?: "",
104
+ copyImageUrlPluralTemplates = parseStringList(value.getArray("copyImageUrlPluralTemplates")),
99
105
  )
100
106
  }
107
+
108
+ fun parseAccessibilityLabels(value: ReadableMap?): AccessibilityLabels {
109
+ if (value == null) return AccessibilityLabels()
110
+ val list = value.getMap("list")
111
+ val blockquote = value.getMap("blockquote")
112
+ val table = value.getMap("table")
113
+ val math = value.getMap("math")
114
+ val rotor = value.getMap("rotor")
115
+ return AccessibilityLabels(
116
+ bulletPoint = list?.getString("bulletPoint") ?: "",
117
+ nestedBulletPoint = list?.getString("nestedBulletPoint") ?: "",
118
+ orderedItem = list?.getString("orderedItem") ?: "",
119
+ nestedOrderedItem = list?.getString("nestedOrderedItem") ?: "",
120
+ blockquote = blockquote?.getString("quote") ?: "",
121
+ nestedBlockquote = blockquote?.getString("nestedQuote") ?: "",
122
+ tableRow = table?.getString("row") ?: "",
123
+ mathEquation = math?.getString("equation") ?: "",
124
+ rotorHeadings = rotor?.getString("headings") ?: "",
125
+ rotorLinks = rotor?.getString("links") ?: "",
126
+ rotorImages = rotor?.getString("images") ?: "",
127
+ )
128
+ }
129
+
130
+ private fun parseStringList(value: ReadableArray?): List<String> = (0 until (value?.size() ?: 0)).mapNotNull { value?.getString(it) }
@@ -26,18 +26,40 @@ fun View.emitLinkLongPressEvent(url: String) {
26
26
  /**
27
27
  * Cancels the JS touch for an active link tap, preventing parent
28
28
  * Pressable/TouchableOpacity from firing onPress for the same tap.
29
+ *
30
+ * Also requests that parent ViewGroups do not intercept subsequent touch
31
+ * events in this gesture. This is necessary for react-native-gesture-handler's
32
+ * Pressable, which intercepts at the native ViewGroup level and would otherwise
33
+ * steal the gesture before ACTION_UP reaches the TextView.
29
34
  */
30
35
  fun TextView.cancelJSTouchForLinkTap(event: MotionEvent) {
31
36
  val currentMovementMethod = movementMethod
32
37
  if (currentMovementMethod is LinkLongPressMovementMethod && currentMovementMethod.isLinkTouchActive) {
38
+ parent?.requestDisallowInterceptTouchEvent(true)
33
39
  NativeGestureUtil.notifyNativeGestureStarted(this, event)
34
40
  }
35
41
  }
36
42
 
43
+ /**
44
+ * Re-allows parent ViewGroups to intercept touch events once the link
45
+ * touch is no longer active (slop exceeded, gesture ended, or cancelled).
46
+ * This restores normal scrolling behavior for parent ScrollView/RecyclerView.
47
+ *
48
+ * Must be called after [LinkLongPressMovementMethod.onTouchEvent] has
49
+ * processed the event, since that is where [isLinkTouchActive] is updated.
50
+ */
51
+ fun TextView.reallowParentInterceptIfLinkReleased() {
52
+ val currentMovementMethod = movementMethod
53
+ if (currentMovementMethod is LinkLongPressMovementMethod && !currentMovementMethod.isLinkTouchActive) {
54
+ parent?.requestDisallowInterceptTouchEvent(false)
55
+ }
56
+ }
57
+
37
58
  /**
38
59
  * Cancels the JS touch unconditionally, preventing parent
39
60
  * Pressable/TouchableOpacity from firing onPress for the same gesture.
40
61
  */
41
62
  fun View.cancelJSTouchForCheckboxTap(event: MotionEvent) {
63
+ parent?.requestDisallowInterceptTouchEvent(true)
42
64
  NativeGestureUtil.notifyNativeGestureStarted(this, event)
43
65
  }