@apollohg/react-native-prose-editor 0.5.19 → 0.5.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -1
- package/android/build.gradle +5 -6
- package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +50 -0
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +187 -16
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +543 -74
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +14 -0
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +10 -2
- package/app.plugin.js +62 -0
- package/dist/EditorToolbar.d.ts +3 -2
- package/dist/EditorToolbar.js +41 -13
- package/dist/NativeRichTextEditor.d.ts +9 -0
- package/dist/NativeRichTextEditor.js +252 -81
- package/dist/YjsCollaboration.d.ts +5 -0
- package/dist/YjsCollaboration.js +44 -8
- package/dist/index.d.ts +1 -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 +49 -2
- package/package.json +5 -2
- 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
package/README.md
CHANGED
|
@@ -60,6 +60,28 @@ Install the package:
|
|
|
60
60
|
npm install @apollohg/react-native-prose-editor@0.5.1
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
+
Expo prebuild apps should add the package config plugin so Android excludes
|
|
64
|
+
obsolete JNA ABI copies that modern NDKs cannot strip:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
export default {
|
|
68
|
+
expo: {
|
|
69
|
+
plugins: ['@apollohg/react-native-prose-editor'],
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
For bare React Native apps or existing generated Android projects, add the same
|
|
75
|
+
packaging exclude to `android/gradle.properties` when your template applies
|
|
76
|
+
`android.packagingOptions.*` properties:
|
|
77
|
+
|
|
78
|
+
```properties
|
|
79
|
+
android.packagingOptions.excludes=**/armeabi/libjnidispatch.so,**/mips/libjnidispatch.so,**/mips64/libjnidispatch.so
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
If your Android project does not read those Gradle properties, add the patterns
|
|
83
|
+
directly under the app module's `android.packagingOptions.jniLibs.excludes`.
|
|
84
|
+
|
|
63
85
|
For local package development in this repo:
|
|
64
86
|
|
|
65
87
|
```sh
|
|
@@ -111,7 +133,7 @@ For setup and customization details, start with the [Documentation Index](https:
|
|
|
111
133
|
|
|
112
134
|
For realtime collaboration, including the correct `useYjsCollaboration()` wiring, encoded-state persistence, remote cursors, and automatic reconnect behavior, see the [Collaboration Guide](https://github.com/apollohg/react-native-prose-editor/wiki/Collaboration).
|
|
113
135
|
|
|
114
|
-
For whole-document JSON loads, `initialJSON`, controlled `valueJSON`, and `setContentJson()` will normalize an empty root document like `{ type: 'doc', content: [] }` to the active schema's empty text block so block-constrained schemas still load a valid empty document.
|
|
136
|
+
For whole-document JSON loads, `initialJSON`, controlled `valueJSON`, and `setContentJson()` will normalize an empty root document like `{ type: 'doc', content: [] }` to the active schema's empty text block so block-constrained schemas still load a valid empty document. For chat composer or draft-reset flows, prefer the ref method `clearContent()`.
|
|
115
137
|
|
|
116
138
|
## Development
|
|
117
139
|
|
package/android/build.gradle
CHANGED
|
@@ -11,7 +11,7 @@ group = 'com.apollohg'
|
|
|
11
11
|
version = packageJson.version
|
|
12
12
|
|
|
13
13
|
android {
|
|
14
|
-
namespace "com.apollohg.editor"
|
|
14
|
+
namespace = "com.apollohg.editor"
|
|
15
15
|
testOptions {
|
|
16
16
|
unitTests.includeAndroidResources = true
|
|
17
17
|
unitTests.all { test ->
|
|
@@ -24,9 +24,9 @@ android {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
defaultConfig {
|
|
27
|
-
versionCode 1
|
|
28
|
-
versionName packageJson.version
|
|
29
|
-
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
27
|
+
versionCode = 1
|
|
28
|
+
versionName = packageJson.version
|
|
29
|
+
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
|
30
30
|
consumerProguardFiles "consumer-rules.pro"
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -57,8 +57,7 @@ android {
|
|
|
57
57
|
dependencies {
|
|
58
58
|
implementation "androidx.appcompat:appcompat:1.7.0"
|
|
59
59
|
implementation "com.google.android.material:material:1.12.0"
|
|
60
|
-
|
|
61
|
-
// consuming apps so release shrinkers see the dependency on the app graph.
|
|
60
|
+
|
|
62
61
|
api "net.java.dev.jna:jna:5.18.1@aar"
|
|
63
62
|
|
|
64
63
|
testImplementation "junit:junit:4.13.2"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
package com.apollohg.editor
|
|
2
|
+
|
|
3
|
+
import android.graphics.Paint
|
|
4
|
+
import android.text.Layout
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Vertical geometry for the text caret, clipped to the rendered glyph height.
|
|
8
|
+
*
|
|
9
|
+
* Android's [android.widget.Editor] draws the native caret from
|
|
10
|
+
* `Layout.getLineTop(line)` to `Layout.getLineBottom(line)`. When a
|
|
11
|
+
* [ParagraphSpacerSpan] inflates a line's descent to create inter-block
|
|
12
|
+
* spacing, `getLineBottom` includes that gap and the caret stretches into it.
|
|
13
|
+
* `getLineBottomWithoutSpacing` cannot help: the inflation lives in the line's
|
|
14
|
+
* DESCENT column, not the line-spacing EXTRA column it subtracts.
|
|
15
|
+
*
|
|
16
|
+
* The baseline is provably independent of descent inflation
|
|
17
|
+
* (`getLineBaseline(line) == getLineTop(line) - ascent`), so anchoring the
|
|
18
|
+
* caret bottom at `baseline + raw font descent` clips it to the glyph height.
|
|
19
|
+
* This mirrors the trim already used for blockquote stripes
|
|
20
|
+
* ([BlockquoteSpan.resolvedStripeBottom]).
|
|
21
|
+
*/
|
|
22
|
+
object CaretGeometry {
|
|
23
|
+
data class VerticalBounds(val top: Float, val bottom: Float)
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether the manually-drawn caret should be visible. The native caret is
|
|
27
|
+
* suppressed, so this gates our replacement: only when the field is focused,
|
|
28
|
+
* its window is focused, and the selection is a collapsed insertion point
|
|
29
|
+
* (a range selection shows the selection highlight instead).
|
|
30
|
+
*/
|
|
31
|
+
fun shouldRender(
|
|
32
|
+
focused: Boolean,
|
|
33
|
+
windowFocused: Boolean,
|
|
34
|
+
selectionStart: Int,
|
|
35
|
+
selectionEnd: Int
|
|
36
|
+
): Boolean = focused &&
|
|
37
|
+
windowFocused &&
|
|
38
|
+
selectionStart >= 0 &&
|
|
39
|
+
selectionStart == selectionEnd
|
|
40
|
+
|
|
41
|
+
fun verticalBounds(layout: Layout, offset: Int, paint: Paint): VerticalBounds {
|
|
42
|
+
val line = layout.getLineForOffset(offset.coerceIn(0, layout.text.length))
|
|
43
|
+
val top = layout.getLineTop(line).toFloat()
|
|
44
|
+
// Anchor the bottom at the glyph descent below the baseline. The baseline
|
|
45
|
+
// is independent of any ReplacementSpan descent inflation, so this clips
|
|
46
|
+
// the caret to the rendered text height instead of the inflated line bottom.
|
|
47
|
+
val bottom = layout.getLineBaseline(line) + paint.fontMetrics.descent
|
|
48
|
+
return VerticalBounds(top, bottom)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -212,6 +212,9 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
212
212
|
discardTransientNativeInputForReadOnly()
|
|
213
213
|
}
|
|
214
214
|
field = value
|
|
215
|
+
if (value) {
|
|
216
|
+
restartInputForEditorIfFocused("editable")
|
|
217
|
+
}
|
|
215
218
|
}
|
|
216
219
|
|
|
217
220
|
/**
|
|
@@ -395,9 +398,90 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
395
398
|
// Background color is applied in setBaseStyle() / applyTheme().
|
|
396
399
|
background = null
|
|
397
400
|
linksClickable = false
|
|
401
|
+
|
|
402
|
+
// Suppress the platform caret and draw our own. Android's Editor anchors
|
|
403
|
+
// the native caret to getLineBottom(line), which a ParagraphSpacerSpan
|
|
404
|
+
// inflates — stretching the caret into the inter-block gap. Our caret is
|
|
405
|
+
// clipped to the glyph height via [CaretGeometry]. See [drawCustomCaret].
|
|
406
|
+
isCursorVisible = false
|
|
407
|
+
|
|
398
408
|
updateEffectivePadding()
|
|
399
409
|
}
|
|
400
410
|
|
|
411
|
+
// ── Custom caret ────────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
private var caretBlinkVisible = true
|
|
414
|
+
private val caretPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG)
|
|
415
|
+
private val caretWidthPx: Float by lazy { maxOf(MIN_CARET_WIDTH_PX, resources.displayMetrics.density) }
|
|
416
|
+
private val caretColor: Int by lazy { resolveCaretColor() }
|
|
417
|
+
private val caretBlinkRunnable = object : Runnable {
|
|
418
|
+
override fun run() {
|
|
419
|
+
caretBlinkVisible = !caretBlinkVisible
|
|
420
|
+
invalidate()
|
|
421
|
+
postDelayed(this, CARET_BLINK_INTERVAL_MS)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Reset the caret to solid-on and (re)schedule the blink. Called whenever the
|
|
427
|
+
* caret could appear or move (focus, window focus, selection change) so it
|
|
428
|
+
* behaves like the platform caret: solid immediately after a move, then blinks.
|
|
429
|
+
*/
|
|
430
|
+
private fun restartCaretBlink() {
|
|
431
|
+
removeCallbacks(caretBlinkRunnable)
|
|
432
|
+
caretBlinkVisible = true
|
|
433
|
+
if (CaretGeometry.shouldRender(isFocused, hasWindowFocus(), selectionStart, selectionEnd)) {
|
|
434
|
+
postDelayed(caretBlinkRunnable, CARET_BLINK_INTERVAL_MS)
|
|
435
|
+
}
|
|
436
|
+
invalidate()
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private fun stopCaretBlink() {
|
|
440
|
+
removeCallbacks(caretBlinkRunnable)
|
|
441
|
+
caretBlinkVisible = false
|
|
442
|
+
invalidate()
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private fun drawCustomCaret(canvas: android.graphics.Canvas) {
|
|
446
|
+
if (!caretBlinkVisible) return
|
|
447
|
+
if (!CaretGeometry.shouldRender(isFocused, hasWindowFocus(), selectionStart, selectionEnd)) return
|
|
448
|
+
val rect = customCaretDrawRect() ?: return
|
|
449
|
+
caretPaint.color = caretColor
|
|
450
|
+
canvas.drawRect(rect.left, rect.top, rect.right, rect.bottom, caretPaint)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* The caret rectangle in canvas (content) coordinates — clipped to the glyph
|
|
455
|
+
* height via [CaretGeometry]. No scroll offset is applied: at onDraw time the
|
|
456
|
+
* framework canvas is already in scrolled content space, exactly like the text
|
|
457
|
+
* Layout it paints. (This differs from [caretRect], which reports view-relative
|
|
458
|
+
* coordinates to JS.)
|
|
459
|
+
*/
|
|
460
|
+
internal fun customCaretDrawRect(): RectF? {
|
|
461
|
+
val textLayout = layout ?: return null
|
|
462
|
+
val offset = selectionEnd.coerceIn(0, textLayout.text.length)
|
|
463
|
+
val bounds = CaretGeometry.verticalBounds(textLayout, offset, paint)
|
|
464
|
+
val left = totalPaddingLeft + textLayout.getPrimaryHorizontal(offset)
|
|
465
|
+
return RectF(left, totalPaddingTop + bounds.top, left + caretWidthPx, totalPaddingTop + bounds.bottom)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* The native caret is tinted by the theme's `colorControlActivated`; resolve
|
|
470
|
+
* the same value so the replacement keeps the platform appearance, falling
|
|
471
|
+
* back to the text color when the attribute is not a color.
|
|
472
|
+
*/
|
|
473
|
+
private fun resolveCaretColor(): Int {
|
|
474
|
+
val resolved = TypedValue()
|
|
475
|
+
val found = context.theme.resolveAttribute(
|
|
476
|
+
android.R.attr.colorControlActivated,
|
|
477
|
+
resolved,
|
|
478
|
+
true
|
|
479
|
+
)
|
|
480
|
+
val isColor = resolved.type >= TypedValue.TYPE_FIRST_COLOR_INT &&
|
|
481
|
+
resolved.type <= TypedValue.TYPE_LAST_COLOR_INT
|
|
482
|
+
return if (found && isColor) resolved.data else currentTextColor
|
|
483
|
+
}
|
|
484
|
+
|
|
401
485
|
fun setAutoCapitalize(autoCapitalize: String?) {
|
|
402
486
|
val next = when (autoCapitalize) {
|
|
403
487
|
"none",
|
|
@@ -631,6 +715,7 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
631
715
|
|
|
632
716
|
override fun onDraw(canvas: android.graphics.Canvas) {
|
|
633
717
|
super.onDraw(canvas)
|
|
718
|
+
drawCustomCaret(canvas)
|
|
634
719
|
|
|
635
720
|
val placeholderLayout =
|
|
636
721
|
buildPlaceholderLayout(width - compoundPaddingLeft - compoundPaddingRight) ?: return
|
|
@@ -953,12 +1038,14 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
953
1038
|
val textLayout = layout ?: return null
|
|
954
1039
|
val selectionOffset = selectionEnd.takeIf { it >= 0 } ?: return null
|
|
955
1040
|
val clampedOffset = selectionOffset.coerceIn(0, textLayout.text.length)
|
|
956
|
-
val line = textLayout.getLineForOffset(clampedOffset)
|
|
957
1041
|
val caretLeft = textLayout.getPrimaryHorizontal(clampedOffset)
|
|
1042
|
+
// Clip the caret to the rendered glyph height so a ParagraphSpacerSpan's
|
|
1043
|
+
// inflated descent does not stretch it into the inter-block gap.
|
|
1044
|
+
val bounds = CaretGeometry.verticalBounds(textLayout, clampedOffset, paint)
|
|
958
1045
|
val left = totalPaddingLeft + caretLeft - scrollX
|
|
959
|
-
val top = totalPaddingTop +
|
|
960
|
-
val bottom = totalPaddingTop +
|
|
961
|
-
return RectF(left, top
|
|
1046
|
+
val top = totalPaddingTop + bounds.top - scrollY
|
|
1047
|
+
val bottom = totalPaddingTop + bounds.bottom - scrollY
|
|
1048
|
+
return RectF(left, top, left + 1f, bottom)
|
|
962
1049
|
}
|
|
963
1050
|
|
|
964
1051
|
// ── Input Handling: Text Commit ─────────────────────────────────────
|
|
@@ -1526,7 +1613,10 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
1526
1613
|
if (inputConnection?.flushPendingCompositionForExternalMutation() == false) {
|
|
1527
1614
|
return false
|
|
1528
1615
|
}
|
|
1529
|
-
return drainNativeTextMutationIfNeeded(
|
|
1616
|
+
return drainNativeTextMutationIfNeeded(
|
|
1617
|
+
allowAfterBlur = true,
|
|
1618
|
+
preserveInputConnectionForExternalUpdate = true
|
|
1619
|
+
)
|
|
1530
1620
|
}
|
|
1531
1621
|
|
|
1532
1622
|
fun prepareForExternalEditorCommand(): CommandPreparation {
|
|
@@ -2532,6 +2622,8 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
2532
2622
|
}
|
|
2533
2623
|
ensureSelectionVisible()
|
|
2534
2624
|
onSelectionOrContentMayChange?.invoke()
|
|
2625
|
+
// Keep the custom caret solid at its new position, then resume blinking.
|
|
2626
|
+
restartCaretBlink()
|
|
2535
2627
|
|
|
2536
2628
|
syncCurrentSelectionToRust()
|
|
2537
2629
|
}
|
|
@@ -2612,6 +2704,7 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
2612
2704
|
lastAuthorizedText = text?.toString().orEmpty()
|
|
2613
2705
|
lastAuthorizedRenderedText = text?.let { SpannableStringBuilder(it) }
|
|
2614
2706
|
lastAuthorizedTextRevision += 1L
|
|
2707
|
+
currentRenderBlocksJson = null
|
|
2615
2708
|
clearNativeTextMutationAdoptionSuppression()
|
|
2616
2709
|
clearNativeTextMutationAfterBlurWindow()
|
|
2617
2710
|
}
|
|
@@ -3017,7 +3110,10 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
3017
3110
|
return mutation.scalarFrom < trackedEnd && mutation.scalarTo > trackedStart
|
|
3018
3111
|
}
|
|
3019
3112
|
|
|
3020
|
-
private fun drainNativeTextMutationIfNeeded(
|
|
3113
|
+
private fun drainNativeTextMutationIfNeeded(
|
|
3114
|
+
allowAfterBlur: Boolean,
|
|
3115
|
+
preserveInputConnectionForExternalUpdate: Boolean = false
|
|
3116
|
+
): Boolean {
|
|
3021
3117
|
if (editorId == 0L) return true
|
|
3022
3118
|
if (discardTransientInputForDestroyedEditorIfNeeded()) return false
|
|
3023
3119
|
val editable = text
|
|
@@ -3026,7 +3122,10 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
3026
3122
|
|
|
3027
3123
|
val mutation = nativeTextMutationFromAuthorizedDiff(currentText)
|
|
3028
3124
|
if (mutation != null && shouldAdoptNativeTextMutation(mutation, allowAfterBlur)) {
|
|
3029
|
-
commitNativeTextMutation(
|
|
3125
|
+
commitNativeTextMutation(
|
|
3126
|
+
mutation,
|
|
3127
|
+
preserveInputConnectionForExternalUpdate = preserveInputConnectionForExternalUpdate
|
|
3128
|
+
)
|
|
3030
3129
|
return true
|
|
3031
3130
|
}
|
|
3032
3131
|
recordImeTraceForTesting(
|
|
@@ -3093,7 +3192,10 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
3093
3192
|
return true
|
|
3094
3193
|
}
|
|
3095
3194
|
|
|
3096
|
-
private fun commitNativeTextMutation(
|
|
3195
|
+
private fun commitNativeTextMutation(
|
|
3196
|
+
mutation: NativeTextMutation,
|
|
3197
|
+
preserveInputConnectionForExternalUpdate: Boolean = false
|
|
3198
|
+
) {
|
|
3097
3199
|
if (!hasLiveEditor()) return
|
|
3098
3200
|
val startedAt = System.nanoTime()
|
|
3099
3201
|
if ((text?.toString() ?: "") != mutation.resultingText) {
|
|
@@ -3104,13 +3206,17 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
3104
3206
|
return
|
|
3105
3207
|
}
|
|
3106
3208
|
val shouldRestartInput = hasFocus()
|
|
3107
|
-
|
|
3209
|
+
if (preserveInputConnectionForExternalUpdate) {
|
|
3210
|
+
clearInputStateForExternalReplacementPreservingConnection()
|
|
3211
|
+
} else {
|
|
3212
|
+
retireInputConnectionForEditor()
|
|
3213
|
+
}
|
|
3108
3214
|
nativeTextMutationAfterBlurWindow?.didAdoptMutation = true
|
|
3109
3215
|
clearNativeTextMutationAfterBlurWindow()
|
|
3110
3216
|
|
|
3111
3217
|
recordImeTraceForTesting(
|
|
3112
3218
|
"nativeMutationApply",
|
|
3113
|
-
"range=${mutation.scalarFrom}..${mutation.scalarTo} replacementLength=${mutation.replacementText.length} restartInput=$shouldRestartInput"
|
|
3219
|
+
"range=${mutation.scalarFrom}..${mutation.scalarTo} replacementLength=${mutation.replacementText.length} restartInput=$shouldRestartInput preserveInputConnection=$preserveInputConnectionForExternalUpdate"
|
|
3114
3220
|
)
|
|
3115
3221
|
if (mutation.replacementText.isEmpty()) {
|
|
3116
3222
|
deleteRangeInRust(mutation.scalarFrom, mutation.scalarTo)
|
|
@@ -3123,7 +3229,9 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
3123
3229
|
}
|
|
3124
3230
|
restoreSelectionAfterNativeTextMutation(mutation)
|
|
3125
3231
|
if (shouldRestartInput) {
|
|
3126
|
-
restartInputForEditor(
|
|
3232
|
+
restartInputForEditor(
|
|
3233
|
+
if (preserveInputConnectionForExternalUpdate) "externalUpdatePreflight" else "explicit"
|
|
3234
|
+
)
|
|
3127
3235
|
}
|
|
3128
3236
|
recordImeTraceForTesting(
|
|
3129
3237
|
"nativeMutationApplyDone",
|
|
@@ -3559,7 +3667,8 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
3559
3667
|
private fun applyRenderedSpannable(
|
|
3560
3668
|
spannable: CharSequence,
|
|
3561
3669
|
replaceRange: RenderReplaceRange? = null,
|
|
3562
|
-
usedPatch: Boolean
|
|
3670
|
+
usedPatch: Boolean,
|
|
3671
|
+
preserveInputConnectionForExternalUpdate: Boolean = false
|
|
3563
3672
|
) {
|
|
3564
3673
|
val startedAt = System.nanoTime()
|
|
3565
3674
|
val previousScrollX = scrollX
|
|
@@ -3579,7 +3688,10 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
3579
3688
|
lastAuthorizedRenderedText = text?.let { SpannableStringBuilder(it) }
|
|
3580
3689
|
lastAuthorizedTextRevision += 1L
|
|
3581
3690
|
clearNativeTextMutationAdoptionSuppression()
|
|
3582
|
-
if (hadCompositionTracking) {
|
|
3691
|
+
if (hadCompositionTracking && preserveInputConnectionForExternalUpdate) {
|
|
3692
|
+
clearInputStateForExternalReplacementPreservingConnection()
|
|
3693
|
+
shouldRestartInput = true
|
|
3694
|
+
} else if (hadCompositionTracking) {
|
|
3583
3695
|
retireInputConnectionForEditor()
|
|
3584
3696
|
shouldRestartInput = true
|
|
3585
3697
|
} else {
|
|
@@ -3595,9 +3707,15 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
3595
3707
|
"applyRenderedSpannable",
|
|
3596
3708
|
"mode=$mode usedPatch=$usedPatch incomingLength=${spannable.length} replace=${replaceRange?.start}..${replaceRange?.endExclusive} hadComposition=$hadCompositionTracking restartInput=$shouldRestartInput applyUs=${nanosToMicros(System.nanoTime() - startedAt)} scroll=$previousScrollX,$previousScrollY->$scrollX,$scrollY layout=${layout != null}"
|
|
3597
3709
|
)
|
|
3710
|
+
invalidateRenderedContent()
|
|
3598
3711
|
restartInputAfterCompositionInvalidationIfNeeded(shouldRestartInput)
|
|
3599
3712
|
}
|
|
3600
3713
|
|
|
3714
|
+
private fun invalidateRenderedContent() {
|
|
3715
|
+
invalidate()
|
|
3716
|
+
postInvalidateOnAnimation()
|
|
3717
|
+
}
|
|
3718
|
+
|
|
3601
3719
|
private fun authorizeVisibleTextForMatchedOptimisticRender(spannable: CharSequence) {
|
|
3602
3720
|
val startedAt = System.nanoTime()
|
|
3603
3721
|
val visibleText = text?.toString().orEmpty()
|
|
@@ -3779,8 +3897,13 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
3779
3897
|
*
|
|
3780
3898
|
* @param updateJSON The JSON string from editor_insert_text, etc.
|
|
3781
3899
|
*/
|
|
3782
|
-
fun applyUpdateJSON(
|
|
3900
|
+
fun applyUpdateJSON(
|
|
3901
|
+
updateJSON: String,
|
|
3902
|
+
notifyListener: Boolean = true,
|
|
3903
|
+
refreshInputConnectionForExternalUpdate: Boolean = false
|
|
3904
|
+
) {
|
|
3783
3905
|
val totalStartedAt = System.nanoTime()
|
|
3906
|
+
val previousVisibleText = text?.toString().orEmpty()
|
|
3784
3907
|
val parseStartedAt = totalStartedAt
|
|
3785
3908
|
val update = try {
|
|
3786
3909
|
org.json.JSONObject(updateJSON)
|
|
@@ -3803,7 +3926,8 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
3803
3926
|
currentRenderBlocksJson?.let { mergeRenderBlocks(it, patch) }
|
|
3804
3927
|
}
|
|
3805
3928
|
val resolveRenderBlocksNanos = System.nanoTime() - resolveRenderBlocksStartedAt
|
|
3806
|
-
val shouldSkipRender =
|
|
3929
|
+
val shouldSkipRender = !refreshInputConnectionForExternalUpdate &&
|
|
3930
|
+
resolvedRenderBlocks != null &&
|
|
3807
3931
|
currentRenderBlocksJson?.let { current ->
|
|
3808
3932
|
renderBlocksEqual(current, resolvedRenderBlocks)
|
|
3809
3933
|
} == true &&
|
|
@@ -3866,7 +3990,11 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
3866
3990
|
if (canReuseOptimisticVisibleText) {
|
|
3867
3991
|
authorizeVisibleTextForMatchedOptimisticRender(fullSpannable)
|
|
3868
3992
|
} else {
|
|
3869
|
-
applyRenderedSpannable(
|
|
3993
|
+
applyRenderedSpannable(
|
|
3994
|
+
fullSpannable,
|
|
3995
|
+
usedPatch = false,
|
|
3996
|
+
preserveInputConnectionForExternalUpdate = refreshInputConnectionForExternalUpdate
|
|
3997
|
+
)
|
|
3870
3998
|
}
|
|
3871
3999
|
pendingOptimisticRenderText = null
|
|
3872
4000
|
applyRenderNanos = System.nanoTime() - applyStartedAt
|
|
@@ -3891,6 +4019,10 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
3891
4019
|
} else {
|
|
3892
4020
|
preserveScrollPosition(previousScrollX, previousScrollY)
|
|
3893
4021
|
}
|
|
4022
|
+
refreshInputConnectionAfterExternalTextReplacementIfNeeded(
|
|
4023
|
+
enabled = refreshInputConnectionForExternalUpdate,
|
|
4024
|
+
previousVisibleText = previousVisibleText
|
|
4025
|
+
)
|
|
3894
4026
|
val postApplyNanos = System.nanoTime() - postApplyStartedAt
|
|
3895
4027
|
|
|
3896
4028
|
val totalNanos = System.nanoTime() - totalStartedAt
|
|
@@ -3916,6 +4048,24 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
3916
4048
|
}
|
|
3917
4049
|
}
|
|
3918
4050
|
|
|
4051
|
+
private fun refreshInputConnectionAfterExternalTextReplacementIfNeeded(
|
|
4052
|
+
enabled: Boolean,
|
|
4053
|
+
previousVisibleText: String
|
|
4054
|
+
) {
|
|
4055
|
+
if (!enabled || !hasFocus()) return
|
|
4056
|
+
val currentVisibleText = text?.toString().orEmpty()
|
|
4057
|
+
if (currentVisibleText == previousVisibleText) return
|
|
4058
|
+
clearInputStateForExternalReplacementPreservingConnection()
|
|
4059
|
+
restartInputForEditor("externalUpdate")
|
|
4060
|
+
}
|
|
4061
|
+
|
|
4062
|
+
private fun clearInputStateForExternalReplacementPreservingConnection() {
|
|
4063
|
+
activeInputConnection?.clearCompositionTrackingForEditor()
|
|
4064
|
+
clearCompositionTrackingForEditor()
|
|
4065
|
+
clearCompositionInvalidationForEditor()
|
|
4066
|
+
clearNativeComposingSpans()
|
|
4067
|
+
}
|
|
4068
|
+
|
|
3919
4069
|
/**
|
|
3920
4070
|
* Apply a render JSON string (just render elements, no update wrapper).
|
|
3921
4071
|
*
|
|
@@ -4070,12 +4220,29 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
4070
4220
|
super.onFocusChanged(focused, direction, previouslyFocusedRect)
|
|
4071
4221
|
if (focused) {
|
|
4072
4222
|
clearNativeTextMutationAfterBlurWindow()
|
|
4223
|
+
restartCaretBlink()
|
|
4073
4224
|
} else {
|
|
4074
4225
|
beginNativeTextMutationAfterBlurWindow()
|
|
4075
4226
|
clearExplicitSelectedImageRange()
|
|
4227
|
+
stopCaretBlink()
|
|
4076
4228
|
}
|
|
4077
4229
|
}
|
|
4078
4230
|
|
|
4231
|
+
override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
|
|
4232
|
+
super.onWindowFocusChanged(hasWindowFocus)
|
|
4233
|
+
if (hasWindowFocus) restartCaretBlink() else stopCaretBlink()
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4236
|
+
override fun onAttachedToWindow() {
|
|
4237
|
+
super.onAttachedToWindow()
|
|
4238
|
+
restartCaretBlink()
|
|
4239
|
+
}
|
|
4240
|
+
|
|
4241
|
+
override fun onDetachedFromWindow() {
|
|
4242
|
+
removeCallbacks(caretBlinkRunnable)
|
|
4243
|
+
super.onDetachedFromWindow()
|
|
4244
|
+
}
|
|
4245
|
+
|
|
4079
4246
|
private fun selectExplicitImageRange(start: Int, end: Int) {
|
|
4080
4247
|
explicitSelectedImageRange = ImageSelectionRange(start, end)
|
|
4081
4248
|
if (selectionStart == start && selectionEnd == end) {
|
|
@@ -4279,5 +4446,9 @@ class EditorEditText @JvmOverloads constructor(
|
|
|
4279
4446
|
private const val NATIVE_TEXT_MUTATION_AFTER_BLUR_WINDOW_MS = 750L
|
|
4280
4447
|
private const val RECENT_HANDLED_HARDWARE_KEY_DOWN_WINDOW_MS = 750L
|
|
4281
4448
|
private const val LOG_TAG = "NativeEditor"
|
|
4449
|
+
|
|
4450
|
+
// Platform caret blink half-period (Editor.BLINK).
|
|
4451
|
+
private const val CARET_BLINK_INTERVAL_MS = 500L
|
|
4452
|
+
private const val MIN_CARET_WIDTH_PX = 2f
|
|
4282
4453
|
}
|
|
4283
4454
|
}
|