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