@apollohg/react-native-prose-editor 0.2.0 → 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 (32) hide show
  1. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +228 -2
  2. package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
  3. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +4 -0
  4. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +3 -0
  5. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +6 -0
  6. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +347 -10
  7. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +76 -8
  8. package/dist/EditorToolbar.d.ts +9 -2
  9. package/dist/EditorToolbar.js +20 -10
  10. package/dist/NativeEditorBridge.d.ts +2 -0
  11. package/dist/NativeEditorBridge.js +3 -0
  12. package/dist/NativeRichTextEditor.d.ts +17 -1
  13. package/dist/NativeRichTextEditor.js +94 -37
  14. package/dist/index.d.ts +2 -2
  15. package/dist/index.js +5 -1
  16. package/dist/schemas.d.ts +12 -0
  17. package/dist/schemas.js +45 -1
  18. package/ios/EditorCore.xcframework/Info.plist +5 -5
  19. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  20. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  21. package/ios/EditorLayoutManager.swift +0 -16
  22. package/ios/Generated_editor_core.swift +20 -2
  23. package/ios/NativeEditorExpoView.swift +51 -16
  24. package/ios/NativeEditorModule.swift +3 -0
  25. package/ios/RenderBridge.swift +208 -0
  26. package/ios/RichTextEditorView.swift +896 -15
  27. package/ios/editor_coreFFI/editor_coreFFI.h +11 -0
  28. package/package.json +1 -1
  29. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  30. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  31. package/rust/android/x86_64/libeditor_core.so +0 -0
  32. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +25 -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
@@ -101,6 +115,7 @@ class EditorEditText @JvmOverloads constructor(
101
115
 
102
116
  var heightBehavior: EditorHeightBehavior = EditorHeightBehavior.FIXED
103
117
  private set
118
+ private var imageResizingEnabled = true
104
119
 
105
120
  private var contentInsets: EditorContentInsets? = null
106
121
  private var viewportBottomInsetPx: Int = 0
@@ -120,6 +135,7 @@ class EditorEditText @JvmOverloads constructor(
120
135
 
121
136
  private var lastHandledHardwareKeyCode: Int? = null
122
137
  private var lastHandledHardwareKeyDownTime: Long? = null
138
+ private var explicitSelectedImageRange: ImageSelectionRange? = null
123
139
 
124
140
  init {
125
141
  // Configure for rich text editing.
@@ -190,6 +206,12 @@ class EditorEditText @JvmOverloads constructor(
190
206
  }
191
207
 
192
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
+ }
193
215
  if (heightBehavior == EditorHeightBehavior.FIXED) {
194
216
  val canScroll = canScrollVertically(-1) || canScrollVertically(1)
195
217
  if (canScroll) {
@@ -204,6 +226,10 @@ class EditorEditText @JvmOverloads constructor(
204
226
  return super.onTouchEvent(event)
205
227
  }
206
228
 
229
+ override fun performClick(): Boolean {
230
+ return super.performClick()
231
+ }
232
+
207
233
  private fun isRenderedContentEmpty(content: CharSequence? = text): Boolean {
208
234
  val renderedContent = content ?: return true
209
235
  if (renderedContent.isEmpty()) return true
@@ -321,6 +347,16 @@ class EditorEditText @JvmOverloads constructor(
321
347
  }
322
348
  }
323
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
+
324
360
  fun resolveAutoGrowHeight(): Int {
325
361
  val laidOutTextHeight = if (isLaidOut) layout?.height else null
326
362
  if (laidOutTextHeight != null && laidOutTextHeight > 0) {
@@ -840,7 +876,12 @@ class EditorEditText @JvmOverloads constructor(
840
876
  */
841
877
  override fun onSelectionChanged(selStart: Int, selEnd: Int) {
842
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
+ }
843
883
  ensureSelectionVisible()
884
+ onSelectionOrContentMayChange?.invoke()
844
885
 
845
886
  if (isApplyingRustState || editorId == 0L) return
846
887
 
@@ -900,6 +941,49 @@ class EditorEditText @JvmOverloads constructor(
900
941
  )
901
942
  }
902
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
+
903
987
  private fun isSelectionInsideList(): Boolean {
904
988
  if (editorId == 0L) return false
905
989
 
@@ -954,12 +1038,14 @@ class EditorEditText @JvmOverloads constructor(
954
1038
  baseFontSize,
955
1039
  baseTextColor,
956
1040
  theme,
957
- resources.displayMetrics.density
1041
+ resources.displayMetrics.density,
1042
+ this
958
1043
  )
959
1044
 
960
1045
  val previousScrollX = scrollX
961
1046
  val previousScrollY = scrollY
962
1047
 
1048
+ explicitSelectedImageRange = null
963
1049
  isApplyingRustState = true
964
1050
  setText(spannable)
965
1051
  lastAuthorizedText = spannable.toString()
@@ -974,6 +1060,7 @@ class EditorEditText @JvmOverloads constructor(
974
1060
  if (notifyListener) {
975
1061
  editorListener?.onEditorUpdate(updateJSON)
976
1062
  }
1063
+ onSelectionOrContentMayChange?.invoke()
977
1064
  if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
978
1065
  requestLayout()
979
1066
  } else {
@@ -995,16 +1082,19 @@ class EditorEditText @JvmOverloads constructor(
995
1082
  baseFontSize,
996
1083
  baseTextColor,
997
1084
  theme,
998
- resources.displayMetrics.density
1085
+ resources.displayMetrics.density,
1086
+ this
999
1087
  )
1000
1088
 
1001
1089
  val previousScrollX = scrollX
1002
1090
  val previousScrollY = scrollY
1003
1091
 
1092
+ explicitSelectedImageRange = null
1004
1093
  isApplyingRustState = true
1005
1094
  setText(spannable)
1006
1095
  lastAuthorizedText = spannable.toString()
1007
1096
  isApplyingRustState = false
1097
+ onSelectionOrContentMayChange?.invoke()
1008
1098
  if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
1009
1099
  requestLayout()
1010
1100
  } else {
@@ -1012,6 +1102,142 @@ class EditorEditText @JvmOverloads constructor(
1012
1102
  }
1013
1103
  }
1014
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
+
1015
1241
  /**
1016
1242
  * Apply a selection from a parsed JSON selection object.
1017
1243
  *
@@ -0,0 +1,199 @@
1
+ package com.apollohg.editor
2
+
3
+ import android.content.Context
4
+ import android.graphics.Canvas
5
+ import android.graphics.Color
6
+ import android.graphics.Paint
7
+ import android.graphics.RectF
8
+ import android.util.AttributeSet
9
+ import android.view.MotionEvent
10
+ import android.view.View
11
+ import kotlin.math.max
12
+
13
+ internal class ImageResizeOverlayView @JvmOverloads constructor(
14
+ context: Context,
15
+ attrs: AttributeSet? = null,
16
+ defStyleAttr: Int = 0
17
+ ) : View(context, attrs, defStyleAttr) {
18
+ private enum class Corner {
19
+ TOP_LEFT,
20
+ TOP_RIGHT,
21
+ BOTTOM_LEFT,
22
+ BOTTOM_RIGHT
23
+ }
24
+
25
+ private data class DragState(
26
+ val corner: Corner,
27
+ val originalRect: RectF,
28
+ val docPos: Int,
29
+ val maximumWidthPx: Float,
30
+ var previewRect: RectF
31
+ )
32
+
33
+ private var editorView: RichTextEditorView? = null
34
+ private var currentGeometry: EditorEditText.SelectedImageGeometry? = null
35
+ private var dragState: DragState? = null
36
+
37
+ private val density = resources.displayMetrics.density
38
+ private val handleRadiusPx = 10f * density
39
+ private val minimumImageSizePx = 48f * density
40
+ private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
41
+ color = Color.parseColor("#0A84FF")
42
+ style = Paint.Style.STROKE
43
+ strokeWidth = max(2f, density)
44
+ }
45
+ private val handleFillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
46
+ color = Color.WHITE
47
+ style = Paint.Style.FILL
48
+ }
49
+ private val handleStrokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
50
+ color = Color.parseColor("#0A84FF")
51
+ style = Paint.Style.STROKE
52
+ strokeWidth = max(2f, density)
53
+ }
54
+
55
+ init {
56
+ setWillNotDraw(false)
57
+ visibility = INVISIBLE
58
+ }
59
+
60
+ fun bind(editorView: RichTextEditorView) {
61
+ this.editorView = editorView
62
+ }
63
+
64
+ fun refresh() {
65
+ currentGeometry = editorView?.selectedImageGeometry()
66
+ visibility = if (currentGeometry == null) INVISIBLE else VISIBLE
67
+ if (currentGeometry != null) {
68
+ bringToFront()
69
+ }
70
+ invalidate()
71
+ }
72
+
73
+ fun visibleRectForTesting(): RectF? =
74
+ currentGeometry?.rect?.let(::RectF)
75
+
76
+ fun simulateResizeForTesting(widthPx: Float, heightPx: Float) {
77
+ val geometry = currentGeometry ?: return
78
+ editorView?.resizeImage(geometry.docPos, widthPx, heightPx)
79
+ }
80
+
81
+ override fun onDraw(canvas: Canvas) {
82
+ super.onDraw(canvas)
83
+ val geometry = currentGeometry ?: return
84
+ canvas.drawRoundRect(geometry.rect, 8f * density, 8f * density, borderPaint)
85
+ for (corner in Corner.entries) {
86
+ val center = handleCenter(corner, geometry.rect)
87
+ canvas.drawCircle(center.x, center.y, handleRadiusPx, handleFillPaint)
88
+ canvas.drawCircle(center.x, center.y, handleRadiusPx, handleStrokePaint)
89
+ }
90
+ }
91
+
92
+ override fun onTouchEvent(event: MotionEvent): Boolean {
93
+ val geometry = currentGeometry ?: return false
94
+
95
+ when (event.actionMasked) {
96
+ MotionEvent.ACTION_DOWN -> {
97
+ val corner = cornerAt(event.x, event.y, geometry.rect) ?: return false
98
+ dragState = DragState(
99
+ corner = corner,
100
+ originalRect = RectF(geometry.rect),
101
+ docPos = geometry.docPos,
102
+ maximumWidthPx = editorView?.maximumImageWidthPx() ?: geometry.rect.width(),
103
+ previewRect = RectF(geometry.rect)
104
+ )
105
+ parent?.requestDisallowInterceptTouchEvent(true)
106
+ return true
107
+ }
108
+
109
+ MotionEvent.ACTION_MOVE -> {
110
+ val state = dragState ?: return false
111
+ val nextRect = resizedRect(
112
+ originalRect = state.originalRect,
113
+ corner = state.corner,
114
+ deltaX = event.x - handleCenter(state.corner, state.originalRect).x,
115
+ deltaY = event.y - handleCenter(state.corner, state.originalRect).y,
116
+ maximumWidthPx = state.maximumWidthPx
117
+ )
118
+ state.previewRect = RectF(nextRect)
119
+ currentGeometry = EditorEditText.SelectedImageGeometry(state.docPos, nextRect)
120
+ invalidate()
121
+ return true
122
+ }
123
+
124
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
125
+ val state = dragState ?: return false
126
+ if (event.actionMasked == MotionEvent.ACTION_UP) {
127
+ editorView?.resizeImage(
128
+ state.docPos,
129
+ state.previewRect.width(),
130
+ state.previewRect.height()
131
+ )
132
+ }
133
+ dragState = null
134
+ parent?.requestDisallowInterceptTouchEvent(false)
135
+ post { refresh() }
136
+ return true
137
+ }
138
+ }
139
+
140
+ return false
141
+ }
142
+
143
+ private fun cornerAt(x: Float, y: Float, rect: RectF): Corner? {
144
+ return Corner.entries.firstOrNull { corner ->
145
+ val center = handleCenter(corner, rect)
146
+ val dx = x - center.x
147
+ val dy = y - center.y
148
+ (dx * dx) + (dy * dy) <= handleRadiusPx * handleRadiusPx * 2
149
+ }
150
+ }
151
+
152
+ private fun handleCenter(corner: Corner, rect: RectF) = when (corner) {
153
+ Corner.TOP_LEFT -> android.graphics.PointF(rect.left, rect.top)
154
+ Corner.TOP_RIGHT -> android.graphics.PointF(rect.right, rect.top)
155
+ Corner.BOTTOM_LEFT -> android.graphics.PointF(rect.left, rect.bottom)
156
+ Corner.BOTTOM_RIGHT -> android.graphics.PointF(rect.right, rect.bottom)
157
+ }
158
+
159
+ private fun anchorPoint(corner: Corner, rect: RectF) = when (corner) {
160
+ Corner.TOP_LEFT -> android.graphics.PointF(rect.right, rect.bottom)
161
+ Corner.TOP_RIGHT -> android.graphics.PointF(rect.left, rect.bottom)
162
+ Corner.BOTTOM_LEFT -> android.graphics.PointF(rect.right, rect.top)
163
+ Corner.BOTTOM_RIGHT -> android.graphics.PointF(rect.left, rect.top)
164
+ }
165
+
166
+ private fun resizedRect(
167
+ originalRect: RectF,
168
+ corner: Corner,
169
+ deltaX: Float,
170
+ deltaY: Float,
171
+ maximumWidthPx: Float?
172
+ ): RectF {
173
+ val aspectRatio = max(originalRect.width() / max(originalRect.height(), 1f), 0.1f)
174
+ val signedDx = if (corner == Corner.TOP_RIGHT || corner == Corner.BOTTOM_RIGHT) deltaX else -deltaX
175
+ val signedDy = if (corner == Corner.BOTTOM_LEFT || corner == Corner.BOTTOM_RIGHT) deltaY else -deltaY
176
+ val widthScale = (originalRect.width() + signedDx) / max(originalRect.width(), 1f)
177
+ val heightScale = (originalRect.height() + signedDy) / max(originalRect.height(), 1f)
178
+ val scale = max(max(widthScale, heightScale), minimumImageSizePx / max(originalRect.width(), 1f))
179
+ val unclampedWidth = max(minimumImageSizePx, originalRect.width() * scale)
180
+ val unclampedHeight = max(minimumImageSizePx / aspectRatio, unclampedWidth / aspectRatio)
181
+ val (width, height) = editorView?.let { boundEditor ->
182
+ maximumWidthPx?.let { maxWidth ->
183
+ boundEditor.clampImageSize(
184
+ widthPx = unclampedWidth,
185
+ heightPx = unclampedHeight,
186
+ maximumWidthPx = maxWidth
187
+ )
188
+ } ?: boundEditor.clampImageSize(unclampedWidth, unclampedHeight)
189
+ } ?: (unclampedWidth to unclampedHeight)
190
+ val anchor = anchorPoint(corner, originalRect)
191
+
192
+ return when (corner) {
193
+ Corner.TOP_LEFT -> RectF(anchor.x - width, anchor.y - height, anchor.x, anchor.y)
194
+ Corner.TOP_RIGHT -> RectF(anchor.x, anchor.y - height, anchor.x + width, anchor.y)
195
+ Corner.BOTTOM_LEFT -> RectF(anchor.x - width, anchor.y, anchor.x, anchor.y + height)
196
+ Corner.BOTTOM_RIGHT -> RectF(anchor.x, anchor.y, anchor.x + width, anchor.y + height)
197
+ }
198
+ }
199
+ }
@@ -170,6 +170,10 @@ class NativeEditorExpoView(
170
170
  updateKeyboardToolbarVisibility()
171
171
  }
172
172
 
173
+ fun setAllowImageResizing(allowImageResizing: Boolean) {
174
+ richTextView.setImageResizingEnabled(allowImageResizing)
175
+ }
176
+
173
177
  fun setToolbarItemsJson(toolbarItemsJson: String?) {
174
178
  keyboardToolbarView.setItems(NativeToolbarItem.fromJson(toolbarItemsJson))
175
179
  }
@@ -294,6 +294,9 @@ class NativeEditorModule : Module() {
294
294
  Prop("heightBehavior") { view: NativeEditorExpoView, heightBehavior: String ->
295
295
  view.setHeightBehavior(heightBehavior)
296
296
  }
297
+ Prop("allowImageResizing") { view: NativeEditorExpoView, allowImageResizing: Boolean ->
298
+ view.setAllowImageResizing(allowImageResizing)
299
+ }
297
300
  Prop("themeJson") { view: NativeEditorExpoView, themeJson: String? ->
298
301
  view.setThemeJson(themeJson)
299
302
  }
@@ -8,6 +8,7 @@ import android.graphics.drawable.GradientDrawable
8
8
  import android.util.TypedValue
9
9
  import android.view.Gravity
10
10
  import android.view.View
11
+ import android.view.ViewOutlineProvider
11
12
  import android.widget.HorizontalScrollView
12
13
  import android.widget.LinearLayout
13
14
  import androidx.appcompat.widget.AppCompatButton
@@ -98,6 +99,7 @@ internal enum class ToolbarDefaultIconId {
98
99
  underline,
99
100
  strike,
100
101
  link,
102
+ image,
101
103
  blockquote,
102
104
  bulletList,
103
105
  orderedList,
@@ -132,6 +134,7 @@ internal data class NativeToolbarIcon(
132
134
  ToolbarDefaultIconId.underline to "U",
133
135
  ToolbarDefaultIconId.strike to "S",
134
136
  ToolbarDefaultIconId.link to "🔗",
137
+ ToolbarDefaultIconId.image to "🖼",
135
138
  ToolbarDefaultIconId.blockquote to "❝",
136
139
  ToolbarDefaultIconId.bulletList to "•≡",
137
140
  ToolbarDefaultIconId.orderedList to "1.",
@@ -148,6 +151,7 @@ internal data class NativeToolbarIcon(
148
151
  ToolbarDefaultIconId.underline to "format-underlined",
149
152
  ToolbarDefaultIconId.strike to "strikethrough-s",
150
153
  ToolbarDefaultIconId.link to "link",
154
+ ToolbarDefaultIconId.image to "image",
151
155
  ToolbarDefaultIconId.blockquote to "format-quote",
152
156
  ToolbarDefaultIconId.bulletList to "format-list-bulleted",
153
157
  ToolbarDefaultIconId.orderedList to "format-list-numbered",
@@ -638,6 +642,8 @@ internal class EditorKeyboardToolbarView(context: Context) : HorizontalScrollVie
638
642
  )
639
643
  }
640
644
  background = drawable
645
+ outlineProvider = ViewOutlineProvider.BACKGROUND
646
+ clipToOutline = cornerRadiusPx > 0f
641
647
  elevation = appliedChromeElevationPx
642
648
  updateContainerLayout(appearance)
643
649
  separators.forEach(::configureSeparator)