react-native-enriched-markdown-scaffold 1.0.2-scaffold.2 → 1.0.2-scaffold.4

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 (135) hide show
  1. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownManagerDelegate.java +3 -0
  2. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownManagerInterface.java +1 -0
  3. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerDelegate.java +3 -0
  4. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerInterface.java +1 -0
  5. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp +72 -0
  6. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.h +50 -0
  7. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.cpp +10 -0
  8. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +134 -0
  9. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +62 -7
  10. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownInternalText.kt +4 -0
  11. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt +13 -0
  12. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +42 -0
  13. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt +12 -0
  14. package/android/src/main/java/com/swmansion/enriched/markdown/events/CopyRequestEvent.kt +28 -0
  15. package/android/src/main/java/com/swmansion/enriched/markdown/events/LinkLayoutEvent.kt +36 -0
  16. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt +37 -7
  17. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt +5 -1
  18. package/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBackgroundSpan.kt +13 -99
  19. package/android/src/main/java/com/swmansion/enriched/markdown/spans/InlineChipPainter.kt +140 -0
  20. package/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkChipSpans.kt +177 -0
  21. package/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt +1 -6
  22. package/android/src/main/java/com/swmansion/enriched/markdown/styles/LinkStyle.kt +36 -1
  23. package/android/src/main/java/com/swmansion/enriched/markdown/styles/LinkVariantEntry.kt +7 -3
  24. package/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt +33 -0
  25. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/HTMLGenerator.kt +2 -1
  26. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/MarkdownExtractor.kt +6 -0
  27. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkLayoutCollector.kt +100 -0
  28. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/SelectionActionMode.kt +24 -2
  29. package/android/src/main/java/com/swmansion/enriched/markdown/views/BlockMenuOptions.kt +27 -8
  30. package/android/src/main/java/com/swmansion/enriched/markdown/views/CodeBlockContainerView.kt +9 -1
  31. package/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt +18 -2
  32. package/android/src/math/java/com/swmansion/enriched/markdown/views/MathContainerView.kt +3 -1
  33. package/docs/API_REFERENCE.md +101 -0
  34. package/docs/COPY_OPTIONS.md +14 -0
  35. package/docs/MENTIONS.md +8 -1
  36. package/docs/STYLES.md +27 -0
  37. package/docs/TEXT.md +2 -0
  38. package/ios/EnrichedMarkdown.mm +91 -2
  39. package/ios/EnrichedMarkdownText.mm +64 -3
  40. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.cpp +72 -0
  41. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.h +50 -0
  42. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.cpp +10 -0
  43. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h +134 -0
  44. package/ios/internals/MeasurementCache.h +5 -0
  45. package/ios/renderer/LinkRenderer.m +47 -15
  46. package/ios/styles/StyleConfig.h +16 -0
  47. package/ios/styles/StyleConfig.mm +60 -0
  48. package/ios/utils/CodeBackground.m +3 -65
  49. package/ios/utils/ENRMBlockMenu.h +12 -3
  50. package/ios/utils/ENRMChipDrawing.h +24 -0
  51. package/ios/utils/ENRMChipDrawing.m +83 -0
  52. package/ios/utils/ENRMLinkChip.h +70 -0
  53. package/ios/utils/ENRMLinkChip.m +194 -0
  54. package/ios/utils/ENRMLinkLayout.h +50 -0
  55. package/ios/utils/ENRMLinkLayout.m +45 -0
  56. package/ios/utils/EditMenuUtils+macOS.m +4 -1
  57. package/ios/utils/EditMenuUtils.h +10 -1
  58. package/ios/utils/EditMenuUtils.m +22 -5
  59. package/ios/utils/HTMLGenerator.m +2 -1
  60. package/ios/utils/MarkdownAccessibilityElementBuilder.m +4 -1
  61. package/ios/utils/MarkdownExtractor.m +2 -1
  62. package/ios/utils/RTFExportUtils.m +2 -0
  63. package/ios/utils/StylePropsUtils.h +37 -1
  64. package/ios/utils/TextViewLayoutManager.mm +3 -0
  65. package/ios/views/ENRMCodeBlockContainerView.h +4 -0
  66. package/ios/views/ENRMCodeBlockContainerView.m +8 -1
  67. package/ios/views/ENRMMathContainerView.h +4 -0
  68. package/ios/views/ENRMMathContainerView.m +8 -1
  69. package/ios/views/TableContainerView.h +7 -0
  70. package/ios/views/TableContainerView.m +30 -3
  71. package/lib/module/EnrichedMarkdownNativeComponent.ts +57 -0
  72. package/lib/module/EnrichedMarkdownTextNativeComponent.ts +57 -0
  73. package/lib/module/index.js.map +1 -1
  74. package/lib/module/native/EnrichedMarkdownText.js +119 -9
  75. package/lib/module/native/EnrichedMarkdownText.js.map +1 -1
  76. package/lib/module/normalizeMarkdownStyle.js +13 -2
  77. package/lib/module/normalizeMarkdownStyle.js.map +1 -1
  78. package/lib/module/normalizeMarkdownStyle.web.js +13 -2
  79. package/lib/module/normalizeMarkdownStyle.web.js.map +1 -1
  80. package/lib/module/web/EnrichedMarkdownText.js +6 -3
  81. package/lib/module/web/EnrichedMarkdownText.js.map +1 -1
  82. package/lib/module/web/renderers/InlineRenderers.js +96 -3
  83. package/lib/module/web/renderers/InlineRenderers.js.map +1 -1
  84. package/lib/module/web/styles.js +18 -0
  85. package/lib/module/web/styles.js.map +1 -1
  86. package/lib/module/web/utils.js +13 -0
  87. package/lib/module/web/utils.js.map +1 -1
  88. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts +53 -0
  89. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts.map +1 -1
  90. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +53 -0
  91. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts.map +1 -1
  92. package/lib/typescript/src/index.d.ts +2 -2
  93. package/lib/typescript/src/index.d.ts.map +1 -1
  94. package/lib/typescript/src/index.web.d.ts +1 -1
  95. package/lib/typescript/src/index.web.d.ts.map +1 -1
  96. package/lib/typescript/src/native/EnrichedMarkdownText.d.ts +5 -5
  97. package/lib/typescript/src/native/EnrichedMarkdownText.d.ts.map +1 -1
  98. package/lib/typescript/src/normalizeMarkdownStyle.d.ts.map +1 -1
  99. package/lib/typescript/src/normalizeMarkdownStyle.web.d.ts.map +1 -1
  100. package/lib/typescript/src/types/MarkdownStyle.d.ts +28 -5
  101. package/lib/typescript/src/types/MarkdownStyle.d.ts.map +1 -1
  102. package/lib/typescript/src/types/MarkdownStyleInternal.d.ts +10 -0
  103. package/lib/typescript/src/types/MarkdownStyleInternal.d.ts.map +1 -1
  104. package/lib/typescript/src/types/MarkdownTextProps.d.ts +54 -1
  105. package/lib/typescript/src/types/MarkdownTextProps.d.ts.map +1 -1
  106. package/lib/typescript/src/types/MarkdownTextProps.web.d.ts +41 -1
  107. package/lib/typescript/src/types/MarkdownTextProps.web.d.ts.map +1 -1
  108. package/lib/typescript/src/types/events.d.ts +22 -0
  109. package/lib/typescript/src/types/events.d.ts.map +1 -1
  110. package/lib/typescript/src/web/EnrichedMarkdownText.d.ts +1 -1
  111. package/lib/typescript/src/web/EnrichedMarkdownText.d.ts.map +1 -1
  112. package/lib/typescript/src/web/renderers/InlineRenderers.d.ts.map +1 -1
  113. package/lib/typescript/src/web/styles.d.ts.map +1 -1
  114. package/lib/typescript/src/web/types.d.ts +4 -0
  115. package/lib/typescript/src/web/types.d.ts.map +1 -1
  116. package/lib/typescript/src/web/utils.d.ts +4 -0
  117. package/lib/typescript/src/web/utils.d.ts.map +1 -1
  118. package/package.json +1 -1
  119. package/src/EnrichedMarkdownNativeComponent.ts +57 -0
  120. package/src/EnrichedMarkdownTextNativeComponent.ts +57 -0
  121. package/src/index.tsx +3 -0
  122. package/src/index.web.tsx +5 -1
  123. package/src/native/EnrichedMarkdownText.tsx +127 -7
  124. package/src/normalizeMarkdownStyle.ts +13 -0
  125. package/src/normalizeMarkdownStyle.web.ts +11 -0
  126. package/src/types/MarkdownStyle.ts +29 -5
  127. package/src/types/MarkdownStyleInternal.ts +10 -0
  128. package/src/types/MarkdownTextProps.ts +56 -0
  129. package/src/types/MarkdownTextProps.web.ts +43 -1
  130. package/src/types/events.ts +23 -0
  131. package/src/web/EnrichedMarkdownText.tsx +10 -3
  132. package/src/web/renderers/InlineRenderers.tsx +111 -3
  133. package/src/web/styles.ts +25 -0
  134. package/src/web/types.ts +4 -0
  135. package/src/web/utils.ts +8 -0
@@ -25,9 +25,12 @@ import com.swmansion.enriched.markdown.utils.common.RenderedSegment
25
25
  import com.swmansion.enriched.markdown.utils.common.SegmentReconciler
26
26
  import com.swmansion.enriched.markdown.utils.common.StreamingMarkdownFilter
27
27
  import com.swmansion.enriched.markdown.utils.common.TableStreamingMode
28
+ import com.swmansion.enriched.markdown.utils.common.emitLinkLayout
28
29
  import com.swmansion.enriched.markdown.utils.common.isReducedMotionEnabled
29
30
  import com.swmansion.enriched.markdown.utils.common.splitASTIntoSegments
30
31
  import com.swmansion.enriched.markdown.utils.text.TailFadeInAnimator
32
+ import com.swmansion.enriched.markdown.utils.text.view.LinkLayoutCollector
33
+ import com.swmansion.enriched.markdown.utils.text.view.LinkLayoutFragment
31
34
  import com.swmansion.enriched.markdown.utils.text.view.SelectionMenuConfig
32
35
  import com.swmansion.enriched.markdown.utils.text.view.applySelectionColors
33
36
  import com.swmansion.enriched.markdown.views.BlockMenuOptions
@@ -99,6 +102,8 @@ class EnrichedMarkdown
99
102
  private var selectionMenuConfig = SelectionMenuConfig()
100
103
  private var accessibilityLabels = AccessibilityLabels()
101
104
  private var textBreakStrategy: String = BreakStrategyUtils.DEFAULT_STRATEGY
105
+ private var linkLayoutEnabled = false
106
+ private var lastLinkLayout: List<LinkLayoutFragment>? = null
102
107
 
103
108
  private var onLinkPressCallback: ((String) -> Unit)? = null
104
109
  private var onLinkLongPressCallback: ((String) -> Unit)? = null
@@ -106,6 +111,7 @@ class EnrichedMarkdown
106
111
  private var onCopyPressCallback: ((String, String) -> Unit)? = null
107
112
  private var contextMenuItemTexts: List<String> = emptyList()
108
113
  var onContextMenuItemPressCallback: ((itemText: String, selectedText: String, selectionStart: Int, selectionEnd: Int) -> Unit)? = null
114
+ var onCopyRequestCallback: ((source: String, markdown: String, text: String, whole: Boolean) -> Unit)? = null
109
115
  var spoilerOverlay: SpoilerOverlay = SpoilerOverlay.PARTICLES
110
116
  set(value) {
111
117
  if (field == value) return
@@ -318,19 +324,18 @@ class EnrichedMarkdown
318
324
  private fun pushCopyLabelsToBlockSegments() {
319
325
  val copyLabel = selectionMenuConfig.copyLabel
320
326
  val copyAsMarkdownLabel = selectionMenuConfig.copyAsMarkdownLabel
321
- val menuOptions = BlockMenuOptions.from(selectionMenuConfig)
322
327
  segmentViews.forEach { view ->
323
328
  when {
324
329
  view is TableContainerView -> {
325
330
  view.copyLabel = copyLabel
326
331
  view.copyAsMarkdownLabel = copyAsMarkdownLabel
327
- view.menuOptions = menuOptions
332
+ view.menuOptions = blockMenuOptions("table")
328
333
  }
329
334
 
330
335
  view is CodeBlockContainerView -> {
331
336
  view.copyLabel = copyLabel
332
337
  view.copyAsMarkdownLabel = copyAsMarkdownLabel
333
- view.menuOptions = menuOptions
338
+ view.menuOptions = blockMenuOptions("code")
334
339
  }
335
340
 
336
341
  isMathContainerView(view) -> {
@@ -341,7 +346,7 @@ class EnrichedMarkdown
341
346
  .invoke(view, copyAsMarkdownLabel)
342
347
  view.javaClass
343
348
  .getMethod("setMenuOptions", BlockMenuOptions::class.java)
344
- .invoke(view, menuOptions)
349
+ .invoke(view, blockMenuOptions("math"))
345
350
  }
346
351
  }
347
352
  }
@@ -368,6 +373,22 @@ class EnrichedMarkdown
368
373
  }
369
374
  }
370
375
 
376
+ private fun blockMenuOptions(source: String) =
377
+ BlockMenuOptions.from(selectionMenuConfig) { markdown, text ->
378
+ onCopyRequestCallback?.invoke(source, markdown, text, true)
379
+ }
380
+
381
+ // Segments only see their own text, so a selection is the whole component
382
+ // only when it spans the sole segment; the markdown is then the original.
383
+ private fun forwardSegmentCopyRequest(
384
+ markdown: String,
385
+ text: String,
386
+ fullSelection: Boolean,
387
+ ) {
388
+ val whole = fullSelection && segmentViews.size == 1 && currentMarkdown.isNotEmpty()
389
+ onCopyRequestCallback?.invoke("selection", if (whole) currentMarkdown else markdown, text, whole)
390
+ }
391
+
371
392
  private fun forwardContextMenuItemPress(
372
393
  itemText: String,
373
394
  selectedText: String,
@@ -615,6 +636,7 @@ class EnrichedMarkdown
615
636
  onTaskListItemPressCallback = { taskIndex, checked, itemText ->
616
637
  this@EnrichedMarkdown.onTaskListItemPressCallback?.invoke(taskIndex, checked, itemText)
617
638
  }
639
+ onCopyRequest = ::forwardSegmentCopyRequest
618
640
 
619
641
  if (contextMenuItemTexts.isNotEmpty()) {
620
642
  setContextMenuItems(contextMenuItemTexts, ::forwardContextMenuItemPress)
@@ -635,7 +657,7 @@ class EnrichedMarkdown
635
657
  copyLabel = this@EnrichedMarkdown.selectionMenuConfig.copyLabel
636
658
  copyAsMarkdownLabel = this@EnrichedMarkdown.selectionMenuConfig.copyAsMarkdownLabel
637
659
  contextMenuHidden = this@EnrichedMarkdown.contextMenuHidden
638
- menuOptions = BlockMenuOptions.from(this@EnrichedMarkdown.selectionMenuConfig)
660
+ menuOptions = blockMenuOptions("table")
639
661
  applyTableNode(segment.node)
640
662
  }
641
663
 
@@ -646,7 +668,7 @@ class EnrichedMarkdown
646
668
  copyLabel = this@EnrichedMarkdown.selectionMenuConfig.copyLabel
647
669
  copyAsMarkdownLabel = this@EnrichedMarkdown.selectionMenuConfig.copyAsMarkdownLabel
648
670
  contextMenuHidden = this@EnrichedMarkdown.contextMenuHidden
649
- menuOptions = BlockMenuOptions.from(this@EnrichedMarkdown.selectionMenuConfig)
671
+ menuOptions = blockMenuOptions("code")
650
672
  onCopyPress = { code, language ->
651
673
  this@EnrichedMarkdown.onCopyPressCallback?.invoke(code, language)
652
674
  }
@@ -678,7 +700,7 @@ class EnrichedMarkdown
678
700
  .invoke(view, selectionMenuConfig.copyAsMarkdownLabel)
679
701
  resolvedClass
680
702
  .getMethod("setMenuOptions", BlockMenuOptions::class.java)
681
- .invoke(view, BlockMenuOptions.from(selectionMenuConfig))
703
+ .invoke(view, blockMenuOptions("math"))
682
704
  setMathContextMenuHidden(view)
683
705
  resolvedClass.getMethod("applyLatex", String::class.java).invoke(view, segment.latex)
684
706
  view
@@ -765,6 +787,39 @@ class EnrichedMarkdown
765
787
  currentY += segment?.segmentMarginBottom ?: 0
766
788
  }
767
789
  }
790
+
791
+ emitLinkLayoutIfNeeded()
792
+ }
793
+
794
+ fun setEmitLinkLayout(enabled: Boolean) {
795
+ if (linkLayoutEnabled == enabled) return
796
+ linkLayoutEnabled = enabled
797
+ lastLinkLayout = null
798
+ emitLinkLayoutIfNeeded()
799
+ }
800
+
801
+ /**
802
+ * Reports link rects of every text segment (offset by the segment's position) to JS for
803
+ * `renderLinkDecoration`. Tables are skipped: their cells scroll horizontally, so rects would
804
+ * go stale. Runs after each segment layout pass; deduplicated so an unchanged layout sends nothing.
805
+ */
806
+ private fun emitLinkLayoutIfNeeded() {
807
+ if (!linkLayoutEnabled || width <= 0) return
808
+ val fragments = mutableListOf<LinkLayoutFragment>()
809
+ var linkCount = 0
810
+ segmentViews.forEach { view ->
811
+ if (view is TableContainerView) {
812
+ linkCount += view.linkCount
813
+ return@forEach
814
+ }
815
+ if (view !is EnrichedMarkdownInternalText) return@forEach
816
+ val (segmentFragments, segmentLinks) = LinkLayoutCollector.collect(view, view.left, view.top, linkCount)
817
+ fragments += segmentFragments
818
+ linkCount += segmentLinks
819
+ }
820
+ if (fragments == lastLinkLayout) return
821
+ lastLinkLayout = fragments
822
+ emitLinkLayout(this, fragments)
768
823
  }
769
824
 
770
825
  private fun computeSegmentsTotalHeight(): Int {
@@ -56,6 +56,7 @@ class EnrichedMarkdownInternalText
56
56
  private var contextMenuItemTexts: List<String> = emptyList()
57
57
  private var onContextMenuItemPress: ((itemText: String, selectedText: String, selectionStart: Int, selectionEnd: Int) -> Unit)? = null
58
58
  var selectionMenuConfig: SelectionMenuConfig = SelectionMenuConfig()
59
+ var onCopyRequest: ((markdown: String, text: String, fullSelection: Boolean) -> Unit)? = null
59
60
  private var activeActionMode: ActionMode? = null
60
61
  var contextMenuHidden: Boolean = false
61
62
  set(value) {
@@ -82,6 +83,9 @@ class EnrichedMarkdownInternalText
82
83
  onCustomItemPress = { itemText, selectedText, start, end ->
83
84
  onContextMenuItemPress?.invoke(itemText, selectedText, start, end)
84
85
  },
86
+ onCopyRequest = { markdown, text, fullSelection ->
87
+ onCopyRequest?.invoke(markdown, text, fullSelection)
88
+ },
85
89
  )
86
90
  }
87
91
 
@@ -18,6 +18,7 @@ import com.swmansion.enriched.markdown.utils.common.CodeBlockStreamingMode
18
18
  import com.swmansion.enriched.markdown.utils.common.TableStreamingMode
19
19
  import com.swmansion.enriched.markdown.utils.common.emitContextMenuItemPress
20
20
  import com.swmansion.enriched.markdown.utils.common.emitCopyPress
21
+ import com.swmansion.enriched.markdown.utils.common.emitCopyRequest
21
22
  import com.swmansion.enriched.markdown.utils.common.emitLinkLongPress
22
23
  import com.swmansion.enriched.markdown.utils.common.emitLinkPress
23
24
  import com.swmansion.enriched.markdown.utils.common.emitTaskListItemPress
@@ -65,6 +66,10 @@ class EnrichedMarkdownManager :
65
66
  emitCopyPress(view, code, language)
66
67
  }
67
68
 
69
+ view.onCopyRequestCallback = { source, markdown, text, whole ->
70
+ emitCopyRequest(view, source, markdown, text, whole)
71
+ }
72
+
68
73
  return view
69
74
  }
70
75
 
@@ -172,6 +177,14 @@ class EnrichedMarkdownManager :
172
177
  view?.setContextMenuHidden(contextMenuHidden)
173
178
  }
174
179
 
180
+ @ReactProp(name = "emitLinkLayout", defaultBoolean = false)
181
+ override fun setEmitLinkLayout(
182
+ view: EnrichedMarkdown?,
183
+ emitLinkLayout: Boolean,
184
+ ) {
185
+ view?.setEmitLinkLayout(emitLinkLayout)
186
+ }
187
+
175
188
  @ReactProp(name = "enableLinkPreview", defaultBoolean = true)
176
189
  override fun setEnableLinkPreview(
177
190
  view: EnrichedMarkdown?,
@@ -24,8 +24,11 @@ import com.swmansion.enriched.markdown.spoiler.SpoilerOverlay
24
24
  import com.swmansion.enriched.markdown.spoiler.SpoilerOverlayDrawer
25
25
  import com.swmansion.enriched.markdown.styles.StyleConfig
26
26
  import com.swmansion.enriched.markdown.utils.common.BreakStrategyUtils
27
+ import com.swmansion.enriched.markdown.utils.common.emitLinkLayout
27
28
  import com.swmansion.enriched.markdown.utils.text.TailFadeInAnimator
28
29
  import com.swmansion.enriched.markdown.utils.text.interaction.CheckboxTouchHelper
30
+ import com.swmansion.enriched.markdown.utils.text.view.LinkLayoutCollector
31
+ import com.swmansion.enriched.markdown.utils.text.view.LinkLayoutFragment
29
32
  import com.swmansion.enriched.markdown.utils.text.view.LinkLongPressMovementMethod
30
33
  import com.swmansion.enriched.markdown.utils.text.view.SelectionMenuConfig
31
34
  import com.swmansion.enriched.markdown.utils.text.view.applySelectableState
@@ -68,6 +71,7 @@ class EnrichedMarkdownText
68
71
 
69
72
  private var contextMenuItemTexts: List<String> = emptyList()
70
73
  var onContextMenuItemPressCallback: ((itemText: String, selectedText: String, selectionStart: Int, selectionEnd: Int) -> Unit)? = null
74
+ var onCopyRequestCallback: ((markdown: String, text: String, whole: Boolean) -> Unit)? = null
71
75
 
72
76
  var markdownStyle: StyleConfig? = null
73
77
  private set
@@ -101,6 +105,9 @@ class EnrichedMarkdownText
101
105
  private var contextMenuHidden = false
102
106
  private var activeActionMode: ActionMode? = null
103
107
 
108
+ private var linkLayoutEnabled = false
109
+ private var lastLinkLayout: List<LinkLayoutFragment>? = null
110
+
104
111
  init {
105
112
  setupAsMarkdownTextView()
106
113
  customSelectionActionModeCallback =
@@ -113,6 +120,10 @@ class EnrichedMarkdownText
113
120
  onCustomItemPress = { itemText, selectedText, start, end ->
114
121
  onContextMenuItemPressCallback?.invoke(itemText, selectedText, start, end)
115
122
  },
123
+ // A full selection already carries the original markdown.
124
+ onCopyRequest = { markdown, text, fullSelection ->
125
+ onCopyRequestCallback?.invoke(markdown, text, fullSelection && currentMarkdown.isNotEmpty())
126
+ },
116
127
  )
117
128
  }
118
129
 
@@ -305,6 +316,37 @@ class EnrichedMarkdownText
305
316
  }
306
317
 
307
318
  applySelectionColors(selectionColor, selectionHandleColor)
319
+ emitLinkLayoutIfNeeded()
320
+ }
321
+
322
+ fun setEmitLinkLayout(enabled: Boolean) {
323
+ if (linkLayoutEnabled == enabled) return
324
+ linkLayoutEnabled = enabled
325
+ lastLinkLayout = null
326
+ emitLinkLayoutIfNeeded()
327
+ }
328
+
329
+ /**
330
+ * Reports link rects to JS (for `renderLinkDecoration`) after every render and layout;
331
+ * deduplicated so an unchanged layout — the common case while streaming — sends nothing.
332
+ */
333
+ private fun emitLinkLayoutIfNeeded() {
334
+ if (!linkLayoutEnabled || layout == null || width <= 0) return
335
+ val (fragments, _) = LinkLayoutCollector.collect(this)
336
+ if (fragments == lastLinkLayout) return
337
+ lastLinkLayout = fragments
338
+ emitLinkLayout(this, fragments)
339
+ }
340
+
341
+ override fun onLayout(
342
+ changed: Boolean,
343
+ left: Int,
344
+ top: Int,
345
+ right: Int,
346
+ bottom: Int,
347
+ ) {
348
+ super.onLayout(changed, left, top, right, bottom)
349
+ emitLinkLayoutIfNeeded()
308
350
  }
309
351
 
310
352
  // Trailing bottom margin included in the shadow-node measurement — must be
@@ -15,6 +15,7 @@ import com.facebook.react.viewmanagers.EnrichedMarkdownTextManagerInterface
15
15
  import com.facebook.yoga.YogaMeasureMode
16
16
  import com.swmansion.enriched.markdown.spoiler.SpoilerOverlay
17
17
  import com.swmansion.enriched.markdown.utils.common.emitContextMenuItemPress
18
+ import com.swmansion.enriched.markdown.utils.common.emitCopyRequest
18
19
  import com.swmansion.enriched.markdown.utils.common.emitLinkLongPress
19
20
  import com.swmansion.enriched.markdown.utils.common.emitLinkPress
20
21
  import com.swmansion.enriched.markdown.utils.common.emitTaskListItemPress
@@ -42,6 +43,9 @@ class EnrichedMarkdownTextManager :
42
43
  view.onContextMenuItemPressCallback = { itemText, selectedText, selectionStart, selectionEnd ->
43
44
  emitContextMenuItemPress(view, itemText, selectedText, selectionStart, selectionEnd)
44
45
  }
46
+ view.onCopyRequestCallback = { markdown, text, whole ->
47
+ emitCopyRequest(view, "selection", markdown, text, whole)
48
+ }
45
49
 
46
50
  view.setOnLinkPressCallback { url ->
47
51
  emitLinkPress(view, url)
@@ -173,6 +177,14 @@ class EnrichedMarkdownTextManager :
173
177
  view?.setContextMenuHidden(contextMenuHidden)
174
178
  }
175
179
 
180
+ @ReactProp(name = "emitLinkLayout", defaultBoolean = false)
181
+ override fun setEmitLinkLayout(
182
+ view: EnrichedMarkdownText?,
183
+ emitLinkLayout: Boolean,
184
+ ) {
185
+ view?.setEmitLinkLayout(emitLinkLayout)
186
+ }
187
+
176
188
  @ReactProp(name = "enableLinkPreview", defaultBoolean = true)
177
189
  override fun setEnableLinkPreview(
178
190
  view: EnrichedMarkdownText?,
@@ -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
+ }
@@ -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,