react-native-enriched-markdown-scaffold 0.7.4-scaffold.10 → 0.7.4-scaffold.11

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 (38) hide show
  1. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextInputManagerDelegate.java +6 -0
  2. package/android/generated/java/com/facebook/react/viewmanagers/EnrichedMarkdownTextInputManagerInterface.java +2 -0
  3. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.cpp +10 -0
  4. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/EventEmitters.h +7 -0
  5. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.cpp +5 -0
  6. package/android/generated/jni/react/renderer/components/EnrichedMarkdownTextSpec/Props.h +1 -0
  7. package/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputManager.kt +18 -0
  8. package/android/src/main/java/com/swmansion/enriched/markdown/input/EnrichedMarkdownTextInputView.kt +119 -0
  9. package/android/src/main/java/com/swmansion/enriched/markdown/input/events/OnPasteEvent.kt +24 -0
  10. package/android/src/main/java/com/swmansion/enriched/markdown/input/layout/InputEventEmitter.kt +8 -0
  11. package/android/src/main/java/com/swmansion/enriched/markdown/utils/input/PasteTextUtils.kt +20 -0
  12. package/docs/API_REFERENCE.md +34 -0
  13. package/docs/INPUT.md +39 -0
  14. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.cpp +10 -0
  15. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/EventEmitters.h +7 -0
  16. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.cpp +5 -0
  17. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h +1 -0
  18. package/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/RCTComponentViewHelpers.h +29 -0
  19. package/ios/input/ENRMInputTextView.mm +11 -1
  20. package/ios/input/EnrichedMarkdownTextInput.h +5 -0
  21. package/ios/input/EnrichedMarkdownTextInput.mm +33 -0
  22. package/lib/module/EnrichedMarkdownTextInput.js +15 -0
  23. package/lib/module/EnrichedMarkdownTextInput.js.map +1 -1
  24. package/lib/module/EnrichedMarkdownTextInputNativeComponent.ts +22 -0
  25. package/lib/module/index.js.map +1 -1
  26. package/lib/typescript/src/EnrichedMarkdownTextInput.d.ts +24 -1
  27. package/lib/typescript/src/EnrichedMarkdownTextInput.d.ts.map +1 -1
  28. package/lib/typescript/src/EnrichedMarkdownTextInputNativeComponent.d.ts +16 -0
  29. package/lib/typescript/src/EnrichedMarkdownTextInputNativeComponent.d.ts.map +1 -1
  30. package/lib/typescript/src/index.d.ts +1 -1
  31. package/lib/typescript/src/index.d.ts.map +1 -1
  32. package/lib/typescript/src/types/events.d.ts +15 -0
  33. package/lib/typescript/src/types/events.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/src/EnrichedMarkdownTextInput.tsx +37 -0
  36. package/src/EnrichedMarkdownTextInputNativeComponent.ts +22 -0
  37. package/src/index.tsx +1 -0
  38. package/src/types/events.ts +16 -0
@@ -50,6 +50,9 @@ public class EnrichedMarkdownTextInputManagerDelegate<T extends View, U extends
50
50
  case "autoCapitalize":
51
51
  mViewManager.setAutoCapitalize(view, value == null ? null : (String) value);
52
52
  break;
53
+ case "interceptPaste":
54
+ mViewManager.setInterceptPaste(view, value == null ? false : (boolean) value);
55
+ break;
53
56
  case "multiline":
54
57
  mViewManager.setMultiline(view, value == null ? false : (boolean) value);
55
58
  break;
@@ -160,6 +163,9 @@ public class EnrichedMarkdownTextInputManagerDelegate<T extends View, U extends
160
163
  case "copyToClipboard":
161
164
  mViewManager.copyToClipboard(view);
162
165
  break;
166
+ case "resolvePaste":
167
+ mViewManager.resolvePaste(view, args.getString(0), args.getString(1));
168
+ break;
163
169
  case "requestMarkdown":
164
170
  mViewManager.requestMarkdown(view, args.getInt(0));
165
171
  break;
@@ -24,6 +24,7 @@ public interface EnrichedMarkdownTextInputManagerInterface<T extends View> exten
24
24
  void setScrollEnabled(T view, boolean value);
25
25
  void setBlockSyntax(T view, boolean value);
26
26
  void setAutoCapitalize(T view, @Nullable String value);
27
+ void setInterceptPaste(T view, boolean value);
27
28
  void setMultiline(T view, boolean value);
28
29
  void setCursorColor(T view, @Nullable Integer value);
29
30
  void setSelectionColor(T view, @Nullable Integer value);
@@ -58,6 +59,7 @@ public interface EnrichedMarkdownTextInputManagerInterface<T extends View> exten
58
59
  void updateMath(T view, int start, String latex, boolean displayMode);
59
60
  void removeMath(T view, int start);
60
61
  void copyToClipboard(T view);
62
+ void resolvePaste(T view, String text, String markdown);
61
63
  void requestMarkdown(T view, int requestId);
62
64
  void requestCaretRect(T view, int requestId);
63
65
  }
@@ -247,6 +247,16 @@ payload.setProperty(runtime, "height", event.height);
247
247
  }
248
248
 
249
249
 
250
+ void EnrichedMarkdownTextInputEventEmitter::onPaste(OnPaste event) const {
251
+ dispatchEvent("paste", [event=std::move(event)](jsi::Runtime &runtime) {
252
+ auto payload = jsi::Object(runtime);
253
+ payload.setProperty(runtime, "text", event.text);
254
+ payload.setProperty(runtime, "markdown", event.markdown);
255
+ return payload;
256
+ });
257
+ }
258
+
259
+
250
260
  void EnrichedMarkdownTextInputEventEmitter::onStartMention(OnStartMention event) const {
251
261
  dispatchEvent("startMention", [event=std::move(event)](jsi::Runtime &runtime) {
252
262
  auto payload = jsi::Object(runtime);
@@ -182,6 +182,11 @@ class EnrichedMarkdownTextInputEventEmitter : public ViewEventEmitter {
182
182
  double height;
183
183
  };
184
184
 
185
+ struct OnPaste {
186
+ std::string text;
187
+ std::string markdown;
188
+ };
189
+
185
190
  struct OnStartMention {
186
191
  std::string indicator;
187
192
  };
@@ -218,6 +223,8 @@ class EnrichedMarkdownTextInputEventEmitter : public ViewEventEmitter {
218
223
 
219
224
  void onMathPress(OnMathPress value) const;
220
225
 
226
+ void onPaste(OnPaste value) const;
227
+
221
228
  void onStartMention(OnStartMention value) const;
222
229
 
223
230
  void onChangeMention(OnChangeMention value) const;
@@ -146,6 +146,7 @@ EnrichedMarkdownTextInputProps::EnrichedMarkdownTextInputProps(
146
146
  scrollEnabled(convertRawProp(context, rawProps, "scrollEnabled", sourceProps.scrollEnabled, {false})),
147
147
  blockSyntax(convertRawProp(context, rawProps, "blockSyntax", sourceProps.blockSyntax, {false})),
148
148
  autoCapitalize(convertRawProp(context, rawProps, "autoCapitalize", sourceProps.autoCapitalize, {})),
149
+ interceptPaste(convertRawProp(context, rawProps, "interceptPaste", sourceProps.interceptPaste, {false})),
149
150
  multiline(convertRawProp(context, rawProps, "multiline", sourceProps.multiline, {false})),
150
151
  cursorColor(convertRawProp(context, rawProps, "cursorColor", sourceProps.cursorColor, {})),
151
152
  selectionColor(convertRawProp(context, rawProps, "selectionColor", sourceProps.selectionColor, {})),
@@ -211,6 +212,10 @@ folly::dynamic EnrichedMarkdownTextInputProps::getDiffProps(
211
212
  result["autoCapitalize"] = autoCapitalize;
212
213
  }
213
214
 
215
+ if (interceptPaste != oldProps->interceptPaste) {
216
+ result["interceptPaste"] = interceptPaste;
217
+ }
218
+
214
219
  if (multiline != oldProps->multiline) {
215
220
  result["multiline"] = multiline;
216
221
  }
@@ -3361,6 +3361,7 @@ class EnrichedMarkdownTextInputProps final : public ViewProps {
3361
3361
  bool scrollEnabled{false};
3362
3362
  bool blockSyntax{false};
3363
3363
  std::string autoCapitalize{};
3364
+ bool interceptPaste{false};
3364
3365
  bool multiline{false};
3365
3366
  SharedColor cursorColor{};
3366
3367
  SharedColor selectionColor{};
@@ -27,6 +27,7 @@ import com.swmansion.enriched.markdown.input.events.OnInputBlurEvent
27
27
  import com.swmansion.enriched.markdown.input.events.OnInputFocusEvent
28
28
  import com.swmansion.enriched.markdown.input.events.OnLinkDetectedEvent
29
29
  import com.swmansion.enriched.markdown.input.events.OnMathPressEvent
30
+ import com.swmansion.enriched.markdown.input.events.OnPasteEvent
30
31
  import com.swmansion.enriched.markdown.input.events.OnRequestCaretRectResultEvent
31
32
  import com.swmansion.enriched.markdown.input.events.OnRequestMarkdownResultEvent
32
33
  import com.swmansion.enriched.markdown.input.events.OnStartMentionEvent
@@ -112,6 +113,7 @@ class EnrichedMarkdownTextInputManager :
112
113
  OnContextMenuItemPressEvent.EVENT_NAME,
113
114
  OnLinkDetectedEvent.EVENT_NAME,
114
115
  OnMathPressEvent.EVENT_NAME,
116
+ OnPasteEvent.EVENT_NAME,
115
117
  OnStartMentionEvent.EVENT_NAME,
116
118
  OnChangeMentionEvent.EVENT_NAME,
117
119
  OnEndMentionEvent.EVENT_NAME,
@@ -186,6 +188,14 @@ class EnrichedMarkdownTextInputManager :
186
188
  view?.setAutoCapitalize(value)
187
189
  }
188
190
 
191
+ @ReactProp(name = "interceptPaste", defaultBoolean = false)
192
+ override fun setInterceptPaste(
193
+ view: EnrichedMarkdownTextInputView?,
194
+ value: Boolean,
195
+ ) {
196
+ view?.interceptPaste = value
197
+ }
198
+
189
199
  @ReactProp(name = "multiline", defaultBoolean = true)
190
200
  override fun setMultiline(
191
201
  view: EnrichedMarkdownTextInputView?,
@@ -517,6 +527,14 @@ class EnrichedMarkdownTextInputManager :
517
527
  view?.copyToClipboard()
518
528
  }
519
529
 
530
+ override fun resolvePaste(
531
+ view: EnrichedMarkdownTextInputView?,
532
+ text: String?,
533
+ markdown: String?,
534
+ ) {
535
+ view?.resolvePaste(text ?: "", markdown ?: "")
536
+ }
537
+
520
538
  override fun requestMarkdown(
521
539
  view: EnrichedMarkdownTextInputView?,
522
540
  requestId: Int,
@@ -20,6 +20,9 @@ import android.view.inputmethod.EditorInfo
20
20
  import android.view.inputmethod.InputConnection
21
21
  import android.view.inputmethod.InputMethodManager
22
22
  import androidx.appcompat.widget.AppCompatEditText
23
+ import androidx.core.view.ContentInfoCompat
24
+ import androidx.core.view.OnReceiveContentListener
25
+ import androidx.core.view.ViewCompat
23
26
  import com.facebook.react.common.ReactConstants
24
27
  import com.facebook.react.uimanager.BackgroundStyleApplicator
25
28
  import com.facebook.react.uimanager.PixelUtil
@@ -44,6 +47,7 @@ import com.swmansion.enriched.markdown.input.model.MathChipStyle
44
47
  import com.swmansion.enriched.markdown.input.model.StyleType
45
48
  import com.swmansion.enriched.markdown.input.toolbar.InputContextMenu
46
49
  import com.swmansion.enriched.markdown.utils.input.AutoCapitalizeUtils
50
+ import com.swmansion.enriched.markdown.utils.input.PasteTextUtils
47
51
  import kotlin.math.ceil
48
52
 
49
53
  private fun Char.isLineBreak(): Boolean = this == '\n' || this == '\r' || this == '\u0085' || this == '\u2028' || this == '\u2029'
@@ -97,6 +101,25 @@ class EnrichedMarkdownTextInputView(
97
101
  private var detectScrollMovement = false
98
102
  var scrollEnabled: Boolean = true
99
103
 
104
+ // When on, a paste is reported to JS instead of inserted. Native never touches
105
+ // the buffer here (see resolvePaste): if JS dies mid-handler the paste is a
106
+ // visible no-op rather than a selection that silently vanished.
107
+ //
108
+ // The listener is installed only while the prop is on. Registering it
109
+ // unconditionally would route every consumer's paste through AppCompat's
110
+ // receive-content path even with the feature off, which is a behavior change
111
+ // nobody asked for.
112
+ var interceptPaste: Boolean = false
113
+ set(value) {
114
+ if (field == value) return
115
+ field = value
116
+ ViewCompat.setOnReceiveContentListener(
117
+ this,
118
+ if (value) PASTE_MIME_TYPES else null,
119
+ if (value) receiveContentListener else null,
120
+ )
121
+ }
122
+
100
123
  // The markdown last handed over by JS, kept so `blockSyntax` can re-parse it. @ReactProp
101
124
  // setters fire in undefined order, so `defaultValue` regularly lands before the flag does.
102
125
  private var lastMarkdownFromJS: String? = null
@@ -488,6 +511,98 @@ class EnrichedMarkdownTextInputView(
488
511
  clipboard.setPrimaryClip(ClipData.newPlainText(null, content))
489
512
  }
490
513
 
514
+ // Paste arrives by two different routes and only the first one is a menu.
515
+ // `onTextContextMenuItem` covers the context menu and the hardware Ctrl+V /
516
+ // KEYCODE_PASTE shortcuts; drag-and-drop, autofill and IME `commitContent`
517
+ // reach the view through `onReceiveContent` instead. Hooking only one of them
518
+ // leaves the other pasting straight into the buffer with nobody intercepting.
519
+ override fun onTextContextMenuItem(id: Int): Boolean {
520
+ val isPaste = id == android.R.id.paste || id == android.R.id.pasteAsPlainText
521
+ if (isPaste && interceptPaste) {
522
+ val text = readClipboardText()
523
+ if (text.isNotEmpty()) {
524
+ eventEmitter.emitPaste(text, "")
525
+ // Stops super, which is what keeps this single-emit: AppCompat routes
526
+ // its own paste handling through the receive-content listener below.
527
+ return true
528
+ }
529
+ // Nothing textual in the clip: hand it to the platform, matching what the
530
+ // listener does with the items it does not claim. Both routes then decide
531
+ // from the same rule, so a clip never behaves differently depending on
532
+ // whether it came from the menu or from Ctrl+V.
533
+ }
534
+ return super.onTextContextMenuItem(id)
535
+ }
536
+
537
+ private val receiveContentListener =
538
+ OnReceiveContentListener { _, payload ->
539
+ // The listener also sees autofill, drag-and-drop, IME commitContent and
540
+ // PROCESS_TEXT. Without this filter an autofill would turn into a paste
541
+ // event — and a consumer would turn that into an attachment chip.
542
+ if (!interceptPaste || payload.source != ContentInfoCompat.SOURCE_CLIPBOARD) {
543
+ return@OnReceiveContentListener payload
544
+ }
545
+ val parts = payload.partition { it.text != null }
546
+ parts.first?.let { accepted ->
547
+ val clip = accepted.clip
548
+ val text = PasteTextUtils.joinClipTexts((0 until clip.itemCount).map { clip.getItemAt(it).text })
549
+ if (text.isNotEmpty()) {
550
+ eventEmitter.emitPaste(text, "")
551
+ }
552
+ }
553
+ // Hand back only what we did not claim, so the buffer stays untouched.
554
+ parts.second
555
+ }
556
+
557
+ private fun readClipboardText(): String {
558
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return ""
559
+ val clip = clipboard.primaryClip ?: return ""
560
+ // `text`, not `coerceToText`: the listener can only claim items that already
561
+ // carry text, and the two routes have to agree on what counts as a paste.
562
+ return PasteTextUtils.joinClipTexts((0 until clip.itemCount).map { clip.getItemAt(it).text })
563
+ }
564
+
565
+ /**
566
+ * Completes a paste reported through `onPaste`. `markdown` is accepted for
567
+ * parity with iOS but ignored here: Android's clipboard is plain text only
568
+ * (see `copyToClipboard`), so there is no flavor to restore.
569
+ *
570
+ * Resolving with an empty string and an empty selection is a zero-length edit
571
+ * — it still emits, which keeps the JS contract identical on both platforms.
572
+ */
573
+ fun resolvePaste(
574
+ pastedText: String,
575
+ @Suppress("UNUSED_PARAMETER") markdown: String,
576
+ ) {
577
+ val editable = text ?: return
578
+ val selStart = selectionStart.coerceAtLeast(0)
579
+ val selEnd = selectionEnd.coerceAtLeast(selStart)
580
+ val safeStart = selStart.coerceAtMost(editable.length)
581
+ val safeEnd = selEnd.coerceAtMost(editable.length)
582
+
583
+ isProcessingTextChange = true
584
+ try {
585
+ editable.replace(safeStart, safeEnd, pastedText)
586
+ formattingStore.adjustForEdit(safeStart, safeEnd - safeStart, pastedText.length)
587
+ lastProcessedText = editable.toString()
588
+ setSelection(safeStart + pastedText.length)
589
+ } finally {
590
+ isProcessingTextChange = false
591
+ }
592
+
593
+ applyFormatting()
594
+ // Parity with the un-intercepted paste, which reached the detectors through
595
+ // the text watcher: a pasted URL still autolinks.
596
+ detectorPipeline.processTextChange(editable, editable.toString(), safeStart, pastedText.length)
597
+ invalidate()
598
+ requestLayout()
599
+ eventEmitter.emitChangeText()
600
+ if (emitMarkdown) eventEmitter.emitChangeMarkdown()
601
+ eventEmitter.emitSelection(selectionStart, selectionEnd)
602
+ eventEmitter.emitState()
603
+ eventEmitter.emitCaretRectChangeIfNeeded()
604
+ }
605
+
491
606
  fun setLinkForSelection(url: String) {
492
607
  val selStart = selectionStart
493
608
  val selEnd = selectionEnd
@@ -1155,6 +1270,10 @@ class EnrichedMarkdownTextInputView(
1155
1270
  .replace("(", "%28")
1156
1271
  .replace(")", "%29")
1157
1272
 
1273
+ private companion object {
1274
+ private val PASTE_MIME_TYPES = arrayOf("text/*")
1275
+ }
1276
+
1158
1277
  private data class MentionCandidate(
1159
1278
  val indicator: String,
1160
1279
  val start: Int,
@@ -0,0 +1,24 @@
1
+ package com.swmansion.enriched.markdown.input.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
+
7
+ class OnPasteEvent(
8
+ surfaceId: Int,
9
+ viewId: Int,
10
+ private val text: String,
11
+ private val markdown: String,
12
+ ) : Event<OnPasteEvent>(surfaceId, viewId) {
13
+ override fun getEventName(): String = EVENT_NAME
14
+
15
+ override fun getEventData(): WritableMap =
16
+ Arguments.createMap().apply {
17
+ putString("text", text)
18
+ putString("markdown", markdown)
19
+ }
20
+
21
+ companion object {
22
+ const val EVENT_NAME = "onPaste"
23
+ }
24
+ }
@@ -16,6 +16,7 @@ import com.swmansion.enriched.markdown.input.events.OnInputBlurEvent
16
16
  import com.swmansion.enriched.markdown.input.events.OnInputFocusEvent
17
17
  import com.swmansion.enriched.markdown.input.events.OnLinkDetectedEvent
18
18
  import com.swmansion.enriched.markdown.input.events.OnMathPressEvent
19
+ import com.swmansion.enriched.markdown.input.events.OnPasteEvent
19
20
  import com.swmansion.enriched.markdown.input.events.OnRequestCaretRectResultEvent
20
21
  import com.swmansion.enriched.markdown.input.events.OnRequestMarkdownResultEvent
21
22
  import com.swmansion.enriched.markdown.input.events.OnStartMentionEvent
@@ -99,6 +100,13 @@ class InputEventEmitter(
99
100
  dispatch(OnMathPressEvent(surfaceId(), view.id, latex, start, end, displayMode, x, y, width, height))
100
101
  }
101
102
 
103
+ fun emitPaste(
104
+ text: String,
105
+ markdown: String,
106
+ ) {
107
+ dispatch(OnPasteEvent(surfaceId(), view.id, text, markdown))
108
+ }
109
+
102
110
  fun emitStartMention(indicator: String) {
103
111
  dispatch(OnStartMentionEvent(surfaceId(), view.id, indicator))
104
112
  }
@@ -0,0 +1,20 @@
1
+ package com.swmansion.enriched.markdown.utils.input
2
+
3
+ /**
4
+ * The pure half of the paste interception path. A `ClipData` cannot be built on
5
+ * the JVM, so the joining rule lives here where a unit test can reach it; the
6
+ * two call sites (context menu and `onReceiveContent`) only extract the items.
7
+ */
8
+ object PasteTextUtils {
9
+ /**
10
+ * Flattens a clip's items into one string. A clip regularly carries more than
11
+ * one item — reading only `getItemAt(0)` silently drops items 2..n. Items
12
+ * without text (a URI or intent) are skipped, and the rest join on newlines,
13
+ * which is what the platform's own paste does.
14
+ */
15
+ fun joinClipTexts(texts: List<CharSequence?>): String =
16
+ texts
17
+ .mapNotNull { it?.toString() }
18
+ .filter { it.isNotEmpty() }
19
+ .joinToString("\n")
20
+ }
@@ -532,6 +532,18 @@ Auto-capitalization behavior.
532
532
  | -------- | -------------- | -------- |
533
533
  | `string` | `'sentences'` | Both |
534
534
 
535
+ ### `interceptPaste`
536
+
537
+ Reports pastes through [`onPaste`](#onpaste) instead of inserting them. Only takes effect when `onPaste` is also set — otherwise native would swallow every paste with nobody left to resolve it, so the library keeps the prop off.
538
+
539
+ See [Intercepting paste](./INPUT.md#intercepting-paste).
540
+
541
+ | Type | Default Value | Platform |
542
+ | --------- | ------------- | ------------- |
543
+ | `boolean` | `false` | iOS, Android |
544
+
545
+ macOS ignores this prop and always pastes inline.
546
+
535
547
  ### `multiline`
536
548
 
537
549
  Whether the input supports multiple lines.
@@ -710,6 +722,18 @@ Fires when the active mention flow ends.
710
722
  | ---- | ------------- | -------- |
711
723
  | `(event: { indicator: string }) => void` | - | Both |
712
724
 
725
+ ### `onPaste`
726
+
727
+ Fires instead of the insert when [`interceptPaste`](#interceptpaste) is on. Native reads the clipboard and leaves the buffer untouched, so the handler owns the paste from that point: it must call [`resolvePaste`](#resolvepastetext-string-markdown-string) exactly once, on every path including failures, or the paste is silently dropped.
728
+
729
+ | Type | Default Value | Platform |
730
+ | ---- | ------------- | -------- |
731
+ | `(event: { text: string; markdown: string }) => void` | - | iOS, Android |
732
+
733
+ `markdown` is non-empty only when the copy source was an `EnrichedMarkdownTextInput`, which the system clipboard can only carry on iOS; Android always reports `''`.
734
+
735
+ See [Intercepting paste](./INPUT.md#intercepting-paste).
736
+
713
737
  ### `writingDirection`
714
738
 
715
739
  Paragraph writing direction in the input. iOS only — Android's `EditText` already resolves direction per paragraph via `TEXT_DIRECTION_FIRST_STRONG` and is unaffected by this prop.
@@ -942,6 +966,16 @@ Copies the input's full content to the system clipboard, matching the result of
942
966
 
943
967
  On iOS and macOS the clipboard receives both plain text and a private Markdown pasteboard type, so pasting back into an `EnrichedMarkdownTextInput` restores the formatting; external apps receive plain text only. On Android the clipboard receives plain text only — inline styles are not preserved for any paste target.
944
968
 
969
+ ### `resolvePaste(text: string, markdown?: string)`
970
+
971
+ Completes a paste reported through [`onPaste`](#onpaste). Call it exactly once per event:
972
+
973
+ - `resolvePaste(text, markdown)` — inserts, restoring the copied formatting when `markdown` is non-empty.
974
+ - `resolvePaste(text)` — inserts `text` verbatim.
975
+ - `resolvePaste('', '')` — drops the clipboard content, replacing the current selection. With an empty selection this is a no-op that still emits `onChangeText` with unchanged content.
976
+
977
+ Anything that replaces a selection replaces it here too, so a paste over selected text behaves the same on every branch.
978
+
945
979
  ### `startMention(indicator: string)`
946
980
 
947
981
  Programmatically triggers a mention flow by inserting the indicator character at the current cursor position. The indicator must be listed in the `mentionIndicators` prop. Useful for toolbar buttons.
package/docs/INPUT.md CHANGED
@@ -205,6 +205,45 @@ The input's content can be copied to the system clipboard from a ref, without re
205
205
 
206
206
  On iOS and macOS the clipboard receives both plain text and a private Markdown pasteboard type, so pasting back into an `EnrichedMarkdownTextInput` restores the formatting; external apps receive plain text only. On Android the clipboard receives plain text only — inline styles are not preserved for any paste target.
207
207
 
208
+ ## Intercepting paste
209
+
210
+ By default a paste goes straight into the buffer. Set [`interceptPaste`](API_REFERENCE.md#interceptpaste) together with [`onPaste`](API_REFERENCE.md#onpaste) to decide what a paste means before any text lands — turning a long paste into an attachment chip, stripping it, or inserting it unchanged.
211
+
212
+ ```tsx
213
+ const inputRef = useRef<EnrichedMarkdownTextInputInstance>(null);
214
+
215
+ <EnrichedMarkdownTextInput
216
+ ref={inputRef}
217
+ interceptPaste
218
+ onPaste={({ text, markdown }) => {
219
+ if (text.length > 2000) {
220
+ attachAsFile(text);
221
+ inputRef.current?.resolvePaste('', ''); // drops it, and replaces the selection
222
+ return;
223
+ }
224
+ inputRef.current?.resolvePaste(text, markdown);
225
+ }}
226
+ />
227
+ ```
228
+
229
+ Native reads the clipboard and **does not touch the buffer**. That makes the handler the only thing that can complete the paste, so the contract is strict:
230
+
231
+ - Call [`resolvePaste`](API_REFERENCE.md#resolvepastetext-string-markdown-string) **exactly once per event, on every path including failures**. A handler that throws before resolving leaves the paste dropped — the user sees nothing happen.
232
+ - Pass `markdown` back through when inserting. It is the copied formatting; dropping it turns a copy-then-paste inside your app into flat text.
233
+ - `interceptPaste` is ignored unless `onPaste` is set, so switching the prop on alone cannot make the input swallow pastes.
234
+
235
+ Because nothing is written until you resolve, an async decision is safe: the selection is still there, and the user sees an unchanged input rather than text that vanished.
236
+
237
+ ### Platform behavior
238
+
239
+ | | Behavior |
240
+ | --- | --- |
241
+ | **iOS** | Context menu and hardware ⌘V. `markdown` is set when the copy source was an `EnrichedMarkdownTextInput`. |
242
+ | **Android** | Context menu, hardware Ctrl+V and `KEYCODE_PASTE`. `markdown` is always `''` — the Android clipboard carries plain text only. |
243
+ | **macOS** | `interceptPaste` is ignored, `onPaste` never fires, and pastes insert inline as usual. |
244
+
245
+ Only clipboard content is intercepted. Drag-and-drop, autofill and IME content insertion bypass `onPaste` on both platforms and insert normally — on Android they are filtered out explicitly, so an autofill never arrives looking like a paste.
246
+
208
247
  ## Style Detection
209
248
 
210
249
  All of the above styles can be detected with the use of [onChangeState](API_REFERENCE.md#onchangestate) callback payload.
@@ -247,6 +247,16 @@ payload.setProperty(runtime, "height", event.height);
247
247
  }
248
248
 
249
249
 
250
+ void EnrichedMarkdownTextInputEventEmitter::onPaste(OnPaste event) const {
251
+ dispatchEvent("paste", [event=std::move(event)](jsi::Runtime &runtime) {
252
+ auto payload = jsi::Object(runtime);
253
+ payload.setProperty(runtime, "text", event.text);
254
+ payload.setProperty(runtime, "markdown", event.markdown);
255
+ return payload;
256
+ });
257
+ }
258
+
259
+
250
260
  void EnrichedMarkdownTextInputEventEmitter::onStartMention(OnStartMention event) const {
251
261
  dispatchEvent("startMention", [event=std::move(event)](jsi::Runtime &runtime) {
252
262
  auto payload = jsi::Object(runtime);
@@ -182,6 +182,11 @@ class EnrichedMarkdownTextInputEventEmitter : public ViewEventEmitter {
182
182
  double height;
183
183
  };
184
184
 
185
+ struct OnPaste {
186
+ std::string text;
187
+ std::string markdown;
188
+ };
189
+
185
190
  struct OnStartMention {
186
191
  std::string indicator;
187
192
  };
@@ -218,6 +223,8 @@ class EnrichedMarkdownTextInputEventEmitter : public ViewEventEmitter {
218
223
 
219
224
  void onMathPress(OnMathPress value) const;
220
225
 
226
+ void onPaste(OnPaste value) const;
227
+
221
228
  void onStartMention(OnStartMention value) const;
222
229
 
223
230
  void onChangeMention(OnChangeMention value) const;
@@ -146,6 +146,7 @@ EnrichedMarkdownTextInputProps::EnrichedMarkdownTextInputProps(
146
146
  scrollEnabled(convertRawProp(context, rawProps, "scrollEnabled", sourceProps.scrollEnabled, {false})),
147
147
  blockSyntax(convertRawProp(context, rawProps, "blockSyntax", sourceProps.blockSyntax, {false})),
148
148
  autoCapitalize(convertRawProp(context, rawProps, "autoCapitalize", sourceProps.autoCapitalize, {})),
149
+ interceptPaste(convertRawProp(context, rawProps, "interceptPaste", sourceProps.interceptPaste, {false})),
149
150
  multiline(convertRawProp(context, rawProps, "multiline", sourceProps.multiline, {false})),
150
151
  cursorColor(convertRawProp(context, rawProps, "cursorColor", sourceProps.cursorColor, {})),
151
152
  selectionColor(convertRawProp(context, rawProps, "selectionColor", sourceProps.selectionColor, {})),
@@ -211,6 +212,10 @@ folly::dynamic EnrichedMarkdownTextInputProps::getDiffProps(
211
212
  result["autoCapitalize"] = autoCapitalize;
212
213
  }
213
214
 
215
+ if (interceptPaste != oldProps->interceptPaste) {
216
+ result["interceptPaste"] = interceptPaste;
217
+ }
218
+
214
219
  if (multiline != oldProps->multiline) {
215
220
  result["multiline"] = multiline;
216
221
  }
@@ -3361,6 +3361,7 @@ class EnrichedMarkdownTextInputProps final : public ViewProps {
3361
3361
  bool scrollEnabled{false};
3362
3362
  bool blockSyntax{false};
3363
3363
  std::string autoCapitalize{};
3364
+ bool interceptPaste{false};
3364
3365
  bool multiline{false};
3365
3366
  SharedColor cursorColor{};
3366
3367
  SharedColor selectionColor{};
@@ -36,6 +36,7 @@ NS_ASSUME_NONNULL_BEGIN
36
36
  - (void)updateMath:(NSInteger)start latex:(NSString *)latex displayMode:(BOOL)displayMode;
37
37
  - (void)removeMath:(NSInteger)start;
38
38
  - (void)copyToClipboard;
39
+ - (void)resolvePaste:(NSString *)text markdown:(NSString *)markdown;
39
40
  - (void)requestMarkdown:(NSInteger)requestId;
40
41
  - (void)requestCaretRect:(NSInteger)requestId;
41
42
  @end
@@ -399,6 +400,34 @@ if ([commandName isEqualToString:@"copyToClipboard"]) {
399
400
  return;
400
401
  }
401
402
 
403
+ if ([commandName isEqualToString:@"resolvePaste"]) {
404
+ #if RCT_DEBUG
405
+ if ([args count] != 2) {
406
+ RCTLogError(@"%@ command %@ received %d arguments, expected %d.", @"EnrichedMarkdownTextInput", commandName, (int)[args count], 2);
407
+ return;
408
+ }
409
+ #endif
410
+
411
+ NSObject *arg0 = args[0];
412
+ #if RCT_DEBUG
413
+ if (!RCTValidateTypeOfViewCommandArgument(arg0, [NSString class], @"string", @"EnrichedMarkdownTextInput", commandName, @"1st")) {
414
+ return;
415
+ }
416
+ #endif
417
+ NSString * text = (NSString *)arg0;
418
+
419
+ NSObject *arg1 = args[1];
420
+ #if RCT_DEBUG
421
+ if (!RCTValidateTypeOfViewCommandArgument(arg1, [NSString class], @"string", @"EnrichedMarkdownTextInput", commandName, @"2nd")) {
422
+ return;
423
+ }
424
+ #endif
425
+ NSString * markdown = (NSString *)arg1;
426
+
427
+ [componentView resolvePaste:text markdown:markdown];
428
+ return;
429
+ }
430
+
402
431
  if ([commandName isEqualToString:@"requestMarkdown"]) {
403
432
  #if RCT_DEBUG
404
433
  if ([args count] != 1) {
@@ -46,12 +46,22 @@ NSString *const kENRMMarkdownPasteboardType = @"com.swmansion.enriched-markdown.
46
46
  markdown = [[NSString alloc] initWithData:markdownValue encoding:NSUTF8StringEncoding];
47
47
  }
48
48
 
49
+ NSString *plainText = pasteboard.string;
50
+
51
+ // Both flavors are read before anything is decided, then nothing is written:
52
+ // JS owns the paste from here and must call resolvePaste. Mutating the buffer
53
+ // first would mean a handler that dies mid-flight leaves the selection gone
54
+ // with no text to show for it — a no-op paste is the visible failure.
55
+ if (self.markdownTextInput.interceptPaste && (plainText.length > 0 || markdown.length > 0)) {
56
+ [self.markdownTextInput emitOnPasteWithText:(plainText ?: @"") markdown:(markdown ?: @"")];
57
+ return;
58
+ }
59
+
49
60
  if (markdown.length > 0 && self.markdownTextInput != nil) {
50
61
  [self.markdownTextInput pasteMarkdown:markdown];
51
62
  return;
52
63
  }
53
64
 
54
- NSString *plainText = pasteboard.string;
55
65
  if (plainText.length > 0) {
56
66
  [self replaceRange:self.selectedTextRange withText:plainText];
57
67
  }
@@ -8,12 +8,17 @@ NS_ASSUME_NONNULL_BEGIN
8
8
 
9
9
  @interface EnrichedMarkdownTextInput : RCTViewComponentView
10
10
  @property (nonatomic, assign) BOOL blockEmitting;
11
+ @property (nonatomic, assign) BOOL interceptPaste;
11
12
  - (CGSize)measureSize:(CGFloat)maxWidth;
12
13
  - (nullable NSString *)markdownForSelectedRange;
13
14
  - (void)copyToClipboard;
14
15
  - (void)pasteMarkdown:(NSString *)markdown;
15
16
  - (void)replaceSelectedTextWith:(NSString *)text formattingRanges:(NSArray *)ranges;
16
17
  - (void)scheduleRelayoutIfNeeded;
18
+ // Paste interception. Declared here rather than in +Internal.h because
19
+ // ENRMInputTextView.mm only imports that header on macOS.
20
+ - (void)emitOnPasteWithText:(NSString *)text markdown:(NSString *)markdown;
21
+ - (void)resolvePaste:(NSString *)text markdown:(NSString *)markdown;
17
22
  @end
18
23
 
19
24
  NS_ASSUME_NONNULL_END