@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 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
 
@@ -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
- // UniFFI loads the Rust bridge through JNA at runtime. Expose JNA to
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 + textLayout.getLineTop(line) - scrollY
960
- val bottom = totalPaddingTop + textLayout.getLineBottom(line) - scrollY
961
- return RectF(left, top.toFloat(), left + 1f, bottom.toFloat())
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(allowAfterBlur = true)
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(allowAfterBlur: Boolean): Boolean {
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(mutation)
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(mutation: NativeTextMutation) {
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
- retireInputConnectionForEditor()
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(updateJSON: String, notifyListener: Boolean = true) {
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 = resolvedRenderBlocks != null &&
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(fullSpannable, usedPatch = false)
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
  }