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
@@ -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
+ }
@@ -0,0 +1,177 @@
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.SpannableStringBuilder
8
+ import android.text.Spanned
9
+ import android.text.TextPaint
10
+ import android.text.style.LineBackgroundSpan
11
+ import android.text.style.ReplacementSpan
12
+ import com.swmansion.enriched.markdown.styles.LinkChipStyle
13
+ import kotlin.math.max
14
+ import kotlin.math.min
15
+ import kotlin.math.roundToInt
16
+
17
+ /**
18
+ * Reserves a link's `paddingHorizontal` at one outer edge. It covers one [LinkSpacers.CHAR]
19
+ * inserted by `LinkRenderer`: U+00A0 is line-break class GL, so a break is allowed before it
20
+ * after a space but never after it, and never before it after a non-space — the spacer always
21
+ * travels with the link's first (or last) glyph, never orphaned at a wrap.
22
+ *
23
+ * Layout-only: every text exit point strips spacers by this span ([LinkSpacers.strip]).
24
+ */
25
+ class LinkSpacerSpan(
26
+ private val width: Float,
27
+ ) : ReplacementSpan() {
28
+ override fun getSize(
29
+ paint: Paint,
30
+ text: CharSequence?,
31
+ start: Int,
32
+ end: Int,
33
+ fm: Paint.FontMetricsInt?,
34
+ ): Int {
35
+ // Keep the line metrics of the surrounding text.
36
+ if (fm != null) paint.getFontMetricsInt(fm)
37
+ return width.roundToInt()
38
+ }
39
+
40
+ override fun draw(
41
+ canvas: Canvas,
42
+ text: CharSequence?,
43
+ start: Int,
44
+ end: Int,
45
+ x: Float,
46
+ top: Int,
47
+ y: Int,
48
+ bottom: Int,
49
+ paint: Paint,
50
+ ) = Unit
51
+ }
52
+
53
+ object LinkSpacers {
54
+ const val CHAR = '\u00A0'
55
+
56
+ /** [text] without link spacers (returns [text] itself when it has none). */
57
+ fun strip(text: CharSequence): CharSequence {
58
+ if (text !is Spanned) return text
59
+ val spacers = text.getSpans(0, text.length, LinkSpacerSpan::class.java)
60
+ if (spacers.isEmpty()) return text
61
+ val builder = SpannableStringBuilder(text)
62
+ spacers
63
+ .map { builder.getSpanStart(it) to builder.getSpanEnd(it) }
64
+ .filter { (start, end) -> start in 0 until end }
65
+ .sortedByDescending { it.first }
66
+ .forEach { (start, end) -> builder.delete(start, end) }
67
+ return builder
68
+ }
69
+
70
+ /** True when [start, end) lies inside a spacer (a segment exporters must skip). */
71
+ fun isSpacer(
72
+ text: Spanned,
73
+ start: Int,
74
+ end: Int,
75
+ ): Boolean = end > start && text.getSpans(start, end, LinkSpacerSpan::class.java).isNotEmpty()
76
+ }
77
+
78
+ /**
79
+ * Paints a link chip (background + border) behind the link glyphs. One instance per link,
80
+ * spanning the link including its spacers, so the chip already covers `paddingHorizontal`.
81
+ * Vertically the chip spans the link font's ascent..descent ± `paddingVertical`; the vertical
82
+ * padding only bleeds into the surrounding line spacing and never changes the line box.
83
+ */
84
+ class LinkChipBackgroundSpan(
85
+ val chip: LinkChipStyle,
86
+ ) : LineBackgroundSpan {
87
+ private val rect = RectF()
88
+ private val path = Path()
89
+ private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
90
+ private val borderPaint =
91
+ Paint(Paint.ANTI_ALIAS_FLAG).apply {
92
+ style = Paint.Style.STROKE
93
+ strokeJoin = Paint.Join.ROUND
94
+ strokeCap = Paint.Cap.ROUND
95
+ }
96
+
97
+ override fun drawBackground(
98
+ canvas: Canvas,
99
+ p: Paint,
100
+ left: Int,
101
+ right: Int,
102
+ top: Int,
103
+ baseline: Int,
104
+ bottom: Int,
105
+ text: CharSequence,
106
+ start: Int,
107
+ end: Int,
108
+ lineNum: Int,
109
+ ) {
110
+ if (!chip.isVisible || text !is Spanned) return
111
+
112
+ val spanStart = text.getSpanStart(this)
113
+ val spanEnd = text.getSpanEnd(this)
114
+ if (spanStart !in 0 until spanEnd || spanEnd <= start || spanStart >= end) return
115
+
116
+ val isFirst = spanStart >= start
117
+ val isLast = spanEnd <= end
118
+
119
+ val leadingMargin = InlineChipPainter.leadingMarginAt(text, start)
120
+ val fragmentStart = max(spanStart, start)
121
+ val fragmentEnd = trimTrailingWhitespace(text, fragmentStart, min(spanEnd, end))
122
+ val startX = InlineChipPainter.horizontalOffset(text, start, end, fragmentStart, p, leadingMargin) + left
123
+ val endX = InlineChipPainter.horizontalOffset(text, start, end, fragmentEnd, p, leadingMargin) + left
124
+
125
+ val (ascent, descent) = linkFontMetrics(text, spanStart, spanEnd, p)
126
+ rect.set(
127
+ min(startX, endX),
128
+ baseline + ascent - chip.paddingVertical,
129
+ max(startX, endX),
130
+ baseline + descent + chip.paddingVertical,
131
+ )
132
+
133
+ backgroundPaint.color = chip.backgroundColor
134
+ borderPaint.color = chip.borderColor
135
+ borderPaint.strokeWidth = chip.borderWidth
136
+ InlineChipPainter.draw(
137
+ canvas,
138
+ rect,
139
+ path,
140
+ chip.borderRadius,
141
+ backgroundPaint.takeIf { android.graphics.Color.alpha(chip.backgroundColor) > 0 },
142
+ borderPaint.takeIf { android.graphics.Color.alpha(chip.borderColor) > 0 && chip.borderWidth > 0f },
143
+ isFirst,
144
+ isLast,
145
+ strokeInset = true,
146
+ )
147
+ }
148
+
149
+ companion object {
150
+ /** A wrapped fragment ends before the whitespace the line break hangs. */
151
+ fun trimTrailingWhitespace(
152
+ text: CharSequence,
153
+ start: Int,
154
+ end: Int,
155
+ ): Int {
156
+ var index = end
157
+ while (index > start && text[index - 1] != LinkSpacers.CHAR && text[index - 1].isWhitespace()) index--
158
+ return index
159
+ }
160
+
161
+ /**
162
+ * Ascent/descent of the link's own font: [LinkSpan] sets its size and typeface at draw
163
+ * time only, so the base paint [p] alone would report the paragraph's metrics.
164
+ */
165
+ fun linkFontMetrics(
166
+ text: Spanned,
167
+ spanStart: Int,
168
+ spanEnd: Int,
169
+ p: Paint,
170
+ ): Pair<Float, Float> {
171
+ val paint = TextPaint(p)
172
+ text.getSpans(spanStart, spanEnd, LinkSpan::class.java).firstOrNull()?.updateDrawState(paint)
173
+ val metrics = paint.fontMetrics
174
+ return metrics.ascent to metrics.descent
175
+ }
176
+ }
177
+ }
@@ -1,7 +1,6 @@
1
1
  package com.swmansion.enriched.markdown.spans
2
2
 
3
3
  import android.content.Context
4
- import android.graphics.Color
5
4
  import android.text.TextPaint
6
5
  import android.text.style.ClickableSpan
7
6
  import android.view.View
@@ -57,10 +56,6 @@ class LinkSpan(
57
56
 
58
57
  textPaint.color = variant?.color ?: styleCache.linkColor
59
58
  textPaint.isUnderlineText = variant?.underline ?: styleCache.linkUnderline
60
-
61
- val backgroundColor = variant?.backgroundColor ?: styleCache.linkBackgroundColor
62
- if (Color.alpha(backgroundColor) > 0) {
63
- textPaint.bgColor = backgroundColor
64
- }
59
+ // The background is painted as a chip by LinkChipBackgroundSpan.
65
60
  }
66
61
  }
@@ -7,6 +7,7 @@ data class LinkStyle(
7
7
  val color: Int,
8
8
  val underline: Boolean,
9
9
  val backgroundColor: Int,
10
+ val chip: LinkChipStyle,
10
11
  ) {
11
12
  companion object {
12
13
  fun fromReadableMap(
@@ -17,7 +18,41 @@ data class LinkStyle(
17
18
  val color = parser.parseColor(map, "color")
18
19
  val underline = map.getBoolean("underline")
19
20
  val backgroundColor = parser.parseColor(map, "backgroundColor")
20
- return LinkStyle(fontFamily, color, underline, backgroundColor)
21
+ return LinkStyle(fontFamily, color, underline, backgroundColor, LinkChipStyle.fromReadableMap(map, parser, backgroundColor))
21
22
  }
22
23
  }
23
24
  }
25
+
26
+ /**
27
+ * Chip ("box") properties of a link, in pixels. Never inherited from the base link
28
+ * style by variants — the JS normalizer fills missing values with 0 / transparent.
29
+ */
30
+ data class LinkChipStyle(
31
+ val backgroundColor: Int,
32
+ val borderColor: Int,
33
+ val borderRadius: Float,
34
+ val borderWidth: Float,
35
+ val paddingHorizontal: Float,
36
+ val paddingVertical: Float,
37
+ ) {
38
+ val isVisible: Boolean
39
+ get() =
40
+ android.graphics.Color.alpha(backgroundColor) > 0 ||
41
+ (android.graphics.Color.alpha(borderColor) > 0 && borderWidth > 0f)
42
+
43
+ companion object {
44
+ fun fromReadableMap(
45
+ map: ReadableMap,
46
+ parser: StyleParser,
47
+ backgroundColor: Int,
48
+ ): LinkChipStyle =
49
+ LinkChipStyle(
50
+ backgroundColor = backgroundColor,
51
+ borderColor = parser.parseOptionalColor(map, "borderColor") ?: 0,
52
+ borderRadius = parser.toPixelFromDIP(parser.parseOptionalDouble(map, "borderRadius").toFloat()),
53
+ borderWidth = parser.toPixelFromDIP(parser.parseOptionalDouble(map, "borderWidth").toFloat()),
54
+ paddingHorizontal = parser.toPixelFromDIP(parser.parseOptionalDouble(map, "paddingHorizontal").toFloat()),
55
+ paddingVertical = parser.toPixelFromDIP(parser.parseOptionalDouble(map, "paddingVertical").toFloat()),
56
+ )
57
+ }
58
+ }
@@ -13,17 +13,21 @@ data class LinkVariantEntry(
13
13
  val color: Int,
14
14
  val underline: Boolean,
15
15
  val backgroundColor: Int,
16
+ val chip: LinkChipStyle,
16
17
  ) {
17
18
  companion object {
18
19
  fun fromReadableMap(
19
20
  map: ReadableMap,
20
21
  parser: StyleParser,
21
- ): LinkVariantEntry =
22
- LinkVariantEntry(
22
+ ): LinkVariantEntry {
23
+ val backgroundColor = parser.parseColor(map, "backgroundColor")
24
+ return LinkVariantEntry(
23
25
  pattern = map.getString("pattern") ?: "",
24
26
  color = parser.parseColor(map, "color"),
25
27
  underline = parser.parseBoolean(map, "underline"),
26
- backgroundColor = parser.parseColor(map, "backgroundColor"),
28
+ backgroundColor = backgroundColor,
29
+ chip = LinkChipStyle.fromReadableMap(map, parser, backgroundColor),
27
30
  )
31
+ }
28
32
  }
29
33
  }
@@ -7,10 +7,13 @@ import com.facebook.react.uimanager.UIManagerHelper
7
7
  import com.swmansion.enriched.markdown.accessibility.AccessibilityLabels
8
8
  import com.swmansion.enriched.markdown.events.ContextMenuItemPressEvent
9
9
  import com.swmansion.enriched.markdown.events.CopyPressEvent
10
+ import com.swmansion.enriched.markdown.events.CopyRequestEvent
11
+ import com.swmansion.enriched.markdown.events.LinkLayoutEvent
10
12
  import com.swmansion.enriched.markdown.events.LinkLongPressEvent
11
13
  import com.swmansion.enriched.markdown.events.LinkPressEvent
12
14
  import com.swmansion.enriched.markdown.events.TaskListItemPressEvent
13
15
  import com.swmansion.enriched.markdown.parser.Md4cFlags
16
+ import com.swmansion.enriched.markdown.utils.text.view.LinkLayoutFragment
14
17
  import com.swmansion.enriched.markdown.utils.text.view.SelectionMenuConfig
15
18
 
16
19
  fun markdownEventTypeConstants(): MutableMap<String, Any> {
@@ -22,11 +25,25 @@ fun markdownEventTypeConstants(): MutableMap<String, Any> {
22
25
  mapOf("registrationName" to TaskListItemPressEvent.EVENT_NAME)
23
26
  map[CopyPressEvent.EVENT_NAME] =
24
27
  mapOf("registrationName" to CopyPressEvent.EVENT_NAME)
28
+ map[CopyRequestEvent.EVENT_NAME] =
29
+ mapOf("registrationName" to CopyRequestEvent.EVENT_NAME)
25
30
  map[ContextMenuItemPressEvent.EVENT_NAME] =
26
31
  mapOf("registrationName" to ContextMenuItemPressEvent.EVENT_NAME)
32
+ map[LinkLayoutEvent.EVENT_NAME] =
33
+ mapOf("registrationName" to LinkLayoutEvent.EVENT_NAME)
27
34
  return map
28
35
  }
29
36
 
37
+ fun emitLinkLayout(
38
+ view: View,
39
+ fragments: List<LinkLayoutFragment>,
40
+ ) {
41
+ val context = view.context as com.facebook.react.bridge.ReactContext
42
+ val surfaceId = UIManagerHelper.getSurfaceId(context)
43
+ val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id)
44
+ eventDispatcher?.dispatchEvent(LinkLayoutEvent(surfaceId, view.id, fragments))
45
+ }
46
+
30
47
  fun emitLinkPress(
31
48
  view: View,
32
49
  url: String,
@@ -74,6 +91,21 @@ fun emitCopyPress(
74
91
  )
75
92
  }
76
93
 
94
+ fun emitCopyRequest(
95
+ view: View,
96
+ source: String,
97
+ markdown: String,
98
+ text: String,
99
+ whole: Boolean,
100
+ ) {
101
+ val context = view.context as com.facebook.react.bridge.ReactContext
102
+ val surfaceId = UIManagerHelper.getSurfaceId(context)
103
+ val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id)
104
+ eventDispatcher?.dispatchEvent(
105
+ CopyRequestEvent(surfaceId, view.id, source, markdown, text, whole),
106
+ )
107
+ }
108
+
77
109
  fun emitContextMenuItemPress(
78
110
  view: View,
79
111
  itemText: String,
@@ -132,6 +164,7 @@ fun parseSelectionMenuConfig(value: ReadableMap?): SelectionMenuConfig {
132
164
  copyFormatMarkdown = value.getBooleanOrDefault("copyFormatMarkdown", false),
133
165
  systemItemsNone = value.getBooleanOrDefault("systemItemsNone", false),
134
166
  codeBlockCopyButton = value.getBooleanOrDefault("codeBlockCopyButton", true),
167
+ interceptCopy = value.getBooleanOrDefault("interceptCopy", false),
135
168
  )
136
169
  }
137
170
 
@@ -12,6 +12,7 @@ import com.swmansion.enriched.markdown.spans.EmphasisSpan
12
12
  import com.swmansion.enriched.markdown.spans.HeadingSpan
13
13
  import com.swmansion.enriched.markdown.spans.HighlightSpan
14
14
  import com.swmansion.enriched.markdown.spans.ImageSpan
15
+ import com.swmansion.enriched.markdown.spans.LinkSpacers
15
16
  import com.swmansion.enriched.markdown.spans.LinkSpan
16
17
  import com.swmansion.enriched.markdown.spans.OrderedListSpan
17
18
  import com.swmansion.enriched.markdown.spans.StrikethroughSpan
@@ -676,7 +677,7 @@ object HTMLGenerator {
676
677
  val segmentEnd = minOf(text.nextSpanTransition(i, end, Any::class.java), end)
677
678
  val segmentText = text.subSequence(i, segmentEnd).toString()
678
679
 
679
- if (segmentText.isNotEmpty() && segmentText != "\n") {
680
+ if (segmentText.isNotEmpty() && segmentText != "\n" && !LinkSpacers.isSpacer(text, i, segmentEnd)) {
680
681
  appendStyledSegment(html, text, i, segmentEnd, segmentText, styles, isCodeBlock)
681
682
  }
682
683
  i = segmentEnd
@@ -12,6 +12,7 @@ import com.swmansion.enriched.markdown.spans.EmphasisSpan
12
12
  import com.swmansion.enriched.markdown.spans.HeadingSpan
13
13
  import com.swmansion.enriched.markdown.spans.HighlightSpan
14
14
  import com.swmansion.enriched.markdown.spans.ImageSpan
15
+ import com.swmansion.enriched.markdown.spans.LinkSpacers
15
16
  import com.swmansion.enriched.markdown.spans.LinkSpan
16
17
  import com.swmansion.enriched.markdown.spans.OrderedListSpan
17
18
  import com.swmansion.enriched.markdown.spans.StrikethroughSpan
@@ -55,6 +56,11 @@ object MarkdownExtractor {
55
56
  var i = start
56
57
  while (i < end) {
57
58
  val nextTransition = spannable.nextSpanTransition(i, end, Any::class.java)
59
+ // Link spacers only reserve layout space for chip padding.
60
+ if (LinkSpacers.isSpacer(spannable, i, nextTransition)) {
61
+ i = nextTransition
62
+ continue
63
+ }
58
64
  val segmentText = spannable.subSequence(i, nextTransition).toString()
59
65
 
60
66
  val handled =
@@ -0,0 +1,100 @@
1
+ package com.swmansion.enriched.markdown.utils.text.view
2
+
3
+ import android.text.Layout
4
+ import android.text.Spanned
5
+ import android.widget.TextView
6
+ import com.facebook.react.uimanager.PixelUtil
7
+ import com.swmansion.enriched.markdown.spans.LinkChipBackgroundSpan
8
+ import com.swmansion.enriched.markdown.spans.LinkSpacers
9
+ import com.swmansion.enriched.markdown.spans.LinkSpan
10
+ import kotlin.math.max
11
+ import kotlin.math.min
12
+
13
+ /** One laid-out line of a link, in dp relative to the reporting view. Covers the drawn chip. */
14
+ data class LinkLayoutFragment(
15
+ val linkIndex: Int,
16
+ val url: String,
17
+ val text: String,
18
+ val x: Float,
19
+ val y: Float,
20
+ val width: Float,
21
+ val height: Float,
22
+ )
23
+
24
+ object LinkLayoutCollector {
25
+ /**
26
+ * Collects the chip rect of every link fragment laid out in [textView], for `onLinkLayout`.
27
+ * Rects are offset by the text view's own padding plus [offsetXPx]/[offsetYPx] (a segment's
28
+ * position inside its container), then converted to dp. Links are numbered in document order
29
+ * from [indexBase]; returns the fragments and the number of links found.
30
+ */
31
+ fun collect(
32
+ textView: TextView,
33
+ offsetXPx: Int = 0,
34
+ offsetYPx: Int = 0,
35
+ indexBase: Int = 0,
36
+ ): Pair<List<LinkLayoutFragment>, Int> {
37
+ val layout = textView.layout ?: return emptyList<LinkLayoutFragment>() to 0
38
+ val text = textView.text as? Spanned ?: return emptyList<LinkLayoutFragment>() to 0
39
+
40
+ // LinkRenderer sets one LinkChipBackgroundSpan per link, spacers included.
41
+ val chips =
42
+ text
43
+ .getSpans(0, text.length, LinkChipBackgroundSpan::class.java)
44
+ .sortedBy { text.getSpanStart(it) }
45
+ val originX = offsetXPx + textView.totalPaddingLeft
46
+ val originY = offsetYPx + textView.totalPaddingTop
47
+
48
+ val fragments = mutableListOf<LinkLayoutFragment>()
49
+ chips.forEachIndexed { i, chipSpan ->
50
+ val start = text.getSpanStart(chipSpan)
51
+ val end = text.getSpanEnd(chipSpan)
52
+ if (start !in 0 until end) return@forEachIndexed
53
+
54
+ val url = text.getSpans(start, end, LinkSpan::class.java).firstOrNull()?.url ?: ""
55
+ val linkText = LinkSpacers.strip(text.subSequence(start, end)).toString()
56
+ val (ascent, descent) = LinkChipBackgroundSpan.linkFontMetrics(text, start, end, textView.paint)
57
+ val paddingVertical = chipSpan.chip.paddingVertical
58
+
59
+ for (line in layout.getLineForOffset(start)..layout.getLineForOffset(end - 1)) {
60
+ val fragmentStart = max(start, layout.getLineStart(line))
61
+ val fragmentEnd =
62
+ LinkChipBackgroundSpan.trimTrailingWhitespace(text, fragmentStart, min(end, layout.getLineEnd(line)))
63
+ if (fragmentEnd <= fragmentStart) continue
64
+
65
+ val left = layout.getPrimaryHorizontal(fragmentStart)
66
+ val right = lineEndHorizontal(layout, line, fragmentEnd)
67
+ val baseline = layout.getLineBaseline(line).toFloat()
68
+ val top = baseline + ascent - paddingVertical
69
+ val bottom = baseline + descent + paddingVertical
70
+
71
+ fragments +=
72
+ LinkLayoutFragment(
73
+ linkIndex = indexBase + i,
74
+ url = url,
75
+ text = linkText,
76
+ x = PixelUtil.toDIPFromPixel(min(left, right) + originX),
77
+ y = PixelUtil.toDIPFromPixel(top + originY),
78
+ width = PixelUtil.toDIPFromPixel(kotlin.math.abs(right - left)),
79
+ height = PixelUtil.toDIPFromPixel(bottom - top),
80
+ )
81
+ }
82
+ }
83
+ return fragments to chips.size
84
+ }
85
+
86
+ /**
87
+ * x of [offset] on [line]. At a line's end offset `getPrimaryHorizontal` reports the start
88
+ * of the next line (the same offset), so a fragment cut mid-word uses the line's right edge.
89
+ */
90
+ private fun lineEndHorizontal(
91
+ layout: Layout,
92
+ line: Int,
93
+ offset: Int,
94
+ ): Float =
95
+ if (offset >= layout.getLineEnd(line) && line < layout.lineCount - 1) {
96
+ layout.getLineRight(line)
97
+ } else {
98
+ layout.getPrimaryHorizontal(offset)
99
+ }
100
+ }
@@ -12,6 +12,7 @@ import android.widget.TextView
12
12
  import com.swmansion.enriched.markdown.EnrichedMarkdown
13
13
  import com.swmansion.enriched.markdown.EnrichedMarkdownText
14
14
  import com.swmansion.enriched.markdown.spans.ImageSpan
15
+ import com.swmansion.enriched.markdown.spans.LinkSpacers
15
16
  import com.swmansion.enriched.markdown.styles.StyleConfig
16
17
  import com.swmansion.enriched.markdown.utils.common.layout.isLayoutRTL
17
18
  import com.swmansion.enriched.markdown.utils.text.conversion.HTMLGenerator
@@ -38,6 +39,8 @@ data class SelectionMenuConfig(
38
39
  // Drop every system-provided item (Select all, Share, text assist, PROCESS_TEXT…).
39
40
  val systemItemsNone: Boolean = false,
40
41
  val codeBlockCopyButton: Boolean = true,
42
+ // Copy hands the selection to onCopyRequest instead of writing the clipboard.
43
+ val interceptCopy: Boolean = false,
41
44
  )
42
45
 
43
46
  /**
@@ -69,6 +72,9 @@ fun createSelectionActionModeCallback(
69
72
  onActionModeChanged: (mode: ActionMode?) -> Unit = {},
70
73
  onCustomItemPress: (itemText: String, selectedText: String, selectionStart: Int, selectionEnd: Int) -> Unit =
71
74
  { _, _, _, _ -> },
75
+ // Receives the intercepted Copy when `interceptCopy` is set. `fullSelection`
76
+ // is whether the selection spans the whole text view.
77
+ onCopyRequest: (markdown: String, text: String, fullSelection: Boolean) -> Unit = { _, _, _ -> },
72
78
  ): ActionMode.Callback =
73
79
  object : ActionMode.Callback {
74
80
  override fun onCreateActionMode(
@@ -157,7 +163,10 @@ fun createSelectionActionModeCallback(
157
163
 
158
164
  when (itemId) {
159
165
  android.R.id.copy -> {
160
- if (!getSelectionMenuConfig().copyFormatMarkdown || !textView.copyMarkdownToClipboard()) {
166
+ val config = getSelectionMenuConfig()
167
+ if (config.interceptCopy) {
168
+ textView.requestCopy(onCopyRequest)
169
+ } else if (!config.copyFormatMarkdown || !textView.copyMarkdownToClipboard()) {
161
170
  textView.copyWithHTML()
162
171
  }
163
172
  mode?.finish()
@@ -203,7 +212,8 @@ private fun TextView.copyWithHTML() {
203
212
  if (start < 0 || end < 0 || start >= end) return
204
213
 
205
214
  val spannable = text as? Spannable ?: return
206
- val selectedText = spannable.subSequence(start, end)
215
+ // Link spacers only reserve layout space; keep them out of every copied flavor.
216
+ val selectedText = LinkSpacers.strip(spannable.subSequence(start, end))
207
217
  val plainText = selectedText.toString()
208
218
 
209
219
  val styleConfig =
@@ -229,6 +239,18 @@ private fun TextView.copyWithHTML() {
229
239
  }
230
240
  }
231
241
 
242
+ /** Hands the selection to [onCopyRequest] without touching the clipboard. */
243
+ private fun TextView.requestCopy(onCopyRequest: (markdown: String, text: String, fullSelection: Boolean) -> Unit) {
244
+ val start = selectionStart
245
+ val end = selectionEnd
246
+ if (start < 0 || end < 0 || start >= end) return
247
+
248
+ val selectedText = LinkSpacers.strip(text.subSequence(start, end)).toString()
249
+ val markdown = MarkdownExtractor.getMarkdownForSelection(this) ?: selectedText
250
+ // Same threshold MarkdownExtractor uses to return the original markdown.
251
+ onCopyRequest(markdown, selectedText, start == 0 && end >= text.length - 1)
252
+ }
253
+
232
254
  /** Returns false when the selection has no Markdown to copy. */
233
255
  private fun TextView.copyMarkdownToClipboard(): Boolean {
234
256
  val markdown = MarkdownExtractor.getMarkdownForSelection(this) ?: return false