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

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 +55 -3
  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 +194 -9
  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
@@ -954,11 +992,25 @@ class EnrichedMarkdownTextInputView(
954
992
  setSelection(selectionStart.coerceAtLeast(0))
955
993
  }
956
994
 
995
+ private fun showAutoFocusKeyboardIfPending() {
996
+ if (!pendingAutoFocusKeyboard || !hasWindowFocus()) return
997
+ pendingAutoFocusKeyboard = false
998
+ inputMethodManager?.showSoftInput(this, 0)
999
+ }
1000
+
957
1001
  fun afterUpdateTransaction() {
958
1002
  updateTypeface()
959
1003
  if (autoFocusRequested) {
960
1004
  autoFocusRequested = false
961
- requestFocusProgrammatically()
1005
+ pendingAutoFocusKeyboard = true
1006
+ post {
1007
+ // afterUpdateTransaction runs before onAttachedToWindow, where requestFocus()/showSoftInput()
1008
+ // are dropped and setTextIsSelectable(true) would reset the caret to 0. Defer to the next loop
1009
+ // so focus sticks and the caret lands at end (matching iOS).
1010
+ requestFocus()
1011
+ setSelection(text?.length ?: 0)
1012
+ showAutoFocusKeyboardIfPending()
1013
+ }
962
1014
  }
963
1015
  }
964
1016
 
@@ -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
  }
@@ -25,8 +25,30 @@ private const val MENU_ITEM_CUSTOM_GROUP = 2001
25
25
  data class SelectionMenuConfig(
26
26
  val copyAsMarkdown: Boolean = true,
27
27
  val copyImageUrl: Boolean = true,
28
+ val copyLabel: String = "",
29
+ val copyAsMarkdownLabel: String = "",
30
+ val copyImageUrlLabel: String = "",
31
+ val copyImageUrlsLabel: String = "",
32
+ // Plural templates indexed by image count (0..100). Empty when no pluralLabels
33
+ // are set; counts > 100 use copyImageUrlsLabel (the "other" form).
34
+ val copyImageUrlPluralTemplates: List<String> = emptyList(),
28
35
  )
29
36
 
37
+ /**
38
+ * Resolves the "Copy Image URL(s)" menu title for the given image count. Uses the
39
+ * precomputed plural templates when present (counts > 100 use the "other" form),
40
+ * otherwise the singular/`{count}` templates. All labels are resolved JS-side.
41
+ */
42
+ private fun SelectionMenuConfig.imageUrlsTitle(count: Int): String {
43
+ val template =
44
+ when {
45
+ copyImageUrlPluralTemplates.isNotEmpty() && count in 0..100 -> copyImageUrlPluralTemplates[count]
46
+ count == 1 -> copyImageUrlLabel
47
+ else -> copyImageUrlsLabel
48
+ }
49
+ return template.replace("{count}", count.toString())
50
+ }
51
+
30
52
  /**
31
53
  * Creates an ActionMode.Callback that adds custom copy options and
32
54
  * overrides the default "Copy" action to include HTML for rich text support.
@@ -56,12 +78,24 @@ fun createSelectionActionModeCallback(
56
78
 
57
79
  val selectionMenuConfig = getSelectionMenuConfig()
58
80
 
81
+ // The system Copy item is added by the platform; relabel it so it
82
+ // matches the rest of the localized menu (iOS rebuilds the menu so
83
+ // gets this for free; Android only intercepts the click).
84
+ if (selectionMenuConfig.copyLabel.isNotEmpty()) {
85
+ menu.findItem(android.R.id.copy)?.title = selectionMenuConfig.copyLabel
86
+ }
87
+
59
88
  if (
60
89
  selectionMenuConfig.copyAsMarkdown &&
61
90
  textView.selectionStart >= 0 &&
62
91
  textView.selectionEnd > textView.selectionStart
63
92
  ) {
64
- menu.add(Menu.NONE, MENU_ITEM_COPY_MARKDOWN, Menu.NONE, "Copy as Markdown")
93
+ menu.add(
94
+ Menu.NONE,
95
+ MENU_ITEM_COPY_MARKDOWN,
96
+ Menu.NONE,
97
+ selectionMenuConfig.copyAsMarkdownLabel,
98
+ )
65
99
  }
66
100
 
67
101
  if (textView.selectionStart >= 0 && textView.selectionEnd > textView.selectionStart) {
@@ -80,12 +114,7 @@ fun createSelectionActionModeCallback(
80
114
  emptyList()
81
115
  }
82
116
  if (imageUrls.isNotEmpty()) {
83
- val title =
84
- if (imageUrls.size == 1) {
85
- "Copy Image URL"
86
- } else {
87
- "Copy ${imageUrls.size} Image URLs"
88
- }
117
+ val title = selectionMenuConfig.imageUrlsTitle(imageUrls.size)
89
118
  menu.add(Menu.NONE, MENU_ITEM_COPY_IMAGE_URL, Menu.NONE, title)
90
119
  }
91
120