@openeditor/react-native-prose-editor 0.0.6 → 0.0.8

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 (68) hide show
  1. package/android/build.gradle +35 -0
  2. package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +2 -2
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +138 -597
  4. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +13 -3
  5. package/android/src/main/java/com/apollohg/editor/InputSnapshotSupport.kt +6 -0
  6. package/android/src/main/java/com/apollohg/editor/NativeBlockEditorSurface.kt +1086 -0
  7. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +1 -1
  8. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +26 -50
  9. package/android/src/main/java/com/apollohg/editor/NativeImageLoader.kt +89 -0
  10. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +31 -66
  11. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +98 -33
  12. package/dist/EditorToolbar.d.ts +1 -1
  13. package/dist/EditorToolbar.js +19 -0
  14. package/dist/NativeEditorBridge.d.ts +62 -35
  15. package/dist/NativeEditorBridge.js +122 -79
  16. package/dist/NativeProseViewer.d.ts +13 -34
  17. package/dist/NativeProseViewer.js +40 -465
  18. package/dist/NativeRichTextEditor.d.ts +10 -1
  19. package/dist/NativeRichTextEditor.js +72 -34
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +2 -1
  22. package/dist/schemas.d.ts +11 -0
  23. package/dist/schemas.js +149 -0
  24. package/dist/useNativeEditor.d.ts +3 -3
  25. package/dist/useNativeEditor.js +3 -3
  26. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  27. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  28. package/ios/Generated_editor_core.swift +97 -2
  29. package/ios/NativeBlockEditorSurface.swift +1393 -0
  30. package/ios/NativeEditorExpoView.swift +3 -3
  31. package/ios/NativeEditorModule.swift +30 -45
  32. package/ios/NativeInputSupport.swift +396 -0
  33. package/ios/PositionBridge.swift +4 -4
  34. package/ios/RichTextEditorView.swift +333 -786
  35. package/ios/editor_coreFFI/editor_coreFFI.h +147 -70
  36. package/package.json +1 -1
  37. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  38. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  39. package/rust/android/x86_64/libeditor_core.so +0 -0
  40. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2087 -1399
  41. package/android/src/androidTest/AndroidManifest.xml +0 -8
  42. package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +0 -53
  43. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +0 -241
  44. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +0 -338
  45. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +0 -789
  46. package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +0 -350
  47. package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +0 -5
  48. package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +0 -315
  49. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +0 -2172
  50. package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +0 -482
  51. package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +0 -137
  52. package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +0 -321
  53. package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +0 -4202
  54. package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +0 -2581
  55. package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +0 -27
  56. package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +0 -197
  57. package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +0 -84
  58. package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +0 -544
  59. package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +0 -461
  60. package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +0 -2054
  61. package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +0 -1367
  62. package/ios/EditorLayoutManager.swift +0 -774
  63. package/ios/NativeProseViewerExpoView.swift +0 -276
  64. package/ios/RenderBridge.swift +0 -1708
  65. package/ios/Tests/NativePerformanceTests.swift +0 -687
  66. package/ios/Tests/PositionBridgeTests.swift +0 -706
  67. package/ios/Tests/RenderBridgeTests.swift +0 -2236
  68. package/ios/Tests/RichTextEditorViewTests.swift +0 -6548
@@ -1,2172 +0,0 @@
1
- package com.openeditor.editor
2
-
3
- import android.graphics.Bitmap
4
- import android.graphics.BitmapFactory
5
- import android.graphics.Canvas
6
- import android.graphics.Color
7
- import android.graphics.Paint
8
- import android.graphics.RectF
9
- import android.graphics.Typeface
10
- import android.util.Base64
11
- import android.util.Log
12
- import android.util.LruCache
13
- import android.text.Annotation
14
- import android.text.Layout
15
- import android.text.SpannableStringBuilder
16
- import android.text.Spanned
17
- import android.text.style.AbsoluteSizeSpan
18
- import android.text.style.BackgroundColorSpan
19
- import android.text.style.ForegroundColorSpan
20
- import android.text.style.LeadingMarginSpan
21
- import android.text.style.LineBackgroundSpan
22
- import android.text.style.LineHeightSpan
23
- import android.text.style.ReplacementSpan
24
- import android.text.style.StrikethroughSpan
25
- import android.text.style.StyleSpan
26
- import android.text.style.TypefaceSpan
27
- import android.text.style.UnderlineSpan
28
- import android.widget.TextView
29
- import org.json.JSONArray
30
- import org.json.JSONObject
31
- import java.lang.ref.WeakReference
32
- import java.net.URL
33
- import java.util.concurrent.LinkedBlockingQueue
34
- import java.util.concurrent.ThreadPoolExecutor
35
- import java.util.concurrent.TimeUnit
36
-
37
- object LayoutConstants {
38
- /** Base indentation per depth level (pixels at base scale). */
39
- const val INDENT_PER_DEPTH: Float = 24f
40
-
41
- /** Width reserved for the list bullet/number (pixels at base scale). */
42
- const val LIST_MARKER_WIDTH: Float = 36f
43
-
44
- /** Gap between the list marker and the text that follows (pixels at base scale). */
45
- const val LIST_MARKER_TEXT_GAP: Float = 8f
46
-
47
- /** Height of the horizontal rule separator line (pixels). */
48
- const val HORIZONTAL_RULE_HEIGHT: Float = 1f
49
-
50
- /** Vertical padding above and below the horizontal rule (pixels). */
51
- const val HORIZONTAL_RULE_VERTICAL_PADDING: Float = 8f
52
-
53
- /** Total leading inset reserved for each blockquote depth. */
54
- const val BLOCKQUOTE_INDENT: Float = 18f
55
-
56
- /** Width of the rendered blockquote border bar (pixels at base scale). */
57
- const val BLOCKQUOTE_BORDER_WIDTH: Float = 3f
58
-
59
- /** Gap between the blockquote border bar and the text that follows. */
60
- const val BLOCKQUOTE_MARKER_GAP: Float = 8f
61
-
62
- /** Bullet character for unordered list items. */
63
- const val UNORDERED_LIST_BULLET: String = "\u2022 "
64
-
65
- /** Scale factor applied only to unordered list marker glyphs. */
66
- const val UNORDERED_LIST_MARKER_FONT_SCALE: Float = 2.0f
67
-
68
- /** Default visual treatment for link text when no explicit theme color exists. */
69
- const val DEFAULT_LINK_COLOR: Int = 0xFF1B73E8.toInt()
70
-
71
- /** Object replacement character used for void block elements. */
72
- const val OBJECT_REPLACEMENT_CHARACTER: String = "\uFFFC"
73
-
74
- /** Zero-width placeholder used to preserve trailing hard-break lines. */
75
- const val SYNTHETIC_PLACEHOLDER_CHARACTER: String = "\u200B"
76
-
77
- /** Background color for inline code spans (light gray). */
78
- const val CODE_BACKGROUND_COLOR: Int = 0x1A000000 // 10% black
79
-
80
- }
81
-
82
- data class BlockContext(
83
- val nodeType: String,
84
- val depth: Int,
85
- val listContext: JSONObject?,
86
- val topLevelChildIndex: Int? = null,
87
- var markerPending: Boolean = false,
88
- var renderStart: Int = 0
89
- )
90
-
91
- private data class PendingLeadingMargin(
92
- val indentPx: Int,
93
- val restIndentPx: Int?,
94
- val blockquoteIndentPx: Int = 0,
95
- val blockquoteStripeColor: Int? = null,
96
- val blockquoteStripeWidthPx: Int = 0,
97
- val blockquoteGapWidthPx: Int = 0,
98
- val blockquoteBaseIndentPx: Int = 0
99
- )
100
-
101
- private data class PendingCodeBlockSpan(
102
- val start: Int,
103
- val end: Int
104
- )
105
-
106
- class BlockquoteSpan(
107
- private val baseIndentPx: Int,
108
- private val totalIndentPx: Int,
109
- private val stripeColor: Int,
110
- private val stripeWidthPx: Int,
111
- private val gapWidthPx: Int
112
- ) : LeadingMarginSpan {
113
-
114
- override fun getLeadingMargin(first: Boolean): Int = totalIndentPx
115
-
116
- override fun drawLeadingMargin(
117
- canvas: Canvas,
118
- paint: Paint,
119
- x: Int,
120
- dir: Int,
121
- top: Int,
122
- baseline: Int,
123
- bottom: Int,
124
- text: CharSequence,
125
- start: Int,
126
- end: Int,
127
- first: Boolean,
128
- layout: android.text.Layout?
129
- ) {
130
- if (!lineContainsQuotedContent(text, start, end)) {
131
- return
132
- }
133
-
134
- val savedColor = paint.color
135
- val savedStyle = paint.style
136
-
137
- paint.color = stripeColor
138
- paint.style = Paint.Style.FILL
139
-
140
- val stripeStart = x + (dir * baseIndentPx)
141
- val stripeLeft = if (dir > 0) stripeStart.toFloat() else (stripeStart - stripeWidthPx).toFloat()
142
- val stripeRight = if (dir > 0) stripeLeft + stripeWidthPx else stripeLeft + stripeWidthPx
143
- val stripeBottom = resolvedStripeBottom(
144
- text = text,
145
- start = start,
146
- end = end,
147
- baseline = baseline,
148
- bottom = bottom,
149
- layout = layout,
150
- paint = paint
151
- )
152
- canvas.drawRect(
153
- stripeLeft,
154
- top.toFloat(),
155
- stripeRight,
156
- stripeBottom,
157
- paint
158
- )
159
-
160
- paint.color = savedColor
161
- paint.style = savedStyle
162
- }
163
-
164
- private fun lineContainsQuotedContent(text: CharSequence, start: Int, end: Int): Boolean {
165
- if (start >= end || text !is Spanned) return true
166
- for (index in start until end.coerceAtMost(text.length)) {
167
- val ch = text[index]
168
- if (ch == '\n' || ch == '\r') continue
169
- val quoted = text.getSpans(index, index + 1, Annotation::class.java).any {
170
- it.key == RenderBridge.NATIVE_BLOCKQUOTE_ANNOTATION
171
- }
172
- if (quoted) {
173
- return true
174
- }
175
- }
176
- return false
177
- }
178
-
179
- internal fun resolvedStripeBottom(
180
- text: CharSequence,
181
- start: Int,
182
- end: Int,
183
- baseline: Int,
184
- bottom: Int,
185
- layout: android.text.Layout?,
186
- paint: Paint? = null
187
- ): Float {
188
- if (layout == null || text.isEmpty()) {
189
- return bottom.toFloat()
190
- }
191
- val lineIndex = safeLineForOffset(layout, start, text.length)
192
- val nextLine = lineIndex + 1
193
- if (nextLine >= layout.lineCount) {
194
- return trimmedTextBottom(baseline, layout, lineIndex, paint)
195
- }
196
-
197
- val nextLineStart = layout.getLineStart(nextLine)
198
- val nextLineEnd = layout.getLineEnd(nextLine)
199
- return if (lineContainsQuotedContent(text, nextLineStart, nextLineEnd)) {
200
- bottom.toFloat()
201
- } else {
202
- trimmedTextBottom(baseline, layout, lineIndex, paint)
203
- }
204
- }
205
-
206
- private fun trimmedTextBottom(
207
- baseline: Int,
208
- layout: Layout,
209
- lineIndex: Int,
210
- paint: Paint?
211
- ): Float {
212
- val fontDescent = paint?.fontMetrics?.descent
213
- return if (fontDescent != null) {
214
- baseline + fontDescent
215
- } else {
216
- (baseline + layout.getLineDescent(lineIndex)).toFloat()
217
- }
218
- }
219
-
220
- private fun safeLineForOffset(layout: Layout, offset: Int, textLength: Int): Int {
221
- if (textLength <= 0) return 0
222
- val safeStart = offset.coerceIn(0, textLength - 1)
223
- return layout.getLineForOffset(safeStart)
224
- }
225
- }
226
-
227
- class CodeBlockSpan(
228
- private val backgroundColor: Int,
229
- private val cornerRadiusPx: Float,
230
- private val paddingHorizontalPx: Int,
231
- private val paddingVerticalPx: Int
232
- ) : LeadingMarginSpan, LineBackgroundSpan {
233
- var spanStart: Int = 0
234
- var spanEnd: Int = 0
235
-
236
- override fun getLeadingMargin(first: Boolean): Int = paddingHorizontalPx
237
-
238
- override fun drawBackground(
239
- canvas: Canvas,
240
- paint: Paint,
241
- left: Int,
242
- right: Int,
243
- top: Int,
244
- baseline: Int,
245
- bottom: Int,
246
- text: CharSequence,
247
- start: Int,
248
- end: Int,
249
- lineNumber: Int
250
- ) {
251
- if (start >= spanEnd || end <= spanStart) return
252
-
253
- val isFirstLine = start <= spanStart
254
- val isLastLine = end >= spanEnd
255
- val rect = RectF(
256
- left.toFloat(),
257
- if (isFirstLine) top.toFloat() - paddingVerticalPx else top.toFloat(),
258
- (right - paddingHorizontalPx).toFloat(),
259
- if (isLastLine) bottom.toFloat() + paddingVerticalPx else bottom.toFloat()
260
- )
261
-
262
- val savedColor = paint.color
263
- val savedStyle = paint.style
264
- paint.color = backgroundColor
265
- paint.style = Paint.Style.FILL
266
-
267
- when {
268
- isFirstLine && isLastLine -> canvas.drawRoundRect(rect, cornerRadiusPx, cornerRadiusPx, paint)
269
- isFirstLine -> {
270
- canvas.drawRoundRect(rect, cornerRadiusPx, cornerRadiusPx, paint)
271
- canvas.drawRect(rect.left, rect.centerY(), rect.right, rect.bottom, paint)
272
- }
273
- isLastLine -> {
274
- canvas.drawRoundRect(rect, cornerRadiusPx, cornerRadiusPx, paint)
275
- canvas.drawRect(rect.left, rect.top, rect.right, rect.centerY(), paint)
276
- }
277
- else -> canvas.drawRect(rect, paint)
278
- }
279
-
280
- paint.color = savedColor
281
- paint.style = savedStyle
282
- }
283
- }
284
-
285
- class HorizontalRuleSpan(
286
- private val lineColor: Int,
287
- private val lineHeight: Float = LayoutConstants.HORIZONTAL_RULE_HEIGHT,
288
- private val verticalPadding: Float = LayoutConstants.HORIZONTAL_RULE_VERTICAL_PADDING
289
- ) : ReplacementSpan(), LeadingMarginSpan {
290
-
291
- override fun getLeadingMargin(first: Boolean): Int = 0
292
-
293
- override fun getSize(
294
- paint: Paint,
295
- text: CharSequence,
296
- start: Int,
297
- end: Int,
298
- fm: Paint.FontMetricsInt?
299
- ): Int {
300
- if (fm != null) {
301
- val totalHeight = kotlin.math.ceil(lineHeight + (verticalPadding * 2)).toInt()
302
- val halfHeight = totalHeight / 2
303
- fm.ascent = -halfHeight
304
- fm.top = fm.ascent
305
- fm.descent = totalHeight - halfHeight
306
- fm.bottom = fm.descent
307
- }
308
- // Keep the placeholder atom in the text model without reserving
309
- // visible glyph width, so Android does not paint a tofu/OBJ box.
310
- return 0
311
- }
312
-
313
- override fun drawLeadingMargin(
314
- canvas: Canvas,
315
- paint: Paint,
316
- x: Int,
317
- dir: Int,
318
- top: Int,
319
- baseline: Int,
320
- bottom: Int,
321
- text: CharSequence,
322
- start: Int,
323
- end: Int,
324
- first: Boolean,
325
- layout: android.text.Layout?
326
- ) {
327
- val savedColor = paint.color
328
- val savedStyle = paint.style
329
-
330
- paint.color = lineColor
331
- paint.style = Paint.Style.FILL
332
-
333
- val lineY = (top + bottom) / 2f
334
- val lineWidth = layout?.width?.toFloat() ?: canvas.width.toFloat()
335
- canvas.drawRect(
336
- x.toFloat(),
337
- lineY - lineHeight / 2f,
338
- lineWidth,
339
- lineY + lineHeight / 2f,
340
- paint
341
- )
342
-
343
- paint.color = savedColor
344
- paint.style = savedStyle
345
- }
346
-
347
- override fun draw(
348
- canvas: Canvas,
349
- text: CharSequence,
350
- start: Int,
351
- end: Int,
352
- x: Float,
353
- top: Int,
354
- y: Int,
355
- bottom: Int,
356
- paint: Paint
357
- ) {
358
- // Intentionally empty: drawLeadingMargin renders the separator line,
359
- // and ReplacementSpan suppresses drawing the underlying FFFC glyph.
360
- }
361
- }
362
-
363
- internal object RenderImageDecoder {
364
- private const val MAX_DECODE_DIMENSION_PX = 2048
365
- internal const val LOG_TAG = "NativeEditorImage"
366
-
367
- fun decodeSource(source: String): Bitmap? {
368
- decodeDataUrlBytes(source)?.let { bytes ->
369
- val decoded = decodeBitmap(bytes)
370
- if (decoded == null) {
371
- Log.w(LOG_TAG, "decodeSource: failed to decode data URL bytes (${sourceSummary(source)})")
372
- } else {
373
- Log.d(
374
- LOG_TAG,
375
- "decodeSource: decoded data URL ${sourceSummary(source)} -> ${decoded.width}x${decoded.height}"
376
- )
377
- }
378
- return decoded
379
- }
380
- val remoteBytes = runCatching {
381
- URL(source).openStream().use { input ->
382
- input.readBytes()
383
- }
384
- }.getOrNull() ?: run {
385
- Log.w(LOG_TAG, "decodeSource: failed to load remote image (${sourceSummary(source)})")
386
- return null
387
- }
388
- val decoded = decodeBitmap(remoteBytes)
389
- if (decoded == null) {
390
- Log.w(LOG_TAG, "decodeSource: failed to decode remote bytes (${sourceSummary(source)})")
391
- }
392
- return decoded
393
- }
394
-
395
- fun decodeDataUrlBytes(source: String): ByteArray? {
396
- val trimmed = source.trim()
397
- if (!trimmed.startsWith("data:image/", ignoreCase = true)) return null
398
- val commaIndex = trimmed.indexOf(',')
399
- if (commaIndex <= 0) return null
400
- val metadata = trimmed.substring(0, commaIndex).lowercase()
401
- if (!metadata.contains(";base64")) return null
402
- val payload = trimmed.substring(commaIndex + 1)
403
- .filterNot(Char::isWhitespace)
404
-
405
- val decodeFlags = intArrayOf(
406
- Base64.DEFAULT,
407
- Base64.NO_WRAP,
408
- Base64.URL_SAFE or Base64.NO_WRAP,
409
- Base64.URL_SAFE
410
- )
411
- for (flags in decodeFlags) {
412
- val bytes = runCatching { Base64.decode(payload, flags) }.getOrNull()
413
- if (bytes != null) {
414
- return bytes
415
- }
416
- }
417
- Log.w(LOG_TAG, "decodeDataUrlBytes: unsupported base64 payload (${sourceSummary(source)})")
418
- return null
419
- }
420
-
421
- fun calculateInSampleSize(
422
- width: Int,
423
- height: Int,
424
- maxWidth: Int = MAX_DECODE_DIMENSION_PX,
425
- maxHeight: Int = MAX_DECODE_DIMENSION_PX
426
- ): Int {
427
- if (width <= 0 || height <= 0) return 1
428
-
429
- var sampleSize = 1
430
- var sampledWidth = width
431
- var sampledHeight = height
432
- while (sampledWidth > maxWidth || sampledHeight > maxHeight) {
433
- sampleSize *= 2
434
- sampledWidth = width / sampleSize
435
- sampledHeight = height / sampleSize
436
- }
437
- return sampleSize.coerceAtLeast(1)
438
- }
439
-
440
- private fun decodeBitmap(bytes: ByteArray): Bitmap? {
441
- val bounds = BitmapFactory.Options().apply {
442
- inJustDecodeBounds = true
443
- }
444
- BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
445
- if (bounds.outWidth <= 0 || bounds.outHeight <= 0) {
446
- Log.w(LOG_TAG, "decodeBitmap: invalid image bounds for ${bytes.size} bytes")
447
- return null
448
- }
449
-
450
- val options = BitmapFactory.Options().apply {
451
- inSampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight)
452
- }
453
- return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
454
- }
455
-
456
- private fun sourceSummary(source: String): String {
457
- val trimmed = source.trim()
458
- if (!trimmed.startsWith("data:image/", ignoreCase = true)) {
459
- return "urlLength=${trimmed.length}"
460
- }
461
- val commaIndex = trimmed.indexOf(',')
462
- if (commaIndex <= 0) {
463
- return "dataUrlLength=${trimmed.length}"
464
- }
465
- val metadata = trimmed.substring(0, commaIndex)
466
- val payloadLength = trimmed.length - commaIndex - 1
467
- return "$metadata payloadLength=$payloadLength"
468
- }
469
- }
470
-
471
- internal object RenderImageLoader {
472
- private val cache = object : LruCache<String, Bitmap>(32 * 1024 * 1024) {
473
- override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
474
- }
475
- private val executor =
476
- ThreadPoolExecutor(
477
- 2,
478
- 2,
479
- 30L,
480
- TimeUnit.SECONDS,
481
- LinkedBlockingQueue()
482
- )
483
- private val lock = Any()
484
- private val inFlight = mutableMapOf<String, MutableList<(Bitmap?) -> Unit>>()
485
-
486
- @Volatile
487
- internal var decodeSourceOverride: ((String) -> Bitmap?)? = null
488
-
489
- fun cached(source: String): Bitmap? = synchronized(cache) { cache.get(source) }
490
-
491
- internal fun resetForTesting() {
492
- synchronized(cache) {
493
- cache.evictAll()
494
- }
495
- synchronized(lock) {
496
- inFlight.clear()
497
- }
498
- decodeSourceOverride = null
499
- }
500
-
501
- fun load(source: String, onLoaded: (Bitmap?) -> Unit) {
502
- cached(source)?.let {
503
- onLoaded(it)
504
- return
505
- }
506
-
507
- if (source.trim().startsWith("data:image/", ignoreCase = true)) {
508
- val bitmap = decode(source)
509
- if (bitmap != null) {
510
- synchronized(cache) {
511
- cache.put(source, bitmap)
512
- }
513
- }
514
- onLoaded(bitmap)
515
- return
516
- }
517
-
518
- synchronized(lock) {
519
- val existing = inFlight[source]
520
- if (existing != null) {
521
- existing += onLoaded
522
- return
523
- }
524
- inFlight[source] = mutableListOf(onLoaded)
525
- }
526
-
527
- executor.execute {
528
- val bitmap = decode(source)
529
- if (bitmap != null) {
530
- synchronized(cache) {
531
- cache.put(source, bitmap)
532
- }
533
- }
534
- val callbacks = synchronized(lock) {
535
- inFlight.remove(source) ?: mutableListOf()
536
- }
537
- callbacks.forEach { it(bitmap) }
538
- }
539
- }
540
-
541
- private fun decode(source: String): Bitmap? {
542
- return decodeSourceOverride?.invoke(source) ?: RenderImageDecoder.decodeSource(source)
543
- }
544
- }
545
-
546
- internal class BlockImageSpan(
547
- private val source: String,
548
- hostView: TextView?,
549
- private val density: Float,
550
- private val preferredWidthDp: Float?,
551
- private val preferredHeightDp: Float?
552
- ) : ReplacementSpan() {
553
- private val hostRef = WeakReference(hostView)
554
-
555
- @Volatile
556
- private var bitmap: Bitmap? = RenderImageLoader.cached(source)
557
- @Volatile
558
- private var lastDrawRect: RectF? = null
559
-
560
- init {
561
- if (bitmap == null) {
562
- RenderImageLoader.load(source) { loaded ->
563
- if (loaded == null) {
564
- Log.w(
565
- RenderImageDecoder.LOG_TAG,
566
- "BlockImageSpan: loader returned null for image source"
567
- )
568
- return@load
569
- }
570
- bitmap = loaded
571
- hostRef.get()?.post {
572
- hostRef.get()?.requestLayout()
573
- hostRef.get()?.invalidate()
574
- (hostRef.get() as? EditorEditText)?.onSelectionOrContentMayChange?.invoke()
575
- }
576
- }
577
- }
578
- }
579
-
580
- override fun getSize(
581
- paint: Paint,
582
- text: CharSequence,
583
- start: Int,
584
- end: Int,
585
- fm: Paint.FontMetricsInt?
586
- ): Int {
587
- val (widthPx, heightPx) = currentSizePx()
588
- if (fm != null) {
589
- fm.ascent = -heightPx
590
- fm.descent = 0
591
- fm.top = fm.ascent
592
- fm.bottom = 0
593
- }
594
- return widthPx
595
- }
596
-
597
- override fun draw(
598
- canvas: Canvas,
599
- text: CharSequence,
600
- start: Int,
601
- end: Int,
602
- x: Float,
603
- top: Int,
604
- y: Int,
605
- bottom: Int,
606
- paint: Paint
607
- ) {
608
- val (widthPx, heightPx) = currentSizePx()
609
- val rect = RectF(
610
- x,
611
- (bottom - heightPx).toFloat(),
612
- x + widthPx,
613
- bottom.toFloat()
614
- )
615
- val host = hostRef.get()
616
- lastDrawRect = RectF(rect).apply {
617
- if (host != null) {
618
- offset(host.compoundPaddingLeft.toFloat(), host.extendedPaddingTop.toFloat())
619
- }
620
- }
621
- val loadedBitmap = bitmap
622
- if (loadedBitmap != null) {
623
- canvas.drawBitmap(loadedBitmap, null, rect, null)
624
- return
625
- }
626
-
627
- val previousColor = paint.color
628
- val previousStyle = paint.style
629
- paint.color = Color.argb(24, 0, 0, 0)
630
- paint.style = Paint.Style.FILL
631
- canvas.drawRoundRect(rect, 16f * density, 16f * density, paint)
632
- paint.color = Color.argb(120, 0, 0, 0)
633
- val iconRadius = minOf(rect.width(), rect.height()) * 0.12f
634
- canvas.drawCircle(rect.centerX(), rect.centerY(), iconRadius, paint)
635
- paint.color = previousColor
636
- paint.style = previousStyle
637
- }
638
-
639
- internal fun currentSizePx(): Pair<Int, Int> {
640
- val maxWidth = resolvedMaxWidth()
641
- val loadedBitmap = bitmap
642
- val fallbackAspectRatio = if (loadedBitmap != null && loadedBitmap.width > 0 && loadedBitmap.height > 0) {
643
- loadedBitmap.height.toFloat() / loadedBitmap.width.toFloat()
644
- } else {
645
- 0.56f
646
- }
647
-
648
- var widthPx = preferredWidthDp?.takeIf { it > 0f }?.times(density)
649
- var heightPx = preferredHeightDp?.takeIf { it > 0f }?.times(density)
650
-
651
- if (widthPx == null && heightPx == null && loadedBitmap != null && loadedBitmap.width > 0 && loadedBitmap.height > 0) {
652
- widthPx = loadedBitmap.width.toFloat()
653
- heightPx = loadedBitmap.height.toFloat()
654
- } else if (widthPx == null && heightPx != null) {
655
- widthPx = heightPx / fallbackAspectRatio
656
- } else if (heightPx == null && widthPx != null) {
657
- heightPx = widthPx * fallbackAspectRatio
658
- }
659
-
660
- if (widthPx == null || heightPx == null) {
661
- val placeholderWidth = maxWidth.coerceAtLeast(160f * density)
662
- val placeholderHeight = minOf(
663
- 180f * density,
664
- placeholderWidth * fallbackAspectRatio
665
- ).coerceAtLeast(96f * density)
666
- widthPx = placeholderWidth
667
- heightPx = placeholderHeight
668
- }
669
-
670
- val scale = minOf(1f, maxWidth / widthPx.coerceAtLeast(1f))
671
- return Pair(
672
- (widthPx * scale).toInt().coerceAtLeast(1),
673
- (heightPx * scale).toInt().coerceAtLeast(1)
674
- )
675
- }
676
-
677
- internal fun currentDrawRect(): RectF? = lastDrawRect?.let(::RectF)
678
-
679
- private fun resolvedMaxWidth(): Float {
680
- val host = hostRef.get()
681
- val hostWidth = host?.let {
682
- maxOf(it.width, it.measuredWidth) - it.totalPaddingLeft - it.totalPaddingRight
683
- } ?: 0
684
- return if (hostWidth > 0) hostWidth.toFloat() else 240f * density
685
- }
686
- }
687
-
688
- class FixedLineHeightSpan(
689
- private val lineHeightPx: Int
690
- ) : LineHeightSpan {
691
- override fun chooseHeight(
692
- text: CharSequence,
693
- start: Int,
694
- end: Int,
695
- spanstartv: Int,
696
- v: Int,
697
- fm: android.graphics.Paint.FontMetricsInt
698
- ) {
699
- val currentHeight = fm.descent - fm.ascent
700
- if (lineHeightPx <= 0 || currentHeight <= 0) return
701
- if (lineHeightPx == currentHeight) return
702
-
703
- val extra = lineHeightPx - currentHeight
704
- fm.descent += extra
705
- fm.bottom = fm.descent
706
- }
707
- }
708
-
709
- /**
710
- * Adds vertical spacing after a paragraph by increasing the descent of the
711
- * inter-block newline character.
712
- *
713
- * Uses [ReplacementSpan] (not [LineHeightSpan]/[android.text.style.ParagraphStyle])
714
- * because Android's StaticLayout normalizes ParagraphStyle metrics across all
715
- * lines in a paragraph, making per-line spacing impossible.
716
- *
717
- * ReplacementSpan only affects the single character it covers, so the extra
718
- * descent applies only to the newline's line — creating a gap below the
719
- * preceding paragraph without inflating other lines.
720
- */
721
- class ParagraphSpacerSpan(
722
- private val spacingPx: Int,
723
- private val baseFontSize: Int,
724
- private val textColor: Int
725
- ) : ReplacementSpan() {
726
- override fun getSize(
727
- paint: Paint,
728
- text: CharSequence,
729
- start: Int,
730
- end: Int,
731
- fm: Paint.FontMetricsInt?
732
- ): Int {
733
- if (fm != null && spacingPx > 0) {
734
- // Keep the natural ascent/top (from baseFontSize) so the newline
735
- // line doesn't shrink above the baseline. Add spacing as descent.
736
- val savedSize = paint.textSize
737
- paint.textSize = baseFontSize.toFloat()
738
- paint.getFontMetricsInt(fm)
739
- paint.textSize = savedSize
740
- fm.descent += spacingPx
741
- fm.bottom = fm.descent
742
- }
743
- return 0
744
- }
745
-
746
- override fun draw(
747
- canvas: Canvas,
748
- text: CharSequence,
749
- start: Int,
750
- end: Int,
751
- x: Float,
752
- top: Int,
753
- y: Int,
754
- bottom: Int,
755
- paint: Paint
756
- ) {
757
- // Draw nothing — pure spacing.
758
- }
759
- }
760
-
761
- class CenteredBulletSpan(
762
- private val textColor: Int,
763
- private val markerWidthPx: Float,
764
- private val bulletRadiusPx: Float,
765
- private val bodyFontSizePx: Float,
766
- private val markerGapToTextPx: Float
767
- ) : ReplacementSpan() {
768
- override fun getSize(
769
- paint: Paint,
770
- text: CharSequence,
771
- start: Int,
772
- end: Int,
773
- fm: Paint.FontMetricsInt?
774
- ): Int {
775
- return kotlin.math.ceil(markerWidthPx).toInt()
776
- }
777
-
778
- override fun draw(
779
- canvas: Canvas,
780
- text: CharSequence,
781
- start: Int,
782
- end: Int,
783
- x: Float,
784
- top: Int,
785
- y: Int,
786
- bottom: Int,
787
- paint: Paint
788
- ) {
789
- val previousColor = paint.color
790
- val previousStyle = paint.style
791
- val previousSize = paint.textSize
792
-
793
- paint.color = textColor
794
- paint.style = Paint.Style.FILL
795
-
796
- // Use body text metrics (not the marker's inflated font) for centering.
797
- paint.textSize = bodyFontSizePx
798
- val fm = paint.fontMetrics
799
- val centerX = resolvedCenterX(x)
800
- val centerY = y + (fm.ascent + fm.descent) / 2f
801
- canvas.drawCircle(centerX, centerY, bulletRadiusPx, paint)
802
-
803
- paint.color = previousColor
804
- paint.style = previousStyle
805
- paint.textSize = previousSize
806
- }
807
-
808
- fun textSideGapPx(x: Float): Float {
809
- return (x + markerWidthPx) - (resolvedCenterX(x) + bulletRadiusPx)
810
- }
811
-
812
- private fun resolvedCenterX(x: Float): Float {
813
- return x + markerWidthPx - markerGapToTextPx - bulletRadiusPx
814
- }
815
- }
816
-
817
- object RenderBridge {
818
- internal const val NATIVE_BLOCKQUOTE_ANNOTATION = "nativeBlockquote"
819
- internal const val NATIVE_TOP_LEVEL_CHILD_INDEX_ANNOTATION = "nativeTopLevelChildIndex"
820
- internal const val NATIVE_LINK_HREF_ANNOTATION = "nativeLinkHref"
821
- private const val NATIVE_SYNTHETIC_PLACEHOLDER_ANNOTATION = "nativeSyntheticPlaceholder"
822
-
823
- private data class RenderBuildState(
824
- val result: SpannableStringBuilder = SpannableStringBuilder(),
825
- val blockStack: MutableList<BlockContext> = mutableListOf(),
826
- val pendingLeadingMargins: MutableMap<Int, PendingLeadingMargin> = linkedMapOf(),
827
- val pendingCodeBlockSpans: MutableList<PendingCodeBlockSpan> = mutableListOf(),
828
- var isFirstBlock: Boolean = true,
829
- var nextBlockSpacingBefore: Float? = null
830
- )
831
-
832
- fun buildSpannable(
833
- json: String,
834
- baseFontSize: Float,
835
- textColor: Int,
836
- theme: EditorTheme? = null,
837
- density: Float = 1f,
838
- hostView: TextView? = null
839
- ): SpannableStringBuilder {
840
- val elements = try {
841
- JSONArray(json)
842
- } catch (_: Exception) {
843
- return SpannableStringBuilder()
844
- }
845
-
846
- return buildSpannableFromArray(elements, baseFontSize, textColor, theme, density, hostView)
847
- }
848
-
849
- fun buildSpannableFromArray(
850
- elements: JSONArray,
851
- baseFontSize: Float,
852
- textColor: Int,
853
- theme: EditorTheme? = null,
854
- density: Float = 1f,
855
- hostView: TextView? = null
856
- ): SpannableStringBuilder {
857
- val state = RenderBuildState()
858
- appendElements(
859
- state = state,
860
- elements = elements,
861
- baseFontSize = baseFontSize,
862
- textColor = textColor,
863
- theme = theme,
864
- density = density,
865
- hostView = hostView
866
- )
867
- applyPendingLeadingMargins(state.result, state.pendingLeadingMargins)
868
- applyPendingCodeBlockSpans(state.result, state.pendingCodeBlockSpans, theme, density)
869
- return state.result
870
- }
871
-
872
- fun buildSpannableFromBlocks(
873
- blocks: JSONArray,
874
- startIndex: Int = 0,
875
- baseFontSize: Float,
876
- textColor: Int,
877
- theme: EditorTheme? = null,
878
- density: Float = 1f,
879
- hostView: TextView? = null
880
- ): SpannableStringBuilder {
881
- val state = RenderBuildState()
882
- for (blockOffset in 0 until blocks.length()) {
883
- val blockElements = blocks.optJSONArray(blockOffset) ?: continue
884
- appendElements(
885
- state = state,
886
- elements = blockElements,
887
- baseFontSize = baseFontSize,
888
- textColor = textColor,
889
- theme = theme,
890
- density = density,
891
- hostView = hostView,
892
- topLevelChildIndex = startIndex + blockOffset
893
- )
894
- }
895
- applyPendingLeadingMargins(state.result, state.pendingLeadingMargins)
896
- applyPendingCodeBlockSpans(state.result, state.pendingCodeBlockSpans, theme, density)
897
- return state.result
898
- }
899
-
900
- fun measureHeight(
901
- json: String,
902
- themeJson: String?,
903
- width: Float,
904
- density: Float
905
- ): Float {
906
- if (width <= 0) return 0f
907
-
908
- val theme = EditorTheme.fromJson(themeJson)
909
- val baseFontSize = theme?.text?.fontSize
910
- ?: theme?.paragraph?.fontSize
911
- ?: 16f
912
-
913
- val spannable = buildSpannable(
914
- json = json,
915
- baseFontSize = baseFontSize,
916
- textColor = android.graphics.Color.BLACK,
917
- theme = theme,
918
- density = density,
919
- hostView = null
920
- )
921
-
922
- if (spannable.isEmpty()) return 0f
923
-
924
- val contentInsets = theme?.contentInsets
925
- val topInset = ((contentInsets?.top ?: 0f) * density).toInt()
926
- val bottomInset = ((contentInsets?.bottom ?: 0f) * density).toInt()
927
- val leftInset = ((contentInsets?.left ?: 0f) * density).toInt()
928
- val rightInset = ((contentInsets?.right ?: 0f) * density).toInt()
929
-
930
- val paint = android.text.TextPaint().apply {
931
- textSize = baseFontSize * density
932
- isAntiAlias = true
933
- }
934
-
935
- val availableWidth = (width - leftInset - rightInset).coerceAtLeast(0f).toInt()
936
-
937
- val staticLayout = android.text.StaticLayout.Builder
938
- .obtain(spannable, 0, spannable.length, paint, availableWidth)
939
- .setAlignment(android.text.Layout.Alignment.ALIGN_NORMAL)
940
- .setIncludePad(true)
941
- .build()
942
-
943
- val height = staticLayout.height + topInset + bottomInset
944
- return height.toFloat()
945
- }
946
-
947
- private fun appendElements(
948
- state: RenderBuildState,
949
- elements: JSONArray,
950
- baseFontSize: Float,
951
- textColor: Int,
952
- theme: EditorTheme?,
953
- density: Float,
954
- hostView: TextView?,
955
- topLevelChildIndex: Int? = null
956
- ) {
957
- for (i in 0 until elements.length()) {
958
- val element = elements.optJSONObject(i) ?: continue
959
- val type = element.optString("type", "")
960
-
961
- when (type) {
962
- "textRun" -> {
963
- val text = element.optString("text", "")
964
- val marksArray = element.optJSONArray("marks")
965
- val marks = parseMarks(marksArray)
966
- appendStyledText(
967
- state.result,
968
- text,
969
- marks,
970
- baseFontSize,
971
- textColor,
972
- state.blockStack,
973
- state.pendingLeadingMargins,
974
- theme,
975
- density
976
- )
977
- }
978
-
979
- "voidInline" -> {
980
- val nodeType = element.optString("nodeType", "")
981
- appendVoidInline(
982
- state.result,
983
- nodeType,
984
- baseFontSize,
985
- textColor,
986
- state.blockStack,
987
- state.pendingLeadingMargins,
988
- theme,
989
- density
990
- )
991
- }
992
-
993
- "voidBlock" -> {
994
- val nodeType = element.optString("nodeType", "")
995
- val attrs = element.optJSONObject("attrs")
996
- if (!state.isFirstBlock) {
997
- val spacingPx = ((state.nextBlockSpacingBefore ?: 0f) * density).toInt()
998
- appendInterBlockNewline(
999
- state.result,
1000
- baseFontSize,
1001
- textColor,
1002
- spacingPx,
1003
- topLevelChildIndex = topLevelChildIndex
1004
- )
1005
- }
1006
- state.isFirstBlock = false
1007
- val spacingBefore = theme?.effectiveTextStyle(nodeType)?.spacingAfter
1008
- ?: theme?.list?.itemSpacing
1009
- state.nextBlockSpacingBefore = spacingBefore
1010
- appendVoidBlock(
1011
- state.result,
1012
- nodeType,
1013
- attrs,
1014
- baseFontSize,
1015
- textColor,
1016
- theme,
1017
- density,
1018
- spacingBefore,
1019
- hostView,
1020
- topLevelChildIndex
1021
- )
1022
- }
1023
-
1024
- "opaqueInlineAtom" -> {
1025
- val nodeType = element.optString("nodeType", "")
1026
- val label = element.optString("label", "?")
1027
- val docPos = element.optInt("docPos", 0)
1028
- val mentionTheme = EditorMentionTheme.fromJson(
1029
- element.optJSONObject("mentionTheme")
1030
- )
1031
- appendOpaqueInlineAtom(
1032
- state.result,
1033
- nodeType,
1034
- label,
1035
- docPos,
1036
- baseFontSize,
1037
- textColor,
1038
- state.blockStack,
1039
- state.pendingLeadingMargins,
1040
- theme,
1041
- mentionTheme,
1042
- density
1043
- )
1044
- }
1045
-
1046
- "opaqueBlockAtom" -> {
1047
- val nodeType = element.optString("nodeType", "")
1048
- val label = element.optString("label", "?")
1049
- val docPos = element.optInt("docPos", 0)
1050
- val blockSpacing = theme?.effectiveTextStyle(nodeType)?.spacingAfter
1051
- if (!state.isFirstBlock) {
1052
- val spacingPx = ((state.nextBlockSpacingBefore ?: 0f) * density).toInt()
1053
- appendInterBlockNewline(
1054
- state.result,
1055
- baseFontSize,
1056
- textColor,
1057
- spacingPx,
1058
- topLevelChildIndex = topLevelChildIndex
1059
- )
1060
- }
1061
- state.isFirstBlock = false
1062
- state.nextBlockSpacingBefore = blockSpacing
1063
- appendOpaqueBlockAtom(
1064
- state.result,
1065
- nodeType,
1066
- label,
1067
- docPos,
1068
- baseFontSize,
1069
- textColor,
1070
- theme,
1071
- blockSpacing,
1072
- topLevelChildIndex
1073
- )
1074
- }
1075
-
1076
- "blockStart" -> {
1077
- val nodeType = element.optString("nodeType", "")
1078
- val depth = element.optInt("depth", 0)
1079
- val listContext = element.optJSONObject("listContext")
1080
- val isListItemContainer = isListItemNodeType(nodeType) && listContext != null
1081
- val isTransparentContainer = isTransparentContainer(nodeType)
1082
- val nestedListItemContainer =
1083
- isListItemContainer &&
1084
- state.blockStack.any {
1085
- isListItemNodeType(it.nodeType) && it.listContext != null
1086
- }
1087
- val blockSpacing = if (isListItemContainer) {
1088
- null
1089
- } else {
1090
- theme?.effectiveTextStyle(nodeType)?.spacingAfter
1091
- ?: (if (listContext != null) theme?.list?.itemSpacing else null)
1092
- }
1093
-
1094
- if (!isListItemContainer && !isTransparentContainer) {
1095
- if (!state.isFirstBlock) {
1096
- val spacingPx = ((state.nextBlockSpacingBefore ?: 0f) * density).toInt()
1097
- val nextBlockStack = state.blockStack + BlockContext(
1098
- nodeType = nodeType,
1099
- depth = depth,
1100
- listContext = listContext,
1101
- topLevelChildIndex = topLevelChildIndex,
1102
- markerPending = isListItemContainer,
1103
- renderStart = state.result.length
1104
- )
1105
- val inBlockquoteSeparator =
1106
- blockquoteDepth(nextBlockStack) > 0f && trailingRenderedContentHasBlockquote(state.result)
1107
- appendInterBlockNewline(
1108
- state.result,
1109
- baseFontSize,
1110
- textColor,
1111
- spacingPx,
1112
- inBlockquote = inBlockquoteSeparator,
1113
- topLevelChildIndex = topLevelChildIndex
1114
- )
1115
- }
1116
- state.isFirstBlock = false
1117
- state.nextBlockSpacingBefore = blockSpacing
1118
- } else if (nestedListItemContainer && theme?.list?.itemSpacing != null) {
1119
- state.nextBlockSpacingBefore = theme.list.itemSpacing
1120
- }
1121
-
1122
- val ctx = BlockContext(
1123
- nodeType = nodeType,
1124
- depth = depth,
1125
- listContext = listContext,
1126
- topLevelChildIndex = topLevelChildIndex,
1127
- markerPending = isListItemContainer,
1128
- renderStart = state.result.length
1129
- )
1130
- state.blockStack.add(ctx)
1131
-
1132
- val markerListContext = when {
1133
- isListItemContainer -> null
1134
- listContext != null -> listContext
1135
- else -> consumePendingListMarker(state.blockStack, state.result.length)
1136
- }
1137
-
1138
- if (markerListContext != null) {
1139
- val ordered = markerListContext.optBoolean("ordered", false)
1140
- val isTask = markerListContext.optString("kind", "") == "task"
1141
- val marker = listMarkerString(markerListContext)
1142
- val markerBaseSize =
1143
- resolveTextStyle(
1144
- nodeType,
1145
- theme,
1146
- blockquoteDepth(state.blockStack) > 0
1147
- ).fontSize?.times(density) ?: baseFontSize
1148
- val resolvedMarkerBaseSize = if (isTask) {
1149
- markerBaseSize * 1.55f
1150
- } else {
1151
- markerBaseSize
1152
- }
1153
- val markerTextStyle = resolveTextStyle(
1154
- nodeType,
1155
- theme,
1156
- blockquoteDepth(state.blockStack) > 0
1157
- )
1158
- appendStyledText(
1159
- state.result,
1160
- marker,
1161
- emptyList(),
1162
- resolvedMarkerBaseSize,
1163
- theme?.list?.markerColor ?: textColor,
1164
- state.blockStack,
1165
- state.pendingLeadingMargins,
1166
- null,
1167
- density,
1168
- applyBlockSpans = false
1169
- )
1170
- val markerStart = state.result.length - marker.length
1171
- val markerEnd = state.result.length
1172
- annotateTopLevelChild(state.result, markerStart, markerEnd, topLevelChildIndex)
1173
- if (!ordered && !isTask) {
1174
- val markerScale =
1175
- theme?.list?.markerScale ?: LayoutConstants.UNORDERED_LIST_MARKER_FONT_SCALE
1176
- val markerWidth = calculateMarkerWidth(density)
1177
- val bulletRadius = ((markerBaseSize * markerScale) * 0.16f).coerceAtLeast(2f * density)
1178
- state.result.setSpan(
1179
- CenteredBulletSpan(
1180
- textColor = theme?.list?.markerColor ?: textColor,
1181
- markerWidthPx = markerWidth,
1182
- bulletRadiusPx = bulletRadius,
1183
- bodyFontSizePx = resolvedMarkerBaseSize,
1184
- markerGapToTextPx = LayoutConstants.LIST_MARKER_TEXT_GAP * density
1185
- ),
1186
- markerStart,
1187
- markerEnd,
1188
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1189
- )
1190
- }
1191
- applyLineHeightSpan(
1192
- builder = state.result,
1193
- start = markerStart,
1194
- end = markerEnd,
1195
- lineHeight = markerTextStyle.lineHeight,
1196
- density = density
1197
- )
1198
- }
1199
- }
1200
-
1201
- "blockEnd" -> {
1202
- if (state.blockStack.isNotEmpty()) {
1203
- val endedBlock = state.blockStack.removeAt(state.blockStack.lastIndex)
1204
- appendTrailingHardBreakPlaceholderIfNeeded(
1205
- builder = state.result,
1206
- endedBlock = endedBlock,
1207
- remainingBlockStack = state.blockStack,
1208
- baseFontSize = baseFontSize,
1209
- textColor = textColor,
1210
- theme = theme,
1211
- density = density,
1212
- pendingLeadingMargins = state.pendingLeadingMargins
1213
- )
1214
- if (isListItemNodeType(endedBlock.nodeType) && endedBlock.listContext != null) {
1215
- state.nextBlockSpacingBefore = theme?.list?.itemSpacing
1216
- }
1217
- if (endedBlock.nodeType == "codeBlock" && endedBlock.renderStart < state.result.length) {
1218
- state.pendingCodeBlockSpans.add(
1219
- PendingCodeBlockSpan(
1220
- start = endedBlock.renderStart,
1221
- end = state.result.length
1222
- )
1223
- )
1224
- }
1225
- }
1226
- }
1227
- }
1228
- }
1229
- }
1230
-
1231
- /**
1232
- * Apply spans to a text run based on its mark names and append to the builder.
1233
- *
1234
- * Supported marks:
1235
- * - `bold` / `strong` -> [StyleSpan] with [Typeface.BOLD]
1236
- * - `italic` / `em` -> [StyleSpan] with [Typeface.ITALIC]
1237
- * - `underline` -> [UnderlineSpan]
1238
- * - `strike` / `strikethrough` -> [StrikethroughSpan]
1239
- * - `code` -> [TypefaceSpan] with "monospace" + [BackgroundColorSpan]
1240
- * - `link` -> [URLSpan] (when mark is an object with `href`)
1241
- *
1242
- * Multiple marks are combined on the same range.
1243
- */
1244
- private fun appendStyledText(
1245
- builder: SpannableStringBuilder,
1246
- text: String,
1247
- marks: List<Any>, // String or JSONObject for link marks
1248
- baseFontSize: Float,
1249
- textColor: Int,
1250
- blockStack: MutableList<BlockContext>,
1251
- pendingLeadingMargins: MutableMap<Int, PendingLeadingMargin>,
1252
- theme: EditorTheme?,
1253
- density: Float,
1254
- applyBlockSpans: Boolean = true
1255
- ) {
1256
- val start = builder.length
1257
- builder.append(text)
1258
- val end = builder.length
1259
-
1260
- if (start == end) return
1261
-
1262
- val currentBlock = effectiveBlockContext(blockStack)
1263
- val isCodeBlock = currentBlock?.nodeType == "codeBlock"
1264
- val textStyle = currentBlock?.let {
1265
- resolveTextStyle(
1266
- it.nodeType,
1267
- theme,
1268
- blockquoteDepth(blockStack) > 0
1269
- )
1270
- } ?: theme?.effectiveTextStyle("paragraph", inBlockquote = blockquoteDepth(blockStack) > 0)
1271
-
1272
- // Determine which marks are active.
1273
- var markBold = false
1274
- var markItalic = false
1275
- var markUnderline = false
1276
- var hasStrike = false
1277
- var hasCode = false
1278
- var isLink = false
1279
- var linkHref: String? = null
1280
- for (mark in marks) {
1281
- when {
1282
- mark is String -> when (mark) {
1283
- "bold", "strong" -> markBold = true
1284
- "italic", "em" -> markItalic = true
1285
- "underline" -> markUnderline = true
1286
- "strike", "strikethrough" -> hasStrike = true
1287
- "code" -> hasCode = true
1288
- }
1289
- mark is JSONObject -> {
1290
- val markType = mark.optString("type", "")
1291
- if (markType == "link") {
1292
- isLink = true
1293
- linkHref = mark.optString("href", "").takeIf { it.isNotBlank() }
1294
- }
1295
- }
1296
- }
1297
- }
1298
- val linkTheme = if (isLink) theme?.links else null
1299
- val effectiveTextStyle = textStyle?.mergedWith(linkTheme?.asTextStyle())
1300
- ?: linkTheme?.asTextStyle()
1301
- val resolvedTextSize = effectiveTextStyle?.fontSize?.times(density) ?: baseFontSize
1302
- val resolvedTextColor = if (isLink) {
1303
- effectiveTextStyle?.color ?: LayoutConstants.DEFAULT_LINK_COLOR
1304
- } else {
1305
- effectiveTextStyle?.color ?: textColor
1306
- }
1307
-
1308
- // Apply base styling.
1309
- builder.setSpan(
1310
- ForegroundColorSpan(resolvedTextColor),
1311
- start, end,
1312
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1313
- )
1314
- builder.setSpan(
1315
- AbsoluteSizeSpan(resolvedTextSize.toInt(), false),
1316
- start, end,
1317
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1318
- )
1319
- linkTheme?.backgroundColor?.let { backgroundColor ->
1320
- builder.setSpan(
1321
- BackgroundColorSpan(backgroundColor),
1322
- start,
1323
- end,
1324
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1325
- )
1326
- }
1327
- linkHref?.let { href ->
1328
- builder.setSpan(
1329
- Annotation(NATIVE_LINK_HREF_ANNOTATION, href),
1330
- start,
1331
- end,
1332
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1333
- )
1334
- }
1335
-
1336
- val typefaceStyle = effectiveTextStyle?.typefaceStyle()
1337
- val hasBold = markBold ||
1338
- typefaceStyle?.let { it == Typeface.BOLD || it == Typeface.BOLD_ITALIC } == true
1339
- val hasItalic = markItalic ||
1340
- typefaceStyle?.let { it == Typeface.ITALIC || it == Typeface.BOLD_ITALIC } == true
1341
- val hasUnderline = markUnderline || (isLink && (linkTheme?.underline ?: true))
1342
-
1343
- // Apply bold/italic as a combined StyleSpan.
1344
- if (hasBold && hasItalic) {
1345
- builder.setSpan(
1346
- StyleSpan(Typeface.BOLD_ITALIC), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1347
- )
1348
- } else if (hasBold) {
1349
- builder.setSpan(
1350
- StyleSpan(Typeface.BOLD), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1351
- )
1352
- } else if (hasItalic) {
1353
- builder.setSpan(
1354
- StyleSpan(Typeface.ITALIC), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1355
- )
1356
- }
1357
-
1358
- val fontFamily = effectiveTextStyle?.fontFamily
1359
- if (!hasCode && !isCodeBlock && !fontFamily.isNullOrBlank()) {
1360
- builder.setSpan(
1361
- TypefaceSpan(fontFamily),
1362
- start,
1363
- end,
1364
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1365
- )
1366
- }
1367
-
1368
- if (hasUnderline) {
1369
- builder.setSpan(UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
1370
- }
1371
-
1372
- if (hasStrike) {
1373
- builder.setSpan(StrikethroughSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
1374
- }
1375
-
1376
- if (hasCode || isCodeBlock) {
1377
- builder.setSpan(
1378
- TypefaceSpan("monospace"), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1379
- )
1380
- if (hasCode && !isCodeBlock) {
1381
- builder.setSpan(
1382
- BackgroundColorSpan(LayoutConstants.CODE_BACKGROUND_COLOR),
1383
- start,
1384
- end,
1385
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1386
- )
1387
- }
1388
- }
1389
-
1390
- // Apply block-level indentation spans if in a block context.
1391
- if (applyBlockSpans) {
1392
- applyBlockStyle(builder, start, end, blockStack, pendingLeadingMargins, theme, density)
1393
- }
1394
- }
1395
-
1396
- /**
1397
- * Append a void inline element (e.g. hardBreak) to the builder.
1398
- *
1399
- * A hardBreak is rendered as a newline character. Unknown void inlines
1400
- * are rendered as the object replacement character.
1401
- */
1402
- private fun appendVoidInline(
1403
- builder: SpannableStringBuilder,
1404
- nodeType: String,
1405
- baseFontSize: Float,
1406
- textColor: Int,
1407
- blockStack: MutableList<BlockContext>,
1408
- pendingLeadingMargins: MutableMap<Int, PendingLeadingMargin>,
1409
- theme: EditorTheme?,
1410
- density: Float
1411
- ) {
1412
- when (nodeType) {
1413
- "hardBreak" -> {
1414
- val start = builder.length
1415
- builder.append("\n")
1416
- val end = builder.length
1417
- builder.setSpan(
1418
- Annotation("nativeVoidNodeType", nodeType),
1419
- start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1420
- )
1421
- builder.setSpan(
1422
- ForegroundColorSpan(resolveInlineTextColor(blockStack, textColor, theme)),
1423
- start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1424
- )
1425
- applyBlockStyle(builder, start, end, blockStack, pendingLeadingMargins, theme, density)
1426
- }
1427
- else -> {
1428
- val start = builder.length
1429
- builder.append(LayoutConstants.OBJECT_REPLACEMENT_CHARACTER)
1430
- val end = builder.length
1431
- builder.setSpan(
1432
- ForegroundColorSpan(resolveInlineTextColor(blockStack, textColor, theme)),
1433
- start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1434
- )
1435
- applyBlockStyle(builder, start, end, blockStack, pendingLeadingMargins, theme, density)
1436
- }
1437
- }
1438
- }
1439
-
1440
- /**
1441
- * Append a void block element (e.g. horizontalRule) to the builder.
1442
- *
1443
- * Horizontal rules are rendered as the object replacement character
1444
- * with a [HorizontalRuleSpan] that draws a separator line.
1445
- */
1446
- private fun appendVoidBlock(
1447
- builder: SpannableStringBuilder,
1448
- nodeType: String,
1449
- attrs: JSONObject?,
1450
- baseFontSize: Float,
1451
- textColor: Int,
1452
- theme: EditorTheme?,
1453
- density: Float,
1454
- spacingBefore: Float?,
1455
- hostView: TextView?,
1456
- topLevelChildIndex: Int?
1457
- ) {
1458
- when (nodeType) {
1459
- "horizontalRule" -> {
1460
- val start = builder.length
1461
- builder.append(LayoutConstants.OBJECT_REPLACEMENT_CHARACTER)
1462
- val end = builder.length
1463
- // Apply a dim version of the text color for the rule line.
1464
- val ruleColor = theme?.horizontalRule?.color ?: Color.argb(
1465
- (Color.alpha(textColor) * 0.3f).toInt(),
1466
- Color.red(textColor),
1467
- Color.green(textColor),
1468
- Color.blue(textColor)
1469
- )
1470
- builder.setSpan(
1471
- HorizontalRuleSpan(
1472
- lineColor = ruleColor,
1473
- lineHeight = (theme?.horizontalRule?.thickness ?: LayoutConstants.HORIZONTAL_RULE_HEIGHT) * density,
1474
- verticalPadding = (theme?.horizontalRule?.verticalMargin ?: LayoutConstants.HORIZONTAL_RULE_VERTICAL_PADDING) * density
1475
- ),
1476
- start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1477
- )
1478
- annotateTopLevelChild(builder, start, end, topLevelChildIndex)
1479
- }
1480
- "image" -> {
1481
- val source = if (attrs != null && attrs.has("src") && !attrs.isNull("src")) {
1482
- attrs.optString("src", "").trim()
1483
- } else {
1484
- ""
1485
- }
1486
- val preferredWidthDp = attrs?.optPositiveFloat("width")
1487
- val preferredHeightDp = attrs?.optPositiveFloat("height")
1488
- if (source.isEmpty()) {
1489
- builder.append(LayoutConstants.OBJECT_REPLACEMENT_CHARACTER)
1490
- return
1491
- }
1492
- val start = builder.length
1493
- builder.append(LayoutConstants.OBJECT_REPLACEMENT_CHARACTER)
1494
- val end = builder.length
1495
- builder.setSpan(
1496
- BlockImageSpan(
1497
- source = source,
1498
- hostView = hostView,
1499
- density = density,
1500
- preferredWidthDp = preferredWidthDp,
1501
- preferredHeightDp = preferredHeightDp
1502
- ),
1503
- start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1504
- )
1505
- annotateTopLevelChild(builder, start, end, topLevelChildIndex)
1506
- }
1507
- else -> {
1508
- val start = builder.length
1509
- builder.append(LayoutConstants.OBJECT_REPLACEMENT_CHARACTER)
1510
- annotateTopLevelChild(builder, start, builder.length, topLevelChildIndex)
1511
- }
1512
- }
1513
- }
1514
-
1515
- private fun appendOpaqueInlineAtom(
1516
- builder: SpannableStringBuilder,
1517
- nodeType: String,
1518
- label: String,
1519
- docPos: Int,
1520
- baseFontSize: Float,
1521
- textColor: Int,
1522
- blockStack: MutableList<BlockContext>,
1523
- pendingLeadingMargins: MutableMap<Int, PendingLeadingMargin>,
1524
- theme: EditorTheme?,
1525
- mentionTheme: EditorMentionTheme?,
1526
- density: Float
1527
- ) {
1528
- val isMention = nodeType == "mention"
1529
- val text = if (isMention) label else "[$label]"
1530
- val start = builder.length
1531
- builder.append(text)
1532
- val end = builder.length
1533
- val resolvedMentionTheme = if (isMention) {
1534
- theme?.mentions?.mergedWith(mentionTheme) ?: mentionTheme
1535
- } else {
1536
- null
1537
- }
1538
- val inlineTextColor = if (isMention) {
1539
- resolvedMentionTheme?.textColor ?: resolveInlineTextColor(blockStack, textColor, theme)
1540
- } else {
1541
- resolveInlineTextColor(blockStack, textColor, theme)
1542
- }
1543
- builder.setSpan(
1544
- ForegroundColorSpan(inlineTextColor),
1545
- start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1546
- )
1547
- builder.setSpan(
1548
- BackgroundColorSpan(
1549
- if (isMention) {
1550
- resolvedMentionTheme?.backgroundColor ?: 0x1f1d4ed8
1551
- } else {
1552
- 0x20000000
1553
- }
1554
- ),
1555
- start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1556
- )
1557
- builder.setSpan(
1558
- Annotation("nativeVoidNodeType", nodeType),
1559
- start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1560
- )
1561
- builder.setSpan(
1562
- Annotation("nativeDocPos", docPos.toString()),
1563
- start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1564
- )
1565
- if (isMention && (resolvedMentionTheme?.fontWeight == "bold" ||
1566
- resolvedMentionTheme?.fontWeight?.toIntOrNull()?.let { it >= 600 } == true)
1567
- ) {
1568
- builder.setSpan(
1569
- StyleSpan(Typeface.BOLD),
1570
- start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1571
- )
1572
- }
1573
- applyBlockStyle(builder, start, end, blockStack, pendingLeadingMargins, theme, density)
1574
- }
1575
-
1576
- private fun appendOpaqueBlockAtom(
1577
- builder: SpannableStringBuilder,
1578
- nodeType: String,
1579
- label: String,
1580
- docPos: Int,
1581
- baseFontSize: Float,
1582
- textColor: Int,
1583
- theme: EditorTheme?,
1584
- spacingBefore: Float?,
1585
- topLevelChildIndex: Int?
1586
- ) {
1587
- val text = if (nodeType == "mention") label else "[$label]"
1588
- val start = builder.length
1589
- builder.append(text)
1590
- val end = builder.length
1591
- builder.setSpan(
1592
- ForegroundColorSpan(theme?.effectiveTextStyle("paragraph")?.color ?: textColor),
1593
- start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1594
- )
1595
- builder.setSpan(
1596
- BackgroundColorSpan(0x20000000), // light gray
1597
- start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1598
- )
1599
- builder.setSpan(
1600
- Annotation("nativeVoidNodeType", nodeType),
1601
- start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1602
- )
1603
- builder.setSpan(
1604
- Annotation("nativeDocPos", docPos.toString()),
1605
- start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1606
- )
1607
- annotateTopLevelChild(builder, start, end, topLevelChildIndex)
1608
- }
1609
-
1610
- private fun applyBlockStyle(
1611
- builder: SpannableStringBuilder,
1612
- start: Int,
1613
- end: Int,
1614
- blockStack: List<BlockContext>,
1615
- pendingLeadingMargins: MutableMap<Int, PendingLeadingMargin>,
1616
- theme: EditorTheme?,
1617
- density: Float
1618
- ) {
1619
- val currentBlock = effectiveBlockContext(blockStack) ?: return
1620
- val indent = calculateIndent(currentBlock, blockStack, theme, density)
1621
- val markerWidth = calculateMarkerWidth(density)
1622
- val quoteDepth = blockquoteDepth(blockStack)
1623
- val indentPerDepth = (theme?.list?.indent ?: LayoutConstants.INDENT_PER_DEPTH) * density
1624
- val listBaseIndentAdjustment =
1625
- calculateListBaseIndentAdjustment(currentBlock, theme, density)
1626
- val quoteStripeColor = if (quoteDepth > 0) {
1627
- theme?.blockquote?.borderColor ?: Color.argb(
1628
- (Color.alpha(resolveInlineTextColor(blockStack, Color.BLACK, theme)) * 0.3f).toInt(),
1629
- Color.red(resolveInlineTextColor(blockStack, Color.BLACK, theme)),
1630
- Color.green(resolveInlineTextColor(blockStack, Color.BLACK, theme)),
1631
- Color.blue(resolveInlineTextColor(blockStack, Color.BLACK, theme))
1632
- )
1633
- } else {
1634
- null
1635
- }
1636
- val quoteStripeWidth = ((theme?.blockquote?.borderWidth
1637
- ?: LayoutConstants.BLOCKQUOTE_BORDER_WIDTH) * density).toInt()
1638
- val quoteGapWidth = ((theme?.blockquote?.markerGap
1639
- ?: LayoutConstants.BLOCKQUOTE_MARKER_GAP) * density).toInt()
1640
- val quoteIndent = maxOf(
1641
- theme?.blockquote?.indent ?: LayoutConstants.BLOCKQUOTE_INDENT,
1642
- (theme?.blockquote?.markerGap ?: LayoutConstants.BLOCKQUOTE_MARKER_GAP) +
1643
- (theme?.blockquote?.borderWidth ?: LayoutConstants.BLOCKQUOTE_BORDER_WIDTH)
1644
- ) * density
1645
- val blockquoteIndentPx = (quoteDepth * quoteIndent).toInt()
1646
- val quoteBaseIndent = if (quoteDepth > 0) {
1647
- ((currentBlock.depth * indentPerDepth)
1648
- - (quoteDepth * indentPerDepth)
1649
- + listBaseIndentAdjustment
1650
- + ((quoteDepth - 1f) * quoteIndent)).toInt()
1651
- } else {
1652
- 0
1653
- }
1654
- val paragraphStart = renderedParagraphStart(
1655
- builder = builder,
1656
- candidateStart = effectiveParagraphStart(blockStack)
1657
- )
1658
- if (paragraphStart < end) {
1659
- if (currentBlock.listContext != null) {
1660
- pendingLeadingMargins[paragraphStart] = PendingLeadingMargin(
1661
- indentPx = indent.toInt(),
1662
- restIndentPx = (indent + markerWidth).toInt(),
1663
- blockquoteIndentPx = blockquoteIndentPx,
1664
- blockquoteStripeColor = quoteStripeColor,
1665
- blockquoteStripeWidthPx = quoteStripeWidth,
1666
- blockquoteGapWidthPx = quoteGapWidth,
1667
- blockquoteBaseIndentPx = quoteBaseIndent
1668
- )
1669
- } else if (indent > 0) {
1670
- pendingLeadingMargins[paragraphStart] = PendingLeadingMargin(
1671
- indentPx = indent.toInt(),
1672
- restIndentPx = null,
1673
- blockquoteIndentPx = blockquoteIndentPx,
1674
- blockquoteStripeColor = quoteStripeColor,
1675
- blockquoteStripeWidthPx = quoteStripeWidth,
1676
- blockquoteGapWidthPx = quoteGapWidth,
1677
- blockquoteBaseIndentPx = quoteBaseIndent
1678
- )
1679
- }
1680
- }
1681
-
1682
- if (quoteDepth > 0f) {
1683
- builder.setSpan(
1684
- Annotation(NATIVE_BLOCKQUOTE_ANNOTATION, "1"),
1685
- start,
1686
- end,
1687
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1688
- )
1689
- }
1690
- annotateTopLevelChild(builder, start, end, currentBlock.topLevelChildIndex)
1691
-
1692
- val lineHeight = resolveTextStyle(
1693
- currentBlock.nodeType,
1694
- theme,
1695
- quoteDepth > 0
1696
- ).lineHeight
1697
- applyLineHeightSpan(builder, start, end, lineHeight, density)
1698
- }
1699
-
1700
- private fun applyLineHeightSpan(
1701
- builder: SpannableStringBuilder,
1702
- start: Int,
1703
- end: Int,
1704
- lineHeight: Float?,
1705
- density: Float
1706
- ) {
1707
- if (lineHeight == null || lineHeight <= 0 || start >= end) {
1708
- return
1709
- }
1710
- builder.setSpan(
1711
- FixedLineHeightSpan((lineHeight * density).toInt()),
1712
- start,
1713
- end,
1714
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1715
- )
1716
- }
1717
-
1718
- private fun applyPendingLeadingMargins(
1719
- builder: SpannableStringBuilder,
1720
- pendingLeadingMargins: Map<Int, PendingLeadingMargin>
1721
- ) {
1722
- if (pendingLeadingMargins.isEmpty()) return
1723
-
1724
- val text = builder.toString()
1725
- val entries = pendingLeadingMargins.toSortedMap().entries.toList()
1726
- var index = 0
1727
- while (index < entries.size) {
1728
- val paragraphStart = entries[index].key
1729
- val spec = entries[index].value
1730
- if (paragraphStart >= builder.length) {
1731
- index += 1
1732
- continue
1733
- }
1734
- if (spec.blockquoteStripeColor != null) {
1735
- val paragraphEnd = blockquoteSpanEnd(builder, text, paragraphStart)
1736
- val quoteEntries = mutableListOf(entries[index])
1737
- var nextIndex = index + 1
1738
- while (nextIndex < entries.size && entries[nextIndex].key < paragraphEnd) {
1739
- quoteEntries.add(entries[nextIndex])
1740
- nextIndex += 1
1741
- }
1742
- index = nextIndex
1743
-
1744
- builder
1745
- .getSpans(0, builder.length, LeadingMarginSpan::class.java)
1746
- .filter { builder.getSpanStart(it) == paragraphStart }
1747
- .forEach(builder::removeSpan)
1748
-
1749
- builder.setSpan(
1750
- BlockquoteSpan(
1751
- baseIndentPx = spec.blockquoteBaseIndentPx,
1752
- totalIndentPx = spec.blockquoteIndentPx,
1753
- stripeColor = spec.blockquoteStripeColor,
1754
- stripeWidthPx = spec.blockquoteStripeWidthPx,
1755
- gapWidthPx = spec.blockquoteGapWidthPx
1756
- ),
1757
- paragraphStart,
1758
- paragraphEnd,
1759
- Spanned.SPAN_PARAGRAPH
1760
- )
1761
-
1762
- quoteEntries.forEach { (entryStart, entrySpec) ->
1763
- applyAdditionalLeadingMargin(
1764
- builder = builder,
1765
- text = text,
1766
- paragraphStart = entryStart,
1767
- spec = entrySpec
1768
- )
1769
- }
1770
- } else {
1771
- index += 1
1772
- val paragraphEnd = defaultParagraphEnd(text, builder.length, paragraphStart)
1773
- val span = spec.restIndentPx?.let {
1774
- LeadingMarginSpan.Standard(spec.indentPx, it)
1775
- } ?: LeadingMarginSpan.Standard(spec.indentPx)
1776
-
1777
- builder
1778
- .getSpans(0, builder.length, LeadingMarginSpan::class.java)
1779
- .filter { builder.getSpanStart(it) == paragraphStart }
1780
- .forEach(builder::removeSpan)
1781
-
1782
- builder.setSpan(span, paragraphStart, paragraphEnd, Spanned.SPAN_PARAGRAPH)
1783
- }
1784
- }
1785
- }
1786
-
1787
- private fun applyPendingCodeBlockSpans(
1788
- builder: SpannableStringBuilder,
1789
- pendingCodeBlockSpans: List<PendingCodeBlockSpan>,
1790
- theme: EditorTheme?,
1791
- density: Float
1792
- ) {
1793
- if (pendingCodeBlockSpans.isEmpty()) return
1794
-
1795
- val backgroundColor = theme?.codeBlock?.backgroundColor ?: LayoutConstants.CODE_BACKGROUND_COLOR
1796
- val cornerRadiusPx = (theme?.codeBlock?.borderRadius ?: 8f) * density
1797
- val paddingHorizontalPx = ((theme?.codeBlock?.paddingHorizontal ?: 12f) * density).toInt()
1798
- val paddingVerticalPx = ((theme?.codeBlock?.paddingVertical ?: 8f) * density).toInt()
1799
-
1800
- for (pending in pendingCodeBlockSpans) {
1801
- if (pending.start >= pending.end || pending.start >= builder.length) {
1802
- continue
1803
- }
1804
- val spanEnd = pending.end.coerceAtMost(builder.length)
1805
- val span = CodeBlockSpan(
1806
- backgroundColor = backgroundColor,
1807
- cornerRadiusPx = cornerRadiusPx,
1808
- paddingHorizontalPx = paddingHorizontalPx,
1809
- paddingVerticalPx = paddingVerticalPx
1810
- ).also {
1811
- it.spanStart = pending.start
1812
- it.spanEnd = spanEnd
1813
- }
1814
- builder.setSpan(
1815
- span,
1816
- pending.start,
1817
- spanEnd,
1818
- Spanned.SPAN_PARAGRAPH
1819
- )
1820
- }
1821
- }
1822
-
1823
- private fun applyAdditionalLeadingMargin(
1824
- builder: SpannableStringBuilder,
1825
- text: String,
1826
- paragraphStart: Int,
1827
- spec: PendingLeadingMargin
1828
- ) {
1829
- val extraFirstIndent = (spec.indentPx - spec.blockquoteIndentPx).coerceAtLeast(0)
1830
- val extraRestIndent = spec.restIndentPx?.let {
1831
- (it - spec.blockquoteIndentPx).coerceAtLeast(0)
1832
- }
1833
- if (extraRestIndent != null) {
1834
- builder.setSpan(
1835
- LeadingMarginSpan.Standard(extraFirstIndent, extraRestIndent),
1836
- paragraphStart,
1837
- defaultParagraphEnd(text, builder.length, paragraphStart),
1838
- Spanned.SPAN_PARAGRAPH
1839
- )
1840
- } else if (extraFirstIndent > 0) {
1841
- builder.setSpan(
1842
- LeadingMarginSpan.Standard(extraFirstIndent),
1843
- paragraphStart,
1844
- defaultParagraphEnd(text, builder.length, paragraphStart),
1845
- Spanned.SPAN_PARAGRAPH
1846
- )
1847
- }
1848
- }
1849
-
1850
-
1851
- private fun calculateIndent(
1852
- context: BlockContext,
1853
- blockStack: List<BlockContext>,
1854
- theme: EditorTheme?,
1855
- density: Float
1856
- ): Float {
1857
- val indentPerDepth = (theme?.list?.indent ?: LayoutConstants.INDENT_PER_DEPTH) * density
1858
- val quoteDepth = blockquoteDepth(blockStack)
1859
- val columnsDepth = columnContainerDepth(blockStack)
1860
- val quoteIndent = maxOf(
1861
- theme?.blockquote?.indent ?: LayoutConstants.BLOCKQUOTE_INDENT,
1862
- (theme?.blockquote?.markerGap ?: LayoutConstants.BLOCKQUOTE_MARKER_GAP) +
1863
- (theme?.blockquote?.borderWidth ?: LayoutConstants.BLOCKQUOTE_BORDER_WIDTH)
1864
- ) * density
1865
- val listBaseIndentAdjustment = calculateListBaseIndentAdjustment(context, theme, density)
1866
- return (context.depth * indentPerDepth) -
1867
- (quoteDepth * indentPerDepth) +
1868
- -(columnsDepth * indentPerDepth) +
1869
- listBaseIndentAdjustment +
1870
- (quoteDepth * quoteIndent)
1871
- }
1872
-
1873
- private fun calculateListBaseIndentAdjustment(
1874
- context: BlockContext,
1875
- theme: EditorTheme?,
1876
- density: Float
1877
- ): Float {
1878
- if (context.listContext == null) {
1879
- return 0f
1880
- }
1881
-
1882
- val indentPerDepth = (theme?.list?.indent ?: LayoutConstants.INDENT_PER_DEPTH) * density
1883
- val listBaseIndentMultiplier = maxOf(theme?.list?.baseIndentMultiplier ?: 1f, 0f)
1884
- return (listBaseIndentMultiplier - 1f) * indentPerDepth
1885
- }
1886
-
1887
- private fun effectiveBlockContext(blockStack: List<BlockContext>): BlockContext? {
1888
- val currentBlock = blockStack.lastOrNull() ?: return null
1889
- if (currentBlock.listContext != null) {
1890
- return currentBlock
1891
- }
1892
- val inheritedListBlock = blockStack
1893
- .dropLast(1)
1894
- .asReversed()
1895
- .firstOrNull { it.listContext != null }
1896
- ?: return currentBlock
1897
- return currentBlock.copy(
1898
- depth = currentBlock.depth,
1899
- listContext = inheritedListBlock.listContext,
1900
- markerPending = false
1901
- )
1902
- }
1903
-
1904
- private fun effectiveParagraphStart(blockStack: List<BlockContext>): Int {
1905
- val currentBlock = blockStack.lastOrNull() ?: return 0
1906
- if (currentBlock.listContext != null) {
1907
- return currentBlock.renderStart
1908
- }
1909
- return blockStack
1910
- .dropLast(1)
1911
- .asReversed()
1912
- .firstOrNull { it.listContext != null }
1913
- ?.renderStart
1914
- ?: currentBlock.renderStart
1915
- }
1916
-
1917
- private fun renderedParagraphStart(
1918
- builder: CharSequence,
1919
- candidateStart: Int
1920
- ): Int {
1921
- val boundedStart = candidateStart.coerceIn(0, builder.length)
1922
- if (boundedStart == 0) return 0
1923
-
1924
- for (index in boundedStart - 1 downTo 0) {
1925
- if (builder[index] == '\n') {
1926
- return index + 1
1927
- }
1928
- }
1929
- return 0
1930
- }
1931
-
1932
- private fun consumePendingListMarker(
1933
- blockStack: MutableList<BlockContext>,
1934
- markerRenderStart: Int
1935
- ): JSONObject? {
1936
- if (blockStack.size < 2) return null
1937
- for (idx in blockStack.lastIndex - 1 downTo 0) {
1938
- val context = blockStack[idx]
1939
- if (!context.markerPending) continue
1940
- context.markerPending = false
1941
- context.renderStart = markerRenderStart
1942
- return context.listContext
1943
- }
1944
- return null
1945
- }
1946
-
1947
- private fun calculateMarkerWidth(density: Float): Float {
1948
- return LayoutConstants.LIST_MARKER_WIDTH * density
1949
- }
1950
-
1951
- private fun blockquoteDepth(blockStack: List<BlockContext>): Float {
1952
- return blockStack.count { it.nodeType == "blockquote" }.toFloat()
1953
- }
1954
-
1955
- private fun columnContainerDepth(blockStack: List<BlockContext>): Float {
1956
- return blockStack.count { it.nodeType == "columns" || it.nodeType == "column" }.toFloat()
1957
- }
1958
-
1959
- private fun isTransparentContainer(nodeType: String): Boolean {
1960
- return nodeType == "blockquote" || nodeType == "columns" || nodeType == "column"
1961
- }
1962
-
1963
- private fun resolveTextStyle(
1964
- nodeType: String,
1965
- theme: EditorTheme?,
1966
- inBlockquote: Boolean = false
1967
- ): EditorTextStyle {
1968
- return theme?.effectiveTextStyle(nodeType, inBlockquote) ?: EditorTextStyle()
1969
- }
1970
-
1971
- private fun resolveInlineTextColor(
1972
- blockStack: List<BlockContext>,
1973
- fallbackColor: Int,
1974
- theme: EditorTheme?
1975
- ): Int {
1976
- val nodeType = effectiveBlockContext(blockStack)?.nodeType ?: "paragraph"
1977
- return resolveTextStyle(nodeType, theme, blockquoteDepth(blockStack) > 0).color ?: fallbackColor
1978
- }
1979
-
1980
- fun listMarkerString(listContext: JSONObject): String {
1981
- if (listContext.optString("kind", "") == "task") {
1982
- return if (listContext.optBoolean("checked", false)) "\u2611 " else "\u2610 "
1983
- }
1984
- val ordered = listContext.optBoolean("ordered", false)
1985
- return if (ordered) {
1986
- val index = listContext.optInt("index", 1)
1987
- "$index. "
1988
- } else {
1989
- LayoutConstants.UNORDERED_LIST_BULLET
1990
- }
1991
- }
1992
-
1993
- private fun isListItemNodeType(nodeType: String): Boolean {
1994
- return nodeType == "listItem" || nodeType == "taskItem"
1995
- }
1996
-
1997
- /**
1998
- * Parse a [JSONArray] of marks into a list of mark identifiers.
1999
- *
2000
- * Each mark can be either a plain string (e.g. "bold") or a JSON object
2001
- * (e.g. `{"type": "link", "href": "https://..."}`). Returns a mixed list
2002
- * of [String] and [JSONObject].
2003
- */
2004
- private fun parseMarks(marksArray: JSONArray?): List<Any> {
2005
- if (marksArray == null || marksArray.length() == 0) return emptyList()
2006
- val marks = mutableListOf<Any>()
2007
- for (i in 0 until marksArray.length()) {
2008
- when (val mark = marksArray.opt(i)) {
2009
- is String -> marks.add(mark)
2010
- is JSONObject -> marks.add(mark)
2011
- }
2012
- }
2013
- return marks
2014
- }
2015
-
2016
- /**
2017
- * Append a newline used between blocks (inter-block separator).
2018
- *
2019
- * When [spacingPx] > 0, applies a [ParagraphSpacerSpan] to the newline
2020
- * character to create vertical spacing after the preceding block.
2021
- */
2022
- private fun appendInterBlockNewline(
2023
- builder: SpannableStringBuilder,
2024
- baseFontSize: Float,
2025
- textColor: Int,
2026
- spacingPx: Int = 0,
2027
- inBlockquote: Boolean = false,
2028
- topLevelChildIndex: Int? = null
2029
- ) {
2030
- val start = builder.length
2031
- builder.append("\n")
2032
- val end = builder.length
2033
- if (spacingPx > 0) {
2034
- builder.setSpan(
2035
- ParagraphSpacerSpan(spacingPx, baseFontSize.toInt(), textColor),
2036
- start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
2037
- )
2038
- } else {
2039
- builder.setSpan(
2040
- ForegroundColorSpan(textColor),
2041
- start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
2042
- )
2043
- builder.setSpan(
2044
- AbsoluteSizeSpan(baseFontSize.toInt(), false),
2045
- start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
2046
- )
2047
- }
2048
- annotateTopLevelChild(builder, start, end, topLevelChildIndex)
2049
- if (inBlockquote) {
2050
- builder.setSpan(
2051
- Annotation(NATIVE_BLOCKQUOTE_ANNOTATION, "1"),
2052
- start,
2053
- end,
2054
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
2055
- )
2056
- }
2057
- }
2058
-
2059
- private fun appendTrailingHardBreakPlaceholderIfNeeded(
2060
- builder: SpannableStringBuilder,
2061
- endedBlock: BlockContext,
2062
- remainingBlockStack: List<BlockContext>,
2063
- baseFontSize: Float,
2064
- textColor: Int,
2065
- theme: EditorTheme?,
2066
- density: Float,
2067
- pendingLeadingMargins: MutableMap<Int, PendingLeadingMargin>
2068
- ) {
2069
- if (builder.isEmpty()) return
2070
- if (isListItemNodeType(endedBlock.nodeType)) return
2071
- if (!lastCharacterIsHardBreak(builder)) return
2072
-
2073
- val start = builder.length
2074
- builder.append(LayoutConstants.SYNTHETIC_PLACEHOLDER_CHARACTER)
2075
- val end = builder.length
2076
- builder.setSpan(
2077
- Annotation(NATIVE_SYNTHETIC_PLACEHOLDER_ANNOTATION, "1"),
2078
- start,
2079
- end,
2080
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
2081
- )
2082
- builder.setSpan(
2083
- ForegroundColorSpan(resolveInlineTextColor(remainingBlockStack + endedBlock, textColor, theme)),
2084
- start,
2085
- end,
2086
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
2087
- )
2088
- applyBlockStyle(
2089
- builder,
2090
- start,
2091
- end,
2092
- remainingBlockStack + endedBlock,
2093
- pendingLeadingMargins,
2094
- theme,
2095
- density
2096
- )
2097
- }
2098
-
2099
- private fun lastCharacterIsHardBreak(builder: SpannableStringBuilder): Boolean {
2100
- if (builder.isEmpty()) return false
2101
- val lastIndex = builder.length - 1
2102
- return builder.getSpans(lastIndex, builder.length, Annotation::class.java).any {
2103
- it.key == "nativeVoidNodeType" && it.value == "hardBreak"
2104
- }
2105
- }
2106
-
2107
- private fun annotateTopLevelChild(
2108
- builder: SpannableStringBuilder,
2109
- start: Int,
2110
- end: Int,
2111
- topLevelChildIndex: Int?
2112
- ) {
2113
- if (topLevelChildIndex == null || start >= end) return
2114
- builder.setSpan(
2115
- Annotation(NATIVE_TOP_LEVEL_CHILD_INDEX_ANNOTATION, topLevelChildIndex.toString()),
2116
- start,
2117
- end,
2118
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
2119
- )
2120
- }
2121
-
2122
- private fun trailingRenderedContentHasBlockquote(builder: Spanned): Boolean {
2123
- for (index in builder.length - 1 downTo 0) {
2124
- val ch = builder[index]
2125
- if (ch == '\n' || ch == '\r') continue
2126
- return hasBlockquoteAnnotationAt(builder, index)
2127
- }
2128
- return false
2129
- }
2130
-
2131
- private fun defaultParagraphEnd(text: String, length: Int, paragraphStart: Int): Int {
2132
- val newlineIndex = text.indexOf('\n', paragraphStart)
2133
- return if (newlineIndex >= 0) newlineIndex + 1 else length
2134
- }
2135
-
2136
- private fun blockquoteSpanEnd(
2137
- builder: Spanned,
2138
- text: String,
2139
- paragraphStart: Int
2140
- ): Int {
2141
- var cursor = paragraphStart
2142
- while (cursor < builder.length) {
2143
- val newlineIndex = text.indexOf('\n', cursor)
2144
- if (newlineIndex < 0) {
2145
- return builder.length
2146
- }
2147
- val newlineQuoted = hasBlockquoteAnnotationAt(builder, newlineIndex)
2148
- val nextIndex = newlineIndex + 1
2149
- val nextQuoted = nextIndex < builder.length && hasBlockquoteAnnotationAt(builder, nextIndex)
2150
-
2151
- if (!newlineQuoted && !nextQuoted) {
2152
- return nextIndex
2153
- }
2154
- cursor = nextIndex
2155
- }
2156
- return builder.length
2157
- }
2158
-
2159
- private fun hasBlockquoteAnnotationAt(text: Spanned, index: Int): Boolean {
2160
- if (index < 0 || index >= text.length) return false
2161
- return text.getSpans(index, index + 1, Annotation::class.java).any {
2162
- it.key == NATIVE_BLOCKQUOTE_ANNOTATION
2163
- }
2164
- }
2165
- }
2166
-
2167
- private fun JSONObject.optPositiveFloat(key: String): Float? {
2168
- if (!has(key) || isNull(key)) return null
2169
- val value = optDouble(key, Double.NaN)
2170
- if (value.isNaN() || value <= 0.0) return null
2171
- return value.toFloat()
2172
- }