react-native-enriched-markdown-scaffold 1.0.2-scaffold.1 → 1.0.2-scaffold.10

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 (163) hide show
  1. package/android/build.gradle +2 -0
  2. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownManagerDelegate.java +18 -0
  3. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownManagerInterface.java +4 -0
  4. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerDelegate.java +18 -0
  5. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerInterface.java +4 -0
  6. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp +148 -0
  7. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.h +120 -0
  8. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.cpp +30 -0
  9. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +210 -0
  10. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +176 -2
  11. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownInternalText.kt +45 -0
  12. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt +35 -0
  13. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +112 -0
  14. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt +34 -0
  15. package/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +46 -8
  16. package/android/src/main/java/com/swmansion/enriched/markdown/events/CopyRequestEvent.kt +28 -0
  17. package/android/src/main/java/com/swmansion/enriched/markdown/events/LinkLayoutEvent.kt +36 -0
  18. package/android/src/main/java/com/swmansion/enriched/markdown/events/SelectionChangeEvent.kt +43 -0
  19. package/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt +25 -3
  20. package/android/src/main/java/com/swmansion/enriched/markdown/input/editing/InputConnectionWrapper.kt +7 -0
  21. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt +37 -7
  22. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt +5 -1
  23. package/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBackgroundSpan.kt +13 -99
  24. package/android/src/main/java/com/swmansion/enriched/markdown/spans/InlineChipPainter.kt +140 -0
  25. package/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkChipSpans.kt +177 -0
  26. package/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt +1 -6
  27. package/android/src/main/java/com/swmansion/enriched/markdown/styles/InlineMathStyle.kt +16 -1
  28. package/android/src/main/java/com/swmansion/enriched/markdown/styles/LinkStyle.kt +36 -1
  29. package/android/src/main/java/com/swmansion/enriched/markdown/styles/LinkVariantEntry.kt +7 -3
  30. package/android/src/main/java/com/swmansion/enriched/markdown/styles/MathStyle.kt +3 -0
  31. package/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt +51 -0
  32. package/android/src/main/java/com/swmansion/enriched/markdown/utils/input/PasteTextUtils.kt +34 -2
  33. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/HTMLGenerator.kt +2 -1
  34. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/MarkdownExtractor.kt +6 -0
  35. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/HeadlessActionMode.kt +88 -0
  36. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkLayoutCollector.kt +100 -0
  37. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/SelectionActionMode.kt +53 -14
  38. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/SelectionChangeReporter.kt +232 -0
  39. package/android/src/main/java/com/swmansion/enriched/markdown/views/BlockMenuOptions.kt +64 -0
  40. package/android/src/main/java/com/swmansion/enriched/markdown/views/CodeBlockContainerView.kt +29 -3
  41. package/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt +30 -7
  42. package/android/src/math/java/com/swmansion/enriched/markdown/renderer/MathInlineRenderer.kt +1 -0
  43. package/android/src/math/java/com/swmansion/enriched/markdown/spans/MathInlineSpan.kt +13 -6
  44. package/android/src/math/java/com/swmansion/enriched/markdown/spans/MathMeasureHelper.kt +2 -1
  45. package/android/src/math/java/com/swmansion/enriched/markdown/views/MathContainerView.kt +25 -9
  46. package/docs/API_REFERENCE.md +183 -4
  47. package/docs/COPY_OPTIONS.md +40 -0
  48. package/docs/INPUT.md +3 -1
  49. package/docs/MENTIONS.md +8 -1
  50. package/docs/STYLES.md +27 -0
  51. package/docs/TEXT.md +2 -0
  52. package/docs/WEB.md +1 -0
  53. package/ios/EnrichedMarkdown.mm +321 -9
  54. package/ios/EnrichedMarkdownText.mm +203 -6
  55. package/ios/attachments/ENRMMathInlineAttachment+macOS.m +2 -1
  56. package/ios/attachments/ENRMMathInlineAttachment.h +3 -0
  57. package/ios/attachments/ENRMMathInlineAttachment.m +2 -1
  58. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.cpp +148 -0
  59. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.h +120 -0
  60. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.cpp +30 -0
  61. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h +210 -0
  62. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/RCTComponentViewHelpers.h +50 -2
  63. package/ios/internals/MeasurementCache.h +8 -2
  64. package/ios/math/ENRMRaTeXBridge.swift +23 -5
  65. package/ios/renderer/ENRMMathInlineRenderer.m +1 -0
  66. package/ios/renderer/LinkRenderer.m +47 -15
  67. package/ios/styles/StyleConfig.h +20 -0
  68. package/ios/styles/StyleConfig.mm +84 -0
  69. package/ios/utils/CodeBackground.m +3 -65
  70. package/ios/utils/ENRMBlockMenu.h +66 -0
  71. package/ios/utils/ENRMChipDrawing.h +24 -0
  72. package/ios/utils/ENRMChipDrawing.m +83 -0
  73. package/ios/utils/ENRMLinkChip.h +70 -0
  74. package/ios/utils/ENRMLinkChip.m +213 -0
  75. package/ios/utils/ENRMLinkLayout.h +50 -0
  76. package/ios/utils/ENRMLinkLayout.m +45 -0
  77. package/ios/utils/ENRMSelectionChange.h +120 -0
  78. package/ios/utils/ENRMSelectionChange.m +282 -0
  79. package/ios/utils/EditMenuUtils+macOS.m +4 -1
  80. package/ios/utils/EditMenuUtils.h +35 -6
  81. package/ios/utils/EditMenuUtils.m +171 -15
  82. package/ios/utils/HTMLGenerator.m +2 -1
  83. package/ios/utils/MarkdownAccessibilityElementBuilder.m +3 -1
  84. package/ios/utils/MarkdownExtractor.m +2 -1
  85. package/ios/utils/RTFExportUtils.m +2 -0
  86. package/ios/utils/StylePropsUtils.h +47 -1
  87. package/ios/utils/TextViewLayoutManager.mm +3 -0
  88. package/ios/views/ENRMCodeBlockContainerView.h +16 -2
  89. package/ios/views/ENRMCodeBlockContainerView.m +22 -19
  90. package/ios/views/ENRMMathContainerView.h +11 -0
  91. package/ios/views/ENRMMathContainerView.m +17 -20
  92. package/ios/views/TableContainerView.h +14 -0
  93. package/ios/views/TableContainerView.m +34 -18
  94. package/lib/module/EnrichedMarkdownNativeComponent.ts +138 -1
  95. package/lib/module/EnrichedMarkdownTextNativeComponent.ts +138 -1
  96. package/lib/module/index.js.map +1 -1
  97. package/lib/module/jest/index.js +14 -0
  98. package/lib/module/jest/index.js.map +1 -1
  99. package/lib/module/native/EnrichedMarkdownText.js +169 -11
  100. package/lib/module/native/EnrichedMarkdownText.js.map +1 -1
  101. package/lib/module/normalizeMarkdownStyle.js +17 -4
  102. package/lib/module/normalizeMarkdownStyle.js.map +1 -1
  103. package/lib/module/normalizeMarkdownStyle.web.js +17 -4
  104. package/lib/module/normalizeMarkdownStyle.web.js.map +1 -1
  105. package/lib/module/web/EnrichedMarkdownText.js +6 -3
  106. package/lib/module/web/EnrichedMarkdownText.js.map +1 -1
  107. package/lib/module/web/renderers/InlineRenderers.js +96 -3
  108. package/lib/module/web/renderers/InlineRenderers.js.map +1 -1
  109. package/lib/module/web/styles.js +18 -0
  110. package/lib/module/web/styles.js.map +1 -1
  111. package/lib/module/web/utils.js +13 -0
  112. package/lib/module/web/utils.js.map +1 -1
  113. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts +123 -2
  114. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts.map +1 -1
  115. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +123 -2
  116. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts.map +1 -1
  117. package/lib/typescript/src/index.d.ts +2 -2
  118. package/lib/typescript/src/index.d.ts.map +1 -1
  119. package/lib/typescript/src/index.web.d.ts +1 -1
  120. package/lib/typescript/src/index.web.d.ts.map +1 -1
  121. package/lib/typescript/src/jest/index.d.ts +1 -1
  122. package/lib/typescript/src/jest/index.d.ts.map +1 -1
  123. package/lib/typescript/src/native/EnrichedMarkdownText.d.ts +5 -5
  124. package/lib/typescript/src/native/EnrichedMarkdownText.d.ts.map +1 -1
  125. package/lib/typescript/src/normalizeMarkdownStyle.d.ts.map +1 -1
  126. package/lib/typescript/src/normalizeMarkdownStyle.web.d.ts.map +1 -1
  127. package/lib/typescript/src/types/MarkdownStyle.d.ts +50 -5
  128. package/lib/typescript/src/types/MarkdownStyle.d.ts.map +1 -1
  129. package/lib/typescript/src/types/MarkdownStyleInternal.d.ts +12 -0
  130. package/lib/typescript/src/types/MarkdownStyleInternal.d.ts.map +1 -1
  131. package/lib/typescript/src/types/MarkdownTextProps.d.ts +144 -11
  132. package/lib/typescript/src/types/MarkdownTextProps.d.ts.map +1 -1
  133. package/lib/typescript/src/types/MarkdownTextProps.web.d.ts +41 -1
  134. package/lib/typescript/src/types/MarkdownTextProps.web.d.ts.map +1 -1
  135. package/lib/typescript/src/types/events.d.ts +66 -0
  136. package/lib/typescript/src/types/events.d.ts.map +1 -1
  137. package/lib/typescript/src/web/EnrichedMarkdownText.d.ts +1 -1
  138. package/lib/typescript/src/web/EnrichedMarkdownText.d.ts.map +1 -1
  139. package/lib/typescript/src/web/renderers/InlineRenderers.d.ts.map +1 -1
  140. package/lib/typescript/src/web/styles.d.ts.map +1 -1
  141. package/lib/typescript/src/web/types.d.ts +4 -0
  142. package/lib/typescript/src/web/types.d.ts.map +1 -1
  143. package/lib/typescript/src/web/utils.d.ts +4 -0
  144. package/lib/typescript/src/web/utils.d.ts.map +1 -1
  145. package/package.json +1 -1
  146. package/src/EnrichedMarkdownNativeComponent.ts +138 -1
  147. package/src/EnrichedMarkdownTextNativeComponent.ts +138 -1
  148. package/src/index.tsx +6 -0
  149. package/src/index.web.tsx +5 -1
  150. package/src/jest/index.tsx +22 -1
  151. package/src/native/EnrichedMarkdownText.tsx +220 -9
  152. package/src/normalizeMarkdownStyle.ts +15 -0
  153. package/src/normalizeMarkdownStyle.web.ts +13 -0
  154. package/src/types/MarkdownStyle.ts +51 -5
  155. package/src/types/MarkdownStyleInternal.ts +12 -0
  156. package/src/types/MarkdownTextProps.ts +151 -11
  157. package/src/types/MarkdownTextProps.web.ts +43 -1
  158. package/src/types/events.ts +69 -0
  159. package/src/web/EnrichedMarkdownText.tsx +10 -3
  160. package/src/web/renderers/InlineRenderers.tsx +111 -3
  161. package/src/web/styles.ts +25 -0
  162. package/src/web/types.ts +4 -0
  163. package/src/web/utils.ts +8 -0
@@ -7,6 +7,7 @@ import android.text.SpannableString
7
7
  import android.text.StaticLayout
8
8
  import android.text.TextPaint
9
9
  import android.util.Log
10
+ import androidx.emoji2.text.EmojiCompat
10
11
  import com.facebook.react.bridge.ReadableMap
11
12
  import com.facebook.react.uimanager.PixelUtil
12
13
  import com.facebook.yoga.YogaMeasureMode
@@ -92,6 +93,38 @@ object MeasurementStore {
92
93
  }
93
94
 
94
95
  private val measurePaint = TextPaint()
96
+
97
+ @Volatile
98
+ private var emojiInitCallbackRegistered = false
99
+
100
+ // A view measured before EmojiCompat loaded keeps a size without its emoji glyphs.
101
+ private val emojiInitCallback =
102
+ object : EmojiCompat.InitCallback() {
103
+ override fun onInitialized() {
104
+ data.clear()
105
+ }
106
+ }
107
+
108
+ // AppCompatTextView draws emoji the system font lacks through EmojiCompat, which can be
109
+ // wider than the fallback glyph and wrap a line earlier; measure the same glyphs.
110
+ private fun withEmojiSpans(text: CharSequence): CharSequence {
111
+ if (text.isEmpty() || !EmojiCompat.isConfigured()) return text
112
+ val emojiCompat = EmojiCompat.get()
113
+ if (emojiCompat.loadState != EmojiCompat.LOAD_STATE_SUCCEEDED) {
114
+ if (!emojiInitCallbackRegistered) {
115
+ emojiInitCallbackRegistered = true
116
+ emojiCompat.registerInitCallback(emojiInitCallback)
117
+ }
118
+ return text
119
+ }
120
+ return try {
121
+ emojiCompat.process(SpannableString(text)) ?: text
122
+ } catch (e: IllegalStateException) {
123
+ Log.w(TAG, "EmojiCompat processing failed, measuring raw text", e)
124
+ text
125
+ }
126
+ }
127
+
95
128
  private val measureRenderer = Renderer()
96
129
 
97
130
  @Volatile
@@ -337,9 +370,12 @@ object MeasurementStore {
337
370
  tryRenderMarkdown(markdown, styleMap, context, md4cFlags, allowFontScaling, maxFontSizeMultiplier, imageRequestHeaders)
338
371
  // Placeholders must be built with the same inlineMath.paddingVertical the real spans use,
339
372
  // or the measured line box won't match the drawn one.
373
+ val inlineMathMap = styleMap.getMapOrNull("inlineMath")
374
+ val inlineMathOverflowDip = inlineMathMap.getFloatOrDefault("glyphOverflow", 0f)
340
375
  spannable?.replaceMathSpansWithPlaceholders(
341
376
  context,
342
- PixelUtil.toPixelFromDIP(styleMap.getMapOrNull("inlineMath").getFloatOrDefault("paddingVertical", 0f)),
377
+ PixelUtil.toPixelFromDIP(inlineMathMap.getFloatOrDefault("paddingVertical", 0f)) +
378
+ if (inlineMathOverflowDip > 0f) ceil(PixelUtil.toPixelFromDIP(inlineMathOverflowDip)) else 0f,
343
379
  )
344
380
  val textToMeasure = spannable ?: markdown
345
381
  val (size, _) = measureWithLayout(width, textToMeasure, measurePaint, id)
@@ -456,7 +492,8 @@ object MeasurementStore {
456
492
  for (i in mathSegmentIndices.indices) {
457
493
  val metrics = mathResults[i]
458
494
  mathHeightByIndex[mathSegmentIndices[i]] =
459
- ceil(metrics.ascent + metrics.descent).toInt() + (style.mathStyle.padding * 2)
495
+ ceil(metrics.ascent + metrics.descent).toInt() + (style.mathStyle.padding * 2) +
496
+ (style.mathStyle.glyphOverflow * 2)
460
497
  }
461
498
  }
462
499
 
@@ -471,7 +508,7 @@ object MeasurementStore {
471
508
 
472
509
  when (segment) {
473
510
  is RenderedSegment.Text -> {
474
- segment.styledText.replaceMathSpansWithPlaceholders(context, style.inlineMathStyle.paddingVertical)
511
+ segment.styledText.replaceMathSpansWithPlaceholders(context, style.inlineMathStyle.boxPaddingVertical)
475
512
 
476
513
  val layout = createStaticLayout(segment.styledText, fontSize, widthPx, id)
477
514
  totalHeightPx += layout.height
@@ -546,9 +583,10 @@ object MeasurementStore {
546
583
  viewId: Int?,
547
584
  ): StaticLayout {
548
585
  measurePaint.textSize = fontSize
549
- prepareImageSpansForMeasurement(text, widthPx)
586
+ val content = withEmojiSpans(text)
587
+ prepareImageSpansForMeasurement(content, widthPx)
550
588
  return StaticLayout.Builder
551
- .obtain(text, 0, text.length, measurePaint, widthPx)
589
+ .obtain(content, 0, content.length, measurePaint, widthPx)
552
590
  .setIncludePad(false)
553
591
  .setLineSpacing(0f, 1f)
554
592
  .apply {
@@ -579,7 +617,7 @@ object MeasurementStore {
579
617
  style.imageRequestHeaders = imageRequestHeaders
580
618
  measureRenderer.configure(style, context)
581
619
  measureRenderer.renderDocument(ast, null).apply {
582
- replaceMathSpansWithPlaceholders(context, style.inlineMathStyle.paddingVertical)
620
+ replaceMathSpansWithPlaceholders(context, style.inlineMathStyle.boxPaddingVertical)
583
621
  }
584
622
  } catch (e: Exception) {
585
623
  Log.w(TAG, "Failed to render markdown for measurement, falling back to raw text", e)
@@ -628,7 +666,7 @@ object MeasurementStore {
628
666
  paint: TextPaint,
629
667
  viewId: Int?,
630
668
  ): Long {
631
- val content = text ?: ""
669
+ val content = withEmojiSpans(text ?: "")
632
670
  val safeWidth = ceil(maxWidth).toInt().coerceAtLeast(1)
633
671
  prepareImageSpansForMeasurement(content, safeWidth)
634
672
 
@@ -668,7 +706,7 @@ object MeasurementStore {
668
706
  paint: TextPaint,
669
707
  viewId: Int?,
670
708
  ): Pair<Long, StaticLayout> {
671
- val content = text ?: ""
709
+ val content = withEmojiSpans(text ?: "")
672
710
  val widthPx = ceil(maxWidth).toInt().coerceAtLeast(1)
673
711
  prepareImageSpansForMeasurement(content, widthPx)
674
712
 
@@ -0,0 +1,28 @@
1
+ package com.swmansion.enriched.markdown.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 CopyRequestEvent(
8
+ surfaceId: Int,
9
+ viewId: Int,
10
+ private val source: String,
11
+ private val markdown: String,
12
+ private val text: String,
13
+ private val whole: Boolean,
14
+ ) : Event<CopyRequestEvent>(surfaceId, viewId) {
15
+ override fun getEventName(): String = EVENT_NAME
16
+
17
+ override fun getEventData(): WritableMap =
18
+ Arguments.createMap().apply {
19
+ putString("source", source)
20
+ putString("markdown", markdown)
21
+ putString("text", text)
22
+ putBoolean("whole", whole)
23
+ }
24
+
25
+ companion object {
26
+ const val EVENT_NAME: String = "onCopyRequest"
27
+ }
28
+ }
@@ -0,0 +1,36 @@
1
+ package com.swmansion.enriched.markdown.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
+ import com.swmansion.enriched.markdown.utils.text.view.LinkLayoutFragment
7
+
8
+ class LinkLayoutEvent(
9
+ surfaceId: Int,
10
+ viewId: Int,
11
+ private val fragments: List<LinkLayoutFragment>,
12
+ ) : Event<LinkLayoutEvent>(surfaceId, viewId) {
13
+ override fun getEventName(): String = EVENT_NAME
14
+
15
+ override fun getEventData(): WritableMap {
16
+ val array = Arguments.createArray()
17
+ fragments.forEach { fragment ->
18
+ array.pushMap(
19
+ Arguments.createMap().apply {
20
+ putInt("linkIndex", fragment.linkIndex)
21
+ putString("url", fragment.url)
22
+ putString("text", fragment.text)
23
+ putDouble("x", fragment.x.toDouble())
24
+ putDouble("y", fragment.y.toDouble())
25
+ putDouble("width", fragment.width.toDouble())
26
+ putDouble("height", fragment.height.toDouble())
27
+ },
28
+ )
29
+ }
30
+ return Arguments.createMap().apply { putArray("fragments", array) }
31
+ }
32
+
33
+ companion object {
34
+ const val EVENT_NAME: String = "onLinkLayout"
35
+ }
36
+ }
@@ -0,0 +1,43 @@
1
+ package com.swmansion.enriched.markdown.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
+ import com.swmansion.enriched.markdown.utils.text.view.SelectionRect
7
+ import com.swmansion.enriched.markdown.utils.text.view.SelectionSnapshot
8
+
9
+ class SelectionChangeEvent(
10
+ surfaceId: Int,
11
+ viewId: Int,
12
+ private val snapshot: SelectionSnapshot,
13
+ ) : Event<SelectionChangeEvent>(surfaceId, viewId) {
14
+ override fun getEventName(): String = EVENT_NAME
15
+
16
+ // Every state is distinct (active/inactive flips must all arrive), so never merge.
17
+ override fun canCoalesce(): Boolean = false
18
+
19
+ override fun getEventData(): WritableMap =
20
+ Arguments.createMap().apply {
21
+ putBoolean("active", snapshot.active)
22
+ putString("source", snapshot.source)
23
+ putString("text", snapshot.text)
24
+ putString("markdown", snapshot.markdown)
25
+ putBoolean("whole", snapshot.whole)
26
+ putMap("rect", snapshot.rect.toWritableMap())
27
+ putMap("startRect", snapshot.startRect.toWritableMap())
28
+ putMap("endRect", snapshot.endRect.toWritableMap())
29
+ putBoolean("adjusting", snapshot.adjusting)
30
+ }
31
+
32
+ private fun SelectionRect.toWritableMap(): WritableMap =
33
+ Arguments.createMap().apply {
34
+ putDouble("x", x.toDouble())
35
+ putDouble("y", y.toDouble())
36
+ putDouble("width", width.toDouble())
37
+ putDouble("height", height.toDouble())
38
+ }
39
+
40
+ companion object {
41
+ const val EVENT_NAME: String = "onSelectionChange"
42
+ }
43
+ }
@@ -796,11 +796,13 @@ class EnrichedMarkdownTextInputView(
796
796
  // and block ranges survive the round trip — mirrors iOS's copy:/cut:/paste:
797
797
  // overrides. External clips keep default handling.
798
798
  //
799
- // Paste arrives by two different routes and only the first one is a menu.
799
+ // Paste arrives by three different routes and only the first one is a menu.
800
800
  // This override covers the context menu and the hardware Ctrl+V /
801
801
  // KEYCODE_PASTE shortcuts; drag-and-drop, autofill and IME `commitContent`
802
- // reach the view through `onReceiveContent` instead. Hooking only one of them
803
- // leaves the other pasting straight into the buffer with nobody intercepting.
802
+ // reach the view through `onReceiveContent` instead, and a keyboard
803
+ // clipboard chip commits the text through the input connection (see
804
+ // interceptImePaste). Hooking only some of them leaves the others pasting
805
+ // straight into the buffer with nobody intercepting.
804
806
  override fun onTextContextMenuItem(id: Int): Boolean {
805
807
  // Checked before the markdown paste below, which would otherwise insert first.
806
808
  if (interceptPaste && (id == android.R.id.paste || id == android.R.id.pasteAsPlainText)) {
@@ -976,6 +978,26 @@ class EnrichedMarkdownTextInputView(
976
978
  parts.second
977
979
  }
978
980
 
981
+ /**
982
+ * Keyboard clipboard chips (Gboard, Samsung Keyboard) paste through
983
+ * `InputConnection.commitText`, a route neither [onTextContextMenuItem] nor
984
+ * the receive-content listener sees, so the text landed in the buffer with no
985
+ * `onPaste`. A commit that equals the primary clip is treated as that paste
986
+ * and reported like the other two routes; the IME commit is then dropped and
987
+ * JS completes it through [resolvePaste].
988
+ *
989
+ * Short typing-sized commits are not compared, so the clipboard is not read
990
+ * (and Android 12's clipboard toast not shown) while the user types; see
991
+ * [PasteTextUtils.mayBeImePaste] and [PasteTextUtils.isImePaste].
992
+ */
993
+ internal fun interceptImePaste(committed: String): Boolean {
994
+ if (!interceptPaste || !PasteTextUtils.mayBeImePaste(committed)) return false
995
+ val clip = readClipboardText()
996
+ if (!PasteTextUtils.isImePaste(committed, clip)) return false
997
+ eventEmitter.emitPaste(clip, "")
998
+ return true
999
+ }
1000
+
979
1001
  private fun readClipboardText(): String {
980
1002
  val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return ""
981
1003
  val clip = clipboard.primaryClip ?: return ""
@@ -106,6 +106,13 @@ class InputConnectionWrapper(
106
106
  if (key.length <= 2) {
107
107
  dispatchKeyPressOrEnqueue(key.ifEmpty { BACKSPACE_KEY_VALUE })
108
108
  }
109
+ // A keyboard clipboard chip pastes here. When the view claims it as a
110
+ // paste, the commit is dropped; the composing region is closed first so the
111
+ // word being typed stays in the buffer instead of being replaced.
112
+ if (editText.interceptImePaste(key)) {
113
+ super.finishComposingText()
114
+ return true
115
+ }
109
116
  return super.commitText(text, newCursorPosition)
110
117
  }
111
118
 
@@ -2,6 +2,9 @@ package com.swmansion.enriched.markdown.renderer
2
2
 
3
3
  import android.text.SpannableStringBuilder
4
4
  import com.swmansion.enriched.markdown.parser.MarkdownASTNode
5
+ import com.swmansion.enriched.markdown.spans.LinkChipBackgroundSpan
6
+ import com.swmansion.enriched.markdown.spans.LinkSpacerSpan
7
+ import com.swmansion.enriched.markdown.spans.LinkSpacers
5
8
  import com.swmansion.enriched.markdown.spans.LinkSpan
6
9
  import com.swmansion.enriched.markdown.utils.text.span.SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE
7
10
 
@@ -16,14 +19,41 @@ class LinkRenderer(
16
19
  factory: RendererFactory,
17
20
  ) {
18
21
  val url = node.getAttribute("url") ?: return
22
+ // Chip ("box") properties never fall back to the base link style.
23
+ val chip = factory.styleCache.linkChipForUrl(url)
24
+ val padding = chip.paddingHorizontal
19
25
 
20
- factory.renderWithSpan(builder, { factory.renderChildren(node, builder, onLinkPress, onLinkLongPress) }) { start, end, blockStyle ->
21
- builder.setSpan(
22
- LinkSpan(url, onLinkPress, onLinkLongPress, factory.styleCache, blockStyle, factory.context),
23
- start,
24
- end,
25
- SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE,
26
- )
26
+ val start = builder.length
27
+ // The leading spacer goes in before the children so ranges they register stay valid.
28
+ if (padding > 0f) appendSpacer(builder, padding)
29
+ val contentStart = builder.length
30
+
31
+ factory.renderChildren(node, builder, onLinkPress, onLinkLongPress)
32
+
33
+ if (builder.length == contentStart) {
34
+ if (contentStart > start) builder.delete(start, contentStart)
35
+ return
27
36
  }
37
+ if (padding > 0f) appendSpacer(builder, padding)
38
+ val end = builder.length
39
+
40
+ val blockStyle = factory.blockStyleContext.requireBlockStyle()
41
+ builder.setSpan(
42
+ LinkSpan(url, onLinkPress, onLinkLongPress, factory.styleCache, blockStyle, factory.context),
43
+ start,
44
+ end,
45
+ SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE,
46
+ )
47
+ // One instance per link: also identifies the link for link-layout reporting.
48
+ builder.setSpan(LinkChipBackgroundSpan(chip), start, end, SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE)
49
+ }
50
+
51
+ private fun appendSpacer(
52
+ builder: SpannableStringBuilder,
53
+ width: Float,
54
+ ) {
55
+ val position = builder.length
56
+ builder.append(LinkSpacers.CHAR)
57
+ builder.setSpan(LinkSpacerSpan(width), position, position + 1, SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE)
28
58
  }
29
59
  }
@@ -4,6 +4,7 @@ import android.content.res.AssetManager
4
4
  import android.graphics.Typeface
5
5
  import com.facebook.react.common.ReactConstants
6
6
  import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles
7
+ import com.swmansion.enriched.markdown.styles.LinkChipStyle
7
8
  import com.swmansion.enriched.markdown.styles.LinkVariantEntry
8
9
  import com.swmansion.enriched.markdown.styles.StyleConfig
9
10
 
@@ -26,7 +27,7 @@ class SpanStyleCache(
26
27
  val linkFontFamily: String = style.linkStyle.fontFamily
27
28
  val linkColor: Int = style.linkStyle.color
28
29
  val linkUnderline: Boolean = style.linkStyle.underline
29
- val linkBackgroundColor: Int = style.linkStyle.backgroundColor
30
+ val linkChip: LinkChipStyle = style.linkStyle.chip
30
31
  val linkVariants: List<LinkVariantEntry> = style.linkVariants
31
32
 
32
33
  private val compiledVariantPatterns: List<Pair<Regex, LinkVariantEntry>> =
@@ -100,6 +101,9 @@ class SpanStyleCache(
100
101
  return compiledVariantPatterns.firstOrNull { (regex, _) -> regex.containsMatchIn(url) }?.second
101
102
  }
102
103
 
104
+ /** Chip style for [url]: the matching variant's own, else the base link's (never merged). */
105
+ fun linkChipForUrl(url: String): LinkChipStyle = resolvedVariantForUrl(url)?.chip ?: linkChip
106
+
103
107
  /**
104
108
  * Cached typeface for font family + style (BOLD, ITALIC, BOLD_ITALIC).
105
109
  *
@@ -5,9 +5,6 @@ import android.graphics.Paint
5
5
  import android.graphics.Path
6
6
  import android.graphics.RectF
7
7
  import android.text.Spanned
8
- import android.text.StaticLayout
9
- import android.text.TextPaint
10
- import android.text.style.LeadingMarginSpan
11
8
  import android.text.style.LineBackgroundSpan
12
9
  import com.swmansion.enriched.markdown.styles.StyleConfig
13
10
  import kotlin.math.max
@@ -73,14 +70,6 @@ class CodeBackgroundSpan(
73
70
  drawShapes(canvas, isFirst, isLast)
74
71
  }
75
72
 
76
- /**
77
- * Returns the x position of [index] relative to the line's left edge, including any
78
- * leading margin. The measuring StaticLayout is built from a subSequence that keeps
79
- * all spans, so LeadingMarginSpans (lists, blockquotes) are already applied to
80
- * getPrimaryHorizontal; adding the margin again on top would shift the background
81
- * right by the indent. The margin is only added explicitly in the early-return case,
82
- * where no layout is built.
83
- */
84
73
  private fun getHorizontalOffset(
85
74
  text: CharSequence,
86
75
  lineStart: Int,
@@ -88,105 +77,30 @@ class CodeBackgroundSpan(
88
77
  index: Int,
89
78
  paint: Paint,
90
79
  leadingMargin: Int,
91
- ): Float {
92
- if (index <= lineStart) return leadingMargin.toFloat()
93
- val lineText = text.subSequence(lineStart, lineEnd)
94
- val textPaint = paint as? TextPaint ?: TextPaint(paint)
95
- val layout = StaticLayout.Builder.obtain(lineText, 0, lineText.length, textPaint, 10000).build()
96
- return layout.getPrimaryHorizontal(index - lineStart)
97
- }
80
+ ): Float = InlineChipPainter.horizontalOffset(text, lineStart, lineEnd, index, paint, leadingMargin)
98
81
 
99
82
  private fun drawShapes(
100
83
  canvas: Canvas,
101
84
  isFirst: Boolean,
102
85
  isLast: Boolean,
103
86
  ) {
104
- val radii = createRadii(isFirst, isLast)
105
-
106
- path.reset()
107
- path.addRoundRect(rect, radii, Path.Direction.CW)
108
- canvas.drawPath(path, sharedBackgroundPaint)
109
-
110
- if (isFirst && isLast) {
111
- canvas.drawPath(path, sharedBorderPaint)
112
- } else {
113
- drawOpenBorders(canvas, isFirst, isLast)
114
- }
115
- }
116
-
117
- private fun drawOpenBorders(
118
- canvas: Canvas,
119
- isFirst: Boolean,
120
- isLast: Boolean,
121
- ) {
122
- val r = CORNER_RADIUS
123
- path.reset()
124
-
125
- if (isFirst) {
126
- path.moveTo(rect.right, rect.top)
127
- path.lineTo(rect.left + r, rect.top)
128
- path.quadTo(rect.left, rect.top, rect.left, rect.top + r)
129
- path.lineTo(rect.left, rect.bottom - r)
130
- path.quadTo(rect.left, rect.bottom, rect.left + r, rect.bottom)
131
- path.lineTo(rect.right, rect.bottom)
132
- } else if (isLast) {
133
- path.moveTo(rect.left, rect.top)
134
- path.lineTo(rect.right - r, rect.top)
135
- path.quadTo(rect.right, rect.top, rect.right, rect.top + r)
136
- path.lineTo(rect.right, rect.bottom - r)
137
- path.quadTo(rect.right, rect.bottom, rect.right - r, rect.bottom)
138
- path.lineTo(rect.left, rect.bottom)
139
- } else {
140
- path.moveTo(rect.left, rect.top)
141
- path.lineTo(rect.right, rect.top)
142
- path.moveTo(rect.left, rect.bottom)
143
- path.lineTo(rect.right, rect.bottom)
144
- }
145
- canvas.drawPath(path, sharedBorderPaint)
146
- }
147
-
148
- private fun createRadii(
149
- isFirst: Boolean,
150
- isLast: Boolean,
151
- ) = when {
152
- isFirst && isLast -> {
153
- floatArrayOf(
154
- CORNER_RADIUS,
155
- CORNER_RADIUS,
156
- CORNER_RADIUS,
157
- CORNER_RADIUS,
158
- CORNER_RADIUS,
159
- CORNER_RADIUS,
160
- CORNER_RADIUS,
161
- CORNER_RADIUS,
162
- )
163
- }
164
-
165
- isFirst -> {
166
- floatArrayOf(CORNER_RADIUS, CORNER_RADIUS, 0f, 0f, 0f, 0f, CORNER_RADIUS, CORNER_RADIUS)
167
- }
168
-
169
- isLast -> {
170
- floatArrayOf(0f, 0f, CORNER_RADIUS, CORNER_RADIUS, CORNER_RADIUS, CORNER_RADIUS, 0f, 0f)
171
- }
172
-
173
- else -> {
174
- floatArrayOf(0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f)
175
- }
87
+ InlineChipPainter.draw(
88
+ canvas,
89
+ rect,
90
+ path,
91
+ CORNER_RADIUS,
92
+ sharedBackgroundPaint,
93
+ sharedBorderPaint,
94
+ isFirst,
95
+ isLast,
96
+ strokeInset = false,
97
+ )
176
98
  }
177
99
 
178
100
  private fun leadingMarginAt(
179
101
  text: Spanned,
180
102
  lineStart: Int,
181
- ): Int {
182
- if (lineStart >= text.length) return 0
183
- val spans = text.getSpans(lineStart, lineStart + 1, LeadingMarginSpan::class.java)
184
- var margin = 0
185
- for (span in spans) {
186
- margin += span.getLeadingMargin(false)
187
- }
188
- return margin
189
- }
103
+ ): Int = InlineChipPainter.leadingMarginAt(text, lineStart)
190
104
 
191
105
  private fun adjustBottomForMargin(
192
106
  text: Spanned,
@@ -0,0 +1,140 @@
1
+ package com.swmansion.enriched.markdown.spans
2
+
3
+ import android.graphics.Canvas
4
+ import android.graphics.Paint
5
+ import android.graphics.Path
6
+ import android.graphics.RectF
7
+ import android.text.Spanned
8
+ import android.text.StaticLayout
9
+ import android.text.TextPaint
10
+ import android.text.style.LeadingMarginSpan
11
+
12
+ /**
13
+ * Shared geometry and drawing for inline "chips" painted from a [android.text.style.LineBackgroundSpan]
14
+ * (inline code, link chips). For a chip that wraps, only the outer ends are rounded and the border
15
+ * stays open where a fragment is cut.
16
+ */
17
+ internal object InlineChipPainter {
18
+ /**
19
+ * Returns the x position of [index] relative to the line's left edge, including any
20
+ * leading margin. The measuring StaticLayout is built from a subSequence that keeps
21
+ * all spans, so LeadingMarginSpans (lists, blockquotes) are already applied to
22
+ * getPrimaryHorizontal; adding the margin again on top would shift the background
23
+ * right by the indent. The margin is only added explicitly in the early-return case,
24
+ * where no layout is built.
25
+ */
26
+ fun horizontalOffset(
27
+ text: CharSequence,
28
+ lineStart: Int,
29
+ lineEnd: Int,
30
+ index: Int,
31
+ paint: Paint,
32
+ leadingMargin: Int,
33
+ ): Float {
34
+ if (index <= lineStart) return leadingMargin.toFloat()
35
+ val lineText = text.subSequence(lineStart, lineEnd)
36
+ val textPaint = paint as? TextPaint ?: TextPaint(paint)
37
+ val layout = StaticLayout.Builder.obtain(lineText, 0, lineText.length, textPaint, 10000).build()
38
+ return layout.getPrimaryHorizontal(index - lineStart)
39
+ }
40
+
41
+ fun leadingMarginAt(
42
+ text: Spanned,
43
+ lineStart: Int,
44
+ ): Int {
45
+ if (lineStart >= text.length) return 0
46
+ val spans = text.getSpans(lineStart, lineStart + 1, LeadingMarginSpan::class.java)
47
+ var margin = 0
48
+ for (span in spans) {
49
+ margin += span.getLeadingMargin(false)
50
+ }
51
+ return margin
52
+ }
53
+
54
+ /**
55
+ * Fills [rect] and strokes its border. [strokeInset] keeps a closed border inside [rect]
56
+ * (link chips); otherwise it is centered on the edge (inline code, kept for pixel parity).
57
+ * [path] is caller-owned scratch space to avoid per-draw allocations.
58
+ */
59
+ fun draw(
60
+ canvas: Canvas,
61
+ rect: RectF,
62
+ path: Path,
63
+ cornerRadius: Float,
64
+ backgroundPaint: Paint?,
65
+ borderPaint: Paint?,
66
+ isFirst: Boolean,
67
+ isLast: Boolean,
68
+ strokeInset: Boolean,
69
+ ) {
70
+ val r = minOf(maxOf(cornerRadius, 0f), minOf(rect.width(), rect.height()) / 2f)
71
+
72
+ if (backgroundPaint != null) {
73
+ path.reset()
74
+ path.addRoundRect(rect, radii(r, isFirst, isLast), Path.Direction.CW)
75
+ canvas.drawPath(path, backgroundPaint)
76
+ }
77
+
78
+ if (borderPaint == null || borderPaint.strokeWidth <= 0f) return
79
+
80
+ path.reset()
81
+ if (isFirst && isLast) {
82
+ if (strokeInset) {
83
+ val inset = borderPaint.strokeWidth / 2f
84
+ val insetRect = RectF(rect.left + inset, rect.top + inset, rect.right - inset, rect.bottom - inset)
85
+ path.addRoundRect(insetRect, radii(maxOf(r - inset, 0f), true, true), Path.Direction.CW)
86
+ } else {
87
+ path.addRoundRect(rect, radii(r, true, true), Path.Direction.CW)
88
+ }
89
+ } else {
90
+ val inset = if (strokeInset) borderPaint.strokeWidth / 2f else 0f
91
+ addOpenBorder(path, rect, r, inset, isFirst, isLast)
92
+ }
93
+ canvas.drawPath(path, borderPaint)
94
+ }
95
+
96
+ private fun addOpenBorder(
97
+ path: Path,
98
+ rect: RectF,
99
+ r: Float,
100
+ inset: Float,
101
+ isFirst: Boolean,
102
+ isLast: Boolean,
103
+ ) {
104
+ val left = rect.left + inset
105
+ val top = rect.top + inset
106
+ val right = rect.right - inset
107
+ val bottom = rect.bottom - inset
108
+
109
+ if (isFirst) {
110
+ path.moveTo(rect.right, top)
111
+ path.lineTo(left + r, top)
112
+ path.quadTo(left, top, left, top + r)
113
+ path.lineTo(left, bottom - r)
114
+ path.quadTo(left, bottom, left + r, bottom)
115
+ path.lineTo(rect.right, bottom)
116
+ } else if (isLast) {
117
+ path.moveTo(rect.left, top)
118
+ path.lineTo(right - r, top)
119
+ path.quadTo(right, top, right, top + r)
120
+ path.lineTo(right, bottom - r)
121
+ path.quadTo(right, bottom, right - r, bottom)
122
+ path.lineTo(rect.left, bottom)
123
+ } else {
124
+ path.moveTo(rect.left, top)
125
+ path.lineTo(rect.right, top)
126
+ path.moveTo(rect.left, bottom)
127
+ path.lineTo(rect.right, bottom)
128
+ }
129
+ }
130
+
131
+ private fun radii(
132
+ r: Float,
133
+ isFirst: Boolean,
134
+ isLast: Boolean,
135
+ ): FloatArray {
136
+ val left = if (isFirst) r else 0f
137
+ val right = if (isLast) r else 0f
138
+ return floatArrayOf(left, left, right, right, right, right, left, left)
139
+ }
140
+ }