@apollohg/react-native-prose-editor 0.1.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 (47) hide show
  1. package/LICENSE +160 -0
  2. package/README.md +143 -0
  3. package/android/build.gradle +39 -0
  4. package/android/src/main/assets/editor-icons/MaterialIcons.json +2236 -0
  5. package/android/src/main/assets/editor-icons/MaterialIcons.ttf +0 -0
  6. package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +131 -0
  7. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +1057 -0
  8. package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
  9. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +191 -0
  10. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +325 -0
  11. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +647 -0
  12. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +257 -0
  13. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +714 -0
  14. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +76 -0
  15. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +1044 -0
  16. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +211 -0
  17. package/expo-module.config.json +9 -0
  18. package/ios/EditorAddons.swift +228 -0
  19. package/ios/EditorCore.xcframework/Info.plist +44 -0
  20. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  21. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  22. package/ios/EditorLayoutManager.swift +254 -0
  23. package/ios/EditorTheme.swift +372 -0
  24. package/ios/Generated_editor_core.swift +1143 -0
  25. package/ios/NativeEditorExpoView.swift +1417 -0
  26. package/ios/NativeEditorModule.swift +263 -0
  27. package/ios/PositionBridge.swift +278 -0
  28. package/ios/ReactNativeProseEditor.podspec +49 -0
  29. package/ios/RenderBridge.swift +825 -0
  30. package/ios/RichTextEditorView.swift +1559 -0
  31. package/ios/editor_coreFFI/editor_coreFFI.h +1014 -0
  32. package/ios/editor_coreFFI/module.modulemap +7 -0
  33. package/ios/editor_coreFFI.h +904 -0
  34. package/ios/editor_coreFFI.modulemap +7 -0
  35. package/package.json +66 -0
  36. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  37. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  38. package/rust/android/x86_64/libeditor_core.so +0 -0
  39. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2014 -0
  40. package/src/EditorTheme.ts +130 -0
  41. package/src/EditorToolbar.tsx +620 -0
  42. package/src/NativeEditorBridge.ts +607 -0
  43. package/src/NativeRichTextEditor.tsx +951 -0
  44. package/src/addons.ts +158 -0
  45. package/src/index.ts +63 -0
  46. package/src/schemas.ts +153 -0
  47. package/src/useNativeEditor.ts +173 -0
@@ -0,0 +1,1044 @@
1
+ package com.apollohg.editor
2
+
3
+ import android.graphics.Canvas
4
+ import android.graphics.Color
5
+ import android.graphics.Paint
6
+ import android.graphics.Typeface
7
+ import android.text.Annotation
8
+ import android.text.SpannableStringBuilder
9
+ import android.text.Spanned
10
+ import android.text.style.AbsoluteSizeSpan
11
+ import android.text.style.BackgroundColorSpan
12
+ import android.text.style.ForegroundColorSpan
13
+ import android.text.style.LeadingMarginSpan
14
+ import android.text.style.LineHeightSpan
15
+ import android.text.style.ReplacementSpan
16
+ import android.text.style.StrikethroughSpan
17
+ import android.text.style.StyleSpan
18
+ import android.text.style.TypefaceSpan
19
+ import android.text.style.URLSpan
20
+ import android.text.style.UnderlineSpan
21
+ import org.json.JSONArray
22
+ import org.json.JSONObject
23
+
24
+ // ── Layout Constants ────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Layout constants for paragraph styles and rendering, matching the iOS
28
+ * [LayoutConstants] enum.
29
+ */
30
+ object LayoutConstants {
31
+ /** Base indentation per depth level (pixels at base scale). */
32
+ const val INDENT_PER_DEPTH: Float = 24f
33
+
34
+ /** Width reserved for the list bullet/number (pixels at base scale). */
35
+ const val LIST_MARKER_WIDTH: Float = 20f
36
+
37
+ /** Gap between the list marker and the text that follows (pixels at base scale). */
38
+ const val LIST_MARKER_TEXT_GAP: Float = 8f
39
+
40
+ /** Height of the horizontal rule separator line (pixels). */
41
+ const val HORIZONTAL_RULE_HEIGHT: Float = 1f
42
+
43
+ /** Vertical padding above and below the horizontal rule (pixels). */
44
+ const val HORIZONTAL_RULE_VERTICAL_PADDING: Float = 8f
45
+
46
+ /** Bullet character for unordered list items. */
47
+ const val UNORDERED_LIST_BULLET: String = "\u2022 "
48
+
49
+ /** Scale factor applied only to unordered list marker glyphs. */
50
+ const val UNORDERED_LIST_MARKER_FONT_SCALE: Float = 2.0f
51
+
52
+ /** Object replacement character used for void block elements. */
53
+ const val OBJECT_REPLACEMENT_CHARACTER: String = "\uFFFC"
54
+
55
+ /** Background color for inline code spans (light gray). */
56
+ const val CODE_BACKGROUND_COLOR: Int = 0x1A000000 // 10% black
57
+ }
58
+
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
+ data class BlockContext(
66
+ val nodeType: String,
67
+ val depth: Int,
68
+ val listContext: JSONObject?,
69
+ var markerPending: Boolean = false,
70
+ var renderStart: Int = 0
71
+ )
72
+
73
+ private data class PendingLeadingMargin(
74
+ val indentPx: Int,
75
+ val restIndentPx: Int?
76
+ )
77
+
78
+ // ── HorizontalRuleSpan ──────────────────────────────────────────────────
79
+
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
+ class HorizontalRuleSpan(
87
+ private val lineColor: Int,
88
+ private val lineHeight: Float = LayoutConstants.HORIZONTAL_RULE_HEIGHT,
89
+ private val verticalPadding: Float = LayoutConstants.HORIZONTAL_RULE_VERTICAL_PADDING
90
+ ) : LeadingMarginSpan {
91
+
92
+ override fun getLeadingMargin(first: Boolean): Int = 0
93
+
94
+ override fun drawLeadingMargin(
95
+ canvas: Canvas,
96
+ paint: Paint,
97
+ x: Int,
98
+ dir: Int,
99
+ top: Int,
100
+ baseline: Int,
101
+ bottom: Int,
102
+ text: CharSequence,
103
+ start: Int,
104
+ end: Int,
105
+ first: Boolean,
106
+ layout: android.text.Layout?
107
+ ) {
108
+ val savedColor = paint.color
109
+ val savedStyle = paint.style
110
+
111
+ paint.color = lineColor
112
+ paint.style = Paint.Style.FILL
113
+
114
+ val lineY = (top + bottom) / 2f
115
+ val lineWidth = layout?.width?.toFloat() ?: canvas.width.toFloat()
116
+ canvas.drawRect(
117
+ x.toFloat(),
118
+ lineY - lineHeight / 2f,
119
+ lineWidth,
120
+ lineY + lineHeight / 2f,
121
+ paint
122
+ )
123
+
124
+ paint.color = savedColor
125
+ paint.style = savedStyle
126
+ }
127
+ }
128
+
129
+ class FixedLineHeightSpan(
130
+ private val lineHeightPx: Int
131
+ ) : LineHeightSpan {
132
+ override fun chooseHeight(
133
+ text: CharSequence,
134
+ start: Int,
135
+ end: Int,
136
+ spanstartv: Int,
137
+ v: Int,
138
+ fm: android.graphics.Paint.FontMetricsInt
139
+ ) {
140
+ val currentHeight = fm.descent - fm.ascent
141
+ if (lineHeightPx <= 0 || currentHeight <= 0) return
142
+ if (lineHeightPx == currentHeight) return
143
+
144
+ val extra = lineHeightPx - currentHeight
145
+ fm.descent += extra
146
+ fm.bottom = fm.descent
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Adds vertical spacing after a paragraph by increasing the descent of the
152
+ * inter-block newline character.
153
+ *
154
+ * Uses [ReplacementSpan] (not [LineHeightSpan]/[android.text.style.ParagraphStyle])
155
+ * because Android's StaticLayout normalizes ParagraphStyle metrics across all
156
+ * lines in a paragraph, making per-line spacing impossible.
157
+ *
158
+ * ReplacementSpan only affects the single character it covers, so the extra
159
+ * descent applies only to the newline's line — creating a gap below the
160
+ * preceding paragraph without inflating other lines.
161
+ */
162
+ class ParagraphSpacerSpan(
163
+ private val spacingPx: Int,
164
+ private val baseFontSize: Int,
165
+ private val textColor: Int
166
+ ) : ReplacementSpan() {
167
+ override fun getSize(
168
+ paint: Paint,
169
+ text: CharSequence,
170
+ start: Int,
171
+ end: Int,
172
+ fm: Paint.FontMetricsInt?
173
+ ): Int {
174
+ if (fm != null && spacingPx > 0) {
175
+ // Keep the natural ascent/top (from baseFontSize) so the newline
176
+ // line doesn't shrink above the baseline. Add spacing as descent.
177
+ val savedSize = paint.textSize
178
+ paint.textSize = baseFontSize.toFloat()
179
+ paint.getFontMetricsInt(fm)
180
+ paint.textSize = savedSize
181
+ fm.descent += spacingPx
182
+ fm.bottom = fm.descent
183
+ }
184
+ return 0
185
+ }
186
+
187
+ override fun draw(
188
+ canvas: Canvas,
189
+ text: CharSequence,
190
+ start: Int,
191
+ end: Int,
192
+ x: Float,
193
+ top: Int,
194
+ y: Int,
195
+ bottom: Int,
196
+ paint: Paint
197
+ ) {
198
+ // Draw nothing — pure spacing.
199
+ }
200
+ }
201
+
202
+ class CenteredBulletSpan(
203
+ private val textColor: Int,
204
+ private val markerWidthPx: Float,
205
+ private val bulletRadiusPx: Float,
206
+ private val bodyFontSizePx: Float,
207
+ private val markerGapToTextPx: Float
208
+ ) : ReplacementSpan() {
209
+ override fun getSize(
210
+ paint: Paint,
211
+ text: CharSequence,
212
+ start: Int,
213
+ end: Int,
214
+ fm: Paint.FontMetricsInt?
215
+ ): Int {
216
+ return kotlin.math.ceil(markerWidthPx).toInt()
217
+ }
218
+
219
+ override fun draw(
220
+ canvas: Canvas,
221
+ text: CharSequence,
222
+ start: Int,
223
+ end: Int,
224
+ x: Float,
225
+ top: Int,
226
+ y: Int,
227
+ bottom: Int,
228
+ paint: Paint
229
+ ) {
230
+ val previousColor = paint.color
231
+ val previousStyle = paint.style
232
+ val previousSize = paint.textSize
233
+
234
+ paint.color = textColor
235
+ paint.style = Paint.Style.FILL
236
+
237
+ // Use body text metrics (not the marker's inflated font) for centering.
238
+ paint.textSize = bodyFontSizePx
239
+ val fm = paint.fontMetrics
240
+ val centerX = resolvedCenterX(x)
241
+ val centerY = y + (fm.ascent + fm.descent) / 2f
242
+ canvas.drawCircle(centerX, centerY, bulletRadiusPx, paint)
243
+
244
+ paint.color = previousColor
245
+ paint.style = previousStyle
246
+ paint.textSize = previousSize
247
+ }
248
+
249
+ fun textSideGapPx(x: Float): Float {
250
+ return (x + markerWidthPx) - (resolvedCenterX(x) + bulletRadiusPx)
251
+ }
252
+
253
+ private fun resolvedCenterX(x: Float): Float {
254
+ return x + markerWidthPx - markerGapToTextPx - bulletRadiusPx
255
+ }
256
+ }
257
+
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
+ object RenderBridge {
277
+
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
+ fun buildSpannable(
289
+ json: String,
290
+ baseFontSize: Float,
291
+ textColor: Int,
292
+ theme: EditorTheme? = null,
293
+ density: Float = 1f
294
+ ): SpannableStringBuilder {
295
+ val elements = try {
296
+ JSONArray(json)
297
+ } catch (_: Exception) {
298
+ return SpannableStringBuilder()
299
+ }
300
+
301
+ return buildSpannableFromArray(elements, baseFontSize, textColor, theme, density)
302
+ }
303
+
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
+ fun buildSpannableFromArray(
316
+ elements: JSONArray,
317
+ baseFontSize: Float,
318
+ textColor: Int,
319
+ theme: EditorTheme? = null,
320
+ density: Float = 1f
321
+ ): SpannableStringBuilder {
322
+ val result = SpannableStringBuilder()
323
+ val blockStack = mutableListOf<BlockContext>()
324
+ val pendingLeadingMargins = linkedMapOf<Int, PendingLeadingMargin>()
325
+ var isFirstBlock = true
326
+ var nextBlockSpacingBefore: Float? = null
327
+
328
+ for (i in 0 until elements.length()) {
329
+ val element = elements.optJSONObject(i) ?: continue
330
+ val type = element.optString("type", "")
331
+
332
+ when (type) {
333
+ "textRun" -> {
334
+ val text = element.optString("text", "")
335
+ val marksArray = element.optJSONArray("marks")
336
+ val marks = parseMarks(marksArray)
337
+ appendStyledText(
338
+ result,
339
+ text,
340
+ marks,
341
+ baseFontSize,
342
+ textColor,
343
+ blockStack,
344
+ pendingLeadingMargins,
345
+ theme,
346
+ density
347
+ )
348
+ }
349
+
350
+ "voidInline" -> {
351
+ val nodeType = element.optString("nodeType", "")
352
+ appendVoidInline(
353
+ result,
354
+ nodeType,
355
+ baseFontSize,
356
+ textColor,
357
+ blockStack,
358
+ pendingLeadingMargins,
359
+ theme,
360
+ density
361
+ )
362
+ }
363
+
364
+ "voidBlock" -> {
365
+ 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
371
+ val spacingBefore = theme?.effectiveTextStyle(nodeType)?.spacingAfter
372
+ ?: theme?.list?.itemSpacing
373
+ nextBlockSpacingBefore = spacingBefore
374
+ appendVoidBlock(
375
+ result,
376
+ nodeType,
377
+ baseFontSize,
378
+ textColor,
379
+ theme,
380
+ density,
381
+ spacingBefore
382
+ )
383
+ }
384
+
385
+ "opaqueInlineAtom" -> {
386
+ val nodeType = element.optString("nodeType", "")
387
+ val label = element.optString("label", "?")
388
+ appendOpaqueInlineAtom(
389
+ result,
390
+ nodeType,
391
+ label,
392
+ baseFontSize,
393
+ textColor,
394
+ blockStack,
395
+ pendingLeadingMargins,
396
+ theme,
397
+ density
398
+ )
399
+ }
400
+
401
+ "opaqueBlockAtom" -> {
402
+ val nodeType = element.optString("nodeType", "")
403
+ val label = element.optString("label", "?")
404
+ val blockSpacing = theme?.effectiveTextStyle(nodeType)?.spacingAfter
405
+ if (!isFirstBlock) {
406
+ val spacingPx = ((nextBlockSpacingBefore ?: 0f) * density).toInt()
407
+ appendInterBlockNewline(result, baseFontSize, textColor, spacingPx)
408
+ }
409
+ isFirstBlock = false
410
+ nextBlockSpacingBefore = blockSpacing
411
+ appendOpaqueBlockAtom(result, nodeType, label, baseFontSize, textColor, theme, blockSpacing)
412
+ }
413
+
414
+ "blockStart" -> {
415
+ val nodeType = element.optString("nodeType", "")
416
+ val depth = element.optInt("depth", 0)
417
+ val listContext = element.optJSONObject("listContext")
418
+ val isListItemContainer = nodeType == "listItem" && listContext != null
419
+ val nestedListItemContainer =
420
+ isListItemContainer && blockStack.any { it.nodeType == "listItem" && it.listContext != null }
421
+ val blockSpacing = if (isListItemContainer) {
422
+ null
423
+ } else {
424
+ theme?.effectiveTextStyle(nodeType)?.spacingAfter
425
+ ?: (if (listContext != null) theme?.list?.itemSpacing else null)
426
+ }
427
+
428
+ if (!isListItemContainer) {
429
+ if (!isFirstBlock) {
430
+ val spacingPx = ((nextBlockSpacingBefore ?: 0f) * density).toInt()
431
+ appendInterBlockNewline(result, baseFontSize, textColor, spacingPx)
432
+ }
433
+ isFirstBlock = false
434
+ nextBlockSpacingBefore = blockSpacing
435
+ } else if (nestedListItemContainer && theme?.list?.itemSpacing != null) {
436
+ nextBlockSpacingBefore = theme.list.itemSpacing
437
+ }
438
+
439
+ val ctx = BlockContext(
440
+ nodeType = nodeType,
441
+ depth = depth,
442
+ listContext = listContext,
443
+ markerPending = isListItemContainer,
444
+ renderStart = result.length
445
+ )
446
+ blockStack.add(ctx)
447
+
448
+ val markerListContext = when {
449
+ isListItemContainer -> null
450
+ listContext != null -> listContext
451
+ else -> consumePendingListMarker(blockStack, result.length)
452
+ }
453
+
454
+ if (markerListContext != null) {
455
+ val ordered = markerListContext.optBoolean("ordered", false)
456
+ val marker = listMarkerString(markerListContext)
457
+ val markerBaseSize =
458
+ resolveTextStyle(nodeType, theme).fontSize?.times(density) ?: baseFontSize
459
+ val markerTextStyle = resolveTextStyle(nodeType, theme)
460
+ appendStyledText(
461
+ result,
462
+ marker,
463
+ emptyList(),
464
+ markerBaseSize,
465
+ theme?.list?.markerColor ?: textColor,
466
+ blockStack,
467
+ pendingLeadingMargins,
468
+ null,
469
+ density,
470
+ applyBlockSpans = false
471
+ )
472
+ if (!ordered) {
473
+ val markerStart = result.length - marker.length
474
+ val markerEnd = result.length
475
+ val markerScale =
476
+ theme?.list?.markerScale ?: LayoutConstants.UNORDERED_LIST_MARKER_FONT_SCALE
477
+ val markerWidth = calculateMarkerWidth(density)
478
+ val bulletRadius = ((markerBaseSize * markerScale) * 0.16f).coerceAtLeast(2f * density)
479
+ result.setSpan(
480
+ CenteredBulletSpan(
481
+ textColor = theme?.list?.markerColor ?: textColor,
482
+ markerWidthPx = markerWidth,
483
+ bulletRadiusPx = bulletRadius,
484
+ bodyFontSizePx = markerBaseSize,
485
+ markerGapToTextPx = LayoutConstants.LIST_MARKER_TEXT_GAP * density
486
+ ),
487
+ markerStart,
488
+ markerEnd,
489
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
490
+ )
491
+ }
492
+ applyLineHeightSpan(
493
+ builder = result,
494
+ start = result.length - marker.length,
495
+ end = result.length,
496
+ lineHeight = markerTextStyle.lineHeight,
497
+ density = density
498
+ )
499
+ }
500
+ }
501
+
502
+ "blockEnd" -> {
503
+ if (blockStack.isNotEmpty()) {
504
+ val endedBlock = blockStack.removeAt(blockStack.lastIndex)
505
+ if (endedBlock.nodeType == "listItem" && endedBlock.listContext != null) {
506
+ nextBlockSpacingBefore = theme?.list?.itemSpacing
507
+ }
508
+ }
509
+ }
510
+ }
511
+ }
512
+
513
+ applyPendingLeadingMargins(result, pendingLeadingMargins)
514
+ return result
515
+ }
516
+
517
+ // ── Mark Handling ───────────────────────────────────────────────────
518
+
519
+ /**
520
+ * Apply spans to a text run based on its mark names and append to the builder.
521
+ *
522
+ * Supported marks:
523
+ * - `bold` / `strong` -> [StyleSpan] with [Typeface.BOLD]
524
+ * - `italic` / `em` -> [StyleSpan] with [Typeface.ITALIC]
525
+ * - `underline` -> [UnderlineSpan]
526
+ * - `strike` / `strikethrough` -> [StrikethroughSpan]
527
+ * - `code` -> [TypefaceSpan] with "monospace" + [BackgroundColorSpan]
528
+ * - `link` -> [URLSpan] (when mark is an object with `href`)
529
+ *
530
+ * Multiple marks are combined on the same range.
531
+ */
532
+ private fun appendStyledText(
533
+ builder: SpannableStringBuilder,
534
+ text: String,
535
+ marks: List<Any>, // String or JSONObject for link marks
536
+ baseFontSize: Float,
537
+ textColor: Int,
538
+ blockStack: MutableList<BlockContext>,
539
+ pendingLeadingMargins: MutableMap<Int, PendingLeadingMargin>,
540
+ theme: EditorTheme?,
541
+ density: Float,
542
+ applyBlockSpans: Boolean = true
543
+ ) {
544
+ val start = builder.length
545
+ builder.append(text)
546
+ val end = builder.length
547
+
548
+ if (start == end) return
549
+
550
+ val currentBlock = effectiveBlockContext(blockStack)
551
+ val textStyle = currentBlock?.let { resolveTextStyle(it.nodeType, theme) } ?: theme?.effectiveTextStyle("paragraph")
552
+ val resolvedTextSize = textStyle?.fontSize?.times(density) ?: baseFontSize
553
+ val resolvedTextColor = textStyle?.color ?: textColor
554
+
555
+ // Apply base styling.
556
+ builder.setSpan(
557
+ ForegroundColorSpan(resolvedTextColor),
558
+ start, end,
559
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
560
+ )
561
+ builder.setSpan(
562
+ AbsoluteSizeSpan(resolvedTextSize.toInt(), false),
563
+ start, end,
564
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
565
+ )
566
+
567
+ // Determine which marks are active.
568
+ var hasBold = textStyle?.typefaceStyle()?.let { it == Typeface.BOLD || it == Typeface.BOLD_ITALIC } == true
569
+ var hasItalic = textStyle?.typefaceStyle()?.let { it == Typeface.ITALIC || it == Typeface.BOLD_ITALIC } == true
570
+ var hasUnderline = false
571
+ var hasStrike = false
572
+ var hasCode = false
573
+ var linkHref: String? = null
574
+
575
+ for (mark in marks) {
576
+ when {
577
+ mark is String -> when (mark) {
578
+ "bold", "strong" -> hasBold = true
579
+ "italic", "em" -> hasItalic = true
580
+ "underline" -> hasUnderline = true
581
+ "strike", "strikethrough" -> hasStrike = true
582
+ "code" -> hasCode = true
583
+ }
584
+ mark is JSONObject -> {
585
+ val markType = mark.optString("type", "")
586
+ if (markType == "link") {
587
+ linkHref = mark.takeUnless { it.isNull("href") }?.optString("href")
588
+ }
589
+ }
590
+ }
591
+ }
592
+
593
+ // Apply bold/italic as a combined StyleSpan.
594
+ if (hasBold && hasItalic) {
595
+ builder.setSpan(
596
+ StyleSpan(Typeface.BOLD_ITALIC), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
597
+ )
598
+ } else if (hasBold) {
599
+ builder.setSpan(
600
+ StyleSpan(Typeface.BOLD), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
601
+ )
602
+ } else if (hasItalic) {
603
+ builder.setSpan(
604
+ StyleSpan(Typeface.ITALIC), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
605
+ )
606
+ }
607
+
608
+ val fontFamily = textStyle?.fontFamily
609
+ if (!hasCode && !fontFamily.isNullOrBlank()) {
610
+ builder.setSpan(
611
+ TypefaceSpan(fontFamily),
612
+ start,
613
+ end,
614
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
615
+ )
616
+ }
617
+
618
+ if (hasUnderline) {
619
+ builder.setSpan(UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
620
+ }
621
+
622
+ if (hasStrike) {
623
+ builder.setSpan(StrikethroughSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
624
+ }
625
+
626
+ if (hasCode) {
627
+ builder.setSpan(
628
+ TypefaceSpan("monospace"), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
629
+ )
630
+ builder.setSpan(
631
+ BackgroundColorSpan(LayoutConstants.CODE_BACKGROUND_COLOR),
632
+ start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
633
+ )
634
+ }
635
+
636
+ if (linkHref != null) {
637
+ builder.setSpan(URLSpan(linkHref), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
638
+ }
639
+
640
+ // Apply block-level indentation spans if in a block context.
641
+ if (applyBlockSpans) {
642
+ applyBlockStyle(builder, start, end, blockStack, pendingLeadingMargins, theme, density)
643
+ }
644
+ }
645
+
646
+ // ── Void Inline Elements ────────────────────────────────────────────
647
+
648
+ /**
649
+ * Append a void inline element (e.g. hardBreak) to the builder.
650
+ *
651
+ * A hardBreak is rendered as a newline character. Unknown void inlines
652
+ * are rendered as the object replacement character.
653
+ */
654
+ private fun appendVoidInline(
655
+ builder: SpannableStringBuilder,
656
+ nodeType: String,
657
+ baseFontSize: Float,
658
+ textColor: Int,
659
+ blockStack: MutableList<BlockContext>,
660
+ pendingLeadingMargins: MutableMap<Int, PendingLeadingMargin>,
661
+ theme: EditorTheme?,
662
+ density: Float
663
+ ) {
664
+ when (nodeType) {
665
+ "hardBreak" -> {
666
+ val start = builder.length
667
+ builder.append("\n")
668
+ val end = builder.length
669
+ builder.setSpan(
670
+ ForegroundColorSpan(resolveInlineTextColor(blockStack, textColor, theme)),
671
+ start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
672
+ )
673
+ applyBlockStyle(builder, start, end, blockStack, pendingLeadingMargins, theme, density)
674
+ }
675
+ else -> {
676
+ val start = builder.length
677
+ builder.append(LayoutConstants.OBJECT_REPLACEMENT_CHARACTER)
678
+ val end = builder.length
679
+ builder.setSpan(
680
+ ForegroundColorSpan(resolveInlineTextColor(blockStack, textColor, theme)),
681
+ start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
682
+ )
683
+ applyBlockStyle(builder, start, end, blockStack, pendingLeadingMargins, theme, density)
684
+ }
685
+ }
686
+ }
687
+
688
+ // ── Void Block Elements ─────────────────────────────────────────────
689
+
690
+ /**
691
+ * Append a void block element (e.g. horizontalRule) to the builder.
692
+ *
693
+ * Horizontal rules are rendered as the object replacement character
694
+ * with a [HorizontalRuleSpan] that draws a separator line.
695
+ */
696
+ private fun appendVoidBlock(
697
+ builder: SpannableStringBuilder,
698
+ nodeType: String,
699
+ baseFontSize: Float,
700
+ textColor: Int,
701
+ theme: EditorTheme?,
702
+ density: Float,
703
+ spacingBefore: Float?
704
+ ) {
705
+ when (nodeType) {
706
+ "horizontalRule" -> {
707
+ val start = builder.length
708
+ builder.append(LayoutConstants.OBJECT_REPLACEMENT_CHARACTER)
709
+ val end = builder.length
710
+ // Apply a dim version of the text color for the rule line.
711
+ val ruleColor = theme?.horizontalRule?.color ?: Color.argb(
712
+ (Color.alpha(textColor) * 0.3f).toInt(),
713
+ Color.red(textColor),
714
+ Color.green(textColor),
715
+ Color.blue(textColor)
716
+ )
717
+ builder.setSpan(
718
+ HorizontalRuleSpan(
719
+ lineColor = ruleColor,
720
+ lineHeight = (theme?.horizontalRule?.thickness ?: LayoutConstants.HORIZONTAL_RULE_HEIGHT) * density,
721
+ verticalPadding = (theme?.horizontalRule?.verticalMargin ?: LayoutConstants.HORIZONTAL_RULE_VERTICAL_PADDING) * density
722
+ ),
723
+ start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
724
+ )
725
+ }
726
+ else -> {
727
+ builder.append(LayoutConstants.OBJECT_REPLACEMENT_CHARACTER)
728
+ }
729
+ }
730
+ }
731
+
732
+ // ── Opaque Atoms ────────────────────────────────────────────────────
733
+
734
+ /**
735
+ * Append an opaque inline atom (unknown inline void) as a bracketed label.
736
+ */
737
+ private fun appendOpaqueInlineAtom(
738
+ builder: SpannableStringBuilder,
739
+ nodeType: String,
740
+ label: String,
741
+ baseFontSize: Float,
742
+ textColor: Int,
743
+ blockStack: MutableList<BlockContext>,
744
+ pendingLeadingMargins: MutableMap<Int, PendingLeadingMargin>,
745
+ theme: EditorTheme?,
746
+ density: Float
747
+ ) {
748
+ val isMention = nodeType == "mention"
749
+ val text = if (isMention) label else "[$label]"
750
+ val start = builder.length
751
+ builder.append(text)
752
+ val end = builder.length
753
+ val inlineTextColor = if (isMention) {
754
+ theme?.mentions?.textColor ?: resolveInlineTextColor(blockStack, textColor, theme)
755
+ } else {
756
+ resolveInlineTextColor(blockStack, textColor, theme)
757
+ }
758
+ builder.setSpan(
759
+ ForegroundColorSpan(inlineTextColor),
760
+ start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
761
+ )
762
+ builder.setSpan(
763
+ BackgroundColorSpan(
764
+ if (isMention) {
765
+ theme?.mentions?.backgroundColor ?: 0x1f1d4ed8
766
+ } else {
767
+ 0x20000000
768
+ }
769
+ ),
770
+ start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
771
+ )
772
+ builder.setSpan(
773
+ Annotation("nativeVoidNodeType", nodeType),
774
+ start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
775
+ )
776
+ if (isMention && (theme?.mentions?.fontWeight == "bold" ||
777
+ theme?.mentions?.fontWeight?.toIntOrNull()?.let { it >= 600 } == true)
778
+ ) {
779
+ builder.setSpan(
780
+ StyleSpan(Typeface.BOLD),
781
+ start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
782
+ )
783
+ }
784
+ applyBlockStyle(builder, start, end, blockStack, pendingLeadingMargins, theme, density)
785
+ }
786
+
787
+ /**
788
+ * Append an opaque block atom (unknown block void) as a bracketed label.
789
+ */
790
+ private fun appendOpaqueBlockAtom(
791
+ builder: SpannableStringBuilder,
792
+ nodeType: String,
793
+ label: String,
794
+ baseFontSize: Float,
795
+ textColor: Int,
796
+ theme: EditorTheme?,
797
+ spacingBefore: Float?
798
+ ) {
799
+ val text = if (nodeType == "mention") label else "[$label]"
800
+ val start = builder.length
801
+ builder.append(text)
802
+ val end = builder.length
803
+ builder.setSpan(
804
+ ForegroundColorSpan(theme?.effectiveTextStyle("paragraph")?.color ?: textColor),
805
+ start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
806
+ )
807
+ builder.setSpan(
808
+ BackgroundColorSpan(0x20000000), // light gray
809
+ start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
810
+ )
811
+ builder.setSpan(
812
+ Annotation("nativeVoidNodeType", nodeType),
813
+ start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
814
+ )
815
+ }
816
+
817
+ // ── Block Styling ───────────────────────────────────────────────────
818
+
819
+ /**
820
+ * Apply the current block context's indentation as a [LeadingMarginSpan]
821
+ * to a span range.
822
+ */
823
+ private fun applyBlockStyle(
824
+ builder: SpannableStringBuilder,
825
+ start: Int,
826
+ end: Int,
827
+ blockStack: List<BlockContext>,
828
+ pendingLeadingMargins: MutableMap<Int, PendingLeadingMargin>,
829
+ theme: EditorTheme?,
830
+ density: Float
831
+ ) {
832
+ val currentBlock = effectiveBlockContext(blockStack) ?: return
833
+ val indent = calculateIndent(currentBlock, theme, density)
834
+ val markerWidth = calculateMarkerWidth(density)
835
+ val paragraphStart = effectiveParagraphStart(blockStack).coerceIn(0, builder.length)
836
+ if (paragraphStart < end) {
837
+ if (currentBlock.listContext != null) {
838
+ pendingLeadingMargins[paragraphStart] = PendingLeadingMargin(
839
+ indentPx = indent.toInt(),
840
+ restIndentPx = (indent + markerWidth).toInt()
841
+ )
842
+ } else if (indent > 0) {
843
+ pendingLeadingMargins[paragraphStart] = PendingLeadingMargin(
844
+ indentPx = indent.toInt(),
845
+ restIndentPx = null
846
+ )
847
+ }
848
+ }
849
+
850
+ val lineHeight = resolveTextStyle(currentBlock.nodeType, theme).lineHeight
851
+ applyLineHeightSpan(builder, start, end, lineHeight, density)
852
+ }
853
+
854
+ private fun applyLineHeightSpan(
855
+ builder: SpannableStringBuilder,
856
+ start: Int,
857
+ end: Int,
858
+ lineHeight: Float?,
859
+ density: Float
860
+ ) {
861
+ if (lineHeight == null || lineHeight <= 0 || start >= end) {
862
+ return
863
+ }
864
+ builder.setSpan(
865
+ FixedLineHeightSpan((lineHeight * density).toInt()),
866
+ start,
867
+ end,
868
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
869
+ )
870
+ }
871
+
872
+ private fun applyPendingLeadingMargins(
873
+ builder: SpannableStringBuilder,
874
+ pendingLeadingMargins: Map<Int, PendingLeadingMargin>
875
+ ) {
876
+ if (pendingLeadingMargins.isEmpty()) return
877
+
878
+ val text = builder.toString()
879
+ pendingLeadingMargins.toSortedMap().forEach { (paragraphStart, spec) ->
880
+ if (paragraphStart >= builder.length) {
881
+ return@forEach
882
+ }
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)
895
+ }
896
+ }
897
+
898
+
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 {
906
+ val indentPerDepth = (theme?.list?.indent ?: LayoutConstants.INDENT_PER_DEPTH) * density
907
+ return context.depth * indentPerDepth
908
+ }
909
+
910
+ private fun effectiveBlockContext(blockStack: List<BlockContext>): BlockContext? {
911
+ val currentBlock = blockStack.lastOrNull() ?: return null
912
+ if (currentBlock.listContext != null) {
913
+ return currentBlock
914
+ }
915
+ val inheritedListContext = blockStack
916
+ .dropLast(1)
917
+ .asReversed()
918
+ .firstOrNull { it.listContext != null }
919
+ ?.listContext ?: return currentBlock
920
+ return currentBlock.copy(listContext = inheritedListContext, markerPending = false)
921
+ }
922
+
923
+ private fun effectiveParagraphStart(blockStack: List<BlockContext>): Int {
924
+ val currentBlock = blockStack.lastOrNull() ?: return 0
925
+ if (currentBlock.listContext != null) {
926
+ return currentBlock.renderStart
927
+ }
928
+ return blockStack
929
+ .dropLast(1)
930
+ .asReversed()
931
+ .firstOrNull { it.listContext != null }
932
+ ?.renderStart
933
+ ?: currentBlock.renderStart
934
+ }
935
+
936
+ private fun consumePendingListMarker(
937
+ blockStack: MutableList<BlockContext>,
938
+ markerRenderStart: Int
939
+ ): JSONObject? {
940
+ if (blockStack.size < 2) return null
941
+ for (idx in blockStack.lastIndex - 1 downTo 0) {
942
+ val context = blockStack[idx]
943
+ if (!context.markerPending) continue
944
+ context.markerPending = false
945
+ context.renderStart = markerRenderStart
946
+ return context.listContext
947
+ }
948
+ return null
949
+ }
950
+
951
+ private fun calculateMarkerWidth(density: Float): Float {
952
+ return LayoutConstants.LIST_MARKER_WIDTH * density
953
+ }
954
+
955
+ private fun resolveTextStyle(nodeType: String, theme: EditorTheme?): EditorTextStyle {
956
+ return theme?.effectiveTextStyle(nodeType) ?: EditorTextStyle()
957
+ }
958
+
959
+ private fun resolveInlineTextColor(
960
+ blockStack: List<BlockContext>,
961
+ fallbackColor: Int,
962
+ theme: EditorTheme?
963
+ ): Int {
964
+ val nodeType = effectiveBlockContext(blockStack)?.nodeType ?: "paragraph"
965
+ return resolveTextStyle(nodeType, theme).color ?: fallbackColor
966
+ }
967
+
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
+ fun listMarkerString(listContext: JSONObject): String {
977
+ val ordered = listContext.optBoolean("ordered", false)
978
+ return if (ordered) {
979
+ val index = listContext.optInt("index", 1)
980
+ "$index. "
981
+ } else {
982
+ LayoutConstants.UNORDERED_LIST_BULLET
983
+ }
984
+ }
985
+
986
+ // ── Private Helpers ─────────────────────────────────────────────────
987
+
988
+ /**
989
+ * Parse a [JSONArray] of marks into a list of mark identifiers.
990
+ *
991
+ * Each mark can be either a plain string (e.g. "bold") or a JSON object
992
+ * (e.g. `{"type": "link", "href": "https://..."}`). Returns a mixed list
993
+ * of [String] and [JSONObject].
994
+ */
995
+ private fun parseMarks(marksArray: JSONArray?): List<Any> {
996
+ if (marksArray == null || marksArray.length() == 0) return emptyList()
997
+ val marks = mutableListOf<Any>()
998
+ 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
+ }
1008
+ }
1009
+ }
1010
+ return marks
1011
+ }
1012
+
1013
+ /**
1014
+ * Append a newline used between blocks (inter-block separator).
1015
+ *
1016
+ * When [spacingPx] > 0, applies a [ParagraphSpacerSpan] to the newline
1017
+ * character to create vertical spacing after the preceding block.
1018
+ */
1019
+ private fun appendInterBlockNewline(
1020
+ builder: SpannableStringBuilder,
1021
+ baseFontSize: Float,
1022
+ textColor: Int,
1023
+ spacingPx: Int = 0
1024
+ ) {
1025
+ val start = builder.length
1026
+ builder.append("\n")
1027
+ val end = builder.length
1028
+ if (spacingPx > 0) {
1029
+ builder.setSpan(
1030
+ ParagraphSpacerSpan(spacingPx, baseFontSize.toInt(), textColor),
1031
+ start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1032
+ )
1033
+ } else {
1034
+ builder.setSpan(
1035
+ ForegroundColorSpan(textColor),
1036
+ start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1037
+ )
1038
+ builder.setSpan(
1039
+ AbsoluteSizeSpan(baseFontSize.toInt(), false),
1040
+ start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1041
+ )
1042
+ }
1043
+ }
1044
+ }