@apollohg/react-native-rich-text-editor 2.0.2 → 2.0.3

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 (43) hide show
  1. package/README.md +21 -7
  2. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +20 -6
  3. package/android/src/main/java/com/apollohg/editor/EditorEditTextClipboard.kt +181 -36
  4. package/android/src/main/java/com/apollohg/editor/EditorEditTextSelection.kt +7 -0
  5. package/android/src/main/java/com/apollohg/editor/EditorTextSurfaceInteraction.kt +31 -11
  6. package/android/src/main/java/com/apollohg/editor/EditorV2Adapter.kt +36 -0
  7. package/android/src/main/java/com/apollohg/editor/EditorV2Backend.kt +1 -0
  8. package/android/src/main/java/com/apollohg/editor/EditorV2Driver.kt +10 -0
  9. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +2 -0
  10. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoViewProperties.kt +4 -0
  11. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +3 -0
  12. package/android/src/main/java/com/apollohg/editor/UniffiEditorV2Backend.kt +4 -0
  13. package/dist/NativeRichTextEditor.d.ts +1 -1
  14. package/dist/RichTextEditorNativeTypes.d.ts +2 -1
  15. package/dist/RichTextEditorTypes.d.ts +4 -0
  16. package/dist/index.d.ts +1 -1
  17. package/dist/useRichTextEditorPresentation.d.ts +1 -1
  18. package/dist/useRichTextEditorPresentation.js +2 -2
  19. package/dist/useRichTextEditorState.d.ts +5 -3
  20. package/dist/useRichTextEditorState.js +4 -3
  21. package/ios/EditorCore.xcframework/Info.plist +5 -5
  22. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  23. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  24. package/ios/EditorTextView+KeyboardInput.swift +299 -0
  25. package/ios/EditorTextView.swift +63 -43
  26. package/ios/EditorV2Adapter+Commands.swift +22 -0
  27. package/ios/EditorV2Shadow.swift +12 -0
  28. package/ios/Generated_editor_core.swift +10 -0
  29. package/ios/NativeEditorExpoView+Properties.swift +4 -0
  30. package/ios/NativeEditorModule.swift +3 -0
  31. package/ios/editor_coreFFI/editor_coreFFI.h +11 -0
  32. package/package.json +1 -1
  33. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  34. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  35. package/rust/android/x86/libeditor_core.so +0 -0
  36. package/rust/android/x86_64/libeditor_core.so +0 -0
  37. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +18 -0
  38. package/src/NativeRichTextEditor.tsx +1 -0
  39. package/src/RichTextEditorNativeTypes.tsx +2 -0
  40. package/src/RichTextEditorTypes.tsx +5 -0
  41. package/src/index.ts +1 -0
  42. package/src/useRichTextEditorPresentation.tsx +3 -0
  43. package/src/useRichTextEditorState.tsx +5 -2
package/README.md CHANGED
@@ -8,13 +8,15 @@ See the [documentation](https://github.com/apollohg/react-native-rich-text-edito
8
8
 
9
9
  ## Highlights
10
10
 
11
- - Native iOS and Android editing backed by a Rust document engine
12
- - HTML and ProseMirror JSON input and output
13
- - Configurable schemas, marks, blockquotes, lists, links, images, and mentions
14
- - Custom atom nodes rendered with your React components
15
- - Native toolbar, theming, selection, undo, and redo
16
- - `RichTextViewer`, an exact-size Fabric renderer for read-only content
17
- - Shared document handles for local editing and Yjs collaboration
11
+ **⭐ Native editing on iOS and Android**
12
+ **⭐ Built-in Yjs collaboration**
13
+ **⭐ React Native components inside documents**
14
+ **⭐ External text composition**
15
+ **⭐ Dedicated Fabric viewer**
16
+ **⭐ Configurable document schemas** with HTML and ProseMirror JSON import/export
17
+ **⭐ Nested bullet and numbered lists**
18
+ **⭐ Optional native code syntax highlighting**
19
+ **⭐ Rich copy and paste**
18
20
 
19
21
  ## Requirements
20
22
 
@@ -87,6 +89,18 @@ export function EditorScreen() {
87
89
 
88
90
  See [Getting Started](https://github.com/apollohg/react-native-rich-text-editor/wiki/Getting-Started) and the [RichTextEditor reference](https://github.com/apollohg/react-native-rich-text-editor/wiki/RichTextEditor-Reference) for the complete API.
89
91
 
92
+ ## Copy and paste
93
+
94
+ Copy and cut publish an editor fragment, HTML, and plain text. Pasting between compatible editor schemas preserves selected marks, nodes, and atom attributes. External rich text imports supported HTML formatting and structure, with RTF conversion on iOS.
95
+
96
+ Use `pasteMode` to control native clipboard paste:
97
+
98
+ ```tsx
99
+ <RichTextEditor documentHandle={documentHandle} pasteMode="plainText" />
100
+ ```
101
+
102
+ The default is `"rich"`. `"plainText"` removes source formatting and atoms, while retaining the destination's ordinary typing marks. `"disabled"` blocks paste without disabling copy or typing. Changes apply immediately. These settings govern clipboard actions; programmatic insertion and drag/drop retain their own behaviour. Unsupported external formatting is reduced to supported content, and clipboard images do not trigger uploads.
103
+
90
104
  ## Custom atom nodes
91
105
 
92
106
  Render interactive cards, embeds, and other custom blocks with your own React components.
@@ -133,6 +133,8 @@ class EditorEditText @JvmOverloads constructor(
133
133
  }
134
134
  }
135
135
 
136
+ internal var pasteMode: EditorPasteMode = EditorPasteMode.RICH
137
+
136
138
  /**
137
139
  * Guard flag to prevent re-entrant input interception while we're
138
140
  * applying state from Rust (calling [setText] or modifying text storage).
@@ -245,6 +247,7 @@ class EditorEditText @JvmOverloads constructor(
245
247
  internal var currentRenderBlocksNeedFullApply = false
246
248
  internal var authorizedVisibleTextNeedsRebuild = false
247
249
  internal var logicalSelectionSnapshot: LogicalSelectionSnapshot? = null
250
+ internal var authoritativeNodeSelectionRange: ImageSelectionRange? = null
248
251
  internal var lastAllowedAtomCaretSelection: Pair<Int, Int>? = null
249
252
  private var localTextDrag: LocalTextDrag? = null
250
253
  internal var lastAppliedDocumentVersion: String? = null
@@ -275,6 +278,7 @@ class EditorEditText @JvmOverloads constructor(
275
278
  internal var onSetSelectionScalarInRustForTesting: ((Int, Int) -> Unit)? = null
276
279
  internal var onDeleteAndSplitScalarInRustForTesting: ((Int, Int) -> Unit)? = null
277
280
  internal var onInsertContentHtmlInRustForTesting: ((String) -> Unit)? = null
281
+ internal var onSetPrimaryClipForTesting: ((android.content.ClipData) -> Unit)? = null
278
282
  internal var onResizeImageAtDocPosForTesting: ((Int, Int, Int) -> Unit)? = null
279
283
  internal var onMoveSelectionScalarForTesting: ((Int, Int, Int) -> Unit)? = null
280
284
  internal var onBeforeRenderRefresh: (() -> Unit)? = null
@@ -788,14 +792,18 @@ class EditorEditText @JvmOverloads constructor(
788
792
 
789
793
  fun performToolbarRedo() = performToolbarRedoImpl()
790
794
 
791
- /**
792
- * Intercept paste operations to route content through Rust.
793
- *
794
- * Attempts to extract HTML from the clipboard first (for rich text paste),
795
- * falling back to plain text.
796
- */
795
+ /** Routes native clipboard actions through the authoritative Rust selection. */
797
796
  override fun onTextContextMenuItem(id: Int): Boolean {
797
+ if (id == android.R.id.copy) {
798
+ handleCopy()
799
+ return true
800
+ }
798
801
  if (!isEditable && isMutatingContextMenuItem(id)) return true
802
+ if (pasteMode == EditorPasteMode.DISABLED &&
803
+ (id == android.R.id.paste || id == android.R.id.pasteAsPlainText)
804
+ ) {
805
+ return true
806
+ }
799
807
  if (id == android.R.id.cut) {
800
808
  handleCut()
801
809
  return true
@@ -812,6 +820,11 @@ class EditorEditText @JvmOverloads constructor(
812
820
  * Selection and copy actions remain available.
813
821
  */
814
822
  override fun performAccessibilityAction(action: Int, arguments: android.os.Bundle?): Boolean {
823
+ if (pasteMode == EditorPasteMode.DISABLED &&
824
+ action == android.view.accessibility.AccessibilityNodeInfo.ACTION_PASTE
825
+ ) {
826
+ return false
827
+ }
815
828
  if (!isEditable && (
816
829
  action == android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT ||
817
830
  action == android.view.accessibility.AccessibilityNodeInfo.ACTION_PASTE ||
@@ -842,6 +855,7 @@ class EditorEditText @JvmOverloads constructor(
842
855
  }
843
856
  ensureSelectionVisible()
844
857
  if (isApplyingRustState) return
858
+ authoritativeNodeSelectionRange = null
845
859
  val wasExternallyComposing = externalTextComposition != null
846
860
  if (!commitExternalTextCompositionBeforeInteractionIfNeeded()) return
847
861
  if (wasExternallyComposing) {
@@ -1,9 +1,94 @@
1
1
  package com.apollohg.editor
2
2
 
3
3
  import android.content.ClipData
4
+ import android.content.ClipDescription
4
5
  import android.content.ClipboardManager
5
6
  import android.content.Context
6
- import android.view.accessibility.AccessibilityNodeInfo
7
+ import android.os.PersistableBundle
8
+ import org.json.JSONObject
9
+
10
+ internal enum class EditorPasteMode {
11
+ RICH,
12
+ PLAIN_TEXT,
13
+ DISABLED;
14
+
15
+ companion object {
16
+ fun fromRaw(value: String?): EditorPasteMode = when (value) {
17
+ "plainText" -> PLAIN_TEXT
18
+ "disabled" -> DISABLED
19
+ else -> RICH
20
+ }
21
+ }
22
+ }
23
+
24
+ internal data class EditorClipboardPayload(
25
+ val fragment: String? = null,
26
+ val html: String? = null,
27
+ val text: String? = null
28
+ )
29
+
30
+ internal object EditorClipboard {
31
+ const val MIME_TYPE_FRAGMENT = "application/vnd.apollohg.native-editor.fragment+json"
32
+ const val EXTRA_FRAGMENT = "com.apollohg.editor.clipboard.FRAGMENT"
33
+
34
+ fun fromExportJson(json: String): EditorClipboardPayload? = try {
35
+ val value = JSONObject(json)
36
+ if (value.optBoolean("empty", false)) {
37
+ null
38
+ } else {
39
+ val fragment = value.optString("fragment").takeIf { it.isNotEmpty() }
40
+ val html = value.optString("html").takeIf { it.isNotEmpty() }
41
+ val text = value.optString("text").takeIf { it.isNotEmpty() }
42
+ if (fragment == null && html == null && text == null) {
43
+ null
44
+ } else {
45
+ EditorClipboardPayload(fragment, html, text)
46
+ }
47
+ }
48
+ } catch (_: Exception) {
49
+ null
50
+ }
51
+
52
+ fun create(payload: EditorClipboardPayload): ClipData {
53
+ val mimeTypes = buildList {
54
+ if (payload.html != null) add(ClipDescription.MIMETYPE_TEXT_HTML)
55
+ add(ClipDescription.MIMETYPE_TEXT_PLAIN)
56
+ if (payload.fragment != null) add(MIME_TYPE_FRAGMENT)
57
+ }.distinct().toTypedArray()
58
+ val description = ClipDescription("rich text", mimeTypes)
59
+ payload.fragment?.let { fragment ->
60
+ description.extras = PersistableBundle().apply {
61
+ putString(EXTRA_FRAGMENT, fragment)
62
+ }
63
+ }
64
+ return ClipData(
65
+ description,
66
+ ClipData.Item(payload.text ?: "", payload.html, null, null)
67
+ )
68
+ }
69
+
70
+ fun read(clip: ClipData, context: Context): EditorClipboardPayload {
71
+ if (clip.itemCount == 0) return EditorClipboardPayload()
72
+ val item = clip.getItemAt(0)
73
+ val hasPrivateMime = (0 until clip.description.mimeTypeCount).any {
74
+ clip.description.getMimeType(it) == MIME_TYPE_FRAGMENT
75
+ }
76
+ val fragment = if (hasPrivateMime) {
77
+ runCatching {
78
+ clip.description.extras?.getString(EXTRA_FRAGMENT)
79
+ }.getOrNull()
80
+ } else {
81
+ null
82
+ }
83
+ return EditorClipboardPayload(
84
+ fragment = fragment,
85
+ html = item.htmlText,
86
+ text = item.text?.toString() ?: runCatching {
87
+ item.coerceToText(context)?.toString()
88
+ }.getOrNull()
89
+ )
90
+ }
91
+ }
7
92
 
8
93
  internal fun EditorEditText.isMutatingContextMenuItem(id: Int): Boolean =
9
94
  id == android.R.id.paste ||
@@ -14,16 +99,37 @@ internal fun EditorEditText.prepareForExternalInteractionMutation(): Boolean =
14
99
  commitExternalTextCompositionBeforeInteractionIfNeeded() &&
15
100
  prepareForExternalEditorUpdate()
16
101
 
17
- internal fun EditorEditText.handlePaste(plainTextOnly: Boolean) {
18
- val selectionRange = normalizedUtf16SelectionRange()
19
- if (selectionRange != null &&
20
- isCollapsedAtomBoundarySelection(selectionRange.first, selectionRange.second)
21
- ) {
22
- return
102
+ internal fun EditorEditText.publishClipboard(payload: EditorClipboardPayload): Boolean {
103
+ return try {
104
+ val clip = EditorClipboard.create(payload)
105
+ val testWriter = onSetPrimaryClipForTesting
106
+ if (testWriter != null) {
107
+ testWriter(clip)
108
+ } else {
109
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
110
+ ?: return false
111
+ clipboard.setPrimaryClip(clip)
112
+ }
113
+ true
114
+ } catch (_: Exception) {
115
+ false
23
116
  }
117
+ }
118
+
119
+ internal fun EditorEditText.handleCopy(): Boolean {
120
+ if (editorId == 0L || v2Driver == null) return baseTextContextMenuItem(android.R.id.copy)
121
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return false
122
+ if (!prepareForExternalInteractionMutation()) return false
123
+ syncCurrentSelectionToRust()
124
+ val payload = v2Driver?.clipboardJson()?.let(EditorClipboard::fromExportJson) ?: return false
125
+ return publishClipboard(payload)
126
+ }
127
+
128
+ internal fun EditorEditText.handlePaste(plainTextOnly: Boolean) {
129
+ val forcePlainText = plainTextOnly || pasteMode == EditorPasteMode.PLAIN_TEXT
24
130
  if (editorId == 0L) {
25
131
  baseTextContextMenuItem(
26
- if (plainTextOnly) android.R.id.pasteAsPlainText else android.R.id.paste
132
+ if (forcePlainText) android.R.id.pasteAsPlainText else android.R.id.paste
27
133
  )
28
134
  return
29
135
  }
@@ -32,21 +138,40 @@ internal fun EditorEditText.handlePaste(plainTextOnly: Boolean) {
32
138
 
33
139
  val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
34
140
  ?: return
35
- val clip = clipboard.primaryClip ?: return
36
- if (clip.itemCount == 0) return
37
-
38
- val item = clip.getItemAt(0)
141
+ val clip = runCatching { clipboard.primaryClip }.getOrNull() ?: return
142
+ val payload = EditorClipboard.read(clip, context)
143
+ if (payload.fragment == null && payload.html == null && payload.text.isNullOrEmpty()) return
39
144
 
40
- val htmlText = item.htmlText
41
- if (!plainTextOnly && htmlText != null) {
42
- pasteHTML(htmlText)
145
+ if (payload.fragment == null && payload.html == null &&
146
+ (onInsertTextInRustForTesting != null || onReplaceTextInRustForTesting != null)
147
+ ) {
148
+ payload.text?.let(::pastePlainText)
43
149
  return
44
150
  }
45
-
46
- val plainText = item.text?.toString() ?: item.coerceToText(context)?.toString()
47
- if (plainText != null) {
48
- pastePlainText(plainText)
151
+ val driver = v2Driver
152
+ if (driver == null) {
153
+ if (forcePlainText) {
154
+ payload.text?.let(::pastePlainText)
155
+ } else if (payload.html != null) {
156
+ pasteHTML(payload.html)
157
+ } else {
158
+ payload.text?.let(::pastePlainText)
159
+ }
160
+ return
49
161
  }
162
+ val (anchor, head) = currentScalarSelection() ?: return
163
+ val preserveEngineSelection = authoritativeNodeSelectionRange?.let { range ->
164
+ selectionStart == range.start && selectionEnd == range.end
165
+ } == true
166
+ driver.pasteAtSelection(
167
+ fragment = payload.fragment,
168
+ html = payload.html,
169
+ text = payload.text,
170
+ plainText = forcePlainText,
171
+ anchor = anchor,
172
+ head = head,
173
+ preserveEngineSelection = preserveEngineSelection
174
+ )?.let { applyUpdateJSON(it) }
50
175
  }
51
176
 
52
177
  internal fun EditorEditText.handleCut() {
@@ -56,25 +181,45 @@ internal fun EditorEditText.handleCut() {
56
181
  }
57
182
  if (discardTransientInputForDestroyedEditorIfNeeded()) return
58
183
  if (!prepareForExternalInteractionMutation()) return
184
+ if (v2Driver == null) {
185
+ val currentText = text?.toString() ?: return
186
+ val (selectionStart, selectionEnd) = normalizedUtf16SelectionRange(currentText) ?: return
187
+ val (utf16Start, utf16End) = PositionBridge.snapRangeToScalarBoundaries(
188
+ selectionStart,
189
+ selectionEnd,
190
+ currentText
191
+ )
192
+ if (utf16Start >= utf16End) return
193
+ val selectedText = currentText.substring(utf16Start, utf16End)
194
+ if (!publishClipboard(EditorClipboardPayload(text = selectedText))) return
195
+ deleteRangeInRust(
196
+ PositionBridge.utf16ToScalar(utf16Start, currentText),
197
+ PositionBridge.utf16ToScalar(utf16End, currentText)
198
+ )
199
+ return
200
+ }
201
+ syncCurrentSelectionToRust()
59
202
 
60
- val currentText = text?.toString() ?: return
61
- val (selectionStart, selectionEnd) = normalizedUtf16SelectionRange(currentText) ?: return
62
- if (selectionStart == selectionEnd) return
63
-
64
- val (utf16Start, utf16End) = PositionBridge.snapRangeToScalarBoundaries(
65
- selectionStart,
66
- selectionEnd,
67
- currentText
68
- )
69
- if (utf16Start >= utf16End) return
70
-
71
- val selectedText = currentText.substring(utf16Start, utf16End)
72
- val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
73
- clipboard?.setPrimaryClip(ClipData.newPlainText(null, selectedText))
203
+ val payload = v2Driver?.clipboardJson()?.let(EditorClipboard::fromExportJson) ?: return
204
+ if (!publishClipboard(payload)) return
74
205
 
75
- val scalarStart = PositionBridge.utf16ToScalar(utf16Start, currentText)
76
- val scalarEnd = PositionBridge.utf16ToScalar(utf16End, currentText)
77
- deleteRangeInRust(scalarStart, scalarEnd)
206
+ val (anchor, head) = currentScalarSelection() ?: return
207
+ if (onDeleteRangeInRustForTesting != null) {
208
+ deleteRangeInRust(minOf(anchor, head), maxOf(anchor, head))
209
+ return
210
+ }
211
+ val preserveEngineSelection = authoritativeNodeSelectionRange?.let { range ->
212
+ selectionStart == range.start && selectionEnd == range.end
213
+ } == true
214
+ v2Driver?.pasteAtSelection(
215
+ fragment = null,
216
+ html = null,
217
+ text = "",
218
+ plainText = true,
219
+ anchor = anchor,
220
+ head = head,
221
+ preserveEngineSelection = preserveEngineSelection
222
+ )?.let { applyUpdateJSON(it) }
78
223
  }
79
224
 
80
225
  internal fun EditorEditText.handleAccessibilitySetText(arguments: android.os.Bundle?): Boolean {
@@ -100,6 +100,10 @@ internal fun EditorEditText.canonicalListCaretOffset(selStart: Int, selEnd: Int)
100
100
 
101
101
  internal fun EditorEditText.syncCurrentSelectionToRust() {
102
102
  if (!hasLiveEditor()) return
103
+ authoritativeNodeSelectionRange?.let { range ->
104
+ if (selectionStart == range.start && selectionEnd == range.end) return
105
+ authoritativeNodeSelectionRange = null
106
+ }
103
107
 
104
108
  val currentText = text?.toString() ?: ""
105
109
  if (currentText != lastAuthorizedText) return
@@ -296,6 +300,7 @@ internal fun EditorEditText.applySelectionFromJSON(
296
300
  val currentText = text?.toString() ?: ""
297
301
  when (type) {
298
302
  "text" -> {
303
+ authoritativeNodeSelectionRange = null
299
304
  val docAnchor = exactV2ScalarInt(selection.opt("anchor") as? Number) ?: return
300
305
  val docHead = exactV2ScalarInt(selection.opt("head") as? Number) ?: return
301
306
  // The frozen v2 update includes exact scalar positions alongside its
@@ -339,10 +344,12 @@ internal fun EditorEditText.applySelectionFromJSON(
339
344
  val clamped = startUtf16.coerceIn(0, len)
340
345
  val endClamped = (clamped + 1).coerceAtMost(len)
341
346
  setSelection(clamped, endClamped)
347
+ authoritativeNodeSelectionRange = ImageSelectionRange(clamped, endClamped)
342
348
  }
343
349
 
344
350
  "all" -> {
345
351
  logicalSelectionSnapshot = null
352
+ authoritativeNodeSelectionRange = null
346
353
  selectAll()
347
354
  }
348
355
  }
@@ -391,21 +391,30 @@ internal class EditorTextSurfaceInteraction(
391
391
  0,
392
392
  android.R.string.copy
393
393
  ).setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
394
- if ((view as? EditorEditText)?.isEditable != false) {
394
+ val editor = view as? EditorEditText
395
+ if (editor?.isEditable != false) {
395
396
  menu.add(
396
397
  0,
397
398
  android.R.id.cut,
398
399
  1,
399
400
  android.R.string.cut
400
401
  ).setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
401
- menu.add(
402
- 0,
403
- android.R.id.paste,
404
- 2,
405
- android.R.string.paste
406
- ).setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
402
+ if (editor?.pasteMode != EditorPasteMode.DISABLED) {
403
+ menu.add(
404
+ 0,
405
+ android.R.id.paste,
406
+ 2,
407
+ android.R.string.paste
408
+ ).setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
409
+ menu.add(
410
+ 0,
411
+ android.R.id.pasteAsPlainText,
412
+ 3,
413
+ android.R.string.paste_as_plain_text
414
+ )
415
+ }
407
416
  }
408
- menu.add(0, android.R.id.selectAll, 3, android.R.string.selectAll)
417
+ menu.add(0, android.R.id.selectAll, 4, android.R.string.selectAll)
409
418
  return true
410
419
  }
411
420
  override fun onPrepareActionMode(mode: ActionMode, menu: Menu) = false
@@ -457,12 +466,20 @@ internal class EditorTextSurfaceInteraction(
457
466
  }
458
467
 
459
468
  fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
460
- if (event.isCtrlPressed) {
469
+ if (event.isCtrlPressed || event.isMetaPressed) {
461
470
  val action = when (keyCode) {
462
471
  KeyEvent.KEYCODE_A -> android.R.id.selectAll
472
+
463
473
  KeyEvent.KEYCODE_C -> android.R.id.copy
474
+
464
475
  KeyEvent.KEYCODE_X -> android.R.id.cut
465
- KeyEvent.KEYCODE_V -> android.R.id.paste
476
+
477
+ KeyEvent.KEYCODE_V -> if (event.isShiftPressed) {
478
+ android.R.id.pasteAsPlainText
479
+ } else {
480
+ android.R.id.paste
481
+ }
482
+
466
483
  else -> 0
467
484
  }
468
485
  if (action != 0) return view.onTextContextMenuItem(action)
@@ -559,10 +576,13 @@ internal class EditorTextSurfaceInteraction(
559
576
  info.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY)
560
577
  info.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY)
561
578
  info.addAction(AccessibilityNodeInfo.ACTION_COPY)
579
+ val editor = view as? EditorEditText
562
580
  if (info.isEditable) {
563
581
  info.addAction(AccessibilityNodeInfo.ACTION_SET_TEXT)
564
582
  info.addAction(AccessibilityNodeInfo.ACTION_CUT)
565
- info.addAction(AccessibilityNodeInfo.ACTION_PASTE)
583
+ if (editor?.pasteMode != EditorPasteMode.DISABLED) {
584
+ info.addAction(AccessibilityNodeInfo.ACTION_PASTE)
585
+ }
566
586
  }
567
587
  }
568
588
 
@@ -796,6 +796,42 @@ internal class EditorV2Adapter private constructor(
796
796
  )
797
797
  }
798
798
 
799
+ override fun clipboardJson(): String? {
800
+ if (destroyed) return null
801
+ return when (val result = backend.getClipboard(editorId)) {
802
+ is EditorV2CallResult.Ok -> result.value
803
+
804
+ is EditorV2CallResult.Err -> {
805
+ emit(result.error)
806
+ null
807
+ }
808
+ }
809
+ }
810
+
811
+ override fun pasteAtSelection(
812
+ fragment: String?,
813
+ html: String?,
814
+ text: String?,
815
+ plainText: Boolean,
816
+ anchor: Int,
817
+ head: Int,
818
+ preserveEngineSelection: Boolean
819
+ ): String? {
820
+ val command = JSONObject().put("type", "paste")
821
+ fragment?.let { command.put("fragment", it) }
822
+ html?.let { command.put("html", it) }
823
+ text?.let { command.put("text", it) }
824
+ if (plainText) command.put("plainText", true)
825
+ return performMutation(
826
+ preSelection = if (preserveEngineSelection) null else intArrayOf(anchor, head),
827
+ adoptEngineSelection = true
828
+ ) {
829
+ callWithEnvelope(JSONObject().put("command", command)) { requestJson ->
830
+ backend.applyCommand(editorId, requestJson)
831
+ }
832
+ }
833
+ }
834
+
799
835
  override fun toggleMark(markName: String, anchor: Int, head: Int): String? = commandAtSelection(
800
836
  JSONObject().put("type", "toggleMark").put("markType", markName),
801
837
  anchor,
@@ -37,6 +37,7 @@ internal interface EditorV2Backend {
37
37
  fun getDocumentJson(editorId: String): EditorV2CallResult<String>
38
38
  fun getDocumentHtml(editorId: String): EditorV2CallResult<String>
39
39
  fun getContentSnapshot(editorId: String): EditorV2CallResult<String>
40
+ fun getClipboard(editorId: String): EditorV2CallResult<String>
40
41
  fun applyInput(editorId: String, requestJson: String): EditorV2CallResult<String>
41
42
  fun applyCommand(editorId: String, requestJson: String): EditorV2CallResult<String>
42
43
  fun applyLocalApi(editorId: String, requestJson: String): EditorV2CallResult<String>
@@ -50,6 +50,16 @@ internal interface EditorV2Driver {
50
50
  fun insertNode(nodeType: String, anchor: Int, head: Int): String?
51
51
  fun insertContentHtmlAtSelection(html: String, anchor: Int, head: Int): String?
52
52
  fun insertContentJsonAtSelection(json: String, anchor: Int, head: Int): String?
53
+ fun clipboardJson(): String?
54
+ fun pasteAtSelection(
55
+ fragment: String?,
56
+ html: String?,
57
+ text: String?,
58
+ plainText: Boolean,
59
+ anchor: Int,
60
+ head: Int,
61
+ preserveEngineSelection: Boolean = false
62
+ ): String?
53
63
  fun toggleMark(markName: String, anchor: Int, head: Int): String?
54
64
  fun setMark(markName: String, attrsJson: String, anchor: Int, head: Int): String?
55
65
  fun unsetMark(markName: String, anchor: Int, head: Int): String?
@@ -388,6 +388,8 @@ class NativeEditorExpoView(context: Context, appContext: AppContext) :
388
388
 
389
389
  fun setEditable(editable: Boolean) = setEditableImpl(editable)
390
390
 
391
+ fun setPasteMode(rawPasteMode: String?) = setPasteModeImpl(rawPasteMode)
392
+
391
393
  fun beginExternalTextComposition(sessionId: String): String =
392
394
  beginExternalTextCompositionImpl(sessionId)
393
395
 
@@ -140,6 +140,10 @@ internal fun NativeEditorExpoView.setEditableImpl(editable: Boolean) {
140
140
  updateKeyboardToolbarVisibility()
141
141
  }
142
142
 
143
+ internal fun NativeEditorExpoView.setPasteModeImpl(rawPasteMode: String?) {
144
+ richTextView.editorEditText.pasteMode = EditorPasteMode.fromRaw(rawPasteMode)
145
+ }
146
+
143
147
  internal fun NativeEditorExpoView.setAccessibilityLabelImpl(label: String?) {
144
148
  richTextView.editorEditText.contentDescription = label
145
149
  }
@@ -520,6 +520,9 @@ class NativeEditorModule : Module() {
520
520
  Prop("editable") { view: NativeEditorExpoView, editable: Boolean ->
521
521
  view.setEditable(editable)
522
522
  }
523
+ Prop("pasteMode") { view: NativeEditorExpoView, pasteMode: String? ->
524
+ view.setPasteMode(pasteMode)
525
+ }
523
526
  Prop("accessibilityLabel") { view: NativeEditorExpoView, label: String? ->
524
527
  view.setAccessibilityLabel(label)
525
528
  }
@@ -20,6 +20,7 @@ import uniffi.editor_core.editorV2CollaborationSocketOpen
20
20
  import uniffi.editor_core.editorV2Create
21
21
  import uniffi.editor_core.editorV2Destroy
22
22
  import uniffi.editor_core.editorV2DocToScalar
23
+ import uniffi.editor_core.editorV2GetClipboard
23
24
  import uniffi.editor_core.editorV2GetContentSnapshot
24
25
  import uniffi.editor_core.editorV2GetDocumentHtml
25
26
  import uniffi.editor_core.editorV2GetDocumentJson
@@ -77,6 +78,9 @@ internal object UniffiEditorV2Backend : EditorV2Backend {
77
78
  override fun getContentSnapshot(editorId: String): EditorV2CallResult<String> =
78
79
  normalize(editorV2GetContentSnapshot(editorId))
79
80
 
81
+ override fun getClipboard(editorId: String): EditorV2CallResult<String> =
82
+ normalize(editorV2GetClipboard(editorId))
83
+
80
84
  override fun applyInput(editorId: String, requestJson: String): EditorV2CallResult<String> =
81
85
  normalize(editorV2ApplyInput(editorId, requestJson))
82
86
 
@@ -1,3 +1,3 @@
1
- export { type RichTextEditorHeightBehavior, type RichTextEditorToolbarPlacement, type RichTextEditorValueJSONUpdateMode, type RichTextEditorAutoCapitalize, type RichTextEditorKeyboardType, type RichTextEditorAndroidInputOptions, type RemoteSelectionDecoration, type LinkRequestContext, type ImageRequestContext, type RichTextEditorProps, type RichTextEditorRef, type RichTextEditorCaretRect, type NativeRichTextEditorHeightBehavior, type NativeRichTextEditorToolbarPlacement, type NativeRichTextEditorValueJSONUpdateMode, type NativeRichTextEditorAutoCapitalize, type NativeRichTextEditorKeyboardType, type NativeRichTextEditorAndroidInputOptions, type NativeRichTextEditorProps, type NativeRichTextEditorRef, type NativeRichTextEditorCaretRect, } from './RichTextEditorTypes';
1
+ export { type RichTextEditorHeightBehavior, type RichTextEditorToolbarPlacement, type RichTextEditorValueJSONUpdateMode, type RichTextEditorPasteMode, type RichTextEditorAutoCapitalize, type RichTextEditorKeyboardType, type RichTextEditorAndroidInputOptions, type RemoteSelectionDecoration, type LinkRequestContext, type ImageRequestContext, type RichTextEditorProps, type RichTextEditorRef, type RichTextEditorCaretRect, type NativeRichTextEditorHeightBehavior, type NativeRichTextEditorToolbarPlacement, type NativeRichTextEditorValueJSONUpdateMode, type NativeRichTextEditorAutoCapitalize, type NativeRichTextEditorKeyboardType, type NativeRichTextEditorAndroidInputOptions, type NativeRichTextEditorProps, type NativeRichTextEditorRef, type NativeRichTextEditorCaretRect, } from './RichTextEditorTypes';
2
2
  export { RichTextEditor, NativeRichTextEditor } from './RichTextEditor';
3
3
  export type { NativeRichTextEditorFocusPreservingElement, NativeRichTextEditorFocusPreservingRef, NativeRichTextEditorFocusPreservingRefs, RichTextEditorFocusPreservingElement, RichTextEditorFocusPreservingRef, RichTextEditorFocusPreservingRefs, } from './useFocusPreservingFrames';
@@ -2,7 +2,7 @@ import { type ExternalTextCompositionManager, type NativeExternalTextComposition
2
2
  import { type ReactNode } from 'react';
3
3
  import { type NativeSyntheticEvent, type StyleProp, type ViewStyle } from 'react-native';
4
4
  import { type DocumentJSON, type NativeEditorDocumentHandle } from './NativeEditorBridge';
5
- import { type RichTextEditorAutoCapitalize, type RichTextEditorKeyboardType, type RichTextEditorToolbarPlacement, type RichTextEditorHeightBehavior } from './RichTextEditorTypes';
5
+ import { type RichTextEditorPasteMode, type RichTextEditorAutoCapitalize, type RichTextEditorKeyboardType, type RichTextEditorToolbarPlacement, type RichTextEditorHeightBehavior } from './RichTextEditorTypes';
6
6
  export interface NativeExternalTextCompositionEvent {
7
7
  editorId: string;
8
8
  resultJson: string;
@@ -21,6 +21,7 @@ export interface NativeEditorViewProps {
21
21
  editorId: string;
22
22
  placeholder?: string;
23
23
  editable: boolean;
24
+ pasteMode: RichTextEditorPasteMode;
24
25
  autoFocus: boolean;
25
26
  autoCapitalize?: RichTextEditorAutoCapitalize;
26
27
  autoCorrect?: boolean;
@@ -24,6 +24,8 @@ export type RichTextEditorToolbarPlacement = 'keyboard' | 'inline';
24
24
  * records one undoable step, `'reset'` clears history entirely.
25
25
  */
26
26
  export type RichTextEditorValueJSONUpdateMode = 'replace' | 'reset';
27
+ /** Controls native clipboard paste. Plain text keeps destination typing marks. */
28
+ export type RichTextEditorPasteMode = 'rich' | 'plainText' | 'disabled';
27
29
  /** Native keyboard auto-capitalization behavior. */
28
30
  export type RichTextEditorAutoCapitalize = 'none' | 'sentences' | 'words' | 'characters';
29
31
  /** Native keyboard layout. Values not supported by a platform fall back to its default. */
@@ -104,6 +106,8 @@ export interface RichTextEditorProps {
104
106
  placeholder?: string;
105
107
  /** Whether the editor is editable. Defaults to true. When false, every mutation ref method rejects with MUTATION_REJECTED; selection and controlled content still flow. */
106
108
  editable?: boolean;
109
+ /** Clipboard paste policy. Defaults to rich; updates apply immediately. */
110
+ pasteMode?: RichTextEditorPasteMode;
107
111
  /** Whether to auto-focus on mount. */
108
112
  autoFocus?: boolean;
109
113
  /** Controls native keyboard auto-capitalization. Defaults to sentences. */
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { RichTextEditor, type RichTextEditorProps, type RichTextEditorRef, type RichTextEditorCaretRect, type RichTextEditorHeightBehavior, type RichTextEditorToolbarPlacement, type RichTextEditorValueJSONUpdateMode, type RichTextEditorAutoCapitalize, type RichTextEditorKeyboardType, type RichTextEditorAndroidInputOptions, type RichTextEditorFocusPreservingElement, type RichTextEditorFocusPreservingRef, type RichTextEditorFocusPreservingRefs, type RemoteSelectionDecoration, type LinkRequestContext, type ImageRequestContext, NativeRichTextEditor, type NativeRichTextEditorProps, type NativeRichTextEditorRef, type NativeRichTextEditorCaretRect, type NativeRichTextEditorHeightBehavior, type NativeRichTextEditorToolbarPlacement, type NativeRichTextEditorValueJSONUpdateMode, type NativeRichTextEditorAutoCapitalize, type NativeRichTextEditorKeyboardType, type NativeRichTextEditorAndroidInputOptions, type NativeRichTextEditorFocusPreservingElement, type NativeRichTextEditorFocusPreservingRef, type NativeRichTextEditorFocusPreservingRefs, } from './NativeRichTextEditor';
1
+ export { RichTextEditor, type RichTextEditorProps, type RichTextEditorRef, type RichTextEditorCaretRect, type RichTextEditorHeightBehavior, type RichTextEditorToolbarPlacement, type RichTextEditorValueJSONUpdateMode, type RichTextEditorPasteMode, type RichTextEditorAutoCapitalize, type RichTextEditorKeyboardType, type RichTextEditorAndroidInputOptions, type RichTextEditorFocusPreservingElement, type RichTextEditorFocusPreservingRef, type RichTextEditorFocusPreservingRefs, type RemoteSelectionDecoration, type LinkRequestContext, type ImageRequestContext, NativeRichTextEditor, type NativeRichTextEditorProps, type NativeRichTextEditorRef, type NativeRichTextEditorCaretRect, type NativeRichTextEditorHeightBehavior, type NativeRichTextEditorToolbarPlacement, type NativeRichTextEditorValueJSONUpdateMode, type NativeRichTextEditorAutoCapitalize, type NativeRichTextEditorKeyboardType, type NativeRichTextEditorAndroidInputOptions, type NativeRichTextEditorFocusPreservingElement, type NativeRichTextEditorFocusPreservingRef, type NativeRichTextEditorFocusPreservingRefs, } from './NativeRichTextEditor';
2
2
  export type { ExternalTextCompositionEndCause, ExternalTextCompositionEndEvent, ExternalTextCompositionOptions, ExternalTextCompositionSession, } from './ExternalTextComposition';
3
3
  export { RichTextViewer, type RichTextViewerProps, type RichTextViewerAddons, type RichTextViewerBaseProps, type RichTextViewerMentionsConfig, type RichTextViewerLinkPressEvent, type RichTextViewerMentionPressEvent, type RichTextViewerErrorEvent, type RichTextViewerAtomAttrsUpdateEvent, NativeProseViewer, type NativeProseViewerProps, type NativeProseViewerAddons, type NativeProseViewerBaseProps, type NativeProseViewerMentionsConfig, type NativeProseViewerLinkPressEvent, type NativeProseViewerMentionPressEvent, type NativeProseViewerErrorEvent, type NativeProseViewerAtomAttrsUpdateEvent, } from './NativeProseViewer';
4
4
  export { EditorToolbar, DEFAULT_EDITOR_TOOLBAR_ITEMS, type EditorToolbarProps, type EditorToolbarItem, type EditorToolbarLeafItem, type EditorToolbarGroupChildItem, type EditorToolbarGroupItem, type EditorToolbarGroupPresentation, type EditorToolbarIcon, type EditorToolbarButtonStyle, type EditorToolbarDefaultIconId, type EditorToolbarSFSymbolIcon, type EditorToolbarMaterialIcon, type EditorToolbarCommand, type EditorToolbarHeadingLevel, type EditorToolbarListType, type EditorToolbarItemPlacement, } from './EditorToolbar';