@openeditor/react-native-prose-editor 0.0.9 → 0.0.11

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 (26) hide show
  1. package/README.md +5 -9
  2. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +23 -0
  3. package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +2 -2
  4. package/android/src/main/java/com/apollohg/editor/NativeBlockEditorSurface.kt +476 -53
  5. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +110 -169
  6. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +1 -1
  7. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +124 -337
  8. package/dist/EditorTheme.d.ts +8 -0
  9. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  10. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  11. package/ios/Generated_editor_core.swift +15 -0
  12. package/ios/NativeBlockEditorSurface.swift +371 -60
  13. package/ios/NativeEditorExpoView.swift +114 -117
  14. package/ios/NativeInputSupport.swift +0 -302
  15. package/ios/PositionBridge.swift +22 -548
  16. package/ios/RichTextEditorView.swift +217 -4671
  17. package/ios/editor_coreFFI/editor_coreFFI.h +11 -0
  18. package/package.json +1 -1
  19. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  20. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  21. package/rust/android/x86_64/libeditor_core.so +0 -0
  22. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +31 -0
  23. package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +0 -50
  24. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +0 -4068
  25. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +0 -1009
  26. package/android/src/main/java/com/apollohg/editor/InputSnapshotSupport.kt +0 -6
@@ -1,15 +1,15 @@
1
1
  package com.openeditor.editor
2
2
 
3
3
  import android.content.Context
4
- import android.graphics.Canvas
5
4
  import android.graphics.Color
6
- import android.graphics.Paint
7
5
  import android.graphics.Rect
8
6
  import android.graphics.RectF
9
7
  import android.graphics.Typeface
10
8
  import android.graphics.drawable.GradientDrawable
11
9
  import android.text.SpannableStringBuilder
12
10
  import android.text.Spanned
11
+ import android.text.Editable
12
+ import android.text.TextWatcher
13
13
  import android.text.style.BackgroundColorSpan
14
14
  import android.text.style.ForegroundColorSpan
15
15
  import android.text.style.StrikethroughSpan
@@ -19,19 +19,32 @@ import android.text.style.UnderlineSpan
19
19
  import android.view.MotionEvent
20
20
  import android.view.View
21
21
  import android.view.ViewGroup
22
+ import android.view.inputmethod.BaseInputConnection
23
+ import android.view.inputmethod.EditorInfo
24
+ import android.view.inputmethod.InputConnection
25
+ import android.view.inputmethod.InputConnectionWrapper
26
+ import android.view.inputmethod.InputMethodManager
22
27
  import android.util.TypedValue
28
+ import android.text.InputType
23
29
  import android.widget.ImageView
24
30
  import android.widget.LinearLayout
25
31
  import android.widget.ScrollView
26
32
  import android.widget.TextView
33
+ import androidx.appcompat.widget.AppCompatEditText
27
34
  import org.json.JSONObject
28
35
  import uniffi.editor_core.editorDocToScalar
36
+ import uniffi.editor_core.editorDeleteBackwardAtSelectionScalar
29
37
  import uniffi.editor_core.editorGetSelectionState
38
+ import uniffi.editor_core.editorReplaceSelectionText
30
39
  import uniffi.editor_core.editorResizeImageAtDocPos
31
40
  import uniffi.editor_core.editorSetCellSelection
32
41
  import uniffi.editor_core.editorSetNodeSelection
33
42
  import uniffi.editor_core.editorSetSelection
43
+ import uniffi.editor_core.editorSplitBlock
34
44
  import uniffi.editor_core.editorToggleTaskItemCheckedAtSelectionScalar
45
+ import uniffi.editor_core.editorUpdateNodeAttrs
46
+
47
+ internal data class SelectedImageGeometry(val docPos: Int, val rect: RectF)
35
48
 
36
49
  class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
37
50
  var isScrollingEnabled: Boolean = true
@@ -48,8 +61,61 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
48
61
  val isAtom: Boolean
49
62
  )
50
63
 
51
- private class TextLeaf(context: Context) : TextView(context) {
64
+ private class TextLeaf(context: Context) : AppCompatEditText(context) {
52
65
  var positionSegments: List<PositionSegment> = emptyList()
66
+ var editorSurface: NativeBlockEditorSurface? = null
67
+ private var beforeText: String = ""
68
+ private var compositionBaselineText: String? = null
69
+ private var beforeSelectionStart: Int = 0
70
+ private var beforeSelectionEnd: Int = 0
71
+
72
+ init {
73
+ addTextChangedListener(object : TextWatcher {
74
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
75
+ beforeText = s?.toString().orEmpty()
76
+ if (editorSurface?.isApplyingRemoteUpdate != true && compositionBaselineText == null) {
77
+ compositionBaselineText = beforeText
78
+ }
79
+ beforeSelectionStart = selectionStart.coerceAtLeast(0)
80
+ beforeSelectionEnd = selectionEnd.coerceAtLeast(beforeSelectionStart)
81
+ }
82
+
83
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
84
+
85
+ override fun afterTextChanged(s: Editable?) {
86
+ val surface = editorSurface ?: return
87
+ if (surface.isApplyingRemoteUpdate) return
88
+ if (s != null && BaseInputConnection.getComposingSpanStart(s) >= 0) return
89
+ val baseline = compositionBaselineText ?: beforeText
90
+ compositionBaselineText = null
91
+ surface.commitLeafTextMutation(this@TextLeaf, baseline, s?.toString().orEmpty())
92
+ }
93
+ })
94
+ }
95
+
96
+ override fun onSelectionChanged(selStart: Int, selEnd: Int) {
97
+ super.onSelectionChanged(selStart, selEnd)
98
+ editorSurface?.selectionChanged(this, selStart, selEnd)
99
+ }
100
+
101
+ override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
102
+ val target = super.onCreateInputConnection(outAttrs) ?: return null
103
+ return object : InputConnectionWrapper(target, false) {
104
+ override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
105
+ if (beforeLength > 0 && selectionStart == 0 && selectionEnd == 0 &&
106
+ editorSurface?.deleteBackwardAtLeafBoundary(this@TextLeaf) == true
107
+ ) return true
108
+ return super.deleteSurroundingText(beforeLength, afterLength)
109
+ }
110
+
111
+ override fun deleteSurroundingTextInCodePoints(beforeLength: Int, afterLength: Int): Boolean {
112
+ if (beforeLength > 0 && selectionStart == 0 && selectionEnd == 0 &&
113
+ editorSurface?.deleteBackwardAtLeafBoundary(this@TextLeaf) == true
114
+ ) return true
115
+ return super.deleteSurroundingTextInCodePoints(beforeLength, afterLength)
116
+ }
117
+ }
118
+ }
53
119
 
54
120
  fun docPositionForOffset(offset: Int): Int? {
55
121
  val segment = positionSegments.firstOrNull {
@@ -82,8 +148,9 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
82
148
 
83
149
  fun caretRectForDocPosition(docPosition: Int, caretWidth: Float): RectF? {
84
150
  val textLayout = layout ?: return null
85
- val offset = nativeOffsetForDocPosition(docPosition)?.coerceIn(0, text.length) ?: return null
86
- val lookupOffset = offset.coerceAtMost((text.length - 1).coerceAtLeast(0))
151
+ val textLength = text?.length ?: 0
152
+ val offset = nativeOffsetForDocPosition(docPosition)?.coerceIn(0, textLength) ?: return null
153
+ val lookupOffset = offset.coerceAtMost((textLength - 1).coerceAtLeast(0))
87
154
  val line = textLayout.getLineForOffset(lookupOffset)
88
155
  val left = compoundPaddingLeft + textLayout.getPrimaryHorizontal(offset)
89
156
  return RectF(
@@ -96,8 +163,9 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
96
163
 
97
164
  fun selectionRectsForDocRange(from: Int, to: Int): List<RectF> {
98
165
  val textLayout = layout ?: return emptyList()
99
- val start = nativeOffsetForDocPosition(from)?.coerceIn(0, text.length) ?: return emptyList()
100
- val end = nativeOffsetForDocPosition(to)?.coerceIn(start, text.length) ?: return emptyList()
166
+ val textLength = text?.length ?: 0
167
+ val start = nativeOffsetForDocPosition(from)?.coerceIn(0, textLength) ?: return emptyList()
168
+ val end = nativeOffsetForDocPosition(to)?.coerceIn(start, textLength) ?: return emptyList()
101
169
  if (start == end) return emptyList()
102
170
  val firstLine = textLayout.getLineForOffset(start)
103
171
  val lastLine = textLayout.getLineForOffset((end - 1).coerceAtLeast(start))
@@ -162,22 +230,117 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
162
230
  private val textLeafViews = mutableListOf<TextLeaf>()
163
231
  private var selectedNodePos: Int? = null
164
232
  private var selectedTextPos: Int? = null
165
- private val caretPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0xFF2F80ED.toInt() }
166
-
167
- var isInputFocused: Boolean = false
233
+ var onAppliedUpdate: ((String) -> Unit)? = null
234
+ var onSelectionChange: ((Int, Int) -> Unit)? = null
235
+ var onEditorUpdate: ((String) -> Unit)? = null
236
+ var onFocusChange: ((Boolean) -> Unit)? = null
237
+ var placeholder: String = ""
168
238
  set(value) {
169
239
  field = value
170
- invalidate()
240
+ textLeafViews.forEach { updateLeafHint(it) }
171
241
  }
172
-
173
- var onAppliedUpdate: ((String) -> Unit)? = null
174
- var onRequestTextInput: ((Int) -> Unit)? = null
242
+ private var configuredAutoCapitalize: String? = null
243
+ private var configuredAutoCorrect: Boolean? = null
244
+ private var configuredKeyboardType: String? = null
175
245
  var isEditable: Boolean = true
176
246
  set(value) {
177
247
  field = value
248
+ textLeafViews.forEach {
249
+ it.isEnabled = value
250
+ it.isFocusable = value
251
+ it.isFocusableInTouchMode = value
252
+ it.setTextIsSelectable(value)
253
+ }
178
254
  refreshImageSelection()
179
255
  }
180
256
 
257
+ internal val activeEditText: AppCompatEditText?
258
+ get() = textLeafViews.firstOrNull { it.hasFocus() }
259
+ ?: selectedTextPos?.let { position ->
260
+ textLeafViews.firstOrNull { it.nativeOffsetForDocPosition(position) != null }
261
+ }
262
+ ?: textLeafViews.firstOrNull()
263
+
264
+ val isEditorFocused: Boolean get() = textLeafViews.any { it.hasFocus() }
265
+
266
+ fun focus(): Boolean {
267
+ val leaf = activeEditText ?: return false
268
+ val focused = leaf.requestFocus()
269
+ if (focused) {
270
+ context.getSystemService(InputMethodManager::class.java)
271
+ ?.showSoftInput(leaf, InputMethodManager.SHOW_IMPLICIT)
272
+ }
273
+ return focused
274
+ }
275
+
276
+ fun blur() {
277
+ activeEditText?.clearFocus()
278
+ }
279
+
280
+ fun hasActiveComposition(): Boolean {
281
+ val editable = activeEditText?.text ?: return false
282
+ return BaseInputConnection.getComposingSpanStart(editable) >= 0
283
+ }
284
+
285
+ fun currentScalarSelection(): Pair<Int, Int>? {
286
+ val selection = activeDocSelection() ?: return null
287
+ if (currentEditorId == 0L) return null
288
+ return editorDocToScalar(currentEditorId.toULong(), selection.first.toUInt()).toInt() to
289
+ editorDocToScalar(currentEditorId.toULong(), selection.second.toUInt()).toInt()
290
+ }
291
+
292
+ fun activeDocPositionForUtf16Offset(offset: Int): Int? {
293
+ val leaf = activeEditText as? TextLeaf ?: return null
294
+ return leaf.docPositionForOffset(offset)
295
+ }
296
+
297
+ fun setAutoCapitalize(value: String?) {
298
+ configuredAutoCapitalize = value
299
+ val flag = when (value) {
300
+ "none" -> 0
301
+ "words" -> InputType.TYPE_TEXT_FLAG_CAP_WORDS
302
+ "characters" -> InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
303
+ else -> InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
304
+ }
305
+ textLeafViews.forEach { leaf ->
306
+ leaf.inputType = (leaf.inputType and (
307
+ InputType.TYPE_TEXT_FLAG_CAP_WORDS or
308
+ InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or
309
+ InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
310
+ ).inv()) or flag
311
+ }
312
+ }
313
+
314
+ fun setAutoCorrect(enabled: Boolean?) {
315
+ configuredAutoCorrect = enabled
316
+ textLeafViews.forEach { leaf ->
317
+ leaf.inputType = if (enabled == true) {
318
+ (leaf.inputType and InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS.inv()) or InputType.TYPE_TEXT_FLAG_AUTO_CORRECT
319
+ } else {
320
+ (leaf.inputType and InputType.TYPE_TEXT_FLAG_AUTO_CORRECT.inv()) or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
321
+ }
322
+ }
323
+ }
324
+
325
+ fun setKeyboardType(value: String?) {
326
+ configuredKeyboardType = value
327
+ val inputClass = when (value) {
328
+ "numeric", "number-pad", "decimal-pad" -> InputType.TYPE_CLASS_NUMBER
329
+ "phone-pad" -> InputType.TYPE_CLASS_PHONE
330
+ else -> InputType.TYPE_CLASS_TEXT
331
+ }
332
+ textLeafViews.forEach { it.inputType = (it.inputType and InputType.TYPE_MASK_CLASS.inv()) or inputClass }
333
+ }
334
+
335
+ private fun activeDocSelection(): Pair<Int, Int>? {
336
+ val leaf = activeEditText as? TextLeaf ?: return null
337
+ val start = leaf.selectionStart.coerceAtLeast(0)
338
+ val end = leaf.selectionEnd.coerceAtLeast(start)
339
+ val anchor = leaf.docPositionForOffset(start) ?: return null
340
+ val head = leaf.docPositionForOffset(end) ?: return null
341
+ return anchor to head
342
+ }
343
+
181
344
  init {
182
345
  isFillViewport = false
183
346
  clipToPadding = false
@@ -193,12 +356,6 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
193
356
  override fun onTouchEvent(event: MotionEvent): Boolean =
194
357
  isScrollingEnabled && super.onTouchEvent(event)
195
358
 
196
- override fun dispatchDraw(canvas: Canvas) {
197
- super.dispatchDraw(canvas)
198
- if (!isEditable || !isInputFocused || selectedNodePos != null) return
199
- selectedCaretRect()?.let { canvas.drawRect(it, caretPaint) }
200
- }
201
-
202
359
  fun bind(editorId: Long) {
203
360
  currentEditorId = editorId
204
361
  if (editorId == 0L) {
@@ -227,10 +384,26 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
227
384
  val bottomInset = ((theme?.contentInsets?.bottom ?: 0f) * density).toInt()
228
385
  val leftInset = ((theme?.contentInsets?.left ?: 0f) * density).toInt()
229
386
  setPadding(leftInset, topInset, rightInset, bottomInset)
387
+ currentRoot?.let { root ->
388
+ val focusedSelection = activeDocSelection()
389
+ // Retire the old input connection before replacing themed leaves. Keeping
390
+ // a detached EditText as the IME target can make Android replay its text
391
+ // into the newly focused canonical leaf after a uiMode change.
392
+ if (focusedSelection != null) activeEditText?.clearFocus()
393
+ isApplyingRemoteUpdate = true
394
+ rebuildAll(root)
395
+ refreshTableCellRegistry()
396
+ isApplyingRemoteUpdate = false
397
+ focusedSelection?.let { applyNativeSelection(it.first, it.second, true) }
398
+ }
230
399
  }
231
400
 
232
401
  fun applyUpdateJSON(updateJSON: String, editorId: Long) {
402
+ val shouldPreserveTextFocus = textLeafViews.any { it.hasFocus() }
233
403
  val update = try { JSONObject(updateJSON) } catch (_: Throwable) { return }
404
+ val textSelection = update.optJSONObject("selection")?.takeIf {
405
+ it.optString("type") == "text"
406
+ }?.let { it.optInt("anchor") to it.optInt("head") }
234
407
  update.optJSONObject("selection")?.let { selection ->
235
408
  selectedNodePos = if (selection.optString("type") == "node") {
236
409
  selection.optInt("pos")
@@ -243,7 +416,11 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
243
416
  ) selection.optInt("anchor") else null
244
417
  refreshImageSelection()
245
418
  }
246
- val root = update.optJSONObject("renderTree")?.optJSONObject("root") ?: return
419
+ val root = update.optJSONObject("renderTree")?.optJSONObject("root")
420
+ if (root == null) {
421
+ textSelection?.let { applyNativeSelection(it.first, it.second, shouldPreserveTextFocus) }
422
+ return
423
+ }
247
424
  currentEditorId = editorId
248
425
  isApplyingRemoteUpdate = true
249
426
  val operations = update.optJSONObject("renderPatch")?.optJSONArray("operations")
@@ -256,6 +433,7 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
256
433
  refreshImageSelection()
257
434
  currentRoot = root
258
435
  isApplyingRemoteUpdate = false
436
+ textSelection?.let { applyNativeSelection(it.first, it.second, shouldPreserveTextFocus) }
259
437
  geometryRevision += 1
260
438
  requestLayout()
261
439
  }
@@ -345,6 +523,10 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
345
523
  rebuildAll(root)
346
524
  return
347
525
  }
526
+ if (oldView is TextLeaf) {
527
+ configureTextLeaf(oldView, node)
528
+ return
529
+ }
348
530
  val parent = oldView.parent as? ViewGroup ?: run {
349
531
  rebuildAll(root)
350
532
  return
@@ -429,6 +611,11 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
429
611
  internal val topLevelViewCountForTesting: Int
430
612
  get() = stack.childCount
431
613
 
614
+ internal fun textColorForTesting(value: String): Int? =
615
+ textLeafViews.firstOrNull { it.text?.toString() == value }?.currentTextColor
616
+
617
+ internal fun tableFillColorsForTesting(): List<Int> = tableCells.map { it.baseFillColor }
618
+
432
619
  private fun viewForNode(node: JSONObject, depth: Int, path: List<Int>): View {
433
620
  val rendered = when (node.optString("kind")) {
434
621
  "list" -> listView(node, depth, path)
@@ -510,10 +697,6 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
510
697
  orientation = LinearLayout.HORIZONTAL
511
698
  isBaselineAligned = false
512
699
  }
513
- row.addView(
514
- markerView(node, parentListType, index),
515
- LinearLayout.LayoutParams((24 * density).toInt(), (24 * density).toInt())
516
- )
517
700
  val content = LinearLayout(context).apply {
518
701
  orientation = LinearLayout.VERTICAL
519
702
  setPadding((8 * density).toInt(), 0, 0, 0)
@@ -525,12 +708,26 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
525
708
  if (content.childCount == 0) {
526
709
  content.addView(textBlockView(node, depth + 1))
527
710
  }
711
+ val marker = if (parentListType == "toggleList" || parentListType == "toggle_list") {
712
+ val open = node.optJSONObject("attrs")?.optBoolean("open", true) ?: true
713
+ setToggleContent(content, open)
714
+ toggleMarkerView(node, content, open)
715
+ } else {
716
+ markerView(node, parentListType, index)
717
+ }
718
+ row.addView(
719
+ marker,
720
+ LinearLayout.LayoutParams((24 * density).toInt(), (24 * density).toInt())
721
+ )
528
722
  row.addView(content, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
529
723
  return row
530
724
  }
531
725
 
532
726
  private fun blockContainerView(node: JSONObject, depth: Int, path: List<Int>): View {
533
727
  val density = resources.displayMetrics.density
728
+ if (node.optString("nodeType") == "callout") {
729
+ return calloutView(node, depth, path)
730
+ }
534
731
  if (node.optString("nodeType") == "blockquote") {
535
732
  val row = LinearLayout(context).apply {
536
733
  orientation = LinearLayout.HORIZONTAL
@@ -569,11 +766,95 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
569
766
  return container
570
767
  }
571
768
 
769
+ private fun calloutView(node: JSONObject, depth: Int, path: List<Int>): View {
770
+ val density = resources.displayMetrics.density
771
+ val tone = node.optJSONObject("attrs")?.optString("tone", "info") ?: "info"
772
+ val accent = when (tone) {
773
+ "success" -> 0xFF16A34A.toInt()
774
+ "warning" -> 0xFFD97706.toInt()
775
+ "danger" -> 0xFFDC2626.toInt()
776
+ else -> 0xFF2563EB.toInt()
777
+ }
778
+ val container = LinearLayout(context).apply {
779
+ orientation = LinearLayout.VERTICAL
780
+ val padding = (12 * density).toInt()
781
+ setPadding(padding, padding, padding, padding)
782
+ background = roundedDrawable(
783
+ Color.argb(24, Color.red(accent), Color.green(accent), Color.blue(accent)),
784
+ Color.argb(140, Color.red(accent), Color.green(accent), Color.blue(accent)),
785
+ 1f,
786
+ 10f
787
+ )
788
+ }
789
+ container.addView(TextView(context).apply {
790
+ text = tone.replaceFirstChar { it.uppercase() }
791
+ setTextColor(accent)
792
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
793
+ setTypeface(typeface, Typeface.BOLD)
794
+ isClickable = this@NativeBlockEditorSurface.isEditable
795
+ setOnClickListener { cycleCalloutTone(node) }
796
+ })
797
+ val children = node.optJSONArray("children")
798
+ for (index in 0 until (children?.length() ?: 0)) {
799
+ children?.optJSONObject(index)?.let { container.addView(viewForNode(it, depth + 1, path + index)) }
800
+ }
801
+ return container
802
+ }
803
+
804
+ private fun toggleMarkerView(node: JSONObject, content: LinearLayout, initiallyOpen: Boolean): View =
805
+ TextView(context).apply {
806
+ var open = initiallyOpen
807
+ text = if (open) "⌄" else "›"
808
+ contentDescription = if (open) "Collapse toggle item" else "Expand toggle item"
809
+ gravity = android.view.Gravity.CENTER
810
+ setTextColor(currentTheme?.list?.markerColor ?: baseTextColor)
811
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
812
+ setTypeface(typeface, Typeface.BOLD)
813
+ setOnClickListener {
814
+ open = !open
815
+ text = if (open) "⌄" else "›"
816
+ contentDescription = if (open) "Collapse toggle item" else "Expand toggle item"
817
+ setToggleContent(content, open)
818
+ if (isEditable) updateNodeAttrs(node, JSONObject().put("open", open))
819
+ }
820
+ }
821
+
822
+ private fun setToggleContent(content: LinearLayout, open: Boolean) {
823
+ for (index in 1 until content.childCount) {
824
+ content.getChildAt(index).visibility = if (open) View.VISIBLE else View.GONE
825
+ }
826
+ }
827
+
828
+ private fun cycleCalloutTone(node: JSONObject) {
829
+ if (!isEditable) return
830
+ val tones = listOf("info", "success", "warning", "danger")
831
+ val current = node.optJSONObject("attrs")?.optString("tone", "info") ?: "info"
832
+ val next = tones[(tones.indexOf(current).coerceAtLeast(0) + 1) % tones.size]
833
+ updateNodeAttrs(node, JSONObject().put("tone", next))
834
+ }
835
+
836
+ private fun updateNodeAttrs(node: JSONObject, patch: JSONObject) {
837
+ if (currentEditorId == 0L) return
838
+ val attrs = JSONObject(node.optJSONObject("attrs")?.toString() ?: "{}")
839
+ patch.keys().forEach { key -> attrs.put(key, patch.get(key)) }
840
+ val updateJSON = editorUpdateNodeAttrs(
841
+ currentEditorId.toULong(),
842
+ node.optInt("docPos", 0).toUInt(),
843
+ attrs.toString()
844
+ )
845
+ applyUpdateJSON(updateJSON, currentEditorId)
846
+ onAppliedUpdate?.invoke(updateJSON)
847
+ }
848
+
572
849
  private fun tableView(node: JSONObject, path: List<Int>): View {
573
850
  val tableId = nextTableId++
574
851
  val table = LinearLayout(context).apply {
575
852
  orientation = LinearLayout.VERTICAL
576
- background = borderDrawable(Color.TRANSPARENT, Color.LTGRAY, 1f)
853
+ background = borderDrawable(
854
+ Color.TRANSPARENT,
855
+ currentTheme?.table?.borderColor ?: baseTextColor,
856
+ 1f
857
+ )
577
858
  }
578
859
  val rows = node.optJSONArray("children")
579
860
  for (index in 0 until (rows?.length() ?: 0)) {
@@ -599,7 +880,15 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
599
880
 
600
881
  private fun cellView(node: JSONObject, tableId: Int, rowIndex: Int, columnIndex: Int, path: List<Int>): View {
601
882
  val density = resources.displayMetrics.density
602
- val fillColor = if (node.optString("nodeType") == "tableHeader") 0xFFF4F5F7.toInt() else Color.WHITE
883
+ val fillColor = if (node.optString("nodeType") == "tableHeader") {
884
+ currentTheme?.table?.headerBackgroundColor
885
+ ?: currentTheme?.codeBlock?.backgroundColor
886
+ ?: Color.TRANSPARENT
887
+ } else {
888
+ currentTheme?.table?.cellBackgroundColor
889
+ ?: currentTheme?.backgroundColor
890
+ ?: Color.TRANSPARENT
891
+ }
603
892
  val cell = CellView(context).apply {
604
893
  docPos = node.optInt("docPos", 0)
605
894
  this.tableId = tableId
@@ -610,7 +899,7 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
610
899
  setPadding((8 * density).toInt(), (8 * density).toInt(), (8 * density).toInt(), (8 * density).toInt())
611
900
  background = borderDrawable(
612
901
  fillColor,
613
- Color.LTGRAY,
902
+ currentTheme?.table?.borderColor ?: baseTextColor,
614
903
  0.5f
615
904
  )
616
905
  isClickable = true
@@ -628,17 +917,29 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
628
917
  }
629
918
 
630
919
  private fun textBlockView(node: JSONObject, depth: Int): View {
920
+ return TextLeaf(context).also { configureTextLeaf(it, node) }
921
+ }
922
+
923
+ private fun configureTextLeaf(leaf: TextLeaf, node: JSONObject) {
631
924
  val rendered = attributedInlineContent(node)
632
- val leaf = TextLeaf(context).apply leaf@ {
925
+ val hadFocus = leaf.hasFocus()
926
+ val previousSelectionStart = leaf.selectionStart.coerceAtLeast(0)
927
+ val previousSelectionEnd = leaf.selectionEnd.coerceAtLeast(previousSelectionStart)
928
+ leaf.apply leaf@ {
633
929
  text = rendered.first
634
930
  positionSegments = if (rendered.second.isEmpty()) {
635
- listOf(PositionSegment(0, 0, "", node.optInt("docPos", 0), node.optInt("docSize", 0), false))
931
+ listOf(PositionSegment(0, 0, "", node.optInt("docPos", 0) + if (node.optInt("docSize", 0) >= 2) 1 else 0, 0, false))
636
932
  } else {
637
933
  rendered.second
638
934
  }
639
- setTextColor(baseTextColor)
640
- setTextSize(TypedValue.COMPLEX_UNIT_PX, textSizeFor(node))
641
- typeface = typefaceFor(node)
935
+ val style = currentTheme?.effectiveTextStyle(node.optString("nodeType"))
936
+ setTextColor(style?.color ?: baseTextColor)
937
+ setTextSize(
938
+ TypedValue.COMPLEX_UNIT_PX,
939
+ style?.fontSize?.let(::spToPx) ?: textSizeFor(node)
940
+ )
941
+ typeface = style?.let { Typeface.create(it.fontFamily, it.typefaceStyle()) }
942
+ ?: typefaceFor(node)
642
943
  minLines = 1
643
944
  background = if (node.optString("nodeType") == "codeBlock" || node.optString("nodeType") == "code_block") {
644
945
  roundedDrawable(
@@ -657,26 +958,59 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
657
958
  } else {
658
959
  setPadding(0, 0, 0, 0)
659
960
  }
660
- isFocusable = false
661
- isFocusableInTouchMode = false
662
- setTextIsSelectable(false)
663
- setOnTouchListener { view, event ->
664
- if (event.actionMasked != MotionEvent.ACTION_UP || !this@NativeBlockEditorSurface.isEditable) {
665
- return@setOnTouchListener true
666
- }
667
- val textView = view as TextLeaf
668
- textView.docPositionForOffset(textView.getOffsetForPosition(event.x, event.y))
669
- ?.let { onRequestTextInput?.invoke(it) }
670
- true
961
+ isEnabled = this@NativeBlockEditorSurface.isEditable
962
+ isFocusable = this@NativeBlockEditorSurface.isEditable
963
+ isFocusableInTouchMode = this@NativeBlockEditorSurface.isEditable
964
+ setTextIsSelectable(this@NativeBlockEditorSurface.isEditable)
965
+ isSingleLine = false
966
+ editorSurface = this@NativeBlockEditorSurface
967
+ updateLeafHint(this)
968
+ onFocusChangeListener = OnFocusChangeListener { _, focused ->
969
+ if (focused) onFocusChange?.invoke(true)
970
+ else if (!this@NativeBlockEditorSurface.isEditorFocused) onFocusChange?.invoke(false)
971
+ }
972
+ applyConfiguredInputTraits(this)
973
+ if (hadFocus) {
974
+ setSelection(
975
+ previousSelectionStart.coerceAtMost(length()),
976
+ previousSelectionEnd.coerceAtMost(length())
977
+ )
671
978
  }
672
979
  }
673
- return leaf
980
+ }
981
+
982
+ private fun updateLeafHint(leaf: TextLeaf) {
983
+ leaf.hint = if ((textLeafViews.firstOrNull() == null || textLeafViews.firstOrNull() === leaf) && leaf.text.isNullOrEmpty()) {
984
+ placeholder
985
+ } else {
986
+ null
987
+ }
988
+ }
989
+
990
+ private fun applyConfiguredInputTraits(leaf: TextLeaf) {
991
+ val capitalFlag = when (configuredAutoCapitalize) {
992
+ "none" -> 0
993
+ "words" -> InputType.TYPE_TEXT_FLAG_CAP_WORDS
994
+ "characters" -> InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
995
+ else -> InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
996
+ }
997
+ val inputClass = when (configuredKeyboardType) {
998
+ "numeric", "number-pad", "decimal-pad" -> InputType.TYPE_CLASS_NUMBER
999
+ "phone-pad" -> InputType.TYPE_CLASS_PHONE
1000
+ else -> InputType.TYPE_CLASS_TEXT
1001
+ }
1002
+ val suggestionFlag = if (configuredAutoCorrect == true) {
1003
+ InputType.TYPE_TEXT_FLAG_AUTO_CORRECT
1004
+ } else {
1005
+ InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
1006
+ }
1007
+ leaf.inputType = inputClass or capitalFlag or suggestionFlag or InputType.TYPE_TEXT_FLAG_MULTI_LINE
674
1008
  }
675
1009
 
676
1010
  private fun positionSegmentsFor(node: JSONObject): List<PositionSegment> {
677
1011
  val segments = attributedInlineContent(node).second
678
1012
  return if (segments.isEmpty()) {
679
- listOf(PositionSegment(0, 0, "", node.optInt("docPos", 0), node.optInt("docSize", 0), false))
1013
+ listOf(PositionSegment(0, 0, "", node.optInt("docPos", 0) + if (node.optInt("docSize", 0) >= 2) 1 else 0, 0, false))
680
1014
  } else {
681
1015
  segments
682
1016
  }
@@ -779,10 +1113,14 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
779
1113
  }
780
1114
  return TextView(context).apply {
781
1115
  text = node.optString("label", node.optString("nodeType", "Block"))
782
- setTextColor(Color.DKGRAY)
1116
+ setTextColor(currentTheme?.text?.color ?: baseTextColor)
783
1117
  textSize = 14f
784
1118
  setPadding(12, 8, 12, 8)
785
- background = borderDrawable(0xFFF2F3F5.toInt(), Color.TRANSPARENT, 0f)
1119
+ background = borderDrawable(
1120
+ currentTheme?.codeBlock?.backgroundColor ?: Color.TRANSPARENT,
1121
+ Color.TRANSPARENT,
1122
+ 0f
1123
+ )
786
1124
  }
787
1125
  }
788
1126
 
@@ -800,7 +1138,12 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
800
1138
  ?.let { (it * density).toInt() }
801
1139
  scaleType = ImageView.ScaleType.FIT_CENTER
802
1140
  adjustViewBounds = true
803
- background = roundedDrawable(0xFFF2F3F5.toInt(), Color.TRANSPARENT, 0f, 8f)
1141
+ background = roundedDrawable(
1142
+ currentTheme?.codeBlock?.backgroundColor ?: Color.TRANSPARENT,
1143
+ Color.TRANSPARENT,
1144
+ 0f,
1145
+ 8f
1146
+ )
804
1147
  isClickable = true
805
1148
  setOnClickListener { selectImage(this) }
806
1149
  }
@@ -834,13 +1177,13 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
834
1177
  }
835
1178
  }
836
1179
 
837
- internal fun selectedImageGeometry(): EditorEditText.SelectedImageGeometry? {
1180
+ internal fun selectedImageGeometry(): SelectedImageGeometry? {
838
1181
  val docPos = selectedNodePos ?: return null
839
1182
  val imageView = imageViews.firstOrNull { it.docPos == docPos } ?: return null
840
1183
  if (!isEditable || imageView.width <= 0 || imageView.height <= 0) return null
841
1184
  val rect = Rect(0, 0, imageView.width, imageView.height)
842
1185
  offsetDescendantRectToMyCoords(imageView, rect)
843
- return EditorEditText.SelectedImageGeometry(docPos, RectF(rect))
1186
+ return SelectedImageGeometry(docPos, RectF(rect))
844
1187
  }
845
1188
 
846
1189
  internal fun selectedCaretRect(): RectF? =
@@ -874,6 +1217,80 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
874
1217
  }
875
1218
  }
876
1219
 
1220
+ private fun applyNativeSelection(anchor: Int, head: Int, preservingFocus: Boolean) {
1221
+ val candidates = textLeafViews.filter {
1222
+ it.nativeOffsetForDocPosition(anchor) != null &&
1223
+ it.nativeOffsetForDocPosition(head) != null
1224
+ }
1225
+ val leaf = candidates.firstOrNull { it.hasFocus() }
1226
+ ?: (if (anchor == head) candidates.lastOrNull() else candidates.firstOrNull())
1227
+ ?: return
1228
+ val start = leaf.nativeOffsetForDocPosition(minOf(anchor, head)) ?: return
1229
+ val end = leaf.nativeOffsetForDocPosition(maxOf(anchor, head)) ?: return
1230
+ isApplyingRemoteUpdate = true
1231
+ try {
1232
+ leaf.setSelection(start.coerceIn(0, leaf.length()), end.coerceIn(0, leaf.length()))
1233
+ if (preservingFocus && !leaf.hasFocus()) leaf.requestFocus()
1234
+ } finally {
1235
+ isApplyingRemoteUpdate = false
1236
+ }
1237
+ }
1238
+
1239
+ private fun selectionChanged(leaf: TextLeaf, start: Int, end: Int) {
1240
+ if (isApplyingRemoteUpdate || !isEditable || currentEditorId == 0L) return
1241
+ val anchor = leaf.docPositionForOffset(start.coerceAtLeast(0)) ?: return
1242
+ val head = leaf.docPositionForOffset(end.coerceAtLeast(start)) ?: return
1243
+ editorSetSelection(currentEditorId.toULong(), anchor.toUInt(), head.toUInt())
1244
+ onSelectionChange?.invoke(anchor, head)
1245
+ onAppliedUpdate?.invoke(editorGetSelectionState(currentEditorId.toULong()))
1246
+ }
1247
+
1248
+ private fun commitLeafTextMutation(leaf: TextLeaf, before: String, after: String) {
1249
+ if (isApplyingRemoteUpdate || !isEditable || currentEditorId == 0L || before == after) return
1250
+ var prefix = 0
1251
+ val prefixLimit = minOf(before.length, after.length)
1252
+ while (prefix < prefixLimit && before[prefix] == after[prefix]) prefix += 1
1253
+ var suffix = 0
1254
+ while (
1255
+ suffix < before.length - prefix &&
1256
+ suffix < after.length - prefix &&
1257
+ before[before.length - 1 - suffix] == after[after.length - 1 - suffix]
1258
+ ) suffix += 1
1259
+
1260
+ val oldEnd = before.length - suffix
1261
+ val newEnd = after.length - suffix
1262
+ val anchor = leaf.docPositionForOffset(prefix) ?: return
1263
+ val head = leaf.docPositionForOffset(oldEnd) ?: return
1264
+ val replacement = after.substring(prefix, newEnd)
1265
+ editorSetSelection(currentEditorId.toULong(), anchor.toUInt(), head.toUInt())
1266
+ val update = if (replacement == "\n") {
1267
+ if (anchor != head) {
1268
+ editorReplaceSelectionText(currentEditorId.toULong(), "")
1269
+ }
1270
+ editorSplitBlock(currentEditorId.toULong(), anchor.toUInt())
1271
+ } else {
1272
+ editorReplaceSelectionText(currentEditorId.toULong(), replacement)
1273
+ }
1274
+ applyUpdateJSON(update, currentEditorId)
1275
+ onEditorUpdate?.invoke(update)
1276
+ onAppliedUpdate?.invoke(update)
1277
+ }
1278
+
1279
+ private fun deleteBackwardAtLeafBoundary(leaf: TextLeaf): Boolean {
1280
+ if (isApplyingRemoteUpdate || !isEditable || currentEditorId == 0L) return false
1281
+ val docPosition = leaf.docPositionForOffset(0) ?: return false
1282
+ val scalar = editorDocToScalar(currentEditorId.toULong(), docPosition.toUInt())
1283
+ val update = editorDeleteBackwardAtSelectionScalar(
1284
+ currentEditorId.toULong(),
1285
+ scalar,
1286
+ scalar
1287
+ )
1288
+ applyUpdateJSON(update, currentEditorId)
1289
+ onEditorUpdate?.invoke(update)
1290
+ onAppliedUpdate?.invoke(update)
1291
+ return true
1292
+ }
1293
+
877
1294
  private fun descendantRectInSurface(descendant: View, local: RectF): RectF {
878
1295
  val origin = Rect(0, 0, 0, 0)
879
1296
  offsetDescendantRectToMyCoords(descendant, origin)
@@ -993,8 +1410,14 @@ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
993
1410
  cell == anchor || cell == head
994
1411
  }
995
1412
  cell.background = borderDrawable(
996
- if (selected) 0xFFE8F1FF.toInt() else cell.baseFillColor,
997
- if (selected) 0xFF2F80ED.toInt() else Color.LTGRAY,
1413
+ if (selected) {
1414
+ currentTheme?.table?.selectionBackgroundColor ?: cell.baseFillColor
1415
+ } else cell.baseFillColor,
1416
+ if (selected) {
1417
+ currentTheme?.table?.selectionBorderColor ?: baseTextColor
1418
+ } else {
1419
+ currentTheme?.table?.borderColor ?: baseTextColor
1420
+ },
998
1421
  if (selected) 1.5f else 0.5f
999
1422
  )
1000
1423
  }