react-native-enriched-markdown-scaffold 0.7.4-scaffold.1 → 0.7.4-scaffold.11

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 (120) hide show
  1. package/android/build.gradle +5 -0
  2. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextInputManagerDelegate.java +9 -0
  3. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextInputManagerInterface.java +3 -0
  4. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp +10 -0
  5. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.h +7 -0
  6. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.cpp +10 -0
  7. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +50 -0
  8. package/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +6 -4
  9. package/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt +38 -0
  10. package/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt +174 -9
  11. package/android/src/main/java/com/swmansion/enriched/markdown/input/events/OnPasteEvent.kt +24 -0
  12. package/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/BlockSyntaxSentinel.kt +69 -0
  13. package/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputParser.kt +71 -12
  14. package/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputEventEmitter.kt +8 -0
  15. package/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputLayoutManager.kt +30 -2
  16. package/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputMeasurementStore.kt +7 -2
  17. package/android/src/main/java/com/swmansion/enriched/markdown/input/model/MathChipStyle.kt +3 -0
  18. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/HeadingRenderer.kt +1 -0
  19. package/android/src/main/java/com/swmansion/enriched/markdown/spans/HeadingSpan.kt +8 -1
  20. package/android/src/main/java/com/swmansion/enriched/markdown/spans/LineHeightSpan.kt +27 -1
  21. package/android/src/main/java/com/swmansion/enriched/markdown/styles/InlineMathStyle.kt +5 -1
  22. package/android/src/main/java/com/swmansion/enriched/markdown/styles/MathFontWeight.kt +25 -0
  23. package/android/src/main/java/com/swmansion/enriched/markdown/styles/MathStyle.kt +12 -1
  24. package/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt +7 -7
  25. package/android/src/main/java/com/swmansion/enriched/markdown/utils/input/MarkdownStyleParser.kt +10 -0
  26. package/android/src/main/java/com/swmansion/enriched/markdown/utils/input/PasteTextUtils.kt +20 -0
  27. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/extensions/SpannableExtensions.kt +12 -4
  28. package/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt +1 -1
  29. package/android/src/math/java/com/swmansion/enriched/markdown/renderer/MathInlineRenderer.kt +2 -0
  30. package/android/src/math/java/com/swmansion/enriched/markdown/spans/MathChipSpan.kt +1 -1
  31. package/android/src/math/java/com/swmansion/enriched/markdown/spans/MathInlinePlaceholderSpan.kt +7 -3
  32. package/android/src/math/java/com/swmansion/enriched/markdown/spans/MathInlineSpan.kt +8 -5
  33. package/android/src/math/java/com/swmansion/enriched/markdown/spans/MathTypeface.kt +30 -0
  34. package/android/src/math/java/com/swmansion/enriched/markdown/views/MathContainerView.kt +3 -1
  35. package/docs/API_REFERENCE.md +43 -0
  36. package/docs/INPUT.md +87 -0
  37. package/docs/LATEX_MATH.md +62 -0
  38. package/docs/STYLES.md +3 -0
  39. package/ios/EnrichedMarkdown.mm +44 -12
  40. package/ios/EnrichedMarkdownText.mm +47 -11
  41. package/ios/attachments/ENRMMathInlineAttachment+macOS.m +5 -1
  42. package/ios/attachments/ENRMMathInlineAttachment.h +3 -0
  43. package/ios/attachments/ENRMMathInlineAttachment.m +2 -1
  44. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.cpp +10 -0
  45. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.h +7 -0
  46. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.cpp +10 -0
  47. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h +50 -0
  48. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/RCTComponentViewHelpers.h +29 -0
  49. package/ios/input/ENRMBlockSyntaxSentinel.h +25 -0
  50. package/ios/input/ENRMBlockSyntaxSentinel.mm +80 -0
  51. package/ios/input/ENRMInputFormatter.h +4 -0
  52. package/ios/input/ENRMInputFormatter.mm +2 -0
  53. package/ios/input/ENRMInputParser.h +5 -0
  54. package/ios/input/ENRMInputParser.mm +79 -6
  55. package/ios/input/ENRMInputTextView.mm +11 -1
  56. package/ios/input/EnrichedMarkdownTextInput.h +5 -0
  57. package/ios/input/EnrichedMarkdownTextInput.mm +88 -22
  58. package/ios/input/InputStylePropsUtils.h +13 -0
  59. package/ios/internals/MeasurementCache.h +1 -0
  60. package/ios/math/ENRMRaTeXBridge.swift +52 -3
  61. package/ios/parser/ENRMMarkdownParser.h +8 -0
  62. package/ios/parser/ENRMMarkdownParser.mm +11 -0
  63. package/ios/renderer/ENRMMathInlineRenderer.m +3 -0
  64. package/ios/renderer/ListItemRenderer.m +8 -5
  65. package/ios/styles/StyleConfig.h +6 -0
  66. package/ios/styles/StyleConfig.mm +36 -0
  67. package/ios/utils/ENRMMathFontWeight.h +18 -0
  68. package/ios/utils/StylePropsUtils.h +21 -0
  69. package/ios/views/ENRMMathContainerView.m +3 -1
  70. package/lib/module/EnrichedMarkdownNativeComponent.ts +3 -0
  71. package/lib/module/EnrichedMarkdownTextInput.js +17 -0
  72. package/lib/module/EnrichedMarkdownTextInput.js.map +1 -1
  73. package/lib/module/EnrichedMarkdownTextInputNativeComponent.ts +34 -0
  74. package/lib/module/EnrichedMarkdownTextNativeComponent.ts +3 -0
  75. package/lib/module/index.js.map +1 -1
  76. package/lib/module/normalizeMarkdownStyle.js +5 -2
  77. package/lib/module/normalizeMarkdownStyle.js.map +1 -1
  78. package/lib/module/normalizeMarkdownStyle.web.js +5 -2
  79. package/lib/module/normalizeMarkdownStyle.web.js.map +1 -1
  80. package/lib/module/normalizeMarkdownTextInputStyle.js +7 -3
  81. package/lib/module/normalizeMarkdownTextInputStyle.js.map +1 -1
  82. package/lib/module/web/globalStyles.js +8 -1
  83. package/lib/module/web/globalStyles.js.map +1 -1
  84. package/lib/module/web/styles.js +3 -1
  85. package/lib/module/web/styles.js.map +1 -1
  86. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts +3 -0
  87. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts.map +1 -1
  88. package/lib/typescript/src/EnrichedMarkdownTextInput.d.ts +26 -1
  89. package/lib/typescript/src/EnrichedMarkdownTextInput.d.ts.map +1 -1
  90. package/lib/typescript/src/EnrichedMarkdownTextInputNativeComponent.d.ts +28 -0
  91. package/lib/typescript/src/EnrichedMarkdownTextInputNativeComponent.d.ts.map +1 -1
  92. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +3 -0
  93. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts.map +1 -1
  94. package/lib/typescript/src/index.d.ts +1 -1
  95. package/lib/typescript/src/index.d.ts.map +1 -1
  96. package/lib/typescript/src/normalizeMarkdownStyle.d.ts.map +1 -1
  97. package/lib/typescript/src/normalizeMarkdownStyle.web.d.ts.map +1 -1
  98. package/lib/typescript/src/normalizeMarkdownTextInputStyle.d.ts +1 -0
  99. package/lib/typescript/src/normalizeMarkdownTextInputStyle.d.ts.map +1 -1
  100. package/lib/typescript/src/types/MarkdownStyle.d.ts +32 -0
  101. package/lib/typescript/src/types/MarkdownStyle.d.ts.map +1 -1
  102. package/lib/typescript/src/types/MarkdownStyleInternal.d.ts +3 -0
  103. package/lib/typescript/src/types/MarkdownStyleInternal.d.ts.map +1 -1
  104. package/lib/typescript/src/types/events.d.ts +15 -0
  105. package/lib/typescript/src/types/events.d.ts.map +1 -1
  106. package/lib/typescript/src/web/styles.d.ts.map +1 -1
  107. package/package.json +1 -1
  108. package/src/EnrichedMarkdownNativeComponent.ts +3 -0
  109. package/src/EnrichedMarkdownTextInput.tsx +41 -0
  110. package/src/EnrichedMarkdownTextInputNativeComponent.ts +34 -0
  111. package/src/EnrichedMarkdownTextNativeComponent.ts +3 -0
  112. package/src/index.tsx +1 -0
  113. package/src/normalizeMarkdownStyle.ts +6 -1
  114. package/src/normalizeMarkdownStyle.web.ts +6 -1
  115. package/src/normalizeMarkdownTextInputStyle.ts +5 -0
  116. package/src/types/MarkdownStyle.ts +32 -0
  117. package/src/types/MarkdownStyleInternal.ts +3 -0
  118. package/src/types/events.ts +16 -0
  119. package/src/web/globalStyles.ts +7 -0
  120. package/src/web/styles.ts +5 -1
@@ -20,6 +20,9 @@ import android.view.inputmethod.EditorInfo
20
20
  import android.view.inputmethod.InputConnection
21
21
  import android.view.inputmethod.InputMethodManager
22
22
  import androidx.appcompat.widget.AppCompatEditText
23
+ import androidx.core.view.ContentInfoCompat
24
+ import androidx.core.view.OnReceiveContentListener
25
+ import androidx.core.view.ViewCompat
23
26
  import com.facebook.react.common.ReactConstants
24
27
  import com.facebook.react.uimanager.BackgroundStyleApplicator
25
28
  import com.facebook.react.uimanager.PixelUtil
@@ -44,6 +47,7 @@ import com.swmansion.enriched.markdown.input.model.MathChipStyle
44
47
  import com.swmansion.enriched.markdown.input.model.StyleType
45
48
  import com.swmansion.enriched.markdown.input.toolbar.InputContextMenu
46
49
  import com.swmansion.enriched.markdown.utils.input.AutoCapitalizeUtils
50
+ import com.swmansion.enriched.markdown.utils.input.PasteTextUtils
47
51
  import kotlin.math.ceil
48
52
 
49
53
  private fun Char.isLineBreak(): Boolean = this == '\n' || this == '\r' || this == '\u0085' || this == '\u2028' || this == '\u2029'
@@ -97,6 +101,36 @@ class EnrichedMarkdownTextInputView(
97
101
  private var detectScrollMovement = false
98
102
  var scrollEnabled: Boolean = true
99
103
 
104
+ // When on, a paste is reported to JS instead of inserted. Native never touches
105
+ // the buffer here (see resolvePaste): if JS dies mid-handler the paste is a
106
+ // visible no-op rather than a selection that silently vanished.
107
+ //
108
+ // The listener is installed only while the prop is on. Registering it
109
+ // unconditionally would route every consumer's paste through AppCompat's
110
+ // receive-content path even with the feature off, which is a behavior change
111
+ // nobody asked for.
112
+ var interceptPaste: Boolean = false
113
+ set(value) {
114
+ if (field == value) return
115
+ field = value
116
+ ViewCompat.setOnReceiveContentListener(
117
+ this,
118
+ if (value) PASTE_MIME_TYPES else null,
119
+ if (value) receiveContentListener else null,
120
+ )
121
+ }
122
+
123
+ // The markdown last handed over by JS, kept so `blockSyntax` can re-parse it. @ReactProp
124
+ // setters fire in undefined order, so `defaultValue` regularly lands before the flag does.
125
+ private var lastMarkdownFromJS: String? = null
126
+
127
+ var blockSyntax: Boolean = true
128
+ set(value) {
129
+ if (field == value) return
130
+ field = value
131
+ lastMarkdownFromJS?.let { setValueFromJS(it) }
132
+ }
133
+
100
134
  private var mentionIndicators: LinkedHashSet<String> = linkedSetOf()
101
135
  private var activeMentionIndicator: String? = null
102
136
  private var activeMentionStart = -1
@@ -477,6 +511,98 @@ class EnrichedMarkdownTextInputView(
477
511
  clipboard.setPrimaryClip(ClipData.newPlainText(null, content))
478
512
  }
479
513
 
514
+ // Paste arrives by two different routes and only the first one is a menu.
515
+ // `onTextContextMenuItem` covers the context menu and the hardware Ctrl+V /
516
+ // KEYCODE_PASTE shortcuts; drag-and-drop, autofill and IME `commitContent`
517
+ // reach the view through `onReceiveContent` instead. Hooking only one of them
518
+ // leaves the other pasting straight into the buffer with nobody intercepting.
519
+ override fun onTextContextMenuItem(id: Int): Boolean {
520
+ val isPaste = id == android.R.id.paste || id == android.R.id.pasteAsPlainText
521
+ if (isPaste && interceptPaste) {
522
+ val text = readClipboardText()
523
+ if (text.isNotEmpty()) {
524
+ eventEmitter.emitPaste(text, "")
525
+ // Stops super, which is what keeps this single-emit: AppCompat routes
526
+ // its own paste handling through the receive-content listener below.
527
+ return true
528
+ }
529
+ // Nothing textual in the clip: hand it to the platform, matching what the
530
+ // listener does with the items it does not claim. Both routes then decide
531
+ // from the same rule, so a clip never behaves differently depending on
532
+ // whether it came from the menu or from Ctrl+V.
533
+ }
534
+ return super.onTextContextMenuItem(id)
535
+ }
536
+
537
+ private val receiveContentListener =
538
+ OnReceiveContentListener { _, payload ->
539
+ // The listener also sees autofill, drag-and-drop, IME commitContent and
540
+ // PROCESS_TEXT. Without this filter an autofill would turn into a paste
541
+ // event — and a consumer would turn that into an attachment chip.
542
+ if (!interceptPaste || payload.source != ContentInfoCompat.SOURCE_CLIPBOARD) {
543
+ return@OnReceiveContentListener payload
544
+ }
545
+ val parts = payload.partition { it.text != null }
546
+ parts.first?.let { accepted ->
547
+ val clip = accepted.clip
548
+ val text = PasteTextUtils.joinClipTexts((0 until clip.itemCount).map { clip.getItemAt(it).text })
549
+ if (text.isNotEmpty()) {
550
+ eventEmitter.emitPaste(text, "")
551
+ }
552
+ }
553
+ // Hand back only what we did not claim, so the buffer stays untouched.
554
+ parts.second
555
+ }
556
+
557
+ private fun readClipboardText(): String {
558
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return ""
559
+ val clip = clipboard.primaryClip ?: return ""
560
+ // `text`, not `coerceToText`: the listener can only claim items that already
561
+ // carry text, and the two routes have to agree on what counts as a paste.
562
+ return PasteTextUtils.joinClipTexts((0 until clip.itemCount).map { clip.getItemAt(it).text })
563
+ }
564
+
565
+ /**
566
+ * Completes a paste reported through `onPaste`. `markdown` is accepted for
567
+ * parity with iOS but ignored here: Android's clipboard is plain text only
568
+ * (see `copyToClipboard`), so there is no flavor to restore.
569
+ *
570
+ * Resolving with an empty string and an empty selection is a zero-length edit
571
+ * — it still emits, which keeps the JS contract identical on both platforms.
572
+ */
573
+ fun resolvePaste(
574
+ pastedText: String,
575
+ @Suppress("UNUSED_PARAMETER") markdown: String,
576
+ ) {
577
+ val editable = text ?: return
578
+ val selStart = selectionStart.coerceAtLeast(0)
579
+ val selEnd = selectionEnd.coerceAtLeast(selStart)
580
+ val safeStart = selStart.coerceAtMost(editable.length)
581
+ val safeEnd = selEnd.coerceAtMost(editable.length)
582
+
583
+ isProcessingTextChange = true
584
+ try {
585
+ editable.replace(safeStart, safeEnd, pastedText)
586
+ formattingStore.adjustForEdit(safeStart, safeEnd - safeStart, pastedText.length)
587
+ lastProcessedText = editable.toString()
588
+ setSelection(safeStart + pastedText.length)
589
+ } finally {
590
+ isProcessingTextChange = false
591
+ }
592
+
593
+ applyFormatting()
594
+ // Parity with the un-intercepted paste, which reached the detectors through
595
+ // the text watcher: a pasted URL still autolinks.
596
+ detectorPipeline.processTextChange(editable, editable.toString(), safeStart, pastedText.length)
597
+ invalidate()
598
+ requestLayout()
599
+ eventEmitter.emitChangeText()
600
+ if (emitMarkdown) eventEmitter.emitChangeMarkdown()
601
+ eventEmitter.emitSelection(selectionStart, selectionEnd)
602
+ eventEmitter.emitState()
603
+ eventEmitter.emitCaretRectChangeIfNeeded()
604
+ }
605
+
480
606
  fun setLinkForSelection(url: String) {
481
607
  val selStart = selectionStart
482
608
  val selEnd = selectionEnd
@@ -634,7 +760,8 @@ class EnrichedMarkdownTextInputView(
634
760
  }
635
761
 
636
762
  fun setValueFromJS(markdown: String) {
637
- val parsed = InputParser.parseToPlainTextAndRanges(markdown)
763
+ lastMarkdownFromJS = markdown
764
+ val parsed = InputParser.parseToPlainTextAndRanges(markdown, blockSyntax)
638
765
  blockEmitting = true
639
766
  try {
640
767
  runAsATransaction {
@@ -708,8 +835,16 @@ class EnrichedMarkdownTextInputView(
708
835
  }
709
836
  invalidate()
710
837
  requestLayout()
838
+ // Chips only reach the text here when `defaultValue` landed before `markdownStyle`
839
+ // (@ReactProp order is undefined). requestLayout() cannot change a height Fabric owns —
840
+ // the shadow tree has to re-measure text that now carries the spans.
841
+ layoutManager.invalidateLayout()
711
842
  } else if (mathStyleChanged) {
712
843
  refreshAllMathChips()
844
+ // A math font size / padding change resizes every chip, so the measured height moves.
845
+ // Colour-only changes end up here too; store() then measures the same size and skips
846
+ // the state update.
847
+ layoutManager.invalidateLayout()
713
848
  }
714
849
  return changed
715
850
  }
@@ -848,18 +983,36 @@ class EnrichedMarkdownTextInputView(
848
983
  return true
849
984
  }
850
985
 
851
- private fun computeChipBounds(
986
+ /** Chip glyph rect in [Layout] coordinates — no padding, no scroll offset. */
987
+ private fun chipBoundsInLayout(
852
988
  layout: Layout,
853
989
  start: Int,
854
990
  ): Rect {
855
991
  val line = layout.getLineForOffset(start)
856
- val xStart = (layout.getPrimaryHorizontal(start)).toInt() + totalPaddingLeft - scrollX
857
- val xEnd = (layout.getPrimaryHorizontal(start + 1)).toInt() + totalPaddingLeft - scrollX
858
- val top = layout.getLineTop(line) + totalPaddingTop - scrollY
859
- val bottom = layout.getLineBottom(line) + totalPaddingTop - scrollY
860
- return Rect(xStart, top, xEnd, bottom)
992
+ val xStart = layout.getPrimaryHorizontal(start).toInt()
993
+ val xEnd = layout.getPrimaryHorizontal(start + 1).toInt()
994
+ // Ordered, not raw start/end: in an RTL run xStart > xEnd, which would make an
995
+ // inverted Rect that contains() always rejects.
996
+ val left = xStart.coerceAtMost(xEnd)
997
+ var right = xStart.coerceAtLeast(xEnd)
998
+ if (right <= left) {
999
+ // A chip that is the last character has start + 1 == length, where some layouts
1000
+ // report the same horizontal for both offsets. Fall back to the line's content edge
1001
+ // so the rect keeps a real width instead of collapsing.
1002
+ right = layout.getLineRight(line).toInt()
1003
+ }
1004
+ return Rect(left, layout.getLineTop(line), right, layout.getLineBottom(line))
861
1005
  }
862
1006
 
1007
+ /** Chip glyph rect in view coordinates, for reporting to JS. */
1008
+ private fun computeChipBounds(
1009
+ layout: Layout,
1010
+ start: Int,
1011
+ ): Rect =
1012
+ chipBoundsInLayout(layout, start).apply {
1013
+ offset(totalPaddingLeft - scrollX, totalPaddingTop - scrollY)
1014
+ }
1015
+
863
1016
  fun mathChipPositionAtTouch(
864
1017
  x: Float,
865
1018
  y: Float,
@@ -869,13 +1022,21 @@ class EnrichedMarkdownTextInputView(
869
1022
  val layoutNow = layout ?: return -1
870
1023
  val adjustedX = (x - totalPaddingLeft + scrollX).toInt()
871
1024
  val adjustedY = (y - totalPaddingTop + scrollY).toInt()
872
- if (adjustedY < 0) return -1
1025
+ // getLineForVertical and getOffsetForHorizontal both clamp into the laid-out text, so
1026
+ // a touch in the padding or in the blank space below the last line resolves onto the
1027
+ // nearest character. Bound the touch to the text box, then require it to land inside
1028
+ // the chip's own rect — otherwise a tap beside a chip reads as a tap on it.
1029
+ if (adjustedY < 0 || adjustedY > layoutNow.height) return -1
873
1030
  val line = layoutNow.getLineForVertical(adjustedY)
874
1031
  val offset = layoutNow.getOffsetForHorizontal(line, adjustedX.toFloat())
875
- if (offset < 0 || offset >= editable.length) return -1
1032
+ if (offset < 0) return -1
876
1033
  val candidates = intArrayOf(offset, offset - 1).filter { it in 0 until editable.length }
877
1034
  for (candidate in candidates) {
878
1035
  val range = controller.chipRangeAt(editable, candidate) ?: continue
1036
+ // An empty rect means the layout could not give us the chip's extent; accept the
1037
+ // candidate then, rather than making the chip untappable.
1038
+ val bounds = chipBoundsInLayout(layoutNow, range.first)
1039
+ if (!bounds.isEmpty && !bounds.contains(adjustedX, adjustedY)) continue
879
1040
  return range.first
880
1041
  }
881
1042
  return -1
@@ -1109,6 +1270,10 @@ class EnrichedMarkdownTextInputView(
1109
1270
  .replace("(", "%28")
1110
1271
  .replace(")", "%29")
1111
1272
 
1273
+ private companion object {
1274
+ private val PASTE_MIME_TYPES = arrayOf("text/*")
1275
+ }
1276
+
1112
1277
  private data class MentionCandidate(
1113
1278
  val indicator: String,
1114
1279
  val start: Int,
@@ -0,0 +1,24 @@
1
+ package com.swmansion.enriched.markdown.input.events
2
+
3
+ import com.facebook.react.bridge.Arguments
4
+ import com.facebook.react.bridge.WritableMap
5
+ import com.facebook.react.uimanager.events.Event
6
+
7
+ class OnPasteEvent(
8
+ surfaceId: Int,
9
+ viewId: Int,
10
+ private val text: String,
11
+ private val markdown: String,
12
+ ) : Event<OnPasteEvent>(surfaceId, viewId) {
13
+ override fun getEventName(): String = EVENT_NAME
14
+
15
+ override fun getEventData(): WritableMap =
16
+ Arguments.createMap().apply {
17
+ putString("text", text)
18
+ putString("markdown", markdown)
19
+ }
20
+
21
+ companion object {
22
+ const val EVENT_NAME = "onPaste"
23
+ }
24
+ }
@@ -0,0 +1,69 @@
1
+ package com.swmansion.enriched.markdown.input.formatting
2
+
3
+ /**
4
+ * Hides block-level markdown structure from md4c so a document parses as a single
5
+ * `Paragraph`. Used by `blockSyntax = false` on `EnrichedMarkdownTextInput`.
6
+ *
7
+ * Every newline is swapped for a vertical tab (`0x0B`) and one more is prepended.
8
+ * md4c treats VT as ordinary whitespace, never as a line break, so no line ever
9
+ * starts at a position where a list marker, `#`, `>` or an indented code block can
10
+ * be recognised — including line 1, which is what the leading sentinel is for.
11
+ * Inline syntax (`$...$`, `**...**`, links) still parses normally.
12
+ *
13
+ * The transform is 1:1 in characters *and* in UTF-8 bytes (VT and `\n` are both one
14
+ * of each), so offsets coming back out of the parser only need a flat -1 shift for
15
+ * the leading sentinel. iOS relies on the same property to keep its byte-to-UTF16
16
+ * map valid.
17
+ */
18
+ internal object BlockSyntaxSentinel {
19
+ const val SENTINEL = '\u000B'
20
+
21
+ /**
22
+ * `\r\n` / `\r` collapse to `\n` (md4c normalizes line endings this way regardless,
23
+ * so this is pre-existing lossiness, not a new one), and any `0x0B` already present
24
+ * is dropped — leaving it in would make [decode] silently turn it into a newline.
25
+ */
26
+ fun canonicalize(markdown: String): String {
27
+ val out = StringBuilder(markdown.length)
28
+ var i = 0
29
+ while (i < markdown.length) {
30
+ when (markdown[i]) {
31
+ '\r' -> {
32
+ out.append('\n')
33
+ if (i + 1 < markdown.length && markdown[i + 1] == '\n') i++
34
+ }
35
+
36
+ SENTINEL -> {}
37
+
38
+ else -> {
39
+ out.append(markdown[i])
40
+ }
41
+ }
42
+ i++
43
+ }
44
+ return out.toString()
45
+ }
46
+
47
+ /** Prepends one sentinel and replaces every `\n` with one. Length grows by exactly 1. */
48
+ fun encode(canonical: String): String {
49
+ val out = StringBuilder(canonical.length + 1)
50
+ out.append(SENTINEL)
51
+ for (c in canonical) out.append(if (c == '\n') SENTINEL else c)
52
+ return out.toString()
53
+ }
54
+
55
+ /**
56
+ * Reverses [encode] on the parser's plain text. Returns `null` when the leading
57
+ * sentinel is missing — the caller then falls back to the canonical source with no
58
+ * ranges rather than emitting text shifted by an offset that no longer holds.
59
+ */
60
+ fun decode(plainText: String): String? {
61
+ if (plainText.isEmpty() || plainText[0] != SENTINEL) return null
62
+ val out = StringBuilder(plainText.length - 1)
63
+ for (i in 1 until plainText.length) {
64
+ val c = plainText[i]
65
+ out.append(if (c == SENTINEL) '\n' else c)
66
+ }
67
+ return out.toString()
68
+ }
69
+ }
@@ -22,19 +22,73 @@ object InputParser {
22
22
  // A source paragraph break: a newline followed by one or more blank lines.
23
23
  private val PARAGRAPH_BREAK_RE = Regex("\\n[ \\t]*(?:\\n[ \\t]*)+")
24
24
 
25
- fun parseToPlainTextAndRanges(markdown: String): ParseResult {
25
+ fun parseToPlainTextAndRanges(
26
+ markdown: String,
27
+ blockSyntax: Boolean = true,
28
+ ): ParseResult {
26
29
  if (markdown.isEmpty()) {
27
30
  return ParseResult("", emptyList())
28
31
  }
29
32
 
30
- val completed = InputRemend.complete(markdown)
33
+ if (!blockSyntax) {
34
+ return parseWithoutBlockSyntax(markdown)
35
+ }
36
+
37
+ return parseCompleted(InputRemend.complete(markdown), markdown, blockSeparators = true)
38
+ }
39
+
40
+ /**
41
+ * Parses with every block construct hidden behind [BlockSyntaxSentinel], so the whole
42
+ * document lands in one `Paragraph` and list markers, `#`, `>` and friends stay in the
43
+ * text verbatim. `InputRemend` is skipped as well: its speculative closing delimiters
44
+ * are the other source of characters the serializer cannot replay.
45
+ */
46
+ private fun parseWithoutBlockSyntax(markdown: String): ParseResult {
47
+ val canonical = BlockSyntaxSentinel.canonicalize(markdown)
48
+ if (canonical.isEmpty()) {
49
+ return ParseResult("", emptyList())
50
+ }
51
+
52
+ // No block separators: the encoded text has no newline for md4c to drop, so every
53
+ // newline the replay would add is spurious. `$$...$$` is the case that proves it —
54
+ // md4c reports display math as a top-level block even mid-line under the sentinel.
55
+ val parsed = parseCompleted(BlockSyntaxSentinel.encode(canonical), canonical, blockSeparators = false)
56
+ val decoded =
57
+ BlockSyntaxSentinel.decode(parsed.plainText)
58
+ ?: return ParseResult(canonical, emptyList())
59
+
60
+ // Only the leading sentinel is removed, so every offset shifts by exactly one.
61
+ val ranges =
62
+ parsed.formattingRanges.mapNotNull { range ->
63
+ val start = range.start - 1
64
+ val end = range.end - 1
65
+ if (start < 0 || end > decoded.length || end <= start) {
66
+ null
67
+ } else {
68
+ FormattingRange(range.type, start, end, range.url)
69
+ }
70
+ }
71
+ val atoms =
72
+ parsed.mathAtoms.mapNotNull { atom ->
73
+ val position = atom.position - 1
74
+ if (position < 0 || position >= decoded.length) null else atom.copy(position = position)
75
+ }
76
+
77
+ return ParseResult(decoded, ranges, atoms)
78
+ }
79
+
80
+ private fun parseCompleted(
81
+ completed: String,
82
+ fallback: String,
83
+ blockSeparators: Boolean,
84
+ ): ParseResult {
31
85
  val flags =
32
86
  Md4cFlags(
33
87
  underline = true,
34
88
  latexMath = FeatureFlags.IS_MATH_ENABLED,
35
89
  permissiveAutolinks = false,
36
90
  )
37
- val ast = Parser.shared.parseMarkdown(completed, flags) ?: return ParseResult(markdown, emptyList())
91
+ val ast = Parser.shared.parseMarkdown(completed, flags) ?: return ParseResult(fallback, emptyList())
38
92
 
39
93
  val plainText = StringBuilder()
40
94
  val ranges = mutableListOf<FormattingRange>()
@@ -43,14 +97,18 @@ object InputParser {
43
97
  // md4c collapses any blank-line run into a single break, unlike iOS which keeps them.
44
98
  // Re-read the real runs so each break replays its original number of newlines.
45
99
  val blankRuns =
46
- ArrayDeque(
47
- PARAGRAPH_BREAK_RE
48
- .findAll(completed.trim())
49
- .map { match -> match.value.count { it == '\n' } }
50
- .toList(),
51
- )
100
+ if (blockSeparators) {
101
+ ArrayDeque(
102
+ PARAGRAPH_BREAK_RE
103
+ .findAll(completed.trim())
104
+ .map { match -> match.value.count { it == '\n' } }
105
+ .toList(),
106
+ )
107
+ } else {
108
+ ArrayDeque()
109
+ }
52
110
 
53
- walkNode(ast, plainText, ranges, mathAtoms, ArrayDeque(), blankRuns)
111
+ walkNode(ast, plainText, ranges, mathAtoms, ArrayDeque(), blankRuns, blockSeparators)
54
112
 
55
113
  return ParseResult(plainText.toString(), ranges, mathAtoms)
56
114
  }
@@ -62,6 +120,7 @@ object InputParser {
62
120
  mathAtoms: MutableList<MathAtom>,
63
121
  activeStyles: ArrayDeque<ActiveStyle>,
64
122
  blankRuns: ArrayDeque<Int>,
123
+ blockSeparators: Boolean,
65
124
  ) {
66
125
  if (node.type == NodeType.LatexMathInline || node.type == NodeType.LatexMathDisplay) {
67
126
  val latex = extractLatex(node)
@@ -92,10 +151,10 @@ object InputParser {
92
151
 
93
152
  for ((index, child) in node.children.withIndex()) {
94
153
  // 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()) {
154
+ if (blockSeparators && index > 0 && child.type.isTopLevelBlock() && plainText.isNotEmpty()) {
96
155
  plainText.append("\n".repeat(blankRuns.removeFirstOrNull() ?: 2))
97
156
  }
98
- walkNode(child, plainText, ranges, mathAtoms, activeStyles, blankRuns)
157
+ walkNode(child, plainText, ranges, mathAtoms, activeStyles, blankRuns, blockSeparators)
99
158
  }
100
159
 
101
160
  if (styleType != null) {
@@ -16,6 +16,7 @@ import com.swmansion.enriched.markdown.input.events.OnInputBlurEvent
16
16
  import com.swmansion.enriched.markdown.input.events.OnInputFocusEvent
17
17
  import com.swmansion.enriched.markdown.input.events.OnLinkDetectedEvent
18
18
  import com.swmansion.enriched.markdown.input.events.OnMathPressEvent
19
+ import com.swmansion.enriched.markdown.input.events.OnPasteEvent
19
20
  import com.swmansion.enriched.markdown.input.events.OnRequestCaretRectResultEvent
20
21
  import com.swmansion.enriched.markdown.input.events.OnRequestMarkdownResultEvent
21
22
  import com.swmansion.enriched.markdown.input.events.OnStartMentionEvent
@@ -99,6 +100,13 @@ class InputEventEmitter(
99
100
  dispatch(OnMathPressEvent(surfaceId(), view.id, latex, start, end, displayMode, x, y, width, height))
100
101
  }
101
102
 
103
+ fun emitPaste(
104
+ text: String,
105
+ markdown: String,
106
+ ) {
107
+ dispatch(OnPasteEvent(surfaceId(), view.id, text, markdown))
108
+ }
109
+
102
110
  fun emitStartMention(indicator: String) {
103
111
  dispatch(OnStartMentionEvent(surfaceId(), view.id, indicator))
104
112
  }
@@ -8,21 +8,49 @@ class InputLayoutManager(
8
8
  ) {
9
9
  private var forceHeightRecalculationCounter = 0
10
10
 
11
+ /**
12
+ * An invalidation that arrived before `stateWrapper` existed. Fabric applies every prop
13
+ * (and runs `onAfterUpdateTransaction`) inside `ViewManager.updateProperties`, which runs
14
+ * *before* `updateState` assigns the wrapper — so the whole mount transaction invalidates
15
+ * into the void. Without this flag the measurement store stays empty and Yoga keeps the
16
+ * `initialMeasure()` approximation until the next text/prop change.
17
+ */
18
+ private var pending = false
19
+
11
20
  fun invalidateLayout() {
12
- if (view.stateWrapper == null) return
21
+ val stateWrapper = view.stateWrapper
22
+ if (stateWrapper == null) {
23
+ pending = true
24
+ return
25
+ }
26
+ pending = false
13
27
 
14
28
  val text = view.text
15
29
  val paint = view.paint
16
30
 
31
+ // First call for this view measures against cachedWidth = 0 and stores a throwaway size;
32
+ // Yoga immediately re-measures through getMeasureById() with the real width and overwrites
33
+ // it. The state update below is what makes that second pass happen.
17
34
  val needUpdate = InputMeasurementStore.store(view.id, text, paint)
18
35
  if (!needUpdate) return
19
36
 
20
37
  val state = Arguments.createMap()
21
38
  state.putInt("forceHeightRecalculationCounter", forceHeightRecalculationCounter++)
22
- view.stateWrapper?.updateState(state)
39
+ stateWrapper.updateState(state)
40
+ }
41
+
42
+ /**
43
+ * Replays an invalidation that was dropped while `stateWrapper` was null. Called once the
44
+ * wrapper is assigned, i.e. after every prop of the mount transaction has been applied — so
45
+ * the text carries its math chips and the paint carries the resolved typeface/size.
46
+ */
47
+ fun flushPendingInvalidation() {
48
+ if (!pending) return
49
+ invalidateLayout()
23
50
  }
24
51
 
25
52
  fun release() {
53
+ pending = false
26
54
  InputMeasurementStore.release(view.id)
27
55
  }
28
56
  }
@@ -10,6 +10,8 @@ import com.facebook.react.uimanager.PixelUtil
10
10
  import com.facebook.yoga.YogaMeasureMode
11
11
  import com.facebook.yoga.YogaMeasureOutput
12
12
  import com.swmansion.enriched.markdown.input.formatting.InputParser
13
+ import com.swmansion.enriched.markdown.utils.common.getBooleanOrDefault
14
+ import com.swmansion.enriched.markdown.utils.common.getFloatOrDefault
13
15
  import java.util.concurrent.ConcurrentHashMap
14
16
 
15
17
  object InputMeasurementStore {
@@ -99,8 +101,11 @@ object InputMeasurementStore {
99
101
  // invisible characters and over-estimates the height into extra lines, until the next text
100
102
  // change forces a re-measure. Parsing first matches what the editor actually renders.
101
103
  val markdown = props?.getString("defaultValue") ?: props?.getString("placeholder") ?: "I"
102
- val text = InputParser.parseToPlainTextAndRanges(markdown).plainText
103
- val fontSize = props?.getDouble("fontSize")?.toFloat() ?: 16f
104
+ // Must read the same flag the view parses with, or the measured height and the
105
+ // rendered height disagree: `blockSyntax = false` keeps list markers and `#` as text.
106
+ val blockSyntax = props.getBooleanOrDefault("blockSyntax", true)
107
+ val text = InputParser.parseToPlainTextAndRanges(markdown, blockSyntax).plainText
108
+ val fontSize = props.getFloatOrDefault("fontSize", 16f)
104
109
  val spSize = kotlin.math.ceil(PixelUtil.toPixelFromSP(fontSize).toDouble()).toFloat()
105
110
 
106
111
  val defaultView = AppCompatEditText(context)
@@ -13,6 +13,7 @@ open class MathChipStyle(
13
13
  val paddingVertical: Float,
14
14
  val selectedBackgroundColor: Int?,
15
15
  val selectedBorderColor: Int?,
16
+ val fontWeight: String = "normal",
16
17
  ) {
17
18
  open fun isBlock(): Boolean = false
18
19
  }
@@ -28,6 +29,7 @@ class MathBlockStyle(
28
29
  paddingVertical: Float,
29
30
  selectedBackgroundColor: Int?,
30
31
  selectedBorderColor: Int?,
32
+ fontWeight: String = "normal",
31
33
  val marginTop: Float,
32
34
  val marginBottom: Float,
33
35
  val alignment: Layout.Alignment,
@@ -42,6 +44,7 @@ class MathBlockStyle(
42
44
  paddingVertical,
43
45
  selectedBackgroundColor,
44
46
  selectedBorderColor,
47
+ fontWeight,
45
48
  ) {
46
49
  override fun isBlock(): Boolean = true
47
50
  }
@@ -40,6 +40,7 @@ class HeadingRenderer(
40
40
  HeadingSpan(
41
41
  level,
42
42
  config.style,
43
+ factory.styleCache,
43
44
  ),
44
45
  start,
45
46
  end,
@@ -4,11 +4,14 @@ import android.annotation.SuppressLint
4
4
  import android.graphics.Typeface
5
5
  import android.text.TextPaint
6
6
  import android.text.style.MetricAffectingSpan
7
+ import com.swmansion.enriched.markdown.renderer.SpanStyleCache
7
8
  import com.swmansion.enriched.markdown.styles.StyleConfig
9
+ import com.swmansion.enriched.markdown.utils.text.extensions.applyColorPreserving
8
10
 
9
11
  class HeadingSpan(
10
12
  val level: Int,
11
13
  styleConfig: StyleConfig,
14
+ private val styleCache: SpanStyleCache,
12
15
  ) : MetricAffectingSpan() {
13
16
  private val fontSize: Float = styleConfig.headingStyles[level]!!.fontSize
14
17
  private val color: Int = styleConfig.headingStyles[level]!!.color
@@ -16,7 +19,11 @@ class HeadingSpan(
16
19
 
17
20
  override fun updateDrawState(tp: TextPaint) {
18
21
  applyHeadingStyle(tp)
19
- tp.color = color
22
+ // Inline spans (link, code, strong/em with their own color) are attached to the range
23
+ // BEFORE this block span, and TextLine replays every CharacterStyle in insertion order,
24
+ // so an unconditional `tp.color = color` here would repaint them. Same guard used by
25
+ // BlockquoteSpan, BaseListSpan, CodeBlockSpan, StrongSpan and EmphasisSpan.
26
+ tp.applyColorPreserving(color, *styleCache.colorsToPreserve)
20
27
  }
21
28
 
22
29
  override fun updateMeasureState(tp: TextPaint) {