@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.
- package/README.md +29 -123
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +20 -6
- package/android/src/main/java/com/apollohg/editor/EditorEditTextClipboard.kt +181 -36
- package/android/src/main/java/com/apollohg/editor/EditorEditTextImages.kt +25 -0
- package/android/src/main/java/com/apollohg/editor/EditorEditTextSelection.kt +7 -0
- package/android/src/main/java/com/apollohg/editor/EditorTextSurfaceInteraction.kt +31 -11
- package/android/src/main/java/com/apollohg/editor/EditorV2Adapter.kt +36 -0
- package/android/src/main/java/com/apollohg/editor/EditorV2Backend.kt +1 -0
- package/android/src/main/java/com/apollohg/editor/EditorV2Driver.kt +10 -0
- package/android/src/main/java/com/apollohg/editor/EditorV2RenderValidation.kt +1 -0
- package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +101 -33
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +2 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoViewProperties.kt +4 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +3 -0
- package/android/src/main/java/com/apollohg/editor/RenderMediaSpans.kt +15 -3
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +14 -0
- package/android/src/main/java/com/apollohg/editor/UniffiEditorV2Backend.kt +4 -0
- package/android/src/main/java/com/apollohg/editor/viewer/AndroidProseLayoutEngine.kt +1 -1
- package/dist/EditorStyleSheetNormalization.js +1 -1
- package/dist/EditorStyleSheetTypes.d.ts +1 -1
- package/dist/NativeRichTextEditor.d.ts +1 -1
- package/dist/RichTextEditorNativeTypes.d.ts +2 -1
- package/dist/RichTextEditorTypes.d.ts +4 -0
- package/dist/index.d.ts +1 -1
- package/dist/useRichTextEditorPresentation.d.ts +1 -1
- package/dist/useRichTextEditorPresentation.js +2 -2
- package/dist/useRichTextEditorState.d.ts +5 -3
- package/dist/useRichTextEditorState.js +4 -3
- package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
- package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
- package/ios/EditorInteractionOverlays.swift +114 -122
- package/ios/EditorLayoutManager.swift +13 -3
- package/ios/EditorStyleSheet.swift +12 -1
- package/ios/EditorTextView+Images.swift +32 -10
- package/ios/EditorTextView+KeyboardInput.swift +299 -0
- package/ios/EditorTextView.swift +64 -44
- package/ios/EditorV2Adapter+Commands.swift +22 -0
- package/ios/EditorV2Adapter+RenderValidation.swift +2 -2
- package/ios/EditorV2Shadow.swift +12 -0
- package/ios/Generated_editor_core.swift +10 -0
- package/ios/NativeEditorExpoView+Properties.swift +4 -0
- package/ios/NativeEditorModule.swift +3 -0
- package/ios/NativeToolbar+ButtonStyle.swift +17 -5
- package/ios/NativeToolbar+Diagnostics.swift +15 -0
- package/ios/RenderBridge+Atoms.swift +0 -1
- package/ios/RenderBridge+Attachments.swift +0 -29
- package/ios/RichTextEditorView.swift +4 -12
- package/ios/Viewer/CoreTextProseLayoutEngine+AttributedText.swift +3 -2
- package/ios/editor_coreFFI/editor_coreFFI.h +11 -0
- package/package.json +1 -1
- package/rust/android/arm64-v8a/libeditor_core.so +0 -0
- package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
- package/rust/android/x86/libeditor_core.so +0 -0
- package/rust/android/x86_64/libeditor_core.so +0 -0
- package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +18 -0
- package/src/EditorStyleSheetNormalization.ts +1 -1
- package/src/EditorStyleSheetTypes.ts +1 -1
- package/src/NativeRichTextEditor.tsx +1 -0
- package/src/RichTextEditorNativeTypes.tsx +2 -0
- package/src/RichTextEditorTypes.tsx +5 -0
- package/src/index.ts +1 -0
- package/src/useRichTextEditorPresentation.tsx +3 -0
- 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
|
-
<
|
|
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
|
-
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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.
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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.
|
|
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.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
61
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
583
|
+
if (editor?.pasteMode != EditorPasteMode.DISABLED) {
|
|
584
|
+
info.addAction(AccessibilityNodeInfo.ACTION_PASTE)
|
|
585
|
+
}
|
|
566
586
|
}
|
|
567
587
|
}
|
|
568
588
|
|