@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.
- package/README.md +18 -0
- package/android/build.gradle +23 -0
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +515 -39
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +58 -28
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +25 -0
- package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +232 -62
- 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/EditorToolbar.d.ts +26 -6
- package/dist/EditorToolbar.js +299 -65
- package/dist/NativeEditorBridge.d.ts +40 -1
- package/dist/NativeEditorBridge.js +184 -90
- package/dist/NativeRichTextEditor.d.ts +5 -1
- package/dist/NativeRichTextEditor.js +201 -78
- package/dist/YjsCollaboration.d.ts +2 -0
- package/dist/YjsCollaboration.js +142 -20
- package/dist/index.d.ts +1 -1
- package/dist/schemas.js +12 -0
- package/dist/useNativeEditor.d.ts +2 -0
- package/dist/useNativeEditor.js +7 -0
- 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 +87 -0
- package/ios/NativeEditorExpoView.swift +488 -178
- package/ios/NativeEditorModule.swift +25 -0
- package/ios/PositionBridge.swift +310 -75
- package/ios/RenderBridge.swift +362 -27
- package/ios/RichTextEditorView.swift +2001 -189
- package/ios/editor_coreFFI/editor_coreFFI.h +55 -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 +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
|
-
|
|
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
|
|
|
@@ -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 (
|
|
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
|
-
|
|
1035
|
-
|
|
1036
|
-
val
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
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
|
-
|
|
1094
|
-
|
|
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()
|