@apollohg/react-native-prose-editor 0.5.9 → 0.5.10
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/android/src/main/java/com/apollohg/editor/EditorEditText.kt +59 -36
- package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +103 -14
- package/ios/EditorCore.xcframework/Info.plist +5 -5
- 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/RichTextEditorView.swift +203 -31
- 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_64/libeditor_core.so +0 -0
|
@@ -38,9 +38,9 @@ import uniffi.editor_core.* // UniFFI-generated bindings
|
|
|
38
38
|
*
|
|
39
39
|
* ## Composition Handling
|
|
40
40
|
*
|
|
41
|
-
* For CJK input methods,
|
|
42
|
-
*
|
|
43
|
-
*
|
|
41
|
+
* For CJK input methods, swipe keyboards, and some autocorrect flows, composing
|
|
42
|
+
* text is rendered transiently by the base [InputConnection]. The final commit
|
|
43
|
+
* is routed through Rust against the original authorized selection.
|
|
44
44
|
*
|
|
45
45
|
* ## Thread Safety
|
|
46
46
|
*
|
|
@@ -190,6 +190,8 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
190
190
|
private var lastAppliedRenderAppearanceRevision: Long = 0L
|
|
191
191
|
internal var onDeleteRangeInRustForTesting: ((Int, Int) -> Unit)? = null
|
|
192
192
|
internal var onDeleteBackwardAtSelectionScalarInRustForTesting: ((Int, Int) -> Unit)? = null
|
|
193
|
+
internal var onInsertTextInRustForTesting: ((String, Int) -> Unit)? = null
|
|
194
|
+
internal var onReplaceTextInRustForTesting: ((Int, Int, String) -> Unit)? = null
|
|
193
195
|
|
|
194
196
|
fun lastRenderAppliedPatch(): Boolean = lastRenderAppliedPatchForTesting
|
|
195
197
|
fun lastApplyUpdateTrace(): ApplyUpdateTrace? = lastApplyUpdateTraceForTesting
|
|
@@ -583,16 +585,48 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
583
585
|
// Range selection: atomic replace via Rust.
|
|
584
586
|
val scalarStart = PositionBridge.utf16ToScalar(start, currentText)
|
|
585
587
|
val scalarEnd = PositionBridge.utf16ToScalar(end, currentText)
|
|
586
|
-
|
|
587
|
-
editorId.toULong(), scalarStart.toUInt(), scalarEnd.toUInt(), text
|
|
588
|
-
)
|
|
589
|
-
applyUpdateJSON(updateJSON)
|
|
588
|
+
replaceTextRangeInRust(scalarStart, scalarEnd, text)
|
|
590
589
|
} else {
|
|
591
590
|
val scalarPos = PositionBridge.utf16ToScalar(start, currentText)
|
|
592
591
|
insertTextInRust(text, scalarPos)
|
|
593
592
|
}
|
|
594
593
|
}
|
|
595
594
|
|
|
595
|
+
internal fun runWithTransientInputMutationGuard(block: () -> Boolean): Boolean {
|
|
596
|
+
val wasApplyingRustState = isApplyingRustState
|
|
597
|
+
isApplyingRustState = true
|
|
598
|
+
return try {
|
|
599
|
+
block()
|
|
600
|
+
} finally {
|
|
601
|
+
isApplyingRustState = wasApplyingRustState
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
fun handleCompositionCommit(text: String, replacementStartUtf16: Int, replacementEndUtf16: Int) {
|
|
606
|
+
if (!isEditable) return
|
|
607
|
+
if (isApplyingRustState) return
|
|
608
|
+
if (editorId == 0L) return
|
|
609
|
+
|
|
610
|
+
if (text == "\n") {
|
|
611
|
+
handleReturnKey()
|
|
612
|
+
return
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
val authorizedText = lastAuthorizedText
|
|
616
|
+
val startUtf16 = minOf(replacementStartUtf16, replacementEndUtf16)
|
|
617
|
+
.coerceIn(0, authorizedText.length)
|
|
618
|
+
val endUtf16 = maxOf(replacementStartUtf16, replacementEndUtf16)
|
|
619
|
+
.coerceIn(0, authorizedText.length)
|
|
620
|
+
val scalarStart = PositionBridge.utf16ToScalar(startUtf16, authorizedText)
|
|
621
|
+
val scalarEnd = PositionBridge.utf16ToScalar(endUtf16, authorizedText)
|
|
622
|
+
|
|
623
|
+
if (scalarStart != scalarEnd) {
|
|
624
|
+
replaceTextRangeInRust(scalarStart, scalarEnd, text)
|
|
625
|
+
} else {
|
|
626
|
+
insertTextInRust(text, scalarStart)
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
596
630
|
// ── Input Handling: Deletion ────────────────────────────────────────
|
|
597
631
|
|
|
598
632
|
/**
|
|
@@ -699,35 +733,6 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
699
733
|
}
|
|
700
734
|
}
|
|
701
735
|
|
|
702
|
-
// ── Input Handling: Composition ─────────────────────────────────────
|
|
703
|
-
|
|
704
|
-
/**
|
|
705
|
-
* Handle finalization of IME composition (CJK input, swipe keyboard).
|
|
706
|
-
*
|
|
707
|
-
* Called by [EditorInputConnection.finishComposingText] after the base
|
|
708
|
-
* InputConnection has finalized the composing text.
|
|
709
|
-
*/
|
|
710
|
-
/**
|
|
711
|
-
* Handle finalization of IME composition.
|
|
712
|
-
*
|
|
713
|
-
* @param composedText The finalized composed text captured from the InputConnection.
|
|
714
|
-
*/
|
|
715
|
-
fun handleCompositionFinished(composedText: String?) {
|
|
716
|
-
if (!isEditable) return
|
|
717
|
-
if (isApplyingRustState) return
|
|
718
|
-
if (editorId == 0L) return
|
|
719
|
-
if (composedText.isNullOrEmpty()) return
|
|
720
|
-
|
|
721
|
-
// The cursor is at the end of the composed text. Calculate the insert
|
|
722
|
-
// position as cursor - composed_length (in scalar offsets).
|
|
723
|
-
val currentText = text?.toString() ?: ""
|
|
724
|
-
val cursorUtf16 = selectionStart
|
|
725
|
-
val cursorScalar = PositionBridge.utf16ToScalar(cursorUtf16, currentText)
|
|
726
|
-
val composedScalarLen = composedText.codePointCount(0, composedText.length)
|
|
727
|
-
val insertPos = if (cursorScalar >= composedScalarLen) cursorScalar - composedScalarLen else 0
|
|
728
|
-
insertTextInRust(composedText, insertPos)
|
|
729
|
-
}
|
|
730
|
-
|
|
731
736
|
// ── Input Handling: Return Key ──────────────────────────────────────
|
|
732
737
|
|
|
733
738
|
/**
|
|
@@ -1078,10 +1083,28 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
1078
1083
|
* Insert text at a scalar position via the Rust editor.
|
|
1079
1084
|
*/
|
|
1080
1085
|
private fun insertTextInRust(text: String, atScalarPos: Int) {
|
|
1086
|
+
onInsertTextInRustForTesting?.let { callback ->
|
|
1087
|
+
callback(text, atScalarPos)
|
|
1088
|
+
return
|
|
1089
|
+
}
|
|
1081
1090
|
val updateJSON = editorInsertTextScalar(editorId.toULong(), atScalarPos.toUInt(), text)
|
|
1082
1091
|
applyUpdateJSON(updateJSON)
|
|
1083
1092
|
}
|
|
1084
1093
|
|
|
1094
|
+
private fun replaceTextRangeInRust(scalarFrom: Int, scalarTo: Int, text: String) {
|
|
1095
|
+
onReplaceTextInRustForTesting?.let { callback ->
|
|
1096
|
+
callback(scalarFrom, scalarTo, text)
|
|
1097
|
+
return
|
|
1098
|
+
}
|
|
1099
|
+
val updateJSON = editorReplaceTextScalar(
|
|
1100
|
+
editorId.toULong(),
|
|
1101
|
+
scalarFrom.toUInt(),
|
|
1102
|
+
scalarTo.toUInt(),
|
|
1103
|
+
text
|
|
1104
|
+
)
|
|
1105
|
+
applyUpdateJSON(updateJSON)
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1085
1108
|
/**
|
|
1086
1109
|
* Delete a scalar range via the Rust editor.
|
|
1087
1110
|
*
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package com.apollohg.editor
|
|
2
2
|
|
|
3
3
|
import android.view.KeyEvent
|
|
4
|
+
import android.view.inputmethod.BaseInputConnection
|
|
4
5
|
import android.view.inputmethod.InputConnection
|
|
5
6
|
import android.view.inputmethod.InputConnectionWrapper
|
|
6
7
|
|
|
@@ -16,11 +17,11 @@ import android.view.inputmethod.InputConnectionWrapper
|
|
|
16
17
|
*
|
|
17
18
|
* ## Composition (IME) Handling
|
|
18
19
|
*
|
|
19
|
-
* For CJK input methods
|
|
20
|
-
* [finishComposingText] are used. During composition, we let
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* For CJK input methods, swipe keyboards, and some autocorrect flows, [setComposingText],
|
|
21
|
+
* [commitText], and [finishComposingText] are used together. During composition, we let
|
|
22
|
+
* the base [InputConnection] render transient composing text, but keep the original
|
|
23
|
+
* Rust-authorized replacement range so the final committed text lands at the correct
|
|
24
|
+
* document position.
|
|
24
25
|
*
|
|
25
26
|
* ## Key Events
|
|
26
27
|
*
|
|
@@ -70,6 +71,8 @@ class EditorInputConnection(
|
|
|
70
71
|
|
|
71
72
|
/** Tracks the current composing text for CJK/swipe input. */
|
|
72
73
|
private var composingText: String? = null
|
|
74
|
+
private var composingReplacementStartUtf16: Int? = null
|
|
75
|
+
private var composingReplacementEndUtf16: Int? = null
|
|
73
76
|
|
|
74
77
|
/**
|
|
75
78
|
* Called when the IME commits finalized text (single character, word,
|
|
@@ -82,7 +85,26 @@ class EditorInputConnection(
|
|
|
82
85
|
if (editorView.isApplyingRustState) {
|
|
83
86
|
return super.commitText(text, newCursorPosition)
|
|
84
87
|
}
|
|
85
|
-
|
|
88
|
+
if (editorView.editorId == 0L) {
|
|
89
|
+
return super.commitText(text, newCursorPosition)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
val committedText = text?.toString()
|
|
93
|
+
val replacementRange = trackedCompositionReplacementRange()
|
|
94
|
+
if (replacementRange != null && committedText != null) {
|
|
95
|
+
clearCompositionTracking()
|
|
96
|
+
editorView.runWithTransientInputMutationGuard {
|
|
97
|
+
super.finishComposingText()
|
|
98
|
+
}
|
|
99
|
+
editorView.handleCompositionCommit(
|
|
100
|
+
committedText,
|
|
101
|
+
replacementRange.first,
|
|
102
|
+
replacementRange.second
|
|
103
|
+
)
|
|
104
|
+
} else {
|
|
105
|
+
clearCompositionTracking()
|
|
106
|
+
committedText?.let { editorView.handleTextCommit(it) }
|
|
107
|
+
}
|
|
86
108
|
return true
|
|
87
109
|
}
|
|
88
110
|
|
|
@@ -99,6 +121,13 @@ class EditorInputConnection(
|
|
|
99
121
|
if (editorView.isApplyingRustState) {
|
|
100
122
|
return super.deleteSurroundingText(beforeLength, afterLength)
|
|
101
123
|
}
|
|
124
|
+
if (trackedCompositionReplacementRange() != null) {
|
|
125
|
+
val result = editorView.runWithTransientInputMutationGuard {
|
|
126
|
+
super.deleteSurroundingText(beforeLength, afterLength)
|
|
127
|
+
}
|
|
128
|
+
refreshComposingTextFromEditable()
|
|
129
|
+
return result
|
|
130
|
+
}
|
|
102
131
|
editorView.handleDelete(beforeLength, afterLength)
|
|
103
132
|
return true
|
|
104
133
|
}
|
|
@@ -108,6 +137,13 @@ class EditorInputConnection(
|
|
|
108
137
|
if (editorView.isApplyingRustState) {
|
|
109
138
|
return super.deleteSurroundingTextInCodePoints(beforeLength, afterLength)
|
|
110
139
|
}
|
|
140
|
+
if (trackedCompositionReplacementRange() != null) {
|
|
141
|
+
val result = editorView.runWithTransientInputMutationGuard {
|
|
142
|
+
super.deleteSurroundingTextInCodePoints(beforeLength, afterLength)
|
|
143
|
+
}
|
|
144
|
+
refreshComposingTextFromEditable()
|
|
145
|
+
return result
|
|
146
|
+
}
|
|
111
147
|
|
|
112
148
|
val currentText = editorView.text?.toString().orEmpty()
|
|
113
149
|
val cursor = editorView.selectionStart.coerceAtLeast(0)
|
|
@@ -132,12 +168,27 @@ class EditorInputConnection(
|
|
|
132
168
|
*
|
|
133
169
|
* We let the base InputConnection handle this normally so the user sees
|
|
134
170
|
* the composing text with its underline decoration. The text is NOT sent
|
|
135
|
-
* to Rust during composition — only when
|
|
171
|
+
* to Rust during composition — only when the IME commits or finishes it.
|
|
136
172
|
*/
|
|
137
173
|
override fun setComposingText(text: CharSequence?, newCursorPosition: Int): Boolean {
|
|
138
174
|
if (!editorView.isEditable) return super.setComposingText(text, newCursorPosition)
|
|
175
|
+
if (editorView.editorId == 0L) return super.setComposingText(text, newCursorPosition)
|
|
176
|
+
captureCompositionReplacementRangeIfNeeded()
|
|
139
177
|
composingText = text?.toString()
|
|
140
|
-
return
|
|
178
|
+
return editorView.runWithTransientInputMutationGuard {
|
|
179
|
+
super.setComposingText(text, newCursorPosition)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
override fun setComposingRegion(start: Int, end: Int): Boolean {
|
|
184
|
+
if (!editorView.isEditable) return super.setComposingRegion(start, end)
|
|
185
|
+
if (editorView.editorId == 0L) return super.setComposingRegion(start, end)
|
|
186
|
+
val authorizedLength = editorView.text?.length ?: 0
|
|
187
|
+
composingReplacementStartUtf16 = minOf(start, end).coerceIn(0, authorizedLength)
|
|
188
|
+
composingReplacementEndUtf16 = maxOf(start, end).coerceIn(0, authorizedLength)
|
|
189
|
+
return editorView.runWithTransientInputMutationGuard {
|
|
190
|
+
super.setComposingRegion(start, end)
|
|
191
|
+
}
|
|
141
192
|
}
|
|
142
193
|
|
|
143
194
|
/**
|
|
@@ -149,22 +200,60 @@ class EditorInputConnection(
|
|
|
149
200
|
*/
|
|
150
201
|
override fun finishComposingText(): Boolean {
|
|
151
202
|
if (!editorView.isEditable) return super.finishComposingText()
|
|
203
|
+
if (editorView.editorId == 0L) return super.finishComposingText()
|
|
152
204
|
val composed = composingText
|
|
153
|
-
|
|
205
|
+
val replacementRange = trackedCompositionReplacementRange()
|
|
206
|
+
clearCompositionTracking()
|
|
154
207
|
|
|
155
208
|
// Prevent selection sync while the base connection commits the composed
|
|
156
209
|
// text, since the Rust document doesn't have it yet.
|
|
157
|
-
editorView.
|
|
158
|
-
|
|
159
|
-
|
|
210
|
+
val result = editorView.runWithTransientInputMutationGuard {
|
|
211
|
+
super.finishComposingText()
|
|
212
|
+
}
|
|
160
213
|
|
|
161
214
|
// Now route the composed text through Rust.
|
|
162
|
-
if (!
|
|
163
|
-
editorView.
|
|
215
|
+
if (replacementRange != null && !composed.isNullOrEmpty()) {
|
|
216
|
+
editorView.handleCompositionCommit(
|
|
217
|
+
composed,
|
|
218
|
+
replacementRange.first,
|
|
219
|
+
replacementRange.second
|
|
220
|
+
)
|
|
164
221
|
}
|
|
165
222
|
return result
|
|
166
223
|
}
|
|
167
224
|
|
|
225
|
+
private fun captureCompositionReplacementRangeIfNeeded() {
|
|
226
|
+
if (trackedCompositionReplacementRange() != null) return
|
|
227
|
+
val start = editorView.selectionStart.coerceAtLeast(0)
|
|
228
|
+
val end = editorView.selectionEnd.coerceAtLeast(0)
|
|
229
|
+
val authorizedLength = editorView.text?.length ?: 0
|
|
230
|
+
composingReplacementStartUtf16 = minOf(start, end).coerceIn(0, authorizedLength)
|
|
231
|
+
composingReplacementEndUtf16 = maxOf(start, end).coerceIn(0, authorizedLength)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private fun trackedCompositionReplacementRange(): Pair<Int, Int>? {
|
|
235
|
+
val start = composingReplacementStartUtf16 ?: return null
|
|
236
|
+
val end = composingReplacementEndUtf16 ?: return null
|
|
237
|
+
return start to end
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private fun clearCompositionTracking() {
|
|
241
|
+
composingText = null
|
|
242
|
+
composingReplacementStartUtf16 = null
|
|
243
|
+
composingReplacementEndUtf16 = null
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private fun refreshComposingTextFromEditable() {
|
|
247
|
+
val editable = editorView.text ?: return
|
|
248
|
+
val start = BaseInputConnection.getComposingSpanStart(editable)
|
|
249
|
+
val end = BaseInputConnection.getComposingSpanEnd(editable)
|
|
250
|
+
if (start < 0 || end < 0 || start > end || end > editable.length) {
|
|
251
|
+
composingText = null
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
composingText = editable.subSequence(start, end).toString()
|
|
255
|
+
}
|
|
256
|
+
|
|
168
257
|
/**
|
|
169
258
|
* Called for hardware keyboard key events.
|
|
170
259
|
*
|
|
@@ -8,32 +8,32 @@
|
|
|
8
8
|
<key>BinaryPath</key>
|
|
9
9
|
<string>libeditor_core.a</string>
|
|
10
10
|
<key>LibraryIdentifier</key>
|
|
11
|
-
<string>ios-
|
|
11
|
+
<string>ios-arm64_x86_64-simulator</string>
|
|
12
12
|
<key>LibraryPath</key>
|
|
13
13
|
<string>libeditor_core.a</string>
|
|
14
14
|
<key>SupportedArchitectures</key>
|
|
15
15
|
<array>
|
|
16
16
|
<string>arm64</string>
|
|
17
|
+
<string>x86_64</string>
|
|
17
18
|
</array>
|
|
18
19
|
<key>SupportedPlatform</key>
|
|
19
20
|
<string>ios</string>
|
|
21
|
+
<key>SupportedPlatformVariant</key>
|
|
22
|
+
<string>simulator</string>
|
|
20
23
|
</dict>
|
|
21
24
|
<dict>
|
|
22
25
|
<key>BinaryPath</key>
|
|
23
26
|
<string>libeditor_core.a</string>
|
|
24
27
|
<key>LibraryIdentifier</key>
|
|
25
|
-
<string>ios-
|
|
28
|
+
<string>ios-arm64</string>
|
|
26
29
|
<key>LibraryPath</key>
|
|
27
30
|
<string>libeditor_core.a</string>
|
|
28
31
|
<key>SupportedArchitectures</key>
|
|
29
32
|
<array>
|
|
30
33
|
<string>arm64</string>
|
|
31
|
-
<string>x86_64</string>
|
|
32
34
|
</array>
|
|
33
35
|
<key>SupportedPlatform</key>
|
|
34
36
|
<string>ios</string>
|
|
35
|
-
<key>SupportedPlatformVariant</key>
|
|
36
|
-
<string>simulator</string>
|
|
37
37
|
</dict>
|
|
38
38
|
</array>
|
|
39
39
|
<key>CFBundlePackageType</key>
|
|
Binary file
|
|
Binary file
|
|
@@ -704,7 +704,8 @@ private final class ImageResizeOverlayView: UIView {
|
|
|
704
704
|
/// For CJK input methods, `setMarkedText` / `unmarkText` are used. During
|
|
705
705
|
/// composition (marked text), we let UITextView handle it normally so the
|
|
706
706
|
/// user sees their composing text. When composition finalizes (`unmarkText`),
|
|
707
|
-
/// we
|
|
707
|
+
/// we commit the final text through Rust at the original Rust-authorized
|
|
708
|
+
/// replacement range.
|
|
708
709
|
///
|
|
709
710
|
/// ## Thread Safety
|
|
710
711
|
///
|
|
@@ -970,9 +971,12 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
970
971
|
private var pendingDeferredImageSelectionRange: NSRange?
|
|
971
972
|
private var pendingDeferredImageSelectionGeneration: UInt64 = 0
|
|
972
973
|
|
|
973
|
-
/// Stores the
|
|
974
|
-
///
|
|
975
|
-
|
|
974
|
+
/// Stores the Rust-authorized scalar range replaced by the active marked
|
|
975
|
+
/// text session. UIKit mutates visible TextKit state during composition,
|
|
976
|
+
/// so final commits must not infer their range from the transient cursor.
|
|
977
|
+
private var markedTextReplacementScalarRange: (from: UInt32, to: UInt32)?
|
|
978
|
+
private var markedTextReplacementUtf16Range: NSRange?
|
|
979
|
+
private var markedTextCompositionText: String?
|
|
976
980
|
|
|
977
981
|
private let editorLayoutManager: EditorLayoutManager
|
|
978
982
|
|
|
@@ -1580,6 +1584,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
1580
1584
|
return
|
|
1581
1585
|
}
|
|
1582
1586
|
|
|
1587
|
+
if markedTextReplacementScalarRange != nil || markedTextRange != nil {
|
|
1588
|
+
let replacementRange = trackedMarkedTextReplacementRange()
|
|
1589
|
+
finishTransientMarkedTextMutation()
|
|
1590
|
+
commitMarkedText(text, replacementRange: replacementRange)
|
|
1591
|
+
return
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1583
1594
|
// Handle Enter/Return as a block split operation.
|
|
1584
1595
|
if text == "\n" {
|
|
1585
1596
|
performInterceptedInput {
|
|
@@ -1664,6 +1675,15 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
1664
1675
|
return
|
|
1665
1676
|
}
|
|
1666
1677
|
|
|
1678
|
+
if markedTextReplacementScalarRange != nil || markedTextRange != nil {
|
|
1679
|
+
performTransientTextMutation {
|
|
1680
|
+
super.deleteBackward()
|
|
1681
|
+
}
|
|
1682
|
+
refreshMarkedTextCompositionText()
|
|
1683
|
+
isComposing = markedTextRange != nil || markedTextReplacementScalarRange != nil
|
|
1684
|
+
return
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1667
1687
|
guard let selectedRange = selectedTextRange else { return }
|
|
1668
1688
|
Self.inputLog.debug(
|
|
1669
1689
|
"[deleteBackward] selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
|
|
@@ -1858,6 +1878,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
1858
1878
|
return
|
|
1859
1879
|
}
|
|
1860
1880
|
|
|
1881
|
+
if markedTextReplacementScalarRange != nil || markedTextRange != nil {
|
|
1882
|
+
let replacementRange = trackedMarkedTextReplacementRange()
|
|
1883
|
+
finishTransientMarkedTextMutation()
|
|
1884
|
+
commitMarkedText(text, replacementRange: replacementRange)
|
|
1885
|
+
return
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1861
1888
|
let scalarRange = PositionBridge.textRangeToScalarRange(range, in: self)
|
|
1862
1889
|
Self.inputLog.debug(
|
|
1863
1890
|
"[replace] text=\(self.preview(text), privacy: .public) scalarRange=\(scalarRange.from)-\(scalarRange.to) selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
|
|
@@ -1884,46 +1911,178 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
1884
1911
|
/// decoration). The text is NOT sent to Rust during composition.
|
|
1885
1912
|
override func setMarkedText(_ markedText: String?, selectedRange: NSRange) {
|
|
1886
1913
|
ensureInternalTextViewDelegate()
|
|
1887
|
-
|
|
1914
|
+
if markedText != nil {
|
|
1915
|
+
captureMarkedTextReplacementRangeIfNeeded()
|
|
1916
|
+
}
|
|
1917
|
+
isComposing = markedText != nil || markedTextReplacementScalarRange != nil
|
|
1888
1918
|
Self.inputLog.debug(
|
|
1889
1919
|
"[setMarkedText] marked=\(self.preview(markedText ?? ""), privacy: .public) nsRange=\(selectedRange.location),\(selectedRange.length) selection=\(self.selectionSummary(), privacy: .public)"
|
|
1890
1920
|
)
|
|
1891
|
-
|
|
1921
|
+
performTransientTextMutation {
|
|
1922
|
+
super.setMarkedText(markedText, selectedRange: selectedRange)
|
|
1923
|
+
}
|
|
1924
|
+
if markedText == nil {
|
|
1925
|
+
clearMarkedTextTracking()
|
|
1926
|
+
restoreAuthorizedTextAfterCancelledCompositionIfNeeded()
|
|
1927
|
+
} else {
|
|
1928
|
+
refreshMarkedTextCompositionText(fallback: markedText)
|
|
1929
|
+
}
|
|
1892
1930
|
}
|
|
1893
1931
|
|
|
1894
1932
|
/// Called when composition is finalized (user selects a candidate or
|
|
1895
1933
|
/// presses space/enter to commit).
|
|
1896
1934
|
///
|
|
1897
|
-
/// At this point, the composed text is final. We capture it and
|
|
1898
|
-
///
|
|
1899
|
-
///
|
|
1900
|
-
/// but we intercept at a higher level.
|
|
1935
|
+
/// At this point, the composed text is final. We capture it and commit it
|
|
1936
|
+
/// to Rust at the original replacement range captured before UIKit mutated
|
|
1937
|
+
/// the transient text storage.
|
|
1901
1938
|
override func unmarkText() {
|
|
1902
1939
|
ensureInternalTextViewDelegate()
|
|
1903
|
-
|
|
1904
|
-
|
|
1940
|
+
let composedText = currentMarkedTextForCommit()
|
|
1941
|
+
let replacementRange = trackedMarkedTextReplacementRange()
|
|
1905
1942
|
|
|
1906
|
-
|
|
1907
|
-
// the Rust document doesn't have the composed text yet.
|
|
1908
|
-
isApplyingRustState = true
|
|
1909
|
-
super.unmarkText()
|
|
1910
|
-
isApplyingRustState = false
|
|
1911
|
-
isComposing = false
|
|
1943
|
+
finishTransientMarkedTextMutation()
|
|
1912
1944
|
|
|
1913
|
-
|
|
1914
|
-
// of the composed text, so the insert position is cursor - length.
|
|
1915
|
-
if let composed = composedText, !composed.isEmpty, editorId != 0 {
|
|
1916
|
-
let cursorPos = PositionBridge.cursorScalarOffset(in: self)
|
|
1917
|
-
let composedScalars = UInt32(composed.unicodeScalars.count)
|
|
1918
|
-
let insertPos = cursorPos >= composedScalars ? cursorPos - composedScalars : 0
|
|
1945
|
+
if let composed = composedText, !composed.isEmpty {
|
|
1919
1946
|
Self.inputLog.debug(
|
|
1920
|
-
"[unmarkText] composed=\(self.preview(composed), privacy: .public)
|
|
1947
|
+
"[unmarkText] composed=\(self.preview(composed), privacy: .public) replacement=\(self.previewMarkedTextReplacementRange(replacementRange), privacy: .public) selection=\(self.selectionSummary(), privacy: .public)"
|
|
1948
|
+
)
|
|
1949
|
+
commitMarkedText(composed, replacementRange: replacementRange)
|
|
1950
|
+
} else {
|
|
1951
|
+
restoreAuthorizedTextAfterCancelledCompositionIfNeeded()
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
private func captureMarkedTextReplacementRangeIfNeeded() {
|
|
1956
|
+
guard markedTextReplacementScalarRange == nil else { return }
|
|
1957
|
+
|
|
1958
|
+
guard let selectedRange = selectedTextRange else {
|
|
1959
|
+
let scalarPos = PositionBridge.cursorScalarOffset(in: self)
|
|
1960
|
+
markedTextReplacementScalarRange = (from: scalarPos, to: scalarPos)
|
|
1961
|
+
markedTextReplacementUtf16Range = NSRange(
|
|
1962
|
+
location: Int(scalarPos),
|
|
1963
|
+
length: 0
|
|
1921
1964
|
)
|
|
1965
|
+
return
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
let scalarRange = PositionBridge.textRangeToScalarRange(selectedRange, in: self)
|
|
1969
|
+
let startUtf16 = offset(from: beginningOfDocument, to: selectedRange.start)
|
|
1970
|
+
let endUtf16 = offset(from: beginningOfDocument, to: selectedRange.end)
|
|
1971
|
+
|
|
1972
|
+
markedTextReplacementScalarRange = (from: scalarRange.from, to: scalarRange.to)
|
|
1973
|
+
markedTextReplacementUtf16Range = NSRange(
|
|
1974
|
+
location: min(startUtf16, endUtf16),
|
|
1975
|
+
length: abs(endUtf16 - startUtf16)
|
|
1976
|
+
)
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
private func trackedMarkedTextReplacementRange() -> (from: UInt32, to: UInt32)? {
|
|
1980
|
+
if let markedTextReplacementScalarRange {
|
|
1981
|
+
return markedTextReplacementScalarRange
|
|
1982
|
+
}
|
|
1983
|
+
guard let selectedRange = selectedTextRange else { return nil }
|
|
1984
|
+
let scalarRange = PositionBridge.textRangeToScalarRange(selectedRange, in: self)
|
|
1985
|
+
return (from: scalarRange.from, to: scalarRange.to)
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
private func clearMarkedTextTracking() {
|
|
1989
|
+
markedTextReplacementScalarRange = nil
|
|
1990
|
+
markedTextReplacementUtf16Range = nil
|
|
1991
|
+
markedTextCompositionText = nil
|
|
1992
|
+
isComposing = false
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
private func finishTransientMarkedTextMutation() {
|
|
1996
|
+
performTransientTextMutation {
|
|
1997
|
+
super.unmarkText()
|
|
1998
|
+
}
|
|
1999
|
+
clearMarkedTextTracking()
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
private func performTransientTextMutation(_ action: () -> Void) {
|
|
2003
|
+
let wasApplyingRustState = isApplyingRustState
|
|
2004
|
+
isApplyingRustState = true
|
|
2005
|
+
action()
|
|
2006
|
+
isApplyingRustState = wasApplyingRustState
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
private func currentMarkedTextForCommit() -> String? {
|
|
2010
|
+
markedTextRange.flatMap { text(in: $0) }
|
|
2011
|
+
?? markedTextCompositionText
|
|
2012
|
+
?? transientMarkedTextFromAuthorizedDiff()
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
private func refreshMarkedTextCompositionText(fallback: String? = nil) {
|
|
2016
|
+
markedTextCompositionText = markedTextRange.flatMap { text(in: $0) }
|
|
2017
|
+
?? transientMarkedTextFromAuthorizedDiff()
|
|
2018
|
+
?? fallback
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
private func transientMarkedTextFromAuthorizedDiff() -> String? {
|
|
2022
|
+
guard let replacementRange = markedTextReplacementUtf16Range else { return nil }
|
|
2023
|
+
|
|
2024
|
+
let currentText = textStorage.string as NSString
|
|
2025
|
+
let authorizedText = lastAuthorizedText as NSString
|
|
2026
|
+
let replacementEnd = replacementRange.location + replacementRange.length
|
|
2027
|
+
guard replacementRange.location >= 0,
|
|
2028
|
+
replacementEnd <= authorizedText.length
|
|
2029
|
+
else {
|
|
2030
|
+
return nil
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
let insertedLength = currentText.length - (authorizedText.length - replacementRange.length)
|
|
2034
|
+
guard insertedLength >= 0,
|
|
2035
|
+
replacementRange.location + insertedLength <= currentText.length
|
|
2036
|
+
else {
|
|
2037
|
+
return nil
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
return currentText.substring(
|
|
2041
|
+
with: NSRange(location: replacementRange.location, length: insertedLength)
|
|
2042
|
+
)
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
private func commitMarkedText(
|
|
2046
|
+
_ text: String,
|
|
2047
|
+
replacementRange: (from: UInt32, to: UInt32)?
|
|
2048
|
+
) {
|
|
2049
|
+
guard editorId != 0 else { return }
|
|
2050
|
+
guard let replacementRange else {
|
|
1922
2051
|
performInterceptedInput {
|
|
1923
|
-
insertTextInRust(
|
|
2052
|
+
insertTextInRust(text, at: PositionBridge.cursorScalarOffset(in: self))
|
|
1924
2053
|
}
|
|
2054
|
+
return
|
|
1925
2055
|
}
|
|
1926
|
-
|
|
2056
|
+
|
|
2057
|
+
performInterceptedInput {
|
|
2058
|
+
if replacementRange.from == replacementRange.to {
|
|
2059
|
+
insertTextInRust(text, at: replacementRange.from)
|
|
2060
|
+
} else {
|
|
2061
|
+
replaceTextRangeInRust(
|
|
2062
|
+
from: replacementRange.from,
|
|
2063
|
+
to: replacementRange.to,
|
|
2064
|
+
with: text
|
|
2065
|
+
)
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
private func restoreAuthorizedTextAfterCancelledCompositionIfNeeded() {
|
|
2071
|
+
guard editorId != 0 else { return }
|
|
2072
|
+
guard textStorage.string != lastAuthorizedText else { return }
|
|
2073
|
+
|
|
2074
|
+
let stateJSON = editorGetCurrentState(id: editorId)
|
|
2075
|
+
applyUpdateJSON(stateJSON)
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
private func previewMarkedTextReplacementRange(
|
|
2079
|
+
_ range: (from: UInt32, to: UInt32)?
|
|
2080
|
+
) -> String {
|
|
2081
|
+
guard let range else { return "none" }
|
|
2082
|
+
let utf16 = markedTextReplacementUtf16Range
|
|
2083
|
+
.map { "\($0.location)..<\($0.location + $0.length)" }
|
|
2084
|
+
?? "none"
|
|
2085
|
+
return "scalar=\(range.from)..<\(range.to) utf16=\(utf16)"
|
|
1927
2086
|
}
|
|
1928
2087
|
|
|
1929
2088
|
// MARK: - Paste Handling
|
|
@@ -1993,7 +2152,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
1993
2152
|
func textViewDidChangeSelection(_ textView: UITextView) {
|
|
1994
2153
|
guard textView === self else { return }
|
|
1995
2154
|
ensureInternalTextViewDelegate()
|
|
1996
|
-
guard !isApplyingRustState else { return }
|
|
2155
|
+
guard !isApplyingRustState, !isComposing else { return }
|
|
1997
2156
|
if normalizeSelectionForEmptyBlockAutocapitalizationIfNeeded() {
|
|
1998
2157
|
return
|
|
1999
2158
|
}
|
|
@@ -2174,7 +2333,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
2174
2333
|
}
|
|
2175
2334
|
|
|
2176
2335
|
private func syncSelectionToRustAndNotifyDelegate() {
|
|
2177
|
-
guard !isApplyingRustState, editorId != 0 else { return }
|
|
2336
|
+
guard !isApplyingRustState, !isComposing, editorId != 0 else { return }
|
|
2178
2337
|
guard let range = selectedTextRange else { return }
|
|
2179
2338
|
|
|
2180
2339
|
let anchor = PositionBridge.textViewToScalar(range.start, in: self)
|
|
@@ -2768,6 +2927,19 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
2768
2927
|
applyUpdateJSON(updateJSON)
|
|
2769
2928
|
}
|
|
2770
2929
|
|
|
2930
|
+
private func replaceTextRangeInRust(from: UInt32, to: UInt32, with text: String) {
|
|
2931
|
+
Self.inputLog.debug(
|
|
2932
|
+
"[rust.replaceTextScalar] text=\(self.preview(text), privacy: .public) scalar=\(from)-\(to) selection=\(self.selectionSummary(), privacy: .public)"
|
|
2933
|
+
)
|
|
2934
|
+
let updateJSON = editorReplaceTextScalar(
|
|
2935
|
+
id: editorId,
|
|
2936
|
+
scalarFrom: from,
|
|
2937
|
+
scalarTo: to,
|
|
2938
|
+
text: text
|
|
2939
|
+
)
|
|
2940
|
+
applyUpdateJSON(updateJSON)
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2771
2943
|
private func insertNodeInRust(_ nodeType: String) {
|
|
2772
2944
|
guard let selection = currentScalarSelection() else { return }
|
|
2773
2945
|
Self.inputLog.debug(
|
|
@@ -4205,7 +4377,7 @@ extension EditorTextView: NSTextStorageDelegate {
|
|
|
4205
4377
|
guard editedMask.contains(.editedCharacters) else { return }
|
|
4206
4378
|
|
|
4207
4379
|
// Skip if this change came from our own Rust apply path.
|
|
4208
|
-
guard !isApplyingRustState, !isInterceptingInput else { return }
|
|
4380
|
+
guard !isApplyingRustState, !isInterceptingInput, !isComposing else { return }
|
|
4209
4381
|
|
|
4210
4382
|
// Skip if no editor is bound yet (nothing to reconcile against).
|
|
4211
4383
|
guard editorId != 0 else { return }
|
|
@@ -4247,7 +4419,7 @@ extension EditorTextView: NSTextStorageDelegate {
|
|
|
4247
4419
|
guard let self else { return }
|
|
4248
4420
|
self.reconciliationWorkScheduled = false
|
|
4249
4421
|
|
|
4250
|
-
guard !self.isApplyingRustState, !self.isInterceptingInput else { return }
|
|
4422
|
+
guard !self.isApplyingRustState, !self.isInterceptingInput, !self.isComposing else { return }
|
|
4251
4423
|
guard self.editorId != 0 else { return }
|
|
4252
4424
|
guard self.textStorage.string != self.lastAuthorizedText else { return }
|
|
4253
4425
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apollohg/react-native-prose-editor",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.10",
|
|
4
4
|
"description": "Native rich text editor with Rust core for React Native",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://github.com/apollohg/react-native-prose-editor",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|