@apollohg/react-native-prose-editor 0.3.0 → 0.4.1

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 (37) hide show
  1. package/README.md +18 -0
  2. package/android/build.gradle +23 -0
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +515 -39
  4. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +58 -28
  5. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +25 -0
  6. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +232 -62
  7. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +57 -27
  8. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +147 -78
  9. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +249 -71
  10. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +7 -6
  11. package/dist/EditorToolbar.d.ts +26 -6
  12. package/dist/EditorToolbar.js +299 -65
  13. package/dist/NativeEditorBridge.d.ts +40 -1
  14. package/dist/NativeEditorBridge.js +184 -90
  15. package/dist/NativeRichTextEditor.d.ts +5 -1
  16. package/dist/NativeRichTextEditor.js +201 -78
  17. package/dist/YjsCollaboration.d.ts +2 -0
  18. package/dist/YjsCollaboration.js +142 -20
  19. package/dist/index.d.ts +1 -1
  20. package/dist/schemas.js +12 -0
  21. package/dist/useNativeEditor.d.ts +2 -0
  22. package/dist/useNativeEditor.js +7 -0
  23. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  24. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  25. package/ios/EditorLayoutManager.swift +3 -3
  26. package/ios/Generated_editor_core.swift +87 -0
  27. package/ios/NativeEditorExpoView.swift +488 -178
  28. package/ios/NativeEditorModule.swift +25 -0
  29. package/ios/PositionBridge.swift +310 -75
  30. package/ios/RenderBridge.swift +362 -27
  31. package/ios/RichTextEditorView.swift +2001 -189
  32. package/ios/editor_coreFFI/editor_coreFFI.h +55 -0
  33. package/package.json +11 -2
  34. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  35. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  36. package/rust/android/x86_64/libeditor_core.so +0 -0
  37. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +128 -0
@@ -5,10 +5,12 @@ import android.content.Context
5
5
  import android.graphics.Typeface
6
6
  import android.graphics.Rect
7
7
  import android.graphics.RectF
8
+ import android.text.Annotation
8
9
  import android.text.Editable
9
10
  import android.text.Layout
10
11
  import android.text.Spanned
11
12
  import android.text.StaticLayout
13
+ import android.text.TextPaint
12
14
  import android.text.TextWatcher
13
15
  import android.util.AttributeSet
14
16
  import android.util.Log
@@ -51,11 +53,43 @@ class EditorEditText @JvmOverloads constructor(
51
53
  attrs: AttributeSet? = null,
52
54
  defStyleAttr: Int = android.R.attr.editTextStyle
53
55
  ) : AppCompatEditText(context, attrs, defStyleAttr) {
56
+ data class ApplyUpdateTrace(
57
+ val attemptedPatch: Boolean,
58
+ val usedPatch: Boolean,
59
+ val skippedRender: Boolean,
60
+ val parseNanos: Long,
61
+ val resolveRenderBlocksNanos: Long,
62
+ val patchEligibilityNanos: Long,
63
+ val buildRenderNanos: Long,
64
+ val applyRenderNanos: Long,
65
+ val selectionNanos: Long,
66
+ val postApplyNanos: Long,
67
+ val totalNanos: Long
68
+ )
69
+
54
70
  data class SelectedImageGeometry(
55
71
  val docPos: Int,
56
72
  val rect: RectF
57
73
  )
58
74
 
75
+ private data class ParsedRenderPatch(
76
+ val startIndex: Int,
77
+ val deleteCount: Int,
78
+ val renderBlocks: org.json.JSONArray
79
+ )
80
+
81
+ private data class RenderReplaceRange(
82
+ val start: Int,
83
+ val endExclusive: Int
84
+ )
85
+
86
+ private data class PatchApplyTrace(
87
+ val applied: Boolean,
88
+ val eligibilityNanos: Long,
89
+ val buildRenderNanos: Long,
90
+ val applyRenderNanos: Long
91
+ )
92
+
59
93
  private data class ImageSelectionRange(
60
94
  val start: Int,
61
95
  val end: Int
@@ -109,7 +143,9 @@ class EditorEditText @JvmOverloads constructor(
109
143
 
110
144
  var placeholderText: String = ""
111
145
  set(value) {
146
+ if (field == value) return
112
147
  field = value
148
+ requestLayout()
113
149
  invalidate()
114
150
  }
115
151
 
@@ -136,6 +172,17 @@ class EditorEditText @JvmOverloads constructor(
136
172
  private var lastHandledHardwareKeyCode: Int? = null
137
173
  private var lastHandledHardwareKeyDownTime: Long? = null
138
174
  private var explicitSelectedImageRange: ImageSelectionRange? = null
175
+ private var lastRenderAppliedPatchForTesting: Boolean = false
176
+ internal var captureApplyUpdateTraceForTesting: Boolean = false
177
+ private var lastApplyUpdateTraceForTesting: ApplyUpdateTrace? = null
178
+ private var currentRenderBlocksJson: org.json.JSONArray? = null
179
+ private var renderAppearanceRevision: Long = 1L
180
+ private var lastAppliedRenderAppearanceRevision: Long = 0L
181
+ internal var onDeleteRangeInRustForTesting: ((Int, Int) -> Unit)? = null
182
+ internal var onDeleteBackwardAtSelectionScalarInRustForTesting: ((Int, Int) -> Unit)? = null
183
+
184
+ fun lastRenderAppliedPatch(): Boolean = lastRenderAppliedPatchForTesting
185
+ fun lastApplyUpdateTrace(): ApplyUpdateTrace? = lastApplyUpdateTraceForTesting
139
186
 
140
187
  init {
141
188
  // Configure for rich text editing.
@@ -186,25 +233,33 @@ class EditorEditText @JvmOverloads constructor(
186
233
  override fun onDraw(canvas: android.graphics.Canvas) {
187
234
  super.onDraw(canvas)
188
235
 
189
- if (!shouldDisplayPlaceholder()) return
190
-
191
- val availableWidth = width - compoundPaddingLeft - compoundPaddingRight
192
- if (availableWidth <= 0) return
236
+ val placeholderLayout =
237
+ buildPlaceholderLayout(width - compoundPaddingLeft - compoundPaddingRight) ?: return
193
238
 
194
239
  val previousColor = paint.color
195
240
  val saveCount = canvas.save()
196
- paint.color = currentHintTextColor
197
241
  canvas.translate(compoundPaddingLeft.toFloat(), extendedPaddingTop.toFloat())
198
- StaticLayout.Builder
199
- .obtain(placeholderText, 0, placeholderText.length, paint, availableWidth)
200
- .setAlignment(Layout.Alignment.ALIGN_NORMAL)
201
- .setIncludePad(includeFontPadding)
202
- .build()
203
- .draw(canvas)
242
+ placeholderLayout.draw(canvas)
204
243
  canvas.restoreToCount(saveCount)
205
244
  paint.color = previousColor
206
245
  }
207
246
 
247
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
248
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
249
+
250
+ val placeholderHeight = resolvePlaceholderHeightForMeasuredWidth(measuredWidth) ?: return
251
+ val desiredHeight = maxOf(measuredHeight, placeholderHeight)
252
+ val resolvedHeight = when (MeasureSpec.getMode(heightMeasureSpec)) {
253
+ MeasureSpec.EXACTLY -> measuredHeight
254
+ MeasureSpec.AT_MOST -> desiredHeight.coerceAtMost(MeasureSpec.getSize(heightMeasureSpec))
255
+ else -> desiredHeight
256
+ }
257
+
258
+ if (resolvedHeight != measuredHeight) {
259
+ setMeasuredDimension(measuredWidth, resolvedHeight)
260
+ }
261
+ }
262
+
208
263
  override fun onTouchEvent(event: MotionEvent): Boolean {
209
264
  if (event.actionMasked == MotionEvent.ACTION_DOWN && imageSpanHitAt(event.x, event.y) == null) {
210
265
  clearExplicitSelectedImageRange()
@@ -250,6 +305,59 @@ class EditorEditText @JvmOverloads constructor(
250
305
 
251
306
  fun shouldDisplayPlaceholderForTesting(): Boolean = shouldDisplayPlaceholder()
252
307
 
308
+ private fun buildPlaceholderLayout(availableWidth: Int): StaticLayout? {
309
+ if (!shouldDisplayPlaceholder()) return null
310
+ if (availableWidth <= 0) return null
311
+
312
+ val placeholderPaint = resolvedPlaceholderPaint()
313
+ return StaticLayout.Builder
314
+ .obtain(
315
+ placeholderText,
316
+ 0,
317
+ placeholderText.length,
318
+ placeholderPaint,
319
+ availableWidth
320
+ )
321
+ .setAlignment(Layout.Alignment.ALIGN_NORMAL)
322
+ .setIncludePad(includeFontPadding)
323
+ .build()
324
+ }
325
+
326
+ private fun resolvedPlaceholderPaint(): TextPaint {
327
+ val textStyle = theme?.effectiveTextStyle("paragraph")
328
+ val resolvedTextSize = textStyle?.fontSize?.times(resources.displayMetrics.density) ?: baseFontSize
329
+ val resolvedTypeface = resolvePlaceholderTypeface(textStyle)
330
+
331
+ return TextPaint(paint).apply {
332
+ color = currentHintTextColor
333
+ textSize = resolvedTextSize
334
+ typeface = resolvedTypeface
335
+ }
336
+ }
337
+
338
+ private fun resolvePlaceholderTypeface(textStyle: EditorTextStyle?): Typeface {
339
+ val baseTypeface = typeface ?: Typeface.DEFAULT
340
+ val requestedStyle = textStyle?.typefaceStyle() ?: Typeface.NORMAL
341
+ val family = textStyle?.fontFamily?.takeIf { it.isNotBlank() }
342
+
343
+ return when {
344
+ family != null -> Typeface.create(family, requestedStyle)
345
+ requestedStyle != Typeface.NORMAL -> Typeface.create(baseTypeface, requestedStyle)
346
+ else -> baseTypeface
347
+ }
348
+ }
349
+
350
+ private fun resolvePlaceholderHeightForMeasuredWidth(widthPx: Int): Int? {
351
+ val availableWidth = (widthPx - compoundPaddingLeft - compoundPaddingRight).coerceAtLeast(0)
352
+ return resolvePlaceholderHeightForAvailableWidth(availableWidth)
353
+ }
354
+
355
+ private fun resolvePlaceholderHeightForAvailableWidth(availableWidth: Int): Int? {
356
+ val placeholderLayout = buildPlaceholderLayout(availableWidth) ?: return null
357
+ val placeholderHeight = placeholderLayout.height.takeIf { it > 0 } ?: lineHeight
358
+ return placeholderHeight + compoundPaddingTop + compoundPaddingBottom
359
+ }
360
+
253
361
  // ── Editor Binding ──────────────────────────────────────────────────
254
362
 
255
363
  /**
@@ -262,8 +370,9 @@ class EditorEditText @JvmOverloads constructor(
262
370
  editorId = id
263
371
 
264
372
  if (!initialHTML.isNullOrEmpty()) {
265
- val renderJSON = editorSetHtml(editorId.toULong(), initialHTML)
266
- applyRenderJSON(renderJSON)
373
+ editorSetHtml(editorId.toULong(), initialHTML)
374
+ val stateJSON = editorGetCurrentState(editorId.toULong())
375
+ applyUpdateJSON(stateJSON, notifyListener = false)
267
376
  } else {
268
377
  // Pull current state from Rust (content may already be loaded via bridge).
269
378
  val stateJSON = editorGetCurrentState(editorId.toULong())
@@ -279,6 +388,9 @@ class EditorEditText @JvmOverloads constructor(
279
388
  }
280
389
 
281
390
  fun setBaseStyle(fontSizePx: Float, textColor: Int, backgroundColor: Int) {
391
+ if (baseFontSize != fontSizePx || baseTextColor != textColor) {
392
+ renderAppearanceRevision += 1L
393
+ }
282
394
  baseFontSize = fontSizePx
283
395
  baseTextColor = textColor
284
396
  baseBackgroundColor = backgroundColor
@@ -289,6 +401,7 @@ class EditorEditText @JvmOverloads constructor(
289
401
 
290
402
  fun applyTheme(theme: EditorTheme?) {
291
403
  this.theme = theme
404
+ renderAppearanceRevision += 1L
292
405
  setBackgroundColor(theme?.backgroundColor ?: baseBackgroundColor)
293
406
  applyContentInsets(theme?.contentInsets)
294
407
  if (editorId != 0L) {
@@ -358,12 +471,16 @@ class EditorEditText @JvmOverloads constructor(
358
471
  }
359
472
 
360
473
  fun resolveAutoGrowHeight(): Int {
474
+ val availableWidth = (measuredWidth - compoundPaddingLeft - compoundPaddingRight).coerceAtLeast(0)
475
+ val placeholderHeight = resolvePlaceholderHeightForAvailableWidth(availableWidth)
361
476
  val laidOutTextHeight = if (isLaidOut) layout?.height else null
362
477
  if (laidOutTextHeight != null && laidOutTextHeight > 0) {
363
- return laidOutTextHeight + compoundPaddingTop + compoundPaddingBottom
478
+ return maxOf(
479
+ laidOutTextHeight + compoundPaddingTop + compoundPaddingBottom,
480
+ placeholderHeight ?: 0
481
+ )
364
482
  }
365
483
 
366
- val availableWidth = (measuredWidth - compoundPaddingLeft - compoundPaddingRight).coerceAtLeast(0)
367
484
  val currentText = text
368
485
  if (availableWidth > 0 && currentText != null) {
369
486
  val staticLayout = StaticLayout.Builder
@@ -372,11 +489,17 @@ class EditorEditText @JvmOverloads constructor(
372
489
  .setIncludePad(includeFontPadding)
373
490
  .build()
374
491
  val textHeight = staticLayout.height.takeIf { it > 0 } ?: lineHeight
375
- return textHeight + compoundPaddingTop + compoundPaddingBottom
492
+ return maxOf(
493
+ textHeight + compoundPaddingTop + compoundPaddingBottom,
494
+ placeholderHeight ?: 0
495
+ )
376
496
  }
377
497
 
378
498
  val minimumHeight = suggestedMinimumHeight.coerceAtLeast(minHeight)
379
- return (lineHeight + compoundPaddingTop + compoundPaddingBottom).coerceAtLeast(minimumHeight)
499
+ return maxOf(
500
+ placeholderHeight ?: 0,
501
+ (lineHeight + compoundPaddingTop + compoundPaddingBottom).coerceAtLeast(minimumHeight)
502
+ )
380
503
  }
381
504
 
382
505
  private fun preserveScrollPosition(previousScrollX: Int, previousScrollY: Int) {
@@ -485,6 +608,15 @@ class EditorEditText @JvmOverloads constructor(
485
608
 
486
609
  val currentText = text?.toString() ?: ""
487
610
  val cursor = selectionStart
611
+ if (beforeLength > 0 &&
612
+ afterLength == 0 &&
613
+ cursor > 0 &&
614
+ currentText.getOrNull(cursor - 1) == EMPTY_BLOCK_PLACEHOLDER
615
+ ) {
616
+ val scalarCursor = PositionBridge.utf16ToScalar(cursor - 1, currentText)
617
+ deleteBackwardAtSelectionScalarInRust(scalarCursor, scalarCursor)
618
+ return
619
+ }
488
620
  val delStart = maxOf(0, cursor - beforeLength)
489
621
  val delEnd = minOf(currentText.length, cursor + afterLength)
490
622
 
@@ -493,6 +625,8 @@ class EditorEditText @JvmOverloads constructor(
493
625
 
494
626
  if (scalarStart < scalarEnd) {
495
627
  deleteRangeInRust(scalarStart, scalarEnd)
628
+ } else if (beforeLength > 0 && afterLength == 0) {
629
+ deleteBackwardAtSelectionScalarInRust(scalarEnd, scalarEnd)
496
630
  }
497
631
  }
498
632
 
@@ -531,6 +665,11 @@ class EditorEditText @JvmOverloads constructor(
531
665
  val scalarEnd = PositionBridge.utf16ToScalar(end, currentText)
532
666
  deleteRangeInRust(scalarStart, scalarEnd)
533
667
  } else if (start > 0) {
668
+ if (currentText.getOrNull(start - 1) == EMPTY_BLOCK_PLACEHOLDER) {
669
+ val scalarCursor = PositionBridge.utf16ToScalar(start - 1, currentText)
670
+ deleteBackwardAtSelectionScalarInRust(scalarCursor, scalarCursor)
671
+ return
672
+ }
534
673
  // Cursor: delete one grapheme cluster backward.
535
674
  // Find the previous grapheme boundary by snapping (start - 1).
536
675
  val breakIter = java.text.BreakIterator.getCharacterInstance()
@@ -540,7 +679,13 @@ class EditorEditText @JvmOverloads constructor(
540
679
 
541
680
  val scalarStart = PositionBridge.utf16ToScalar(prevUtf16, currentText)
542
681
  val scalarEnd = PositionBridge.utf16ToScalar(start, currentText)
543
- deleteRangeInRust(scalarStart, scalarEnd)
682
+ if (scalarStart < scalarEnd) {
683
+ deleteRangeInRust(scalarStart, scalarEnd)
684
+ } else {
685
+ deleteBackwardAtSelectionScalarInRust(scalarEnd, scalarEnd)
686
+ }
687
+ } else {
688
+ deleteBackwardAtSelectionScalarInRust(0, 0)
544
689
  }
545
690
  }
546
691
 
@@ -765,6 +910,19 @@ class EditorEditText @JvmOverloads constructor(
765
910
  applyUpdateJSON(updateJSON)
766
911
  }
767
912
 
913
+ fun performToolbarToggleHeading(level: Int) {
914
+ if (!isEditable || isApplyingRustState || editorId == 0L) return
915
+ if (level !in 1..6) return
916
+ val selection = currentScalarSelection() ?: return
917
+ val updateJSON = editorToggleHeadingAtSelectionScalar(
918
+ editorId.toULong(),
919
+ selection.first.toUInt(),
920
+ selection.second.toUInt(),
921
+ level.toUByte()
922
+ )
923
+ applyUpdateJSON(updateJSON)
924
+ }
925
+
768
926
  fun performToolbarIndentListItem() {
769
927
  if (!isEditable || isApplyingRustState || editorId == 0L) return
770
928
  val selection = currentScalarSelection() ?: return
@@ -876,6 +1034,7 @@ class EditorEditText @JvmOverloads constructor(
876
1034
  */
877
1035
  override fun onSelectionChanged(selStart: Int, selEnd: Int) {
878
1036
  super.onSelectionChanged(selStart, selEnd)
1037
+ if (isApplyingRustState) return
879
1038
  val spannable = text as? Spanned
880
1039
  if (spannable != null && isExactImageSpanRange(spannable, selStart, selEnd)) {
881
1040
  explicitSelectedImageRange = ImageSelectionRange(selStart, selEnd)
@@ -883,7 +1042,7 @@ class EditorEditText @JvmOverloads constructor(
883
1042
  ensureSelectionVisible()
884
1043
  onSelectionOrContentMayChange?.invoke()
885
1044
 
886
- if (isApplyingRustState || editorId == 0L) return
1045
+ if (editorId == 0L) return
887
1046
 
888
1047
  val currentText = text?.toString() ?: ""
889
1048
  if (currentText != lastAuthorizedText) return
@@ -921,10 +1080,27 @@ class EditorEditText @JvmOverloads constructor(
921
1080
  */
922
1081
  private fun deleteRangeInRust(scalarFrom: Int, scalarTo: Int) {
923
1082
  if (scalarFrom >= scalarTo) return
1083
+ onDeleteRangeInRustForTesting?.let { callback ->
1084
+ callback(scalarFrom, scalarTo)
1085
+ return
1086
+ }
924
1087
  val updateJSON = editorDeleteScalarRange(editorId.toULong(), scalarFrom.toUInt(), scalarTo.toUInt())
925
1088
  applyUpdateJSON(updateJSON)
926
1089
  }
927
1090
 
1091
+ private fun deleteBackwardAtSelectionScalarInRust(scalarAnchor: Int, scalarHead: Int) {
1092
+ onDeleteBackwardAtSelectionScalarInRustForTesting?.let { callback ->
1093
+ callback(scalarAnchor, scalarHead)
1094
+ return
1095
+ }
1096
+ val updateJSON = editorDeleteBackwardAtSelectionScalar(
1097
+ editorId.toULong(),
1098
+ scalarAnchor.toUInt(),
1099
+ scalarHead.toUInt()
1100
+ )
1101
+ applyUpdateJSON(updateJSON)
1102
+ }
1103
+
928
1104
  /**
929
1105
  * Split a block at a scalar position via the Rust editor.
930
1106
  */
@@ -1016,6 +1192,244 @@ class EditorEditText @JvmOverloads constructor(
1016
1192
 
1017
1193
  // ── Applying Rust State ─────────────────────────────────────────────
1018
1194
 
1195
+ private fun parseRenderPatch(raw: org.json.JSONObject?): ParsedRenderPatch? {
1196
+ if (raw == null) return null
1197
+ val renderBlocks = raw.optJSONArray("renderBlocks") ?: return null
1198
+ return ParsedRenderPatch(
1199
+ startIndex = raw.optInt("startIndex", -1),
1200
+ deleteCount = raw.optInt("deleteCount", -1),
1201
+ renderBlocks = renderBlocks
1202
+ ).takeIf { it.startIndex >= 0 && it.deleteCount >= 0 }
1203
+ }
1204
+
1205
+ private fun hasTopLevelChildMetadata(content: Spanned): Boolean =
1206
+ content.getSpans(0, content.length, Annotation::class.java).any {
1207
+ it.key == RenderBridge.NATIVE_TOP_LEVEL_CHILD_INDEX_ANNOTATION
1208
+ }
1209
+
1210
+ private fun firstCharacterOffsetForTopLevelChildIndex(content: Spanned, index: Int): Int? {
1211
+ val targetValue = index.toString()
1212
+ return content
1213
+ .getSpans(0, content.length, Annotation::class.java)
1214
+ .asSequence()
1215
+ .filter { it.key == RenderBridge.NATIVE_TOP_LEVEL_CHILD_INDEX_ANNOTATION && it.value == targetValue }
1216
+ .mapNotNull { span ->
1217
+ val spanStart = content.getSpanStart(span)
1218
+ val spanEnd = content.getSpanEnd(span)
1219
+ if (spanStart < 0 || spanEnd <= spanStart) {
1220
+ null
1221
+ } else {
1222
+ var candidate = spanStart
1223
+ while (candidate < spanEnd && candidate < content.length) {
1224
+ when (content[candidate]) {
1225
+ '\n', '\r' -> candidate += 1
1226
+ else -> return@mapNotNull candidate
1227
+ }
1228
+ }
1229
+ null
1230
+ }
1231
+ }
1232
+ .minOrNull()
1233
+ }
1234
+
1235
+ private fun replacementRangeForRenderPatch(
1236
+ content: Spanned,
1237
+ startIndex: Int,
1238
+ deleteCount: Int
1239
+ ): RenderReplaceRange? {
1240
+ val start = firstCharacterOffsetForTopLevelChildIndex(content, startIndex)
1241
+ ?: if (deleteCount == 0) content.length else return null
1242
+ val endExclusive = firstCharacterOffsetForTopLevelChildIndex(content, startIndex + deleteCount)
1243
+ ?: content.length
1244
+ if (start > endExclusive) return null
1245
+ return RenderReplaceRange(start = start, endExclusive = endExclusive)
1246
+ }
1247
+
1248
+ private fun spannedRangeContainsImageSpan(content: Spanned, start: Int, endExclusive: Int): Boolean {
1249
+ if (start >= endExclusive) return false
1250
+ return content.getSpans(start, endExclusive, BlockImageSpan::class.java).isNotEmpty()
1251
+ }
1252
+
1253
+ private fun spannedContainsImageSpan(content: Spanned): Boolean =
1254
+ spannedRangeContainsImageSpan(content, 0, content.length)
1255
+
1256
+ private fun applyRenderedSpannable(
1257
+ spannable: CharSequence,
1258
+ replaceRange: RenderReplaceRange? = null,
1259
+ usedPatch: Boolean
1260
+ ) {
1261
+ isApplyingRustState = true
1262
+ beginBatchEdit()
1263
+ try {
1264
+ if (replaceRange != null) {
1265
+ editableText.replace(replaceRange.start, replaceRange.endExclusive, spannable)
1266
+ } else {
1267
+ setText(spannable)
1268
+ }
1269
+ lastAuthorizedText = text?.toString().orEmpty()
1270
+ lastRenderAppliedPatchForTesting = usedPatch
1271
+ } finally {
1272
+ endBatchEdit()
1273
+ isApplyingRustState = false
1274
+ }
1275
+ }
1276
+
1277
+ private fun buildPatchedSpannable(patch: ParsedRenderPatch): android.text.SpannableStringBuilder =
1278
+ RenderBridge.buildSpannableFromBlocks(
1279
+ patch.renderBlocks,
1280
+ startIndex = patch.startIndex,
1281
+ baseFontSize = baseFontSize,
1282
+ textColor = baseTextColor,
1283
+ theme = theme,
1284
+ density = resources.displayMetrics.density,
1285
+ hostView = this
1286
+ )
1287
+
1288
+ private fun cloneJsonArray(array: org.json.JSONArray): org.json.JSONArray =
1289
+ org.json.JSONArray().also { clone ->
1290
+ for (index in 0 until array.length()) {
1291
+ clone.put(array.opt(index))
1292
+ }
1293
+ }
1294
+
1295
+ private fun normalizedJsonValue(value: Any?): Any? =
1296
+ if (value === org.json.JSONObject.NULL) null else value
1297
+
1298
+ private fun jsonValuesEqual(left: Any?, right: Any?): Boolean {
1299
+ val normalizedLeft = normalizedJsonValue(left)
1300
+ val normalizedRight = normalizedJsonValue(right)
1301
+ if (normalizedLeft === normalizedRight) return true
1302
+ if (normalizedLeft == null || normalizedRight == null) return false
1303
+
1304
+ if (normalizedLeft is org.json.JSONArray && normalizedRight is org.json.JSONArray) {
1305
+ if (normalizedLeft.length() != normalizedRight.length()) return false
1306
+ for (index in 0 until normalizedLeft.length()) {
1307
+ if (!jsonValuesEqual(normalizedLeft.opt(index), normalizedRight.opt(index))) {
1308
+ return false
1309
+ }
1310
+ }
1311
+ return true
1312
+ }
1313
+
1314
+ if (normalizedLeft is org.json.JSONObject && normalizedRight is org.json.JSONObject) {
1315
+ if (normalizedLeft.length() != normalizedRight.length()) return false
1316
+ val keys = normalizedLeft.keys()
1317
+ while (keys.hasNext()) {
1318
+ val key = keys.next()
1319
+ if (!normalizedRight.has(key)) return false
1320
+ if (!jsonValuesEqual(normalizedLeft.opt(key), normalizedRight.opt(key))) {
1321
+ return false
1322
+ }
1323
+ }
1324
+ return true
1325
+ }
1326
+
1327
+ if (normalizedLeft is Number && normalizedRight is Number) {
1328
+ return normalizedLeft.toDouble() == normalizedRight.toDouble()
1329
+ }
1330
+
1331
+ return normalizedLeft == normalizedRight
1332
+ }
1333
+
1334
+ private fun renderBlocksEqual(
1335
+ current: org.json.JSONArray,
1336
+ updated: org.json.JSONArray
1337
+ ): Boolean {
1338
+ if (current.length() != updated.length()) return false
1339
+ for (index in 0 until current.length()) {
1340
+ if (!jsonValuesEqual(current.opt(index), updated.opt(index))) {
1341
+ return false
1342
+ }
1343
+ }
1344
+ return true
1345
+ }
1346
+
1347
+ private fun mergeRenderBlocks(
1348
+ current: org.json.JSONArray,
1349
+ patch: ParsedRenderPatch
1350
+ ): org.json.JSONArray? {
1351
+ if (
1352
+ patch.startIndex < 0 ||
1353
+ patch.deleteCount < 0 ||
1354
+ patch.startIndex > current.length() ||
1355
+ patch.startIndex + patch.deleteCount > current.length()
1356
+ ) {
1357
+ return null
1358
+ }
1359
+
1360
+ return org.json.JSONArray().also { merged ->
1361
+ for (index in 0 until patch.startIndex) {
1362
+ merged.put(current.opt(index))
1363
+ }
1364
+ for (index in 0 until patch.renderBlocks.length()) {
1365
+ merged.put(patch.renderBlocks.opt(index))
1366
+ }
1367
+ for (index in (patch.startIndex + patch.deleteCount) until current.length()) {
1368
+ merged.put(current.opt(index))
1369
+ }
1370
+ }
1371
+ }
1372
+
1373
+ private fun applyRenderPatchIfPossible(patch: ParsedRenderPatch): PatchApplyTrace {
1374
+ val eligibilityStartedAt = System.nanoTime()
1375
+ val content = text as? Spanned ?: return PatchApplyTrace(
1376
+ applied = false,
1377
+ eligibilityNanos = System.nanoTime() - eligibilityStartedAt,
1378
+ buildRenderNanos = 0L,
1379
+ applyRenderNanos = 0L
1380
+ )
1381
+ if (!hasTopLevelChildMetadata(content)) {
1382
+ return PatchApplyTrace(
1383
+ applied = false,
1384
+ eligibilityNanos = System.nanoTime() - eligibilityStartedAt,
1385
+ buildRenderNanos = 0L,
1386
+ applyRenderNanos = 0L
1387
+ )
1388
+ }
1389
+
1390
+ val replaceRange = replacementRangeForRenderPatch(content, patch.startIndex, patch.deleteCount)
1391
+ ?: return PatchApplyTrace(
1392
+ applied = false,
1393
+ eligibilityNanos = System.nanoTime() - eligibilityStartedAt,
1394
+ buildRenderNanos = 0L,
1395
+ applyRenderNanos = 0L
1396
+ )
1397
+ if (spannedRangeContainsImageSpan(content, replaceRange.start, replaceRange.endExclusive)) {
1398
+ return PatchApplyTrace(
1399
+ applied = false,
1400
+ eligibilityNanos = System.nanoTime() - eligibilityStartedAt,
1401
+ buildRenderNanos = 0L,
1402
+ applyRenderNanos = 0L
1403
+ )
1404
+ }
1405
+ val eligibilityNanos = System.nanoTime() - eligibilityStartedAt
1406
+
1407
+ val buildStartedAt = System.nanoTime()
1408
+ val patchedSpannable = buildPatchedSpannable(patch)
1409
+ val buildRenderNanos = System.nanoTime() - buildStartedAt
1410
+ if (spannedContainsImageSpan(patchedSpannable)) {
1411
+ return PatchApplyTrace(
1412
+ applied = false,
1413
+ eligibilityNanos = eligibilityNanos,
1414
+ buildRenderNanos = buildRenderNanos,
1415
+ applyRenderNanos = 0L
1416
+ )
1417
+ }
1418
+
1419
+ val applyStartedAt = System.nanoTime()
1420
+ applyRenderedSpannable(
1421
+ spannable = patchedSpannable,
1422
+ replaceRange = replaceRange,
1423
+ usedPatch = true
1424
+ )
1425
+ return PatchApplyTrace(
1426
+ applied = true,
1427
+ eligibilityNanos = eligibilityNanos,
1428
+ buildRenderNanos = buildRenderNanos,
1429
+ applyRenderNanos = System.nanoTime() - applyStartedAt
1430
+ )
1431
+ }
1432
+
1019
1433
  /**
1020
1434
  * Apply a full render update from Rust to the EditText.
1021
1435
  *
@@ -1025,38 +1439,85 @@ class EditorEditText @JvmOverloads constructor(
1025
1439
  * @param updateJSON The JSON string from editor_insert_text, etc.
1026
1440
  */
1027
1441
  fun applyUpdateJSON(updateJSON: String, notifyListener: Boolean = true) {
1442
+ val totalStartedAt = System.nanoTime()
1443
+ val parseStartedAt = totalStartedAt
1028
1444
  val update = try {
1029
1445
  org.json.JSONObject(updateJSON)
1030
1446
  } catch (_: Exception) {
1031
1447
  return
1032
1448
  }
1033
-
1034
- val renderElements = update.optJSONArray("renderElements") ?: return
1035
-
1036
- val spannable = RenderBridge.buildSpannableFromArray(
1037
- renderElements,
1038
- baseFontSize,
1039
- baseTextColor,
1040
- theme,
1041
- resources.displayMetrics.density,
1042
- this
1043
- )
1044
-
1449
+ val parseNanos = System.nanoTime() - parseStartedAt
1450
+
1451
+ val resolveRenderBlocksStartedAt = System.nanoTime()
1452
+ val renderElements = update.optJSONArray("renderElements")
1453
+ val renderBlocks = update.optJSONArray("renderBlocks")
1454
+ val renderPatch = parseRenderPatch(update.optJSONObject("renderPatch"))
1455
+ val resolvedRenderBlocks = renderBlocks
1456
+ ?: renderPatch?.let { patch ->
1457
+ currentRenderBlocksJson?.let { mergeRenderBlocks(it, patch) }
1458
+ }
1459
+ val resolveRenderBlocksNanos = System.nanoTime() - resolveRenderBlocksStartedAt
1460
+ val shouldSkipRender = resolvedRenderBlocks != null &&
1461
+ currentRenderBlocksJson?.let { current ->
1462
+ renderBlocksEqual(current, resolvedRenderBlocks)
1463
+ } == true &&
1464
+ text?.toString() == lastAuthorizedText &&
1465
+ lastAppliedRenderAppearanceRevision == renderAppearanceRevision
1045
1466
  val previousScrollX = scrollX
1046
1467
  val previousScrollY = scrollY
1047
1468
 
1048
1469
  explicitSelectedImageRange = null
1049
- isApplyingRustState = true
1050
- setText(spannable)
1051
- lastAuthorizedText = spannable.toString()
1052
- isApplyingRustState = false
1470
+ val buildRenderNanos: Long
1471
+ val applyRenderNanos: Long
1472
+ if (shouldSkipRender) {
1473
+ lastRenderAppliedPatchForTesting = false
1474
+ currentRenderBlocksJson = resolvedRenderBlocks?.let(::cloneJsonArray)
1475
+ buildRenderNanos = 0L
1476
+ applyRenderNanos = 0L
1477
+ } else {
1478
+ // Android's Editable.replace(...) path benchmarks substantially slower than
1479
+ // rebuilding from merged render blocks, so patch payloads are treated as a
1480
+ // transport optimization only. We still resolve the merged block state above,
1481
+ // then apply it through the faster full-text path here.
1482
+ val buildStartedAt = System.nanoTime()
1483
+ val fullSpannable = if (resolvedRenderBlocks != null) {
1484
+ RenderBridge.buildSpannableFromBlocks(
1485
+ resolvedRenderBlocks,
1486
+ baseFontSize = baseFontSize,
1487
+ textColor = baseTextColor,
1488
+ theme = theme,
1489
+ density = resources.displayMetrics.density,
1490
+ hostView = this
1491
+ )
1492
+ } else if (renderElements != null) {
1493
+ RenderBridge.buildSpannableFromArray(
1494
+ renderElements,
1495
+ baseFontSize,
1496
+ baseTextColor,
1497
+ theme,
1498
+ resources.displayMetrics.density,
1499
+ this
1500
+ )
1501
+ } else {
1502
+ return
1503
+ }
1504
+ buildRenderNanos = System.nanoTime() - buildStartedAt
1505
+ currentRenderBlocksJson = resolvedRenderBlocks?.let(::cloneJsonArray)
1506
+ val applyStartedAt = System.nanoTime()
1507
+ applyRenderedSpannable(fullSpannable, usedPatch = false)
1508
+ applyRenderNanos = System.nanoTime() - applyStartedAt
1509
+ lastAppliedRenderAppearanceRevision = renderAppearanceRevision
1510
+ }
1053
1511
 
1054
1512
  // Apply the selection from the update.
1513
+ val selectionStartedAt = System.nanoTime()
1055
1514
  val selection = update.optJSONObject("selection")
1056
1515
  if (selection != null) {
1057
1516
  applySelectionFromJSON(selection)
1058
1517
  }
1518
+ val selectionNanos = System.nanoTime() - selectionStartedAt
1059
1519
 
1520
+ val postApplyStartedAt = System.nanoTime()
1060
1521
  if (notifyListener) {
1061
1522
  editorListener?.onEditorUpdate(updateJSON)
1062
1523
  }
@@ -1066,6 +1527,23 @@ class EditorEditText @JvmOverloads constructor(
1066
1527
  } else {
1067
1528
  preserveScrollPosition(previousScrollX, previousScrollY)
1068
1529
  }
1530
+ val postApplyNanos = System.nanoTime() - postApplyStartedAt
1531
+
1532
+ if (captureApplyUpdateTraceForTesting) {
1533
+ lastApplyUpdateTraceForTesting = ApplyUpdateTrace(
1534
+ attemptedPatch = renderPatch != null,
1535
+ usedPatch = false,
1536
+ skippedRender = shouldSkipRender,
1537
+ parseNanos = parseNanos,
1538
+ resolveRenderBlocksNanos = resolveRenderBlocksNanos,
1539
+ patchEligibilityNanos = 0L,
1540
+ buildRenderNanos = buildRenderNanos,
1541
+ applyRenderNanos = applyRenderNanos,
1542
+ selectionNanos = selectionNanos,
1543
+ postApplyNanos = postApplyNanos,
1544
+ totalNanos = System.nanoTime() - totalStartedAt
1545
+ )
1546
+ }
1069
1547
  }
1070
1548
 
1071
1549
  /**
@@ -1090,10 +1568,8 @@ class EditorEditText @JvmOverloads constructor(
1090
1568
  val previousScrollY = scrollY
1091
1569
 
1092
1570
  explicitSelectedImageRange = null
1093
- isApplyingRustState = true
1094
- setText(spannable)
1095
- lastAuthorizedText = spannable.toString()
1096
- isApplyingRustState = false
1571
+ currentRenderBlocksJson = null
1572
+ applyRenderedSpannable(spannable, usedPatch = false)
1097
1573
  onSelectionOrContentMayChange?.invoke()
1098
1574
  if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
1099
1575
  requestLayout()