@apollohg/react-native-prose-editor 0.4.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 (30) 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 +502 -39
  4. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +56 -28
  5. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +6 -0
  6. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +57 -27
  7. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +147 -78
  8. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +249 -71
  9. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +7 -6
  10. package/dist/NativeEditorBridge.d.ts +36 -1
  11. package/dist/NativeEditorBridge.js +173 -94
  12. package/dist/NativeRichTextEditor.d.ts +2 -0
  13. package/dist/NativeRichTextEditor.js +160 -53
  14. package/dist/YjsCollaboration.d.ts +2 -0
  15. package/dist/YjsCollaboration.js +142 -20
  16. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  17. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  18. package/ios/EditorLayoutManager.swift +3 -3
  19. package/ios/Generated_editor_core.swift +41 -0
  20. package/ios/NativeEditorExpoView.swift +43 -11
  21. package/ios/NativeEditorModule.swift +6 -0
  22. package/ios/PositionBridge.swift +310 -75
  23. package/ios/RenderBridge.swift +362 -27
  24. package/ios/RichTextEditorView.swift +1983 -187
  25. package/ios/editor_coreFFI/editor_coreFFI.h +33 -0
  26. package/package.json +11 -2
  27. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  28. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  29. package/rust/android/x86_64/libeditor_core.so +0 -0
  30. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +63 -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
 
@@ -889,6 +1034,7 @@ class EditorEditText @JvmOverloads constructor(
889
1034
  */
890
1035
  override fun onSelectionChanged(selStart: Int, selEnd: Int) {
891
1036
  super.onSelectionChanged(selStart, selEnd)
1037
+ if (isApplyingRustState) return
892
1038
  val spannable = text as? Spanned
893
1039
  if (spannable != null && isExactImageSpanRange(spannable, selStart, selEnd)) {
894
1040
  explicitSelectedImageRange = ImageSelectionRange(selStart, selEnd)
@@ -896,7 +1042,7 @@ class EditorEditText @JvmOverloads constructor(
896
1042
  ensureSelectionVisible()
897
1043
  onSelectionOrContentMayChange?.invoke()
898
1044
 
899
- if (isApplyingRustState || editorId == 0L) return
1045
+ if (editorId == 0L) return
900
1046
 
901
1047
  val currentText = text?.toString() ?: ""
902
1048
  if (currentText != lastAuthorizedText) return
@@ -934,10 +1080,27 @@ class EditorEditText @JvmOverloads constructor(
934
1080
  */
935
1081
  private fun deleteRangeInRust(scalarFrom: Int, scalarTo: Int) {
936
1082
  if (scalarFrom >= scalarTo) return
1083
+ onDeleteRangeInRustForTesting?.let { callback ->
1084
+ callback(scalarFrom, scalarTo)
1085
+ return
1086
+ }
937
1087
  val updateJSON = editorDeleteScalarRange(editorId.toULong(), scalarFrom.toUInt(), scalarTo.toUInt())
938
1088
  applyUpdateJSON(updateJSON)
939
1089
  }
940
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
+
941
1104
  /**
942
1105
  * Split a block at a scalar position via the Rust editor.
943
1106
  */
@@ -1029,6 +1192,244 @@ class EditorEditText @JvmOverloads constructor(
1029
1192
 
1030
1193
  // ── Applying Rust State ─────────────────────────────────────────────
1031
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
+
1032
1433
  /**
1033
1434
  * Apply a full render update from Rust to the EditText.
1034
1435
  *
@@ -1038,38 +1439,85 @@ class EditorEditText @JvmOverloads constructor(
1038
1439
  * @param updateJSON The JSON string from editor_insert_text, etc.
1039
1440
  */
1040
1441
  fun applyUpdateJSON(updateJSON: String, notifyListener: Boolean = true) {
1442
+ val totalStartedAt = System.nanoTime()
1443
+ val parseStartedAt = totalStartedAt
1041
1444
  val update = try {
1042
1445
  org.json.JSONObject(updateJSON)
1043
1446
  } catch (_: Exception) {
1044
1447
  return
1045
1448
  }
1046
-
1047
- val renderElements = update.optJSONArray("renderElements") ?: return
1048
-
1049
- val spannable = RenderBridge.buildSpannableFromArray(
1050
- renderElements,
1051
- baseFontSize,
1052
- baseTextColor,
1053
- theme,
1054
- resources.displayMetrics.density,
1055
- this
1056
- )
1057
-
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
1058
1466
  val previousScrollX = scrollX
1059
1467
  val previousScrollY = scrollY
1060
1468
 
1061
1469
  explicitSelectedImageRange = null
1062
- isApplyingRustState = true
1063
- setText(spannable)
1064
- lastAuthorizedText = spannable.toString()
1065
- 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
+ }
1066
1511
 
1067
1512
  // Apply the selection from the update.
1513
+ val selectionStartedAt = System.nanoTime()
1068
1514
  val selection = update.optJSONObject("selection")
1069
1515
  if (selection != null) {
1070
1516
  applySelectionFromJSON(selection)
1071
1517
  }
1518
+ val selectionNanos = System.nanoTime() - selectionStartedAt
1072
1519
 
1520
+ val postApplyStartedAt = System.nanoTime()
1073
1521
  if (notifyListener) {
1074
1522
  editorListener?.onEditorUpdate(updateJSON)
1075
1523
  }
@@ -1079,6 +1527,23 @@ class EditorEditText @JvmOverloads constructor(
1079
1527
  } else {
1080
1528
  preserveScrollPosition(previousScrollX, previousScrollY)
1081
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
+ }
1082
1547
  }
1083
1548
 
1084
1549
  /**
@@ -1103,10 +1568,8 @@ class EditorEditText @JvmOverloads constructor(
1103
1568
  val previousScrollY = scrollY
1104
1569
 
1105
1570
  explicitSelectedImageRange = null
1106
- isApplyingRustState = true
1107
- setText(spannable)
1108
- lastAuthorizedText = spannable.toString()
1109
- isApplyingRustState = false
1571
+ currentRenderBlocksJson = null
1572
+ applyRenderedSpannable(spannable, usedPatch = false)
1110
1573
  onSelectionOrContentMayChange?.invoke()
1111
1574
  if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
1112
1575
  requestLayout()