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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/android/build.gradle +2 -0
  2. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownManagerDelegate.java +18 -0
  3. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownManagerInterface.java +4 -0
  4. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerDelegate.java +18 -0
  5. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextManagerInterface.java +4 -0
  6. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp +148 -0
  7. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.h +120 -0
  8. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.cpp +30 -0
  9. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +210 -0
  10. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +176 -2
  11. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownInternalText.kt +45 -0
  12. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownManager.kt +35 -0
  13. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownText.kt +112 -0
  14. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdownTextManager.kt +34 -0
  15. package/android/src/main/java/com/swmansion/enriched/markdown/MeasurementStore.kt +46 -8
  16. package/android/src/main/java/com/swmansion/enriched/markdown/events/CopyRequestEvent.kt +28 -0
  17. package/android/src/main/java/com/swmansion/enriched/markdown/events/LinkLayoutEvent.kt +36 -0
  18. package/android/src/main/java/com/swmansion/enriched/markdown/events/SelectionChangeEvent.kt +43 -0
  19. package/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt +25 -3
  20. package/android/src/main/java/com/swmansion/enriched/markdown/input/editing/InputConnectionWrapper.kt +7 -0
  21. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/LinkRenderer.kt +37 -7
  22. package/android/src/main/java/com/swmansion/enriched/markdown/renderer/SpanStyleCache.kt +5 -1
  23. package/android/src/main/java/com/swmansion/enriched/markdown/spans/CodeBackgroundSpan.kt +13 -99
  24. package/android/src/main/java/com/swmansion/enriched/markdown/spans/InlineChipPainter.kt +140 -0
  25. package/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkChipSpans.kt +177 -0
  26. package/android/src/main/java/com/swmansion/enriched/markdown/spans/LinkSpan.kt +1 -6
  27. package/android/src/main/java/com/swmansion/enriched/markdown/styles/InlineMathStyle.kt +16 -1
  28. package/android/src/main/java/com/swmansion/enriched/markdown/styles/LinkStyle.kt +36 -1
  29. package/android/src/main/java/com/swmansion/enriched/markdown/styles/LinkVariantEntry.kt +7 -3
  30. package/android/src/main/java/com/swmansion/enriched/markdown/styles/MathStyle.kt +3 -0
  31. package/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt +51 -0
  32. package/android/src/main/java/com/swmansion/enriched/markdown/utils/input/PasteTextUtils.kt +34 -2
  33. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/HTMLGenerator.kt +2 -1
  34. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/conversion/MarkdownExtractor.kt +6 -0
  35. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/HeadlessActionMode.kt +88 -0
  36. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/LinkLayoutCollector.kt +100 -0
  37. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/SelectionActionMode.kt +53 -14
  38. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/SelectionChangeReporter.kt +232 -0
  39. package/android/src/main/java/com/swmansion/enriched/markdown/views/BlockMenuOptions.kt +64 -0
  40. package/android/src/main/java/com/swmansion/enriched/markdown/views/CodeBlockContainerView.kt +29 -3
  41. package/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt +30 -7
  42. package/android/src/math/java/com/swmansion/enriched/markdown/renderer/MathInlineRenderer.kt +1 -0
  43. package/android/src/math/java/com/swmansion/enriched/markdown/spans/MathInlineSpan.kt +13 -6
  44. package/android/src/math/java/com/swmansion/enriched/markdown/spans/MathMeasureHelper.kt +2 -1
  45. package/android/src/math/java/com/swmansion/enriched/markdown/views/MathContainerView.kt +25 -9
  46. package/docs/API_REFERENCE.md +183 -4
  47. package/docs/COPY_OPTIONS.md +40 -0
  48. package/docs/INPUT.md +3 -1
  49. package/docs/MENTIONS.md +8 -1
  50. package/docs/STYLES.md +27 -0
  51. package/docs/TEXT.md +2 -0
  52. package/docs/WEB.md +1 -0
  53. package/ios/EnrichedMarkdown.mm +321 -9
  54. package/ios/EnrichedMarkdownText.mm +203 -6
  55. package/ios/attachments/ENRMMathInlineAttachment+macOS.m +2 -1
  56. package/ios/attachments/ENRMMathInlineAttachment.h +3 -0
  57. package/ios/attachments/ENRMMathInlineAttachment.m +2 -1
  58. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.cpp +148 -0
  59. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.h +120 -0
  60. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.cpp +30 -0
  61. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h +210 -0
  62. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/RCTComponentViewHelpers.h +50 -2
  63. package/ios/internals/MeasurementCache.h +8 -2
  64. package/ios/math/ENRMRaTeXBridge.swift +23 -5
  65. package/ios/renderer/ENRMMathInlineRenderer.m +1 -0
  66. package/ios/renderer/LinkRenderer.m +47 -15
  67. package/ios/styles/StyleConfig.h +20 -0
  68. package/ios/styles/StyleConfig.mm +84 -0
  69. package/ios/utils/CodeBackground.m +3 -65
  70. package/ios/utils/ENRMBlockMenu.h +66 -0
  71. package/ios/utils/ENRMChipDrawing.h +24 -0
  72. package/ios/utils/ENRMChipDrawing.m +83 -0
  73. package/ios/utils/ENRMLinkChip.h +70 -0
  74. package/ios/utils/ENRMLinkChip.m +213 -0
  75. package/ios/utils/ENRMLinkLayout.h +50 -0
  76. package/ios/utils/ENRMLinkLayout.m +45 -0
  77. package/ios/utils/ENRMSelectionChange.h +120 -0
  78. package/ios/utils/ENRMSelectionChange.m +282 -0
  79. package/ios/utils/EditMenuUtils+macOS.m +4 -1
  80. package/ios/utils/EditMenuUtils.h +35 -6
  81. package/ios/utils/EditMenuUtils.m +171 -15
  82. package/ios/utils/HTMLGenerator.m +2 -1
  83. package/ios/utils/MarkdownAccessibilityElementBuilder.m +3 -1
  84. package/ios/utils/MarkdownExtractor.m +2 -1
  85. package/ios/utils/RTFExportUtils.m +2 -0
  86. package/ios/utils/StylePropsUtils.h +47 -1
  87. package/ios/utils/TextViewLayoutManager.mm +3 -0
  88. package/ios/views/ENRMCodeBlockContainerView.h +16 -2
  89. package/ios/views/ENRMCodeBlockContainerView.m +22 -19
  90. package/ios/views/ENRMMathContainerView.h +11 -0
  91. package/ios/views/ENRMMathContainerView.m +17 -20
  92. package/ios/views/TableContainerView.h +14 -0
  93. package/ios/views/TableContainerView.m +34 -18
  94. package/lib/module/EnrichedMarkdownNativeComponent.ts +138 -1
  95. package/lib/module/EnrichedMarkdownTextNativeComponent.ts +138 -1
  96. package/lib/module/index.js.map +1 -1
  97. package/lib/module/jest/index.js +14 -0
  98. package/lib/module/jest/index.js.map +1 -1
  99. package/lib/module/native/EnrichedMarkdownText.js +169 -11
  100. package/lib/module/native/EnrichedMarkdownText.js.map +1 -1
  101. package/lib/module/normalizeMarkdownStyle.js +17 -4
  102. package/lib/module/normalizeMarkdownStyle.js.map +1 -1
  103. package/lib/module/normalizeMarkdownStyle.web.js +17 -4
  104. package/lib/module/normalizeMarkdownStyle.web.js.map +1 -1
  105. package/lib/module/web/EnrichedMarkdownText.js +6 -3
  106. package/lib/module/web/EnrichedMarkdownText.js.map +1 -1
  107. package/lib/module/web/renderers/InlineRenderers.js +96 -3
  108. package/lib/module/web/renderers/InlineRenderers.js.map +1 -1
  109. package/lib/module/web/styles.js +18 -0
  110. package/lib/module/web/styles.js.map +1 -1
  111. package/lib/module/web/utils.js +13 -0
  112. package/lib/module/web/utils.js.map +1 -1
  113. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts +123 -2
  114. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts.map +1 -1
  115. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +123 -2
  116. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts.map +1 -1
  117. package/lib/typescript/src/index.d.ts +2 -2
  118. package/lib/typescript/src/index.d.ts.map +1 -1
  119. package/lib/typescript/src/index.web.d.ts +1 -1
  120. package/lib/typescript/src/index.web.d.ts.map +1 -1
  121. package/lib/typescript/src/jest/index.d.ts +1 -1
  122. package/lib/typescript/src/jest/index.d.ts.map +1 -1
  123. package/lib/typescript/src/native/EnrichedMarkdownText.d.ts +5 -5
  124. package/lib/typescript/src/native/EnrichedMarkdownText.d.ts.map +1 -1
  125. package/lib/typescript/src/normalizeMarkdownStyle.d.ts.map +1 -1
  126. package/lib/typescript/src/normalizeMarkdownStyle.web.d.ts.map +1 -1
  127. package/lib/typescript/src/types/MarkdownStyle.d.ts +50 -5
  128. package/lib/typescript/src/types/MarkdownStyle.d.ts.map +1 -1
  129. package/lib/typescript/src/types/MarkdownStyleInternal.d.ts +12 -0
  130. package/lib/typescript/src/types/MarkdownStyleInternal.d.ts.map +1 -1
  131. package/lib/typescript/src/types/MarkdownTextProps.d.ts +144 -11
  132. package/lib/typescript/src/types/MarkdownTextProps.d.ts.map +1 -1
  133. package/lib/typescript/src/types/MarkdownTextProps.web.d.ts +41 -1
  134. package/lib/typescript/src/types/MarkdownTextProps.web.d.ts.map +1 -1
  135. package/lib/typescript/src/types/events.d.ts +66 -0
  136. package/lib/typescript/src/types/events.d.ts.map +1 -1
  137. package/lib/typescript/src/web/EnrichedMarkdownText.d.ts +1 -1
  138. package/lib/typescript/src/web/EnrichedMarkdownText.d.ts.map +1 -1
  139. package/lib/typescript/src/web/renderers/InlineRenderers.d.ts.map +1 -1
  140. package/lib/typescript/src/web/styles.d.ts.map +1 -1
  141. package/lib/typescript/src/web/types.d.ts +4 -0
  142. package/lib/typescript/src/web/types.d.ts.map +1 -1
  143. package/lib/typescript/src/web/utils.d.ts +4 -0
  144. package/lib/typescript/src/web/utils.d.ts.map +1 -1
  145. package/package.json +1 -1
  146. package/src/EnrichedMarkdownNativeComponent.ts +138 -1
  147. package/src/EnrichedMarkdownTextNativeComponent.ts +138 -1
  148. package/src/index.tsx +6 -0
  149. package/src/index.web.tsx +5 -1
  150. package/src/jest/index.tsx +22 -1
  151. package/src/native/EnrichedMarkdownText.tsx +220 -9
  152. package/src/normalizeMarkdownStyle.ts +15 -0
  153. package/src/normalizeMarkdownStyle.web.ts +13 -0
  154. package/src/types/MarkdownStyle.ts +51 -5
  155. package/src/types/MarkdownStyleInternal.ts +12 -0
  156. package/src/types/MarkdownTextProps.ts +151 -11
  157. package/src/types/MarkdownTextProps.web.ts +43 -1
  158. package/src/types/events.ts +69 -0
  159. package/src/web/EnrichedMarkdownText.tsx +10 -3
  160. package/src/web/renderers/InlineRenderers.tsx +111 -3
  161. package/src/web/styles.ts +25 -0
  162. package/src/web/types.ts +4 -0
  163. package/src/web/utils.ts +8 -0
@@ -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
@@ -32,6 +33,14 @@ data class SelectionMenuConfig(
32
33
  // Plural templates indexed by image count (0..100). Empty when no pluralLabels
33
34
  // are set; counts > 100 use copyImageUrlsLabel (the "other" form).
34
35
  val copyImageUrlPluralTemplates: List<String> = emptyList(),
36
+ val copyEnabled: Boolean = true,
37
+ // Copy writes Markdown (plain text) instead of plain + HTML / raw block content.
38
+ val copyFormatMarkdown: Boolean = false,
39
+ // Drop every system-provided item (Select all, Share, text assist, PROCESS_TEXT…).
40
+ val systemItemsNone: Boolean = false,
41
+ val codeBlockCopyButton: Boolean = true,
42
+ // Copy hands the selection to onCopyRequest instead of writing the clipboard.
43
+ val interceptCopy: Boolean = false,
35
44
  )
36
45
 
37
46
  /**
@@ -63,6 +72,9 @@ fun createSelectionActionModeCallback(
63
72
  onActionModeChanged: (mode: ActionMode?) -> Unit = {},
64
73
  onCustomItemPress: (itemText: String, selectedText: String, selectionStart: Int, selectionEnd: Int) -> Unit =
65
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 = { _, _, _ -> },
66
78
  ): ActionMode.Callback =
67
79
  object : ActionMode.Callback {
68
80
  override fun onCreateActionMode(
@@ -92,19 +104,26 @@ fun createSelectionActionModeCallback(
92
104
  menu.removeGroup(MENU_ITEM_CUSTOM_GROUP)
93
105
 
94
106
  val selectionMenuConfig = getSelectionMenuConfig()
107
+ val hasSelection = textView.selectionStart >= 0 && textView.selectionEnd > textView.selectionStart
95
108
 
96
- // The system Copy item is added by the platform; relabel it so it
97
- // matches the rest of the localized menu (iOS rebuilds the menu so
98
- // gets this for free; Android only intercepts the click).
99
- if (selectionMenuConfig.copyLabel.isNotEmpty()) {
109
+ if (selectionMenuConfig.systemItemsNone) {
110
+ // Editor re-adds Select all and the text-assist items before calling
111
+ // us on every prepare, so clear on each pass and put back only Copy.
112
+ // Its click is still intercepted in onActionItemClicked.
113
+ menu.clear()
114
+ if (selectionMenuConfig.copyEnabled && hasSelection) {
115
+ menu.add(Menu.NONE, android.R.id.copy, Menu.NONE, selectionMenuConfig.copyLabel)
116
+ }
117
+ } else if (!selectionMenuConfig.copyEnabled) {
118
+ menu.removeItem(android.R.id.copy)
119
+ } else if (selectionMenuConfig.copyLabel.isNotEmpty()) {
120
+ // The system Copy item is added by the platform; relabel it so it
121
+ // matches the rest of the localized menu (iOS rebuilds the menu so
122
+ // gets this for free; Android only intercepts the click).
100
123
  menu.findItem(android.R.id.copy)?.title = selectionMenuConfig.copyLabel
101
124
  }
102
125
 
103
- if (
104
- selectionMenuConfig.copyAsMarkdown &&
105
- textView.selectionStart >= 0 &&
106
- textView.selectionEnd > textView.selectionStart
107
- ) {
126
+ if (selectionMenuConfig.copyAsMarkdown && hasSelection) {
108
127
  menu.add(
109
128
  Menu.NONE,
110
129
  MENU_ITEM_COPY_MARKDOWN,
@@ -113,7 +132,7 @@ fun createSelectionActionModeCallback(
113
132
  )
114
133
  }
115
134
 
116
- if (textView.selectionStart >= 0 && textView.selectionEnd > textView.selectionStart) {
135
+ if (hasSelection) {
117
136
  val customItems = getCustomItemTexts()
118
137
  customItems.forEachIndexed { index, text ->
119
138
  menu
@@ -144,7 +163,12 @@ fun createSelectionActionModeCallback(
144
163
 
145
164
  when (itemId) {
146
165
  android.R.id.copy -> {
147
- textView.copyWithHTML()
166
+ val config = getSelectionMenuConfig()
167
+ if (config.interceptCopy) {
168
+ textView.requestCopy(onCopyRequest)
169
+ } else if (!config.copyFormatMarkdown || !textView.copyMarkdownToClipboard()) {
170
+ textView.copyWithHTML()
171
+ }
148
172
  mode?.finish()
149
173
  return true
150
174
  }
@@ -188,7 +212,8 @@ private fun TextView.copyWithHTML() {
188
212
  if (start < 0 || end < 0 || start >= end) return
189
213
 
190
214
  val spannable = text as? Spannable ?: return
191
- 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))
192
217
  val plainText = selectedText.toString()
193
218
 
194
219
  val styleConfig =
@@ -214,10 +239,24 @@ private fun TextView.copyWithHTML() {
214
239
  }
215
240
  }
216
241
 
217
- private fun TextView.copyMarkdownToClipboard() {
218
- val markdown = MarkdownExtractor.getMarkdownForSelection(this) ?: return
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
+
254
+ /** Returns false when the selection has no Markdown to copy. */
255
+ private fun TextView.copyMarkdownToClipboard(): Boolean {
256
+ val markdown = MarkdownExtractor.getMarkdownForSelection(this) ?: return false
219
257
  val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
220
258
  clipboard.setPrimaryClip(ClipData.newPlainText("Markdown", markdown))
259
+ return true
221
260
  }
222
261
 
223
262
  /** Returns remote image URLs (http/https only) from the current selection. */
@@ -0,0 +1,232 @@
1
+ package com.swmansion.enriched.markdown.utils.text.view
2
+
3
+ import android.text.Layout
4
+ import android.text.Selection
5
+ import android.text.Spannable
6
+ import android.view.View
7
+ import android.widget.TextView
8
+ import com.facebook.react.uimanager.PixelUtil
9
+ import com.swmansion.enriched.markdown.spans.LinkSpacers
10
+ import com.swmansion.enriched.markdown.utils.text.conversion.MarkdownExtractor
11
+ import kotlin.math.max
12
+ import kotlin.math.min
13
+
14
+ /** A rect in dp, relative to the reporting view. */
15
+ data class SelectionRect(
16
+ val x: Float,
17
+ val y: Float,
18
+ val width: Float,
19
+ val height: Float,
20
+ ) {
21
+ companion object {
22
+ val ZERO = SelectionRect(0f, 0f, 0f, 0f)
23
+
24
+ /** Converts a px rect (already in the reporting view's coordinates) to dp. */
25
+ fun fromPx(
26
+ left: Float,
27
+ top: Float,
28
+ right: Float,
29
+ bottom: Float,
30
+ ) = SelectionRect(
31
+ PixelUtil.toDIPFromPixel(min(left, right)),
32
+ PixelUtil.toDIPFromPixel(min(top, bottom)),
33
+ PixelUtil.toDIPFromPixel(kotlin.math.abs(right - left)),
34
+ PixelUtil.toDIPFromPixel(kotlin.math.abs(bottom - top)),
35
+ )
36
+ }
37
+ }
38
+
39
+ /** Payload of `onSelectionChange`. Compared structurally for deduplication. */
40
+ data class SelectionSnapshot(
41
+ val active: Boolean,
42
+ val source: String,
43
+ val text: String,
44
+ val markdown: String,
45
+ val whole: Boolean,
46
+ val rect: SelectionRect,
47
+ val startRect: SelectionRect,
48
+ val endRect: SelectionRect,
49
+ // Not tracked on Android: the selection is reported continuously while a handle is dragged.
50
+ val adjusting: Boolean = false,
51
+ ) {
52
+ companion object {
53
+ val INACTIVE =
54
+ SelectionSnapshot(false, "selection", "", "", false, SelectionRect.ZERO, SelectionRect.ZERO, SelectionRect.ZERO)
55
+
56
+ /** A long-pressed block: `whole`, and every rect is the block's frame. */
57
+ fun block(
58
+ source: String,
59
+ markdown: String,
60
+ text: String,
61
+ rect: SelectionRect,
62
+ ) = SelectionSnapshot(true, source, text, markdown, true, rect, rect, rect)
63
+ }
64
+ }
65
+
66
+ /** A text view's current selection, before the owner applies its `whole` rule. */
67
+ data class TextSelection(
68
+ val text: String,
69
+ val markdown: String,
70
+ // Same threshold MarkdownExtractor uses to return the original markdown.
71
+ val fullSelection: Boolean,
72
+ val rect: SelectionRect,
73
+ val startRect: SelectionRect,
74
+ val endRect: SelectionRect,
75
+ )
76
+
77
+ /**
78
+ * Reads the selection of this text view with its rects offset by [offsetXPx]/[offsetYPx]
79
+ * (a segment's position inside its container). `rect` spans the full content width when the
80
+ * selection covers several lines; `startRect`/`endRect` cover the selected part of the first
81
+ * and last line. Returns null when nothing is selected.
82
+ */
83
+ fun TextView.currentTextSelection(
84
+ offsetXPx: Int = 0,
85
+ offsetYPx: Int = 0,
86
+ ): TextSelection? {
87
+ val start = selectionStart
88
+ val end = selectionEnd
89
+ if (start < 0 || end <= start) return null
90
+ val layout = layout ?: return null
91
+ val content = text ?: return null
92
+
93
+ val selectedText = LinkSpacers.strip(content.subSequence(start, end)).toString()
94
+ val markdown = MarkdownExtractor.getMarkdownForSelection(this) ?: selectedText
95
+
96
+ val originX = (offsetXPx + totalPaddingLeft - scrollX).toFloat()
97
+ val originY = (offsetYPx + totalPaddingTop - scrollY).toFloat()
98
+
99
+ val startLine = layout.getLineForOffset(start)
100
+ // An end at a line break belongs to the line it closes, not the next one.
101
+ val endLine = layout.getLineForOffset(max(start, end - 1))
102
+ val startX = layout.getPrimaryHorizontal(start)
103
+ val endX = lineEndHorizontal(layout, endLine, end)
104
+
105
+ fun lineRect(
106
+ line: Int,
107
+ left: Float,
108
+ right: Float,
109
+ ) = SelectionRect.fromPx(
110
+ left + originX,
111
+ layout.getLineTop(line) + originY,
112
+ right + originX,
113
+ layout.getLineBottom(line) + originY,
114
+ )
115
+
116
+ val startRect: SelectionRect
117
+ val endRect: SelectionRect
118
+ val rect: SelectionRect
119
+ if (startLine == endLine) {
120
+ startRect = lineRect(startLine, startX, endX)
121
+ endRect = startRect
122
+ rect = startRect
123
+ } else {
124
+ startRect = lineRect(startLine, startX, layout.getLineRight(startLine))
125
+ endRect = lineRect(endLine, layout.getLineLeft(endLine), endX)
126
+ rect =
127
+ SelectionRect.fromPx(
128
+ originX,
129
+ layout.getLineTop(startLine) + originY,
130
+ layout.width + originX,
131
+ layout.getLineBottom(endLine) + originY,
132
+ )
133
+ }
134
+
135
+ return TextSelection(
136
+ text = selectedText,
137
+ markdown = markdown,
138
+ fullSelection = start == 0 && end >= content.length - 1,
139
+ rect = rect,
140
+ startRect = startRect,
141
+ endRect = endRect,
142
+ )
143
+ }
144
+
145
+ /** Clears this text view's selection, which also ends its selection ActionMode. */
146
+ fun TextView.clearTextSelection() {
147
+ val spannable = text as? Spannable ?: return
148
+ Selection.removeSelection(spannable)
149
+ }
150
+
151
+ /**
152
+ * x of [offset] on [line]. At a line's end offset `getPrimaryHorizontal` reports the start
153
+ * of the next line, so a selection ending there uses the line's right edge.
154
+ */
155
+ private fun lineEndHorizontal(
156
+ layout: Layout,
157
+ line: Int,
158
+ offset: Int,
159
+ ): Float =
160
+ if (offset >= layout.getLineEnd(line) && line < layout.lineCount - 1) {
161
+ layout.getLineRight(line)
162
+ } else {
163
+ layout.getPrimaryHorizontal(offset)
164
+ }
165
+
166
+ /**
167
+ * Coalesces selection changes into at most one `onSelectionChange` per frame. The payload is
168
+ * read when the frame fires, so the latest state always reaches JS; a cleared selection is
169
+ * reported right away and drops any pending update. Identical payloads are sent once, so the
170
+ * `setText` passes of streaming don't repeat `active: false`.
171
+ */
172
+ class SelectionChangeReporter(
173
+ private val view: View,
174
+ private val snapshot: () -> SelectionSnapshot,
175
+ private val emit: (SelectionSnapshot) -> Unit,
176
+ ) {
177
+ var enabled = false
178
+ set(value) {
179
+ if (field == value) return
180
+ field = value
181
+ cancel()
182
+ lastReported = SelectionSnapshot.INACTIVE
183
+ if (value) schedule()
184
+ }
185
+
186
+ private var lastReported = SelectionSnapshot.INACTIVE
187
+ private var pending = false
188
+ private val flushRunnable =
189
+ Runnable {
190
+ pending = false
191
+ report()
192
+ }
193
+
194
+ /** Reports the current state on the next frame. */
195
+ fun schedule() {
196
+ if (!enabled || pending) return
197
+ pending = true
198
+ view.postOnAnimation(flushRunnable)
199
+ }
200
+
201
+ fun onSelectionChanged(hasSelection: Boolean) {
202
+ if (hasSelection) schedule() else flushNow()
203
+ }
204
+
205
+ /** Reports the current state right away, dropping any pending frame. */
206
+ fun flushNow() {
207
+ cancel()
208
+ report()
209
+ }
210
+
211
+ /**
212
+ * Makes the next report go out even if it equals the last one — a block long-pressed
213
+ * again is a new request for the app's menu, not a repeat.
214
+ */
215
+ fun forgetLastReported() {
216
+ lastReported = SelectionSnapshot.INACTIVE
217
+ }
218
+
219
+ fun cancel() {
220
+ if (!pending) return
221
+ pending = false
222
+ view.removeCallbacks(flushRunnable)
223
+ }
224
+
225
+ private fun report() {
226
+ if (!enabled) return
227
+ val current = snapshot()
228
+ if (current == lastReported) return
229
+ lastReported = current
230
+ emit(current)
231
+ }
232
+ }
@@ -0,0 +1,64 @@
1
+ package com.swmansion.enriched.markdown.views
2
+
3
+ import com.swmansion.enriched.markdown.utils.text.view.SelectionMenuConfig
4
+
5
+ /** Receives an intercepted block Copy: the block's Markdown and its raw content. */
6
+ typealias BlockCopyRequest = (markdown: String, text: String) -> Unit
7
+
8
+ /**
9
+ * Selection-menu options that table, math and code block views need for their
10
+ * own long-press menus. Every default keeps the legacy behavior, so a block view
11
+ * that has not received options yet behaves exactly as before.
12
+ */
13
+ data class BlockMenuOptions(
14
+ val copyHidden: Boolean = false,
15
+ // The Copy item writes the block's Markdown instead of its raw content.
16
+ val copyWritesMarkdown: Boolean = false,
17
+ val copyAsMarkdownHidden: Boolean = false,
18
+ // Hides the copy button in the code block header.
19
+ val copyButtonHidden: Boolean = false,
20
+ // Set when `onCopy` is on: the Copy item hands the content here instead of
21
+ // writing the clipboard. The host binds it to the block's source.
22
+ val onCopyRequest: BlockCopyRequest? = null,
23
+ ) {
24
+ val hasItems: Boolean get() = !copyHidden || !copyAsMarkdownHidden
25
+
26
+ companion object {
27
+ fun from(
28
+ config: SelectionMenuConfig,
29
+ onCopyRequest: BlockCopyRequest? = null,
30
+ ) = BlockMenuOptions(
31
+ copyHidden = !config.copyEnabled,
32
+ copyWritesMarkdown = config.copyFormatMarkdown,
33
+ copyAsMarkdownHidden = !config.copyAsMarkdown,
34
+ copyButtonHidden = !config.codeBlockCopyButton,
35
+ onCopyRequest = onCopyRequest.takeIf { config.interceptCopy },
36
+ )
37
+ }
38
+ }
39
+
40
+ /** Adds the Copy / Copy as Markdown items shared by the table, math and code block menus. */
41
+ fun ContextMenuPopup.Builder.blockCopyItems(
42
+ options: BlockMenuOptions,
43
+ copyLabel: String,
44
+ copyAsMarkdownLabel: String,
45
+ // The block's Markdown and raw content, read only for an intercepted Copy.
46
+ markdown: () -> String,
47
+ rawText: () -> String,
48
+ copyRaw: () -> Unit,
49
+ copyMarkdown: () -> Unit,
50
+ ) {
51
+ if (!options.copyHidden) {
52
+ val onCopyRequest = options.onCopyRequest
53
+ val onCopy =
54
+ when {
55
+ onCopyRequest != null -> { -> onCopyRequest(markdown(), rawText()) }
56
+ options.copyWritesMarkdown -> copyMarkdown
57
+ else -> copyRaw
58
+ }
59
+ item(ContextMenuPopup.Icon.COPY, copyLabel, onCopy)
60
+ }
61
+ if (!options.copyAsMarkdownHidden) {
62
+ item(ContextMenuPopup.Icon.DOCUMENT, copyAsMarkdownLabel, copyMarkdown)
63
+ }
64
+ }
@@ -70,6 +70,19 @@ class CodeBlockContainerView(
70
70
  // control, not a context menu, and stays.
71
71
  var contextMenuHidden: Boolean = false
72
72
 
73
+ // Called instead of the menu on long press while contextMenuHidden is on, so
74
+ // the owner can report the block (onSelectionChange) and show its own UI.
75
+ var onMenuSuppressed: ((markdown: String, text: String) -> Unit)? = null
76
+
77
+ // Copy / Copy as Markdown visibility, Copy format and the header copy button.
78
+ // The header button always copies the raw code.
79
+ var menuOptions: BlockMenuOptions = BlockMenuOptions()
80
+ set(value) {
81
+ field = value
82
+ // Hiding the button leaves the header (and its height) untouched.
83
+ copyButton.visibility = if (value.copyButtonHidden) View.GONE else View.VISIBLE
84
+ }
85
+
73
86
  var onCopyPress: ((code: String, language: String) -> Unit)? = null
74
87
 
75
88
  private var code: String = ""
@@ -250,10 +263,23 @@ class CodeBlockContainerView(
250
263
  // the event when there is one (a pending block has no menu yet, and a hidden
251
264
  // one never does).
252
265
  private fun showContextMenu(anchor: View): Boolean {
253
- if (pending || contextMenuHidden) return false
266
+ if (pending) return false
267
+ if (contextMenuHidden) {
268
+ val onSuppressed = onMenuSuppressed ?: return false
269
+ onSuppressed(CodeBlockNode.fencedMarkdown(code, language, fenceChar), code)
270
+ return true
271
+ }
272
+ if (!menuOptions.hasItems) return false
254
273
  ContextMenuPopup.show(anchor, this) {
255
- item(ContextMenuPopup.Icon.COPY, copyLabel) { copyCode() }
256
- item(ContextMenuPopup.Icon.DOCUMENT, copyAsMarkdownLabel) { copyFencedMarkdown() }
274
+ blockCopyItems(
275
+ menuOptions,
276
+ copyLabel,
277
+ copyAsMarkdownLabel,
278
+ markdown = { CodeBlockNode.fencedMarkdown(code, language, fenceChar) },
279
+ rawText = { code },
280
+ copyRaw = ::copyCode,
281
+ copyMarkdown = ::copyFencedMarkdown,
282
+ )
257
283
  }
258
284
  return true
259
285
  }
@@ -24,6 +24,7 @@ import com.swmansion.enriched.markdown.accessibility.AccessibilityLabels
24
24
  import com.swmansion.enriched.markdown.parser.MarkdownASTNode
25
25
  import com.swmansion.enriched.markdown.parser.MarkdownASTNode.NodeType
26
26
  import com.swmansion.enriched.markdown.renderer.Renderer
27
+ import com.swmansion.enriched.markdown.spans.LinkChipBackgroundSpan
27
28
  import com.swmansion.enriched.markdown.styles.StyleConfig
28
29
  import com.swmansion.enriched.markdown.styles.TableStyle
29
30
  import com.swmansion.enriched.markdown.utils.common.layout.isLayoutRTL
@@ -64,6 +65,11 @@ class TableContainerView(
64
65
  var copyLabel: String = ""
65
66
  var copyAsMarkdownLabel: String = ""
66
67
  var contextMenuHidden: Boolean = false
68
+ var menuOptions: BlockMenuOptions = BlockMenuOptions()
69
+
70
+ // Called instead of the menu on long press while contextMenuHidden is on, so
71
+ // the owner can report the block (onSelectionChange) and show its own UI.
72
+ var onMenuSuppressed: ((markdown: String, text: String) -> Unit)? = null
67
73
 
68
74
  private val scrollView =
69
75
  HorizontalScrollView(context).apply {
@@ -80,6 +86,19 @@ class TableContainerView(
80
86
 
81
87
  val rowCount: Int get() = rows.size
82
88
 
89
+ /**
90
+ * Number of links across all cells. Tables are not reported by `onLinkLayout`, but their links
91
+ * still count so later link indices stay in document order.
92
+ */
93
+ val linkCount: Int
94
+ get() =
95
+ rows.sumOf { row ->
96
+ row.sumOf { cell ->
97
+ // LinkRenderer attaches one LinkChipBackgroundSpan per link.
98
+ cell.attributedText.getSpans(0, cell.attributedText.length, LinkChipBackgroundSpan::class.java).size
99
+ }
100
+ }
101
+
83
102
  fun animateNewRows(
84
103
  previousRowCount: Int,
85
104
  durationMs: Long,
@@ -363,11 +382,14 @@ class TableContainerView(
363
382
  }
364
383
 
365
384
  private fun showContextMenu(anchor: View) {
366
- if (contextMenuHidden) return
385
+ if (contextMenuHidden) {
386
+ onMenuSuppressed?.invoke(tableMarkdown, buildPlainText())
387
+ return
388
+ }
367
389
  val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
368
390
  ContextMenuPopup.show(anchor, this) {
369
- item(ContextMenuPopup.Icon.COPY, copyLabel) {
370
- val plainText = rows.joinToString("\n") { row -> row.joinToString("\t") { it.plainText } }
391
+ blockCopyItems(menuOptions, copyLabel, copyAsMarkdownLabel, markdown = { tableMarkdown }, rawText = ::buildPlainText, copyRaw = {
392
+ val plainText = buildPlainText()
371
393
  if (plainText.isNotEmpty()) {
372
394
  val displayMetrics = context.resources.displayMetrics
373
395
  val tableRows =
@@ -377,13 +399,14 @@ class TableContainerView(
377
399
  val html = HTMLGenerator.generateTableHTML(tableRows, styleConfig, displayMetrics.scaledDensity, displayMetrics.density)
378
400
  clipboard.setPrimaryClip(ClipData.newHtmlText("Table", plainText, html))
379
401
  }
380
- }
381
- item(ContextMenuPopup.Icon.DOCUMENT, copyAsMarkdownLabel) {
402
+ }, copyMarkdown = {
382
403
  if (tableMarkdown.isNotEmpty()) clipboard.setPrimaryClip(ClipData.newPlainText("Table", tableMarkdown))
383
- }
404
+ })
384
405
  }
385
406
  }
386
407
 
408
+ private fun buildPlainText(): String = rows.joinToString("\n") { row -> row.joinToString("\t") { it.plainText } }
409
+
387
410
  private fun buildMarkdownFromRows(): String =
388
411
  rows.joinToString("") { row ->
389
412
  val line = "| ${row.joinToString(" | ") { it.markdownText }} |\n"
@@ -483,7 +506,7 @@ class TableContainerView(
483
506
  .apply { configure(config, context) }
484
507
  .renderDocument(MarkdownASTNode(NodeType.Document, children = listOf(paragraph)), null, null)
485
508
  }
486
- styledText.replaceMathSpansWithPlaceholders(context, config.inlineMathStyle.paddingVertical)
509
+ styledText.replaceMathSpansWithPlaceholders(context, config.inlineMathStyle.boxPaddingVertical)
487
510
  if (isHeader && styledText.isNotEmpty()) {
488
511
  styledText.setSpan(HeaderTypefaceSpan(headerTypeface), 0, styledText.length, 33)
489
512
  }
@@ -37,6 +37,7 @@ class MathInlineRenderer(
37
37
  textColor = config.style.inlineMathStyle.color,
38
38
  paddingVertical = config.style.inlineMathStyle.paddingVertical,
39
39
  fontWeight = config.style.inlineMathStyle.fontWeight,
40
+ glyphOverflow = config.style.inlineMathStyle.glyphOverflow,
40
41
  )
41
42
 
42
43
  builder.setSpan(span, start, end, SPAN_FLAGS_EXCLUSIVE_EXCLUSIVE)