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
@@ -5,9 +5,6 @@ import android.graphics.Paint
5
5
  import android.graphics.Path
6
6
  import android.graphics.RectF
7
7
  import android.text.Spanned
8
- import android.text.StaticLayout
9
- import android.text.TextPaint
10
- import android.text.style.LeadingMarginSpan
11
8
  import android.text.style.LineBackgroundSpan
12
9
  import com.swmansion.enriched.markdown.styles.StyleConfig
13
10
  import kotlin.math.max
@@ -73,14 +70,6 @@ class CodeBackgroundSpan(
73
70
  drawShapes(canvas, isFirst, isLast)
74
71
  }
75
72
 
76
- /**
77
- * Returns the x position of [index] relative to the line's left edge, including any
78
- * leading margin. The measuring StaticLayout is built from a subSequence that keeps
79
- * all spans, so LeadingMarginSpans (lists, blockquotes) are already applied to
80
- * getPrimaryHorizontal; adding the margin again on top would shift the background
81
- * right by the indent. The margin is only added explicitly in the early-return case,
82
- * where no layout is built.
83
- */
84
73
  private fun getHorizontalOffset(
85
74
  text: CharSequence,
86
75
  lineStart: Int,
@@ -88,105 +77,30 @@ class CodeBackgroundSpan(
88
77
  index: Int,
89
78
  paint: Paint,
90
79
  leadingMargin: Int,
91
- ): Float {
92
- if (index <= lineStart) return leadingMargin.toFloat()
93
- val lineText = text.subSequence(lineStart, lineEnd)
94
- val textPaint = paint as? TextPaint ?: TextPaint(paint)
95
- val layout = StaticLayout.Builder.obtain(lineText, 0, lineText.length, textPaint, 10000).build()
96
- return layout.getPrimaryHorizontal(index - lineStart)
97
- }
80
+ ): Float = InlineChipPainter.horizontalOffset(text, lineStart, lineEnd, index, paint, leadingMargin)
98
81
 
99
82
  private fun drawShapes(
100
83
  canvas: Canvas,
101
84
  isFirst: Boolean,
102
85
  isLast: Boolean,
103
86
  ) {
104
- val radii = createRadii(isFirst, isLast)
105
-
106
- path.reset()
107
- path.addRoundRect(rect, radii, Path.Direction.CW)
108
- canvas.drawPath(path, sharedBackgroundPaint)
109
-
110
- if (isFirst && isLast) {
111
- canvas.drawPath(path, sharedBorderPaint)
112
- } else {
113
- drawOpenBorders(canvas, isFirst, isLast)
114
- }
115
- }
116
-
117
- private fun drawOpenBorders(
118
- canvas: Canvas,
119
- isFirst: Boolean,
120
- isLast: Boolean,
121
- ) {
122
- val r = CORNER_RADIUS
123
- path.reset()
124
-
125
- if (isFirst) {
126
- path.moveTo(rect.right, rect.top)
127
- path.lineTo(rect.left + r, rect.top)
128
- path.quadTo(rect.left, rect.top, rect.left, rect.top + r)
129
- path.lineTo(rect.left, rect.bottom - r)
130
- path.quadTo(rect.left, rect.bottom, rect.left + r, rect.bottom)
131
- path.lineTo(rect.right, rect.bottom)
132
- } else if (isLast) {
133
- path.moveTo(rect.left, rect.top)
134
- path.lineTo(rect.right - r, rect.top)
135
- path.quadTo(rect.right, rect.top, rect.right, rect.top + r)
136
- path.lineTo(rect.right, rect.bottom - r)
137
- path.quadTo(rect.right, rect.bottom, rect.right - r, rect.bottom)
138
- path.lineTo(rect.left, rect.bottom)
139
- } else {
140
- path.moveTo(rect.left, rect.top)
141
- path.lineTo(rect.right, rect.top)
142
- path.moveTo(rect.left, rect.bottom)
143
- path.lineTo(rect.right, rect.bottom)
144
- }
145
- canvas.drawPath(path, sharedBorderPaint)
146
- }
147
-
148
- private fun createRadii(
149
- isFirst: Boolean,
150
- isLast: Boolean,
151
- ) = when {
152
- isFirst && isLast -> {
153
- floatArrayOf(
154
- CORNER_RADIUS,
155
- CORNER_RADIUS,
156
- CORNER_RADIUS,
157
- CORNER_RADIUS,
158
- CORNER_RADIUS,
159
- CORNER_RADIUS,
160
- CORNER_RADIUS,
161
- CORNER_RADIUS,
162
- )
163
- }
164
-
165
- isFirst -> {
166
- floatArrayOf(CORNER_RADIUS, CORNER_RADIUS, 0f, 0f, 0f, 0f, CORNER_RADIUS, CORNER_RADIUS)
167
- }
168
-
169
- isLast -> {
170
- floatArrayOf(0f, 0f, CORNER_RADIUS, CORNER_RADIUS, CORNER_RADIUS, CORNER_RADIUS, 0f, 0f)
171
- }
172
-
173
- else -> {
174
- floatArrayOf(0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f)
175
- }
87
+ InlineChipPainter.draw(
88
+ canvas,
89
+ rect,
90
+ path,
91
+ CORNER_RADIUS,
92
+ sharedBackgroundPaint,
93
+ sharedBorderPaint,
94
+ isFirst,
95
+ isLast,
96
+ strokeInset = false,
97
+ )
176
98
  }
177
99
 
178
100
  private fun leadingMarginAt(
179
101
  text: Spanned,
180
102
  lineStart: Int,
181
- ): Int {
182
- if (lineStart >= text.length) return 0
183
- val spans = text.getSpans(lineStart, lineStart + 1, LeadingMarginSpan::class.java)
184
- var margin = 0
185
- for (span in spans) {
186
- margin += span.getLeadingMargin(false)
187
- }
188
- return margin
189
- }
103
+ ): Int = InlineChipPainter.leadingMarginAt(text, lineStart)
190
104
 
191
105
  private fun adjustBottomForMargin(
192
106
  text: Spanned,
@@ -0,0 +1,140 @@
1
+ package com.swmansion.enriched.markdown.spans
2
+
3
+ import android.graphics.Canvas
4
+ import android.graphics.Paint
5
+ import android.graphics.Path
6
+ import android.graphics.RectF
7
+ import android.text.Spanned
8
+ import android.text.StaticLayout
9
+ import android.text.TextPaint
10
+ import android.text.style.LeadingMarginSpan
11
+
12
+ /**
13
+ * Shared geometry and drawing for inline "chips" painted from a [android.text.style.LineBackgroundSpan]
14
+ * (inline code, link chips). For a chip that wraps, only the outer ends are rounded and the border
15
+ * stays open where a fragment is cut.
16
+ */
17
+ internal object InlineChipPainter {
18
+ /**
19
+ * Returns the x position of [index] relative to the line's left edge, including any
20
+ * leading margin. The measuring StaticLayout is built from a subSequence that keeps
21
+ * all spans, so LeadingMarginSpans (lists, blockquotes) are already applied to
22
+ * getPrimaryHorizontal; adding the margin again on top would shift the background
23
+ * right by the indent. The margin is only added explicitly in the early-return case,
24
+ * where no layout is built.
25
+ */
26
+ fun horizontalOffset(
27
+ text: CharSequence,
28
+ lineStart: Int,
29
+ lineEnd: Int,
30
+ index: Int,
31
+ paint: Paint,
32
+ leadingMargin: Int,
33
+ ): Float {
34
+ if (index <= lineStart) return leadingMargin.toFloat()
35
+ val lineText = text.subSequence(lineStart, lineEnd)
36
+ val textPaint = paint as? TextPaint ?: TextPaint(paint)
37
+ val layout = StaticLayout.Builder.obtain(lineText, 0, lineText.length, textPaint, 10000).build()
38
+ return layout.getPrimaryHorizontal(index - lineStart)
39
+ }
40
+
41
+ fun leadingMarginAt(
42
+ text: Spanned,
43
+ lineStart: Int,
44
+ ): Int {
45
+ if (lineStart >= text.length) return 0
46
+ val spans = text.getSpans(lineStart, lineStart + 1, LeadingMarginSpan::class.java)
47
+ var margin = 0
48
+ for (span in spans) {
49
+ margin += span.getLeadingMargin(false)
50
+ }
51
+ return margin
52
+ }
53
+
54
+ /**
55
+ * Fills [rect] and strokes its border. [strokeInset] keeps a closed border inside [rect]
56
+ * (link chips); otherwise it is centered on the edge (inline code, kept for pixel parity).
57
+ * [path] is caller-owned scratch space to avoid per-draw allocations.
58
+ */
59
+ fun draw(
60
+ canvas: Canvas,
61
+ rect: RectF,
62
+ path: Path,
63
+ cornerRadius: Float,
64
+ backgroundPaint: Paint?,
65
+ borderPaint: Paint?,
66
+ isFirst: Boolean,
67
+ isLast: Boolean,
68
+ strokeInset: Boolean,
69
+ ) {
70
+ val r = minOf(maxOf(cornerRadius, 0f), minOf(rect.width(), rect.height()) / 2f)
71
+
72
+ if (backgroundPaint != null) {
73
+ path.reset()
74
+ path.addRoundRect(rect, radii(r, isFirst, isLast), Path.Direction.CW)
75
+ canvas.drawPath(path, backgroundPaint)
76
+ }
77
+
78
+ if (borderPaint == null || borderPaint.strokeWidth <= 0f) return
79
+
80
+ path.reset()
81
+ if (isFirst && isLast) {
82
+ if (strokeInset) {
83
+ val inset = borderPaint.strokeWidth / 2f
84
+ val insetRect = RectF(rect.left + inset, rect.top + inset, rect.right - inset, rect.bottom - inset)
85
+ path.addRoundRect(insetRect, radii(maxOf(r - inset, 0f), true, true), Path.Direction.CW)
86
+ } else {
87
+ path.addRoundRect(rect, radii(r, true, true), Path.Direction.CW)
88
+ }
89
+ } else {
90
+ val inset = if (strokeInset) borderPaint.strokeWidth / 2f else 0f
91
+ addOpenBorder(path, rect, r, inset, isFirst, isLast)
92
+ }
93
+ canvas.drawPath(path, borderPaint)
94
+ }
95
+
96
+ private fun addOpenBorder(
97
+ path: Path,
98
+ rect: RectF,
99
+ r: Float,
100
+ inset: Float,
101
+ isFirst: Boolean,
102
+ isLast: Boolean,
103
+ ) {
104
+ val left = rect.left + inset
105
+ val top = rect.top + inset
106
+ val right = rect.right - inset
107
+ val bottom = rect.bottom - inset
108
+
109
+ if (isFirst) {
110
+ path.moveTo(rect.right, top)
111
+ path.lineTo(left + r, top)
112
+ path.quadTo(left, top, left, top + r)
113
+ path.lineTo(left, bottom - r)
114
+ path.quadTo(left, bottom, left + r, bottom)
115
+ path.lineTo(rect.right, bottom)
116
+ } else if (isLast) {
117
+ path.moveTo(rect.left, top)
118
+ path.lineTo(right - r, top)
119
+ path.quadTo(right, top, right, top + r)
120
+ path.lineTo(right, bottom - r)
121
+ path.quadTo(right, bottom, right - r, bottom)
122
+ path.lineTo(rect.left, bottom)
123
+ } else {
124
+ path.moveTo(rect.left, top)
125
+ path.lineTo(rect.right, top)
126
+ path.moveTo(rect.left, bottom)
127
+ path.lineTo(rect.right, bottom)
128
+ }
129
+ }
130
+
131
+ private fun radii(
132
+ r: Float,
133
+ isFirst: Boolean,
134
+ isLast: Boolean,
135
+ ): FloatArray {
136
+ val left = if (isFirst) r else 0f
137
+ val right = if (isLast) r else 0f
138
+ return floatArrayOf(left, left, right, right, right, right, left, left)
139
+ }
140
+ }
@@ -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
  }
@@ -8,11 +8,15 @@ 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
10
  import com.swmansion.enriched.markdown.events.CopyRequestEvent
11
+ import com.swmansion.enriched.markdown.events.LinkLayoutEvent
11
12
  import com.swmansion.enriched.markdown.events.LinkLongPressEvent
12
13
  import com.swmansion.enriched.markdown.events.LinkPressEvent
14
+ import com.swmansion.enriched.markdown.events.SelectionChangeEvent
13
15
  import com.swmansion.enriched.markdown.events.TaskListItemPressEvent
14
16
  import com.swmansion.enriched.markdown.parser.Md4cFlags
17
+ import com.swmansion.enriched.markdown.utils.text.view.LinkLayoutFragment
15
18
  import com.swmansion.enriched.markdown.utils.text.view.SelectionMenuConfig
19
+ import com.swmansion.enriched.markdown.utils.text.view.SelectionSnapshot
16
20
 
17
21
  fun markdownEventTypeConstants(): MutableMap<String, Any> {
18
22
  val map = mutableMapOf<String, Any>()
@@ -27,9 +31,33 @@ fun markdownEventTypeConstants(): MutableMap<String, Any> {
27
31
  mapOf("registrationName" to CopyRequestEvent.EVENT_NAME)
28
32
  map[ContextMenuItemPressEvent.EVENT_NAME] =
29
33
  mapOf("registrationName" to ContextMenuItemPressEvent.EVENT_NAME)
34
+ map[LinkLayoutEvent.EVENT_NAME] =
35
+ mapOf("registrationName" to LinkLayoutEvent.EVENT_NAME)
36
+ map[SelectionChangeEvent.EVENT_NAME] =
37
+ mapOf("registrationName" to SelectionChangeEvent.EVENT_NAME)
30
38
  return map
31
39
  }
32
40
 
41
+ fun emitLinkLayout(
42
+ view: View,
43
+ fragments: List<LinkLayoutFragment>,
44
+ ) {
45
+ val context = view.context as com.facebook.react.bridge.ReactContext
46
+ val surfaceId = UIManagerHelper.getSurfaceId(context)
47
+ val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id)
48
+ eventDispatcher?.dispatchEvent(LinkLayoutEvent(surfaceId, view.id, fragments))
49
+ }
50
+
51
+ fun emitSelectionChange(
52
+ view: View,
53
+ snapshot: SelectionSnapshot,
54
+ ) {
55
+ val context = view.context as com.facebook.react.bridge.ReactContext
56
+ val surfaceId = UIManagerHelper.getSurfaceId(context)
57
+ val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, view.id)
58
+ eventDispatcher?.dispatchEvent(SelectionChangeEvent(surfaceId, view.id, snapshot))
59
+ }
60
+
33
61
  fun emitLinkPress(
34
62
  view: View,
35
63
  url: String,
@@ -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 =