@openeditor/react-native-prose-editor 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/build.gradle +35 -0
- package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +2 -2
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +138 -597
- package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +13 -3
- package/android/src/main/java/com/apollohg/editor/InputSnapshotSupport.kt +6 -0
- package/android/src/main/java/com/apollohg/editor/NativeBlockEditorSurface.kt +1086 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +1 -1
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +26 -50
- package/android/src/main/java/com/apollohg/editor/NativeImageLoader.kt +89 -0
- package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +31 -66
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +98 -33
- package/dist/EditorToolbar.d.ts +1 -1
- package/dist/EditorToolbar.js +19 -0
- package/dist/NativeEditorBridge.d.ts +62 -35
- package/dist/NativeEditorBridge.js +122 -79
- package/dist/NativeProseViewer.d.ts +13 -34
- package/dist/NativeProseViewer.js +40 -465
- package/dist/NativeRichTextEditor.d.ts +10 -1
- package/dist/NativeRichTextEditor.js +72 -34
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/schemas.d.ts +11 -0
- package/dist/schemas.js +149 -0
- package/dist/useNativeEditor.d.ts +3 -3
- package/dist/useNativeEditor.js +3 -3
- 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/Generated_editor_core.swift +97 -2
- package/ios/NativeBlockEditorSurface.swift +1393 -0
- package/ios/NativeEditorExpoView.swift +3 -3
- package/ios/NativeEditorModule.swift +30 -45
- package/ios/NativeInputSupport.swift +396 -0
- package/ios/PositionBridge.swift +4 -4
- package/ios/RichTextEditorView.swift +333 -786
- package/ios/editor_coreFFI/editor_coreFFI.h +147 -70
- package/package.json +1 -1
- 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 +2087 -1399
- package/android/src/androidTest/AndroidManifest.xml +0 -8
- package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +0 -53
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +0 -241
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +0 -338
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +0 -789
- package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +0 -350
- package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +0 -5
- package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +0 -315
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +0 -2172
- package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +0 -482
- package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +0 -137
- package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +0 -321
- package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +0 -4202
- package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +0 -2581
- package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +0 -27
- package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +0 -197
- package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +0 -84
- package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +0 -544
- package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +0 -461
- package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +0 -2054
- package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +0 -1367
- package/ios/EditorLayoutManager.swift +0 -774
- package/ios/NativeProseViewerExpoView.swift +0 -276
- package/ios/RenderBridge.swift +0 -1708
- package/ios/Tests/NativePerformanceTests.swift +0 -687
- package/ios/Tests/PositionBridgeTests.swift +0 -706
- package/ios/Tests/RenderBridgeTests.swift +0 -2236
- package/ios/Tests/RichTextEditorViewTests.swift +0 -6548
|
@@ -1,482 +0,0 @@
|
|
|
1
|
-
package com.openeditor.editor
|
|
2
|
-
|
|
3
|
-
import android.graphics.Color
|
|
4
|
-
import java.util.Locale
|
|
5
|
-
import kotlin.math.sqrt
|
|
6
|
-
import org.json.JSONArray
|
|
7
|
-
import org.json.JSONObject
|
|
8
|
-
import uniffi.editor_core.*
|
|
9
|
-
|
|
10
|
-
internal data class TimingStats(
|
|
11
|
-
val name: String,
|
|
12
|
-
val samplesNanos: List<Long>
|
|
13
|
-
) {
|
|
14
|
-
val averageMillis: Double = samplesNanos.average() / 1_000_000.0
|
|
15
|
-
|
|
16
|
-
private val relativeStdDev: Double = run {
|
|
17
|
-
if (samplesNanos.size <= 1) {
|
|
18
|
-
0.0
|
|
19
|
-
} else {
|
|
20
|
-
val average = samplesNanos.average()
|
|
21
|
-
val variance = samplesNanos
|
|
22
|
-
.map { sample -> (sample - average) * (sample - average) }
|
|
23
|
-
.average()
|
|
24
|
-
if (average == 0.0) 0.0 else sqrt(variance) / average
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
fun summaryString(tag: String = "NativePerformanceTest"): String {
|
|
29
|
-
val formattedSamples = samplesNanos.joinToString(", ") { sample ->
|
|
30
|
-
String.format(Locale.US, "%.3f", sample / 1_000_000.0)
|
|
31
|
-
}
|
|
32
|
-
return buildString {
|
|
33
|
-
append("[")
|
|
34
|
-
append(tag)
|
|
35
|
-
append("] ")
|
|
36
|
-
append(name)
|
|
37
|
-
append(" avg=")
|
|
38
|
-
append(String.format(Locale.US, "%.3fms", averageMillis))
|
|
39
|
-
append(" rsd=")
|
|
40
|
-
append(String.format(Locale.US, "%.3f%%", relativeStdDev * 100.0))
|
|
41
|
-
append(" samplesMs=[")
|
|
42
|
-
append(formattedSamples)
|
|
43
|
-
append("]")
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
internal data class ApplyUpdateTraceStats(
|
|
49
|
-
val name: String,
|
|
50
|
-
val traces: List<EditorEditText.ApplyUpdateTrace>
|
|
51
|
-
) {
|
|
52
|
-
private fun average(selector: (EditorEditText.ApplyUpdateTrace) -> Long): Double =
|
|
53
|
-
if (traces.isEmpty()) 0.0 else traces.map(selector).average() / 1_000_000.0
|
|
54
|
-
|
|
55
|
-
fun summaryString(tag: String = "NativePerformanceTest"): String {
|
|
56
|
-
return buildString {
|
|
57
|
-
append("[")
|
|
58
|
-
append(tag)
|
|
59
|
-
append("] ")
|
|
60
|
-
append(name)
|
|
61
|
-
append(" avgMs={")
|
|
62
|
-
append("parse=")
|
|
63
|
-
append(String.format(Locale.US, "%.3f", average { it.parseNanos }))
|
|
64
|
-
append(", resolveBlocks=")
|
|
65
|
-
append(String.format(Locale.US, "%.3f", average { it.resolveRenderBlocksNanos }))
|
|
66
|
-
append(", patchEligibility=")
|
|
67
|
-
append(String.format(Locale.US, "%.3f", average { it.patchEligibilityNanos }))
|
|
68
|
-
append(", buildRender=")
|
|
69
|
-
append(String.format(Locale.US, "%.3f", average { it.buildRenderNanos }))
|
|
70
|
-
append(", applyRender=")
|
|
71
|
-
append(String.format(Locale.US, "%.3f", average { it.applyRenderNanos }))
|
|
72
|
-
append(", selection=")
|
|
73
|
-
append(String.format(Locale.US, "%.3f", average { it.selectionNanos }))
|
|
74
|
-
append(", postApply=")
|
|
75
|
-
append(String.format(Locale.US, "%.3f", average { it.postApplyNanos }))
|
|
76
|
-
append(", total=")
|
|
77
|
-
append(String.format(Locale.US, "%.3f", average { it.totalNanos }))
|
|
78
|
-
append("} patchUsage=")
|
|
79
|
-
append(traces.count { it.usedPatch })
|
|
80
|
-
append("/")
|
|
81
|
-
append(traces.size)
|
|
82
|
-
append(" skippedRender=")
|
|
83
|
-
append(traces.count { it.skippedRender })
|
|
84
|
-
append("/")
|
|
85
|
-
append(traces.size)
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
internal object NativePerformanceFixtureFactory {
|
|
91
|
-
private const val blockCount = 96
|
|
92
|
-
private const val paragraphCharacterCount = 180
|
|
93
|
-
private const val patchInsertIndex = 24
|
|
94
|
-
|
|
95
|
-
fun largeRenderJson(): String = largeRenderElements().toString()
|
|
96
|
-
|
|
97
|
-
fun largeDocumentJson(): String = JSONObject()
|
|
98
|
-
.put("type", "doc")
|
|
99
|
-
.put("content", largeDocumentContent())
|
|
100
|
-
.toString()
|
|
101
|
-
|
|
102
|
-
fun largeUpdateJson(): String {
|
|
103
|
-
val renderBlocks = largeRenderBlocks()
|
|
104
|
-
return JSONObject()
|
|
105
|
-
.put("renderBlocks", renderBlocks)
|
|
106
|
-
.toString()
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
fun largePatchedUpdateJson(): String {
|
|
110
|
-
val originalBlocks = largeRenderBlocks()
|
|
111
|
-
val insertedBlock = emptyParagraphRenderBlock()
|
|
112
|
-
val patchedBlocks = JSONArray()
|
|
113
|
-
for (index in 0 until originalBlocks.length()) {
|
|
114
|
-
if (index == patchInsertIndex) {
|
|
115
|
-
patchedBlocks.put(insertedBlock)
|
|
116
|
-
}
|
|
117
|
-
patchedBlocks.put(cloneJsonArray(originalBlocks.optJSONArray(index) ?: JSONArray()))
|
|
118
|
-
}
|
|
119
|
-
if (patchInsertIndex >= originalBlocks.length()) {
|
|
120
|
-
patchedBlocks.put(insertedBlock)
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
val startIndex = if (patchInsertIndex > 0) patchInsertIndex - 1 else 0
|
|
124
|
-
val oldDeleteCount = when {
|
|
125
|
-
originalBlocks.length() == 0 -> 0
|
|
126
|
-
patchInsertIndex == 0 -> minOf(1, originalBlocks.length())
|
|
127
|
-
patchInsertIndex >= originalBlocks.length() -> 1
|
|
128
|
-
else -> 2
|
|
129
|
-
}
|
|
130
|
-
val patchBlocks = JSONArray()
|
|
131
|
-
for (index in startIndex until minOf(patchedBlocks.length(), startIndex + oldDeleteCount + 1)) {
|
|
132
|
-
patchBlocks.put(cloneJsonArray(patchedBlocks.optJSONArray(index) ?: JSONArray()))
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return JSONObject()
|
|
136
|
-
.put(
|
|
137
|
-
"renderPatch",
|
|
138
|
-
JSONObject()
|
|
139
|
-
.put("startIndex", startIndex)
|
|
140
|
-
.put("deleteCount", oldDeleteCount)
|
|
141
|
-
.put("renderBlocks", patchBlocks)
|
|
142
|
-
)
|
|
143
|
-
.toString()
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
fun loadLargeDocumentIntoEditor(editorId: ULong): String {
|
|
147
|
-
editorSetJson(editorId, largeDocumentJson())
|
|
148
|
-
return editorGetCurrentState(editorId)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
fun remoteSelections(
|
|
152
|
-
totalScalar: Int,
|
|
153
|
-
peerCount: Int = 6,
|
|
154
|
-
selectionWidth: Int = 0
|
|
155
|
-
): List<RemoteSelectionDecoration> {
|
|
156
|
-
val upperBound = maxOf(1, totalScalar - 1)
|
|
157
|
-
val colors = listOf(
|
|
158
|
-
Color.parseColor("#007AFF"),
|
|
159
|
-
Color.parseColor("#34C759"),
|
|
160
|
-
Color.parseColor("#FF9500"),
|
|
161
|
-
Color.parseColor("#FF2D55"),
|
|
162
|
-
Color.parseColor("#AF52DE"),
|
|
163
|
-
Color.parseColor("#30B0C7"),
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
return evenlySpacedValues(1, upperBound, peerCount)
|
|
167
|
-
.mapIndexed { index, scalar ->
|
|
168
|
-
val head = if (selectionWidth > 0 && index % 2 == 1) {
|
|
169
|
-
minOf(upperBound, scalar + selectionWidth)
|
|
170
|
-
} else {
|
|
171
|
-
scalar
|
|
172
|
-
}
|
|
173
|
-
RemoteSelectionDecoration(
|
|
174
|
-
clientId = index + 1,
|
|
175
|
-
anchor = scalar,
|
|
176
|
-
head = head,
|
|
177
|
-
color = colors[index % colors.size],
|
|
178
|
-
name = "Peer ${index + 1}",
|
|
179
|
-
isFocused = true
|
|
180
|
-
)
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
fun typingCursorOffset(renderedText: CharSequence): Int =
|
|
185
|
-
selectionScrubOffsets(renderedText, points = 1).firstOrNull() ?: 0
|
|
186
|
-
|
|
187
|
-
fun selectionScrubOffsets(renderedText: CharSequence, points: Int): List<Int> {
|
|
188
|
-
val candidates = renderedText
|
|
189
|
-
.mapIndexedNotNull { index, char ->
|
|
190
|
-
when (char) {
|
|
191
|
-
'\uFFFC', '\u200B', '\n', '\r' -> null
|
|
192
|
-
else -> index
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
if (candidates.isEmpty()) {
|
|
197
|
-
return listOf(0)
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
return evenlySpacedValues(0, candidates.lastIndex, points).map { candidates[it] }
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
private fun largeDocumentContent(): JSONArray {
|
|
204
|
-
val content = JSONArray()
|
|
205
|
-
content.put(
|
|
206
|
-
JSONObject()
|
|
207
|
-
.put("type", "h1")
|
|
208
|
-
.put("content", JSONArray().put(textNode(textFragment(seed = 10_000, minCharacterCount = 40))))
|
|
209
|
-
)
|
|
210
|
-
|
|
211
|
-
for (index in 0 until blockCount) {
|
|
212
|
-
if (index > 0 && index % 18 == 0) {
|
|
213
|
-
content.put(JSONObject().put("type", "horizontalRule"))
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
when {
|
|
217
|
-
index % 12 == 5 -> {
|
|
218
|
-
content.put(
|
|
219
|
-
JSONObject()
|
|
220
|
-
.put("type", "blockquote")
|
|
221
|
-
.put(
|
|
222
|
-
"content",
|
|
223
|
-
JSONArray().put(
|
|
224
|
-
JSONObject()
|
|
225
|
-
.put("type", "paragraph")
|
|
226
|
-
.put(
|
|
227
|
-
"content",
|
|
228
|
-
richInlineDocContent(
|
|
229
|
-
seed = index,
|
|
230
|
-
totalCharacters = paragraphCharacterCount
|
|
231
|
-
)
|
|
232
|
-
)
|
|
233
|
-
)
|
|
234
|
-
)
|
|
235
|
-
)
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
index % 9 == 3 -> {
|
|
239
|
-
content.put(
|
|
240
|
-
JSONObject()
|
|
241
|
-
.put("type", "h2")
|
|
242
|
-
.put(
|
|
243
|
-
"content",
|
|
244
|
-
JSONArray().put(
|
|
245
|
-
textNode(
|
|
246
|
-
textFragment(seed = index + 2_000, minCharacterCount = 72)
|
|
247
|
-
)
|
|
248
|
-
)
|
|
249
|
-
)
|
|
250
|
-
)
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
else -> {
|
|
254
|
-
content.put(
|
|
255
|
-
JSONObject()
|
|
256
|
-
.put("type", "paragraph")
|
|
257
|
-
.put(
|
|
258
|
-
"content",
|
|
259
|
-
richInlineDocContent(
|
|
260
|
-
seed = index,
|
|
261
|
-
totalCharacters = paragraphCharacterCount
|
|
262
|
-
)
|
|
263
|
-
)
|
|
264
|
-
)
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
return content
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
private fun richInlineDocContent(seed: Int, totalCharacters: Int): JSONArray {
|
|
273
|
-
val text = textFragment(seed = seed, minCharacterCount = totalCharacters)
|
|
274
|
-
val cutA = text.length / 4
|
|
275
|
-
val cutB = text.length / 2
|
|
276
|
-
val cutC = (text.length * 3) / 4
|
|
277
|
-
|
|
278
|
-
val content = JSONArray()
|
|
279
|
-
appendTextNode(content, text.substring(0, cutA))
|
|
280
|
-
appendTextNode(content, text.substring(cutA, cutB), JSONArray().put("bold"))
|
|
281
|
-
appendTextNode(content, text.substring(cutB, cutC), JSONArray().put("italic"))
|
|
282
|
-
appendTextNode(
|
|
283
|
-
content,
|
|
284
|
-
text.substring(cutC),
|
|
285
|
-
JSONArray().put(
|
|
286
|
-
JSONObject()
|
|
287
|
-
.put("type", "link")
|
|
288
|
-
.put(
|
|
289
|
-
"attrs",
|
|
290
|
-
JSONObject()
|
|
291
|
-
.put("href", "https://example.com/item/$seed")
|
|
292
|
-
.put("target", "_blank")
|
|
293
|
-
.put("rel", "noopener noreferrer nofollow")
|
|
294
|
-
.put("class", JSONObject.NULL)
|
|
295
|
-
.put("title", JSONObject.NULL)
|
|
296
|
-
)
|
|
297
|
-
)
|
|
298
|
-
)
|
|
299
|
-
return content
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
private fun appendTextNode(content: JSONArray, text: String, marks: JSONArray? = null) {
|
|
303
|
-
if (text.isEmpty()) return
|
|
304
|
-
val node = textNode(text)
|
|
305
|
-
if (marks != null && marks.length() > 0) {
|
|
306
|
-
node.put("marks", marks)
|
|
307
|
-
}
|
|
308
|
-
content.put(node)
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
private fun textNode(text: String): JSONObject =
|
|
312
|
-
JSONObject()
|
|
313
|
-
.put("type", "text")
|
|
314
|
-
.put("text", text)
|
|
315
|
-
|
|
316
|
-
private fun evenlySpacedValues(start: Int, endInclusive: Int, count: Int): List<Int> {
|
|
317
|
-
if (count <= 1 || endInclusive <= start) {
|
|
318
|
-
return listOf(start.coerceAtMost(endInclusive))
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
return (0 until count).map { index ->
|
|
322
|
-
start + (((endInclusive - start).toLong() * index.toLong()) / (count - 1).toLong()).toInt()
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
private fun largeRenderElements(): JSONArray = flattenRenderBlocks(largeRenderBlocks())
|
|
327
|
-
|
|
328
|
-
private fun largeRenderBlocks(): JSONArray {
|
|
329
|
-
val blocks = JSONArray()
|
|
330
|
-
|
|
331
|
-
blocks.put(
|
|
332
|
-
JSONArray().apply {
|
|
333
|
-
appendBlockStart(this, nodeType = "h1", depth = 0)
|
|
334
|
-
appendTextRun(this, textFragment(seed = 10_000, minCharacterCount = 40))
|
|
335
|
-
appendBlockEnd(this)
|
|
336
|
-
}
|
|
337
|
-
)
|
|
338
|
-
|
|
339
|
-
for (index in 0 until blockCount) {
|
|
340
|
-
if (index > 0 && index % 18 == 0) {
|
|
341
|
-
blocks.put(
|
|
342
|
-
JSONArray().apply {
|
|
343
|
-
appendHorizontalRule(this)
|
|
344
|
-
}
|
|
345
|
-
)
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
blocks.put(
|
|
349
|
-
JSONArray().apply {
|
|
350
|
-
when {
|
|
351
|
-
index % 12 == 5 -> {
|
|
352
|
-
appendBlockStart(this, nodeType = "blockquote", depth = 0)
|
|
353
|
-
appendBlockStart(this, nodeType = "paragraph", depth = 1)
|
|
354
|
-
appendRichInlineContent(
|
|
355
|
-
this,
|
|
356
|
-
seed = index,
|
|
357
|
-
totalCharacters = paragraphCharacterCount
|
|
358
|
-
)
|
|
359
|
-
appendBlockEnd(this)
|
|
360
|
-
appendBlockEnd(this)
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
index % 9 == 3 -> {
|
|
364
|
-
appendBlockStart(this, nodeType = "h2", depth = 0)
|
|
365
|
-
appendTextRun(
|
|
366
|
-
this,
|
|
367
|
-
textFragment(seed = index + 2_000, minCharacterCount = 72)
|
|
368
|
-
)
|
|
369
|
-
appendBlockEnd(this)
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
else -> {
|
|
373
|
-
appendBlockStart(this, nodeType = "paragraph", depth = 0)
|
|
374
|
-
appendRichInlineContent(
|
|
375
|
-
this,
|
|
376
|
-
seed = index,
|
|
377
|
-
totalCharacters = paragraphCharacterCount
|
|
378
|
-
)
|
|
379
|
-
appendBlockEnd(this)
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
)
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
return blocks
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
private fun flattenRenderBlocks(blocks: JSONArray): JSONArray {
|
|
390
|
-
val flattened = JSONArray()
|
|
391
|
-
for (blockIndex in 0 until blocks.length()) {
|
|
392
|
-
val block = blocks.optJSONArray(blockIndex) ?: continue
|
|
393
|
-
for (elementIndex in 0 until block.length()) {
|
|
394
|
-
flattened.put(block.optJSONObject(elementIndex))
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
return flattened
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
private fun emptyParagraphRenderBlock(): JSONArray =
|
|
401
|
-
JSONArray().apply {
|
|
402
|
-
appendBlockStart(this, nodeType = "paragraph", depth = 0)
|
|
403
|
-
appendTextRun(this, "\u200B")
|
|
404
|
-
appendBlockEnd(this)
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
private fun cloneJsonArray(array: JSONArray): JSONArray = JSONArray(array.toString())
|
|
408
|
-
|
|
409
|
-
private fun appendRichInlineContent(
|
|
410
|
-
elements: JSONArray,
|
|
411
|
-
seed: Int,
|
|
412
|
-
totalCharacters: Int
|
|
413
|
-
) {
|
|
414
|
-
val text = textFragment(seed = seed, minCharacterCount = totalCharacters)
|
|
415
|
-
val cutA = text.length / 4
|
|
416
|
-
val cutB = text.length / 2
|
|
417
|
-
val cutC = (text.length * 3) / 4
|
|
418
|
-
|
|
419
|
-
appendTextRun(elements, text.substring(0, cutA))
|
|
420
|
-
appendTextRun(elements, text.substring(cutA, cutB), marks = JSONArray().put("bold"))
|
|
421
|
-
appendTextRun(elements, text.substring(cutB, cutC), marks = JSONArray().put("italic"))
|
|
422
|
-
appendTextRun(
|
|
423
|
-
elements,
|
|
424
|
-
text.substring(cutC),
|
|
425
|
-
marks = JSONArray().put(
|
|
426
|
-
JSONObject()
|
|
427
|
-
.put("type", "link")
|
|
428
|
-
.put("href", "https://example.com/item/$seed")
|
|
429
|
-
)
|
|
430
|
-
)
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
private fun appendBlockStart(elements: JSONArray, nodeType: String, depth: Int) {
|
|
434
|
-
elements.put(
|
|
435
|
-
JSONObject()
|
|
436
|
-
.put("type", "blockStart")
|
|
437
|
-
.put("nodeType", nodeType)
|
|
438
|
-
.put("depth", depth)
|
|
439
|
-
)
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
private fun appendBlockEnd(elements: JSONArray) {
|
|
443
|
-
elements.put(JSONObject().put("type", "blockEnd"))
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
private fun appendHorizontalRule(elements: JSONArray) {
|
|
447
|
-
elements.put(
|
|
448
|
-
JSONObject()
|
|
449
|
-
.put("type", "voidBlock")
|
|
450
|
-
.put("nodeType", "horizontalRule")
|
|
451
|
-
.put("docPos", 0)
|
|
452
|
-
)
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
private fun appendTextRun(elements: JSONArray, text: String, marks: JSONArray = JSONArray()) {
|
|
456
|
-
elements.put(
|
|
457
|
-
JSONObject()
|
|
458
|
-
.put("type", "textRun")
|
|
459
|
-
.put("text", text)
|
|
460
|
-
.put("marks", marks)
|
|
461
|
-
)
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
private fun textFragment(seed: Int, minCharacterCount: Int): String {
|
|
465
|
-
val words = listOf(
|
|
466
|
-
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india",
|
|
467
|
-
"juliet", "kilo", "lima", "mike", "november", "oscar", "papa", "quebec", "romeo",
|
|
468
|
-
"sierra", "tango", "uniform", "victor", "whiskey", "xray", "yankee", "zulu",
|
|
469
|
-
)
|
|
470
|
-
|
|
471
|
-
val builder = StringBuilder()
|
|
472
|
-
var cursor = 0
|
|
473
|
-
while (builder.length < minCharacterCount) {
|
|
474
|
-
if (builder.isNotEmpty()) {
|
|
475
|
-
builder.append(' ')
|
|
476
|
-
}
|
|
477
|
-
builder.append(words[(seed + cursor) % words.size])
|
|
478
|
-
cursor += 1
|
|
479
|
-
}
|
|
480
|
-
return builder.substring(0, minCharacterCount)
|
|
481
|
-
}
|
|
482
|
-
}
|
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
package com.openeditor.editor
|
|
2
|
-
|
|
3
|
-
import android.graphics.Color
|
|
4
|
-
import android.text.SpannableStringBuilder
|
|
5
|
-
import android.text.Spanned
|
|
6
|
-
import android.text.StaticLayout
|
|
7
|
-
import android.text.TextPaint
|
|
8
|
-
import org.junit.Assert.assertEquals
|
|
9
|
-
import org.junit.Assert.assertFalse
|
|
10
|
-
import org.junit.Assert.assertTrue
|
|
11
|
-
import org.junit.Test
|
|
12
|
-
import org.junit.runner.RunWith
|
|
13
|
-
import org.robolectric.RobolectricTestRunner
|
|
14
|
-
import org.robolectric.annotation.Config
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Unit tests for [CaretGeometry] — the caret must clip to the rendered glyph
|
|
18
|
-
* height and never extend into the paragraph gap that [ParagraphSpacerSpan]
|
|
19
|
-
* adds below the line via inflated descent.
|
|
20
|
-
*/
|
|
21
|
-
@RunWith(RobolectricTestRunner::class)
|
|
22
|
-
@Config(sdk = [34])
|
|
23
|
-
class CaretGeometryTest {
|
|
24
|
-
|
|
25
|
-
private fun layoutFor(text: CharSequence, paint: TextPaint): StaticLayout =
|
|
26
|
-
StaticLayout.Builder.obtain(text, 0, text.length, paint, WIDTH_PX).build()
|
|
27
|
-
|
|
28
|
-
@Test
|
|
29
|
-
fun `caret bottom clips paragraph spacer descent to the glyph height`() {
|
|
30
|
-
val text = SpannableStringBuilder("Hello\nWorld")
|
|
31
|
-
// Inter-block spacer inflates the descent of line 0 (the "Hello\n" line),
|
|
32
|
-
// exactly as RenderBridge applies it between blocks.
|
|
33
|
-
text.setSpan(
|
|
34
|
-
ParagraphSpacerSpan(spacingPx = SPACER_PX, baseFontSize = FONT_SIZE_PX, textColor = Color.BLACK),
|
|
35
|
-
5,
|
|
36
|
-
6,
|
|
37
|
-
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
38
|
-
)
|
|
39
|
-
val paint = TextPaint().apply { textSize = FONT_SIZE_PX.toFloat() }
|
|
40
|
-
val layout = layoutFor(text, paint)
|
|
41
|
-
val line = 0
|
|
42
|
-
|
|
43
|
-
// Reproduction guard: the spacer really did inflate the layout descent.
|
|
44
|
-
assertTrue(
|
|
45
|
-
"ParagraphSpacerSpan should inflate layout descent in this reproduction",
|
|
46
|
-
layout.getLineDescent(line) > paint.fontMetrics.descent
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
val bounds = CaretGeometry.verticalBounds(layout, offset = 5, paint = paint)
|
|
50
|
-
|
|
51
|
-
assertEquals(
|
|
52
|
-
"caret top should be the line top",
|
|
53
|
-
layout.getLineTop(line).toFloat(),
|
|
54
|
-
bounds.top,
|
|
55
|
-
TOLERANCE_PX
|
|
56
|
-
)
|
|
57
|
-
assertEquals(
|
|
58
|
-
"caret bottom should be baseline + raw font descent, not the inflated getLineBottom",
|
|
59
|
-
layout.getLineBaseline(line) + paint.fontMetrics.descent,
|
|
60
|
-
bounds.bottom,
|
|
61
|
-
TOLERANCE_PX
|
|
62
|
-
)
|
|
63
|
-
assertTrue(
|
|
64
|
-
"caret must not extend into the paragraph gap below the line",
|
|
65
|
-
bounds.bottom < layout.getLineBottom(line)
|
|
66
|
-
)
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
@Test
|
|
70
|
-
fun `caret height is identical with and without the paragraph spacer`() {
|
|
71
|
-
val paint = TextPaint().apply { textSize = FONT_SIZE_PX.toFloat() }
|
|
72
|
-
|
|
73
|
-
val plain = layoutFor(SpannableStringBuilder("Hello\nWorld"), paint)
|
|
74
|
-
|
|
75
|
-
val spaced = SpannableStringBuilder("Hello\nWorld")
|
|
76
|
-
spaced.setSpan(
|
|
77
|
-
ParagraphSpacerSpan(spacingPx = SPACER_PX, baseFontSize = FONT_SIZE_PX, textColor = Color.BLACK),
|
|
78
|
-
5,
|
|
79
|
-
6,
|
|
80
|
-
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
81
|
-
)
|
|
82
|
-
val spacedLayout = layoutFor(spaced, paint)
|
|
83
|
-
|
|
84
|
-
// Sanity: the spacer changed the layout (line 1 sits lower).
|
|
85
|
-
assertTrue(
|
|
86
|
-
"spacer should push the following line down",
|
|
87
|
-
spacedLayout.getLineTop(1) > plain.getLineTop(1)
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
val plainBounds = CaretGeometry.verticalBounds(plain, offset = 5, paint = paint)
|
|
91
|
-
val spacedBounds = CaretGeometry.verticalBounds(spacedLayout, offset = 5, paint = paint)
|
|
92
|
-
|
|
93
|
-
assertEquals(
|
|
94
|
-
"caret top must not depend on the paragraph spacer",
|
|
95
|
-
plainBounds.top,
|
|
96
|
-
spacedBounds.top,
|
|
97
|
-
TOLERANCE_PX
|
|
98
|
-
)
|
|
99
|
-
assertEquals(
|
|
100
|
-
"caret height must be the rendered glyph height regardless of paragraph spacing",
|
|
101
|
-
plainBounds.bottom,
|
|
102
|
-
spacedBounds.bottom,
|
|
103
|
-
TOLERANCE_PX
|
|
104
|
-
)
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
@Test
|
|
108
|
-
fun `caret renders only when focused, window-focused, and selection collapsed`() {
|
|
109
|
-
assertTrue(
|
|
110
|
-
"collapsed caret in a focused field should render",
|
|
111
|
-
CaretGeometry.shouldRender(focused = true, windowFocused = true, selectionStart = 3, selectionEnd = 3)
|
|
112
|
-
)
|
|
113
|
-
assertFalse(
|
|
114
|
-
"no caret when the field is not focused",
|
|
115
|
-
CaretGeometry.shouldRender(focused = false, windowFocused = true, selectionStart = 3, selectionEnd = 3)
|
|
116
|
-
)
|
|
117
|
-
assertFalse(
|
|
118
|
-
"no caret when the window is not focused",
|
|
119
|
-
CaretGeometry.shouldRender(focused = true, windowFocused = false, selectionStart = 3, selectionEnd = 3)
|
|
120
|
-
)
|
|
121
|
-
assertFalse(
|
|
122
|
-
"no caret for a range selection (highlight shown instead)",
|
|
123
|
-
CaretGeometry.shouldRender(focused = true, windowFocused = true, selectionStart = 3, selectionEnd = 5)
|
|
124
|
-
)
|
|
125
|
-
assertFalse(
|
|
126
|
-
"no caret when there is no selection",
|
|
127
|
-
CaretGeometry.shouldRender(focused = true, windowFocused = true, selectionStart = -1, selectionEnd = -1)
|
|
128
|
-
)
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
private companion object {
|
|
132
|
-
const val WIDTH_PX = 200
|
|
133
|
-
const val FONT_SIZE_PX = 16
|
|
134
|
-
const val SPACER_PX = 40
|
|
135
|
-
const val TOLERANCE_PX = 0.01f
|
|
136
|
-
}
|
|
137
|
-
}
|