@apollohg/react-native-prose-editor 0.1.0

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 (47) hide show
  1. package/LICENSE +160 -0
  2. package/README.md +143 -0
  3. package/android/build.gradle +39 -0
  4. package/android/src/main/assets/editor-icons/MaterialIcons.json +2236 -0
  5. package/android/src/main/assets/editor-icons/MaterialIcons.ttf +0 -0
  6. package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +131 -0
  7. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +1057 -0
  8. package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
  9. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +191 -0
  10. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +325 -0
  11. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +647 -0
  12. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +257 -0
  13. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +714 -0
  14. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +76 -0
  15. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +1044 -0
  16. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +211 -0
  17. package/expo-module.config.json +9 -0
  18. package/ios/EditorAddons.swift +228 -0
  19. package/ios/EditorCore.xcframework/Info.plist +44 -0
  20. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  21. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  22. package/ios/EditorLayoutManager.swift +254 -0
  23. package/ios/EditorTheme.swift +372 -0
  24. package/ios/Generated_editor_core.swift +1143 -0
  25. package/ios/NativeEditorExpoView.swift +1417 -0
  26. package/ios/NativeEditorModule.swift +263 -0
  27. package/ios/PositionBridge.swift +278 -0
  28. package/ios/ReactNativeProseEditor.podspec +49 -0
  29. package/ios/RenderBridge.swift +825 -0
  30. package/ios/RichTextEditorView.swift +1559 -0
  31. package/ios/editor_coreFFI/editor_coreFFI.h +1014 -0
  32. package/ios/editor_coreFFI/module.modulemap +7 -0
  33. package/ios/editor_coreFFI.h +904 -0
  34. package/ios/editor_coreFFI.modulemap +7 -0
  35. package/package.json +66 -0
  36. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  37. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  38. package/rust/android/x86_64/libeditor_core.so +0 -0
  39. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2014 -0
  40. package/src/EditorTheme.ts +130 -0
  41. package/src/EditorToolbar.tsx +620 -0
  42. package/src/NativeEditorBridge.ts +607 -0
  43. package/src/NativeRichTextEditor.tsx +951 -0
  44. package/src/addons.ts +158 -0
  45. package/src/index.ts +63 -0
  46. package/src/schemas.ts +153 -0
  47. package/src/useNativeEditor.ts +173 -0
@@ -0,0 +1,1057 @@
1
+ package com.apollohg.editor
2
+
3
+ import android.content.ClipboardManager
4
+ import android.content.Context
5
+ import android.graphics.Typeface
6
+ import android.graphics.Rect
7
+ import android.text.Editable
8
+ import android.text.Layout
9
+ import android.text.StaticLayout
10
+ import android.text.TextWatcher
11
+ import android.util.AttributeSet
12
+ import android.util.Log
13
+ import android.util.TypedValue
14
+ import android.view.KeyEvent
15
+ import android.view.MotionEvent
16
+ import android.view.inputmethod.EditorInfo
17
+ import android.view.inputmethod.InputConnection
18
+ import androidx.appcompat.widget.AppCompatEditText
19
+ import uniffi.editor_core.* // UniFFI-generated bindings
20
+
21
+ /**
22
+ * Custom [AppCompatEditText] subclass that intercepts all text input and routes it
23
+ * through the Rust editor-core engine via UniFFI bindings.
24
+ *
25
+ * Instead of letting Android's EditText internal text storage handle insertions
26
+ * and deletions, this class captures the user's intent (typing, deleting,
27
+ * pasting, autocorrect) and sends it to the Rust editor. The Rust editor
28
+ * returns render elements, which are converted to [android.text.SpannableStringBuilder]
29
+ * via [RenderBridge] and applied back to the EditText.
30
+ *
31
+ * This is the "input interception" pattern: the EditText is effectively
32
+ * a rendering surface, not a text editing engine.
33
+ *
34
+ * ## Composition Handling
35
+ *
36
+ * For CJK input methods, composing text is handled normally by the base
37
+ * [InputConnection]. When composition finalizes, we capture the result and
38
+ * route it through Rust.
39
+ *
40
+ * ## Thread Safety
41
+ *
42
+ * All EditText methods are called on the main thread. The UniFFI calls
43
+ * (`editor_insert_text`, `editor_delete_range`, etc.) are synchronous and
44
+ * fast enough for main-thread use.
45
+ */
46
+ class EditorEditText @JvmOverloads constructor(
47
+ context: Context,
48
+ attrs: AttributeSet? = null,
49
+ defStyleAttr: Int = android.R.attr.editTextStyle
50
+ ) : AppCompatEditText(context, attrs, defStyleAttr) {
51
+ /**
52
+ * Listener interface for editor events, parallel to iOS's EditorTextViewDelegate.
53
+ */
54
+ interface EditorListener {
55
+ /** Called when the editor's selection changes (anchor and head as scalar offsets). */
56
+ fun onSelectionChanged(anchor: Int, head: Int)
57
+
58
+ /** Called when the editor content is updated after a Rust operation. */
59
+ fun onEditorUpdate(updateJSON: String)
60
+ }
61
+
62
+ /** The Rust editor instance ID (from editor_create / editor_create_with_max_length). */
63
+ var editorId: Long = 0
64
+
65
+ /**
66
+ * Controls whether user input is accepted.
67
+ *
68
+ * When false, all user-input mutation entry points (typing, deletion,
69
+ * paste, composition) are blocked. Unlike [isEnabled], this preserves
70
+ * focus, text selection, and copy capability.
71
+ */
72
+ var isEditable: Boolean = true
73
+
74
+ /**
75
+ * Guard flag to prevent re-entrant input interception while we're
76
+ * applying state from Rust (calling [setText] or modifying text storage).
77
+ */
78
+ var isApplyingRustState = false
79
+
80
+ /** Listener for editor events. */
81
+ var editorListener: EditorListener? = null
82
+
83
+ /** The base font size in pixels used for unstyled text. */
84
+ private var baseFontSize: Float = textSize
85
+
86
+ /** The base text color as an ARGB int. */
87
+ private var baseTextColor: Int = currentTextColor
88
+
89
+ /** The base background color before theme overrides. */
90
+ private var baseBackgroundColor: Int = android.graphics.Color.WHITE
91
+
92
+ /** Optional render theme supplied by React. */
93
+ var theme: EditorTheme? = null
94
+ private set
95
+
96
+ var heightBehavior: EditorHeightBehavior = EditorHeightBehavior.FIXED
97
+ private set
98
+
99
+ private var contentInsets: EditorContentInsets? = null
100
+ private var viewportBottomInsetPx: Int = 0
101
+
102
+ /**
103
+ * The plain text from the last Rust-authorized render.
104
+ * Used by [ReconciliationWatcher] to detect unauthorized divergence.
105
+ */
106
+ private var lastAuthorizedText: String = ""
107
+
108
+ /**
109
+ * Number of reconciliation events triggered during this EditText's lifetime.
110
+ * Useful for monitoring and kill-condition analysis.
111
+ */
112
+ var reconciliationCount: Int = 0
113
+ private set
114
+
115
+ private var lastHandledHardwareKeyCode: Int? = null
116
+ private var lastHandledHardwareKeyDownTime: Long? = null
117
+
118
+ init {
119
+ // Configure for rich text editing.
120
+ inputType = EditorInfo.TYPE_CLASS_TEXT or
121
+ EditorInfo.TYPE_TEXT_FLAG_MULTI_LINE or
122
+ EditorInfo.TYPE_TEXT_FLAG_AUTO_CORRECT or
123
+ EditorInfo.TYPE_TEXT_FLAG_CAP_SENTENCES
124
+
125
+ // Disable built-in spell checking to avoid conflicts with Rust state.
126
+ // The Rust editor is the source of truth for text content.
127
+ isSaveEnabled = false
128
+
129
+ // Watch for unauthorized text mutations (IME, accessibility, etc.)
130
+ // and reconcile back to Rust's authoritative state.
131
+ addTextChangedListener(ReconciliationWatcher())
132
+ baseBackgroundColor = android.graphics.Color.WHITE
133
+ isVerticalScrollBarEnabled = true
134
+ overScrollMode = OVER_SCROLL_IF_CONTENT_SCROLLS
135
+
136
+ // Pin content to top-start to prevent theme-dependent vertical centering.
137
+ gravity = android.view.Gravity.TOP or android.view.Gravity.START
138
+
139
+ // Strip the default EditText theme drawable which carries implicit padding.
140
+ // Background color is applied in setBaseStyle() / applyTheme().
141
+ background = null
142
+ updateEffectivePadding()
143
+ }
144
+
145
+ // ── InputConnection Override ────────────────────────────────────────
146
+
147
+ /**
148
+ * Create a custom [EditorInputConnection] that intercepts all input
149
+ * from the soft keyboard.
150
+ */
151
+ override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
152
+ val baseConnection = super.onCreateInputConnection(outAttrs) ?: return null
153
+ return EditorInputConnection(this, baseConnection)
154
+ }
155
+
156
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
157
+ if (handleHardwareKeyEvent(event)) {
158
+ return true
159
+ }
160
+ return super.dispatchKeyEvent(event)
161
+ }
162
+
163
+ override fun onTouchEvent(event: MotionEvent): Boolean {
164
+ if (heightBehavior == EditorHeightBehavior.FIXED) {
165
+ val canScroll = canScrollVertically(-1) || canScrollVertically(1)
166
+ if (canScroll) {
167
+ when (event.actionMasked) {
168
+ MotionEvent.ACTION_DOWN,
169
+ MotionEvent.ACTION_MOVE -> parent?.requestDisallowInterceptTouchEvent(true)
170
+ MotionEvent.ACTION_UP,
171
+ MotionEvent.ACTION_CANCEL -> parent?.requestDisallowInterceptTouchEvent(false)
172
+ }
173
+ }
174
+ }
175
+ return super.onTouchEvent(event)
176
+ }
177
+
178
+ // ── Editor Binding ──────────────────────────────────────────────────
179
+
180
+ /**
181
+ * Bind this EditText to a Rust editor instance and optionally apply initial content.
182
+ *
183
+ * @param id The editor ID from `editor_create()`.
184
+ * @param initialHTML Optional HTML to set as initial content.
185
+ */
186
+ fun bindEditor(id: Long, initialHTML: String? = null) {
187
+ editorId = id
188
+
189
+ if (!initialHTML.isNullOrEmpty()) {
190
+ val renderJSON = editorSetHtml(editorId.toULong(), initialHTML)
191
+ applyRenderJSON(renderJSON)
192
+ } else {
193
+ // Pull current state from Rust (content may already be loaded via bridge).
194
+ val stateJSON = editorGetCurrentState(editorId.toULong())
195
+ applyUpdateJSON(stateJSON)
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Unbind from the current editor instance.
201
+ */
202
+ fun unbindEditor() {
203
+ editorId = 0
204
+ }
205
+
206
+ fun setBaseStyle(fontSizePx: Float, textColor: Int, backgroundColor: Int) {
207
+ baseFontSize = fontSizePx
208
+ baseTextColor = textColor
209
+ baseBackgroundColor = backgroundColor
210
+ setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSizePx)
211
+ setTextColor(textColor)
212
+ setBackgroundColor(theme?.backgroundColor ?: backgroundColor)
213
+ }
214
+
215
+ fun applyTheme(theme: EditorTheme?) {
216
+ this.theme = theme
217
+ setBackgroundColor(theme?.backgroundColor ?: baseBackgroundColor)
218
+ applyContentInsets(theme?.contentInsets)
219
+ if (editorId != 0L) {
220
+ val previousScrollX = scrollX
221
+ val previousScrollY = scrollY
222
+ val stateJSON = editorGetCurrentState(editorId.toULong())
223
+ applyUpdateJSON(stateJSON, notifyListener = false)
224
+ if (heightBehavior == EditorHeightBehavior.FIXED) {
225
+ preserveScrollPosition(previousScrollX, previousScrollY)
226
+ } else {
227
+ requestLayout()
228
+ }
229
+ }
230
+ }
231
+
232
+ fun setHeightBehavior(heightBehavior: EditorHeightBehavior) {
233
+ if (this.heightBehavior == heightBehavior) return
234
+ this.heightBehavior = heightBehavior
235
+ isVerticalScrollBarEnabled = heightBehavior == EditorHeightBehavior.FIXED
236
+ overScrollMode = if (heightBehavior == EditorHeightBehavior.FIXED) {
237
+ OVER_SCROLL_IF_CONTENT_SCROLLS
238
+ } else {
239
+ OVER_SCROLL_NEVER
240
+ }
241
+ updateEffectivePadding()
242
+ ensureSelectionVisible()
243
+ requestLayout()
244
+ }
245
+
246
+ private fun applyContentInsets(contentInsets: EditorContentInsets?) {
247
+ this.contentInsets = contentInsets
248
+ updateEffectivePadding()
249
+ }
250
+
251
+ fun setViewportBottomInsetPx(bottomInsetPx: Int) {
252
+ val clampedInset = bottomInsetPx.coerceAtLeast(0)
253
+ if (viewportBottomInsetPx == clampedInset) return
254
+ viewportBottomInsetPx = clampedInset
255
+ updateEffectivePadding()
256
+ ensureSelectionVisible()
257
+ }
258
+
259
+ private fun updateEffectivePadding() {
260
+ val density = resources.displayMetrics.density
261
+ val left = ((contentInsets?.left ?: 0f) * density).toInt()
262
+ val top = ((contentInsets?.top ?: 0f) * density).toInt()
263
+ val right = ((contentInsets?.right ?: 0f) * density).toInt()
264
+ val bottom = ((contentInsets?.bottom ?: 0f) * density).toInt()
265
+
266
+ if (heightBehavior == EditorHeightBehavior.FIXED) {
267
+ setPadding(left, 0, right, 0)
268
+ setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, null, null)
269
+ } else {
270
+ setPadding(left, top, right, bottom)
271
+ setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, null, null)
272
+ }
273
+ }
274
+
275
+ fun resolveAutoGrowHeight(): Int {
276
+ val laidOutTextHeight = if (isLaidOut) layout?.height else null
277
+ if (laidOutTextHeight != null && laidOutTextHeight > 0) {
278
+ return laidOutTextHeight + compoundPaddingTop + compoundPaddingBottom
279
+ }
280
+
281
+ val availableWidth = (measuredWidth - compoundPaddingLeft - compoundPaddingRight).coerceAtLeast(0)
282
+ val currentText = text
283
+ if (availableWidth > 0 && currentText != null) {
284
+ val staticLayout = StaticLayout.Builder
285
+ .obtain(currentText, 0, currentText.length, paint, availableWidth)
286
+ .setAlignment(Layout.Alignment.ALIGN_NORMAL)
287
+ .setIncludePad(includeFontPadding)
288
+ .build()
289
+ val textHeight = staticLayout.height.takeIf { it > 0 } ?: lineHeight
290
+ return textHeight + compoundPaddingTop + compoundPaddingBottom
291
+ }
292
+
293
+ val minimumHeight = suggestedMinimumHeight.coerceAtLeast(minHeight)
294
+ return (lineHeight + compoundPaddingTop + compoundPaddingBottom).coerceAtLeast(minimumHeight)
295
+ }
296
+
297
+ private fun preserveScrollPosition(previousScrollX: Int, previousScrollY: Int) {
298
+ val restore = {
299
+ val maxScrollX = maxOf(0, computeHorizontalScrollRange() - width)
300
+ val maxScrollY = maxOf(0, computeVerticalScrollRange() - height)
301
+ scrollTo(
302
+ previousScrollX.coerceIn(0, maxScrollX),
303
+ previousScrollY.coerceIn(0, maxScrollY)
304
+ )
305
+ }
306
+
307
+ restore()
308
+ post { restore() }
309
+ }
310
+
311
+ private fun ensureSelectionVisible() {
312
+ if (heightBehavior != EditorHeightBehavior.FIXED) return
313
+ if (!isLaidOut || width <= 0 || height <= 0) return
314
+ val selectionOffset = selectionEnd.takeIf { it >= 0 } ?: return
315
+
316
+ post {
317
+ if (!isLaidOut || layout == null) return@post
318
+ bringPointIntoView(selectionOffset)
319
+
320
+ val textLayout = layout ?: return@post
321
+ val clampedOffset = selectionOffset.coerceAtMost(textLayout.text.length)
322
+ val line = textLayout.getLineForOffset(clampedOffset)
323
+ val caretLeft = textLayout.getPrimaryHorizontal(clampedOffset).toInt()
324
+ val rect = Rect(
325
+ caretLeft + totalPaddingLeft,
326
+ textLayout.getLineTop(line) + totalPaddingTop,
327
+ caretLeft + totalPaddingLeft + 1,
328
+ textLayout.getLineBottom(line) + totalPaddingTop
329
+ )
330
+ requestRectangleOnScreen(rect)
331
+ }
332
+ }
333
+
334
+ // ── Input Handling: Text Commit ─────────────────────────────────────
335
+
336
+ /**
337
+ * Handle committed text from the IME (typed characters, autocomplete).
338
+ *
339
+ * Called by [EditorInputConnection.commitText]. Routes the text through
340
+ * the Rust editor instead of directly inserting into the EditText.
341
+ */
342
+ fun handleTextCommit(text: String) {
343
+ if (!isEditable) return
344
+ if (isApplyingRustState) return
345
+ if (editorId == 0L) {
346
+ // No Rust editor bound — fall through to direct editing (dev mode).
347
+ val editable = this.text ?: return
348
+ val start = selectionStart
349
+ val end = selectionEnd
350
+ editable.replace(start, end, text)
351
+ return
352
+ }
353
+
354
+ // Handle Enter/Return as a block split operation.
355
+ if (text == "\n") {
356
+ handleReturnKey()
357
+ return
358
+ }
359
+
360
+ val currentText = this.text?.toString() ?: ""
361
+ val start = selectionStart
362
+ val end = selectionEnd
363
+
364
+ if (start != end) {
365
+ // Range selection: atomic replace via Rust.
366
+ val scalarStart = PositionBridge.utf16ToScalar(start, currentText)
367
+ val scalarEnd = PositionBridge.utf16ToScalar(end, currentText)
368
+ val updateJSON = editorReplaceTextScalar(
369
+ editorId.toULong(), scalarStart.toUInt(), scalarEnd.toUInt(), text
370
+ )
371
+ applyUpdateJSON(updateJSON)
372
+ } else {
373
+ val scalarPos = PositionBridge.utf16ToScalar(start, currentText)
374
+ insertTextInRust(text, scalarPos)
375
+ }
376
+ }
377
+
378
+ // ── Input Handling: Deletion ────────────────────────────────────────
379
+
380
+ /**
381
+ * Handle surrounding text deletion from the IME.
382
+ *
383
+ * Called by [EditorInputConnection.deleteSurroundingText].
384
+ *
385
+ * @param beforeLength Number of UTF-16 code units to delete before the cursor.
386
+ * @param afterLength Number of UTF-16 code units to delete after the cursor.
387
+ */
388
+ fun handleDelete(beforeLength: Int, afterLength: Int) {
389
+ if (!isEditable) return
390
+ if (isApplyingRustState) return
391
+ if (editorId == 0L) {
392
+ // Dev mode: direct editing.
393
+ val editable = this.text ?: return
394
+ val cursor = selectionStart
395
+ val delStart = maxOf(0, cursor - beforeLength)
396
+ val delEnd = minOf(editable.length, cursor + afterLength)
397
+ editable.delete(delStart, delEnd)
398
+ return
399
+ }
400
+
401
+ val currentText = text?.toString() ?: ""
402
+ val cursor = selectionStart
403
+ val delStart = maxOf(0, cursor - beforeLength)
404
+ val delEnd = minOf(currentText.length, cursor + afterLength)
405
+
406
+ val scalarStart = PositionBridge.utf16ToScalar(delStart, currentText)
407
+ val scalarEnd = PositionBridge.utf16ToScalar(delEnd, currentText)
408
+
409
+ if (scalarStart < scalarEnd) {
410
+ deleteRangeInRust(scalarStart, scalarEnd)
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Handle backspace key press (hardware keyboard or key event).
416
+ *
417
+ * If there's a range selection, deletes the range. Otherwise deletes
418
+ * the grapheme cluster before the cursor.
419
+ */
420
+ fun handleBackspace() {
421
+ if (!isEditable) return
422
+ if (isApplyingRustState) return
423
+ if (editorId == 0L) {
424
+ // Dev mode: direct editing.
425
+ val editable = this.text ?: return
426
+ val start = selectionStart
427
+ val end = selectionEnd
428
+ if (start != end) {
429
+ editable.delete(start, end)
430
+ } else if (start > 0) {
431
+ // Delete one grapheme cluster backward.
432
+ val prevBoundary = PositionBridge.snapToGraphemeBoundary(start - 1, text?.toString() ?: "")
433
+ val adjustedPrev = if (prevBoundary >= start) maxOf(0, start - 1) else prevBoundary
434
+ editable.delete(adjustedPrev, start)
435
+ }
436
+ return
437
+ }
438
+
439
+ val currentText = text?.toString() ?: ""
440
+ val start = selectionStart
441
+ val end = selectionEnd
442
+
443
+ if (start != end) {
444
+ // Range selection: delete the range.
445
+ val scalarStart = PositionBridge.utf16ToScalar(start, currentText)
446
+ val scalarEnd = PositionBridge.utf16ToScalar(end, currentText)
447
+ deleteRangeInRust(scalarStart, scalarEnd)
448
+ } else if (start > 0) {
449
+ // Cursor: delete one grapheme cluster backward.
450
+ // Find the previous grapheme boundary by snapping (start - 1).
451
+ val breakIter = java.text.BreakIterator.getCharacterInstance()
452
+ breakIter.setText(currentText)
453
+ val prevBoundary = breakIter.preceding(start)
454
+ val prevUtf16 = if (prevBoundary == java.text.BreakIterator.DONE) 0 else prevBoundary
455
+
456
+ val scalarStart = PositionBridge.utf16ToScalar(prevUtf16, currentText)
457
+ val scalarEnd = PositionBridge.utf16ToScalar(start, currentText)
458
+ deleteRangeInRust(scalarStart, scalarEnd)
459
+ }
460
+ }
461
+
462
+ // ── Input Handling: Composition ─────────────────────────────────────
463
+
464
+ /**
465
+ * Handle finalization of IME composition (CJK input, swipe keyboard).
466
+ *
467
+ * Called by [EditorInputConnection.finishComposingText] after the base
468
+ * InputConnection has finalized the composing text.
469
+ */
470
+ /**
471
+ * Handle finalization of IME composition.
472
+ *
473
+ * @param composedText The finalized composed text captured from the InputConnection.
474
+ */
475
+ fun handleCompositionFinished(composedText: String?) {
476
+ if (!isEditable) return
477
+ if (isApplyingRustState) return
478
+ if (editorId == 0L) return
479
+ if (composedText.isNullOrEmpty()) return
480
+
481
+ // The cursor is at the end of the composed text. Calculate the insert
482
+ // position as cursor - composed_length (in scalar offsets).
483
+ val currentText = text?.toString() ?: ""
484
+ val cursorUtf16 = selectionStart
485
+ val cursorScalar = PositionBridge.utf16ToScalar(cursorUtf16, currentText)
486
+ val composedScalarLen = composedText.codePointCount(0, composedText.length)
487
+ val insertPos = if (cursorScalar >= composedScalarLen) cursorScalar - composedScalarLen else 0
488
+ insertTextInRust(composedText, insertPos)
489
+ }
490
+
491
+ // ── Input Handling: Return Key ──────────────────────────────────────
492
+
493
+ /**
494
+ * Handle return/enter key as a block split operation.
495
+ */
496
+ fun handleReturnKey() {
497
+ if (!isEditable) return
498
+ if (isApplyingRustState) return
499
+
500
+ val currentText = text?.toString() ?: ""
501
+ val start = selectionStart
502
+ val end = selectionEnd
503
+
504
+ if (editorId == 0L) {
505
+ // Dev mode: insert newline directly.
506
+ val editable = this.text ?: return
507
+ editable.replace(start, end, "\n")
508
+ return
509
+ }
510
+
511
+ if (start != end) {
512
+ // Range selection: atomic delete-and-split via Rust.
513
+ val scalarStart = PositionBridge.utf16ToScalar(start, currentText)
514
+ val scalarEnd = PositionBridge.utf16ToScalar(end, currentText)
515
+ val updateJSON = editorDeleteAndSplitScalar(
516
+ editorId.toULong(), scalarStart.toUInt(), scalarEnd.toUInt()
517
+ )
518
+ applyUpdateJSON(updateJSON)
519
+ } else {
520
+ val scalarPos = PositionBridge.utf16ToScalar(start, currentText)
521
+ splitBlockInRust(scalarPos)
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Handle Shift+Enter as an inline hard break insertion.
527
+ */
528
+ fun handleHardBreak() {
529
+ if (!isEditable) return
530
+ if (isApplyingRustState) return
531
+
532
+ if (editorId == 0L) {
533
+ val editable = this.text ?: return
534
+ val start = selectionStart
535
+ val end = selectionEnd
536
+ editable.replace(start, end, "\n")
537
+ return
538
+ }
539
+
540
+ val selection = currentScalarSelection() ?: return
541
+ val updateJSON = editorInsertNodeAtSelectionScalar(
542
+ editorId.toULong(),
543
+ selection.first.toUInt(),
544
+ selection.second.toUInt(),
545
+ "hardBreak"
546
+ )
547
+ applyUpdateJSON(updateJSON)
548
+ }
549
+
550
+ /**
551
+ * Handle hardware Tab / Shift+Tab as list indent / outdent when the caret is in a list.
552
+ */
553
+ fun handleTab(shiftPressed: Boolean): Boolean {
554
+ if (!isEditable) return false
555
+ if (isApplyingRustState) return false
556
+ if (editorId == 0L) return false
557
+ if (!isSelectionInsideList()) return false
558
+ val selection = currentScalarSelection() ?: return false
559
+
560
+ val updateJSON = if (shiftPressed) {
561
+ editorOutdentListItemAtSelectionScalar(
562
+ editorId.toULong(),
563
+ selection.first.toUInt(),
564
+ selection.second.toUInt()
565
+ )
566
+ } else {
567
+ editorIndentListItemAtSelectionScalar(
568
+ editorId.toULong(),
569
+ selection.first.toUInt(),
570
+ selection.second.toUInt()
571
+ )
572
+ }
573
+ applyUpdateJSON(updateJSON)
574
+ return true
575
+ }
576
+
577
+ fun handleHardwareKeyDown(keyCode: Int, shiftPressed: Boolean): Boolean {
578
+ if (!isEditable || isApplyingRustState) return false
579
+ return when (keyCode) {
580
+ KeyEvent.KEYCODE_DEL -> {
581
+ handleBackspace()
582
+ true
583
+ }
584
+ KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
585
+ if (shiftPressed) {
586
+ handleHardBreak()
587
+ } else {
588
+ handleReturnKey()
589
+ }
590
+ true
591
+ }
592
+ KeyEvent.KEYCODE_TAB -> handleTab(shiftPressed)
593
+ else -> false
594
+ }
595
+ }
596
+
597
+ fun handleHardwareKeyEvent(event: KeyEvent?): Boolean {
598
+ if (event == null || !isEditable || isApplyingRustState) return false
599
+
600
+ return when (event.action) {
601
+ KeyEvent.ACTION_DOWN -> {
602
+ val supported = when (event.keyCode) {
603
+ KeyEvent.KEYCODE_DEL,
604
+ KeyEvent.KEYCODE_ENTER,
605
+ KeyEvent.KEYCODE_NUMPAD_ENTER,
606
+ KeyEvent.KEYCODE_TAB -> true
607
+ else -> false
608
+ }
609
+ if (!supported) return false
610
+
611
+ if (lastHandledHardwareKeyCode == event.keyCode &&
612
+ lastHandledHardwareKeyDownTime == event.downTime) {
613
+ return true
614
+ }
615
+
616
+ if (handleHardwareKeyDown(event.keyCode, event.isShiftPressed)) {
617
+ lastHandledHardwareKeyCode = event.keyCode
618
+ lastHandledHardwareKeyDownTime = event.downTime
619
+ true
620
+ } else {
621
+ false
622
+ }
623
+ }
624
+
625
+ KeyEvent.ACTION_UP -> {
626
+ if (lastHandledHardwareKeyCode == event.keyCode &&
627
+ lastHandledHardwareKeyDownTime == event.downTime) {
628
+ lastHandledHardwareKeyCode = null
629
+ lastHandledHardwareKeyDownTime = null
630
+ true
631
+ } else {
632
+ false
633
+ }
634
+ }
635
+
636
+ else -> false
637
+ }
638
+ }
639
+
640
+ fun performToolbarToggleMark(markName: String) {
641
+ if (!isEditable || isApplyingRustState || editorId == 0L) return
642
+ val selection = currentScalarSelection() ?: return
643
+ val updateJSON = editorToggleMarkAtSelectionScalar(
644
+ editorId.toULong(),
645
+ selection.first.toUInt(),
646
+ selection.second.toUInt(),
647
+ markName
648
+ )
649
+ applyUpdateJSON(updateJSON)
650
+ }
651
+
652
+ fun performToolbarToggleList(listType: String, isActive: Boolean) {
653
+ if (!isEditable || isApplyingRustState || editorId == 0L) return
654
+ val selection = currentScalarSelection() ?: return
655
+ val updateJSON = if (isActive) {
656
+ editorUnwrapFromListAtSelectionScalar(
657
+ editorId.toULong(),
658
+ selection.first.toUInt(),
659
+ selection.second.toUInt()
660
+ )
661
+ } else {
662
+ editorWrapInListAtSelectionScalar(
663
+ editorId.toULong(),
664
+ selection.first.toUInt(),
665
+ selection.second.toUInt(),
666
+ listType
667
+ )
668
+ }
669
+ applyUpdateJSON(updateJSON)
670
+ }
671
+
672
+ fun performToolbarIndentListItem() {
673
+ if (!isEditable || isApplyingRustState || editorId == 0L) return
674
+ val selection = currentScalarSelection() ?: return
675
+ val updateJSON = editorIndentListItemAtSelectionScalar(
676
+ editorId.toULong(),
677
+ selection.first.toUInt(),
678
+ selection.second.toUInt()
679
+ )
680
+ applyUpdateJSON(updateJSON)
681
+ }
682
+
683
+ fun performToolbarOutdentListItem() {
684
+ if (!isEditable || isApplyingRustState || editorId == 0L) return
685
+ val selection = currentScalarSelection() ?: return
686
+ val updateJSON = editorOutdentListItemAtSelectionScalar(
687
+ editorId.toULong(),
688
+ selection.first.toUInt(),
689
+ selection.second.toUInt()
690
+ )
691
+ applyUpdateJSON(updateJSON)
692
+ }
693
+
694
+ fun performToolbarInsertNode(nodeType: String) {
695
+ if (!isEditable || isApplyingRustState || editorId == 0L) return
696
+ val selection = currentScalarSelection() ?: return
697
+ val updateJSON = editorInsertNodeAtSelectionScalar(
698
+ editorId.toULong(),
699
+ selection.first.toUInt(),
700
+ selection.second.toUInt(),
701
+ nodeType
702
+ )
703
+ applyUpdateJSON(updateJSON)
704
+ }
705
+
706
+ fun performToolbarUndo() {
707
+ if (!isEditable || isApplyingRustState || editorId == 0L) return
708
+ applyUpdateJSON(editorUndo(editorId.toULong()))
709
+ }
710
+
711
+ fun performToolbarRedo() {
712
+ if (!isEditable || isApplyingRustState || editorId == 0L) return
713
+ applyUpdateJSON(editorRedo(editorId.toULong()))
714
+ }
715
+
716
+ // ── Input Handling: Paste ────────────────────────────────────────────
717
+
718
+ /**
719
+ * Intercept paste operations to route content through Rust.
720
+ *
721
+ * Attempts to extract HTML from the clipboard first (for rich text paste),
722
+ * falling back to plain text.
723
+ */
724
+ override fun onTextContextMenuItem(id: Int): Boolean {
725
+ if (!isEditable && id == android.R.id.paste) return true
726
+ if (id == android.R.id.paste) {
727
+ handlePaste()
728
+ return true
729
+ }
730
+ return super.onTextContextMenuItem(id)
731
+ }
732
+
733
+ /**
734
+ * Block accessibility-initiated text mutations (paste, set text) when not editable.
735
+ * Selection and copy actions remain available.
736
+ */
737
+ override fun performAccessibilityAction(action: Int, arguments: android.os.Bundle?): Boolean {
738
+ if (!isEditable && (action == android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT
739
+ || action == android.view.accessibility.AccessibilityNodeInfo.ACTION_PASTE)) {
740
+ return false
741
+ }
742
+ return super.performAccessibilityAction(action, arguments)
743
+ }
744
+
745
+ private fun handlePaste() {
746
+ if (editorId == 0L) {
747
+ // Dev mode: default paste behavior.
748
+ super.onTextContextMenuItem(android.R.id.paste)
749
+ return
750
+ }
751
+
752
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
753
+ ?: return
754
+ val clip = clipboard.primaryClip ?: return
755
+ if (clip.itemCount == 0) return
756
+
757
+ val item = clip.getItemAt(0)
758
+
759
+ // Try HTML first for rich paste.
760
+ val htmlText = item.htmlText
761
+ if (htmlText != null) {
762
+ pasteHTML(htmlText)
763
+ return
764
+ }
765
+
766
+ // Fallback to plain text.
767
+ val plainText = item.text?.toString()
768
+ if (plainText != null) {
769
+ pastePlainText(plainText)
770
+ }
771
+ }
772
+
773
+ // ── Selection Change ────────────────────────────────────────────────
774
+
775
+ /**
776
+ * Override to notify the listener when selection changes.
777
+ *
778
+ * Converts the EditText selection to scalar offsets and notifies both
779
+ * the listener and the Rust editor.
780
+ */
781
+ override fun onSelectionChanged(selStart: Int, selEnd: Int) {
782
+ super.onSelectionChanged(selStart, selEnd)
783
+ ensureSelectionVisible()
784
+
785
+ if (isApplyingRustState || editorId == 0L) return
786
+
787
+ val currentText = text?.toString() ?: ""
788
+ if (currentText != lastAuthorizedText) return
789
+ val scalarAnchor = PositionBridge.utf16ToScalar(selStart, currentText)
790
+ val scalarHead = PositionBridge.utf16ToScalar(selEnd, currentText)
791
+
792
+ // Sync selection to Rust (converts scalar→doc internally).
793
+ editorSetSelectionScalar(
794
+ editorId.toULong(),
795
+ scalarAnchor.toUInt(),
796
+ scalarHead.toUInt()
797
+ )
798
+
799
+ // Emit doc positions (not scalar offsets) to match the Selection contract.
800
+ val docAnchor = editorScalarToDoc(editorId.toULong(), scalarAnchor.toUInt()).toInt()
801
+ val docHead = editorScalarToDoc(editorId.toULong(), scalarHead.toUInt()).toInt()
802
+ editorListener?.onSelectionChanged(docAnchor, docHead)
803
+ }
804
+
805
+ // ── Rust Integration ────────────────────────────────────────────────
806
+
807
+ /**
808
+ * Insert text at a scalar position via the Rust editor.
809
+ */
810
+ private fun insertTextInRust(text: String, atScalarPos: Int) {
811
+ val updateJSON = editorInsertTextScalar(editorId.toULong(), atScalarPos.toUInt(), text)
812
+ applyUpdateJSON(updateJSON)
813
+ }
814
+
815
+ /**
816
+ * Delete a scalar range via the Rust editor.
817
+ *
818
+ * @param scalarFrom Start scalar offset (inclusive).
819
+ * @param scalarTo End scalar offset (exclusive).
820
+ */
821
+ private fun deleteRangeInRust(scalarFrom: Int, scalarTo: Int) {
822
+ if (scalarFrom >= scalarTo) return
823
+ val updateJSON = editorDeleteScalarRange(editorId.toULong(), scalarFrom.toUInt(), scalarTo.toUInt())
824
+ applyUpdateJSON(updateJSON)
825
+ }
826
+
827
+ /**
828
+ * Split a block at a scalar position via the Rust editor.
829
+ */
830
+ private fun splitBlockInRust(atScalarPos: Int) {
831
+ val updateJSON = editorSplitBlockScalar(editorId.toULong(), atScalarPos.toUInt())
832
+ applyUpdateJSON(updateJSON)
833
+ }
834
+
835
+ private fun currentScalarSelection(): Pair<Int, Int>? {
836
+ val currentText = text?.toString() ?: return null
837
+ return Pair(
838
+ PositionBridge.utf16ToScalar(selectionStart, currentText),
839
+ PositionBridge.utf16ToScalar(selectionEnd, currentText)
840
+ )
841
+ }
842
+
843
+ private fun isSelectionInsideList(): Boolean {
844
+ if (editorId == 0L) return false
845
+
846
+ return try {
847
+ val state = org.json.JSONObject(editorGetCurrentState(editorId.toULong()))
848
+ val nodes = state.optJSONObject("activeState")?.optJSONObject("nodes")
849
+ nodes?.optBoolean("bulletList", false) == true ||
850
+ nodes?.optBoolean("orderedList", false) == true
851
+ } catch (_: Exception) {
852
+ false
853
+ }
854
+ }
855
+
856
+ /**
857
+ * Paste HTML content through Rust.
858
+ */
859
+ private fun pasteHTML(html: String) {
860
+ val updateJSON = editorInsertContentHtml(editorId.toULong(), html)
861
+ applyUpdateJSON(updateJSON)
862
+ }
863
+
864
+ /**
865
+ * Paste plain text through Rust.
866
+ */
867
+ private fun pastePlainText(text: String) {
868
+ val currentText = this.text?.toString() ?: ""
869
+ val scalarPos = PositionBridge.utf16ToScalar(selectionStart, currentText)
870
+ insertTextInRust(text, scalarPos)
871
+ }
872
+
873
+ // ── Applying Rust State ─────────────────────────────────────────────
874
+
875
+ /**
876
+ * Apply a full render update from Rust to the EditText.
877
+ *
878
+ * Parses the update JSON, converts render elements to [android.text.SpannableStringBuilder]
879
+ * via [RenderBridge], and replaces the EditText's content.
880
+ *
881
+ * @param updateJSON The JSON string from editor_insert_text, etc.
882
+ */
883
+ fun applyUpdateJSON(updateJSON: String, notifyListener: Boolean = true) {
884
+ val update = try {
885
+ org.json.JSONObject(updateJSON)
886
+ } catch (_: Exception) {
887
+ return
888
+ }
889
+
890
+ val renderElements = update.optJSONArray("renderElements") ?: return
891
+
892
+ val spannable = RenderBridge.buildSpannableFromArray(
893
+ renderElements,
894
+ baseFontSize,
895
+ baseTextColor,
896
+ theme,
897
+ resources.displayMetrics.density
898
+ )
899
+
900
+ val previousScrollX = scrollX
901
+ val previousScrollY = scrollY
902
+
903
+ isApplyingRustState = true
904
+ setText(spannable)
905
+ lastAuthorizedText = spannable.toString()
906
+ isApplyingRustState = false
907
+
908
+ // Apply the selection from the update.
909
+ val selection = update.optJSONObject("selection")
910
+ if (selection != null) {
911
+ applySelectionFromJSON(selection)
912
+ }
913
+
914
+ if (notifyListener) {
915
+ editorListener?.onEditorUpdate(updateJSON)
916
+ }
917
+ if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
918
+ requestLayout()
919
+ } else {
920
+ preserveScrollPosition(previousScrollX, previousScrollY)
921
+ }
922
+ }
923
+
924
+ /**
925
+ * Apply a render JSON string (just render elements, no update wrapper).
926
+ *
927
+ * Used for initial content loading (set_html / set_json return render
928
+ * elements directly, not wrapped in an EditorUpdate).
929
+ *
930
+ * @param renderJSON The JSON array string of render elements.
931
+ */
932
+ fun applyRenderJSON(renderJSON: String) {
933
+ val spannable = RenderBridge.buildSpannable(
934
+ renderJSON,
935
+ baseFontSize,
936
+ baseTextColor,
937
+ theme,
938
+ resources.displayMetrics.density
939
+ )
940
+
941
+ val previousScrollX = scrollX
942
+ val previousScrollY = scrollY
943
+
944
+ isApplyingRustState = true
945
+ setText(spannable)
946
+ lastAuthorizedText = spannable.toString()
947
+ isApplyingRustState = false
948
+ if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
949
+ requestLayout()
950
+ } else {
951
+ preserveScrollPosition(previousScrollX, previousScrollY)
952
+ }
953
+ }
954
+
955
+ /**
956
+ * Apply a selection from a parsed JSON selection object.
957
+ *
958
+ * The selection JSON matches the format from `serialize_editor_update`:
959
+ * ```json
960
+ * {"type": "text", "anchor": 5, "head": 5}
961
+ * {"type": "node", "pos": 10}
962
+ * {"type": "all"}
963
+ * ```
964
+ *
965
+ * anchor/head from Rust are **document positions** (include structural tokens).
966
+ * We convert doc→scalar via [editorDocToScalar] before converting to UTF-16.
967
+ */
968
+ private fun applySelectionFromJSON(selection: org.json.JSONObject) {
969
+ val type = selection.optString("type", "") ?: return
970
+
971
+ isApplyingRustState = true
972
+ try {
973
+ val currentText = text?.toString() ?: ""
974
+ when (type) {
975
+ "text" -> {
976
+ val docAnchor = selection.optInt("anchor", 0)
977
+ val docHead = selection.optInt("head", 0)
978
+ // Convert doc positions to scalar offsets.
979
+ val scalarAnchor = editorDocToScalar(editorId.toULong(), docAnchor.toUInt()).toInt()
980
+ val scalarHead = editorDocToScalar(editorId.toULong(), docHead.toUInt()).toInt()
981
+ val startUtf16 = PositionBridge.scalarToUtf16(minOf(scalarAnchor, scalarHead), currentText)
982
+ val endUtf16 = PositionBridge.scalarToUtf16(maxOf(scalarAnchor, scalarHead), currentText)
983
+ val len = text?.length ?: 0
984
+ setSelection(
985
+ startUtf16.coerceIn(0, len),
986
+ endUtf16.coerceIn(0, len)
987
+ )
988
+ }
989
+ "node" -> {
990
+ val docPos = selection.optInt("pos", 0)
991
+ // Convert doc position to scalar offset.
992
+ val scalarPos = editorDocToScalar(editorId.toULong(), docPos.toUInt()).toInt()
993
+ val startUtf16 = PositionBridge.scalarToUtf16(scalarPos, currentText)
994
+ val len = text?.length ?: 0
995
+ val clamped = startUtf16.coerceIn(0, len)
996
+ // Select one character (the void node placeholder).
997
+ val endClamped = (clamped + 1).coerceAtMost(len)
998
+ setSelection(clamped, endClamped)
999
+ }
1000
+ "all" -> {
1001
+ selectAll()
1002
+ }
1003
+ }
1004
+ } finally {
1005
+ isApplyingRustState = false
1006
+ }
1007
+ }
1008
+
1009
+ // ── Reconciliation ─────────────────────────────────────────────────
1010
+
1011
+ /**
1012
+ * [TextWatcher] that detects when the EditText's text diverges from the
1013
+ * last Rust-authorized content (e.g., due to IME autocorrect, accessibility
1014
+ * services, or other Android framework mutations that bypass our
1015
+ * [EditorInputConnection]).
1016
+ *
1017
+ * When divergence is detected, Rust's current state is re-fetched and
1018
+ * re-applied — "Rust wins" — to maintain the invariant that the Rust
1019
+ * editor-core is the single source of truth for document content.
1020
+ */
1021
+ private inner class ReconciliationWatcher : TextWatcher {
1022
+
1023
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
1024
+ // No-op: we only need afterTextChanged.
1025
+ }
1026
+
1027
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
1028
+ // No-op: we only need afterTextChanged.
1029
+ }
1030
+
1031
+ override fun afterTextChanged(s: Editable?) {
1032
+ if (isApplyingRustState) return
1033
+ if (editorId == 0L) return
1034
+
1035
+ val currentText = s?.toString() ?: ""
1036
+ if (currentText == lastAuthorizedText) return
1037
+
1038
+ // Text has diverged from Rust's authorized state.
1039
+ reconciliationCount++
1040
+ Log.w(
1041
+ LOG_TAG,
1042
+ "reconciliation: EditText diverged from Rust state" +
1043
+ " (count=$reconciliationCount," +
1044
+ " editText=${currentText.length} chars," +
1045
+ " authorized=${lastAuthorizedText.length} chars)"
1046
+ )
1047
+
1048
+ // Re-fetch Rust's current state and re-apply ("Rust wins").
1049
+ val stateJSON = editorGetCurrentState(editorId.toULong())
1050
+ applyUpdateJSON(stateJSON)
1051
+ }
1052
+ }
1053
+
1054
+ companion object {
1055
+ private const val LOG_TAG = "NativeEditor"
1056
+ }
1057
+ }