@apollohg/react-native-prose-editor 0.1.1 → 0.3.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 (53) hide show
  1. package/README.md +12 -7
  2. package/android/build.gradle +7 -2
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +289 -2
  4. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +51 -1
  5. package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
  6. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +16 -3
  7. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +82 -1
  8. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +403 -45
  9. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +246 -0
  10. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +841 -155
  11. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +125 -8
  12. package/{src/EditorTheme.ts → dist/EditorTheme.d.ts} +12 -52
  13. package/dist/EditorTheme.js +29 -0
  14. package/dist/EditorToolbar.d.ts +129 -0
  15. package/dist/EditorToolbar.js +394 -0
  16. package/dist/NativeEditorBridge.d.ts +242 -0
  17. package/dist/NativeEditorBridge.js +647 -0
  18. package/dist/NativeRichTextEditor.d.ts +142 -0
  19. package/dist/NativeRichTextEditor.js +649 -0
  20. package/dist/YjsCollaboration.d.ts +83 -0
  21. package/dist/YjsCollaboration.js +585 -0
  22. package/dist/addons.d.ts +70 -0
  23. package/dist/addons.js +77 -0
  24. package/dist/index.d.ts +8 -0
  25. package/dist/index.js +26 -0
  26. package/dist/schemas.d.ts +35 -0
  27. package/{src/schemas.ts → dist/schemas.js} +62 -27
  28. package/dist/useNativeEditor.d.ts +40 -0
  29. package/dist/useNativeEditor.js +117 -0
  30. package/ios/EditorAddons.swift +26 -3
  31. package/ios/EditorCore.xcframework/Info.plist +5 -5
  32. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  33. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  34. package/ios/EditorLayoutManager.swift +236 -0
  35. package/ios/EditorTheme.swift +51 -1
  36. package/ios/Generated_editor_core.swift +270 -2
  37. package/ios/NativeEditorExpoView.swift +612 -45
  38. package/ios/NativeEditorModule.swift +81 -0
  39. package/ios/PositionBridge.swift +22 -0
  40. package/ios/RenderBridge.swift +427 -39
  41. package/ios/RichTextEditorView.swift +1342 -18
  42. package/ios/editor_coreFFI/editor_coreFFI.h +209 -0
  43. package/package.json +80 -64
  44. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  45. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  46. package/rust/android/x86_64/libeditor_core.so +0 -0
  47. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +404 -4
  48. package/src/EditorToolbar.tsx +0 -620
  49. package/src/NativeEditorBridge.ts +0 -607
  50. package/src/NativeRichTextEditor.tsx +0 -951
  51. package/src/addons.ts +0 -158
  52. package/src/index.ts +0 -63
  53. package/src/useNativeEditor.ts +0 -173
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
- # React Native Prose Editor
1
+ # React Native Prose Editor [![NPM version](https://img.shields.io/npm/v/@apollohg/react-native-prose-editor.svg?style=flat)](https://www.npmjs.com/package/@apollohg/react-native-prose-editor)
2
2
 
3
3
  `@apollohg/react-native-prose-editor` is a native rich text editor for React Native with a Rust document core, native iOS and Android rendering, configurable schemas, and a React-facing toolbar and theme API.
4
4
 
5
- This project is currently in alpha and the API, behavior, and packaging may still change.
5
+ This project is currently in `alpha` and the API, behavior, and packaging may still change.
6
6
 
7
7
  <p align="center">
8
- <img src="./docs/images/example-1.PNG" alt="Example editor screen" width="45%" />
9
- <img src="./docs/images/example-2.PNG" alt="Example editor theme screen" width="45%" />
8
+ <img src="./docs/images/example-ios.png" alt="Example editor Android" width="45%" align="top" />
9
+ <img src="./docs/images/example-android.png" alt="Example editor iOS" width="45%" align="top" />
10
10
  </p>
11
11
 
12
12
  This repository contains three main pieces:
@@ -21,7 +21,8 @@ The editor already supports:
21
21
 
22
22
  - HTML and ProseMirror JSON content input/output
23
23
  - configurable schemas
24
- - marks such as bold, italic, underline, and strike
24
+ - marks such as bold, italic, underline, strike, and links
25
+ - blockquotes
25
26
  - bullet and ordered lists with indent/outdent behavior
26
27
  - hard breaks and horizontal rules
27
28
  - native @-mentions with themed suggestion UI in the toolbar area
@@ -99,14 +100,17 @@ export function EditorScreen() {
99
100
  The main extension points today are:
100
101
 
101
102
  - `schema`: provide a custom schema definition
102
- - `theme`: style text blocks, lists, horizontal rules, background, and toolbar chrome
103
+ - `theme`: style text blocks, blockquotes, lists, horizontal rules, background, and toolbar chrome, including a native-looking keyboard toolbar mode
103
104
  - `toolbarItems`: define the visible toolbar controls and order
104
105
  - `onToolbarAction`: handle app-defined toolbar buttons
106
+ - `onRequestLink`: collect or edit hyperlink URLs when a toolbar link item is pressed
105
107
  - `addons`: configure optional features like @-mentions
106
108
  - `heightBehavior`: switch between internal scrolling and auto-grow
107
109
 
108
110
  For setup and customization details, start with the [Documentation Index](./docs/README.md).
109
111
 
112
+ For realtime collaboration, including the correct `useYjsCollaboration()` wiring, encoded-state persistence, remote cursors, and automatic reconnect behavior, see the [Collaboration Guide](./docs/modules/collaboration.md).
113
+
110
114
  ## Development
111
115
 
112
116
  Common commands:
@@ -132,8 +136,9 @@ npm run android:test # Android Robolectric test
132
136
  - [Documentation Index](./docs/README.md): main documentation index
133
137
  - [Installation Guide](./docs/guides/installation.md): installation and local setup
134
138
  - [Getting Started](./docs/guides/getting-started.md): first setup and first editor
139
+ - [Collaboration Guide](./docs/modules/collaboration.md): Yjs collaboration wiring, source-of-truth rules, and persistence
135
140
  - [Toolbar Setup](./docs/guides/toolbar-setup.md): toolbar setup patterns and examples
136
- - [Mentions Guide](./docs/guides/mentions.md): @-mentions addon setup and configuration
141
+ - [Mentions Guide](./docs/modules/mentions.md): @-mentions addon setup and configuration
137
142
  - [Styling Guide](./docs/guides/styling.md): content, toolbar, and mention styling
138
143
  - [NativeRichTextEditor Reference](./docs/reference/native-rich-text-editor.md): component props and ref methods
139
144
  - [Design Decisions](./docs/explanations/design-decisions.md): rationale for key API and architecture decisions
@@ -1,10 +1,14 @@
1
+ import groovy.json.JsonSlurper
2
+
1
3
  plugins {
2
4
  id 'com.android.library'
3
5
  id 'expo-module-gradle-plugin'
4
6
  }
5
7
 
8
+ def packageJson = new JsonSlurper().parse(file('../package.json'))
9
+
6
10
  group = 'com.apollohg'
7
- version = '0.1.1'
11
+ version = packageJson.version
8
12
 
9
13
  android {
10
14
  namespace "com.apollohg.editor"
@@ -13,7 +17,7 @@ android {
13
17
  }
14
18
  defaultConfig {
15
19
  versionCode 1
16
- versionName '0.1.1'
20
+ versionName packageJson.version
17
21
  }
18
22
 
19
23
  // Include prebuilt Rust .so files from the package's rust/android/ directory
@@ -32,6 +36,7 @@ android {
32
36
 
33
37
  dependencies {
34
38
  implementation "androidx.appcompat:appcompat:1.7.0"
39
+ implementation "com.google.android.material:material:1.12.0"
35
40
  implementation "net.java.dev.jna:jna:5.14.0@aar"
36
41
 
37
42
  testImplementation "junit:junit:4.13.2"
@@ -4,8 +4,10 @@ import android.content.ClipboardManager
4
4
  import android.content.Context
5
5
  import android.graphics.Typeface
6
6
  import android.graphics.Rect
7
+ import android.graphics.RectF
7
8
  import android.text.Editable
8
9
  import android.text.Layout
10
+ import android.text.Spanned
9
11
  import android.text.StaticLayout
10
12
  import android.text.TextWatcher
11
13
  import android.util.AttributeSet
@@ -16,6 +18,7 @@ import android.view.MotionEvent
16
18
  import android.view.inputmethod.EditorInfo
17
19
  import android.view.inputmethod.InputConnection
18
20
  import androidx.appcompat.widget.AppCompatEditText
21
+ import kotlin.math.roundToInt
19
22
  import uniffi.editor_core.* // UniFFI-generated bindings
20
23
 
21
24
  /**
@@ -48,6 +51,16 @@ class EditorEditText @JvmOverloads constructor(
48
51
  attrs: AttributeSet? = null,
49
52
  defStyleAttr: Int = android.R.attr.editTextStyle
50
53
  ) : AppCompatEditText(context, attrs, defStyleAttr) {
54
+ data class SelectedImageGeometry(
55
+ val docPos: Int,
56
+ val rect: RectF
57
+ )
58
+
59
+ private data class ImageSelectionRange(
60
+ val start: Int,
61
+ val end: Int
62
+ )
63
+
51
64
  /**
52
65
  * Listener interface for editor events, parallel to iOS's EditorTextViewDelegate.
53
66
  */
@@ -79,6 +92,7 @@ class EditorEditText @JvmOverloads constructor(
79
92
 
80
93
  /** Listener for editor events. */
81
94
  var editorListener: EditorListener? = null
95
+ var onSelectionOrContentMayChange: (() -> Unit)? = null
82
96
 
83
97
  /** The base font size in pixels used for unstyled text. */
84
98
  private var baseFontSize: Float = textSize
@@ -93,8 +107,15 @@ class EditorEditText @JvmOverloads constructor(
93
107
  var theme: EditorTheme? = null
94
108
  private set
95
109
 
110
+ var placeholderText: String = ""
111
+ set(value) {
112
+ field = value
113
+ invalidate()
114
+ }
115
+
96
116
  var heightBehavior: EditorHeightBehavior = EditorHeightBehavior.FIXED
97
117
  private set
118
+ private var imageResizingEnabled = true
98
119
 
99
120
  private var contentInsets: EditorContentInsets? = null
100
121
  private var viewportBottomInsetPx: Int = 0
@@ -114,6 +135,7 @@ class EditorEditText @JvmOverloads constructor(
114
135
 
115
136
  private var lastHandledHardwareKeyCode: Int? = null
116
137
  private var lastHandledHardwareKeyDownTime: Long? = null
138
+ private var explicitSelectedImageRange: ImageSelectionRange? = null
117
139
 
118
140
  init {
119
141
  // Configure for rich text editing.
@@ -139,6 +161,7 @@ class EditorEditText @JvmOverloads constructor(
139
161
  // Strip the default EditText theme drawable which carries implicit padding.
140
162
  // Background color is applied in setBaseStyle() / applyTheme().
141
163
  background = null
164
+ linksClickable = false
142
165
  updateEffectivePadding()
143
166
  }
144
167
 
@@ -160,7 +183,35 @@ class EditorEditText @JvmOverloads constructor(
160
183
  return super.dispatchKeyEvent(event)
161
184
  }
162
185
 
186
+ override fun onDraw(canvas: android.graphics.Canvas) {
187
+ super.onDraw(canvas)
188
+
189
+ if (!shouldDisplayPlaceholder()) return
190
+
191
+ val availableWidth = width - compoundPaddingLeft - compoundPaddingRight
192
+ if (availableWidth <= 0) return
193
+
194
+ val previousColor = paint.color
195
+ val saveCount = canvas.save()
196
+ paint.color = currentHintTextColor
197
+ canvas.translate(compoundPaddingLeft.toFloat(), extendedPaddingTop.toFloat())
198
+ StaticLayout.Builder
199
+ .obtain(placeholderText, 0, placeholderText.length, paint, availableWidth)
200
+ .setAlignment(Layout.Alignment.ALIGN_NORMAL)
201
+ .setIncludePad(includeFontPadding)
202
+ .build()
203
+ .draw(canvas)
204
+ canvas.restoreToCount(saveCount)
205
+ paint.color = previousColor
206
+ }
207
+
163
208
  override fun onTouchEvent(event: MotionEvent): Boolean {
209
+ if (event.actionMasked == MotionEvent.ACTION_DOWN && imageSpanHitAt(event.x, event.y) == null) {
210
+ clearExplicitSelectedImageRange()
211
+ }
212
+ if (handleImageTap(event)) {
213
+ return true
214
+ }
164
215
  if (heightBehavior == EditorHeightBehavior.FIXED) {
165
216
  val canScroll = canScrollVertically(-1) || canScrollVertically(1)
166
217
  if (canScroll) {
@@ -175,6 +226,30 @@ class EditorEditText @JvmOverloads constructor(
175
226
  return super.onTouchEvent(event)
176
227
  }
177
228
 
229
+ override fun performClick(): Boolean {
230
+ return super.performClick()
231
+ }
232
+
233
+ private fun isRenderedContentEmpty(content: CharSequence? = text): Boolean {
234
+ val renderedContent = content ?: return true
235
+ if (renderedContent.isEmpty()) return true
236
+
237
+ for (index in 0 until renderedContent.length) {
238
+ when (renderedContent[index]) {
239
+ EMPTY_BLOCK_PLACEHOLDER, '\n', '\r' -> continue
240
+ else -> return false
241
+ }
242
+ }
243
+
244
+ return true
245
+ }
246
+
247
+ private fun shouldDisplayPlaceholder(): Boolean {
248
+ return placeholderText.isNotEmpty() && isRenderedContentEmpty()
249
+ }
250
+
251
+ fun shouldDisplayPlaceholderForTesting(): Boolean = shouldDisplayPlaceholder()
252
+
178
253
  // ── Editor Binding ──────────────────────────────────────────────────
179
254
 
180
255
  /**
@@ -272,6 +347,16 @@ class EditorEditText @JvmOverloads constructor(
272
347
  }
273
348
  }
274
349
 
350
+ fun setImageResizingEnabled(enabled: Boolean) {
351
+ if (imageResizingEnabled == enabled) return
352
+ imageResizingEnabled = enabled
353
+ if (!enabled) {
354
+ clearExplicitSelectedImageRange()
355
+ } else {
356
+ onSelectionOrContentMayChange?.invoke()
357
+ }
358
+ }
359
+
275
360
  fun resolveAutoGrowHeight(): Int {
276
361
  val laidOutTextHeight = if (isLaidOut) layout?.height else null
277
362
  if (laidOutTextHeight != null && laidOutTextHeight > 0) {
@@ -669,6 +754,17 @@ class EditorEditText @JvmOverloads constructor(
669
754
  applyUpdateJSON(updateJSON)
670
755
  }
671
756
 
757
+ fun performToolbarToggleBlockquote() {
758
+ if (!isEditable || isApplyingRustState || editorId == 0L) return
759
+ val selection = currentScalarSelection() ?: return
760
+ val updateJSON = editorToggleBlockquoteAtSelectionScalar(
761
+ editorId.toULong(),
762
+ selection.first.toUInt(),
763
+ selection.second.toUInt()
764
+ )
765
+ applyUpdateJSON(updateJSON)
766
+ }
767
+
672
768
  fun performToolbarIndentListItem() {
673
769
  if (!isEditable || isApplyingRustState || editorId == 0L) return
674
770
  val selection = currentScalarSelection() ?: return
@@ -780,7 +876,12 @@ class EditorEditText @JvmOverloads constructor(
780
876
  */
781
877
  override fun onSelectionChanged(selStart: Int, selEnd: Int) {
782
878
  super.onSelectionChanged(selStart, selEnd)
879
+ val spannable = text as? Spanned
880
+ if (spannable != null && isExactImageSpanRange(spannable, selStart, selEnd)) {
881
+ explicitSelectedImageRange = ImageSelectionRange(selStart, selEnd)
882
+ }
783
883
  ensureSelectionVisible()
884
+ onSelectionOrContentMayChange?.invoke()
784
885
 
785
886
  if (isApplyingRustState || editorId == 0L) return
786
887
 
@@ -840,6 +941,49 @@ class EditorEditText @JvmOverloads constructor(
840
941
  )
841
942
  }
842
943
 
944
+ fun selectedImageGeometry(): SelectedImageGeometry? {
945
+ if (!imageResizingEnabled) return null
946
+ val spannable = text as? Spanned ?: return null
947
+ val selection = resolvedSelectedImageRange(spannable) ?: return null
948
+ val start = selection.start
949
+ val end = selection.end
950
+ val imageSpan = spannable
951
+ .getSpans(start, end, BlockImageSpan::class.java)
952
+ .firstOrNull() ?: return null
953
+ val spanStart = spannable.getSpanStart(imageSpan)
954
+ val spanEnd = spannable.getSpanEnd(imageSpan)
955
+ if (spanStart != start || spanEnd != end) return null
956
+
957
+ val textLayout = layout ?: return null
958
+ val currentText = text?.toString() ?: return null
959
+ val scalarPos = PositionBridge.utf16ToScalar(spanStart, currentText)
960
+ val docPos = if (editorId != 0L) {
961
+ editorScalarToDoc(editorId.toULong(), scalarPos.toUInt()).toInt()
962
+ } else {
963
+ 0
964
+ }
965
+ val line = textLayout.getLineForOffset(spanStart.coerceAtMost(maxOf(spannable.length - 1, 0)))
966
+ val rect = resolvedImageRect(textLayout, imageSpan, spanStart, spanEnd)
967
+ return SelectedImageGeometry(
968
+ docPos = docPos,
969
+ rect = rect
970
+ )
971
+ }
972
+
973
+ fun resizeImageAtDocPos(docPos: Int, widthPx: Float, heightPx: Float) {
974
+ if (editorId == 0L) return
975
+ val density = resources.displayMetrics.density
976
+ val widthDp = maxOf(48, (widthPx / density).roundToInt())
977
+ val heightDp = maxOf(48, (heightPx / density).roundToInt())
978
+ val updateJSON = editorResizeImageAtDocPos(
979
+ editorId.toULong(),
980
+ docPos.toUInt(),
981
+ widthDp.toUInt(),
982
+ heightDp.toUInt()
983
+ )
984
+ applyUpdateJSON(updateJSON)
985
+ }
986
+
843
987
  private fun isSelectionInsideList(): Boolean {
844
988
  if (editorId == 0L) return false
845
989
 
@@ -894,12 +1038,14 @@ class EditorEditText @JvmOverloads constructor(
894
1038
  baseFontSize,
895
1039
  baseTextColor,
896
1040
  theme,
897
- resources.displayMetrics.density
1041
+ resources.displayMetrics.density,
1042
+ this
898
1043
  )
899
1044
 
900
1045
  val previousScrollX = scrollX
901
1046
  val previousScrollY = scrollY
902
1047
 
1048
+ explicitSelectedImageRange = null
903
1049
  isApplyingRustState = true
904
1050
  setText(spannable)
905
1051
  lastAuthorizedText = spannable.toString()
@@ -914,6 +1060,7 @@ class EditorEditText @JvmOverloads constructor(
914
1060
  if (notifyListener) {
915
1061
  editorListener?.onEditorUpdate(updateJSON)
916
1062
  }
1063
+ onSelectionOrContentMayChange?.invoke()
917
1064
  if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
918
1065
  requestLayout()
919
1066
  } else {
@@ -935,16 +1082,19 @@ class EditorEditText @JvmOverloads constructor(
935
1082
  baseFontSize,
936
1083
  baseTextColor,
937
1084
  theme,
938
- resources.displayMetrics.density
1085
+ resources.displayMetrics.density,
1086
+ this
939
1087
  )
940
1088
 
941
1089
  val previousScrollX = scrollX
942
1090
  val previousScrollY = scrollY
943
1091
 
1092
+ explicitSelectedImageRange = null
944
1093
  isApplyingRustState = true
945
1094
  setText(spannable)
946
1095
  lastAuthorizedText = spannable.toString()
947
1096
  isApplyingRustState = false
1097
+ onSelectionOrContentMayChange?.invoke()
948
1098
  if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
949
1099
  requestLayout()
950
1100
  } else {
@@ -952,6 +1102,142 @@ class EditorEditText @JvmOverloads constructor(
952
1102
  }
953
1103
  }
954
1104
 
1105
+ private fun handleImageTap(event: MotionEvent): Boolean {
1106
+ if (!imageResizingEnabled) {
1107
+ return false
1108
+ }
1109
+ if (event.actionMasked != MotionEvent.ACTION_DOWN && event.actionMasked != MotionEvent.ACTION_UP) {
1110
+ return false
1111
+ }
1112
+ val hit = imageSpanHitAt(event.x, event.y) ?: return false
1113
+ requestFocus()
1114
+ selectExplicitImageRange(hit.first, hit.second)
1115
+ if (event.actionMasked == MotionEvent.ACTION_UP) {
1116
+ performClick()
1117
+ }
1118
+ return true
1119
+ }
1120
+
1121
+ private fun imageSpanHitAt(x: Float, y: Float): Pair<Int, Int>? {
1122
+ val spannable = text as? Spanned ?: return null
1123
+ imageSpanRangeNearTouchOffset(spannable, x, y)?.let { return it }
1124
+ val textLayout = layout ?: return null
1125
+ return imageSpanRectHit(spannable, textLayout, x, y)
1126
+ }
1127
+
1128
+ private fun imageSpanRectHit(
1129
+ spannable: Spanned,
1130
+ textLayout: Layout,
1131
+ x: Float,
1132
+ y: Float
1133
+ ): Pair<Int, Int>? {
1134
+ val candidateSpans = spannable.getSpans(0, spannable.length, BlockImageSpan::class.java)
1135
+ for (span in candidateSpans) {
1136
+ val spanStart = spannable.getSpanStart(span)
1137
+ val spanEnd = spannable.getSpanEnd(span)
1138
+ if (spanStart < 0 || spanEnd <= spanStart) continue
1139
+ val rect = resolvedImageRect(textLayout, span, spanStart, spanEnd)
1140
+ if (rect.contains(x, y)) {
1141
+ return spanStart to spanEnd
1142
+ }
1143
+ }
1144
+ return null
1145
+ }
1146
+
1147
+ override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
1148
+ super.onFocusChanged(focused, direction, previouslyFocusedRect)
1149
+ if (!focused) {
1150
+ clearExplicitSelectedImageRange()
1151
+ }
1152
+ }
1153
+
1154
+ private fun selectExplicitImageRange(start: Int, end: Int) {
1155
+ explicitSelectedImageRange = ImageSelectionRange(start, end)
1156
+ if (selectionStart == start && selectionEnd == end) {
1157
+ onSelectionOrContentMayChange?.invoke()
1158
+ return
1159
+ }
1160
+ setSelection(start, end)
1161
+ }
1162
+
1163
+ private fun clearExplicitSelectedImageRange() {
1164
+ if (explicitSelectedImageRange == null) return
1165
+ explicitSelectedImageRange = null
1166
+ onSelectionOrContentMayChange?.invoke()
1167
+ }
1168
+
1169
+ private fun resolvedSelectedImageRange(spannable: Spanned): ImageSelectionRange? {
1170
+ explicitSelectedImageRange?.let { explicit ->
1171
+ if (isExactImageSpanRange(spannable, explicit.start, explicit.end)) {
1172
+ return explicit
1173
+ }
1174
+ explicitSelectedImageRange = null
1175
+ }
1176
+
1177
+ val start = selectionStart
1178
+ val end = selectionEnd
1179
+ if (!isExactImageSpanRange(spannable, start, end)) return null
1180
+ return ImageSelectionRange(start, end)
1181
+ }
1182
+
1183
+ private fun isExactImageSpanRange(spannable: Spanned, start: Int, end: Int): Boolean {
1184
+ if (start < 0 || end != start + 1) return false
1185
+ val imageSpan = spannable
1186
+ .getSpans(start, end, BlockImageSpan::class.java)
1187
+ .firstOrNull() ?: return false
1188
+ return spannable.getSpanStart(imageSpan) == start && spannable.getSpanEnd(imageSpan) == end
1189
+ }
1190
+
1191
+ private fun imageSpanRangeNearTouchOffset(
1192
+ spannable: Spanned,
1193
+ x: Float,
1194
+ y: Float
1195
+ ): Pair<Int, Int>? {
1196
+ val safeOffset = runCatching { getOffsetForPosition(x, y) }.getOrNull() ?: return null
1197
+ val nearbyOffsets = linkedSetOf(
1198
+ safeOffset,
1199
+ (safeOffset - 1).coerceAtLeast(0),
1200
+ (safeOffset + 1).coerceAtMost(spannable.length)
1201
+ )
1202
+ for (offset in nearbyOffsets) {
1203
+ val searchStart = (offset - 1).coerceAtLeast(0)
1204
+ val searchEnd = (offset + 1).coerceAtMost(spannable.length)
1205
+ val imageSpan = spannable
1206
+ .getSpans(searchStart, searchEnd, BlockImageSpan::class.java)
1207
+ .firstOrNull() ?: continue
1208
+ val spanStart = spannable.getSpanStart(imageSpan)
1209
+ val spanEnd = spannable.getSpanEnd(imageSpan)
1210
+ if (spanStart >= 0 && spanEnd > spanStart) {
1211
+ return spanStart to spanEnd
1212
+ }
1213
+ }
1214
+ return null
1215
+ }
1216
+
1217
+ private fun resolvedImageRect(
1218
+ textLayout: Layout,
1219
+ imageSpan: BlockImageSpan,
1220
+ spanStart: Int,
1221
+ spanEnd: Int
1222
+ ): RectF {
1223
+ imageSpan.currentDrawRect()?.let { drawnRect ->
1224
+ return drawnRect
1225
+ }
1226
+
1227
+ val safeOffset = spanStart.coerceAtMost(maxOf((text?.length ?: 0) - 1, 0))
1228
+ val line = textLayout.getLineForOffset(safeOffset)
1229
+ val startHorizontal = textLayout.getPrimaryHorizontal(spanStart)
1230
+ val endHorizontal = textLayout.getPrimaryHorizontal(spanEnd)
1231
+ val (widthPx, heightPx) = imageSpan.currentSizePx()
1232
+ val left = compoundPaddingLeft + minOf(startHorizontal, endHorizontal)
1233
+ val right = compoundPaddingLeft + maxOf(
1234
+ maxOf(startHorizontal, endHorizontal),
1235
+ minOf(startHorizontal, endHorizontal) + widthPx
1236
+ )
1237
+ val top = extendedPaddingTop + textLayout.getLineBottom(line) - heightPx
1238
+ return RectF(left, top.toFloat(), right, top + heightPx.toFloat())
1239
+ }
1240
+
955
1241
  /**
956
1242
  * Apply a selection from a parsed JSON selection object.
957
1243
  *
@@ -1052,6 +1338,7 @@ class EditorEditText @JvmOverloads constructor(
1052
1338
  }
1053
1339
 
1054
1340
  companion object {
1341
+ private const val EMPTY_BLOCK_PLACEHOLDER = '\u200B'
1055
1342
  private const val LOG_TAG = "NativeEditor"
1056
1343
  }
1057
1344
  }
@@ -89,6 +89,27 @@ data class EditorHorizontalRuleTheme(
89
89
  }
90
90
  }
91
91
 
92
+ data class EditorBlockquoteTheme(
93
+ val text: EditorTextStyle? = null,
94
+ val indent: Float? = null,
95
+ val borderColor: Int? = null,
96
+ val borderWidth: Float? = null,
97
+ val markerGap: Float? = null
98
+ ) {
99
+ companion object {
100
+ fun fromJson(json: JSONObject?): EditorBlockquoteTheme? {
101
+ json ?: return null
102
+ return EditorBlockquoteTheme(
103
+ text = EditorTextStyle.fromJson(json.optJSONObject("text")),
104
+ indent = json.optNullableFloat("indent"),
105
+ borderColor = parseColor(json.optNullableString("borderColor")),
106
+ borderWidth = json.optNullableFloat("borderWidth"),
107
+ markerGap = json.optNullableFloat("markerGap")
108
+ )
109
+ }
110
+ }
111
+ }
112
+
92
113
  data class EditorMentionTheme(
93
114
  val textColor: Int? = null,
94
115
  val backgroundColor: Int? = null,
@@ -130,7 +151,22 @@ data class EditorMentionTheme(
130
151
  }
131
152
  }
132
153
 
154
+ enum class EditorToolbarAppearance {
155
+ CUSTOM,
156
+ NATIVE;
157
+
158
+ companion object {
159
+ fun fromRaw(raw: String?): EditorToolbarAppearance? =
160
+ when (raw?.trim()?.lowercase()) {
161
+ "custom" -> CUSTOM
162
+ "native" -> NATIVE
163
+ else -> null
164
+ }
165
+ }
166
+ }
167
+
133
168
  data class EditorToolbarTheme(
169
+ val appearance: EditorToolbarAppearance? = null,
134
170
  val backgroundColor: Int? = null,
135
171
  val borderColor: Int? = null,
136
172
  val borderWidth: Float? = null,
@@ -144,10 +180,21 @@ data class EditorToolbarTheme(
144
180
  val buttonActiveBackgroundColor: Int? = null,
145
181
  val buttonBorderRadius: Float? = null
146
182
  ) {
183
+ fun resolvedKeyboardOffset(): Float = keyboardOffset ?: if (appearance == EditorToolbarAppearance.NATIVE) 8f else 0f
184
+
185
+ fun resolvedHorizontalInset(): Float = horizontalInset ?: if (appearance == EditorToolbarAppearance.NATIVE) 0f else 0f
186
+
187
+ fun resolvedBorderRadius(): Float = if (appearance == EditorToolbarAppearance.NATIVE) 32f else (borderRadius ?: 0f)
188
+
189
+ fun resolvedBorderWidth(): Float = borderWidth ?: if (appearance == EditorToolbarAppearance.NATIVE) 0f else 1f
190
+
191
+ fun resolvedButtonBorderRadius(): Float = if (appearance == EditorToolbarAppearance.NATIVE) 20f else (buttonBorderRadius ?: 6f)
192
+
147
193
  companion object {
148
194
  fun fromJson(json: JSONObject?): EditorToolbarTheme? {
149
195
  json ?: return null
150
196
  return EditorToolbarTheme(
197
+ appearance = EditorToolbarAppearance.fromRaw(json.optNullableString("appearance")),
151
198
  backgroundColor = parseColor(json.optNullableString("backgroundColor")),
152
199
  borderColor = parseColor(json.optNullableString("borderColor")),
153
200
  borderWidth = json.optNullableFloat("borderWidth"),
@@ -187,6 +234,7 @@ data class EditorContentInsets(
187
234
  data class EditorTheme(
188
235
  val text: EditorTextStyle? = null,
189
236
  val paragraph: EditorTextStyle? = null,
237
+ val blockquote: EditorBlockquoteTheme? = null,
190
238
  val headings: Map<String, EditorTextStyle> = emptyMap(),
191
239
  val list: EditorListTheme? = null,
192
240
  val horizontalRule: EditorHorizontalRuleTheme? = null,
@@ -216,6 +264,7 @@ data class EditorTheme(
216
264
  return EditorTheme(
217
265
  text = EditorTextStyle.fromJson(root.optJSONObject("text")),
218
266
  paragraph = EditorTextStyle.fromJson(root.optJSONObject("paragraph")),
267
+ blockquote = EditorBlockquoteTheme.fromJson(root.optJSONObject("blockquote")),
219
268
  headings = headings,
220
269
  list = EditorListTheme.fromJson(root.optJSONObject("list")),
221
270
  horizontalRule = EditorHorizontalRuleTheme.fromJson(root.optJSONObject("horizontalRule")),
@@ -228,8 +277,9 @@ data class EditorTheme(
228
277
  }
229
278
  }
230
279
 
231
- fun effectiveTextStyle(nodeType: String): EditorTextStyle {
280
+ fun effectiveTextStyle(nodeType: String, inBlockquote: Boolean = false): EditorTextStyle {
232
281
  var style = text ?: EditorTextStyle()
282
+ style = style.mergedWith(if (inBlockquote) blockquote?.text else null)
233
283
  if (nodeType == "paragraph") {
234
284
  style = style.mergedWith(paragraph)
235
285
  if (paragraph?.lineHeight == null) {