@apollohg/react-native-prose-editor 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +12 -7
  2. package/android/build.gradle +7 -2
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +289 -2
  4. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +51 -1
  5. package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
  6. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +16 -3
  7. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +82 -1
  8. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +403 -45
  9. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +246 -0
  10. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +841 -155
  11. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +125 -8
  12. package/{src/EditorTheme.ts → dist/EditorTheme.d.ts} +12 -52
  13. package/dist/EditorTheme.js +29 -0
  14. package/dist/EditorToolbar.d.ts +129 -0
  15. package/dist/EditorToolbar.js +394 -0
  16. package/dist/NativeEditorBridge.d.ts +242 -0
  17. package/dist/NativeEditorBridge.js +647 -0
  18. package/dist/NativeRichTextEditor.d.ts +142 -0
  19. package/dist/NativeRichTextEditor.js +649 -0
  20. package/dist/YjsCollaboration.d.ts +83 -0
  21. package/dist/YjsCollaboration.js +585 -0
  22. package/dist/addons.d.ts +70 -0
  23. package/dist/addons.js +77 -0
  24. package/dist/index.d.ts +8 -0
  25. package/dist/index.js +26 -0
  26. package/dist/schemas.d.ts +35 -0
  27. package/{src/schemas.ts → dist/schemas.js} +62 -27
  28. package/dist/useNativeEditor.d.ts +40 -0
  29. package/dist/useNativeEditor.js +117 -0
  30. package/ios/EditorAddons.swift +26 -3
  31. package/ios/EditorCore.xcframework/Info.plist +5 -5
  32. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  33. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  34. package/ios/EditorLayoutManager.swift +236 -0
  35. package/ios/EditorTheme.swift +51 -1
  36. package/ios/Generated_editor_core.swift +270 -2
  37. package/ios/NativeEditorExpoView.swift +612 -45
  38. package/ios/NativeEditorModule.swift +81 -0
  39. package/ios/PositionBridge.swift +22 -0
  40. package/ios/RenderBridge.swift +427 -39
  41. package/ios/RichTextEditorView.swift +1342 -18
  42. package/ios/editor_coreFFI/editor_coreFFI.h +209 -0
  43. package/package.json +80 -64
  44. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  45. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  46. package/rust/android/x86_64/libeditor_core.so +0 -0
  47. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +404 -4
  48. package/src/EditorToolbar.tsx +0 -620
  49. package/src/NativeEditorBridge.ts +0 -607
  50. package/src/NativeRichTextEditor.tsx +0 -951
  51. package/src/addons.ts +0 -158
  52. package/src/index.ts +0 -63
  53. package/src/useNativeEditor.ts +0 -173
@@ -1,10 +1,17 @@
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
14
+ import android.text.Layout
8
15
  import android.text.SpannableStringBuilder
9
16
  import android.text.Spanned
10
17
  import android.text.style.AbsoluteSizeSpan
@@ -16,17 +23,14 @@ import android.text.style.ReplacementSpan
16
23
  import android.text.style.StrikethroughSpan
17
24
  import android.text.style.StyleSpan
18
25
  import android.text.style.TypefaceSpan
19
- import android.text.style.URLSpan
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
- // ── Layout Constants ────────────────────────────────────────────────────
25
-
26
- /**
27
- * Layout constants for paragraph styles and rendering, matching the iOS
28
- * [LayoutConstants] enum.
29
- */
30
34
  object LayoutConstants {
31
35
  /** Base indentation per depth level (pixels at base scale). */
32
36
  const val INDENT_PER_DEPTH: Float = 24f
@@ -43,25 +47,34 @@ object LayoutConstants {
43
47
  /** Vertical padding above and below the horizontal rule (pixels). */
44
48
  const val HORIZONTAL_RULE_VERTICAL_PADDING: Float = 8f
45
49
 
50
+ /** Total leading inset reserved for each blockquote depth. */
51
+ const val BLOCKQUOTE_INDENT: Float = 18f
52
+
53
+ /** Width of the rendered blockquote border bar (pixels at base scale). */
54
+ const val BLOCKQUOTE_BORDER_WIDTH: Float = 3f
55
+
56
+ /** Gap between the blockquote border bar and the text that follows. */
57
+ const val BLOCKQUOTE_MARKER_GAP: Float = 8f
58
+
46
59
  /** Bullet character for unordered list items. */
47
60
  const val UNORDERED_LIST_BULLET: String = "\u2022 "
48
61
 
49
62
  /** Scale factor applied only to unordered list marker glyphs. */
50
63
  const val UNORDERED_LIST_MARKER_FONT_SCALE: Float = 2.0f
51
64
 
65
+ /** Default visual treatment for link text when no explicit theme color exists. */
66
+ const val DEFAULT_LINK_COLOR: Int = 0xFF1B73E8.toInt()
67
+
52
68
  /** Object replacement character used for void block elements. */
53
69
  const val OBJECT_REPLACEMENT_CHARACTER: String = "\uFFFC"
54
70
 
71
+ /** Zero-width placeholder used to preserve trailing hard-break lines. */
72
+ const val SYNTHETIC_PLACEHOLDER_CHARACTER: String = "\u200B"
73
+
55
74
  /** Background color for inline code spans (light gray). */
56
75
  const val CODE_BACKGROUND_COLOR: Int = 0x1A000000 // 10% black
57
76
  }
58
77
 
59
- // ── BlockContext ─────────────────────────────────────────────────────────
60
-
61
- /**
62
- * Transient context while rendering block elements. Pushed onto a stack
63
- * when a `blockStart` element is encountered and popped on `blockEnd`.
64
- */
65
78
  data class BlockContext(
66
79
  val nodeType: String,
67
80
  val depth: Int,
@@ -72,17 +85,135 @@ data class BlockContext(
72
85
 
73
86
  private data class PendingLeadingMargin(
74
87
  val indentPx: Int,
75
- val restIndentPx: Int?
88
+ val restIndentPx: Int?,
89
+ val blockquoteIndentPx: Int = 0,
90
+ val blockquoteStripeColor: Int? = null,
91
+ val blockquoteStripeWidthPx: Int = 0,
92
+ val blockquoteGapWidthPx: Int = 0,
93
+ val blockquoteBaseIndentPx: Int = 0
76
94
  )
77
95
 
78
- // ── HorizontalRuleSpan ──────────────────────────────────────────────────
96
+ class BlockquoteSpan(
97
+ private val baseIndentPx: Int,
98
+ private val totalIndentPx: Int,
99
+ private val stripeColor: Int,
100
+ private val stripeWidthPx: Int,
101
+ private val gapWidthPx: Int
102
+ ) : LeadingMarginSpan {
103
+
104
+ override fun getLeadingMargin(first: Boolean): Int = totalIndentPx
105
+
106
+ override fun drawLeadingMargin(
107
+ canvas: Canvas,
108
+ paint: Paint,
109
+ x: Int,
110
+ dir: Int,
111
+ top: Int,
112
+ baseline: Int,
113
+ bottom: Int,
114
+ text: CharSequence,
115
+ start: Int,
116
+ end: Int,
117
+ first: Boolean,
118
+ layout: android.text.Layout?
119
+ ) {
120
+ if (!lineContainsQuotedContent(text, start, end)) {
121
+ return
122
+ }
123
+
124
+ val savedColor = paint.color
125
+ val savedStyle = paint.style
126
+
127
+ paint.color = stripeColor
128
+ paint.style = Paint.Style.FILL
129
+
130
+ val stripeStart = x + (dir * baseIndentPx)
131
+ val stripeLeft = if (dir > 0) stripeStart.toFloat() else (stripeStart - stripeWidthPx).toFloat()
132
+ val stripeRight = if (dir > 0) stripeLeft + stripeWidthPx else stripeLeft + stripeWidthPx
133
+ val stripeBottom = resolvedStripeBottom(
134
+ text = text,
135
+ start = start,
136
+ end = end,
137
+ baseline = baseline,
138
+ bottom = bottom,
139
+ layout = layout,
140
+ paint = paint
141
+ )
142
+ canvas.drawRect(
143
+ stripeLeft,
144
+ top.toFloat(),
145
+ stripeRight,
146
+ stripeBottom,
147
+ paint
148
+ )
149
+
150
+ paint.color = savedColor
151
+ paint.style = savedStyle
152
+ }
153
+
154
+ private fun lineContainsQuotedContent(text: CharSequence, start: Int, end: Int): Boolean {
155
+ if (start >= end || text !is Spanned) return true
156
+ for (index in start until end.coerceAtMost(text.length)) {
157
+ val ch = text[index]
158
+ if (ch == '\n' || ch == '\r') continue
159
+ val quoted = text.getSpans(index, index + 1, Annotation::class.java).any {
160
+ it.key == RenderBridge.NATIVE_BLOCKQUOTE_ANNOTATION
161
+ }
162
+ if (quoted) {
163
+ return true
164
+ }
165
+ }
166
+ return false
167
+ }
168
+
169
+ internal fun resolvedStripeBottom(
170
+ text: CharSequence,
171
+ start: Int,
172
+ end: Int,
173
+ baseline: Int,
174
+ bottom: Int,
175
+ layout: android.text.Layout?,
176
+ paint: Paint? = null
177
+ ): Float {
178
+ if (layout == null || text.isEmpty()) {
179
+ return bottom.toFloat()
180
+ }
181
+ val lineIndex = safeLineForOffset(layout, start, text.length)
182
+ val nextLine = lineIndex + 1
183
+ if (nextLine >= layout.lineCount) {
184
+ return trimmedTextBottom(baseline, layout, lineIndex, paint)
185
+ }
186
+
187
+ val nextLineStart = layout.getLineStart(nextLine)
188
+ val nextLineEnd = layout.getLineEnd(nextLine)
189
+ return if (lineContainsQuotedContent(text, nextLineStart, nextLineEnd)) {
190
+ bottom.toFloat()
191
+ } else {
192
+ trimmedTextBottom(baseline, layout, lineIndex, paint)
193
+ }
194
+ }
195
+
196
+ private fun trimmedTextBottom(
197
+ baseline: Int,
198
+ layout: Layout,
199
+ lineIndex: Int,
200
+ paint: Paint?
201
+ ): Float {
202
+ val fontDescent = paint?.fontMetrics?.descent
203
+ return if (fontDescent != null) {
204
+ baseline + fontDescent
205
+ } else {
206
+ (baseline + layout.getLineDescent(lineIndex)).toFloat()
207
+ }
208
+ }
209
+
210
+ private fun safeLineForOffset(layout: Layout, offset: Int, textLength: Int): Int {
211
+ if (textLength <= 0) return 0
212
+ val safeStart = offset.coerceIn(0, textLength - 1)
213
+ return layout.getLineForOffset(safeStart)
214
+ }
215
+ }
79
216
 
80
- /**
81
- * A [LeadingMarginSpan] replacement span that draws a horizontal separator line.
82
- *
83
- * Used for `horizontalRule` void block elements. Renders as a thin line
84
- * across the available width with vertical padding.
85
- */
86
217
  class HorizontalRuleSpan(
87
218
  private val lineColor: Int,
88
219
  private val lineHeight: Float = LayoutConstants.HORIZONTAL_RULE_HEIGHT,
@@ -126,6 +257,293 @@ class HorizontalRuleSpan(
126
257
  }
127
258
  }
128
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
+
129
547
  class FixedLineHeightSpan(
130
548
  private val lineHeightPx: Int
131
549
  ) : LineHeightSpan {
@@ -255,42 +673,17 @@ class CenteredBulletSpan(
255
673
  }
256
674
  }
257
675
 
258
- // ── RenderBridge ────────────────────────────────────────────────────────
259
-
260
- /**
261
- * Converts RenderElement JSON (emitted by Rust editor-core via UniFFI) into
262
- * [SpannableStringBuilder] for display in an Android EditText.
263
- *
264
- * The JSON format matches the output of `serialize_render_elements` in lib.rs:
265
- * ```json
266
- * [
267
- * {"type": "blockStart", "nodeType": "paragraph", "depth": 0},
268
- * {"type": "textRun", "text": "Hello ", "marks": []},
269
- * {"type": "textRun", "text": "world", "marks": ["bold"]},
270
- * {"type": "blockEnd"},
271
- * {"type": "voidInline", "nodeType": "hardBreak", "docPos": 12},
272
- * {"type": "voidBlock", "nodeType": "horizontalRule", "docPos": 15}
273
- * ]
274
- * ```
275
- */
276
676
  object RenderBridge {
677
+ internal const val NATIVE_BLOCKQUOTE_ANNOTATION = "nativeBlockquote"
678
+ private const val NATIVE_SYNTHETIC_PLACEHOLDER_ANNOTATION = "nativeSyntheticPlaceholder"
277
679
 
278
- // ── Public API ──────────────────────────────────────────────────────
279
-
280
- /**
281
- * Convert a JSON array of RenderElements into a [SpannableStringBuilder].
282
- *
283
- * @param json A JSON string representing an array of render elements.
284
- * @param baseFontSize The default font size in pixels for unstyled text.
285
- * @param textColor The default text color as an ARGB int.
286
- * @return The rendered spannable string. Returns an empty builder if the JSON is invalid.
287
- */
288
680
  fun buildSpannable(
289
681
  json: String,
290
682
  baseFontSize: Float,
291
683
  textColor: Int,
292
684
  theme: EditorTheme? = null,
293
- density: Float = 1f
685
+ density: Float = 1f,
686
+ hostView: TextView? = null
294
687
  ): SpannableStringBuilder {
295
688
  val elements = try {
296
689
  JSONArray(json)
@@ -298,26 +691,16 @@ object RenderBridge {
298
691
  return SpannableStringBuilder()
299
692
  }
300
693
 
301
- return buildSpannableFromArray(elements, baseFontSize, textColor, theme, density)
694
+ return buildSpannableFromArray(elements, baseFontSize, textColor, theme, density, hostView)
302
695
  }
303
696
 
304
- /**
305
- * Convert a parsed [JSONArray] of RenderElements into a [SpannableStringBuilder].
306
- *
307
- * This is the main rendering entry point. It processes elements in order,
308
- * maintaining a block context stack for proper paragraph styling.
309
- *
310
- * @param elements Parsed JSON array where each element is a [JSONObject].
311
- * @param baseFontSize The default font size in pixels for unstyled text.
312
- * @param textColor The default text color as an ARGB int.
313
- * @return The rendered spannable string.
314
- */
315
697
  fun buildSpannableFromArray(
316
698
  elements: JSONArray,
317
699
  baseFontSize: Float,
318
700
  textColor: Int,
319
701
  theme: EditorTheme? = null,
320
- density: Float = 1f
702
+ density: Float = 1f,
703
+ hostView: TextView? = null
321
704
  ): SpannableStringBuilder {
322
705
  val result = SpannableStringBuilder()
323
706
  val blockStack = mutableListOf<BlockContext>()
@@ -363,22 +746,25 @@ object RenderBridge {
363
746
 
364
747
  "voidBlock" -> {
365
748
  val nodeType = element.optString("nodeType", "")
366
- if (!isFirstBlock) {
367
- val spacingPx = ((nextBlockSpacingBefore ?: 0f) * density).toInt()
368
- appendInterBlockNewline(result, baseFontSize, textColor, spacingPx)
369
- }
370
- 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
371
755
  val spacingBefore = theme?.effectiveTextStyle(nodeType)?.spacingAfter
372
756
  ?: theme?.list?.itemSpacing
373
757
  nextBlockSpacingBefore = spacingBefore
374
758
  appendVoidBlock(
375
759
  result,
376
760
  nodeType,
761
+ attrs,
377
762
  baseFontSize,
378
763
  textColor,
379
764
  theme,
380
765
  density,
381
- spacingBefore
766
+ spacingBefore,
767
+ hostView
382
768
  )
383
769
  }
384
770
 
@@ -416,6 +802,7 @@ object RenderBridge {
416
802
  val depth = element.optInt("depth", 0)
417
803
  val listContext = element.optJSONObject("listContext")
418
804
  val isListItemContainer = nodeType == "listItem" && listContext != null
805
+ val isTransparentContainer = nodeType == "blockquote"
419
806
  val nestedListItemContainer =
420
807
  isListItemContainer && blockStack.any { it.nodeType == "listItem" && it.listContext != null }
421
808
  val blockSpacing = if (isListItemContainer) {
@@ -425,10 +812,25 @@ object RenderBridge {
425
812
  ?: (if (listContext != null) theme?.list?.itemSpacing else null)
426
813
  }
427
814
 
428
- if (!isListItemContainer) {
815
+ if (!isListItemContainer && !isTransparentContainer) {
429
816
  if (!isFirstBlock) {
430
817
  val spacingPx = ((nextBlockSpacingBefore ?: 0f) * density).toInt()
431
- appendInterBlockNewline(result, baseFontSize, textColor, spacingPx)
818
+ val nextBlockStack = blockStack + BlockContext(
819
+ nodeType = nodeType,
820
+ depth = depth,
821
+ listContext = listContext,
822
+ markerPending = isListItemContainer,
823
+ renderStart = result.length
824
+ )
825
+ val inBlockquoteSeparator =
826
+ blockquoteDepth(nextBlockStack) > 0f && trailingRenderedContentHasBlockquote(result)
827
+ appendInterBlockNewline(
828
+ result,
829
+ baseFontSize,
830
+ textColor,
831
+ spacingPx,
832
+ inBlockquote = inBlockquoteSeparator
833
+ )
432
834
  }
433
835
  isFirstBlock = false
434
836
  nextBlockSpacingBefore = blockSpacing
@@ -455,8 +857,16 @@ object RenderBridge {
455
857
  val ordered = markerListContext.optBoolean("ordered", false)
456
858
  val marker = listMarkerString(markerListContext)
457
859
  val markerBaseSize =
458
- resolveTextStyle(nodeType, theme).fontSize?.times(density) ?: baseFontSize
459
- val markerTextStyle = resolveTextStyle(nodeType, theme)
860
+ resolveTextStyle(
861
+ nodeType,
862
+ theme,
863
+ blockquoteDepth(blockStack) > 0
864
+ ).fontSize?.times(density) ?: baseFontSize
865
+ val markerTextStyle = resolveTextStyle(
866
+ nodeType,
867
+ theme,
868
+ blockquoteDepth(blockStack) > 0
869
+ )
460
870
  appendStyledText(
461
871
  result,
462
872
  marker,
@@ -502,6 +912,16 @@ object RenderBridge {
502
912
  "blockEnd" -> {
503
913
  if (blockStack.isNotEmpty()) {
504
914
  val endedBlock = blockStack.removeAt(blockStack.lastIndex)
915
+ appendTrailingHardBreakPlaceholderIfNeeded(
916
+ builder = result,
917
+ endedBlock = endedBlock,
918
+ remainingBlockStack = blockStack,
919
+ baseFontSize = baseFontSize,
920
+ textColor = textColor,
921
+ theme = theme,
922
+ density = density,
923
+ pendingLeadingMargins = pendingLeadingMargins
924
+ )
505
925
  if (endedBlock.nodeType == "listItem" && endedBlock.listContext != null) {
506
926
  nextBlockSpacingBefore = theme?.list?.itemSpacing
507
927
  }
@@ -514,8 +934,6 @@ object RenderBridge {
514
934
  return result
515
935
  }
516
936
 
517
- // ── Mark Handling ───────────────────────────────────────────────────
518
-
519
937
  /**
520
938
  * Apply spans to a text run based on its mark names and append to the builder.
521
939
  *
@@ -548,7 +966,13 @@ object RenderBridge {
548
966
  if (start == end) return
549
967
 
550
968
  val currentBlock = effectiveBlockContext(blockStack)
551
- val textStyle = currentBlock?.let { resolveTextStyle(it.nodeType, theme) } ?: theme?.effectiveTextStyle("paragraph")
969
+ val textStyle = currentBlock?.let {
970
+ resolveTextStyle(
971
+ it.nodeType,
972
+ theme,
973
+ blockquoteDepth(blockStack) > 0
974
+ )
975
+ } ?: theme?.effectiveTextStyle("paragraph", inBlockquote = blockquoteDepth(blockStack) > 0)
552
976
  val resolvedTextSize = textStyle?.fontSize?.times(density) ?: baseFontSize
553
977
  val resolvedTextColor = textStyle?.color ?: textColor
554
978
 
@@ -570,8 +994,6 @@ object RenderBridge {
570
994
  var hasUnderline = false
571
995
  var hasStrike = false
572
996
  var hasCode = false
573
- var linkHref: String? = null
574
-
575
997
  for (mark in marks) {
576
998
  when {
577
999
  mark is String -> when (mark) {
@@ -584,7 +1006,13 @@ object RenderBridge {
584
1006
  mark is JSONObject -> {
585
1007
  val markType = mark.optString("type", "")
586
1008
  if (markType == "link") {
587
- linkHref = mark.takeUnless { it.isNull("href") }?.optString("href")
1009
+ hasUnderline = true
1010
+ builder.setSpan(
1011
+ ForegroundColorSpan(LayoutConstants.DEFAULT_LINK_COLOR),
1012
+ start,
1013
+ end,
1014
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1015
+ )
588
1016
  }
589
1017
  }
590
1018
  }
@@ -633,18 +1061,12 @@ object RenderBridge {
633
1061
  )
634
1062
  }
635
1063
 
636
- if (linkHref != null) {
637
- builder.setSpan(URLSpan(linkHref), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
638
- }
639
-
640
1064
  // Apply block-level indentation spans if in a block context.
641
1065
  if (applyBlockSpans) {
642
1066
  applyBlockStyle(builder, start, end, blockStack, pendingLeadingMargins, theme, density)
643
1067
  }
644
1068
  }
645
1069
 
646
- // ── Void Inline Elements ────────────────────────────────────────────
647
-
648
1070
  /**
649
1071
  * Append a void inline element (e.g. hardBreak) to the builder.
650
1072
  *
@@ -666,6 +1088,10 @@ object RenderBridge {
666
1088
  val start = builder.length
667
1089
  builder.append("\n")
668
1090
  val end = builder.length
1091
+ builder.setSpan(
1092
+ Annotation("nativeVoidNodeType", nodeType),
1093
+ start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1094
+ )
669
1095
  builder.setSpan(
670
1096
  ForegroundColorSpan(resolveInlineTextColor(blockStack, textColor, theme)),
671
1097
  start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
@@ -685,8 +1111,6 @@ object RenderBridge {
685
1111
  }
686
1112
  }
687
1113
 
688
- // ── Void Block Elements ─────────────────────────────────────────────
689
-
690
1114
  /**
691
1115
  * Append a void block element (e.g. horizontalRule) to the builder.
692
1116
  *
@@ -696,11 +1120,13 @@ object RenderBridge {
696
1120
  private fun appendVoidBlock(
697
1121
  builder: SpannableStringBuilder,
698
1122
  nodeType: String,
1123
+ attrs: JSONObject?,
699
1124
  baseFontSize: Float,
700
1125
  textColor: Int,
701
1126
  theme: EditorTheme?,
702
1127
  density: Float,
703
- spacingBefore: Float?
1128
+ spacingBefore: Float?,
1129
+ hostView: TextView?
704
1130
  ) {
705
1131
  when (nodeType) {
706
1132
  "horizontalRule" -> {
@@ -723,17 +1149,38 @@ object RenderBridge {
723
1149
  start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
724
1150
  )
725
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
+ }
726
1178
  else -> {
727
1179
  builder.append(LayoutConstants.OBJECT_REPLACEMENT_CHARACTER)
728
1180
  }
729
1181
  }
730
1182
  }
731
1183
 
732
- // ── Opaque Atoms ────────────────────────────────────────────────────
733
-
734
- /**
735
- * Append an opaque inline atom (unknown inline void) as a bracketed label.
736
- */
737
1184
  private fun appendOpaqueInlineAtom(
738
1185
  builder: SpannableStringBuilder,
739
1186
  nodeType: String,
@@ -784,9 +1231,6 @@ object RenderBridge {
784
1231
  applyBlockStyle(builder, start, end, blockStack, pendingLeadingMargins, theme, density)
785
1232
  }
786
1233
 
787
- /**
788
- * Append an opaque block atom (unknown block void) as a bracketed label.
789
- */
790
1234
  private fun appendOpaqueBlockAtom(
791
1235
  builder: SpannableStringBuilder,
792
1236
  nodeType: String,
@@ -814,12 +1258,6 @@ object RenderBridge {
814
1258
  )
815
1259
  }
816
1260
 
817
- // ── Block Styling ───────────────────────────────────────────────────
818
-
819
- /**
820
- * Apply the current block context's indentation as a [LeadingMarginSpan]
821
- * to a span range.
822
- */
823
1261
  private fun applyBlockStyle(
824
1262
  builder: SpannableStringBuilder,
825
1263
  start: Int,
@@ -830,24 +1268,78 @@ object RenderBridge {
830
1268
  density: Float
831
1269
  ) {
832
1270
  val currentBlock = effectiveBlockContext(blockStack) ?: return
833
- val indent = calculateIndent(currentBlock, theme, density)
1271
+ val indent = calculateIndent(currentBlock, blockStack, theme, density)
834
1272
  val markerWidth = calculateMarkerWidth(density)
835
- val paragraphStart = effectiveParagraphStart(blockStack).coerceIn(0, builder.length)
1273
+ val quoteDepth = blockquoteDepth(blockStack)
1274
+ val quoteStripeColor = if (quoteDepth > 0) {
1275
+ theme?.blockquote?.borderColor ?: Color.argb(
1276
+ (Color.alpha(resolveInlineTextColor(blockStack, Color.BLACK, theme)) * 0.3f).toInt(),
1277
+ Color.red(resolveInlineTextColor(blockStack, Color.BLACK, theme)),
1278
+ Color.green(resolveInlineTextColor(blockStack, Color.BLACK, theme)),
1279
+ Color.blue(resolveInlineTextColor(blockStack, Color.BLACK, theme))
1280
+ )
1281
+ } else {
1282
+ null
1283
+ }
1284
+ val quoteStripeWidth = ((theme?.blockquote?.borderWidth
1285
+ ?: LayoutConstants.BLOCKQUOTE_BORDER_WIDTH) * density).toInt()
1286
+ val quoteGapWidth = ((theme?.blockquote?.markerGap
1287
+ ?: LayoutConstants.BLOCKQUOTE_MARKER_GAP) * density).toInt()
1288
+ val quoteIndent = maxOf(
1289
+ theme?.blockquote?.indent ?: LayoutConstants.BLOCKQUOTE_INDENT,
1290
+ (theme?.blockquote?.markerGap ?: LayoutConstants.BLOCKQUOTE_MARKER_GAP) +
1291
+ (theme?.blockquote?.borderWidth ?: LayoutConstants.BLOCKQUOTE_BORDER_WIDTH)
1292
+ ) * density
1293
+ val blockquoteIndentPx = (quoteDepth * quoteIndent).toInt()
1294
+ val quoteBaseIndent = if (quoteDepth > 0) {
1295
+ ((currentBlock.depth * ((theme?.list?.indent ?: LayoutConstants.INDENT_PER_DEPTH) * density))
1296
+ - (quoteDepth * ((theme?.list?.indent ?: LayoutConstants.INDENT_PER_DEPTH) * density))
1297
+ + ((quoteDepth - 1f) * quoteIndent)).toInt()
1298
+ } else {
1299
+ 0
1300
+ }
1301
+ val paragraphStart = renderedParagraphStart(
1302
+ builder = builder,
1303
+ candidateStart = effectiveParagraphStart(blockStack)
1304
+ )
836
1305
  if (paragraphStart < end) {
837
1306
  if (currentBlock.listContext != null) {
838
1307
  pendingLeadingMargins[paragraphStart] = PendingLeadingMargin(
839
1308
  indentPx = indent.toInt(),
840
- restIndentPx = (indent + markerWidth).toInt()
1309
+ restIndentPx = (indent + markerWidth).toInt(),
1310
+ blockquoteIndentPx = blockquoteIndentPx,
1311
+ blockquoteStripeColor = quoteStripeColor,
1312
+ blockquoteStripeWidthPx = quoteStripeWidth,
1313
+ blockquoteGapWidthPx = quoteGapWidth,
1314
+ blockquoteBaseIndentPx = quoteBaseIndent
841
1315
  )
842
1316
  } else if (indent > 0) {
843
1317
  pendingLeadingMargins[paragraphStart] = PendingLeadingMargin(
844
1318
  indentPx = indent.toInt(),
845
- restIndentPx = null
1319
+ restIndentPx = null,
1320
+ blockquoteIndentPx = blockquoteIndentPx,
1321
+ blockquoteStripeColor = quoteStripeColor,
1322
+ blockquoteStripeWidthPx = quoteStripeWidth,
1323
+ blockquoteGapWidthPx = quoteGapWidth,
1324
+ blockquoteBaseIndentPx = quoteBaseIndent
846
1325
  )
847
1326
  }
848
1327
  }
849
1328
 
850
- val lineHeight = resolveTextStyle(currentBlock.nodeType, theme).lineHeight
1329
+ if (quoteDepth > 0f) {
1330
+ builder.setSpan(
1331
+ Annotation(NATIVE_BLOCKQUOTE_ANNOTATION, "1"),
1332
+ start,
1333
+ end,
1334
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1335
+ )
1336
+ }
1337
+
1338
+ val lineHeight = resolveTextStyle(
1339
+ currentBlock.nodeType,
1340
+ theme,
1341
+ quoteDepth > 0
1342
+ ).lineHeight
851
1343
  applyLineHeightSpan(builder, start, end, lineHeight, density)
852
1344
  }
853
1345
 
@@ -876,35 +1368,110 @@ object RenderBridge {
876
1368
  if (pendingLeadingMargins.isEmpty()) return
877
1369
 
878
1370
  val text = builder.toString()
879
- pendingLeadingMargins.toSortedMap().forEach { (paragraphStart, spec) ->
1371
+ val entries = pendingLeadingMargins.toSortedMap().entries.toList()
1372
+ var index = 0
1373
+ while (index < entries.size) {
1374
+ val paragraphStart = entries[index].key
1375
+ val spec = entries[index].value
880
1376
  if (paragraphStart >= builder.length) {
881
- return@forEach
1377
+ index += 1
1378
+ continue
882
1379
  }
883
- val newlineIndex = text.indexOf('\n', paragraphStart)
884
- val paragraphEnd = if (newlineIndex >= 0) newlineIndex + 1 else builder.length
885
- val span = spec.restIndentPx?.let {
886
- LeadingMarginSpan.Standard(spec.indentPx, it)
887
- } ?: LeadingMarginSpan.Standard(spec.indentPx)
888
-
889
- builder
890
- .getSpans(0, builder.length, LeadingMarginSpan.Standard::class.java)
891
- .filter { builder.getSpanStart(it) == paragraphStart }
892
- .forEach(builder::removeSpan)
893
-
894
- builder.setSpan(span, paragraphStart, paragraphEnd, Spanned.SPAN_PARAGRAPH)
1380
+ if (spec.blockquoteStripeColor != null) {
1381
+ val paragraphEnd = blockquoteSpanEnd(builder, text, paragraphStart)
1382
+ val quoteEntries = mutableListOf(entries[index])
1383
+ var nextIndex = index + 1
1384
+ while (nextIndex < entries.size && entries[nextIndex].key < paragraphEnd) {
1385
+ quoteEntries.add(entries[nextIndex])
1386
+ nextIndex += 1
1387
+ }
1388
+ index = nextIndex
1389
+
1390
+ builder
1391
+ .getSpans(0, builder.length, LeadingMarginSpan::class.java)
1392
+ .filter { builder.getSpanStart(it) == paragraphStart }
1393
+ .forEach(builder::removeSpan)
1394
+
1395
+ builder.setSpan(
1396
+ BlockquoteSpan(
1397
+ baseIndentPx = spec.blockquoteBaseIndentPx,
1398
+ totalIndentPx = spec.blockquoteIndentPx,
1399
+ stripeColor = spec.blockquoteStripeColor,
1400
+ stripeWidthPx = spec.blockquoteStripeWidthPx,
1401
+ gapWidthPx = spec.blockquoteGapWidthPx
1402
+ ),
1403
+ paragraphStart,
1404
+ paragraphEnd,
1405
+ Spanned.SPAN_PARAGRAPH
1406
+ )
1407
+
1408
+ quoteEntries.forEach { (entryStart, entrySpec) ->
1409
+ applyAdditionalLeadingMargin(
1410
+ builder = builder,
1411
+ text = text,
1412
+ paragraphStart = entryStart,
1413
+ spec = entrySpec
1414
+ )
1415
+ }
1416
+ } else {
1417
+ index += 1
1418
+ val paragraphEnd = defaultParagraphEnd(text, builder.length, paragraphStart)
1419
+ val span = spec.restIndentPx?.let {
1420
+ LeadingMarginSpan.Standard(spec.indentPx, it)
1421
+ } ?: LeadingMarginSpan.Standard(spec.indentPx)
1422
+
1423
+ builder
1424
+ .getSpans(0, builder.length, LeadingMarginSpan::class.java)
1425
+ .filter { builder.getSpanStart(it) == paragraphStart }
1426
+ .forEach(builder::removeSpan)
1427
+
1428
+ builder.setSpan(span, paragraphStart, paragraphEnd, Spanned.SPAN_PARAGRAPH)
1429
+ }
1430
+ }
1431
+ }
1432
+
1433
+ private fun applyAdditionalLeadingMargin(
1434
+ builder: SpannableStringBuilder,
1435
+ text: String,
1436
+ paragraphStart: Int,
1437
+ spec: PendingLeadingMargin
1438
+ ) {
1439
+ val extraFirstIndent = (spec.indentPx - spec.blockquoteIndentPx).coerceAtLeast(0)
1440
+ val extraRestIndent = spec.restIndentPx?.let {
1441
+ (it - spec.blockquoteIndentPx).coerceAtLeast(0)
1442
+ }
1443
+ if (extraRestIndent != null) {
1444
+ builder.setSpan(
1445
+ LeadingMarginSpan.Standard(extraFirstIndent, extraRestIndent),
1446
+ paragraphStart,
1447
+ defaultParagraphEnd(text, builder.length, paragraphStart),
1448
+ Spanned.SPAN_PARAGRAPH
1449
+ )
1450
+ } else if (extraFirstIndent > 0) {
1451
+ builder.setSpan(
1452
+ LeadingMarginSpan.Standard(extraFirstIndent),
1453
+ paragraphStart,
1454
+ defaultParagraphEnd(text, builder.length, paragraphStart),
1455
+ Spanned.SPAN_PARAGRAPH
1456
+ )
895
1457
  }
896
1458
  }
897
1459
 
898
1460
 
899
- /**
900
- * Calculate the leading margin indent for a block context.
901
- *
902
- * List items get the base depth indent. The list marker width is handled
903
- * by the marker text itself, matching the iOS hanging indent approach.
904
- */
905
- private fun calculateIndent(context: BlockContext, theme: EditorTheme?, density: Float): Float {
1461
+ private fun calculateIndent(
1462
+ context: BlockContext,
1463
+ blockStack: List<BlockContext>,
1464
+ theme: EditorTheme?,
1465
+ density: Float
1466
+ ): Float {
906
1467
  val indentPerDepth = (theme?.list?.indent ?: LayoutConstants.INDENT_PER_DEPTH) * density
907
- return context.depth * indentPerDepth
1468
+ val quoteDepth = blockquoteDepth(blockStack)
1469
+ val quoteIndent = maxOf(
1470
+ theme?.blockquote?.indent ?: LayoutConstants.BLOCKQUOTE_INDENT,
1471
+ (theme?.blockquote?.markerGap ?: LayoutConstants.BLOCKQUOTE_MARKER_GAP) +
1472
+ (theme?.blockquote?.borderWidth ?: LayoutConstants.BLOCKQUOTE_BORDER_WIDTH)
1473
+ ) * density
1474
+ return (context.depth * indentPerDepth) - (quoteDepth * indentPerDepth) + (quoteDepth * quoteIndent)
908
1475
  }
909
1476
 
910
1477
  private fun effectiveBlockContext(blockStack: List<BlockContext>): BlockContext? {
@@ -912,12 +1479,16 @@ object RenderBridge {
912
1479
  if (currentBlock.listContext != null) {
913
1480
  return currentBlock
914
1481
  }
915
- val inheritedListContext = blockStack
1482
+ val inheritedListBlock = blockStack
916
1483
  .dropLast(1)
917
1484
  .asReversed()
918
1485
  .firstOrNull { it.listContext != null }
919
- ?.listContext ?: return currentBlock
920
- return currentBlock.copy(listContext = inheritedListContext, markerPending = false)
1486
+ ?: return currentBlock
1487
+ return currentBlock.copy(
1488
+ depth = currentBlock.depth,
1489
+ listContext = inheritedListBlock.listContext,
1490
+ markerPending = false
1491
+ )
921
1492
  }
922
1493
 
923
1494
  private fun effectiveParagraphStart(blockStack: List<BlockContext>): Int {
@@ -933,6 +1504,21 @@ object RenderBridge {
933
1504
  ?: currentBlock.renderStart
934
1505
  }
935
1506
 
1507
+ private fun renderedParagraphStart(
1508
+ builder: CharSequence,
1509
+ candidateStart: Int
1510
+ ): Int {
1511
+ val boundedStart = candidateStart.coerceIn(0, builder.length)
1512
+ if (boundedStart == 0) return 0
1513
+
1514
+ for (index in boundedStart - 1 downTo 0) {
1515
+ if (builder[index] == '\n') {
1516
+ return index + 1
1517
+ }
1518
+ }
1519
+ return 0
1520
+ }
1521
+
936
1522
  private fun consumePendingListMarker(
937
1523
  blockStack: MutableList<BlockContext>,
938
1524
  markerRenderStart: Int
@@ -952,8 +1538,16 @@ object RenderBridge {
952
1538
  return LayoutConstants.LIST_MARKER_WIDTH * density
953
1539
  }
954
1540
 
955
- private fun resolveTextStyle(nodeType: String, theme: EditorTheme?): EditorTextStyle {
956
- return theme?.effectiveTextStyle(nodeType) ?: EditorTextStyle()
1541
+ private fun blockquoteDepth(blockStack: List<BlockContext>): Float {
1542
+ return blockStack.count { it.nodeType == "blockquote" }.toFloat()
1543
+ }
1544
+
1545
+ private fun resolveTextStyle(
1546
+ nodeType: String,
1547
+ theme: EditorTheme?,
1548
+ inBlockquote: Boolean = false
1549
+ ): EditorTextStyle {
1550
+ return theme?.effectiveTextStyle(nodeType, inBlockquote) ?: EditorTextStyle()
957
1551
  }
958
1552
 
959
1553
  private fun resolveInlineTextColor(
@@ -962,17 +1556,9 @@ object RenderBridge {
962
1556
  theme: EditorTheme?
963
1557
  ): Int {
964
1558
  val nodeType = effectiveBlockContext(blockStack)?.nodeType ?: "paragraph"
965
- return resolveTextStyle(nodeType, theme).color ?: fallbackColor
1559
+ return resolveTextStyle(nodeType, theme, blockquoteDepth(blockStack) > 0).color ?: fallbackColor
966
1560
  }
967
1561
 
968
- // ── List Markers ────────────────────────────────────────────────────
969
-
970
- /**
971
- * Generate the list marker string (bullet or number) from a list context.
972
- *
973
- * @param listContext A [JSONObject] with at least `ordered` (bool) and `index` (int).
974
- * @return The marker string, e.g. "1. " for ordered or bullet + space for unordered.
975
- */
976
1562
  fun listMarkerString(listContext: JSONObject): String {
977
1563
  val ordered = listContext.optBoolean("ordered", false)
978
1564
  return if (ordered) {
@@ -983,8 +1569,6 @@ object RenderBridge {
983
1569
  }
984
1570
  }
985
1571
 
986
- // ── Private Helpers ─────────────────────────────────────────────────
987
-
988
1572
  /**
989
1573
  * Parse a [JSONArray] of marks into a list of mark identifiers.
990
1574
  *
@@ -996,15 +1580,9 @@ object RenderBridge {
996
1580
  if (marksArray == null || marksArray.length() == 0) return emptyList()
997
1581
  val marks = mutableListOf<Any>()
998
1582
  for (i in 0 until marksArray.length()) {
999
- // Try as string first, then as object.
1000
- val markStr = marksArray.optString(i, null)
1001
- if (markStr != null && markStr != "null") {
1002
- marks.add(markStr)
1003
- } else {
1004
- val markObj = marksArray.optJSONObject(i)
1005
- if (markObj != null) {
1006
- marks.add(markObj)
1007
- }
1583
+ when (val mark = marksArray.opt(i)) {
1584
+ is String -> marks.add(mark)
1585
+ is JSONObject -> marks.add(mark)
1008
1586
  }
1009
1587
  }
1010
1588
  return marks
@@ -1020,7 +1598,8 @@ object RenderBridge {
1020
1598
  builder: SpannableStringBuilder,
1021
1599
  baseFontSize: Float,
1022
1600
  textColor: Int,
1023
- spacingPx: Int = 0
1601
+ spacingPx: Int = 0,
1602
+ inBlockquote: Boolean = false
1024
1603
  ) {
1025
1604
  val start = builder.length
1026
1605
  builder.append("\n")
@@ -1040,5 +1619,112 @@ object RenderBridge {
1040
1619
  start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1041
1620
  )
1042
1621
  }
1622
+ if (inBlockquote) {
1623
+ builder.setSpan(
1624
+ Annotation(NATIVE_BLOCKQUOTE_ANNOTATION, "1"),
1625
+ start,
1626
+ end,
1627
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1628
+ )
1629
+ }
1630
+ }
1631
+
1632
+ private fun appendTrailingHardBreakPlaceholderIfNeeded(
1633
+ builder: SpannableStringBuilder,
1634
+ endedBlock: BlockContext,
1635
+ remainingBlockStack: List<BlockContext>,
1636
+ baseFontSize: Float,
1637
+ textColor: Int,
1638
+ theme: EditorTheme?,
1639
+ density: Float,
1640
+ pendingLeadingMargins: MutableMap<Int, PendingLeadingMargin>
1641
+ ) {
1642
+ if (builder.isEmpty()) return
1643
+ if (endedBlock.nodeType == "listItem") return
1644
+ if (!lastCharacterIsHardBreak(builder)) return
1645
+
1646
+ val start = builder.length
1647
+ builder.append(LayoutConstants.SYNTHETIC_PLACEHOLDER_CHARACTER)
1648
+ val end = builder.length
1649
+ builder.setSpan(
1650
+ Annotation(NATIVE_SYNTHETIC_PLACEHOLDER_ANNOTATION, "1"),
1651
+ start,
1652
+ end,
1653
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1654
+ )
1655
+ builder.setSpan(
1656
+ ForegroundColorSpan(resolveInlineTextColor(remainingBlockStack + endedBlock, textColor, theme)),
1657
+ start,
1658
+ end,
1659
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1660
+ )
1661
+ applyBlockStyle(
1662
+ builder,
1663
+ start,
1664
+ end,
1665
+ remainingBlockStack + endedBlock,
1666
+ pendingLeadingMargins,
1667
+ theme,
1668
+ density
1669
+ )
1670
+ }
1671
+
1672
+ private fun lastCharacterIsHardBreak(builder: SpannableStringBuilder): Boolean {
1673
+ if (builder.isEmpty()) return false
1674
+ val lastIndex = builder.length - 1
1675
+ return builder.getSpans(lastIndex, builder.length, Annotation::class.java).any {
1676
+ it.key == "nativeVoidNodeType" && it.value == "hardBreak"
1677
+ }
1678
+ }
1679
+
1680
+ private fun trailingRenderedContentHasBlockquote(builder: Spanned): Boolean {
1681
+ for (index in builder.length - 1 downTo 0) {
1682
+ val ch = builder[index]
1683
+ if (ch == '\n' || ch == '\r') continue
1684
+ return hasBlockquoteAnnotationAt(builder, index)
1685
+ }
1686
+ return false
1043
1687
  }
1688
+
1689
+ private fun defaultParagraphEnd(text: String, length: Int, paragraphStart: Int): Int {
1690
+ val newlineIndex = text.indexOf('\n', paragraphStart)
1691
+ return if (newlineIndex >= 0) newlineIndex + 1 else length
1692
+ }
1693
+
1694
+ private fun blockquoteSpanEnd(
1695
+ builder: Spanned,
1696
+ text: String,
1697
+ paragraphStart: Int
1698
+ ): Int {
1699
+ var cursor = paragraphStart
1700
+ while (cursor < builder.length) {
1701
+ val newlineIndex = text.indexOf('\n', cursor)
1702
+ if (newlineIndex < 0) {
1703
+ return builder.length
1704
+ }
1705
+ val newlineQuoted = hasBlockquoteAnnotationAt(builder, newlineIndex)
1706
+ val nextIndex = newlineIndex + 1
1707
+ val nextQuoted = nextIndex < builder.length && hasBlockquoteAnnotationAt(builder, nextIndex)
1708
+
1709
+ if (!newlineQuoted && !nextQuoted) {
1710
+ return nextIndex
1711
+ }
1712
+ cursor = nextIndex
1713
+ }
1714
+ return builder.length
1715
+ }
1716
+
1717
+ private fun hasBlockquoteAnnotationAt(text: Spanned, index: Int): Boolean {
1718
+ if (index < 0 || index >= text.length) return false
1719
+ return text.getSpans(index, index + 1, Annotation::class.java).any {
1720
+ it.key == NATIVE_BLOCKQUOTE_ANNOTATION
1721
+ }
1722
+ }
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()
1044
1730
  }