@apollohg/react-native-prose-editor 0.5.9 → 0.5.11
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 +71 -36
- package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +103 -14
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +150 -23
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +4 -0
- package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +2 -1
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +10 -0
- package/dist/EditorToolbar.d.ts +18 -1
- package/dist/EditorToolbar.js +156 -4
- package/dist/NativeRichTextEditor.d.ts +16 -0
- package/dist/NativeRichTextEditor.js +87 -11
- package/dist/index.d.ts +1 -1
- 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 +70 -8
- package/ios/NativeEditorModule.swift +3 -0
- package/ios/RichTextEditorView.swift +210 -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
|
|
@@ -549,6 +551,18 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
549
551
|
}
|
|
550
552
|
}
|
|
551
553
|
|
|
554
|
+
internal fun caretRect(): RectF? {
|
|
555
|
+
val textLayout = layout ?: return null
|
|
556
|
+
val selectionOffset = selectionEnd.takeIf { it >= 0 } ?: return null
|
|
557
|
+
val clampedOffset = selectionOffset.coerceIn(0, textLayout.text.length)
|
|
558
|
+
val line = textLayout.getLineForOffset(clampedOffset)
|
|
559
|
+
val caretLeft = textLayout.getPrimaryHorizontal(clampedOffset)
|
|
560
|
+
val left = totalPaddingLeft + caretLeft - scrollX
|
|
561
|
+
val top = totalPaddingTop + textLayout.getLineTop(line) - scrollY
|
|
562
|
+
val bottom = totalPaddingTop + textLayout.getLineBottom(line) - scrollY
|
|
563
|
+
return RectF(left, top.toFloat(), left + 1f, bottom.toFloat())
|
|
564
|
+
}
|
|
565
|
+
|
|
552
566
|
// ── Input Handling: Text Commit ─────────────────────────────────────
|
|
553
567
|
|
|
554
568
|
/**
|
|
@@ -583,16 +597,48 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
583
597
|
// Range selection: atomic replace via Rust.
|
|
584
598
|
val scalarStart = PositionBridge.utf16ToScalar(start, currentText)
|
|
585
599
|
val scalarEnd = PositionBridge.utf16ToScalar(end, currentText)
|
|
586
|
-
|
|
587
|
-
editorId.toULong(), scalarStart.toUInt(), scalarEnd.toUInt(), text
|
|
588
|
-
)
|
|
589
|
-
applyUpdateJSON(updateJSON)
|
|
600
|
+
replaceTextRangeInRust(scalarStart, scalarEnd, text)
|
|
590
601
|
} else {
|
|
591
602
|
val scalarPos = PositionBridge.utf16ToScalar(start, currentText)
|
|
592
603
|
insertTextInRust(text, scalarPos)
|
|
593
604
|
}
|
|
594
605
|
}
|
|
595
606
|
|
|
607
|
+
internal fun runWithTransientInputMutationGuard(block: () -> Boolean): Boolean {
|
|
608
|
+
val wasApplyingRustState = isApplyingRustState
|
|
609
|
+
isApplyingRustState = true
|
|
610
|
+
return try {
|
|
611
|
+
block()
|
|
612
|
+
} finally {
|
|
613
|
+
isApplyingRustState = wasApplyingRustState
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
fun handleCompositionCommit(text: String, replacementStartUtf16: Int, replacementEndUtf16: Int) {
|
|
618
|
+
if (!isEditable) return
|
|
619
|
+
if (isApplyingRustState) return
|
|
620
|
+
if (editorId == 0L) return
|
|
621
|
+
|
|
622
|
+
if (text == "\n") {
|
|
623
|
+
handleReturnKey()
|
|
624
|
+
return
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
val authorizedText = lastAuthorizedText
|
|
628
|
+
val startUtf16 = minOf(replacementStartUtf16, replacementEndUtf16)
|
|
629
|
+
.coerceIn(0, authorizedText.length)
|
|
630
|
+
val endUtf16 = maxOf(replacementStartUtf16, replacementEndUtf16)
|
|
631
|
+
.coerceIn(0, authorizedText.length)
|
|
632
|
+
val scalarStart = PositionBridge.utf16ToScalar(startUtf16, authorizedText)
|
|
633
|
+
val scalarEnd = PositionBridge.utf16ToScalar(endUtf16, authorizedText)
|
|
634
|
+
|
|
635
|
+
if (scalarStart != scalarEnd) {
|
|
636
|
+
replaceTextRangeInRust(scalarStart, scalarEnd, text)
|
|
637
|
+
} else {
|
|
638
|
+
insertTextInRust(text, scalarStart)
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
596
642
|
// ── Input Handling: Deletion ────────────────────────────────────────
|
|
597
643
|
|
|
598
644
|
/**
|
|
@@ -699,35 +745,6 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
699
745
|
}
|
|
700
746
|
}
|
|
701
747
|
|
|
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
748
|
// ── Input Handling: Return Key ──────────────────────────────────────
|
|
732
749
|
|
|
733
750
|
/**
|
|
@@ -1078,10 +1095,28 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
1078
1095
|
* Insert text at a scalar position via the Rust editor.
|
|
1079
1096
|
*/
|
|
1080
1097
|
private fun insertTextInRust(text: String, atScalarPos: Int) {
|
|
1098
|
+
onInsertTextInRustForTesting?.let { callback ->
|
|
1099
|
+
callback(text, atScalarPos)
|
|
1100
|
+
return
|
|
1101
|
+
}
|
|
1081
1102
|
val updateJSON = editorInsertTextScalar(editorId.toULong(), atScalarPos.toUInt(), text)
|
|
1082
1103
|
applyUpdateJSON(updateJSON)
|
|
1083
1104
|
}
|
|
1084
1105
|
|
|
1106
|
+
private fun replaceTextRangeInRust(scalarFrom: Int, scalarTo: Int, text: String) {
|
|
1107
|
+
onReplaceTextInRustForTesting?.let { callback ->
|
|
1108
|
+
callback(scalarFrom, scalarTo, text)
|
|
1109
|
+
return
|
|
1110
|
+
}
|
|
1111
|
+
val updateJSON = editorReplaceTextScalar(
|
|
1112
|
+
editorId.toULong(),
|
|
1113
|
+
scalarFrom.toUInt(),
|
|
1114
|
+
scalarTo.toUInt(),
|
|
1115
|
+
text
|
|
1116
|
+
)
|
|
1117
|
+
applyUpdateJSON(updateJSON)
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1085
1120
|
/**
|
|
1086
1121
|
* Delete a scalar range via the Rust editor.
|
|
1087
1122
|
*
|
|
@@ -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
|
*
|
|
@@ -5,6 +5,7 @@ import android.content.Context
|
|
|
5
5
|
import android.content.ContextWrapper
|
|
6
6
|
import android.graphics.Rect
|
|
7
7
|
import android.graphics.RectF
|
|
8
|
+
import android.os.SystemClock
|
|
8
9
|
import android.view.Gravity
|
|
9
10
|
import android.view.MotionEvent
|
|
10
11
|
import android.view.View
|
|
@@ -61,7 +62,8 @@ class NativeEditorExpoView(
|
|
|
61
62
|
private var lastEmittedContentHeight = 0
|
|
62
63
|
private var outsideTapWindowCallback: Window.Callback? = null
|
|
63
64
|
private var previousWindowCallback: Window.Callback? = null
|
|
64
|
-
private var
|
|
65
|
+
private var toolbarFramesInWindow: List<RectF> = emptyList()
|
|
66
|
+
private var lastToolbarTouchUptimeMs: Long? = null
|
|
65
67
|
private var addons = NativeEditorAddons(null)
|
|
66
68
|
private var mentionQueryState: MentionQueryState? = null
|
|
67
69
|
private var lastMentionEventJson: String? = null
|
|
@@ -91,7 +93,7 @@ class NativeEditorExpoView(
|
|
|
91
93
|
ViewCompat.setOnApplyWindowInsetsListener(keyboardToolbarView) { _, insets ->
|
|
92
94
|
currentImeBottom = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
|
|
93
95
|
updateKeyboardToolbarLayout()
|
|
94
|
-
|
|
96
|
+
updateAttachedKeyboardToolbarForInsets()
|
|
95
97
|
insets
|
|
96
98
|
}
|
|
97
99
|
|
|
@@ -101,6 +103,12 @@ class NativeEditorExpoView(
|
|
|
101
103
|
installOutsideTapBlurHandlerIfNeeded()
|
|
102
104
|
refreshMentionQuery()
|
|
103
105
|
} else {
|
|
106
|
+
if (shouldPreserveFocusAfterToolbarTouch()) {
|
|
107
|
+
richTextView.editorEditText.post {
|
|
108
|
+
focus()
|
|
109
|
+
}
|
|
110
|
+
return@setOnFocusChangeListener
|
|
111
|
+
}
|
|
104
112
|
uninstallOutsideTapBlurHandler()
|
|
105
113
|
clearMentionQueryState()
|
|
106
114
|
}
|
|
@@ -195,23 +203,52 @@ class NativeEditorExpoView(
|
|
|
195
203
|
if (lastToolbarFrameJson == toolbarFrameJson) return
|
|
196
204
|
lastToolbarFrameJson = toolbarFrameJson
|
|
197
205
|
if (toolbarFrameJson.isNullOrBlank()) {
|
|
198
|
-
|
|
206
|
+
toolbarFramesInWindow = emptyList()
|
|
199
207
|
return
|
|
200
208
|
}
|
|
201
209
|
|
|
202
|
-
|
|
210
|
+
toolbarFramesInWindow = try {
|
|
203
211
|
val json = JSONObject(toolbarFrameJson)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
212
|
+
val frames = json.optJSONArray("frames")
|
|
213
|
+
if (frames != null) {
|
|
214
|
+
buildList {
|
|
215
|
+
for (index in 0 until frames.length()) {
|
|
216
|
+
frames.optJSONObject(index)?.toToolbarFrame()?.let { add(it) }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
listOfNotNull(json.toToolbarFrame())
|
|
221
|
+
}
|
|
210
222
|
} catch (_: Throwable) {
|
|
211
|
-
|
|
223
|
+
emptyList()
|
|
212
224
|
}
|
|
213
225
|
}
|
|
214
226
|
|
|
227
|
+
private fun JSONObject.toToolbarFrame(): RectF? {
|
|
228
|
+
val x = optDouble("x", Double.NaN)
|
|
229
|
+
val y = optDouble("y", Double.NaN)
|
|
230
|
+
val width = optDouble("width", Double.NaN)
|
|
231
|
+
val height = optDouble("height", Double.NaN)
|
|
232
|
+
if (
|
|
233
|
+
x.isNaN() || x.isInfinite() ||
|
|
234
|
+
y.isNaN() || y.isInfinite() ||
|
|
235
|
+
width.isNaN() || width.isInfinite() ||
|
|
236
|
+
height.isNaN() || height.isInfinite()
|
|
237
|
+
) {
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
240
|
+
if (width <= 0.0 || height <= 0.0) {
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return RectF(
|
|
245
|
+
x.toFloat(),
|
|
246
|
+
y.toFloat(),
|
|
247
|
+
(x + width).toFloat(),
|
|
248
|
+
(y + height).toFloat()
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
215
252
|
fun setPendingEditorUpdateJson(editorUpdateJson: String?) {
|
|
216
253
|
pendingEditorUpdateJson = editorUpdateJson
|
|
217
254
|
}
|
|
@@ -230,14 +267,33 @@ class NativeEditorExpoView(
|
|
|
230
267
|
|
|
231
268
|
fun focus() {
|
|
232
269
|
richTextView.editorEditText.requestFocus()
|
|
270
|
+
richTextView.editorEditText.post {
|
|
271
|
+
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
|
272
|
+
imm?.showSoftInput(richTextView.editorEditText, InputMethodManager.SHOW_IMPLICIT)
|
|
273
|
+
}
|
|
233
274
|
}
|
|
234
275
|
|
|
235
276
|
fun blur() {
|
|
277
|
+
clearRecentToolbarTouch()
|
|
236
278
|
richTextView.editorEditText.clearFocus()
|
|
237
279
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
|
238
280
|
imm?.hideSoftInputFromWindow(richTextView.editorEditText.windowToken, 0)
|
|
239
281
|
}
|
|
240
282
|
|
|
283
|
+
fun getCaretRectJson(): String? {
|
|
284
|
+
if (width <= 0 || height <= 0) return null
|
|
285
|
+
val rect = richTextView.caretRect() ?: return null
|
|
286
|
+
val density = resources.displayMetrics.density
|
|
287
|
+
return JSONObject()
|
|
288
|
+
.put("x", rect.left / density)
|
|
289
|
+
.put("y", rect.top / density)
|
|
290
|
+
.put("width", rect.width() / density)
|
|
291
|
+
.put("height", rect.height() / density)
|
|
292
|
+
.put("editorWidth", width / density)
|
|
293
|
+
.put("editorHeight", height / density)
|
|
294
|
+
.toString()
|
|
295
|
+
}
|
|
296
|
+
|
|
241
297
|
override fun onDetachedFromWindow() {
|
|
242
298
|
super.onDetachedFromWindow()
|
|
243
299
|
uninstallOutsideTapBlurHandler()
|
|
@@ -404,24 +460,85 @@ class NativeEditorExpoView(
|
|
|
404
460
|
if (isTouchInsideKeyboardToolbar(event)) {
|
|
405
461
|
return false
|
|
406
462
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
463
|
+
if (isTouchInsideStandaloneToolbar(event)) {
|
|
464
|
+
markRecentToolbarTouch()
|
|
465
|
+
return false
|
|
466
|
+
}
|
|
467
|
+
val rect = Rect()
|
|
468
|
+
richTextView.editorEditText.getGlobalVisibleRect(rect)
|
|
469
|
+
return !rect.contains(event.rawX.toInt(), event.rawY.toInt())
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private fun markRecentToolbarTouch() {
|
|
473
|
+
lastToolbarTouchUptimeMs = SystemClock.uptimeMillis()
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private fun clearRecentToolbarTouch() {
|
|
477
|
+
lastToolbarTouchUptimeMs = null
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private fun shouldPreserveFocusAfterToolbarTouch(): Boolean {
|
|
481
|
+
val lastToolbarTouch = lastToolbarTouchUptimeMs ?: return false
|
|
482
|
+
val elapsedMs = SystemClock.uptimeMillis() - lastToolbarTouch
|
|
483
|
+
return elapsedMs in 0L..TOOLBAR_FOCUS_PRESERVE_MS
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
internal fun markRecentToolbarTouchForTesting() {
|
|
487
|
+
markRecentToolbarTouch()
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
internal fun shouldPreserveFocusAfterToolbarTouchForTesting(): Boolean =
|
|
491
|
+
shouldPreserveFocusAfterToolbarTouch()
|
|
492
|
+
|
|
493
|
+
private fun isTouchInsideStandaloneToolbar(event: MotionEvent): Boolean {
|
|
494
|
+
val visibleWindowFrame = Rect()
|
|
495
|
+
getWindowVisibleDisplayFrame(visibleWindowFrame)
|
|
496
|
+
return isPointInsideStandaloneToolbar(event.rawX, event.rawY, visibleWindowFrame)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
internal fun isPointInsideStandaloneToolbarForTesting(
|
|
500
|
+
rawX: Float,
|
|
501
|
+
rawY: Float,
|
|
502
|
+
visibleWindowFrame: Rect
|
|
503
|
+
): Boolean = isPointInsideStandaloneToolbar(rawX, rawY, visibleWindowFrame)
|
|
504
|
+
|
|
505
|
+
private fun isPointInsideStandaloneToolbar(
|
|
506
|
+
rawX: Float,
|
|
507
|
+
rawY: Float,
|
|
508
|
+
visibleWindowFrame: Rect
|
|
509
|
+
): Boolean {
|
|
510
|
+
if (toolbarFramesInWindow.isEmpty()) {
|
|
511
|
+
return false
|
|
512
|
+
}
|
|
513
|
+
// toolbarFrame is in DP from React Native's measureInWindow. On Android
|
|
514
|
+
// that is window-relative after visible-window insets are subtracted,
|
|
515
|
+
// while rawX/rawY are screen pixels. Fabric/newer implementations may
|
|
516
|
+
// differ here, so accept both window-relative and raw-screen comparisons.
|
|
517
|
+
val density = resources.displayMetrics.density
|
|
518
|
+
val hitSlopPx = TOOLBAR_HIT_SLOP_DP * density
|
|
519
|
+
val eventX = rawX - visibleWindowFrame.left
|
|
520
|
+
val eventY = rawY - visibleWindowFrame.top
|
|
521
|
+
for (toolbarFrame in toolbarFramesInWindow) {
|
|
522
|
+
val windowFrameInPx = RectF(
|
|
413
523
|
toolbarFrame.left * density,
|
|
414
524
|
toolbarFrame.top * density,
|
|
415
525
|
toolbarFrame.right * density,
|
|
416
526
|
toolbarFrame.bottom * density
|
|
417
|
-
)
|
|
418
|
-
|
|
419
|
-
|
|
527
|
+
).apply {
|
|
528
|
+
inset(-hitSlopPx, -hitSlopPx)
|
|
529
|
+
}
|
|
530
|
+
val screenFrameInPx = RectF(windowFrameInPx).apply {
|
|
531
|
+
offset(visibleWindowFrame.left.toFloat(), visibleWindowFrame.top.toFloat())
|
|
532
|
+
}
|
|
533
|
+
if (
|
|
534
|
+
windowFrameInPx.contains(rawX, rawY) ||
|
|
535
|
+
windowFrameInPx.contains(eventX, eventY) ||
|
|
536
|
+
screenFrameInPx.contains(rawX, rawY)
|
|
537
|
+
) {
|
|
538
|
+
return true
|
|
420
539
|
}
|
|
421
540
|
}
|
|
422
|
-
|
|
423
|
-
richTextView.editorEditText.getGlobalVisibleRect(rect)
|
|
424
|
-
return !rect.contains(event.rawX.toInt(), event.rawY.toInt())
|
|
541
|
+
return false
|
|
425
542
|
}
|
|
426
543
|
|
|
427
544
|
private fun isTouchInsideKeyboardToolbar(event: MotionEvent): Boolean {
|
|
@@ -433,6 +550,11 @@ class NativeEditorExpoView(
|
|
|
433
550
|
return rect.contains(event.rawX.toInt(), event.rawY.toInt())
|
|
434
551
|
}
|
|
435
552
|
|
|
553
|
+
private companion object {
|
|
554
|
+
private const val TOOLBAR_HIT_SLOP_DP = 8f
|
|
555
|
+
private const val TOOLBAR_FOCUS_PRESERVE_MS = 750L
|
|
556
|
+
}
|
|
557
|
+
|
|
436
558
|
private fun resolveActivity(context: Context): Activity? {
|
|
437
559
|
var current: Context? = context
|
|
438
560
|
while (current is ContextWrapper) {
|
|
@@ -658,6 +780,11 @@ class NativeEditorExpoView(
|
|
|
658
780
|
keyboardToolbarView.layoutParams = params
|
|
659
781
|
}
|
|
660
782
|
|
|
783
|
+
private fun updateAttachedKeyboardToolbarForInsets() {
|
|
784
|
+
keyboardToolbarView.visibility = if (currentImeBottom > 0) View.VISIBLE else View.INVISIBLE
|
|
785
|
+
updateEditorViewportInset()
|
|
786
|
+
}
|
|
787
|
+
|
|
661
788
|
private fun updateKeyboardToolbarVisibility() {
|
|
662
789
|
val shouldAttach =
|
|
663
790
|
showsToolbar &&
|
|
@@ -380,6 +380,10 @@ class NativeEditorModule : Module() {
|
|
|
380
380
|
view.blur()
|
|
381
381
|
}
|
|
382
382
|
|
|
383
|
+
AsyncFunction("getCaretRect") { view: NativeEditorExpoView ->
|
|
384
|
+
view.getCaretRectJson()
|
|
385
|
+
}
|
|
386
|
+
|
|
383
387
|
AsyncFunction("applyEditorUpdate") { view: NativeEditorExpoView, updateJson: String ->
|
|
384
388
|
view.applyEditorUpdate(updateJson)
|
|
385
389
|
}
|
|
@@ -11,6 +11,7 @@ import android.view.View
|
|
|
11
11
|
import android.view.ViewOutlineProvider
|
|
12
12
|
import android.widget.HorizontalScrollView
|
|
13
13
|
import android.widget.LinearLayout
|
|
14
|
+
import androidx.appcompat.R as AppCompatR
|
|
14
15
|
import androidx.appcompat.widget.AppCompatButton
|
|
15
16
|
import androidx.appcompat.widget.PopupMenu
|
|
16
17
|
import androidx.appcompat.widget.AppCompatTextView
|
|
@@ -834,7 +835,7 @@ internal class EditorKeyboardToolbarView(context: Context) : HorizontalScrollVie
|
|
|
834
835
|
0.38f
|
|
835
836
|
)
|
|
836
837
|
active -> theme?.buttonActiveColor ?: resolveColorAttr(
|
|
837
|
-
|
|
838
|
+
AppCompatR.attr.colorPrimary,
|
|
838
839
|
android.R.attr.textColorPrimary
|
|
839
840
|
)
|
|
840
841
|
else -> theme?.buttonColor ?: resolveColorAttr(
|
|
@@ -301,6 +301,16 @@ class RichTextEditorView @JvmOverloads constructor(
|
|
|
301
301
|
)
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
+
internal fun caretRect(): RectF? {
|
|
305
|
+
val rect = editorEditText.caretRect() ?: return null
|
|
306
|
+
return RectF(
|
|
307
|
+
editorViewport.left + editorScrollView.left + editorEditText.left + rect.left,
|
|
308
|
+
editorViewport.top + editorScrollView.top + editorEditText.top + rect.top - editorScrollView.scrollY,
|
|
309
|
+
editorViewport.left + editorScrollView.left + editorEditText.left + rect.right,
|
|
310
|
+
editorViewport.top + editorScrollView.top + editorEditText.top + rect.bottom - editorScrollView.scrollY
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
|
|
304
314
|
internal fun maximumImageWidthPx(): Float {
|
|
305
315
|
val availableWidth =
|
|
306
316
|
maxOf(editorEditText.width, editorEditText.measuredWidth) -
|
package/dist/EditorToolbar.d.ts
CHANGED
|
@@ -91,6 +91,18 @@ export type EditorToolbarItem = EditorToolbarLeafItem | EditorToolbarGroupItem |
|
|
|
91
91
|
type: 'separator';
|
|
92
92
|
key?: string;
|
|
93
93
|
};
|
|
94
|
+
export interface EditorToolbarFrame {
|
|
95
|
+
x: number;
|
|
96
|
+
y: number;
|
|
97
|
+
width: number;
|
|
98
|
+
height: number;
|
|
99
|
+
}
|
|
100
|
+
export declare function isEditorToolbarFocusPreservationActive(): boolean;
|
|
101
|
+
export declare function useEditorToolbarFrames(): readonly EditorToolbarFrame[];
|
|
102
|
+
export declare function _setEditorToolbarFrameForTests(id: number, frame: EditorToolbarFrame | null): void;
|
|
103
|
+
export declare function _resetEditorToolbarFrameRegistryForTests(): void;
|
|
104
|
+
export declare function _beginEditorToolbarInteractionForTests(): void;
|
|
105
|
+
export declare function _endEditorToolbarInteractionForTests(): void;
|
|
94
106
|
export declare const DEFAULT_EDITOR_TOOLBAR_ITEMS: readonly EditorToolbarItem[];
|
|
95
107
|
export interface EditorToolbarProps {
|
|
96
108
|
/** Currently active marks and nodes from the Rust engine. */
|
|
@@ -145,5 +157,10 @@ export interface EditorToolbarProps {
|
|
|
145
157
|
theme?: EditorToolbarTheme;
|
|
146
158
|
/** Whether to render the built-in top separator line. */
|
|
147
159
|
showTopBorder?: boolean;
|
|
160
|
+
/**
|
|
161
|
+
* Keep NativeRichTextEditor focused when this toolbar is rendered outside
|
|
162
|
+
* the editor wrapper. Defaults to true.
|
|
163
|
+
*/
|
|
164
|
+
preserveEditorFocus?: boolean;
|
|
148
165
|
}
|
|
149
|
-
export declare function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic, onToggleUnderline, onToggleStrike, onToggleBulletList, onToggleHeading, onToggleBlockquote, onToggleOrderedList, onIndentList, onOutdentList, onInsertHorizontalRule, onInsertLineBreak, onUndo, onRedo, onToggleMark, onToggleListType, onInsertNodeType, onRunCommand, onToolbarAction, onRequestLink, onRequestImage, toolbarItems, theme, showTopBorder, }: EditorToolbarProps): import("react/jsx-runtime").JSX.Element;
|
|
166
|
+
export declare function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic, onToggleUnderline, onToggleStrike, onToggleBulletList, onToggleHeading, onToggleBlockquote, onToggleOrderedList, onIndentList, onOutdentList, onInsertHorizontalRule, onInsertLineBreak, onUndo, onRedo, onToggleMark, onToggleListType, onInsertNodeType, onRunCommand, onToolbarAction, onRequestLink, onRequestImage, toolbarItems, theme, showTopBorder, preserveEditorFocus, }: EditorToolbarProps): import("react/jsx-runtime").JSX.Element;
|