@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.
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +228 -2
- package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +4 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +3 -0
- package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +6 -0
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +347 -10
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +76 -8
- package/dist/EditorToolbar.d.ts +9 -2
- package/dist/EditorToolbar.js +20 -10
- package/dist/NativeEditorBridge.d.ts +2 -0
- package/dist/NativeEditorBridge.js +3 -0
- package/dist/NativeRichTextEditor.d.ts +17 -1
- package/dist/NativeRichTextEditor.js +94 -37
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -1
- package/dist/schemas.d.ts +12 -0
- package/dist/schemas.js +45 -1
- package/ios/EditorCore.xcframework/Info.plist +5 -5
- package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
- package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
- package/ios/EditorLayoutManager.swift +0 -16
- package/ios/Generated_editor_core.swift +20 -2
- package/ios/NativeEditorExpoView.swift +51 -16
- package/ios/NativeEditorModule.swift +3 -0
- package/ios/RenderBridge.swift +208 -0
- package/ios/RichTextEditorView.swift +896 -15
- package/ios/editor_coreFFI/editor_coreFFI.h +11 -0
- package/package.json +1 -1
- package/rust/android/arm64-v8a/libeditor_core.so +0 -0
- package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
- package/rust/android/x86_64/libeditor_core.so +0 -0
- package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +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)
|