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