@apollohg/react-native-prose-editor 0.5.8 → 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/android/src/main/java/com/apollohg/editor/EditorTheme.kt +4 -0
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +4 -1
- 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/NativeEditorExpoView.swift +63 -3
- package/ios/RichTextEditorView.swift +236 -35
- 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
|
*
|
|
@@ -362,6 +362,10 @@ private fun parseColor(raw: String?): Int? {
|
|
|
362
362
|
val value = raw?.trim()?.lowercase() ?: return null
|
|
363
363
|
if (value.isEmpty()) return null
|
|
364
364
|
|
|
365
|
+
when (value) {
|
|
366
|
+
"clear", "transparent" -> return Color.TRANSPARENT
|
|
367
|
+
}
|
|
368
|
+
|
|
365
369
|
parseCssHexColor(value)?.let { return it }
|
|
366
370
|
|
|
367
371
|
try {
|
|
@@ -54,6 +54,7 @@ class RichTextEditorView @JvmOverloads constructor(
|
|
|
54
54
|
private var baseBackgroundColor: Int = Color.WHITE
|
|
55
55
|
private var viewportBottomInsetPx: Int = 0
|
|
56
56
|
internal var appliedCornerRadiusPx: Float = 0f
|
|
57
|
+
internal var appliedBackgroundColorForTesting: Int = Color.WHITE
|
|
57
58
|
|
|
58
59
|
/** Binds or unbinds the Rust editor instance. */
|
|
59
60
|
var editorId: Long = 0
|
|
@@ -251,13 +252,15 @@ class RichTextEditorView @JvmOverloads constructor(
|
|
|
251
252
|
|
|
252
253
|
private fun updateScrollContainerAppearance() {
|
|
253
254
|
val cornerRadiusPx = (theme?.borderRadius ?: 0f) * resources.displayMetrics.density
|
|
255
|
+
val backgroundColor = theme?.backgroundColor ?: baseBackgroundColor
|
|
254
256
|
editorViewport.background = GradientDrawable().apply {
|
|
255
257
|
cornerRadius = cornerRadiusPx
|
|
256
|
-
setColor(
|
|
258
|
+
setColor(backgroundColor)
|
|
257
259
|
}
|
|
258
260
|
editorViewport.clipToOutline = cornerRadiusPx > 0f
|
|
259
261
|
editorScrollView.setBackgroundColor(Color.TRANSPARENT)
|
|
260
262
|
appliedCornerRadiusPx = cornerRadiusPx
|
|
263
|
+
appliedBackgroundColorForTesting = backgroundColor
|
|
261
264
|
}
|
|
262
265
|
|
|
263
266
|
private fun updateScrollContainerInsets() {
|
|
@@ -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
|
|
@@ -1576,6 +1576,46 @@ final class EditorAccessoryToolbarView: UIInputView {
|
|
|
1576
1576
|
}
|
|
1577
1577
|
}
|
|
1578
1578
|
|
|
1579
|
+
/// Keeps iOS keyboard integrations on the inputAccessoryView path when the
|
|
1580
|
+
/// visible toolbar is rendered outside the native keyboard accessory.
|
|
1581
|
+
final class EditorAccessoryPlaceholderView: UIView {
|
|
1582
|
+
override init(frame: CGRect) {
|
|
1583
|
+
super.init(
|
|
1584
|
+
frame: CGRect(
|
|
1585
|
+
x: frame.origin.x,
|
|
1586
|
+
y: frame.origin.y,
|
|
1587
|
+
width: frame.width,
|
|
1588
|
+
height: 0
|
|
1589
|
+
)
|
|
1590
|
+
)
|
|
1591
|
+
commonInit()
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
required init?(coder: NSCoder) {
|
|
1595
|
+
return nil
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
override var intrinsicContentSize: CGSize {
|
|
1599
|
+
CGSize(width: UIView.noIntrinsicMetric, height: 0)
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
|
1603
|
+
CGSize(width: size.width, height: 0)
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
|
1607
|
+
false
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
private func commonInit() {
|
|
1611
|
+
frame.size.height = 0
|
|
1612
|
+
backgroundColor = .clear
|
|
1613
|
+
isOpaque = false
|
|
1614
|
+
isUserInteractionEnabled = false
|
|
1615
|
+
autoresizingMask = [.flexibleWidth]
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1579
1619
|
class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognizerDelegate {
|
|
1580
1620
|
|
|
1581
1621
|
// MARK: - Subviews
|
|
@@ -1585,6 +1625,7 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
|
|
|
1585
1625
|
frame: .zero,
|
|
1586
1626
|
inputViewStyle: .keyboard
|
|
1587
1627
|
)
|
|
1628
|
+
private let accessoryPlaceholder = EditorAccessoryPlaceholderView(frame: .zero)
|
|
1588
1629
|
private var toolbarFrameInWindow: CGRect?
|
|
1589
1630
|
private var didApplyAutoFocus = false
|
|
1590
1631
|
private var toolbarState = NativeToolbarState.empty
|
|
@@ -2269,14 +2310,33 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
|
|
|
2269
2310
|
func triggerMentionSuggestionTapForTesting(at index: Int) {
|
|
2270
2311
|
accessoryToolbar.triggerMentionSuggestionTapForTesting(at: index)
|
|
2271
2312
|
}
|
|
2313
|
+
|
|
2314
|
+
func inputAccessoryViewForTesting() -> UIView? {
|
|
2315
|
+
richTextView.textView.inputAccessoryView
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
func isUsingAccessoryToolbarForTesting() -> Bool {
|
|
2319
|
+
richTextView.textView.inputAccessoryView === accessoryToolbar
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
func isUsingAccessoryPlaceholderForTesting() -> Bool {
|
|
2323
|
+
richTextView.textView.inputAccessoryView === accessoryPlaceholder
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2272
2326
|
private func updateAccessoryToolbarVisibility() {
|
|
2273
2327
|
refreshSystemAssistantToolbarIfNeeded()
|
|
2274
|
-
let nextAccessoryView: UIView?
|
|
2328
|
+
let nextAccessoryView: UIView?
|
|
2329
|
+
if showsToolbar &&
|
|
2275
2330
|
toolbarPlacement == "keyboard" &&
|
|
2276
2331
|
richTextView.textView.isEditable &&
|
|
2277
2332
|
!shouldUseSystemAssistantToolbar
|
|
2278
|
-
|
|
2279
|
-
|
|
2333
|
+
{
|
|
2334
|
+
nextAccessoryView = accessoryToolbar
|
|
2335
|
+
} else if richTextView.textView.isEditable && !shouldUseSystemAssistantToolbar {
|
|
2336
|
+
nextAccessoryView = accessoryPlaceholder
|
|
2337
|
+
} else {
|
|
2338
|
+
nextAccessoryView = nil
|
|
2339
|
+
}
|
|
2280
2340
|
if richTextView.textView.inputAccessoryView !== nextAccessoryView {
|
|
2281
2341
|
richTextView.textView.inputAccessoryView = nextAccessoryView
|
|
2282
2342
|
if richTextView.textView.isFirstResponder {
|
|
@@ -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
|
|
|
@@ -1050,7 +1054,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
1050
1054
|
// Register as the text storage delegate so we can detect unauthorized
|
|
1051
1055
|
// mutations (reconciliation fallback).
|
|
1052
1056
|
textStorage.delegate = self
|
|
1053
|
-
|
|
1057
|
+
ensureInternalTextViewDelegate()
|
|
1054
1058
|
addGestureRecognizer(imageSelectionTapRecognizer)
|
|
1055
1059
|
installImageSelectionTapDependencies()
|
|
1056
1060
|
|
|
@@ -1161,6 +1165,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
1161
1165
|
override func becomeFirstResponder() -> Bool {
|
|
1162
1166
|
let didBecomeFirstResponder = super.becomeFirstResponder()
|
|
1163
1167
|
if didBecomeFirstResponder {
|
|
1168
|
+
ensureInternalTextViewDelegate()
|
|
1169
|
+
DispatchQueue.main.async { [weak self] in
|
|
1170
|
+
self?.ensureInternalTextViewDelegate()
|
|
1171
|
+
}
|
|
1164
1172
|
_ = normalizeSelectionForEmptyBlockAutocapitalizationIfNeeded()
|
|
1165
1173
|
refreshTypingAttributesForSelection()
|
|
1166
1174
|
}
|
|
@@ -1340,6 +1348,10 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
1340
1348
|
lastApplyUpdateTraceForTesting
|
|
1341
1349
|
}
|
|
1342
1350
|
|
|
1351
|
+
func isUsingInternalTextViewDelegateForTesting() -> Bool {
|
|
1352
|
+
(delegate as AnyObject?) === (self as AnyObject)
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1343
1355
|
func blockquoteStripeRectsForTesting() -> [CGRect] {
|
|
1344
1356
|
editorLayoutManager.blockquoteStripeRectsForTesting(in: textStorage)
|
|
1345
1357
|
}
|
|
@@ -1533,6 +1545,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
1533
1545
|
/// - id: The editor ID from `editor_create()`.
|
|
1534
1546
|
/// - initialHTML: Optional HTML to set as initial content.
|
|
1535
1547
|
func bindEditor(id: UInt64, initialHTML: String? = nil) {
|
|
1548
|
+
ensureInternalTextViewDelegate()
|
|
1536
1549
|
editorId = id
|
|
1537
1550
|
|
|
1538
1551
|
if let html = initialHTML, !html.isEmpty {
|
|
@@ -1561,6 +1574,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
1561
1574
|
/// Instead of calling `super.insertText()` (which would modify the
|
|
1562
1575
|
/// underlying text storage directly), we route through Rust.
|
|
1563
1576
|
override func insertText(_ text: String) {
|
|
1577
|
+
ensureInternalTextViewDelegate()
|
|
1564
1578
|
guard !isApplyingRustState else {
|
|
1565
1579
|
super.insertText(text)
|
|
1566
1580
|
return
|
|
@@ -1570,6 +1584,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
1570
1584
|
return
|
|
1571
1585
|
}
|
|
1572
1586
|
|
|
1587
|
+
if markedTextReplacementScalarRange != nil || markedTextRange != nil {
|
|
1588
|
+
let replacementRange = trackedMarkedTextReplacementRange()
|
|
1589
|
+
finishTransientMarkedTextMutation()
|
|
1590
|
+
commitMarkedText(text, replacementRange: replacementRange)
|
|
1591
|
+
return
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1573
1594
|
// Handle Enter/Return as a block split operation.
|
|
1574
1595
|
if text == "\n" {
|
|
1575
1596
|
performInterceptedInput {
|
|
@@ -1644,6 +1665,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
1644
1665
|
/// If there's a range selection, delete the range. If it's a cursor,
|
|
1645
1666
|
/// delete the character (grapheme cluster) before the cursor.
|
|
1646
1667
|
override func deleteBackward() {
|
|
1668
|
+
ensureInternalTextViewDelegate()
|
|
1647
1669
|
guard !isApplyingRustState else {
|
|
1648
1670
|
super.deleteBackward()
|
|
1649
1671
|
return
|
|
@@ -1653,6 +1675,15 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
1653
1675
|
return
|
|
1654
1676
|
}
|
|
1655
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
|
+
|
|
1656
1687
|
guard let selectedRange = selectedTextRange else { return }
|
|
1657
1688
|
Self.inputLog.debug(
|
|
1658
1689
|
"[deleteBackward] selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
|
|
@@ -1837,6 +1868,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
1837
1868
|
///
|
|
1838
1869
|
/// We route the replacement through Rust to keep the document model in sync.
|
|
1839
1870
|
override func replace(_ range: UITextRange, withText text: String) {
|
|
1871
|
+
ensureInternalTextViewDelegate()
|
|
1840
1872
|
guard !isApplyingRustState else {
|
|
1841
1873
|
super.replace(range, withText: text)
|
|
1842
1874
|
return
|
|
@@ -1846,6 +1878,13 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
1846
1878
|
return
|
|
1847
1879
|
}
|
|
1848
1880
|
|
|
1881
|
+
if markedTextReplacementScalarRange != nil || markedTextRange != nil {
|
|
1882
|
+
let replacementRange = trackedMarkedTextReplacementRange()
|
|
1883
|
+
finishTransientMarkedTextMutation()
|
|
1884
|
+
commitMarkedText(text, replacementRange: replacementRange)
|
|
1885
|
+
return
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1849
1888
|
let scalarRange = PositionBridge.textRangeToScalarRange(range, in: self)
|
|
1850
1889
|
Self.inputLog.debug(
|
|
1851
1890
|
"[replace] text=\(self.preview(text), privacy: .public) scalarRange=\(scalarRange.from)-\(scalarRange.to) selection=\(self.selectionSummary(), privacy: .public) textState=\(self.textSnapshotSummary(), privacy: .public)"
|
|
@@ -1871,45 +1910,179 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
1871
1910
|
/// UITextView display the composing text normally (with its underline
|
|
1872
1911
|
/// decoration). The text is NOT sent to Rust during composition.
|
|
1873
1912
|
override func setMarkedText(_ markedText: String?, selectedRange: NSRange) {
|
|
1874
|
-
|
|
1913
|
+
ensureInternalTextViewDelegate()
|
|
1914
|
+
if markedText != nil {
|
|
1915
|
+
captureMarkedTextReplacementRangeIfNeeded()
|
|
1916
|
+
}
|
|
1917
|
+
isComposing = markedText != nil || markedTextReplacementScalarRange != nil
|
|
1875
1918
|
Self.inputLog.debug(
|
|
1876
1919
|
"[setMarkedText] marked=\(self.preview(markedText ?? ""), privacy: .public) nsRange=\(selectedRange.location),\(selectedRange.length) selection=\(self.selectionSummary(), privacy: .public)"
|
|
1877
1920
|
)
|
|
1878
|
-
|
|
1921
|
+
performTransientTextMutation {
|
|
1922
|
+
super.setMarkedText(markedText, selectedRange: selectedRange)
|
|
1923
|
+
}
|
|
1924
|
+
if markedText == nil {
|
|
1925
|
+
clearMarkedTextTracking()
|
|
1926
|
+
restoreAuthorizedTextAfterCancelledCompositionIfNeeded()
|
|
1927
|
+
} else {
|
|
1928
|
+
refreshMarkedTextCompositionText(fallback: markedText)
|
|
1929
|
+
}
|
|
1879
1930
|
}
|
|
1880
1931
|
|
|
1881
1932
|
/// Called when composition is finalized (user selects a candidate or
|
|
1882
1933
|
/// presses space/enter to commit).
|
|
1883
1934
|
///
|
|
1884
|
-
/// At this point, the composed text is final. We capture it and
|
|
1885
|
-
///
|
|
1886
|
-
///
|
|
1887
|
-
/// 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.
|
|
1888
1938
|
override func unmarkText() {
|
|
1889
|
-
|
|
1890
|
-
composedText =
|
|
1939
|
+
ensureInternalTextViewDelegate()
|
|
1940
|
+
let composedText = currentMarkedTextForCommit()
|
|
1941
|
+
let replacementRange = trackedMarkedTextReplacementRange()
|
|
1891
1942
|
|
|
1892
|
-
|
|
1893
|
-
// the Rust document doesn't have the composed text yet.
|
|
1894
|
-
isApplyingRustState = true
|
|
1895
|
-
super.unmarkText()
|
|
1896
|
-
isApplyingRustState = false
|
|
1897
|
-
isComposing = false
|
|
1943
|
+
finishTransientMarkedTextMutation()
|
|
1898
1944
|
|
|
1899
|
-
|
|
1900
|
-
// of the composed text, so the insert position is cursor - length.
|
|
1901
|
-
if let composed = composedText, !composed.isEmpty, editorId != 0 {
|
|
1902
|
-
let cursorPos = PositionBridge.cursorScalarOffset(in: self)
|
|
1903
|
-
let composedScalars = UInt32(composed.unicodeScalars.count)
|
|
1904
|
-
let insertPos = cursorPos >= composedScalars ? cursorPos - composedScalars : 0
|
|
1945
|
+
if let composed = composedText, !composed.isEmpty {
|
|
1905
1946
|
Self.inputLog.debug(
|
|
1906
|
-
"[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)"
|
|
1907
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
|
|
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 {
|
|
1908
2051
|
performInterceptedInput {
|
|
1909
|
-
insertTextInRust(
|
|
2052
|
+
insertTextInRust(text, at: PositionBridge.cursorScalarOffset(in: self))
|
|
2053
|
+
}
|
|
2054
|
+
return
|
|
2055
|
+
}
|
|
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
|
+
)
|
|
1910
2066
|
}
|
|
1911
2067
|
}
|
|
1912
|
-
|
|
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)"
|
|
1913
2086
|
}
|
|
1914
2087
|
|
|
1915
2088
|
// MARK: - Paste Handling
|
|
@@ -1919,6 +2092,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
1919
2092
|
/// Attempts to extract HTML from the pasteboard first (for rich text paste),
|
|
1920
2093
|
/// falling back to plain text.
|
|
1921
2094
|
override func paste(_ sender: Any?) {
|
|
2095
|
+
ensureInternalTextViewDelegate()
|
|
1922
2096
|
guard editorId != 0 else {
|
|
1923
2097
|
super.paste(sender)
|
|
1924
2098
|
return
|
|
@@ -1977,7 +2151,8 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
1977
2151
|
/// internally during tap handling and word-boundary resolution.
|
|
1978
2152
|
func textViewDidChangeSelection(_ textView: UITextView) {
|
|
1979
2153
|
guard textView === self else { return }
|
|
1980
|
-
|
|
2154
|
+
ensureInternalTextViewDelegate()
|
|
2155
|
+
guard !isApplyingRustState, !isComposing else { return }
|
|
1981
2156
|
if normalizeSelectionForEmptyBlockAutocapitalizationIfNeeded() {
|
|
1982
2157
|
return
|
|
1983
2158
|
}
|
|
@@ -2029,6 +2204,14 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
2029
2204
|
interceptedInputDepth > 0
|
|
2030
2205
|
}
|
|
2031
2206
|
|
|
2207
|
+
private func ensureInternalTextViewDelegate() {
|
|
2208
|
+
// Some keyboard integrations replace UITextView's private delegate ivar
|
|
2209
|
+
// directly. The editor must own delegate callbacks so external observers
|
|
2210
|
+
// cannot inspect transient TextKit state during Rust-driven edits.
|
|
2211
|
+
guard (delegate as AnyObject?) !== (self as AnyObject) else { return }
|
|
2212
|
+
delegate = self
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2032
2215
|
private func performInterceptedInput(_ action: () -> Void) {
|
|
2033
2216
|
interceptedInputDepth += 1
|
|
2034
2217
|
Self.inputLog.debug(
|
|
@@ -2150,7 +2333,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
2150
2333
|
}
|
|
2151
2334
|
|
|
2152
2335
|
private func syncSelectionToRustAndNotifyDelegate() {
|
|
2153
|
-
guard !isApplyingRustState, editorId != 0 else { return }
|
|
2336
|
+
guard !isApplyingRustState, !isComposing, editorId != 0 else { return }
|
|
2154
2337
|
guard let range = selectedTextRange else { return }
|
|
2155
2338
|
|
|
2156
2339
|
let anchor = PositionBridge.textViewToScalar(range.start, in: self)
|
|
@@ -2744,6 +2927,19 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
2744
2927
|
applyUpdateJSON(updateJSON)
|
|
2745
2928
|
}
|
|
2746
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
|
+
|
|
2747
2943
|
private func insertNodeInRust(_ nodeType: String) {
|
|
2748
2944
|
guard let selection = currentScalarSelection() else { return }
|
|
2749
2945
|
Self.inputLog.debug(
|
|
@@ -3236,12 +3432,11 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
3236
3432
|
var stringMutationNanos: UInt64 = 0
|
|
3237
3433
|
var attributeMutationNanos: UInt64 = 0
|
|
3238
3434
|
let previousTextStorageDelegate = textStorage.delegate
|
|
3239
|
-
let previousTextViewDelegate = delegate
|
|
3240
3435
|
textStorage.delegate = nil
|
|
3241
3436
|
delegate = nil
|
|
3242
3437
|
defer {
|
|
3243
3438
|
textStorage.delegate = previousTextStorageDelegate
|
|
3244
|
-
|
|
3439
|
+
ensureInternalTextViewDelegate()
|
|
3245
3440
|
}
|
|
3246
3441
|
if let replaceRange {
|
|
3247
3442
|
if shouldUseSmallPatchTextMutation {
|
|
@@ -3810,6 +4005,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
3810
4005
|
///
|
|
3811
4006
|
/// - Parameter updateJSON: The JSON string from editor_insert_text, etc.
|
|
3812
4007
|
func applyUpdateJSON(_ updateJSON: String, notifyDelegate: Bool = true) {
|
|
4008
|
+
ensureInternalTextViewDelegate()
|
|
3813
4009
|
let totalStartedAt = DispatchTime.now().uptimeNanoseconds
|
|
3814
4010
|
let parseStartedAt = totalStartedAt
|
|
3815
4011
|
guard let data = updateJSON.data(using: .utf8),
|
|
@@ -3996,6 +4192,7 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
3996
4192
|
/// Used for initial content loading (set_html / set_json return render
|
|
3997
4193
|
/// elements directly, not wrapped in an EditorUpdate).
|
|
3998
4194
|
func applyRenderJSON(_ renderJSON: String) {
|
|
4195
|
+
ensureInternalTextViewDelegate()
|
|
3999
4196
|
Self.updateLog.debug(
|
|
4000
4197
|
"[applyRenderJSON.begin] before=\(self.textSnapshotSummary(), privacy: .public)"
|
|
4001
4198
|
)
|
|
@@ -4031,7 +4228,11 @@ final class EditorTextView: UITextView, UITextViewDelegate, UIGestureRecognizerD
|
|
|
4031
4228
|
|
|
4032
4229
|
let totalStartedAt = DispatchTime.now().uptimeNanoseconds
|
|
4033
4230
|
isApplyingRustState = true
|
|
4034
|
-
|
|
4231
|
+
delegate = nil
|
|
4232
|
+
defer {
|
|
4233
|
+
ensureInternalTextViewDelegate()
|
|
4234
|
+
isApplyingRustState = false
|
|
4235
|
+
}
|
|
4035
4236
|
|
|
4036
4237
|
switch type {
|
|
4037
4238
|
case "text":
|
|
@@ -4176,7 +4377,7 @@ extension EditorTextView: NSTextStorageDelegate {
|
|
|
4176
4377
|
guard editedMask.contains(.editedCharacters) else { return }
|
|
4177
4378
|
|
|
4178
4379
|
// Skip if this change came from our own Rust apply path.
|
|
4179
|
-
guard !isApplyingRustState, !isInterceptingInput else { return }
|
|
4380
|
+
guard !isApplyingRustState, !isInterceptingInput, !isComposing else { return }
|
|
4180
4381
|
|
|
4181
4382
|
// Skip if no editor is bound yet (nothing to reconcile against).
|
|
4182
4383
|
guard editorId != 0 else { return }
|
|
@@ -4218,7 +4419,7 @@ extension EditorTextView: NSTextStorageDelegate {
|
|
|
4218
4419
|
guard let self else { return }
|
|
4219
4420
|
self.reconciliationWorkScheduled = false
|
|
4220
4421
|
|
|
4221
|
-
guard !self.isApplyingRustState, !self.isInterceptingInput else { return }
|
|
4422
|
+
guard !self.isApplyingRustState, !self.isInterceptingInput, !self.isComposing else { return }
|
|
4222
4423
|
guard self.editorId != 0 else { return }
|
|
4223
4424
|
guard self.textStorage.string != self.lastAuthorizedText else { return }
|
|
4224
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
|