react-native-enriched-markdown-scaffold 1.0.2-scaffold.3 → 1.0.2-scaffold.5

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 (140) hide show
  1. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownManagerDelegate.java +15 -0
  2. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownManagerInterface.java +3 -0
  3. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerDelegate.java +15 -0
  4. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerInterface.java +3 -0
  5. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp +122 -0
  6. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.h +100 -0
  7. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.cpp +20 -0
  8. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +124 -0
  9. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +145 -0
  10. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownInternalText.kt +19 -0
  11. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt +22 -0
  12. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +85 -0
  13. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt +22 -0
  14. package/android/src/main/java/com/swmansion/enriched/markdown/events/LinkLayoutEvent.kt +36 -0
  15. package/android/src/main/java/com/swmansion/enriched/markdown/events/SelectionChangeEvent.kt +42 -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 +28 -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 +4 -2
  29. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/SelectionChangeReporter.kt +230 -0
  30. package/android/src/main/java/com/swmansion/enriched/markdown/views/CodeBlockContainerView.kt +11 -1
  31. package/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt +22 -1
  32. package/android/src/math/java/com/swmansion/enriched/markdown/views/MathContainerView.kt +8 -1
  33. package/docs/API_REFERENCE.md +111 -0
  34. package/docs/MENTIONS.md +8 -1
  35. package/docs/STYLES.md +27 -0
  36. package/docs/TEXT.md +2 -0
  37. package/ios/EnrichedMarkdown.mm +221 -1
  38. package/ios/EnrichedMarkdownText.mm +142 -1
  39. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.cpp +122 -0
  40. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.h +100 -0
  41. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.cpp +20 -0
  42. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h +124 -0
  43. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/RCTComponentViewHelpers.h +50 -2
  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/ENRMChipDrawing.h +24 -0
  50. package/ios/utils/ENRMChipDrawing.m +83 -0
  51. package/ios/utils/ENRMLinkChip.h +70 -0
  52. package/ios/utils/ENRMLinkChip.m +194 -0
  53. package/ios/utils/ENRMLinkLayout.h +50 -0
  54. package/ios/utils/ENRMLinkLayout.m +45 -0
  55. package/ios/utils/ENRMSelectionChange.h +96 -0
  56. package/ios/utils/ENRMSelectionChange.m +204 -0
  57. package/ios/utils/EditMenuUtils+macOS.m +4 -1
  58. package/ios/utils/EditMenuUtils.m +4 -1
  59. package/ios/utils/HTMLGenerator.m +2 -1
  60. package/ios/utils/MarkdownAccessibilityElementBuilder.m +3 -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 +3 -0
  66. package/ios/views/ENRMCodeBlockContainerView.m +3 -0
  67. package/ios/views/ENRMMathContainerView.h +3 -0
  68. package/ios/views/ENRMMathContainerView.m +3 -0
  69. package/ios/views/TableContainerView.h +6 -0
  70. package/ios/views/TableContainerView.m +24 -0
  71. package/lib/module/EnrichedMarkdownNativeComponent.ts +101 -1
  72. package/lib/module/EnrichedMarkdownTextNativeComponent.ts +101 -1
  73. package/lib/module/index.js.map +1 -1
  74. package/lib/module/jest/index.js +13 -0
  75. package/lib/module/jest/index.js.map +1 -1
  76. package/lib/module/native/EnrichedMarkdownText.js +138 -9
  77. package/lib/module/native/EnrichedMarkdownText.js.map +1 -1
  78. package/lib/module/normalizeMarkdownStyle.js +13 -2
  79. package/lib/module/normalizeMarkdownStyle.js.map +1 -1
  80. package/lib/module/normalizeMarkdownStyle.web.js +13 -2
  81. package/lib/module/normalizeMarkdownStyle.web.js.map +1 -1
  82. package/lib/module/web/EnrichedMarkdownText.js +6 -3
  83. package/lib/module/web/EnrichedMarkdownText.js.map +1 -1
  84. package/lib/module/web/renderers/InlineRenderers.js +96 -3
  85. package/lib/module/web/renderers/InlineRenderers.js.map +1 -1
  86. package/lib/module/web/styles.js +18 -0
  87. package/lib/module/web/styles.js.map +1 -1
  88. package/lib/module/web/utils.js +13 -0
  89. package/lib/module/web/utils.js.map +1 -1
  90. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts +93 -2
  91. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts.map +1 -1
  92. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +93 -2
  93. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts.map +1 -1
  94. package/lib/typescript/src/index.d.ts +2 -2
  95. package/lib/typescript/src/index.d.ts.map +1 -1
  96. package/lib/typescript/src/index.web.d.ts +1 -1
  97. package/lib/typescript/src/index.web.d.ts.map +1 -1
  98. package/lib/typescript/src/jest/index.d.ts +1 -1
  99. package/lib/typescript/src/jest/index.d.ts.map +1 -1
  100. package/lib/typescript/src/native/EnrichedMarkdownText.d.ts +5 -5
  101. package/lib/typescript/src/native/EnrichedMarkdownText.d.ts.map +1 -1
  102. package/lib/typescript/src/normalizeMarkdownStyle.d.ts.map +1 -1
  103. package/lib/typescript/src/normalizeMarkdownStyle.web.d.ts.map +1 -1
  104. package/lib/typescript/src/types/MarkdownStyle.d.ts +28 -5
  105. package/lib/typescript/src/types/MarkdownStyle.d.ts.map +1 -1
  106. package/lib/typescript/src/types/MarkdownStyleInternal.d.ts +10 -0
  107. package/lib/typescript/src/types/MarkdownStyleInternal.d.ts.map +1 -1
  108. package/lib/typescript/src/types/MarkdownTextProps.d.ts +76 -2
  109. package/lib/typescript/src/types/MarkdownTextProps.d.ts.map +1 -1
  110. package/lib/typescript/src/types/MarkdownTextProps.web.d.ts +41 -1
  111. package/lib/typescript/src/types/MarkdownTextProps.web.d.ts.map +1 -1
  112. package/lib/typescript/src/types/events.d.ts +36 -0
  113. package/lib/typescript/src/types/events.d.ts.map +1 -1
  114. package/lib/typescript/src/web/EnrichedMarkdownText.d.ts +1 -1
  115. package/lib/typescript/src/web/EnrichedMarkdownText.d.ts.map +1 -1
  116. package/lib/typescript/src/web/renderers/InlineRenderers.d.ts.map +1 -1
  117. package/lib/typescript/src/web/styles.d.ts.map +1 -1
  118. package/lib/typescript/src/web/types.d.ts +4 -0
  119. package/lib/typescript/src/web/types.d.ts.map +1 -1
  120. package/lib/typescript/src/web/utils.d.ts +4 -0
  121. package/lib/typescript/src/web/utils.d.ts.map +1 -1
  122. package/package.json +1 -1
  123. package/src/EnrichedMarkdownNativeComponent.ts +101 -1
  124. package/src/EnrichedMarkdownTextNativeComponent.ts +101 -1
  125. package/src/index.tsx +5 -0
  126. package/src/index.web.tsx +5 -1
  127. package/src/jest/index.tsx +21 -1
  128. package/src/native/EnrichedMarkdownText.tsx +181 -8
  129. package/src/normalizeMarkdownStyle.ts +13 -0
  130. package/src/normalizeMarkdownStyle.web.ts +11 -0
  131. package/src/types/MarkdownStyle.ts +29 -5
  132. package/src/types/MarkdownStyleInternal.ts +10 -0
  133. package/src/types/MarkdownTextProps.ts +85 -1
  134. package/src/types/MarkdownTextProps.web.ts +43 -1
  135. package/src/types/events.ts +38 -0
  136. package/src/web/EnrichedMarkdownText.tsx +10 -3
  137. package/src/web/renderers/InlineRenderers.tsx +111 -3
  138. package/src/web/styles.ts +25 -0
  139. package/src/web/types.ts +4 -0
  140. package/src/web/utils.ts +8 -0
@@ -25,11 +25,19 @@ 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
29
+ import com.swmansion.enriched.markdown.utils.common.emitSelectionChange
28
30
  import com.swmansion.enriched.markdown.utils.common.isReducedMotionEnabled
29
31
  import com.swmansion.enriched.markdown.utils.common.splitASTIntoSegments
30
32
  import com.swmansion.enriched.markdown.utils.text.TailFadeInAnimator
33
+ import com.swmansion.enriched.markdown.utils.text.view.LinkLayoutCollector
34
+ import com.swmansion.enriched.markdown.utils.text.view.LinkLayoutFragment
35
+ import com.swmansion.enriched.markdown.utils.text.view.SelectionChangeReporter
31
36
  import com.swmansion.enriched.markdown.utils.text.view.SelectionMenuConfig
37
+ import com.swmansion.enriched.markdown.utils.text.view.SelectionRect
38
+ import com.swmansion.enriched.markdown.utils.text.view.SelectionSnapshot
32
39
  import com.swmansion.enriched.markdown.utils.text.view.applySelectionColors
40
+ import com.swmansion.enriched.markdown.utils.text.view.currentTextSelection
33
41
  import com.swmansion.enriched.markdown.views.BlockMenuOptions
34
42
  import com.swmansion.enriched.markdown.views.BlockSegmentView
35
43
  import com.swmansion.enriched.markdown.views.CodeBlockContainerView
@@ -99,6 +107,23 @@ class EnrichedMarkdown
99
107
  private var selectionMenuConfig = SelectionMenuConfig()
100
108
  private var accessibilityLabels = AccessibilityLabels()
101
109
  private var textBreakStrategy: String = BreakStrategyUtils.DEFAULT_STRATEGY
110
+ private var linkLayoutEnabled = false
111
+ private var lastLinkLayout: List<LinkLayoutFragment>? = null
112
+
113
+ // At most one segment holds a selection; a new one clears the previous.
114
+ private var selectionOwner: EnrichedMarkdownInternalText? = null
115
+
116
+ // A block long-pressed while contextMenuHidden is on, reported as the selection.
117
+ private class ActiveBlock(
118
+ val view: View,
119
+ val source: String,
120
+ val markdown: String,
121
+ val text: String,
122
+ )
123
+
124
+ private var activeBlock: ActiveBlock? = null
125
+ private val selectionReporter =
126
+ SelectionChangeReporter(this, ::selectionSnapshot) { snapshot -> emitSelectionChange(this, snapshot) }
102
127
 
103
128
  private var onLinkPressCallback: ((String) -> Unit)? = null
104
129
  private var onLinkLongPressCallback: ((String) -> Unit)? = null
@@ -393,6 +418,78 @@ class EnrichedMarkdown
393
418
  onContextMenuItemPressCallback?.invoke(itemText, selectedText, selectionStart, selectionEnd)
394
419
  }
395
420
 
421
+ fun setEmitSelectionChange(enabled: Boolean) {
422
+ selectionReporter.enabled = enabled
423
+ }
424
+
425
+ /** Clears the selection of every segment (and any active block) and reports `active: false`. */
426
+ fun clearSelection() {
427
+ val owner = selectionOwner
428
+ selectionOwner = null
429
+ activeBlock = null
430
+ owner?.clearSelection()
431
+ selectionReporter.flushNow()
432
+ }
433
+
434
+ private fun onSegmentSelectionChanged(
435
+ view: EnrichedMarkdownInternalText,
436
+ hasSelection: Boolean,
437
+ ) {
438
+ if (hasSelection) {
439
+ // Take ownership first so the previous owner's own "cleared" callback is ignored.
440
+ val previous = selectionOwner
441
+ selectionOwner = view
442
+ activeBlock = null
443
+ if (previous !== view) previous?.clearSelection()
444
+ selectionReporter.schedule()
445
+ } else if (selectionOwner === view) {
446
+ selectionReporter.onSelectionChanged(false)
447
+ }
448
+ }
449
+
450
+ private fun onBlockMenuSuppressed(
451
+ view: View,
452
+ source: String,
453
+ markdown: String,
454
+ text: String,
455
+ ) {
456
+ if (!selectionReporter.enabled) return
457
+ val previous = selectionOwner
458
+ selectionOwner = null
459
+ previous?.clearSelection()
460
+ activeBlock = ActiveBlock(view, source, markdown, text)
461
+ selectionReporter.forgetLastReported()
462
+ selectionReporter.flushNow()
463
+ }
464
+
465
+ private fun selectionSnapshot(): SelectionSnapshot {
466
+ activeBlock?.let { block ->
467
+ if (block.view.parent !== this) return SelectionSnapshot.INACTIVE
468
+ val rect =
469
+ SelectionRect.fromPx(
470
+ block.view.left.toFloat(),
471
+ block.view.top.toFloat(),
472
+ block.view.right.toFloat(),
473
+ block.view.bottom.toFloat(),
474
+ )
475
+ return SelectionSnapshot.block(block.source, block.markdown, block.text, rect)
476
+ }
477
+ val owner = selectionOwner?.takeIf { it.parent === this } ?: return SelectionSnapshot.INACTIVE
478
+ val selection = owner.currentTextSelection(owner.left, owner.top) ?: return SelectionSnapshot.INACTIVE
479
+ // Same rule as forwardSegmentCopyRequest.
480
+ val whole = selection.fullSelection && segmentViews.size == 1 && currentMarkdown.isNotEmpty()
481
+ return SelectionSnapshot(
482
+ active = true,
483
+ source = "selection",
484
+ text = selection.text,
485
+ markdown = if (whole) currentMarkdown else selection.markdown,
486
+ whole = whole,
487
+ rect = selection.rect,
488
+ startRect = selection.startRect,
489
+ endRect = selection.endRect,
490
+ )
491
+ }
492
+
396
493
  private fun recreateStyleConfig() {
397
494
  markdownStyleMap?.let {
398
495
  markdownStyle =
@@ -493,6 +590,9 @@ class EnrichedMarkdown
493
590
  segmentSignatures.clear()
494
591
  segmentSignatures.addAll(result.signatures)
495
592
 
593
+ if (selectionOwner?.let { owner -> segmentViews.none { it === owner } } == true) selectionOwner = null
594
+ if (activeBlock?.let { block -> segmentViews.none { it === block.view } } == true) activeBlock = null
595
+
496
596
  // A just-closed block has unchanged content, so the reconciler reuses it
497
597
  // without an update; sync pending here to trigger its deferred highlight.
498
598
  segmentViews.forEachIndexed { index, view ->
@@ -632,6 +732,7 @@ class EnrichedMarkdown
632
732
  this@EnrichedMarkdown.onTaskListItemPressCallback?.invoke(taskIndex, checked, itemText)
633
733
  }
634
734
  onCopyRequest = ::forwardSegmentCopyRequest
735
+ onSelectionChangedCallback = ::onSegmentSelectionChanged
635
736
 
636
737
  if (contextMenuItemTexts.isNotEmpty()) {
637
738
  setContextMenuItems(contextMenuItemTexts, ::forwardContextMenuItemPress)
@@ -653,6 +754,7 @@ class EnrichedMarkdown
653
754
  copyAsMarkdownLabel = this@EnrichedMarkdown.selectionMenuConfig.copyAsMarkdownLabel
654
755
  contextMenuHidden = this@EnrichedMarkdown.contextMenuHidden
655
756
  menuOptions = blockMenuOptions("table")
757
+ onMenuSuppressed = { markdown, text -> onBlockMenuSuppressed(this, "table", markdown, text) }
656
758
  applyTableNode(segment.node)
657
759
  }
658
760
 
@@ -664,6 +766,7 @@ class EnrichedMarkdown
664
766
  copyAsMarkdownLabel = this@EnrichedMarkdown.selectionMenuConfig.copyAsMarkdownLabel
665
767
  contextMenuHidden = this@EnrichedMarkdown.contextMenuHidden
666
768
  menuOptions = blockMenuOptions("code")
769
+ onMenuSuppressed = { markdown, text -> onBlockMenuSuppressed(this, "code", markdown, text) }
667
770
  onCopyPress = { code, language ->
668
771
  this@EnrichedMarkdown.onCopyPressCallback?.invoke(code, language)
669
772
  }
@@ -697,6 +800,12 @@ class EnrichedMarkdown
697
800
  .getMethod("setMenuOptions", BlockMenuOptions::class.java)
698
801
  .invoke(view, blockMenuOptions("math"))
699
802
  setMathContextMenuHidden(view)
803
+ val onMenuSuppressed: (String, String) -> Unit = { markdown, text ->
804
+ onBlockMenuSuppressed(view, "math", markdown, text)
805
+ }
806
+ runCatching {
807
+ resolvedClass.getMethod("setOnMenuSuppressed", Function2::class.java).invoke(view, onMenuSuppressed)
808
+ }.onFailure { Log.e(TAG, "Failed to set onMenuSuppressed on math view", it) }
700
809
  resolvedClass.getMethod("applyLatex", String::class.java).invoke(view, segment.latex)
701
810
  view
702
811
  } catch (e: Exception) {
@@ -782,6 +891,41 @@ class EnrichedMarkdown
782
891
  currentY += segment?.segmentMarginBottom ?: 0
783
892
  }
784
893
  }
894
+
895
+ emitLinkLayoutIfNeeded()
896
+ // Segments may have moved or been replaced; re-read the selection (deduplicated).
897
+ if (selectionOwner != null || activeBlock != null) selectionReporter.schedule() else selectionReporter.flushNow()
898
+ }
899
+
900
+ fun setEmitLinkLayout(enabled: Boolean) {
901
+ if (linkLayoutEnabled == enabled) return
902
+ linkLayoutEnabled = enabled
903
+ lastLinkLayout = null
904
+ emitLinkLayoutIfNeeded()
905
+ }
906
+
907
+ /**
908
+ * Reports link rects of every text segment (offset by the segment's position) to JS for
909
+ * `renderLinkDecoration`. Tables are skipped: their cells scroll horizontally, so rects would
910
+ * go stale. Runs after each segment layout pass; deduplicated so an unchanged layout sends nothing.
911
+ */
912
+ private fun emitLinkLayoutIfNeeded() {
913
+ if (!linkLayoutEnabled || width <= 0) return
914
+ val fragments = mutableListOf<LinkLayoutFragment>()
915
+ var linkCount = 0
916
+ segmentViews.forEach { view ->
917
+ if (view is TableContainerView) {
918
+ linkCount += view.linkCount
919
+ return@forEach
920
+ }
921
+ if (view !is EnrichedMarkdownInternalText) return@forEach
922
+ val (segmentFragments, segmentLinks) = LinkLayoutCollector.collect(view, view.left, view.top, linkCount)
923
+ fragments += segmentFragments
924
+ linkCount += segmentLinks
925
+ }
926
+ if (fragments == lastLinkLayout) return
927
+ lastLinkLayout = fragments
928
+ emitLinkLayout(this, fragments)
785
929
  }
786
930
 
787
931
  private fun computeSegmentsTotalHeight(): Int {
@@ -799,6 +943,7 @@ class EnrichedMarkdown
799
943
  }
800
944
 
801
945
  fun cleanup() {
946
+ selectionReporter.cancel()
802
947
  executor.shutdownNow()
803
948
  }
804
949
 
@@ -18,6 +18,7 @@ import com.swmansion.enriched.markdown.utils.text.view.SelectionMenuConfig
18
18
  import com.swmansion.enriched.markdown.utils.text.view.applySelectableState
19
19
  import com.swmansion.enriched.markdown.utils.text.view.cancelJSTouchForCheckboxTap
20
20
  import com.swmansion.enriched.markdown.utils.text.view.cancelJSTouchForLinkTap
21
+ import com.swmansion.enriched.markdown.utils.text.view.clearTextSelection
21
22
  import com.swmansion.enriched.markdown.utils.text.view.createSelectionActionModeCallback
22
23
  import com.swmansion.enriched.markdown.utils.text.view.reallowParentInterceptIfLinkReleased
23
24
  import com.swmansion.enriched.markdown.utils.text.view.setupAsMarkdownTextView
@@ -57,6 +58,10 @@ class EnrichedMarkdownInternalText
57
58
  private var onContextMenuItemPress: ((itemText: String, selectedText: String, selectionStart: Int, selectionEnd: Int) -> Unit)? = null
58
59
  var selectionMenuConfig: SelectionMenuConfig = SelectionMenuConfig()
59
60
  var onCopyRequest: ((markdown: String, text: String, fullSelection: Boolean) -> Unit)? = null
61
+
62
+ // Read from onSelectionChanged, which TextView already calls from its own
63
+ // constructor — so it must stay nullable.
64
+ var onSelectionChangedCallback: ((view: EnrichedMarkdownInternalText, hasSelection: Boolean) -> Unit)? = null
60
65
  private var activeActionMode: ActionMode? = null
61
66
  var contextMenuHidden: Boolean = false
62
67
  set(value) {
@@ -115,6 +120,20 @@ class EnrichedMarkdownInternalText
115
120
  applySelectableState(selectable)
116
121
  }
117
122
 
123
+ override fun onSelectionChanged(
124
+ selStart: Int,
125
+ selEnd: Int,
126
+ ) {
127
+ super.onSelectionChanged(selStart, selEnd)
128
+ onSelectionChangedCallback?.invoke(this, selEnd > selStart)
129
+ }
130
+
131
+ /** Clears the selection and ends its ActionMode. */
132
+ fun clearSelection() {
133
+ clearTextSelection()
134
+ activeActionMode?.finish()
135
+ }
136
+
118
137
  fun setContextMenuItems(
119
138
  items: List<String>,
120
139
  onPress: (itemText: String, selectedText: String, selectionStart: Int, selectionEnd: Int) -> Unit,
@@ -177,6 +177,28 @@ class EnrichedMarkdownManager :
177
177
  view?.setContextMenuHidden(contextMenuHidden)
178
178
  }
179
179
 
180
+ @ReactProp(name = "emitLinkLayout", defaultBoolean = false)
181
+ override fun setEmitLinkLayout(
182
+ view: EnrichedMarkdown?,
183
+ emitLinkLayout: Boolean,
184
+ ) {
185
+ view?.setEmitLinkLayout(emitLinkLayout)
186
+ }
187
+
188
+ @ReactProp(name = "emitSelectionChange", defaultBoolean = false)
189
+ override fun setEmitSelectionChange(
190
+ view: EnrichedMarkdown?,
191
+ emitSelectionChange: Boolean,
192
+ ) {
193
+ view?.setEmitSelectionChange(emitSelectionChange)
194
+ }
195
+
196
+ // Commands
197
+
198
+ override fun clearSelection(view: EnrichedMarkdown?) {
199
+ view?.clearSelection()
200
+ }
201
+
180
202
  @ReactProp(name = "enableLinkPreview", defaultBoolean = true)
181
203
  override fun setEnableLinkPreview(
182
204
  view: EnrichedMarkdown?,
@@ -24,15 +24,23 @@ 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
28
+ import com.swmansion.enriched.markdown.utils.common.emitSelectionChange
27
29
  import com.swmansion.enriched.markdown.utils.text.TailFadeInAnimator
28
30
  import com.swmansion.enriched.markdown.utils.text.interaction.CheckboxTouchHelper
31
+ import com.swmansion.enriched.markdown.utils.text.view.LinkLayoutCollector
32
+ import com.swmansion.enriched.markdown.utils.text.view.LinkLayoutFragment
29
33
  import com.swmansion.enriched.markdown.utils.text.view.LinkLongPressMovementMethod
34
+ import com.swmansion.enriched.markdown.utils.text.view.SelectionChangeReporter
30
35
  import com.swmansion.enriched.markdown.utils.text.view.SelectionMenuConfig
36
+ import com.swmansion.enriched.markdown.utils.text.view.SelectionSnapshot
31
37
  import com.swmansion.enriched.markdown.utils.text.view.applySelectableState
32
38
  import com.swmansion.enriched.markdown.utils.text.view.applySelectionColors
33
39
  import com.swmansion.enriched.markdown.utils.text.view.cancelJSTouchForCheckboxTap
34
40
  import com.swmansion.enriched.markdown.utils.text.view.cancelJSTouchForLinkTap
41
+ import com.swmansion.enriched.markdown.utils.text.view.clearTextSelection
35
42
  import com.swmansion.enriched.markdown.utils.text.view.createSelectionActionModeCallback
43
+ import com.swmansion.enriched.markdown.utils.text.view.currentTextSelection
36
44
  import com.swmansion.enriched.markdown.utils.text.view.emitLinkLongPressEvent
37
45
  import com.swmansion.enriched.markdown.utils.text.view.emitLinkPressEvent
38
46
  import com.swmansion.enriched.markdown.utils.text.view.reallowParentInterceptIfLinkReleased
@@ -102,8 +110,17 @@ class EnrichedMarkdownText
102
110
  private var contextMenuHidden = false
103
111
  private var activeActionMode: ActionMode? = null
104
112
 
113
+ private var linkLayoutEnabled = false
114
+ private var lastLinkLayout: List<LinkLayoutFragment>? = null
115
+
116
+ // Nullable and assigned in init: TextView calls onSelectionChanged from its
117
+ // own constructor, before this class's fields exist.
118
+ private var selectionReporter: SelectionChangeReporter? = null
119
+
105
120
  init {
106
121
  setupAsMarkdownTextView()
122
+ selectionReporter =
123
+ SelectionChangeReporter(this, ::selectionSnapshot) { snapshot -> emitSelectionChange(this, snapshot) }
107
124
  customSelectionActionModeCallback =
108
125
  createSelectionActionModeCallback(
109
126
  this,
@@ -310,6 +327,73 @@ class EnrichedMarkdownText
310
327
  }
311
328
 
312
329
  applySelectionColors(selectionColor, selectionHandleColor)
330
+ emitLinkLayoutIfNeeded()
331
+ // New text drops the selection; report it if setText didn't already.
332
+ selectionReporter?.schedule()
333
+ }
334
+
335
+ fun setEmitSelectionChange(enabled: Boolean) {
336
+ selectionReporter?.enabled = enabled
337
+ }
338
+
339
+ /** Clears the selection (ending its ActionMode) and reports `active: false`. */
340
+ fun clearSelection() {
341
+ clearTextSelection()
342
+ activeActionMode?.finish()
343
+ selectionReporter?.flushNow()
344
+ }
345
+
346
+ override fun onSelectionChanged(
347
+ selStart: Int,
348
+ selEnd: Int,
349
+ ) {
350
+ super.onSelectionChanged(selStart, selEnd)
351
+ selectionReporter?.onSelectionChanged(selEnd > selStart)
352
+ }
353
+
354
+ private fun selectionSnapshot(): SelectionSnapshot {
355
+ val selection = currentTextSelection() ?: return SelectionSnapshot.INACTIVE
356
+ return SelectionSnapshot(
357
+ active = true,
358
+ source = "selection",
359
+ text = selection.text,
360
+ markdown = selection.markdown,
361
+ // Same rule as onCopyRequest: a full selection already carries the original markdown.
362
+ whole = selection.fullSelection && currentMarkdown.isNotEmpty(),
363
+ rect = selection.rect,
364
+ startRect = selection.startRect,
365
+ endRect = selection.endRect,
366
+ )
367
+ }
368
+
369
+ fun setEmitLinkLayout(enabled: Boolean) {
370
+ if (linkLayoutEnabled == enabled) return
371
+ linkLayoutEnabled = enabled
372
+ lastLinkLayout = null
373
+ emitLinkLayoutIfNeeded()
374
+ }
375
+
376
+ /**
377
+ * Reports link rects to JS (for `renderLinkDecoration`) after every render and layout;
378
+ * deduplicated so an unchanged layout — the common case while streaming — sends nothing.
379
+ */
380
+ private fun emitLinkLayoutIfNeeded() {
381
+ if (!linkLayoutEnabled || layout == null || width <= 0) return
382
+ val (fragments, _) = LinkLayoutCollector.collect(this)
383
+ if (fragments == lastLinkLayout) return
384
+ lastLinkLayout = fragments
385
+ emitLinkLayout(this, fragments)
386
+ }
387
+
388
+ override fun onLayout(
389
+ changed: Boolean,
390
+ left: Int,
391
+ top: Int,
392
+ right: Int,
393
+ bottom: Int,
394
+ ) {
395
+ super.onLayout(changed, left, top, right, bottom)
396
+ emitLinkLayoutIfNeeded()
313
397
  }
314
398
 
315
399
  // Trailing bottom margin included in the shadow-node measurement — must be
@@ -387,6 +471,7 @@ class EnrichedMarkdownText
387
471
  }
388
472
 
389
473
  override fun onDetachedFromWindow() {
474
+ selectionReporter?.cancel()
390
475
  stopSpoilerAnimations()
391
476
  super.onDetachedFromWindow()
392
477
  }
@@ -177,6 +177,28 @@ class EnrichedMarkdownTextManager :
177
177
  view?.setContextMenuHidden(contextMenuHidden)
178
178
  }
179
179
 
180
+ @ReactProp(name = "emitLinkLayout", defaultBoolean = false)
181
+ override fun setEmitLinkLayout(
182
+ view: EnrichedMarkdownText?,
183
+ emitLinkLayout: Boolean,
184
+ ) {
185
+ view?.setEmitLinkLayout(emitLinkLayout)
186
+ }
187
+
188
+ @ReactProp(name = "emitSelectionChange", defaultBoolean = false)
189
+ override fun setEmitSelectionChange(
190
+ view: EnrichedMarkdownText?,
191
+ emitSelectionChange: Boolean,
192
+ ) {
193
+ view?.setEmitSelectionChange(emitSelectionChange)
194
+ }
195
+
196
+ // Commands
197
+
198
+ override fun clearSelection(view: EnrichedMarkdownText?) {
199
+ view?.clearSelection()
200
+ }
201
+
180
202
  @ReactProp(name = "enableLinkPreview", defaultBoolean = true)
181
203
  override fun setEnableLinkPreview(
182
204
  view: EnrichedMarkdownText?,
@@ -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,42 @@
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
+ }
30
+
31
+ private fun SelectionRect.toWritableMap(): WritableMap =
32
+ Arguments.createMap().apply {
33
+ putDouble("x", x.toDouble())
34
+ putDouble("y", y.toDouble())
35
+ putDouble("width", width.toDouble())
36
+ putDouble("height", height.toDouble())
37
+ }
38
+
39
+ companion object {
40
+ const val EVENT_NAME: String = "onSelectionChange"
41
+ }
42
+ }
@@ -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
  *