@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
@@ -1,9 +1,15 @@
1
1
  package com.apollohg.editor
2
2
 
3
+ import android.graphics.Bitmap
4
+ import android.graphics.BitmapFactory
3
5
  import android.graphics.Canvas
4
6
  import android.graphics.Color
5
7
  import android.graphics.Paint
8
+ import android.graphics.RectF
6
9
  import android.graphics.Typeface
10
+ import android.util.Base64
11
+ import android.util.Log
12
+ import android.util.LruCache
7
13
  import android.text.Annotation
8
14
  import android.text.Layout
9
15
  import android.text.SpannableStringBuilder
@@ -18,8 +24,12 @@ import android.text.style.StrikethroughSpan
18
24
  import android.text.style.StyleSpan
19
25
  import android.text.style.TypefaceSpan
20
26
  import android.text.style.UnderlineSpan
27
+ import android.widget.TextView
21
28
  import org.json.JSONArray
22
29
  import org.json.JSONObject
30
+ import java.lang.ref.WeakReference
31
+ import java.net.URL
32
+ import java.util.concurrent.Executors
23
33
 
24
34
  object LayoutConstants {
25
35
  /** Base indentation per depth level (pixels at base scale). */
@@ -247,6 +257,293 @@ class HorizontalRuleSpan(
247
257
  }
248
258
  }
249
259
 
260
+ internal object RenderImageDecoder {
261
+ private const val MAX_DECODE_DIMENSION_PX = 2048
262
+ internal const val LOG_TAG = "NativeEditorImage"
263
+
264
+ fun decodeSource(source: String): Bitmap? {
265
+ decodeDataUrlBytes(source)?.let { bytes ->
266
+ val decoded = decodeBitmap(bytes)
267
+ if (decoded == null) {
268
+ Log.w(LOG_TAG, "decodeSource: failed to decode data URL bytes (${sourceSummary(source)})")
269
+ } else {
270
+ Log.d(
271
+ LOG_TAG,
272
+ "decodeSource: decoded data URL ${sourceSummary(source)} -> ${decoded.width}x${decoded.height}"
273
+ )
274
+ }
275
+ return decoded
276
+ }
277
+ val remoteBytes = runCatching {
278
+ URL(source).openStream().use { input ->
279
+ input.readBytes()
280
+ }
281
+ }.getOrNull() ?: run {
282
+ Log.w(LOG_TAG, "decodeSource: failed to load remote image (${sourceSummary(source)})")
283
+ return null
284
+ }
285
+ val decoded = decodeBitmap(remoteBytes)
286
+ if (decoded == null) {
287
+ Log.w(LOG_TAG, "decodeSource: failed to decode remote bytes (${sourceSummary(source)})")
288
+ }
289
+ return decoded
290
+ }
291
+
292
+ fun decodeDataUrlBytes(source: String): ByteArray? {
293
+ val trimmed = source.trim()
294
+ if (!trimmed.startsWith("data:image/", ignoreCase = true)) return null
295
+ val commaIndex = trimmed.indexOf(',')
296
+ if (commaIndex <= 0) return null
297
+ val metadata = trimmed.substring(0, commaIndex).lowercase()
298
+ if (!metadata.contains(";base64")) return null
299
+ val payload = trimmed.substring(commaIndex + 1)
300
+ .filterNot(Char::isWhitespace)
301
+
302
+ val decodeFlags = intArrayOf(
303
+ Base64.DEFAULT,
304
+ Base64.NO_WRAP,
305
+ Base64.URL_SAFE or Base64.NO_WRAP,
306
+ Base64.URL_SAFE
307
+ )
308
+ for (flags in decodeFlags) {
309
+ val bytes = runCatching { Base64.decode(payload, flags) }.getOrNull()
310
+ if (bytes != null) {
311
+ return bytes
312
+ }
313
+ }
314
+ Log.w(LOG_TAG, "decodeDataUrlBytes: unsupported base64 payload (${sourceSummary(source)})")
315
+ return null
316
+ }
317
+
318
+ fun calculateInSampleSize(
319
+ width: Int,
320
+ height: Int,
321
+ maxWidth: Int = MAX_DECODE_DIMENSION_PX,
322
+ maxHeight: Int = MAX_DECODE_DIMENSION_PX
323
+ ): Int {
324
+ if (width <= 0 || height <= 0) return 1
325
+
326
+ var sampleSize = 1
327
+ var sampledWidth = width
328
+ var sampledHeight = height
329
+ while (sampledWidth > maxWidth || sampledHeight > maxHeight) {
330
+ sampleSize *= 2
331
+ sampledWidth = width / sampleSize
332
+ sampledHeight = height / sampleSize
333
+ }
334
+ return sampleSize.coerceAtLeast(1)
335
+ }
336
+
337
+ private fun decodeBitmap(bytes: ByteArray): Bitmap? {
338
+ val bounds = BitmapFactory.Options().apply {
339
+ inJustDecodeBounds = true
340
+ }
341
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
342
+ if (bounds.outWidth <= 0 || bounds.outHeight <= 0) {
343
+ Log.w(LOG_TAG, "decodeBitmap: invalid image bounds for ${bytes.size} bytes")
344
+ return null
345
+ }
346
+
347
+ val options = BitmapFactory.Options().apply {
348
+ inSampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight)
349
+ }
350
+ return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
351
+ }
352
+
353
+ private fun sourceSummary(source: String): String {
354
+ val trimmed = source.trim()
355
+ if (!trimmed.startsWith("data:image/", ignoreCase = true)) {
356
+ return "urlLength=${trimmed.length}"
357
+ }
358
+ val commaIndex = trimmed.indexOf(',')
359
+ if (commaIndex <= 0) {
360
+ return "dataUrlLength=${trimmed.length}"
361
+ }
362
+ val metadata = trimmed.substring(0, commaIndex)
363
+ val payloadLength = trimmed.length - commaIndex - 1
364
+ return "$metadata payloadLength=$payloadLength"
365
+ }
366
+ }
367
+
368
+ private object RenderImageLoader {
369
+ private val cache = object : LruCache<String, Bitmap>(32 * 1024 * 1024) {
370
+ override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
371
+ }
372
+ private val executor = Executors.newCachedThreadPool()
373
+
374
+ fun cached(source: String): Bitmap? = synchronized(cache) { cache.get(source) }
375
+
376
+ fun load(source: String, onLoaded: (Bitmap?) -> Unit) {
377
+ cached(source)?.let {
378
+ onLoaded(it)
379
+ return
380
+ }
381
+
382
+ if (source.trim().startsWith("data:image/", ignoreCase = true)) {
383
+ val bitmap = RenderImageDecoder.decodeSource(source)
384
+ if (bitmap != null) {
385
+ synchronized(cache) {
386
+ cache.put(source, bitmap)
387
+ }
388
+ }
389
+ onLoaded(bitmap)
390
+ return
391
+ }
392
+
393
+ executor.execute {
394
+ val bitmap = RenderImageDecoder.decodeSource(source)
395
+ if (bitmap != null) {
396
+ synchronized(cache) {
397
+ cache.put(source, bitmap)
398
+ }
399
+ }
400
+ onLoaded(bitmap)
401
+ }
402
+ }
403
+ }
404
+
405
+ internal class BlockImageSpan(
406
+ private val source: String,
407
+ hostView: TextView?,
408
+ private val density: Float,
409
+ private val preferredWidthDp: Float?,
410
+ private val preferredHeightDp: Float?
411
+ ) : ReplacementSpan() {
412
+ private val hostRef = WeakReference(hostView)
413
+
414
+ @Volatile
415
+ private var bitmap: Bitmap? = RenderImageLoader.cached(source)
416
+ @Volatile
417
+ private var lastDrawRect: RectF? = null
418
+
419
+ init {
420
+ if (bitmap == null) {
421
+ RenderImageLoader.load(source) { loaded ->
422
+ if (loaded == null) {
423
+ Log.w(
424
+ RenderImageDecoder.LOG_TAG,
425
+ "BlockImageSpan: loader returned null for image source"
426
+ )
427
+ return@load
428
+ }
429
+ bitmap = loaded
430
+ hostRef.get()?.post {
431
+ hostRef.get()?.requestLayout()
432
+ hostRef.get()?.invalidate()
433
+ (hostRef.get() as? EditorEditText)?.onSelectionOrContentMayChange?.invoke()
434
+ }
435
+ }
436
+ }
437
+ }
438
+
439
+ override fun getSize(
440
+ paint: Paint,
441
+ text: CharSequence,
442
+ start: Int,
443
+ end: Int,
444
+ fm: Paint.FontMetricsInt?
445
+ ): Int {
446
+ val (widthPx, heightPx) = currentSizePx()
447
+ if (fm != null) {
448
+ fm.ascent = -heightPx
449
+ fm.descent = 0
450
+ fm.top = fm.ascent
451
+ fm.bottom = 0
452
+ }
453
+ return widthPx
454
+ }
455
+
456
+ override fun draw(
457
+ canvas: Canvas,
458
+ text: CharSequence,
459
+ start: Int,
460
+ end: Int,
461
+ x: Float,
462
+ top: Int,
463
+ y: Int,
464
+ bottom: Int,
465
+ paint: Paint
466
+ ) {
467
+ val (widthPx, heightPx) = currentSizePx()
468
+ val rect = RectF(
469
+ x,
470
+ (bottom - heightPx).toFloat(),
471
+ x + widthPx,
472
+ bottom.toFloat()
473
+ )
474
+ val host = hostRef.get()
475
+ lastDrawRect = RectF(rect).apply {
476
+ if (host != null) {
477
+ offset(host.compoundPaddingLeft.toFloat(), host.extendedPaddingTop.toFloat())
478
+ }
479
+ }
480
+ val loadedBitmap = bitmap
481
+ if (loadedBitmap != null) {
482
+ canvas.drawBitmap(loadedBitmap, null, rect, null)
483
+ return
484
+ }
485
+
486
+ val previousColor = paint.color
487
+ val previousStyle = paint.style
488
+ paint.color = Color.argb(24, 0, 0, 0)
489
+ paint.style = Paint.Style.FILL
490
+ canvas.drawRoundRect(rect, 16f * density, 16f * density, paint)
491
+ paint.color = Color.argb(120, 0, 0, 0)
492
+ val iconRadius = minOf(rect.width(), rect.height()) * 0.12f
493
+ canvas.drawCircle(rect.centerX(), rect.centerY(), iconRadius, paint)
494
+ paint.color = previousColor
495
+ paint.style = previousStyle
496
+ }
497
+
498
+ internal fun currentSizePx(): Pair<Int, Int> {
499
+ val maxWidth = resolvedMaxWidth()
500
+ val loadedBitmap = bitmap
501
+ val fallbackAspectRatio = if (loadedBitmap != null && loadedBitmap.width > 0 && loadedBitmap.height > 0) {
502
+ loadedBitmap.height.toFloat() / loadedBitmap.width.toFloat()
503
+ } else {
504
+ 0.56f
505
+ }
506
+
507
+ var widthPx = preferredWidthDp?.takeIf { it > 0f }?.times(density)
508
+ var heightPx = preferredHeightDp?.takeIf { it > 0f }?.times(density)
509
+
510
+ if (widthPx == null && heightPx == null && loadedBitmap != null && loadedBitmap.width > 0 && loadedBitmap.height > 0) {
511
+ widthPx = loadedBitmap.width.toFloat()
512
+ heightPx = loadedBitmap.height.toFloat()
513
+ } else if (widthPx == null && heightPx != null) {
514
+ widthPx = heightPx / fallbackAspectRatio
515
+ } else if (heightPx == null && widthPx != null) {
516
+ heightPx = widthPx * fallbackAspectRatio
517
+ }
518
+
519
+ if (widthPx == null || heightPx == null) {
520
+ val placeholderWidth = maxWidth.coerceAtLeast(160f * density)
521
+ val placeholderHeight = minOf(
522
+ 180f * density,
523
+ placeholderWidth * fallbackAspectRatio
524
+ ).coerceAtLeast(96f * density)
525
+ widthPx = placeholderWidth
526
+ heightPx = placeholderHeight
527
+ }
528
+
529
+ val scale = minOf(1f, maxWidth / widthPx.coerceAtLeast(1f))
530
+ return Pair(
531
+ (widthPx * scale).toInt().coerceAtLeast(1),
532
+ (heightPx * scale).toInt().coerceAtLeast(1)
533
+ )
534
+ }
535
+
536
+ internal fun currentDrawRect(): RectF? = lastDrawRect?.let(::RectF)
537
+
538
+ private fun resolvedMaxWidth(): Float {
539
+ val host = hostRef.get()
540
+ val hostWidth = host?.let {
541
+ maxOf(it.width, it.measuredWidth) - it.totalPaddingLeft - it.totalPaddingRight
542
+ } ?: 0
543
+ return if (hostWidth > 0) hostWidth.toFloat() else 240f * density
544
+ }
545
+ }
546
+
250
547
  class FixedLineHeightSpan(
251
548
  private val lineHeightPx: Int
252
549
  ) : LineHeightSpan {
@@ -385,7 +682,8 @@ object RenderBridge {
385
682
  baseFontSize: Float,
386
683
  textColor: Int,
387
684
  theme: EditorTheme? = null,
388
- density: Float = 1f
685
+ density: Float = 1f,
686
+ hostView: TextView? = null
389
687
  ): SpannableStringBuilder {
390
688
  val elements = try {
391
689
  JSONArray(json)
@@ -393,7 +691,7 @@ object RenderBridge {
393
691
  return SpannableStringBuilder()
394
692
  }
395
693
 
396
- return buildSpannableFromArray(elements, baseFontSize, textColor, theme, density)
694
+ return buildSpannableFromArray(elements, baseFontSize, textColor, theme, density, hostView)
397
695
  }
398
696
 
399
697
  fun buildSpannableFromArray(
@@ -401,7 +699,8 @@ object RenderBridge {
401
699
  baseFontSize: Float,
402
700
  textColor: Int,
403
701
  theme: EditorTheme? = null,
404
- density: Float = 1f
702
+ density: Float = 1f,
703
+ hostView: TextView? = null
405
704
  ): SpannableStringBuilder {
406
705
  val result = SpannableStringBuilder()
407
706
  val blockStack = mutableListOf<BlockContext>()
@@ -447,22 +746,25 @@ object RenderBridge {
447
746
 
448
747
  "voidBlock" -> {
449
748
  val nodeType = element.optString("nodeType", "")
450
- if (!isFirstBlock) {
451
- val spacingPx = ((nextBlockSpacingBefore ?: 0f) * density).toInt()
452
- appendInterBlockNewline(result, baseFontSize, textColor, spacingPx)
453
- }
454
- isFirstBlock = false
749
+ val attrs = element.optJSONObject("attrs")
750
+ if (!isFirstBlock) {
751
+ val spacingPx = ((nextBlockSpacingBefore ?: 0f) * density).toInt()
752
+ appendInterBlockNewline(result, baseFontSize, textColor, spacingPx)
753
+ }
754
+ isFirstBlock = false
455
755
  val spacingBefore = theme?.effectiveTextStyle(nodeType)?.spacingAfter
456
756
  ?: theme?.list?.itemSpacing
457
757
  nextBlockSpacingBefore = spacingBefore
458
758
  appendVoidBlock(
459
759
  result,
460
760
  nodeType,
761
+ attrs,
461
762
  baseFontSize,
462
763
  textColor,
463
764
  theme,
464
765
  density,
465
- spacingBefore
766
+ spacingBefore,
767
+ hostView
466
768
  )
467
769
  }
468
770
 
@@ -818,11 +1120,13 @@ object RenderBridge {
818
1120
  private fun appendVoidBlock(
819
1121
  builder: SpannableStringBuilder,
820
1122
  nodeType: String,
1123
+ attrs: JSONObject?,
821
1124
  baseFontSize: Float,
822
1125
  textColor: Int,
823
1126
  theme: EditorTheme?,
824
1127
  density: Float,
825
- spacingBefore: Float?
1128
+ spacingBefore: Float?,
1129
+ hostView: TextView?
826
1130
  ) {
827
1131
  when (nodeType) {
828
1132
  "horizontalRule" -> {
@@ -845,6 +1149,32 @@ object RenderBridge {
845
1149
  start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
846
1150
  )
847
1151
  }
1152
+ "image" -> {
1153
+ val source = if (attrs != null && attrs.has("src") && !attrs.isNull("src")) {
1154
+ attrs.optString("src", "").trim()
1155
+ } else {
1156
+ ""
1157
+ }
1158
+ val preferredWidthDp = attrs?.optPositiveFloat("width")
1159
+ val preferredHeightDp = attrs?.optPositiveFloat("height")
1160
+ if (source.isEmpty()) {
1161
+ builder.append(LayoutConstants.OBJECT_REPLACEMENT_CHARACTER)
1162
+ return
1163
+ }
1164
+ val start = builder.length
1165
+ builder.append(LayoutConstants.OBJECT_REPLACEMENT_CHARACTER)
1166
+ val end = builder.length
1167
+ builder.setSpan(
1168
+ BlockImageSpan(
1169
+ source = source,
1170
+ hostView = hostView,
1171
+ density = density,
1172
+ preferredWidthDp = preferredWidthDp,
1173
+ preferredHeightDp = preferredHeightDp
1174
+ ),
1175
+ start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1176
+ )
1177
+ }
848
1178
  else -> {
849
1179
  builder.append(LayoutConstants.OBJECT_REPLACEMENT_CHARACTER)
850
1180
  }
@@ -1391,3 +1721,10 @@ object RenderBridge {
1391
1721
  }
1392
1722
  }
1393
1723
  }
1724
+
1725
+ private fun JSONObject.optPositiveFloat(key: String): Float? {
1726
+ if (!has(key) || isNull(key)) return null
1727
+ val value = optDouble(key, Double.NaN)
1728
+ if (value.isNaN() || value <= 0.0) return null
1729
+ return value.toFloat()
1730
+ }
@@ -2,6 +2,7 @@ package com.apollohg.editor
2
2
 
3
3
  import android.content.Context
4
4
  import android.graphics.Color
5
+ import android.graphics.RectF
5
6
  import android.graphics.drawable.GradientDrawable
6
7
  import android.util.AttributeSet
7
8
  import android.view.MotionEvent
@@ -17,7 +18,6 @@ class RichTextEditorView @JvmOverloads constructor(
17
18
  attrs: AttributeSet? = null,
18
19
  defStyleAttr: Int = 0
19
20
  ) : LinearLayout(context, attrs, defStyleAttr) {
20
-
21
21
  val editorViewport: FrameLayout
22
22
 
23
23
  private class EditorScrollView(context: Context) : ScrollView(context) {
@@ -46,8 +46,10 @@ class RichTextEditorView @JvmOverloads constructor(
46
46
  val editorEditText: EditorEditText
47
47
  val editorScrollView: ScrollView
48
48
  private val remoteSelectionOverlayView: RemoteSelectionOverlayView
49
+ private val imageResizeOverlayView: ImageResizeOverlayView
49
50
 
50
51
  private var heightBehavior: EditorHeightBehavior = EditorHeightBehavior.FIXED
52
+ private var imageResizingEnabled = true
51
53
  private var theme: EditorTheme? = null
52
54
  private var baseBackgroundColor: Int = Color.WHITE
53
55
  private var viewportBottomInsetPx: Int = 0
@@ -62,7 +64,7 @@ class RichTextEditorView @JvmOverloads constructor(
62
64
  } else {
63
65
  editorEditText.unbindEditor()
64
66
  }
65
- remoteSelectionOverlayView.invalidate()
67
+ refreshOverlays()
66
68
  }
67
69
 
68
70
  init {
@@ -75,6 +77,7 @@ class RichTextEditorView @JvmOverloads constructor(
75
77
  }
76
78
  editorViewport = FrameLayout(context)
77
79
  remoteSelectionOverlayView = RemoteSelectionOverlayView(context)
80
+ imageResizeOverlayView = ImageResizeOverlayView(context)
78
81
  editorScrollView.addView(editorEditText, createEditorLayoutParams())
79
82
  editorViewport.addView(
80
83
  editorScrollView,
@@ -90,10 +93,19 @@ class RichTextEditorView @JvmOverloads constructor(
90
93
  ViewGroup.LayoutParams.MATCH_PARENT
91
94
  )
92
95
  )
96
+ editorViewport.addView(
97
+ imageResizeOverlayView,
98
+ FrameLayout.LayoutParams(
99
+ ViewGroup.LayoutParams.MATCH_PARENT,
100
+ ViewGroup.LayoutParams.MATCH_PARENT
101
+ )
102
+ )
93
103
  remoteSelectionOverlayView.bind(this)
104
+ imageResizeOverlayView.bind(this)
94
105
  editorScrollView.setOnScrollChangeListener { _, _, _, _, _ ->
95
- remoteSelectionOverlayView.invalidate()
106
+ refreshOverlays()
96
107
  }
108
+ editorEditText.onSelectionOrContentMayChange = { refreshOverlays() }
97
109
 
98
110
  addView(editorViewport, createContainerLayoutParams())
99
111
  updateScrollContainerAppearance()
@@ -108,7 +120,7 @@ class RichTextEditorView @JvmOverloads constructor(
108
120
  baseBackgroundColor = backgroundColor
109
121
  editorEditText.setBaseStyle(textSizePx, textColor, backgroundColor)
110
122
  updateScrollContainerAppearance()
111
- remoteSelectionOverlayView.invalidate()
123
+ refreshOverlays()
112
124
  }
113
125
 
114
126
  fun applyTheme(theme: EditorTheme?) {
@@ -125,10 +137,10 @@ class RichTextEditorView @JvmOverloads constructor(
125
137
  childHeight + editorScrollView.paddingTop + editorScrollView.paddingBottom - editorScrollView.height
126
138
  )
127
139
  editorScrollView.scrollTo(0, previousScrollY.coerceIn(0, maxScrollY))
128
- remoteSelectionOverlayView.invalidate()
140
+ refreshOverlays()
129
141
  }
130
142
  }
131
- remoteSelectionOverlayView.invalidate()
143
+ refreshOverlays()
132
144
  }
133
145
 
134
146
  fun setHeightBehavior(heightBehavior: EditorHeightBehavior) {
@@ -144,17 +156,24 @@ class RichTextEditorView @JvmOverloads constructor(
144
156
  OVER_SCROLL_NEVER
145
157
  }
146
158
  updateScrollContainerInsets()
147
- remoteSelectionOverlayView.invalidate()
159
+ refreshOverlays()
148
160
  requestLayout()
149
161
  }
150
162
 
163
+ fun setImageResizingEnabled(enabled: Boolean) {
164
+ if (imageResizingEnabled == enabled) return
165
+ imageResizingEnabled = enabled
166
+ editorEditText.setImageResizingEnabled(enabled)
167
+ refreshOverlays()
168
+ }
169
+
151
170
  fun setViewportBottomInsetPx(bottomInsetPx: Int) {
152
171
  val clampedInset = bottomInsetPx.coerceAtLeast(0)
153
172
  if (viewportBottomInsetPx == clampedInset) return
154
173
  viewportBottomInsetPx = clampedInset
155
174
  updateScrollContainerInsets()
156
175
  editorEditText.setViewportBottomInsetPx(clampedInset)
157
- remoteSelectionOverlayView.invalidate()
176
+ refreshOverlays()
158
177
  requestLayout()
159
178
  }
160
179
 
@@ -166,6 +185,13 @@ class RichTextEditorView @JvmOverloads constructor(
166
185
  remoteSelectionOverlayView.invalidate()
167
186
  }
168
187
 
188
+ fun imageResizeOverlayRectForTesting(): android.graphics.RectF? =
189
+ imageResizeOverlayView.visibleRectForTesting()
190
+
191
+ fun resizeSelectedImageForTesting(widthPx: Float, heightPx: Float) {
192
+ imageResizeOverlayView.simulateResizeForTesting(widthPx, heightPx)
193
+ }
194
+
169
195
  fun remoteSelectionDebugSnapshotsForTesting(): List<RemoteSelectionDebugSnapshot> =
170
196
  remoteSelectionOverlayView.debugSnapshotsForTesting()
171
197
 
@@ -257,4 +283,46 @@ class RichTextEditorView @JvmOverloads constructor(
257
283
  ViewGroup.LayoutParams.MATCH_PARENT,
258
284
  ViewGroup.LayoutParams.WRAP_CONTENT
259
285
  )
286
+
287
+ internal fun selectedImageGeometry(): EditorEditText.SelectedImageGeometry? {
288
+ val geometry = editorEditText.selectedImageGeometry() ?: return null
289
+ return EditorEditText.SelectedImageGeometry(
290
+ docPos = geometry.docPos,
291
+ rect = RectF(
292
+ editorViewport.left + editorScrollView.left + editorEditText.left + geometry.rect.left,
293
+ editorViewport.top + editorScrollView.top + editorEditText.top + geometry.rect.top - editorScrollView.scrollY,
294
+ editorViewport.left + editorScrollView.left + editorEditText.left + geometry.rect.right,
295
+ editorViewport.top + editorScrollView.top + editorEditText.top + geometry.rect.bottom - editorScrollView.scrollY
296
+ )
297
+ )
298
+ }
299
+
300
+ internal fun maximumImageWidthPx(): Float {
301
+ val availableWidth =
302
+ maxOf(editorEditText.width, editorEditText.measuredWidth) -
303
+ editorEditText.compoundPaddingLeft -
304
+ editorEditText.compoundPaddingRight
305
+ return availableWidth.coerceAtLeast(48).toFloat()
306
+ }
307
+
308
+ internal fun clampImageSize(
309
+ widthPx: Float,
310
+ heightPx: Float,
311
+ maximumWidthPx: Float = maximumImageWidthPx()
312
+ ): Pair<Float, Float> {
313
+ val aspectRatio = maxOf(widthPx / maxOf(heightPx, 1f), 0.1f)
314
+ val clampedWidth = minOf(maxOf(48f, maximumWidthPx), maxOf(48f, widthPx))
315
+ val clampedHeight = maxOf(48f, clampedWidth / aspectRatio)
316
+ return clampedWidth to clampedHeight
317
+ }
318
+
319
+ internal fun resizeImage(docPos: Int, widthPx: Float, heightPx: Float) {
320
+ val (clampedWidth, clampedHeight) = clampImageSize(widthPx, heightPx)
321
+ editorEditText.resizeImageAtDocPos(docPos, clampedWidth, clampedHeight)
322
+ }
323
+
324
+ private fun refreshOverlays() {
325
+ remoteSelectionOverlayView.invalidate()
326
+ imageResizeOverlayView.refresh()
327
+ }
260
328
  }
@@ -2,7 +2,7 @@ import type { ActiveState, HistoryState } from './NativeEditorBridge';
2
2
  import type { EditorToolbarTheme } from './EditorTheme';
3
3
  export type EditorToolbarListType = 'bulletList' | 'orderedList';
4
4
  export type EditorToolbarCommand = 'indentList' | 'outdentList' | 'undo' | 'redo';
5
- export type EditorToolbarDefaultIconId = 'bold' | 'italic' | 'underline' | 'strike' | 'link' | 'blockquote' | 'bulletList' | 'orderedList' | 'indentList' | 'outdentList' | 'lineBreak' | 'horizontalRule' | 'undo' | 'redo';
5
+ export type EditorToolbarDefaultIconId = 'bold' | 'italic' | 'underline' | 'strike' | 'link' | 'image' | 'blockquote' | 'bulletList' | 'orderedList' | 'indentList' | 'outdentList' | 'lineBreak' | 'horizontalRule' | 'undo' | 'redo';
6
6
  export interface EditorToolbarSFSymbolIcon {
7
7
  type: 'sfSymbol';
8
8
  name: string;
@@ -34,6 +34,11 @@ export type EditorToolbarItem = {
34
34
  label: string;
35
35
  icon: EditorToolbarIcon;
36
36
  key?: string;
37
+ } | {
38
+ type: 'image';
39
+ label: string;
40
+ icon: EditorToolbarIcon;
41
+ key?: string;
37
42
  } | {
38
43
  type: 'blockquote';
39
44
  label: string;
@@ -112,6 +117,8 @@ export interface EditorToolbarProps {
112
117
  onToolbarAction?: (key: string) => void;
113
118
  /** Link button handler used by first-class link toolbar items. */
114
119
  onRequestLink?: () => void;
120
+ /** Image button handler used by first-class image toolbar items. */
121
+ onRequestImage?: () => void;
115
122
  /** Displayed toolbar items, in order. Defaults to the built-in toolbar. */
116
123
  toolbarItems?: readonly EditorToolbarItem[];
117
124
  /** Optional theme overrides for toolbar chrome and button colors. */
@@ -119,4 +126,4 @@ export interface EditorToolbarProps {
119
126
  /** Whether to render the built-in top separator line. */
120
127
  showTopBorder?: boolean;
121
128
  }
122
- export declare function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic, onToggleUnderline, onToggleStrike, onToggleBulletList, onToggleBlockquote, onToggleOrderedList, onIndentList, onOutdentList, onInsertHorizontalRule, onInsertLineBreak, onUndo, onRedo, onToggleMark, onToggleListType, onInsertNodeType, onRunCommand, onToolbarAction, onRequestLink, toolbarItems, theme, showTopBorder, }: EditorToolbarProps): import("react/jsx-runtime").JSX.Element;
129
+ export declare function EditorToolbar({ activeState, historyState, onToggleBold, onToggleItalic, onToggleUnderline, onToggleStrike, onToggleBulletList, onToggleBlockquote, onToggleOrderedList, onIndentList, onOutdentList, onInsertHorizontalRule, onInsertLineBreak, onUndo, onRedo, onToggleMark, onToggleListType, onInsertNodeType, onRunCommand, onToolbarAction, onRequestLink, onRequestImage, toolbarItems, theme, showTopBorder, }: EditorToolbarProps): import("react/jsx-runtime").JSX.Element;