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