@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.
- package/README.md +18 -0
- package/android/build.gradle +23 -0
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +502 -39
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +56 -28
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +6 -0
- package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +57 -27
- package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +147 -78
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +249 -71
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +7 -6
- package/dist/NativeEditorBridge.d.ts +36 -1
- package/dist/NativeEditorBridge.js +173 -94
- package/dist/NativeRichTextEditor.d.ts +2 -0
- package/dist/NativeRichTextEditor.js +160 -53
- package/dist/YjsCollaboration.d.ts +2 -0
- package/dist/YjsCollaboration.js +142 -20
- package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
- package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
- package/ios/EditorLayoutManager.swift +3 -3
- package/ios/Generated_editor_core.swift +41 -0
- package/ios/NativeEditorExpoView.swift +43 -11
- package/ios/NativeEditorModule.swift +6 -0
- package/ios/PositionBridge.swift +310 -75
- package/ios/RenderBridge.swift +362 -27
- package/ios/RichTextEditorView.swift +1983 -187
- package/ios/editor_coreFFI/editor_coreFFI.h +33 -0
- package/package.json +11 -2
- package/rust/android/arm64-v8a/libeditor_core.so +0 -0
- package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
- package/rust/android/x86_64/libeditor_core.so +0 -0
- package/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
|
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
|
|
492
|
+
return maxOf(
|
|
493
|
+
textHeight + compoundPaddingTop + compoundPaddingBottom,
|
|
494
|
+
placeholderHeight ?: 0
|
|
495
|
+
)
|
|
376
496
|
}
|
|
377
497
|
|
|
378
498
|
val minimumHeight = suggestedMinimumHeight.coerceAtLeast(minHeight)
|
|
379
|
-
return (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1048
|
-
|
|
1049
|
-
val
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
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
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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
|
-
|
|
1107
|
-
|
|
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()
|