@apollohg/react-native-rich-text-editor 2.0.1 → 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 (63) hide show
  1. package/README.md +29 -123
  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/EditorEditTextImages.kt +25 -0
  5. package/android/src/main/java/com/apollohg/editor/EditorEditTextSelection.kt +7 -0
  6. package/android/src/main/java/com/apollohg/editor/EditorTextSurfaceInteraction.kt +31 -11
  7. package/android/src/main/java/com/apollohg/editor/EditorV2Adapter.kt +36 -0
  8. package/android/src/main/java/com/apollohg/editor/EditorV2Backend.kt +1 -0
  9. package/android/src/main/java/com/apollohg/editor/EditorV2Driver.kt +10 -0
  10. package/android/src/main/java/com/apollohg/editor/EditorV2RenderValidation.kt +1 -0
  11. package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +101 -33
  12. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +2 -0
  13. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoViewProperties.kt +4 -0
  14. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +3 -0
  15. package/android/src/main/java/com/apollohg/editor/RenderMediaSpans.kt +15 -3
  16. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +14 -0
  17. package/android/src/main/java/com/apollohg/editor/UniffiEditorV2Backend.kt +4 -0
  18. package/android/src/main/java/com/apollohg/editor/viewer/AndroidProseLayoutEngine.kt +1 -1
  19. package/dist/EditorStyleSheetNormalization.js +1 -1
  20. package/dist/EditorStyleSheetTypes.d.ts +1 -1
  21. package/dist/NativeRichTextEditor.d.ts +1 -1
  22. package/dist/RichTextEditorNativeTypes.d.ts +2 -1
  23. package/dist/RichTextEditorTypes.d.ts +4 -0
  24. package/dist/index.d.ts +1 -1
  25. package/dist/useRichTextEditorPresentation.d.ts +1 -1
  26. package/dist/useRichTextEditorPresentation.js +2 -2
  27. package/dist/useRichTextEditorState.d.ts +5 -3
  28. package/dist/useRichTextEditorState.js +4 -3
  29. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  30. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  31. package/ios/EditorInteractionOverlays.swift +114 -122
  32. package/ios/EditorLayoutManager.swift +13 -3
  33. package/ios/EditorStyleSheet.swift +12 -1
  34. package/ios/EditorTextView+Images.swift +32 -10
  35. package/ios/EditorTextView+KeyboardInput.swift +299 -0
  36. package/ios/EditorTextView.swift +64 -44
  37. package/ios/EditorV2Adapter+Commands.swift +22 -0
  38. package/ios/EditorV2Adapter+RenderValidation.swift +2 -2
  39. package/ios/EditorV2Shadow.swift +12 -0
  40. package/ios/Generated_editor_core.swift +10 -0
  41. package/ios/NativeEditorExpoView+Properties.swift +4 -0
  42. package/ios/NativeEditorModule.swift +3 -0
  43. package/ios/NativeToolbar+ButtonStyle.swift +17 -5
  44. package/ios/NativeToolbar+Diagnostics.swift +15 -0
  45. package/ios/RenderBridge+Atoms.swift +0 -1
  46. package/ios/RenderBridge+Attachments.swift +0 -29
  47. package/ios/RichTextEditorView.swift +4 -12
  48. package/ios/Viewer/CoreTextProseLayoutEngine+AttributedText.swift +3 -2
  49. package/ios/editor_coreFFI/editor_coreFFI.h +11 -0
  50. package/package.json +1 -1
  51. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  52. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  53. package/rust/android/x86/libeditor_core.so +0 -0
  54. package/rust/android/x86_64/libeditor_core.so +0 -0
  55. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +18 -0
  56. package/src/EditorStyleSheetNormalization.ts +1 -1
  57. package/src/EditorStyleSheetTypes.ts +1 -1
  58. package/src/NativeRichTextEditor.tsx +1 -0
  59. package/src/RichTextEditorNativeTypes.tsx +2 -0
  60. package/src/RichTextEditorTypes.tsx +5 -0
  61. package/src/index.ts +1 -0
  62. package/src/useRichTextEditorPresentation.tsx +3 -0
  63. package/src/useRichTextEditorState.tsx +5 -2
package/README.md CHANGED
@@ -4,19 +4,19 @@
4
4
 
5
5
  See the [documentation](https://github.com/apollohg/react-native-rich-text-editor/wiki) for guides and API references. The package is under active development; review the [changelog](./CHANGELOG.md) before upgrading between major versions.
6
6
 
7
- <img src="https://github.com/apollohg/react-native-rich-text-editor/wiki/images/github-banner.png" alt="Example editor on iOS" width="100%" />
7
+ <a href="./assets/github-demo-banner.mp4"><img src="./assets/github-demo-banner.gif" alt="Native iOS editor demo with rich text, nested lists, a code block, and image resizing" width="100%" /></a>
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
18
-
19
- `NativeRichTextEditor` and `NativeProseViewer`, along with their associated types, remain available as deprecated aliases of `RichTextEditor` and `RichTextViewer`.
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**
20
20
 
21
21
  ## Requirements
22
22
 
@@ -50,7 +50,7 @@ npx expo prebuild
50
50
  npx expo run:ios # or: npx expo run:android
51
51
  ```
52
52
 
53
- See the [Installation Guide](https://github.com/apollohg/react-native-rich-text-editor/wiki/Installation) for bare React Native setup and migration from `@apollohg/react-native-prose-editor`.
53
+ See the [Installation Guide](https://github.com/apollohg/react-native-rich-text-editor/wiki/Installation) for bare React Native setup.
54
54
 
55
55
  ## Editor usage
56
56
 
@@ -89,9 +89,21 @@ export function EditorScreen() {
89
89
 
90
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.
91
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
+
92
104
  ## Custom atom nodes
93
105
 
94
- Render interactive cards, embeds, and other custom blocks with your own React components. Define an atom with `defineAtomNode`, add it to your document schema, and pass it through the editor or viewer's `atoms` prop.
106
+ Render interactive cards, embeds, and other custom blocks with your own React components.
95
107
 
96
108
  See [Custom Atom Nodes](https://github.com/apollohg/react-native-rich-text-editor/wiki/Custom-Atom-Nodes) for a complete example and API details.
97
109
 
@@ -107,108 +119,13 @@ import { RichTextViewer } from '@apollohg/react-native-rich-text-editor';
107
119
 
108
120
  See the [Viewer Guide](https://github.com/apollohg/react-native-rich-text-editor/wiki/Viewer) for styling, images, interactions, and custom atoms.
109
121
 
110
- ## Element styling and addons
111
-
112
- Version 2 uses a flat, typed stylesheet for both the editor and viewer:
113
-
114
- ```tsx
115
- import { EditorStyleSheet, RichTextEditor } from '@apollohg/react-native-rich-text-editor';
116
-
117
- const theme = EditorStyleSheet.create({
118
- content: { padding: 16, backgroundColor: '#ffffff' },
119
- text: { fontSize: 16, color: '#202124' },
120
- paragraph: { marginBottom: 12 },
121
- link: { color: '#285dcc', textDecorationLine: 'underline' },
122
- blockquote: {
123
- backgroundColor: '#f3f5f8',
124
- padding: 12,
125
- borderWidth: 1,
126
- borderColor: '#dce1e8',
127
- borderLeftWidth: 4,
128
- borderLeftColor: '#285dcc',
129
- borderTopRightRadius: 8,
130
- },
131
- codeBlock: { backgroundColor: '#eff1f5', padding: 12, borderRadius: 8 },
132
- image: { backgroundColor: '#eff1f5', borderRadius: 12, resizeMode: 'cover' },
133
- });
134
-
135
- <RichTextEditor documentHandle={documentHandle} theme={theme} />;
136
- ```
137
-
138
- Each entry accepts a style object or nested, conditional style arrays. Later entries override earlier properties; explicit `undefined` removes an earlier property. Side and corner properties override shorthands. Supported fields are checked by TypeScript and validated before native updates. These are editor styles: layout fields such as `flex`, transforms, and positioning are not accepted.
139
-
140
- Text styling inherits from `text` and enclosing blocks. Backgrounds, spacing, borders, and corner radii belong to each element. Inline entries support typography and backgrounds; mentions also support borders. List containers, items, markers, and checkboxes have separate entries. `toolbar` retains its existing configuration.
141
-
142
- Android editing uses per-block text layout for physical margins, borders, and padding, including RTL content. Per-block justification is supported on Android API 26+; API 24/25 use normal alignment.
143
-
144
- Custom atom components mount inside Android's native scrolling content. Their measured height participates in document layout, and their controls receive normal React Native touch events.
122
+ ## Styling and addons
145
123
 
146
- Addons are a readonly array. Conditional `false`, `null`, and `undefined` entries are allowed; duplicate capabilities are rejected:
147
-
148
- ```tsx
149
- import { createMentionsAddon } from '@apollohg/react-native-rich-text-editor';
150
- import { createCodeHighlightingAddon } from '@apollohg/react-native-rich-text-editor-code-highlighting';
151
-
152
- <RichTextEditor
153
- documentHandle={documentHandle}
154
- theme={theme}
155
- addons={[
156
- createMentionsAddon({ trigger: '@', suggestions }),
157
- enableHighlighting && createCodeHighlightingAddon({ theme: 'base16-ocean.dark' }),
158
- ]}
159
- />;
160
- ```
161
-
162
- Syntax highlighting requires the separately installed [code-highlighting package](./packages/code-highlighting), followed by a native rebuild. The base editor does not include syntect or its grammars. Set a code block's `attrs.language`, for example `{ type: 'codeBlock', attrs: { language: 'typescript' }, content: [...] }`. Missing or unsupported languages keep ordinary code styling. The code block theme controls the panel; the highlighting addon controls token colors and font traits. Mention-enabled editor handles still require `withMentionsSchema` when creating their schema.
163
-
164
- When migrating from version 1, replace `links` with `link`, heading maps with `h1`–`h6`, `spacingAfter` with `marginBottom`, and `contentInsets` with `content.padding*`. Flatten nested quote/code typography into their element entry, and move list appearance into the relevant container/item/marker entries. Addon objects become `[createMentionsAddon(options)]`. The new built-in schema declares code-block language metadata; coordinate schema changes across collaborating clients.
124
+ Customize the editor and viewer with themes, mentions, and syntax highlighting. See [Styling](https://github.com/apollohg/react-native-rich-text-editor/wiki/Styling) and [Addons](https://github.com/apollohg/react-native-rich-text-editor/wiki/Addons).
165
125
 
166
126
  ## Collaboration
167
127
 
168
- `useYjsCollaboration` connects a room-backed document handle to a Yjs sync and awareness server. The editor and collaboration controller must share the same handle.
169
-
170
- ```tsx
171
- import React, { useEffect, useMemo } from 'react';
172
- import {
173
- createNativeEditorDocumentHandle,
174
- RichTextEditor,
175
- useYjsCollaboration,
176
- } from '@apollohg/react-native-rich-text-editor';
177
-
178
- export function CollaborativeEditor({ documentId }: { documentId: string }) {
179
- const documentHandle = useMemo(
180
- () =>
181
- createNativeEditorDocumentHandle({
182
- initialization: {
183
- type: 'room',
184
- documentId,
185
- lineageId: `my-app|${documentId}`,
186
- },
187
- }),
188
- [documentId]
189
- );
190
-
191
- const collaboration = useYjsCollaboration({
192
- documentId,
193
- handle: documentHandle,
194
- transport: {
195
- url: `wss://example.com/collaboration?documentId=${encodeURIComponent(documentId)}`,
196
- connect: true,
197
- },
198
- localAwareness: {
199
- userId: 'user-1',
200
- name: 'Ada',
201
- color: '#0A84FF',
202
- },
203
- });
204
-
205
- useEffect(() => () => documentHandle.destroy(), [documentHandle]);
206
-
207
- return <RichTextEditor {...collaboration.editorBindings} />;
208
- }
209
- ```
210
-
211
- See the [Collaboration Guide](https://github.com/apollohg/react-native-rich-text-editor/wiki/Collaboration) for server requirements, persistence, authentication, and recovery.
128
+ Connect shared documents to a Yjs server for collaborative editing and live cursors. See the [Collaboration Guide](https://github.com/apollohg/react-native-rich-text-editor/wiki/Collaboration).
212
129
 
213
130
  ## Comparison with other React Native editors
214
131
 
@@ -225,14 +142,6 @@ Reviewed on 7 September 2026
225
142
 
226
143
  ## Development
227
144
 
228
- Run `npm run lint:kotlin` to check Kotlin style or `npm run format:kotlin` to
229
- apply automatic fixes. Both commands use ktlint 1.8.0 with Android Studio style
230
- and four-space indentation. Java 17+ is required (`JAVA_HOME` or `java` on PATH).
231
- The first run downloads and verifies the CLI into `.tmp/ktlint`; subsequent runs
232
- use the cached copy. Generated bindings, build output, dependencies, and Expo's
233
- generated native projects are excluded. Additional ktlint options can be passed
234
- after `--`, for example `npm run lint:kotlin -- --reporter=plain-summary`.
235
-
236
145
  See the [example app](./example) to try the editor and viewer, and the [Development Workflow](https://github.com/apollohg/react-native-rich-text-editor/wiki/Development-Workflow) for local setup, native builds, and testing.
237
146
 
238
147
  ## Documentation
@@ -248,12 +157,9 @@ See the [example app](./example) to try the editor and viewer, and the [Developm
248
157
  - [Mentions](https://github.com/apollohg/react-native-rich-text-editor/wiki/Mentions)
249
158
  - [Styling](https://github.com/apollohg/react-native-rich-text-editor/wiki/Styling)
250
159
  - [Production limits and errors](https://github.com/apollohg/react-native-rich-text-editor/wiki/Production-Limits-and-Errors)
160
+ - [Migration guide](https://github.com/apollohg/react-native-rich-text-editor/wiki/Migration-Guide)
251
161
  - [Changelog](./CHANGELOG.md)
252
162
 
253
163
  ## License
254
164
 
255
- [Apache-2.0](./LICENSE) for this project's own code. Dependencies retain their respective licenses.
256
-
257
- [Third-party notices](./THIRD_PARTY_NOTICES.md) cover the bundled Rust libraries, native dependencies, and copied icon assets; [Rust standard-library notices](./RUST-STANDARD-LIBRARY-NOTICES.html) accompany the prebuilt native libraries. The optional highlighting package ships its own notices for syntect, grammars, themes, and its other dependencies.
258
-
259
- When distributing an app, include the applicable notices with the app's open-source acknowledgements or other accompanying materials, including the MPL corresponding-source links. Retain notices for the actual React Native/Expo and native dependency versions resolved by your app as well. An npm package's notices are not automatically copied into the final app by Metro. Refresh notices when dependencies, bundled assets, or the Rust toolchain change.
165
+ [Apache-2.0](./LICENSE). See [third-party notices](./THIRD_PARTY_NOTICES.md) and [Rust standard-library notices](./RUST-STANDARD-LIBRARY-NOTICES.html) for bundled dependencies.
@@ -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 {
@@ -76,6 +76,31 @@ internal fun EditorEditText.selectedImageGeometryImpl(): SelectedImageGeometry?
76
76
  )
77
77
  }
78
78
 
79
+ internal fun EditorEditText.selectedImageSpanForResize(): BlockImageSpan? {
80
+ val spannable = text as? Spanned ?: return null
81
+ val selection = resolvedSelectedImageRange(spannable) ?: return null
82
+ return spannable.getSpans(
83
+ selection.start,
84
+ selection.end,
85
+ BlockImageSpan::class.java
86
+ ).firstOrNull { span ->
87
+ spannable.getSpanStart(span) == selection.start &&
88
+ spannable.getSpanEnd(span) == selection.end
89
+ }
90
+ }
91
+
92
+ internal fun EditorEditText.relayoutImageResizePreview(span: BlockImageSpan) {
93
+ val content = text ?: return
94
+ val start = content.getSpanStart(span)
95
+ val end = content.getSpanEnd(span)
96
+ if (start < 0 || end <= start) return
97
+ val flags = content.getSpanFlags(span)
98
+ content.setSpan(span, start, end, flags)
99
+ requestLayout()
100
+ invalidate()
101
+ onContentSizeMayChange?.invoke()
102
+ }
103
+
79
104
  internal fun EditorEditText.resizeImageAtDocPosImpl(docPos: Int, widthPx: Float, heightPx: Float) {
80
105
  if (!hasLiveEditor()) return
81
106
  val density = resources.displayMetrics.density
@@ -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