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

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 (71) 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 +74 -0
  6. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.h +68 -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 +4 -0
  9. package/android/src/main/java/com/swmansion/enriched/markdown/EnrichedMarkdown.kt +107 -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 +48 -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/SelectionChangeEvent.kt +42 -0
  15. package/android/src/main/java/com/swmansion/enriched/markdown/utils/common/MarkdownViewManagerUtils.kt +14 -0
  16. package/android/src/main/java/com/swmansion/enriched/markdown/utils/text/view/SelectionChangeReporter.kt +230 -0
  17. package/android/src/main/java/com/swmansion/enriched/markdown/views/CodeBlockContainerView.kt +11 -1
  18. package/android/src/main/java/com/swmansion/enriched/markdown/views/TableContainerView.kt +8 -1
  19. package/android/src/math/java/com/swmansion/enriched/markdown/views/MathContainerView.kt +8 -1
  20. package/docs/API_REFERENCE.md +68 -0
  21. package/docs/WEB.md +1 -0
  22. package/ios/EnrichedMarkdown.mm +188 -2
  23. package/ios/EnrichedMarkdownText.mm +117 -1
  24. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.cpp +74 -0
  25. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.h +68 -0
  26. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.cpp +20 -0
  27. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h +4 -0
  28. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/RCTComponentViewHelpers.h +50 -2
  29. package/ios/utils/ENRMLinkChip.m +38 -20
  30. package/ios/utils/ENRMLinkLayout.m +12 -12
  31. package/ios/utils/ENRMSelectionChange.h +96 -0
  32. package/ios/utils/ENRMSelectionChange.m +204 -0
  33. package/ios/utils/EditMenuUtils+macOS.m +1 -1
  34. package/ios/utils/EditMenuUtils.h +3 -0
  35. package/ios/utils/EditMenuUtils.m +116 -0
  36. package/ios/utils/MarkdownAccessibilityElementBuilder.m +1 -2
  37. package/ios/views/ENRMCodeBlockContainerView.h +3 -0
  38. package/ios/views/ENRMCodeBlockContainerView.m +3 -0
  39. package/ios/views/ENRMMathContainerView.h +3 -0
  40. package/ios/views/ENRMMathContainerView.m +3 -0
  41. package/ios/views/TableContainerView.h +3 -0
  42. package/ios/views/TableContainerView.m +4 -1
  43. package/lib/module/EnrichedMarkdownNativeComponent.ts +70 -1
  44. package/lib/module/EnrichedMarkdownTextNativeComponent.ts +70 -1
  45. package/lib/module/index.js.map +1 -1
  46. package/lib/module/jest/index.js +14 -0
  47. package/lib/module/jest/index.js.map +1 -1
  48. package/lib/module/native/EnrichedMarkdownText.js +46 -4
  49. package/lib/module/native/EnrichedMarkdownText.js.map +1 -1
  50. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts +63 -2
  51. package/lib/typescript/src/EnrichedMarkdownNativeComponent.d.ts.map +1 -1
  52. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts +63 -2
  53. package/lib/typescript/src/EnrichedMarkdownTextNativeComponent.d.ts.map +1 -1
  54. package/lib/typescript/src/index.d.ts +2 -2
  55. package/lib/typescript/src/index.d.ts.map +1 -1
  56. package/lib/typescript/src/jest/index.d.ts +1 -1
  57. package/lib/typescript/src/jest/index.d.ts.map +1 -1
  58. package/lib/typescript/src/native/EnrichedMarkdownText.d.ts +5 -5
  59. package/lib/typescript/src/native/EnrichedMarkdownText.d.ts.map +1 -1
  60. package/lib/typescript/src/types/MarkdownTextProps.d.ts +46 -3
  61. package/lib/typescript/src/types/MarkdownTextProps.d.ts.map +1 -1
  62. package/lib/typescript/src/types/events.d.ts +36 -0
  63. package/lib/typescript/src/types/events.d.ts.map +1 -1
  64. package/package.json +1 -1
  65. package/src/EnrichedMarkdownNativeComponent.ts +70 -1
  66. package/src/EnrichedMarkdownTextNativeComponent.ts +70 -1
  67. package/src/index.tsx +3 -0
  68. package/src/jest/index.tsx +22 -1
  69. package/src/native/EnrichedMarkdownText.tsx +87 -9
  70. package/src/types/MarkdownTextProps.ts +53 -2
  71. package/src/types/events.ts +38 -0
@@ -25,17 +25,22 @@ import com.swmansion.enriched.markdown.spoiler.SpoilerOverlayDrawer
25
25
  import com.swmansion.enriched.markdown.styles.StyleConfig
26
26
  import com.swmansion.enriched.markdown.utils.common.BreakStrategyUtils
27
27
  import com.swmansion.enriched.markdown.utils.common.emitLinkLayout
28
+ import com.swmansion.enriched.markdown.utils.common.emitSelectionChange
28
29
  import com.swmansion.enriched.markdown.utils.text.TailFadeInAnimator
29
30
  import com.swmansion.enriched.markdown.utils.text.interaction.CheckboxTouchHelper
30
31
  import com.swmansion.enriched.markdown.utils.text.view.LinkLayoutCollector
31
32
  import com.swmansion.enriched.markdown.utils.text.view.LinkLayoutFragment
32
33
  import com.swmansion.enriched.markdown.utils.text.view.LinkLongPressMovementMethod
34
+ import com.swmansion.enriched.markdown.utils.text.view.SelectionChangeReporter
33
35
  import com.swmansion.enriched.markdown.utils.text.view.SelectionMenuConfig
36
+ import com.swmansion.enriched.markdown.utils.text.view.SelectionSnapshot
34
37
  import com.swmansion.enriched.markdown.utils.text.view.applySelectableState
35
38
  import com.swmansion.enriched.markdown.utils.text.view.applySelectionColors
36
39
  import com.swmansion.enriched.markdown.utils.text.view.cancelJSTouchForCheckboxTap
37
40
  import com.swmansion.enriched.markdown.utils.text.view.cancelJSTouchForLinkTap
41
+ import com.swmansion.enriched.markdown.utils.text.view.clearTextSelection
38
42
  import com.swmansion.enriched.markdown.utils.text.view.createSelectionActionModeCallback
43
+ import com.swmansion.enriched.markdown.utils.text.view.currentTextSelection
39
44
  import com.swmansion.enriched.markdown.utils.text.view.emitLinkLongPressEvent
40
45
  import com.swmansion.enriched.markdown.utils.text.view.emitLinkPressEvent
41
46
  import com.swmansion.enriched.markdown.utils.text.view.reallowParentInterceptIfLinkReleased
@@ -108,8 +113,14 @@ class EnrichedMarkdownText
108
113
  private var linkLayoutEnabled = false
109
114
  private var lastLinkLayout: List<LinkLayoutFragment>? = null
110
115
 
116
+ // Nullable and assigned in init: TextView calls onSelectionChanged from its
117
+ // own constructor, before this class's fields exist.
118
+ private var selectionReporter: SelectionChangeReporter? = null
119
+
111
120
  init {
112
121
  setupAsMarkdownTextView()
122
+ selectionReporter =
123
+ SelectionChangeReporter(this, ::selectionSnapshot) { snapshot -> emitSelectionChange(this, snapshot) }
113
124
  customSelectionActionModeCallback =
114
125
  createSelectionActionModeCallback(
115
126
  this,
@@ -317,6 +328,42 @@ class EnrichedMarkdownText
317
328
 
318
329
  applySelectionColors(selectionColor, selectionHandleColor)
319
330
  emitLinkLayoutIfNeeded()
331
+ // New text drops the selection; report it if setText didn't already.
332
+ selectionReporter?.schedule()
333
+ }
334
+
335
+ fun setEmitSelectionChange(enabled: Boolean) {
336
+ selectionReporter?.enabled = enabled
337
+ }
338
+
339
+ /** Clears the selection (ending its ActionMode) and reports `active: false`. */
340
+ fun clearSelection() {
341
+ clearTextSelection()
342
+ activeActionMode?.finish()
343
+ selectionReporter?.flushNow()
344
+ }
345
+
346
+ override fun onSelectionChanged(
347
+ selStart: Int,
348
+ selEnd: Int,
349
+ ) {
350
+ super.onSelectionChanged(selStart, selEnd)
351
+ selectionReporter?.onSelectionChanged(selEnd > selStart)
352
+ }
353
+
354
+ private fun selectionSnapshot(): SelectionSnapshot {
355
+ val selection = currentTextSelection() ?: return SelectionSnapshot.INACTIVE
356
+ return SelectionSnapshot(
357
+ active = true,
358
+ source = "selection",
359
+ text = selection.text,
360
+ markdown = selection.markdown,
361
+ // Same rule as onCopyRequest: a full selection already carries the original markdown.
362
+ whole = selection.fullSelection && currentMarkdown.isNotEmpty(),
363
+ rect = selection.rect,
364
+ startRect = selection.startRect,
365
+ endRect = selection.endRect,
366
+ )
320
367
  }
321
368
 
322
369
  fun setEmitLinkLayout(enabled: Boolean) {
@@ -424,6 +471,7 @@ class EnrichedMarkdownText
424
471
  }
425
472
 
426
473
  override fun onDetachedFromWindow() {
474
+ selectionReporter?.cancel()
427
475
  stopSpoilerAnimations()
428
476
  super.onDetachedFromWindow()
429
477
  }
@@ -185,6 +185,20 @@ class EnrichedMarkdownTextManager :
185
185
  view?.setEmitLinkLayout(emitLinkLayout)
186
186
  }
187
187
 
188
+ @ReactProp(name = "emitSelectionChange", defaultBoolean = false)
189
+ override fun setEmitSelectionChange(
190
+ view: EnrichedMarkdownText?,
191
+ emitSelectionChange: Boolean,
192
+ ) {
193
+ view?.setEmitSelectionChange(emitSelectionChange)
194
+ }
195
+
196
+ // Commands
197
+
198
+ override fun clearSelection(view: EnrichedMarkdownText?) {
199
+ view?.clearSelection()
200
+ }
201
+
188
202
  @ReactProp(name = "enableLinkPreview", defaultBoolean = true)
189
203
  override fun setEnableLinkPreview(
190
204
  view: EnrichedMarkdownText?,
@@ -193,6 +207,14 @@ class EnrichedMarkdownTextManager :
193
207
  // No-op on Android — only used on iOS
194
208
  }
195
209
 
210
+ @ReactProp(name = "linkLongPressSelects", defaultBoolean = false)
211
+ override fun setLinkLongPressSelects(
212
+ view: EnrichedMarkdownText?,
213
+ linkLongPressSelects: Boolean,
214
+ ) {
215
+ // No-op on Android — only used on iOS
216
+ }
217
+
196
218
  @ReactProp(name = "enableTaskListItemToggle", defaultBoolean = true)
197
219
  override fun setEnableTaskListItemToggle(
198
220
  view: EnrichedMarkdownText?,
@@ -0,0 +1,42 @@
1
+ package com.swmansion.enriched.markdown.events
2
+
3
+ import com.facebook.react.bridge.Arguments
4
+ import com.facebook.react.bridge.WritableMap
5
+ import com.facebook.react.uimanager.events.Event
6
+ import com.swmansion.enriched.markdown.utils.text.view.SelectionRect
7
+ import com.swmansion.enriched.markdown.utils.text.view.SelectionSnapshot
8
+
9
+ class SelectionChangeEvent(
10
+ surfaceId: Int,
11
+ viewId: Int,
12
+ private val snapshot: SelectionSnapshot,
13
+ ) : Event<SelectionChangeEvent>(surfaceId, viewId) {
14
+ override fun getEventName(): String = EVENT_NAME
15
+
16
+ // Every state is distinct (active/inactive flips must all arrive), so never merge.
17
+ override fun canCoalesce(): Boolean = false
18
+
19
+ override fun getEventData(): WritableMap =
20
+ Arguments.createMap().apply {
21
+ putBoolean("active", snapshot.active)
22
+ putString("source", snapshot.source)
23
+ putString("text", snapshot.text)
24
+ putString("markdown", snapshot.markdown)
25
+ putBoolean("whole", snapshot.whole)
26
+ putMap("rect", snapshot.rect.toWritableMap())
27
+ putMap("startRect", snapshot.startRect.toWritableMap())
28
+ putMap("endRect", snapshot.endRect.toWritableMap())
29
+ }
30
+
31
+ private fun SelectionRect.toWritableMap(): WritableMap =
32
+ Arguments.createMap().apply {
33
+ putDouble("x", x.toDouble())
34
+ putDouble("y", y.toDouble())
35
+ putDouble("width", width.toDouble())
36
+ putDouble("height", height.toDouble())
37
+ }
38
+
39
+ companion object {
40
+ const val EVENT_NAME: String = "onSelectionChange"
41
+ }
42
+ }
@@ -11,10 +11,12 @@ import com.swmansion.enriched.markdown.events.CopyRequestEvent
11
11
  import com.swmansion.enriched.markdown.events.LinkLayoutEvent
12
12
  import com.swmansion.enriched.markdown.events.LinkLongPressEvent
13
13
  import com.swmansion.enriched.markdown.events.LinkPressEvent
14
+ import com.swmansion.enriched.markdown.events.SelectionChangeEvent
14
15
  import com.swmansion.enriched.markdown.events.TaskListItemPressEvent
15
16
  import com.swmansion.enriched.markdown.parser.Md4cFlags
16
17
  import com.swmansion.enriched.markdown.utils.text.view.LinkLayoutFragment
17
18
  import com.swmansion.enriched.markdown.utils.text.view.SelectionMenuConfig
19
+ import com.swmansion.enriched.markdown.utils.text.view.SelectionSnapshot
18
20
 
19
21
  fun markdownEventTypeConstants(): MutableMap<String, Any> {
20
22
  val map = mutableMapOf<String, Any>()
@@ -31,6 +33,8 @@ fun markdownEventTypeConstants(): MutableMap<String, Any> {
31
33
  mapOf("registrationName" to ContextMenuItemPressEvent.EVENT_NAME)
32
34
  map[LinkLayoutEvent.EVENT_NAME] =
33
35
  mapOf("registrationName" to LinkLayoutEvent.EVENT_NAME)
36
+ map[SelectionChangeEvent.EVENT_NAME] =
37
+ mapOf("registrationName" to SelectionChangeEvent.EVENT_NAME)
34
38
  return map
35
39
  }
36
40
 
@@ -44,6 +48,16 @@ fun emitLinkLayout(
44
48
  eventDispatcher?.dispatchEvent(LinkLayoutEvent(surfaceId, view.id, fragments))
45
49
  }
46
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
+
47
61
  fun emitLinkPress(
48
62
  view: View,
49
63
  url: String,
@@ -0,0 +1,230 @@
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
+ ) {
50
+ companion object {
51
+ val INACTIVE =
52
+ SelectionSnapshot(false, "selection", "", "", false, SelectionRect.ZERO, SelectionRect.ZERO, SelectionRect.ZERO)
53
+
54
+ /** A long-pressed block: `whole`, and every rect is the block's frame. */
55
+ fun block(
56
+ source: String,
57
+ markdown: String,
58
+ text: String,
59
+ rect: SelectionRect,
60
+ ) = SelectionSnapshot(true, source, text, markdown, true, rect, rect, rect)
61
+ }
62
+ }
63
+
64
+ /** A text view's current selection, before the owner applies its `whole` rule. */
65
+ data class TextSelection(
66
+ val text: String,
67
+ val markdown: String,
68
+ // Same threshold MarkdownExtractor uses to return the original markdown.
69
+ val fullSelection: Boolean,
70
+ val rect: SelectionRect,
71
+ val startRect: SelectionRect,
72
+ val endRect: SelectionRect,
73
+ )
74
+
75
+ /**
76
+ * Reads the selection of this text view with its rects offset by [offsetXPx]/[offsetYPx]
77
+ * (a segment's position inside its container). `rect` spans the full content width when the
78
+ * selection covers several lines; `startRect`/`endRect` cover the selected part of the first
79
+ * and last line. Returns null when nothing is selected.
80
+ */
81
+ fun TextView.currentTextSelection(
82
+ offsetXPx: Int = 0,
83
+ offsetYPx: Int = 0,
84
+ ): TextSelection? {
85
+ val start = selectionStart
86
+ val end = selectionEnd
87
+ if (start < 0 || end <= start) return null
88
+ val layout = layout ?: return null
89
+ val content = text ?: return null
90
+
91
+ val selectedText = LinkSpacers.strip(content.subSequence(start, end)).toString()
92
+ val markdown = MarkdownExtractor.getMarkdownForSelection(this) ?: selectedText
93
+
94
+ val originX = (offsetXPx + totalPaddingLeft - scrollX).toFloat()
95
+ val originY = (offsetYPx + totalPaddingTop - scrollY).toFloat()
96
+
97
+ val startLine = layout.getLineForOffset(start)
98
+ // An end at a line break belongs to the line it closes, not the next one.
99
+ val endLine = layout.getLineForOffset(max(start, end - 1))
100
+ val startX = layout.getPrimaryHorizontal(start)
101
+ val endX = lineEndHorizontal(layout, endLine, end)
102
+
103
+ fun lineRect(
104
+ line: Int,
105
+ left: Float,
106
+ right: Float,
107
+ ) = SelectionRect.fromPx(
108
+ left + originX,
109
+ layout.getLineTop(line) + originY,
110
+ right + originX,
111
+ layout.getLineBottom(line) + originY,
112
+ )
113
+
114
+ val startRect: SelectionRect
115
+ val endRect: SelectionRect
116
+ val rect: SelectionRect
117
+ if (startLine == endLine) {
118
+ startRect = lineRect(startLine, startX, endX)
119
+ endRect = startRect
120
+ rect = startRect
121
+ } else {
122
+ startRect = lineRect(startLine, startX, layout.getLineRight(startLine))
123
+ endRect = lineRect(endLine, layout.getLineLeft(endLine), endX)
124
+ rect =
125
+ SelectionRect.fromPx(
126
+ originX,
127
+ layout.getLineTop(startLine) + originY,
128
+ layout.width + originX,
129
+ layout.getLineBottom(endLine) + originY,
130
+ )
131
+ }
132
+
133
+ return TextSelection(
134
+ text = selectedText,
135
+ markdown = markdown,
136
+ fullSelection = start == 0 && end >= content.length - 1,
137
+ rect = rect,
138
+ startRect = startRect,
139
+ endRect = endRect,
140
+ )
141
+ }
142
+
143
+ /** Clears this text view's selection, which also ends its selection ActionMode. */
144
+ fun TextView.clearTextSelection() {
145
+ val spannable = text as? Spannable ?: return
146
+ Selection.removeSelection(spannable)
147
+ }
148
+
149
+ /**
150
+ * x of [offset] on [line]. At a line's end offset `getPrimaryHorizontal` reports the start
151
+ * of the next line, so a selection ending there uses the line's right edge.
152
+ */
153
+ private fun lineEndHorizontal(
154
+ layout: Layout,
155
+ line: Int,
156
+ offset: Int,
157
+ ): Float =
158
+ if (offset >= layout.getLineEnd(line) && line < layout.lineCount - 1) {
159
+ layout.getLineRight(line)
160
+ } else {
161
+ layout.getPrimaryHorizontal(offset)
162
+ }
163
+
164
+ /**
165
+ * Coalesces selection changes into at most one `onSelectionChange` per frame. The payload is
166
+ * read when the frame fires, so the latest state always reaches JS; a cleared selection is
167
+ * reported right away and drops any pending update. Identical payloads are sent once, so the
168
+ * `setText` passes of streaming don't repeat `active: false`.
169
+ */
170
+ class SelectionChangeReporter(
171
+ private val view: View,
172
+ private val snapshot: () -> SelectionSnapshot,
173
+ private val emit: (SelectionSnapshot) -> Unit,
174
+ ) {
175
+ var enabled = false
176
+ set(value) {
177
+ if (field == value) return
178
+ field = value
179
+ cancel()
180
+ lastReported = SelectionSnapshot.INACTIVE
181
+ if (value) schedule()
182
+ }
183
+
184
+ private var lastReported = SelectionSnapshot.INACTIVE
185
+ private var pending = false
186
+ private val flushRunnable =
187
+ Runnable {
188
+ pending = false
189
+ report()
190
+ }
191
+
192
+ /** Reports the current state on the next frame. */
193
+ fun schedule() {
194
+ if (!enabled || pending) return
195
+ pending = true
196
+ view.postOnAnimation(flushRunnable)
197
+ }
198
+
199
+ fun onSelectionChanged(hasSelection: Boolean) {
200
+ if (hasSelection) schedule() else flushNow()
201
+ }
202
+
203
+ /** Reports the current state right away, dropping any pending frame. */
204
+ fun flushNow() {
205
+ cancel()
206
+ report()
207
+ }
208
+
209
+ /**
210
+ * Makes the next report go out even if it equals the last one — a block long-pressed
211
+ * again is a new request for the app's menu, not a repeat.
212
+ */
213
+ fun forgetLastReported() {
214
+ lastReported = SelectionSnapshot.INACTIVE
215
+ }
216
+
217
+ fun cancel() {
218
+ if (!pending) return
219
+ pending = false
220
+ view.removeCallbacks(flushRunnable)
221
+ }
222
+
223
+ private fun report() {
224
+ if (!enabled) return
225
+ val current = snapshot()
226
+ if (current == lastReported) return
227
+ lastReported = current
228
+ emit(current)
229
+ }
230
+ }
@@ -70,6 +70,10 @@ 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
+
73
77
  // Copy / Copy as Markdown visibility, Copy format and the header copy button.
74
78
  // The header button always copies the raw code.
75
79
  var menuOptions: BlockMenuOptions = BlockMenuOptions()
@@ -259,7 +263,13 @@ class CodeBlockContainerView(
259
263
  // the event when there is one (a pending block has no menu yet, and a hidden
260
264
  // one never does).
261
265
  private fun showContextMenu(anchor: View): Boolean {
262
- if (pending || contextMenuHidden || !menuOptions.hasItems) 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
263
273
  ContextMenuPopup.show(anchor, this) {
264
274
  blockCopyItems(
265
275
  menuOptions,
@@ -67,6 +67,10 @@ class TableContainerView(
67
67
  var contextMenuHidden: Boolean = false
68
68
  var menuOptions: BlockMenuOptions = BlockMenuOptions()
69
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
73
+
70
74
  private val scrollView =
71
75
  HorizontalScrollView(context).apply {
72
76
  isHorizontalScrollBarEnabled = true
@@ -378,7 +382,10 @@ class TableContainerView(
378
382
  }
379
383
 
380
384
  private fun showContextMenu(anchor: View) {
381
- if (contextMenuHidden) return
385
+ if (contextMenuHidden) {
386
+ onMenuSuppressed?.invoke(tableMarkdown, buildPlainText())
387
+ return
388
+ }
382
389
  val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
383
390
  ContextMenuPopup.show(anchor, this) {
384
391
  blockCopyItems(menuOptions, copyLabel, copyAsMarkdownLabel, markdown = { tableMarkdown }, rawText = ::buildPlainText, copyRaw = {
@@ -44,6 +44,10 @@ class MathContainerView(
44
44
  var contextMenuHidden: Boolean = false
45
45
  var menuOptions: BlockMenuOptions = BlockMenuOptions()
46
46
 
47
+ // Called instead of the menu on long press while contextMenuHidden is on, so
48
+ // the owner can report the block (onSelectionChange) and show its own UI.
49
+ var onMenuSuppressed: ((markdown: String, text: String) -> Unit)? = null
50
+
47
51
  override val segmentMarginTop: Int get() = mathStyle.marginTop.toInt()
48
52
  override val segmentMarginBottom: Int get() = mathStyle.marginBottom.toInt()
49
53
 
@@ -119,7 +123,10 @@ class MathContainerView(
119
123
  }
120
124
 
121
125
  private fun showContextMenu(anchor: View) {
122
- if (contextMenuHidden) return
126
+ if (contextMenuHidden) {
127
+ onMenuSuppressed?.invoke("$$\n$cachedLatex\n$$", cachedLatex)
128
+ return
129
+ }
123
130
  val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
124
131
  ContextMenuPopup.show(anchor, this) {
125
132
  blockCopyItems(menuOptions, copyLabel, copyAsMarkdownLabel, markdown = {
@@ -223,6 +223,62 @@ import * as Clipboard from 'expo-clipboard';
223
223
  />
224
224
  ```
225
225
 
226
+ ### `onSelectionChange`
227
+
228
+ Reports the text selection so the app can draw its own selection menu. Pair it with [`contextMenuHidden`](#contextmenuhidden) to replace the native menu entirely.
229
+
230
+ | Type | Default Value | Platform |
231
+ | ---------------------------------------- | ------------- | -------- |
232
+ | `(event: SelectionChangeEvent) => void` | - | Both |
233
+
234
+ ```ts
235
+ interface SelectionRect { x: number; y: number; width: number; height: number }
236
+
237
+ interface SelectionChangeEvent {
238
+ /** `false` once the selection is gone; every other field is then empty / zero. */
239
+ active: boolean;
240
+ /** `'selection'` for text, or the kind of a long-pressed block. */
241
+ source: 'selection' | 'math' | 'table' | 'code';
242
+ /** Same as `CopyEvent.text` / `CopyEvent.markdown` / `CopyEvent.whole`. */
243
+ text: string;
244
+ markdown: string;
245
+ whole: boolean;
246
+ /** Bounding box (full content width when it spans several lines). */
247
+ rect: SelectionRect;
248
+ /** Selected part of the first / last line — anchor a menu above the start or below the end. */
249
+ startRect: SelectionRect;
250
+ endRect: SelectionRect;
251
+ }
252
+ ```
253
+
254
+ - Fires at most once per frame while the selection changes (payloads are deduplicated), and with `active: false` right away once it is gone — including when new `markdown` replaces the text.
255
+ - Rects are in dp, relative to the component's native view: add the origin from `ref.current.measureInWindow` to get window coordinates. Re-measure after scrolling instead of waiting for a new event.
256
+ - With `contextMenuHidden`, a long press on a table, math or code block (`flavor="github"`) fires the event with `source` set to the block kind, `whole: true` and every rect set to the block's frame. These blocks have no text selection, so without this they would offer no way to copy. Without `contextMenuHidden` the block menu shows as usual and no event fires.
257
+ - There is no "dragging" flag. Debounce before showing your menu, so it doesn't flicker while a handle is dragged.
258
+ - Within one component only one part holds a selection at a time: selecting in another text segment clears the previous one. To keep a single selection across several components, call `clearSelection()` on the previous one.
259
+ - Ignored on macOS and web.
260
+
261
+ **Imperative handle.** `ref` exposes `clearSelection()`, which clears the selection (and any long-pressed block) and fires `active: false`. It also forwards `measure`, `measureInWindow` and `measureLayout`.
262
+
263
+ ```tsx
264
+ const ref = useRef<EnrichedMarkdownTextInstance>(null);
265
+
266
+ <EnrichedMarkdownText
267
+ ref={ref}
268
+ markdown={content}
269
+ contextMenuHidden
270
+ onSelectionChange={(e) => {
271
+ if (!e.active) return hideMenu();
272
+ ref.current?.measureInWindow((x, y) =>
273
+ showMenuDebounced({ x: x + e.startRect.x, y: y + e.startRect.y }, e.markdown)
274
+ );
275
+ }}
276
+ />;
277
+
278
+ // After the menu action:
279
+ ref.current?.clearSelection();
280
+ ```
281
+
226
282
  ### `enableLinkPreview`
227
283
 
228
284
  Controls the native link preview on long press (iOS only). Automatically set to `false` when `onLinkLongPress` is provided.
@@ -243,6 +299,16 @@ You can also control this behavior explicitly without providing a handler:
243
299
  />
244
300
  ```
245
301
 
302
+ ### `linkLongPressSelects`
303
+
304
+ Long-pressing a link selects its label and shows the selection edit menu (the same one a manual selection gets), instead of the system link preview (iOS only). Takes precedence over `enableLinkPreview` and `onLinkLongPress`.
305
+
306
+ | Type | Default Value | Platform |
307
+ | --------- | ------------- | -------- |
308
+ | `boolean` | `false` | iOS |
309
+
310
+ Only the link label is selected, not the chip padding; the selection handles can be dragged to extend it. Long-pressing inline math always behaves this way, without the prop.
311
+
246
312
  ### `selectable`
247
313
 
248
314
  Whether text can be selected.
@@ -281,6 +347,8 @@ Notes:
281
347
  prop suppresses it the same way.
282
348
  - Applies to `EnrichedMarkdownText` only — it does not affect
283
349
  `EnrichedMarkdownTextInput`.
350
+ - To show your own menu instead, use [`onSelectionChange`](#onselectionchange);
351
+ with this prop on, block long-presses are reported through it as well.
284
352
 
285
353
  ### `selectionColor`
286
354
 
package/docs/WEB.md CHANGED
@@ -37,6 +37,7 @@ The web implementation also exports `WebMarkdownTextProps` which extends `Enrich
37
37
  |---|---|
38
38
  | `flavor` | The web renderer always uses full GFM capabilities. On native, `flavor` controls whether a single `TextView` (CommonMark) or container-based renderer (GitHub) is used; the DOM has no such constraint. |
39
39
  | `enableLinkPreview` | iOS-only feature (native link preview on long press). |
40
+ | `linkLongPressSelects` | iOS-only feature (selects a link on long press instead of the link preview). |
40
41
  | `allowFontScaling` / `maxFontSizeMultiplier` | React Native text scaling props. Browsers handle font scaling natively via OS accessibility settings. |
41
42
  | `streamingAnimation` | Native-only tail fade-in animation. Not yet implemented on web. |
42
43
  | `streamingConfig` | Native-only streaming table configuration. Not yet implemented on web. |