@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
|
@@ -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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
+
refreshOverlays()
|
|
129
141
|
}
|
|
130
142
|
}
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/EditorToolbar.d.ts
CHANGED
|
@@ -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;
|