@openeditor/react-native-prose-editor 0.0.6 → 0.0.8

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.
Files changed (68) hide show
  1. package/android/build.gradle +35 -0
  2. package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +2 -2
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +138 -597
  4. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +13 -3
  5. package/android/src/main/java/com/apollohg/editor/InputSnapshotSupport.kt +6 -0
  6. package/android/src/main/java/com/apollohg/editor/NativeBlockEditorSurface.kt +1086 -0
  7. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +1 -1
  8. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +26 -50
  9. package/android/src/main/java/com/apollohg/editor/NativeImageLoader.kt +89 -0
  10. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +31 -66
  11. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +98 -33
  12. package/dist/EditorToolbar.d.ts +1 -1
  13. package/dist/EditorToolbar.js +19 -0
  14. package/dist/NativeEditorBridge.d.ts +62 -35
  15. package/dist/NativeEditorBridge.js +122 -79
  16. package/dist/NativeProseViewer.d.ts +13 -34
  17. package/dist/NativeProseViewer.js +40 -465
  18. package/dist/NativeRichTextEditor.d.ts +10 -1
  19. package/dist/NativeRichTextEditor.js +72 -34
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +2 -1
  22. package/dist/schemas.d.ts +11 -0
  23. package/dist/schemas.js +149 -0
  24. package/dist/useNativeEditor.d.ts +3 -3
  25. package/dist/useNativeEditor.js +3 -3
  26. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  27. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  28. package/ios/Generated_editor_core.swift +97 -2
  29. package/ios/NativeBlockEditorSurface.swift +1393 -0
  30. package/ios/NativeEditorExpoView.swift +3 -3
  31. package/ios/NativeEditorModule.swift +30 -45
  32. package/ios/NativeInputSupport.swift +396 -0
  33. package/ios/PositionBridge.swift +4 -4
  34. package/ios/RichTextEditorView.swift +333 -786
  35. package/ios/editor_coreFFI/editor_coreFFI.h +147 -70
  36. package/package.json +1 -1
  37. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  38. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  39. package/rust/android/x86_64/libeditor_core.so +0 -0
  40. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2087 -1399
  41. package/android/src/androidTest/AndroidManifest.xml +0 -8
  42. package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +0 -53
  43. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +0 -241
  44. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +0 -338
  45. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +0 -789
  46. package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +0 -350
  47. package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +0 -5
  48. package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +0 -315
  49. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +0 -2172
  50. package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +0 -482
  51. package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +0 -137
  52. package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +0 -321
  53. package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +0 -4202
  54. package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +0 -2581
  55. package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +0 -27
  56. package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +0 -197
  57. package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +0 -84
  58. package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +0 -544
  59. package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +0 -461
  60. package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +0 -2054
  61. package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +0 -1367
  62. package/ios/EditorLayoutManager.swift +0 -774
  63. package/ios/NativeProseViewerExpoView.swift +0 -276
  64. package/ios/RenderBridge.swift +0 -1708
  65. package/ios/Tests/NativePerformanceTests.swift +0 -687
  66. package/ios/Tests/PositionBridgeTests.swift +0 -706
  67. package/ios/Tests/RenderBridgeTests.swift +0 -2236
  68. package/ios/Tests/RichTextEditorViewTests.swift +0 -6548
@@ -48,11 +48,11 @@ import uniffi.editor_core.* // UniFFI-generated bindings
48
48
  * Instead of letting Android's EditText internal text storage handle insertions
49
49
  * and deletions, this class captures the user's intent (typing, deleting,
50
50
  * pasting, autocorrect) and sends it to the Rust editor. The Rust editor
51
- * returns render elements, which are converted to [android.text.SpannableStringBuilder]
52
- * via [RenderBridge] and applied back to the EditText.
51
+ * returns a canonical plain-text input snapshot, which is authorized and
52
+ * applied back to this hidden input adapter.
53
53
  *
54
54
  * This is the "input interception" pattern: the EditText is effectively
55
- * a rendering surface, not a text editing engine.
55
+ * an IME/selection surface, not the visible block renderer.
56
56
  *
57
57
  * ## Composition Handling
58
58
  *
@@ -99,6 +99,7 @@ class EditorEditText @JvmOverloads constructor(
99
99
  val rect: RectF
100
100
  )
101
101
 
102
+
102
103
  data class MentionHit(
103
104
  val docPos: Int,
104
105
  val label: String
@@ -120,29 +121,11 @@ class EditorEditText @JvmOverloads constructor(
120
121
  val text: String
121
122
  )
122
123
 
123
- private data class ParsedRenderPatch(
124
- val startIndex: Int,
125
- val deleteCount: Int,
126
- val renderBlocks: org.json.JSONArray
127
- )
128
-
129
124
  private data class RenderReplaceRange(
130
125
  val start: Int,
131
126
  val endExclusive: Int
132
127
  )
133
128
 
134
- private data class PatchApplyTrace(
135
- val applied: Boolean,
136
- val eligibilityNanos: Long,
137
- val buildRenderNanos: Long,
138
- val applyRenderNanos: Long
139
- )
140
-
141
- private data class ImageSelectionRange(
142
- val start: Int,
143
- val end: Int
144
- )
145
-
146
129
  private data class NativeTextMutation(
147
130
  val scalarFrom: Int,
148
131
  val scalarTo: Int,
@@ -226,6 +209,7 @@ class EditorEditText @JvmOverloads constructor(
226
209
  /** Listener for editor events. */
227
210
  var editorListener: EditorListener? = null
228
211
  var onSelectionOrContentMayChange: (() -> Unit)? = null
212
+ var onUpdateJSONApplied: ((String) -> Unit)? = null
229
213
 
230
214
  /** The base font size in pixels used for unstyled text. */
231
215
  private var baseFontSize: Float = textSize
@@ -285,14 +269,12 @@ class EditorEditText @JvmOverloads constructor(
285
269
  private var nativeTextMutationAdoptionSuppression: NativeTextMutationAdoptionSuppression? = null
286
270
  private var lastAuthorizedTextRevision: Long = 0L
287
271
  private var lastAuthorizedRenderedText: CharSequence? = null
288
- private var explicitSelectedImageRange: ImageSelectionRange? = null
289
272
  private var lastRenderAppliedPatchForTesting: Boolean = false
290
273
  internal var captureApplyUpdateTraceForTesting: Boolean = false
291
274
  private var lastApplyUpdateTraceForTesting: ApplyUpdateTrace? = null
292
275
  private val imeTraceForTesting = java.util.ArrayDeque<String>()
293
276
  private var imeTraceSequence: Long = 0L
294
277
  private var lastImeTraceUptimeMs: Long = 0L
295
- private var currentRenderBlocksJson: org.json.JSONArray? = null
296
278
  private var renderAppearanceRevision: Long = 1L
297
279
  private var lastAppliedRenderAppearanceRevision: Long = 0L
298
280
  private var pendingOptimisticRenderText: String? = null
@@ -401,8 +383,8 @@ class EditorEditText @JvmOverloads constructor(
401
383
  linksClickable = false
402
384
 
403
385
  // Suppress the platform caret and draw our own. Android's Editor anchors
404
- // the native caret to getLineBottom(line), which a ParagraphSpacerSpan
405
- // inflates — stretching the caret into the inter-block gap. Our caret is
386
+ // the native caret to getLineBottom(line), which line-height spans can
387
+ // inflate. Our caret is
406
388
  // clipped to the glyph height via [CaretGeometry]. See [drawCustomCaret].
407
389
  isCursorVisible = false
408
390
 
@@ -642,7 +624,7 @@ class EditorEditText @JvmOverloads constructor(
642
624
  '\\' -> builder.append("\\\\")
643
625
  '"' -> builder.append("\\\"")
644
626
  else -> {
645
- if (ch.code < 0x20 || ch == LayoutConstants.SYNTHETIC_PLACEHOLDER_CHARACTER[0]) {
627
+ if (ch.code < 0x20 || ch == InputSnapshotConstants.SYNTHETIC_PLACEHOLDER_CHARACTER[0]) {
646
628
  builder.append("\\u")
647
629
  builder.append(ch.code.toString(16).padStart(4, '0'))
648
630
  } else {
@@ -746,15 +728,6 @@ class EditorEditText @JvmOverloads constructor(
746
728
  }
747
729
 
748
730
  override fun onTouchEvent(event: MotionEvent): Boolean {
749
- if (event.actionMasked == MotionEvent.ACTION_DOWN && imageSpanHitAt(event.x, event.y) == null) {
750
- clearExplicitSelectedImageRange()
751
- }
752
- if (handleTaskListMarkerTap(event)) {
753
- return true
754
- }
755
- if (handleImageTap(event)) {
756
- return true
757
- }
758
731
  if (heightBehavior == EditorHeightBehavior.FIXED) {
759
732
  val canScroll = canScrollVertically(-1) || canScrollVertically(1)
760
733
  if (canScroll) {
@@ -962,9 +935,7 @@ class EditorEditText @JvmOverloads constructor(
962
935
  fun setImageResizingEnabled(enabled: Boolean) {
963
936
  if (imageResizingEnabled == enabled) return
964
937
  imageResizingEnabled = enabled
965
- if (!enabled) {
966
- clearExplicitSelectedImageRange()
967
- } else {
938
+ if (enabled) {
968
939
  onSelectionOrContentMayChange?.invoke()
969
940
  }
970
941
  }
@@ -1043,8 +1014,8 @@ class EditorEditText @JvmOverloads constructor(
1043
1014
  val selectionOffset = selectionEnd.takeIf { it >= 0 } ?: return null
1044
1015
  val clampedOffset = selectionOffset.coerceIn(0, textLayout.text.length)
1045
1016
  val caretLeft = textLayout.getPrimaryHorizontal(clampedOffset)
1046
- // Clip the caret to the rendered glyph height so a ParagraphSpacerSpan's
1047
- // inflated descent does not stretch it into the inter-block gap.
1017
+ // Clip the caret to the rendered glyph height so inflated descent does
1018
+ // not stretch it into extra line space.
1048
1019
  val bounds = CaretGeometry.verticalBounds(textLayout, clampedOffset, paint)
1049
1020
  val left = totalPaddingLeft + caretLeft - scrollX
1050
1021
  val top = totalPaddingTop + bounds.top - scrollY
@@ -1169,8 +1140,6 @@ class EditorEditText @JvmOverloads constructor(
1169
1140
  if (currentText != lastAuthorizedText) return false
1170
1141
  if (startUtf16 < 0 || endUtf16 < startUtf16 || endUtf16 > editable.length) return false
1171
1142
  val spanned = editable as? Spanned
1172
- if (spanned != null && spannedRangeContainsImageSpan(spanned, startUtf16, endUtf16)) return false
1173
-
1174
1143
  val inlineSpans = spanned?.let {
1175
1144
  optimisticInlineSpansForInsertion(it, startUtf16)
1176
1145
  }.orEmpty()
@@ -1290,6 +1259,32 @@ class EditorEditText @JvmOverloads constructor(
1290
1259
  setCompositionReplacementRange(start, end)
1291
1260
  }
1292
1261
 
1262
+ /** Select the synthetic empty-block scalar so BaseInputConnection replaces
1263
+ * it with transient composing text instead of appending beside it. The
1264
+ * Rust replacement range remains the collapsed 0-width document range. */
1265
+ internal fun selectSyntheticPlaceholderForTransientComposition(): Boolean {
1266
+ val editable = text ?: return false
1267
+ val start = selectionStart
1268
+ val end = selectionEnd
1269
+ if (start < 0 || start != end || start >= editable.length) return false
1270
+ if (editable[start] != EMPTY_BLOCK_PLACEHOLDER) return false
1271
+ Selection.setSelection(editable, start, start + 1)
1272
+ return true
1273
+ }
1274
+
1275
+ internal fun collapseSelectionToComposingEndForEditor() {
1276
+ val editable = text ?: return
1277
+ val composingEnd = BaseInputConnection.getComposingSpanEnd(editable)
1278
+ val replacementStart = composingReplacementStartUtf16
1279
+ val trackedEnd = if (replacementStart != null) {
1280
+ replacementStart + (composingText?.length ?: 0)
1281
+ } else {
1282
+ -1
1283
+ }
1284
+ val end = if (composingEnd >= 0) composingEnd else trackedEnd
1285
+ if (end >= 0) Selection.setSelection(editable, end.coerceIn(0, editable.length))
1286
+ }
1287
+
1293
1288
  internal fun setCompositionReplacementRange(start: Int, end: Int) {
1294
1289
  if (didInvalidateCompositionReplacementRange) return
1295
1290
  val replacementRange = authorizedUtf16Range(start, end)
@@ -1425,8 +1420,22 @@ class EditorEditText @JvmOverloads constructor(
1425
1420
  val currentText = text?.toString() ?: return null
1426
1421
  if (start < 0 || end < start || end > authorizedText.length) return null
1427
1422
 
1423
+ // The input snapshot represents an otherwise empty text block with a
1424
+ // native-only zero-width scalar. Composition at that document position
1425
+ // replaces the scalar visually, while its Rust replacement range stays
1426
+ // collapsed because the scalar is not document content. Exclude it from
1427
+ // the authorized suffix when recovering the transient replacement.
1428
+ val authorizedEnd = if (
1429
+ start == end &&
1430
+ end < authorizedText.length &&
1431
+ authorizedText[end] == EMPTY_BLOCK_PLACEHOLDER
1432
+ ) {
1433
+ end + 1
1434
+ } else {
1435
+ end
1436
+ }
1428
1437
  val authorizedPrefix = authorizedText.substring(0, start)
1429
- val authorizedSuffix = authorizedText.substring(end)
1438
+ val authorizedSuffix = authorizedText.substring(authorizedEnd)
1430
1439
  if (!currentText.startsWith(authorizedPrefix)) return null
1431
1440
  if (!currentText.endsWith(authorizedSuffix)) return null
1432
1441
 
@@ -2620,10 +2629,6 @@ class EditorEditText @JvmOverloads constructor(
2620
2629
  override fun onSelectionChanged(selStart: Int, selEnd: Int) {
2621
2630
  super.onSelectionChanged(selStart, selEnd)
2622
2631
  if (isApplyingRustState) return
2623
- val spannable = text as? Spanned
2624
- if (spannable != null && isExactImageSpanRange(spannable, selStart, selEnd)) {
2625
- explicitSelectedImageRange = ImageSelectionRange(selStart, selEnd)
2626
- }
2627
2632
  ensureSelectionVisible()
2628
2633
  onSelectionOrContentMayChange?.invoke()
2629
2634
  // Keep the custom caret solid at its new position, then resume blinking.
@@ -2708,7 +2713,6 @@ class EditorEditText @JvmOverloads constructor(
2708
2713
  lastAuthorizedText = text?.toString().orEmpty()
2709
2714
  lastAuthorizedRenderedText = text?.let { SpannableStringBuilder(it) }
2710
2715
  lastAuthorizedTextRevision += 1L
2711
- currentRenderBlocksJson = null
2712
2716
  clearNativeTextMutationAdoptionSuppression()
2713
2717
  clearNativeTextMutationAfterBlurWindow()
2714
2718
  }
@@ -3371,7 +3375,7 @@ class EditorEditText @JvmOverloads constructor(
3371
3375
  var effectiveCursor = cursor
3372
3376
  while (
3373
3377
  effectiveCursor > 0 &&
3374
- content[effectiveCursor - 1] == LayoutConstants.SYNTHETIC_PLACEHOLDER_CHARACTER[0]
3378
+ content[effectiveCursor - 1] == InputSnapshotConstants.SYNTHETIC_PLACEHOLDER_CHARACTER[0]
3375
3379
  ) {
3376
3380
  effectiveCursor -= 1
3377
3381
  }
@@ -3386,7 +3390,7 @@ class EditorEditText @JvmOverloads constructor(
3386
3390
 
3387
3391
  internal fun initialSurroundingTextForImeForEditor(): ImeInitialSurroundingText? {
3388
3392
  val rawText = text?.toString() ?: return null
3389
- val placeholder = LayoutConstants.SYNTHETIC_PLACEHOLDER_CHARACTER[0]
3393
+ val placeholder = InputSnapshotConstants.SYNTHETIC_PLACEHOLDER_CHARACTER[0]
3390
3394
  if (rawText.indexOf(placeholder) < 0) return null
3391
3395
  val start = selectionStart
3392
3396
  val end = selectionEnd
@@ -3471,14 +3475,14 @@ class EditorEditText @JvmOverloads constructor(
3471
3475
  ch == ' ' ||
3472
3476
  ch == '\t' ||
3473
3477
  ch == '\u00A0' ||
3474
- ch == LayoutConstants.SYNTHETIC_PLACEHOLDER_CHARACTER[0]
3478
+ ch == InputSnapshotConstants.SYNTHETIC_PLACEHOLDER_CHARACTER[0]
3475
3479
 
3476
3480
  private fun renderedListMarkerEnd(text: String, start: Int, endExclusive: Int): Int? {
3477
3481
  if (start >= endExclusive) return null
3478
3482
  if (renderedTaskListMarkerEnd(text, start, endExclusive) != null) {
3479
3483
  return start + 2
3480
3484
  }
3481
- if (text[start] == LayoutConstants.UNORDERED_LIST_BULLET[0]) {
3485
+ if (text[start] == InputSnapshotConstants.UNORDERED_LIST_BULLET[0]) {
3482
3486
  return start + 1
3483
3487
  }
3484
3488
 
@@ -3556,48 +3560,7 @@ class EditorEditText @JvmOverloads constructor(
3556
3560
  PositionBridge.utf16ToScalar(snappedHead, currentText)
3557
3561
  }
3558
3562
 
3559
- fun selectedImageGeometry(): SelectedImageGeometry? {
3560
- if (!imageResizingEnabled) return null
3561
- val spannable = text as? Spanned ?: return null
3562
- val selection = resolvedSelectedImageRange(spannable) ?: return null
3563
- val start = selection.start
3564
- val end = selection.end
3565
- val imageSpan = spannable
3566
- .getSpans(start, end, BlockImageSpan::class.java)
3567
- .firstOrNull() ?: return null
3568
- val spanStart = spannable.getSpanStart(imageSpan)
3569
- val spanEnd = spannable.getSpanEnd(imageSpan)
3570
- if (spanStart != start || spanEnd != end) return null
3571
-
3572
- val textLayout = layout ?: return null
3573
- val currentText = text?.toString() ?: return null
3574
- val scalarPos = PositionBridge.utf16ToScalar(spanStart, currentText)
3575
- val docPos = if (hasLiveEditor()) {
3576
- editorScalarToDoc(editorId.toULong(), scalarPos.toUInt()).toInt()
3577
- } else {
3578
- 0
3579
- }
3580
- val line = textLayout.getLineForOffset(spanStart.coerceAtMost(maxOf(spannable.length - 1, 0)))
3581
- val rect = resolvedImageRect(textLayout, imageSpan, spanStart, spanEnd)
3582
- return SelectedImageGeometry(
3583
- docPos = docPos,
3584
- rect = rect
3585
- )
3586
- }
3587
3563
 
3588
- fun resizeImageAtDocPos(docPos: Int, widthPx: Float, heightPx: Float) {
3589
- if (!hasLiveEditor()) return
3590
- val density = resources.displayMetrics.density
3591
- val widthDp = maxOf(48, (widthPx / density).roundToInt())
3592
- val heightDp = maxOf(48, (heightPx / density).roundToInt())
3593
- val updateJSON = editorResizeImageAtDocPos(
3594
- editorId.toULong(),
3595
- docPos.toUInt(),
3596
- widthDp.toUInt(),
3597
- heightDp.toUInt()
3598
- )
3599
- applyUpdateJSON(updateJSON)
3600
- }
3601
3564
 
3602
3565
  private fun isSelectionInsideList(): Boolean {
3603
3566
  if (!hasLiveEditor()) return false
@@ -3636,66 +3599,6 @@ class EditorEditText @JvmOverloads constructor(
3636
3599
 
3637
3600
  // ── Applying Rust State ─────────────────────────────────────────────
3638
3601
 
3639
- private fun parseRenderPatch(raw: org.json.JSONObject?): ParsedRenderPatch? {
3640
- if (raw == null) return null
3641
- val renderBlocks = raw.optJSONArray("renderBlocks") ?: return null
3642
- return ParsedRenderPatch(
3643
- startIndex = raw.optInt("startIndex", -1),
3644
- deleteCount = raw.optInt("deleteCount", -1),
3645
- renderBlocks = renderBlocks
3646
- ).takeIf { it.startIndex >= 0 && it.deleteCount >= 0 }
3647
- }
3648
-
3649
- private fun hasTopLevelChildMetadata(content: Spanned): Boolean =
3650
- content.getSpans(0, content.length, Annotation::class.java).any {
3651
- it.key == RenderBridge.NATIVE_TOP_LEVEL_CHILD_INDEX_ANNOTATION
3652
- }
3653
-
3654
- private fun firstCharacterOffsetForTopLevelChildIndex(content: Spanned, index: Int): Int? {
3655
- val targetValue = index.toString()
3656
- return content
3657
- .getSpans(0, content.length, Annotation::class.java)
3658
- .asSequence()
3659
- .filter { it.key == RenderBridge.NATIVE_TOP_LEVEL_CHILD_INDEX_ANNOTATION && it.value == targetValue }
3660
- .mapNotNull { span ->
3661
- val spanStart = content.getSpanStart(span)
3662
- val spanEnd = content.getSpanEnd(span)
3663
- if (spanStart < 0 || spanEnd <= spanStart) {
3664
- null
3665
- } else {
3666
- var candidate = spanStart
3667
- while (candidate < spanEnd && candidate < content.length) {
3668
- when (content[candidate]) {
3669
- '\n', '\r' -> candidate += 1
3670
- else -> return@mapNotNull candidate
3671
- }
3672
- }
3673
- null
3674
- }
3675
- }
3676
- .minOrNull()
3677
- }
3678
-
3679
- private fun replacementRangeForRenderPatch(
3680
- content: Spanned,
3681
- startIndex: Int,
3682
- deleteCount: Int
3683
- ): RenderReplaceRange? {
3684
- val start = firstCharacterOffsetForTopLevelChildIndex(content, startIndex)
3685
- ?: if (deleteCount == 0) content.length else return null
3686
- val endExclusive = firstCharacterOffsetForTopLevelChildIndex(content, startIndex + deleteCount)
3687
- ?: content.length
3688
- if (start > endExclusive) return null
3689
- return RenderReplaceRange(start = start, endExclusive = endExclusive)
3690
- }
3691
-
3692
- private fun spannedRangeContainsImageSpan(content: Spanned, start: Int, endExclusive: Int): Boolean {
3693
- if (start >= endExclusive) return false
3694
- return content.getSpans(start, endExclusive, BlockImageSpan::class.java).isNotEmpty()
3695
- }
3696
-
3697
- private fun spannedContainsImageSpan(content: Spanned): Boolean =
3698
- spannedRangeContainsImageSpan(content, 0, content.length)
3699
3602
 
3700
3603
  private fun applyRenderedSpannable(
3701
3604
  spannable: CharSequence,
@@ -3766,167 +3669,11 @@ class EditorEditText @JvmOverloads constructor(
3766
3669
  )
3767
3670
  }
3768
3671
 
3769
- private fun buildPatchedSpannable(patch: ParsedRenderPatch): android.text.SpannableStringBuilder =
3770
- RenderBridge.buildSpannableFromBlocks(
3771
- patch.renderBlocks,
3772
- startIndex = patch.startIndex,
3773
- baseFontSize = baseFontSize,
3774
- textColor = baseTextColor,
3775
- theme = theme,
3776
- density = resources.displayMetrics.density,
3777
- hostView = this
3778
- )
3779
-
3780
- private fun cloneJsonArray(array: org.json.JSONArray): org.json.JSONArray =
3781
- org.json.JSONArray().also { clone ->
3782
- for (index in 0 until array.length()) {
3783
- clone.put(array.opt(index))
3784
- }
3785
- }
3786
-
3787
- private fun normalizedJsonValue(value: Any?): Any? =
3788
- if (value === org.json.JSONObject.NULL) null else value
3789
-
3790
- private fun jsonValuesEqual(left: Any?, right: Any?): Boolean {
3791
- val normalizedLeft = normalizedJsonValue(left)
3792
- val normalizedRight = normalizedJsonValue(right)
3793
- if (normalizedLeft === normalizedRight) return true
3794
- if (normalizedLeft == null || normalizedRight == null) return false
3795
-
3796
- if (normalizedLeft is org.json.JSONArray && normalizedRight is org.json.JSONArray) {
3797
- if (normalizedLeft.length() != normalizedRight.length()) return false
3798
- for (index in 0 until normalizedLeft.length()) {
3799
- if (!jsonValuesEqual(normalizedLeft.opt(index), normalizedRight.opt(index))) {
3800
- return false
3801
- }
3802
- }
3803
- return true
3804
- }
3805
-
3806
- if (normalizedLeft is org.json.JSONObject && normalizedRight is org.json.JSONObject) {
3807
- if (normalizedLeft.length() != normalizedRight.length()) return false
3808
- val keys = normalizedLeft.keys()
3809
- while (keys.hasNext()) {
3810
- val key = keys.next()
3811
- if (!normalizedRight.has(key)) return false
3812
- if (!jsonValuesEqual(normalizedLeft.opt(key), normalizedRight.opt(key))) {
3813
- return false
3814
- }
3815
- }
3816
- return true
3817
- }
3818
-
3819
- if (normalizedLeft is Number && normalizedRight is Number) {
3820
- return normalizedLeft.toDouble() == normalizedRight.toDouble()
3821
- }
3822
-
3823
- return normalizedLeft == normalizedRight
3824
- }
3825
-
3826
- private fun renderBlocksEqual(
3827
- current: org.json.JSONArray,
3828
- updated: org.json.JSONArray
3829
- ): Boolean {
3830
- if (current.length() != updated.length()) return false
3831
- for (index in 0 until current.length()) {
3832
- if (!jsonValuesEqual(current.opt(index), updated.opt(index))) {
3833
- return false
3834
- }
3835
- }
3836
- return true
3837
- }
3838
-
3839
- private fun mergeRenderBlocks(
3840
- current: org.json.JSONArray,
3841
- patch: ParsedRenderPatch
3842
- ): org.json.JSONArray? {
3843
- if (
3844
- patch.startIndex < 0 ||
3845
- patch.deleteCount < 0 ||
3846
- patch.startIndex > current.length() ||
3847
- patch.startIndex + patch.deleteCount > current.length()
3848
- ) {
3849
- return null
3850
- }
3851
-
3852
- return org.json.JSONArray().also { merged ->
3853
- for (index in 0 until patch.startIndex) {
3854
- merged.put(current.opt(index))
3855
- }
3856
- for (index in 0 until patch.renderBlocks.length()) {
3857
- merged.put(patch.renderBlocks.opt(index))
3858
- }
3859
- for (index in (patch.startIndex + patch.deleteCount) until current.length()) {
3860
- merged.put(current.opt(index))
3861
- }
3862
- }
3863
- }
3864
-
3865
- private fun applyRenderPatchIfPossible(patch: ParsedRenderPatch): PatchApplyTrace {
3866
- val eligibilityStartedAt = System.nanoTime()
3867
- val content = text as? Spanned ?: return PatchApplyTrace(
3868
- applied = false,
3869
- eligibilityNanos = System.nanoTime() - eligibilityStartedAt,
3870
- buildRenderNanos = 0L,
3871
- applyRenderNanos = 0L
3872
- )
3873
- if (!hasTopLevelChildMetadata(content)) {
3874
- return PatchApplyTrace(
3875
- applied = false,
3876
- eligibilityNanos = System.nanoTime() - eligibilityStartedAt,
3877
- buildRenderNanos = 0L,
3878
- applyRenderNanos = 0L
3879
- )
3880
- }
3881
-
3882
- val replaceRange = replacementRangeForRenderPatch(content, patch.startIndex, patch.deleteCount)
3883
- ?: return PatchApplyTrace(
3884
- applied = false,
3885
- eligibilityNanos = System.nanoTime() - eligibilityStartedAt,
3886
- buildRenderNanos = 0L,
3887
- applyRenderNanos = 0L
3888
- )
3889
- if (spannedRangeContainsImageSpan(content, replaceRange.start, replaceRange.endExclusive)) {
3890
- return PatchApplyTrace(
3891
- applied = false,
3892
- eligibilityNanos = System.nanoTime() - eligibilityStartedAt,
3893
- buildRenderNanos = 0L,
3894
- applyRenderNanos = 0L
3895
- )
3896
- }
3897
- val eligibilityNanos = System.nanoTime() - eligibilityStartedAt
3898
-
3899
- val buildStartedAt = System.nanoTime()
3900
- val patchedSpannable = buildPatchedSpannable(patch)
3901
- val buildRenderNanos = System.nanoTime() - buildStartedAt
3902
- if (spannedContainsImageSpan(patchedSpannable)) {
3903
- return PatchApplyTrace(
3904
- applied = false,
3905
- eligibilityNanos = eligibilityNanos,
3906
- buildRenderNanos = buildRenderNanos,
3907
- applyRenderNanos = 0L
3908
- )
3909
- }
3910
-
3911
- val applyStartedAt = System.nanoTime()
3912
- applyRenderedSpannable(
3913
- spannable = patchedSpannable,
3914
- replaceRange = replaceRange,
3915
- usedPatch = true
3916
- )
3917
- return PatchApplyTrace(
3918
- applied = true,
3919
- eligibilityNanos = eligibilityNanos,
3920
- buildRenderNanos = buildRenderNanos,
3921
- applyRenderNanos = System.nanoTime() - applyStartedAt
3922
- )
3923
- }
3924
-
3925
3672
  /**
3926
- * Apply a full render update from Rust to the EditText.
3673
+ * Apply a structural editor update from Rust to the hidden input EditText.
3927
3674
  *
3928
- * Parses the update JSON, converts render elements to [android.text.SpannableStringBuilder]
3929
- * via [RenderBridge], and replaces the EditText's content.
3675
+ * The visible editor surface renders the block tree. This EditText keeps a
3676
+ * plain text snapshot for input, selection, and IME routing only.
3930
3677
  *
3931
3678
  * @param updateJSON The JSON string from editor_insert_text, etc.
3932
3679
  */
@@ -3950,76 +3697,57 @@ class EditorEditText @JvmOverloads constructor(
3950
3697
  cancelDeferredRustUpdateApplication()
3951
3698
  val parseNanos = System.nanoTime() - parseStartedAt
3952
3699
 
3953
- val resolveRenderBlocksStartedAt = System.nanoTime()
3954
- val renderElements = update.optJSONArray("renderElements")
3955
- val renderBlocks = update.optJSONArray("renderBlocks")
3956
- val renderPatch = parseRenderPatch(update.optJSONObject("renderPatch"))
3957
- val resolvedRenderBlocks = renderBlocks
3958
- ?: renderPatch?.let { patch ->
3959
- currentRenderBlocksJson?.let { mergeRenderBlocks(it, patch) }
3700
+ val resolveRenderBlocksNanos = 0L
3701
+ val inputSnapshot = if (update.has("inputText")) update.optString("inputText") else null
3702
+ if (inputSnapshot == null && update.has("selection")) {
3703
+ val selectionStartedAt = System.nanoTime()
3704
+ update.optJSONObject("selection")?.let { applySelectionFromJSON(it) }
3705
+ val selectionNanos = System.nanoTime() - selectionStartedAt
3706
+ if (notifyListener) {
3707
+ editorListener?.onEditorUpdate(updateJSON)
3960
3708
  }
3961
- val resolveRenderBlocksNanos = System.nanoTime() - resolveRenderBlocksStartedAt
3709
+ onUpdateJSONApplied?.invoke(updateJSON)
3710
+ onSelectionOrContentMayChange?.invoke()
3711
+ recordImeTraceForTesting(
3712
+ "applyUpdateJSON",
3713
+ "notify=$notifyListener selectionOnly=true jsonLength=${updateJSON.length} selectionUs=${nanosToMicros(selectionNanos)}"
3714
+ )
3715
+ return
3716
+ }
3962
3717
  val shouldSkipRender = !refreshInputConnectionForExternalUpdate &&
3963
- resolvedRenderBlocks != null &&
3964
- currentRenderBlocksJson?.let { current ->
3965
- renderBlocksEqual(current, resolvedRenderBlocks)
3966
- } == true &&
3718
+ inputSnapshot != null &&
3719
+ text?.toString() == inputSnapshot &&
3967
3720
  text?.toString() == lastAuthorizedText &&
3968
3721
  lastAppliedRenderAppearanceRevision == renderAppearanceRevision
3969
3722
  val previousScrollX = scrollX
3970
3723
  val previousScrollY = scrollY
3971
3724
 
3972
- explicitSelectedImageRange = null
3973
3725
  val buildRenderNanos: Long
3974
3726
  val applyRenderNanos: Long
3975
3727
  if (shouldSkipRender) {
3976
3728
  pendingOptimisticRenderText = null
3977
3729
  lastRenderAppliedPatchForTesting = false
3978
- currentRenderBlocksJson = resolvedRenderBlocks?.let(::cloneJsonArray)
3979
3730
  clearNativeTextMutationAdoptionSuppression()
3980
3731
  clearNativeTextMutationAfterBlurWindow()
3981
3732
  buildRenderNanos = 0L
3982
3733
  applyRenderNanos = 0L
3983
3734
  } else {
3984
- // Android's Editable.replace(...) path benchmarks substantially slower than
3985
- // rebuilding from merged render blocks, so patch payloads are treated as a
3986
- // transport optimization only. We still resolve the merged block state above,
3987
- // then apply it through the faster full-text path here.
3988
3735
  val buildStartedAt = System.nanoTime()
3989
- val fullSpannable = if (resolvedRenderBlocks != null) {
3990
- RenderBridge.buildSpannableFromBlocks(
3991
- resolvedRenderBlocks,
3992
- baseFontSize = baseFontSize,
3993
- textColor = baseTextColor,
3994
- theme = theme,
3995
- density = resources.displayMetrics.density,
3996
- hostView = this
3997
- )
3998
- } else if (renderElements != null) {
3999
- RenderBridge.buildSpannableFromArray(
4000
- renderElements,
4001
- baseFontSize,
4002
- baseTextColor,
4003
- theme,
4004
- resources.displayMetrics.density,
4005
- this
4006
- )
4007
- } else {
3736
+ if (inputSnapshot == null) {
4008
3737
  recordImeTraceForTesting(
4009
3738
  "applyUpdateJSONNoop",
4010
- "reason=noRenderPayload jsonLength=${updateJSON.length}"
3739
+ "reason=noRenderTree jsonLength=${updateJSON.length}"
4011
3740
  )
4012
3741
  return
4013
3742
  }
3743
+ val fullSpannable = android.text.SpannableStringBuilder(inputSnapshot)
4014
3744
  buildRenderNanos = System.nanoTime() - buildStartedAt
4015
- currentRenderBlocksJson = resolvedRenderBlocks?.let(::cloneJsonArray)
4016
3745
  val applyStartedAt = System.nanoTime()
4017
3746
  val optimisticText = pendingOptimisticRenderText
4018
3747
  val canReuseOptimisticVisibleText =
4019
3748
  optimisticText != null &&
4020
3749
  text?.toString() == optimisticText &&
4021
- fullSpannable.toString() == optimisticText &&
4022
- !spannedContainsImageSpan(fullSpannable)
3750
+ fullSpannable.toString() == optimisticText
4023
3751
  if (canReuseOptimisticVisibleText) {
4024
3752
  authorizeVisibleTextForMatchedOptimisticRender(fullSpannable)
4025
3753
  } else {
@@ -4046,6 +3774,7 @@ class EditorEditText @JvmOverloads constructor(
4046
3774
  if (notifyListener) {
4047
3775
  editorListener?.onEditorUpdate(updateJSON)
4048
3776
  }
3777
+ onUpdateJSONApplied?.invoke(updateJSON)
4049
3778
  onSelectionOrContentMayChange?.invoke()
4050
3779
  if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
4051
3780
  requestLayout()
@@ -4061,12 +3790,12 @@ class EditorEditText @JvmOverloads constructor(
4061
3790
  val totalNanos = System.nanoTime() - totalStartedAt
4062
3791
  recordImeTraceForTesting(
4063
3792
  "applyUpdateJSON",
4064
- "notify=$notifyListener skippedRender=$shouldSkipRender attemptedPatch=${renderPatch != null} jsonLength=${updateJSON.length} parseUs=${nanosToMicros(parseNanos)} resolveUs=${nanosToMicros(resolveRenderBlocksNanos)} buildUs=${nanosToMicros(buildRenderNanos)} applyUs=${nanosToMicros(applyRenderNanos)} selectionUs=${nanosToMicros(selectionNanos)} postUs=${nanosToMicros(postApplyNanos)} totalUs=${nanosToMicros(totalNanos)}"
3793
+ "notify=$notifyListener skippedRender=$shouldSkipRender structuralInput=true jsonLength=${updateJSON.length} parseUs=${nanosToMicros(parseNanos)} buildUs=${nanosToMicros(buildRenderNanos)} applyUs=${nanosToMicros(applyRenderNanos)} selectionUs=${nanosToMicros(selectionNanos)} postUs=${nanosToMicros(postApplyNanos)} totalUs=${nanosToMicros(totalNanos)}"
4065
3794
  )
4066
3795
 
4067
3796
  if (captureApplyUpdateTraceForTesting) {
4068
3797
  lastApplyUpdateTraceForTesting = ApplyUpdateTrace(
4069
- attemptedPatch = renderPatch != null,
3798
+ attemptedPatch = false,
4070
3799
  usedPatch = false,
4071
3800
  skippedRender = shouldSkipRender,
4072
3801
  parseNanos = parseNanos,
@@ -4081,6 +3810,60 @@ class EditorEditText @JvmOverloads constructor(
4081
3810
  }
4082
3811
  }
4083
3812
 
3813
+ private fun plainTextSnapshotFromRenderTree(tree: org.json.JSONObject?): String? {
3814
+ val root = tree?.optJSONObject("root") ?: return null
3815
+ val builder = StringBuilder()
3816
+ appendInputSnapshotNode(root, builder, isRoot = true)
3817
+ return builder.toString()
3818
+ }
3819
+
3820
+ private fun appendInputSnapshotNode(
3821
+ node: org.json.JSONObject,
3822
+ builder: StringBuilder,
3823
+ isRoot: Boolean
3824
+ ) {
3825
+ val kind = node.optString("kind", "block")
3826
+ val nodeType = node.optString("nodeType", "unknown")
3827
+ when (kind) {
3828
+ "text" -> {
3829
+ builder.append(node.optString("text", ""))
3830
+ return
3831
+ }
3832
+ "inlineAtom" -> {
3833
+ if (nodeType == "hardBreak" || nodeType == "hard_break") {
3834
+ builder.append('\n')
3835
+ } else {
3836
+ builder.append(node.optString("label", "\uFFFC"))
3837
+ }
3838
+ return
3839
+ }
3840
+ "blockAtom" -> {
3841
+ if (builder.isNotEmpty() && builder.last() != '\n') builder.append('\n')
3842
+ builder.append(node.optString("label", "\uFFFC"))
3843
+ builder.append('\n')
3844
+ return
3845
+ }
3846
+ }
3847
+
3848
+ val children = node.optJSONArray("children") ?: org.json.JSONArray()
3849
+ val startsBlock = !isRoot && isInputSnapshotBlockBoundary(kind)
3850
+ if (startsBlock && builder.isNotEmpty() && builder.last() != '\n') builder.append('\n')
3851
+ for (index in 0 until children.length()) {
3852
+ children.optJSONObject(index)?.let {
3853
+ appendInputSnapshotNode(
3854
+ it,
3855
+ builder,
3856
+ isRoot = false
3857
+ )
3858
+ }
3859
+ }
3860
+ if (startsBlock && builder.isNotEmpty() && builder.last() != '\n') builder.append('\n')
3861
+ }
3862
+
3863
+ private fun isInputSnapshotBlockBoundary(kind: String): Boolean =
3864
+ kind == "textBlock" || kind == "listItem" || kind == "block" ||
3865
+ kind == "tableCell" || kind == "tableRow" || kind == "table"
3866
+
4084
3867
  private fun refreshInputConnectionAfterExternalTextReplacementIfNeeded(
4085
3868
  enabled: Boolean,
4086
3869
  previousVisibleText: String
@@ -4099,44 +3882,6 @@ class EditorEditText @JvmOverloads constructor(
4099
3882
  clearNativeComposingSpans()
4100
3883
  }
4101
3884
 
4102
- /**
4103
- * Apply a render JSON string (just render elements, no update wrapper).
4104
- *
4105
- * Used for initial content loading (set_html / set_json return render
4106
- * elements directly, not wrapped in an EditorUpdate).
4107
- *
4108
- * @param renderJSON The JSON array string of render elements.
4109
- */
4110
- fun applyRenderJSON(renderJSON: String) {
4111
- val startedAt = System.nanoTime()
4112
- val spannable = RenderBridge.buildSpannable(
4113
- renderJSON,
4114
- baseFontSize,
4115
- baseTextColor,
4116
- theme,
4117
- resources.displayMetrics.density,
4118
- this
4119
- )
4120
-
4121
- val previousScrollX = scrollX
4122
- val previousScrollY = scrollY
4123
-
4124
- explicitSelectedImageRange = null
4125
- currentRenderBlocksJson = null
4126
- pendingOptimisticRenderText = null
4127
- applyRenderedSpannable(spannable, usedPatch = false)
4128
- onSelectionOrContentMayChange?.invoke()
4129
- if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
4130
- requestLayout()
4131
- } else {
4132
- preserveScrollPosition(previousScrollX, previousScrollY)
4133
- }
4134
- recordImeTraceForTesting(
4135
- "applyRenderJSON",
4136
- "jsonLength=${renderJSON.length} totalUs=${nanosToMicros(System.nanoTime() - startedAt)}"
4137
- )
4138
- }
4139
-
4140
3885
  private fun textOffsetHitAt(x: Float, y: Float): Pair<Spanned, Int>? {
4141
3886
  val spannable = text as? Spanned ?: return null
4142
3887
  val layout = layout ?: return null
@@ -4160,134 +3905,12 @@ class EditorEditText @JvmOverloads constructor(
4160
3905
  return spannable to offset
4161
3906
  }
4162
3907
 
4163
- fun mentionHitAt(x: Float, y: Float): MentionHit? {
4164
- val (spannable, offset) = textOffsetHitAt(x, y) ?: return null
4165
- val annotations = spannable.getSpans(
4166
- offset,
4167
- (offset + 1).coerceAtMost(spannable.length),
4168
- Annotation::class.java
4169
- )
4170
- val mentionAnnotation = annotations.firstOrNull {
4171
- it.key == "nativeVoidNodeType" && it.value == "mention"
4172
- } ?: return null
4173
- val docPos = annotations.firstOrNull { it.key == "nativeDocPos" }
4174
- ?.value
4175
- ?.toIntOrNull() ?: return null
4176
- val start = spannable.getSpanStart(mentionAnnotation)
4177
- val end = spannable.getSpanEnd(mentionAnnotation)
4178
- if (start < 0 || end <= start) {
4179
- return null
4180
- }
4181
3908
 
4182
- return MentionHit(
4183
- docPos = docPos,
4184
- label = spannable.subSequence(start, end).toString()
4185
- )
4186
- }
4187
3909
 
4188
- fun linkHitAt(x: Float, y: Float): LinkHit? {
4189
- val (spannable, offset) = textOffsetHitAt(x, y) ?: return null
4190
- val annotations = spannable.getSpans(
4191
- offset,
4192
- (offset + 1).coerceAtMost(spannable.length),
4193
- Annotation::class.java
4194
- )
4195
- val linkAnnotation = annotations.firstOrNull {
4196
- it.key == RenderBridge.NATIVE_LINK_HREF_ANNOTATION && it.value.isNotBlank()
4197
- } ?: return null
4198
- val start = spannable.getSpanStart(linkAnnotation)
4199
- val end = spannable.getSpanEnd(linkAnnotation)
4200
- if (start < 0 || end <= start) {
4201
- return null
4202
- }
4203
-
4204
- return LinkHit(
4205
- href = linkAnnotation.value,
4206
- text = spannable.subSequence(start, end).toString()
4207
- )
4208
- }
4209
-
4210
- private fun handleImageTap(event: MotionEvent): Boolean {
4211
- if (!imageResizingEnabled) {
4212
- return false
4213
- }
4214
- if (event.actionMasked != MotionEvent.ACTION_DOWN && event.actionMasked != MotionEvent.ACTION_UP) {
4215
- return false
4216
- }
4217
- val hit = imageSpanHitAt(event.x, event.y) ?: return false
4218
- requestFocus()
4219
- selectExplicitImageRange(hit.first, hit.second)
4220
- if (event.actionMasked == MotionEvent.ACTION_UP) {
4221
- performClick()
4222
- }
4223
- return true
4224
- }
4225
-
4226
- private fun handleTaskListMarkerTap(event: MotionEvent): Boolean {
4227
- if (event.actionMasked != MotionEvent.ACTION_DOWN && event.actionMasked != MotionEvent.ACTION_UP) {
4228
- return false
4229
- }
4230
- val scalarHit = taskListMarkerScalarHitAt(event.x, event.y) ?: return false
4231
- requestFocus()
4232
- if (event.actionMasked == MotionEvent.ACTION_UP) {
4233
- toggleTaskItemCheckedAtSelectionScalarInRust(scalarHit, scalarHit)
4234
- performClick()
4235
- }
4236
- return true
4237
- }
4238
3910
 
4239
- private fun taskListMarkerScalarHitAt(x: Float, y: Float): Int? {
4240
- val currentText = text?.toString() ?: return null
4241
- val textLayout = layout ?: return null
4242
- if (currentText.isEmpty()) return null
4243
3911
 
4244
- val localX = x + scrollX - totalPaddingLeft
4245
- val localY = y + scrollY - totalPaddingTop
4246
- if (localY < 0) return null
4247
-
4248
- val line = textLayout.getLineForVertical(localY.toInt().coerceAtLeast(0))
4249
- val lineStart = textLayout.getLineStart(line).coerceIn(0, currentText.length)
4250
- val lineEnd = textLayout.getLineEnd(line).coerceIn(lineStart, currentText.length)
4251
- val markerEnd = renderedTaskListMarkerEnd(currentText, lineStart, lineEnd) ?: return null
4252
- val markerRight = textLayout.getPrimaryHorizontal(markerEnd).coerceAtLeast(
4253
- textLayout.getPrimaryHorizontal(lineStart)
4254
- ) + (8f * resources.displayMetrics.density)
4255
- if (localX > markerRight) {
4256
- return null
4257
- }
4258
- val snappedUtf16 = PositionBridge.snapToScalarBoundary(
4259
- lineStart,
4260
- currentText,
4261
- biasForward = true
4262
- )
4263
- return PositionBridge.utf16ToScalar(snappedUtf16, currentText)
4264
- }
4265
3912
 
4266
- private fun imageSpanHitAt(x: Float, y: Float): Pair<Int, Int>? {
4267
- val spannable = text as? Spanned ?: return null
4268
- imageSpanRangeNearTouchOffset(spannable, x, y)?.let { return it }
4269
- val textLayout = layout ?: return null
4270
- return imageSpanRectHit(spannable, textLayout, x, y)
4271
- }
4272
3913
 
4273
- private fun imageSpanRectHit(
4274
- spannable: Spanned,
4275
- textLayout: Layout,
4276
- x: Float,
4277
- y: Float
4278
- ): Pair<Int, Int>? {
4279
- val candidateSpans = spannable.getSpans(0, spannable.length, BlockImageSpan::class.java)
4280
- for (span in candidateSpans) {
4281
- val spanStart = spannable.getSpanStart(span)
4282
- val spanEnd = spannable.getSpanEnd(span)
4283
- if (spanStart < 0 || spanEnd <= spanStart) continue
4284
- val rect = resolvedImageRect(textLayout, span, spanStart, spanEnd)
4285
- if (rect.contains(x, y)) {
4286
- return spanStart to spanEnd
4287
- }
4288
- }
4289
- return null
4290
- }
4291
3914
 
4292
3915
  override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
4293
3916
  super.onFocusChanged(focused, direction, previouslyFocusedRect)
@@ -4296,7 +3919,6 @@ class EditorEditText @JvmOverloads constructor(
4296
3919
  restartCaretBlink()
4297
3920
  } else {
4298
3921
  beginNativeTextMutationAfterBlurWindow()
4299
- clearExplicitSelectedImageRange()
4300
3922
  stopCaretBlink()
4301
3923
  }
4302
3924
  }
@@ -4316,92 +3938,11 @@ class EditorEditText @JvmOverloads constructor(
4316
3938
  super.onDetachedFromWindow()
4317
3939
  }
4318
3940
 
4319
- private fun selectExplicitImageRange(start: Int, end: Int) {
4320
- explicitSelectedImageRange = ImageSelectionRange(start, end)
4321
- if (selectionStart == start && selectionEnd == end) {
4322
- onSelectionOrContentMayChange?.invoke()
4323
- return
4324
- }
4325
- setSelection(start, end)
4326
- }
4327
-
4328
- private fun clearExplicitSelectedImageRange() {
4329
- if (explicitSelectedImageRange == null) return
4330
- explicitSelectedImageRange = null
4331
- onSelectionOrContentMayChange?.invoke()
4332
- }
4333
-
4334
- private fun resolvedSelectedImageRange(spannable: Spanned): ImageSelectionRange? {
4335
- explicitSelectedImageRange?.let { explicit ->
4336
- if (isExactImageSpanRange(spannable, explicit.start, explicit.end)) {
4337
- return explicit
4338
- }
4339
- explicitSelectedImageRange = null
4340
- }
4341
3941
 
4342
- val start = selectionStart
4343
- val end = selectionEnd
4344
- if (!isExactImageSpanRange(spannable, start, end)) return null
4345
- return ImageSelectionRange(start, end)
4346
- }
4347
3942
 
4348
- private fun isExactImageSpanRange(spannable: Spanned, start: Int, end: Int): Boolean {
4349
- if (start < 0 || end != start + 1) return false
4350
- val imageSpan = spannable
4351
- .getSpans(start, end, BlockImageSpan::class.java)
4352
- .firstOrNull() ?: return false
4353
- return spannable.getSpanStart(imageSpan) == start && spannable.getSpanEnd(imageSpan) == end
4354
- }
4355
3943
 
4356
- private fun imageSpanRangeNearTouchOffset(
4357
- spannable: Spanned,
4358
- x: Float,
4359
- y: Float
4360
- ): Pair<Int, Int>? {
4361
- val safeOffset = runCatching { getOffsetForPosition(x, y) }.getOrNull() ?: return null
4362
- val nearbyOffsets = linkedSetOf(
4363
- safeOffset,
4364
- (safeOffset - 1).coerceAtLeast(0),
4365
- (safeOffset + 1).coerceAtMost(spannable.length)
4366
- )
4367
- for (offset in nearbyOffsets) {
4368
- val searchStart = (offset - 1).coerceAtLeast(0)
4369
- val searchEnd = (offset + 1).coerceAtMost(spannable.length)
4370
- val imageSpan = spannable
4371
- .getSpans(searchStart, searchEnd, BlockImageSpan::class.java)
4372
- .firstOrNull() ?: continue
4373
- val spanStart = spannable.getSpanStart(imageSpan)
4374
- val spanEnd = spannable.getSpanEnd(imageSpan)
4375
- if (spanStart >= 0 && spanEnd > spanStart) {
4376
- return spanStart to spanEnd
4377
- }
4378
- }
4379
- return null
4380
- }
4381
3944
 
4382
- private fun resolvedImageRect(
4383
- textLayout: Layout,
4384
- imageSpan: BlockImageSpan,
4385
- spanStart: Int,
4386
- spanEnd: Int
4387
- ): RectF {
4388
- imageSpan.currentDrawRect()?.let { drawnRect ->
4389
- return drawnRect
4390
- }
4391
3945
 
4392
- val safeOffset = spanStart.coerceAtMost(maxOf((text?.length ?: 0) - 1, 0))
4393
- val line = textLayout.getLineForOffset(safeOffset)
4394
- val startHorizontal = textLayout.getPrimaryHorizontal(spanStart)
4395
- val endHorizontal = textLayout.getPrimaryHorizontal(spanEnd)
4396
- val (widthPx, heightPx) = imageSpan.currentSizePx()
4397
- val left = compoundPaddingLeft + minOf(startHorizontal, endHorizontal)
4398
- val right = compoundPaddingLeft + maxOf(
4399
- maxOf(startHorizontal, endHorizontal),
4400
- minOf(startHorizontal, endHorizontal) + widthPx
4401
- )
4402
- val top = extendedPaddingTop + textLayout.getLineBottom(line) - heightPx
4403
- return RectF(left, top.toFloat(), right, top + heightPx.toFloat())
4404
- }
4405
3946
 
4406
3947
  /**
4407
3948
  * Apply a selection from a parsed JSON selection object.