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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/android/build.gradle +35 -0
  2. package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +2 -2
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +138 -597
  4. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +13 -3
  5. package/android/src/main/java/com/apollohg/editor/InputSnapshotSupport.kt +6 -0
  6. package/android/src/main/java/com/apollohg/editor/NativeBlockEditorSurface.kt +1086 -0
  7. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +1 -1
  8. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +26 -50
  9. package/android/src/main/java/com/apollohg/editor/NativeImageLoader.kt +89 -0
  10. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +31 -66
  11. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +98 -33
  12. package/dist/EditorToolbar.d.ts +1 -1
  13. package/dist/EditorToolbar.js +19 -0
  14. package/dist/NativeEditorBridge.d.ts +62 -35
  15. package/dist/NativeEditorBridge.js +122 -79
  16. package/dist/NativeProseViewer.d.ts +13 -34
  17. package/dist/NativeProseViewer.js +40 -465
  18. package/dist/NativeRichTextEditor.d.ts +10 -1
  19. package/dist/NativeRichTextEditor.js +72 -34
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +2 -1
  22. package/dist/schemas.d.ts +11 -0
  23. package/dist/schemas.js +149 -0
  24. package/dist/useNativeEditor.d.ts +3 -3
  25. package/dist/useNativeEditor.js +3 -3
  26. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  27. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  28. package/ios/Generated_editor_core.swift +97 -2
  29. package/ios/NativeBlockEditorSurface.swift +1393 -0
  30. package/ios/NativeEditorExpoView.swift +3 -3
  31. package/ios/NativeEditorModule.swift +30 -45
  32. package/ios/NativeInputSupport.swift +396 -0
  33. package/ios/PositionBridge.swift +4 -4
  34. package/ios/RichTextEditorView.swift +333 -786
  35. package/ios/editor_coreFFI/editor_coreFFI.h +147 -70
  36. package/package.json +1 -1
  37. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  38. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  39. package/rust/android/x86_64/libeditor_core.so +0 -0
  40. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2087 -1399
  41. package/android/src/androidTest/AndroidManifest.xml +0 -8
  42. package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +0 -53
  43. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +0 -241
  44. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +0 -338
  45. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +0 -789
  46. package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +0 -350
  47. package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +0 -5
  48. package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +0 -315
  49. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +0 -2172
  50. package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +0 -482
  51. package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +0 -137
  52. package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +0 -321
  53. package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +0 -4202
  54. package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +0 -2581
  55. package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +0 -27
  56. package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +0 -197
  57. package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +0 -84
  58. package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +0 -544
  59. package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +0 -461
  60. package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +0 -2054
  61. package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +0 -1367
  62. package/ios/EditorLayoutManager.swift +0 -774
  63. package/ios/NativeProseViewerExpoView.swift +0 -276
  64. package/ios/RenderBridge.swift +0 -1708
  65. package/ios/Tests/NativePerformanceTests.swift +0 -687
  66. package/ios/Tests/PositionBridgeTests.swift +0 -706
  67. package/ios/Tests/RenderBridgeTests.swift +0 -2236
  68. package/ios/Tests/RichTextEditorViewTests.swift +0 -6548
@@ -1,1367 +0,0 @@
1
- package com.openeditor.editor
2
-
3
- import android.graphics.Color
4
- import android.graphics.Bitmap
5
- import android.graphics.Canvas
6
- import android.text.SpannableStringBuilder
7
- import android.text.StaticLayout
8
- import android.text.TextPaint
9
- import android.text.Spanned
10
- import android.widget.LinearLayout
11
- import android.view.MotionEvent
12
- import android.view.View
13
- import android.view.ViewGroup
14
- import android.widget.FrameLayout
15
- import org.json.JSONArray
16
- import org.json.JSONObject
17
- import org.junit.Assert.assertEquals
18
- import org.junit.Assert.assertFalse
19
- import org.junit.Assert.assertNotNull
20
- import org.junit.Assert.assertNull
21
- import org.junit.Assert.assertTrue
22
- import org.junit.Test
23
- import org.junit.runner.RunWith
24
- import org.robolectric.RobolectricTestRunner
25
- import org.robolectric.RuntimeEnvironment
26
- import org.robolectric.annotation.Config
27
-
28
- @RunWith(RobolectricTestRunner::class)
29
- @Config(sdk = [34])
30
- class RichTextEditorViewTest {
31
- private class InterceptAwareFrameLayout(context: android.content.Context) : FrameLayout(context) {
32
- var disallowInterceptRequested = false
33
-
34
- override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
35
- disallowInterceptRequested = disallowIntercept
36
- super.requestDisallowInterceptTouchEvent(disallowIntercept)
37
- }
38
- }
39
-
40
- private fun exampleTheme(markerScale: Float = 2f): EditorTheme? =
41
- EditorTheme.fromJson(
42
- """
43
- {
44
- "backgroundColor": "#f6f1e8",
45
- "text": { "color": "#2a2118", "fontSize": 17 },
46
- "paragraph": { "spacingAfter": 16 },
47
- "list": { "indent": 14, "itemSpacing": 6, "markerColor": "#9a4f2d", "markerScale": $markerScale }
48
- }
49
- """.trimIndent()
50
- )
51
-
52
- private fun exampleRenderJson(): String = """
53
- [
54
- {"type":"blockStart","nodeType":"paragraph","depth":0},
55
- {"type":"textRun","text":"Native Editor example app.","marks":["bold"]},
56
- {"type":"blockEnd"},
57
- {"type":"blockStart","nodeType":"paragraph","depth":0},
58
- {"type":"textRun","text":"Use this screen to test focus, theme updates, lists, line breaks, toolbar behavior, and optional addons.","marks":[]},
59
- {"type":"blockEnd"},
60
- {"type":"blockStart","nodeType":"paragraph","depth":0},
61
- {"type":"textRun","text":"Enable mentions above, then type @ after a space, on a blank line, or after punctuation to show native mention suggestions in the toolbar.","marks":[]},
62
- {"type":"blockEnd"},
63
- {"type":"blockStart","nodeType":"listItem","depth":1,"listContext":{"ordered":false,"index":1,"total":2,"start":1,"isFirst":true,"isLast":false}},
64
- {"type":"blockStart","nodeType":"paragraph","depth":2},
65
- {"type":"textRun","text":"Try typing","marks":[]},
66
- {"type":"blockEnd"},
67
- {"type":"blockEnd"},
68
- {"type":"blockStart","nodeType":"listItem","depth":1,"listContext":{"ordered":false,"index":2,"total":2,"start":1,"isFirst":false,"isLast":true}},
69
- {"type":"blockStart","nodeType":"paragraph","depth":2},
70
- {"type":"textRun","text":"Try list indenting","marks":[]},
71
- {"type":"blockEnd"},
72
- {"type":"blockEnd"},
73
- {"type":"blockStart","nodeType":"paragraph","depth":0},
74
- {"type":"blockEnd"}
75
- ]
76
- """.trimIndent()
77
-
78
- private fun singleBulletListRenderJson(): String = """
79
- [
80
- {"type":"blockStart","nodeType":"listItem","depth":0,"listContext":{"ordered":false,"index":1,"total":1,"start":1,"isFirst":true,"isLast":true}},
81
- {"type":"blockStart","nodeType":"paragraph","depth":1},
82
- {"type":"textRun","text":"Bullet item","marks":[]},
83
- {"type":"blockEnd"},
84
- {"type":"blockEnd"}
85
- ]
86
- """.trimIndent()
87
-
88
- private fun emptyParagraphRenderJson(): String = """
89
- [
90
- {"type":"blockStart","nodeType":"paragraph","depth":0},
91
- {"type":"textRun","text":"\u200B","marks":[]},
92
- {"type":"blockEnd"}
93
- ]
94
- """.trimIndent()
95
-
96
- private fun imageRenderJson(): String = """
97
- [
98
- {"type":"blockStart","nodeType":"paragraph","depth":0},
99
- {"type":"textRun","text":"Hello","marks":[]},
100
- {"type":"blockEnd"},
101
- {"type":"voidBlock","nodeType":"image","docPos":7,"attrs":{"src":"https://example.com/cat.png","width":140,"height":80}},
102
- {"type":"blockStart","nodeType":"paragraph","depth":0},
103
- {"type":"blockEnd"}
104
- ]
105
- """.trimIndent()
106
-
107
- private fun paragraphRenderBlock(text: String): JSONArray {
108
- return JSONArray().apply {
109
- put(
110
- JSONObject()
111
- .put("type", "blockStart")
112
- .put("nodeType", "paragraph")
113
- .put("depth", 0)
114
- )
115
- put(
116
- JSONObject()
117
- .put("type", "textRun")
118
- .put("text", text)
119
- .put("marks", JSONArray())
120
- )
121
- put(JSONObject().put("type", "blockEnd"))
122
- }
123
- }
124
-
125
- private fun renderUpdateJson(
126
- blocks: JSONArray,
127
- includeFullRenderBlocks: Boolean = true,
128
- renderPatch: JSONObject? = null
129
- ): String {
130
- return JSONObject().apply {
131
- if (includeFullRenderBlocks) {
132
- put("renderBlocks", blocks)
133
- }
134
- if (renderPatch != null) {
135
- put("renderPatch", renderPatch)
136
- }
137
- }.toString()
138
- }
139
-
140
- @Test
141
- fun `placeholder shows for rendered empty paragraph`() {
142
- val editText = EditorEditText(RuntimeEnvironment.getApplication())
143
- editText.placeholderText = "Type here"
144
- editText.applyRenderJSON(emptyParagraphRenderJson())
145
-
146
- assertTrue(editText.shouldDisplayPlaceholderForTesting())
147
- }
148
-
149
- @Test
150
- fun `apply update json resolves patch-only payload for middle paragraph split`() {
151
- val editText = EditorEditText(RuntimeEnvironment.getApplication())
152
- val initialBlocks = JSONArray().apply {
153
- put(paragraphRenderBlock("Alpha"))
154
- put(paragraphRenderBlock("Beta"))
155
- put(paragraphRenderBlock("Gamma"))
156
- }
157
- editText.applyUpdateJSON(renderUpdateJson(initialBlocks), notifyListener = false)
158
-
159
- val patchedBlocks = JSONArray().apply {
160
- put(paragraphRenderBlock("Alpha"))
161
- put(paragraphRenderBlock("Beta"))
162
- put(paragraphRenderBlock("\u200B"))
163
- put(paragraphRenderBlock("Gamma"))
164
- }
165
- val renderPatch = JSONObject()
166
- .put("startIndex", 1)
167
- .put("deleteCount", 2)
168
- .put(
169
- "renderBlocks",
170
- JSONArray().apply {
171
- put(paragraphRenderBlock("Beta"))
172
- put(paragraphRenderBlock("\u200B"))
173
- put(paragraphRenderBlock("Gamma"))
174
- }
175
- )
176
-
177
- editText.applyUpdateJSON(
178
- renderUpdateJson(
179
- patchedBlocks,
180
- includeFullRenderBlocks = false,
181
- renderPatch = renderPatch
182
- ),
183
- notifyListener = false
184
- )
185
-
186
- assertEquals("Alpha\nBeta\n\u200B\nGamma", editText.text?.toString())
187
- }
188
-
189
- @Test
190
- fun `apply update json skips render work when render blocks are unchanged`() {
191
- val editText = EditorEditText(RuntimeEnvironment.getApplication()).apply {
192
- captureApplyUpdateTraceForTesting = true
193
- }
194
- val initialBlocks = JSONArray().apply {
195
- put(paragraphRenderBlock("Alpha"))
196
- put(paragraphRenderBlock("Beta"))
197
- }
198
-
199
- editText.applyUpdateJSON(renderUpdateJson(initialBlocks), notifyListener = false)
200
- editText.lastApplyUpdateTrace()
201
-
202
- val selectionOnlyUpdate = JSONObject()
203
- .put("renderBlocks", JSONArray(initialBlocks.toString()))
204
- .toString()
205
-
206
- editText.applyUpdateJSON(selectionOnlyUpdate, notifyListener = false)
207
-
208
- val trace = editText.lastApplyUpdateTrace()
209
- assertNotNull(trace)
210
- assertTrue(trace?.skippedRender == true)
211
- assertFalse(trace?.usedPatch == true)
212
- assertEquals(0L, trace?.buildRenderNanos)
213
- assertEquals(0L, trace?.applyRenderNanos)
214
- assertEquals("Alpha\nBeta", editText.text?.toString())
215
- }
216
-
217
- @Test
218
- fun `placeholder hides for rendered non-empty paragraph`() {
219
- val editText = EditorEditText(RuntimeEnvironment.getApplication())
220
- editText.placeholderText = "Type here"
221
- editText.setText("Hello")
222
-
223
- assertTrue(!editText.shouldDisplayPlaceholderForTesting())
224
- }
225
-
226
- @Test
227
- fun `multiline placeholder expands empty editor height`() {
228
- val editText = EditorEditText(RuntimeEnvironment.getApplication())
229
- editText.setHeightBehavior(EditorHeightBehavior.AUTO_GROW)
230
- editText.placeholderText =
231
- "Type a much longer placeholder that should wrap onto multiple lines in the empty editor"
232
- editText.applyRenderJSON(emptyParagraphRenderJson())
233
-
234
- val widthSpec = View.MeasureSpec.makeMeasureSpec(220, View.MeasureSpec.EXACTLY)
235
- val heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
236
- editText.measure(widthSpec, heightSpec)
237
- editText.layout(0, 0, editText.measuredWidth, editText.measuredHeight)
238
-
239
- val availableWidth =
240
- editText.measuredWidth - editText.compoundPaddingLeft - editText.compoundPaddingRight
241
- val expectedPlaceholderHeight =
242
- StaticLayout.Builder
243
- .obtain(
244
- editText.placeholderText,
245
- 0,
246
- editText.placeholderText.length,
247
- editText.paint,
248
- availableWidth
249
- )
250
- .setAlignment(android.text.Layout.Alignment.ALIGN_NORMAL)
251
- .setIncludePad(editText.includeFontPadding)
252
- .build()
253
- .height +
254
- editText.compoundPaddingTop +
255
- editText.compoundPaddingBottom
256
-
257
- assertTrue(editText.measuredHeight >= expectedPlaceholderHeight)
258
- assertTrue(editText.resolveAutoGrowHeight() >= expectedPlaceholderHeight)
259
- }
260
-
261
- @Test
262
- fun `placeholder uses paragraph font size from theme`() {
263
- val context = RuntimeEnvironment.getApplication()
264
- val density = context.resources.displayMetrics.density
265
- val editText = EditorEditText(context)
266
- editText.setBaseStyle(24f * density, Color.BLACK, Color.WHITE)
267
- editText.setHeightBehavior(EditorHeightBehavior.AUTO_GROW)
268
- editText.placeholderText = "Placeholder wraps"
269
- editText.applyTheme(
270
- EditorTheme.fromJson(
271
- """
272
- {
273
- "text": { "fontSize": 12 },
274
- "paragraph": { "fontSize": 10 }
275
- }
276
- """.trimIndent()
277
- )
278
- )
279
- editText.applyRenderJSON(emptyParagraphRenderJson())
280
-
281
- val widthSpec = View.MeasureSpec.makeMeasureSpec(220, View.MeasureSpec.EXACTLY)
282
- val heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
283
- editText.measure(widthSpec, heightSpec)
284
- editText.layout(0, 0, editText.measuredWidth, editText.measuredHeight)
285
-
286
- val availableWidth =
287
- editText.measuredWidth - editText.compoundPaddingLeft - editText.compoundPaddingRight
288
- val expectedPlaceholderHeight =
289
- StaticLayout.Builder
290
- .obtain(
291
- editText.placeholderText,
292
- 0,
293
- editText.placeholderText.length,
294
- TextPaint(editText.paint).apply {
295
- textSize = 10f * density
296
- },
297
- availableWidth
298
- )
299
- .setAlignment(android.text.Layout.Alignment.ALIGN_NORMAL)
300
- .setIncludePad(editText.includeFontPadding)
301
- .build()
302
- .height +
303
- editText.compoundPaddingTop +
304
- editText.compoundPaddingBottom
305
-
306
- assertEquals(expectedPlaceholderHeight, editText.resolveAutoGrowHeight())
307
- }
308
-
309
- @Test
310
- fun `editor disables clickable links`() {
311
- val editText = EditorEditText(RuntimeEnvironment.getApplication())
312
-
313
- assertTrue(!editText.linksClickable)
314
- }
315
-
316
- @Test
317
- fun `editor auto grow height resolves from text layout`() {
318
- val editText = EditorEditText(RuntimeEnvironment.getApplication())
319
- editText.setText("Line one\nLine two\nLine three")
320
-
321
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
322
- val heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
323
- editText.measure(widthSpec, heightSpec)
324
- editText.layout(0, 0, editText.measuredWidth, editText.measuredHeight)
325
-
326
- val expectedHeight =
327
- (editText.layout?.height ?: 0) + editText.compoundPaddingTop + editText.compoundPaddingBottom
328
-
329
- assertTrue(expectedHeight > 0)
330
- assertEquals(expectedHeight, editText.resolveAutoGrowHeight())
331
- }
332
-
333
- @Test
334
- fun `rich text editor auto grow measures to content height within parent limit`() {
335
- val richTextEditorView = RichTextEditorView(RuntimeEnvironment.getApplication())
336
- richTextEditorView.setHeightBehavior(EditorHeightBehavior.AUTO_GROW)
337
- richTextEditorView.editorEditText.setText("Short content")
338
-
339
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
340
- val heightSpec = View.MeasureSpec.makeMeasureSpec(1600, View.MeasureSpec.AT_MOST)
341
- richTextEditorView.measure(widthSpec, heightSpec)
342
- richTextEditorView.layout(
343
- 0,
344
- 0,
345
- richTextEditorView.measuredWidth,
346
- richTextEditorView.measuredHeight
347
- )
348
-
349
- val contentHeight = richTextEditorView.editorEditText.resolveAutoGrowHeight()
350
-
351
- assertTrue(contentHeight > 0)
352
- assertEquals(contentHeight, richTextEditorView.measuredHeight)
353
- }
354
-
355
- @Test
356
- fun `rich text editor auto grow ignores oversized exact parent height`() {
357
- val richTextEditorView = RichTextEditorView(RuntimeEnvironment.getApplication())
358
- richTextEditorView.setHeightBehavior(EditorHeightBehavior.AUTO_GROW)
359
- richTextEditorView.editorEditText.setText("Short content")
360
-
361
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
362
- val wrapHeightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
363
- richTextEditorView.measure(widthSpec, wrapHeightSpec)
364
- richTextEditorView.layout(
365
- 0,
366
- 0,
367
- richTextEditorView.measuredWidth,
368
- richTextEditorView.measuredHeight
369
- )
370
- val expectedContentHeight = richTextEditorView.editorEditText.resolveAutoGrowHeight()
371
-
372
- val oversizedExactHeightSpec = View.MeasureSpec.makeMeasureSpec(1600, View.MeasureSpec.EXACTLY)
373
- richTextEditorView.measure(widthSpec, oversizedExactHeightSpec)
374
- richTextEditorView.layout(
375
- 0,
376
- 0,
377
- richTextEditorView.measuredWidth,
378
- richTextEditorView.measuredHeight
379
- )
380
-
381
- assertEquals(expectedContentHeight, richTextEditorView.measuredHeight)
382
- }
383
-
384
- @Test
385
- fun `editor auto grow height ignores stale exact measured height before layout`() {
386
- val context = RuntimeEnvironment.getApplication()
387
- val expectedView = EditorEditText(context)
388
- expectedView.setText("Short content")
389
-
390
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
391
- val wrapHeightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
392
- expectedView.measure(widthSpec, wrapHeightSpec)
393
- expectedView.layout(0, 0, expectedView.measuredWidth, expectedView.measuredHeight)
394
- val expectedHeight = expectedView.resolveAutoGrowHeight()
395
-
396
- val subject = EditorEditText(context)
397
- subject.setText("Short content")
398
- val fixedHeightSpec = View.MeasureSpec.makeMeasureSpec(1200, View.MeasureSpec.EXACTLY)
399
- subject.measure(widthSpec, fixedHeightSpec)
400
-
401
- assertEquals(1200, subject.measuredHeight)
402
- val resolvedHeight = subject.resolveAutoGrowHeight()
403
- assertEquals(
404
- "expected=$expectedHeight resolved=$resolvedHeight " +
405
- "isLaidOut=${subject.isLaidOut} measuredWidth=${subject.measuredWidth} " +
406
- "layoutHeight=${subject.layout?.height} lineHeight=${subject.lineHeight} " +
407
- "compoundPaddingTop=${subject.compoundPaddingTop} compoundPaddingBottom=${subject.compoundPaddingBottom}",
408
- expectedHeight,
409
- resolvedHeight
410
- )
411
- }
412
-
413
- @Test
414
- fun `editor auto grow height ignores stale exact height after layout`() {
415
- val context = RuntimeEnvironment.getApplication()
416
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
417
- val wrapHeightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
418
-
419
- val expectedView = EditorEditText(context)
420
- expectedView.layoutParams = ViewGroup.LayoutParams(
421
- ViewGroup.LayoutParams.MATCH_PARENT,
422
- ViewGroup.LayoutParams.WRAP_CONTENT
423
- )
424
- expectedView.setText("Short content")
425
- expectedView.measure(widthSpec, wrapHeightSpec)
426
- expectedView.layout(0, 0, expectedView.measuredWidth, expectedView.measuredHeight)
427
- val expectedHeight = expectedView.resolveAutoGrowHeight()
428
-
429
- val subject = EditorEditText(context)
430
- subject.layoutParams = ViewGroup.LayoutParams(
431
- ViewGroup.LayoutParams.MATCH_PARENT,
432
- ViewGroup.LayoutParams.WRAP_CONTENT
433
- )
434
- subject.setText("Short content")
435
- val staleHeight = expectedHeight + 320
436
- val exactHeightSpec = View.MeasureSpec.makeMeasureSpec(staleHeight, View.MeasureSpec.EXACTLY)
437
- subject.measure(widthSpec, exactHeightSpec)
438
- subject.layout(0, 0, subject.measuredWidth, subject.measuredHeight)
439
-
440
- assertEquals(staleHeight, subject.height)
441
- val resolvedHeight = subject.resolveAutoGrowHeight()
442
-
443
- assertEquals(expectedHeight, resolvedHeight)
444
- }
445
-
446
- @Test
447
- fun `editor auto grow height expands after exact-height feedback loop`() {
448
- val context = RuntimeEnvironment.getApplication()
449
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
450
- val wrapHeightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
451
- val shortText = "Short content"
452
- val tallText = "Line one\nLine two\nLine three\nLine four\nLine five"
453
-
454
- val expectedTallView = EditorEditText(context)
455
- expectedTallView.layoutParams = ViewGroup.LayoutParams(
456
- ViewGroup.LayoutParams.MATCH_PARENT,
457
- ViewGroup.LayoutParams.WRAP_CONTENT
458
- )
459
- expectedTallView.setText(tallText)
460
- expectedTallView.measure(widthSpec, wrapHeightSpec)
461
- expectedTallView.layout(
462
- 0,
463
- 0,
464
- expectedTallView.measuredWidth,
465
- expectedTallView.measuredHeight
466
- )
467
- val expectedTallHeight = expectedTallView.resolveAutoGrowHeight()
468
-
469
- val subject = EditorEditText(context)
470
- subject.layoutParams = ViewGroup.LayoutParams(
471
- ViewGroup.LayoutParams.MATCH_PARENT,
472
- ViewGroup.LayoutParams.WRAP_CONTENT
473
- )
474
- subject.setText(shortText)
475
- subject.measure(widthSpec, wrapHeightSpec)
476
- subject.layout(0, 0, subject.measuredWidth, subject.measuredHeight)
477
- val shortHeight = subject.resolveAutoGrowHeight()
478
-
479
- // Simulate React Native feeding the previous contentHeight back as an exact height.
480
- val exactShortHeightSpec = View.MeasureSpec.makeMeasureSpec(shortHeight, View.MeasureSpec.EXACTLY)
481
- subject.measure(widthSpec, exactShortHeightSpec)
482
- subject.layout(0, 0, subject.measuredWidth, subject.measuredHeight)
483
-
484
- subject.setText(tallText)
485
- val expandedHeight = subject.resolveAutoGrowHeight()
486
-
487
- assertTrue(expandedHeight > shortHeight)
488
- assertEquals(expectedTallHeight, expandedHeight)
489
- }
490
-
491
- @Test
492
- fun `editor auto grow height shrinks after exact-height feedback loop`() {
493
- val context = RuntimeEnvironment.getApplication()
494
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
495
- val wrapHeightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
496
- val shortText = "Short content"
497
- val tallText = "Line one\nLine two\nLine three\nLine four\nLine five"
498
-
499
- val expectedShortView = EditorEditText(context)
500
- expectedShortView.layoutParams = ViewGroup.LayoutParams(
501
- ViewGroup.LayoutParams.MATCH_PARENT,
502
- ViewGroup.LayoutParams.WRAP_CONTENT
503
- )
504
- expectedShortView.setText(shortText)
505
- expectedShortView.measure(widthSpec, wrapHeightSpec)
506
- expectedShortView.layout(
507
- 0,
508
- 0,
509
- expectedShortView.measuredWidth,
510
- expectedShortView.measuredHeight
511
- )
512
- val expectedShortHeight = expectedShortView.resolveAutoGrowHeight()
513
-
514
- val subject = EditorEditText(context)
515
- subject.layoutParams = ViewGroup.LayoutParams(
516
- ViewGroup.LayoutParams.MATCH_PARENT,
517
- ViewGroup.LayoutParams.WRAP_CONTENT
518
- )
519
- subject.setText(tallText)
520
- subject.measure(widthSpec, wrapHeightSpec)
521
- subject.layout(0, 0, subject.measuredWidth, subject.measuredHeight)
522
- val tallHeight = subject.resolveAutoGrowHeight()
523
-
524
- // Simulate React Native feeding the previous contentHeight back as an exact height.
525
- val exactTallHeightSpec = View.MeasureSpec.makeMeasureSpec(tallHeight, View.MeasureSpec.EXACTLY)
526
- subject.measure(widthSpec, exactTallHeightSpec)
527
- subject.layout(0, 0, subject.measuredWidth, subject.measuredHeight)
528
-
529
- subject.setText(shortText)
530
- val shrunkHeight = subject.resolveAutoGrowHeight()
531
-
532
- assertTrue(shrunkHeight < tallHeight)
533
- assertEquals(expectedShortHeight, shrunkHeight)
534
- }
535
-
536
- @Test
537
- fun `rich text editor auto grow expands after content changes`() {
538
- val richTextEditorView = RichTextEditorView(RuntimeEnvironment.getApplication())
539
- richTextEditorView.setHeightBehavior(EditorHeightBehavior.AUTO_GROW)
540
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
541
- val heightSpec = View.MeasureSpec.makeMeasureSpec(1600, View.MeasureSpec.AT_MOST)
542
-
543
- richTextEditorView.editorEditText.setText("Short content")
544
- richTextEditorView.measure(widthSpec, heightSpec)
545
- richTextEditorView.layout(
546
- 0,
547
- 0,
548
- richTextEditorView.measuredWidth,
549
- richTextEditorView.measuredHeight
550
- )
551
- val shortHeight = richTextEditorView.measuredHeight
552
-
553
- richTextEditorView.editorEditText.setText("Line one\nLine two\nLine three\nLine four")
554
- richTextEditorView.measure(widthSpec, heightSpec)
555
- richTextEditorView.layout(
556
- 0,
557
- 0,
558
- richTextEditorView.measuredWidth,
559
- richTextEditorView.measuredHeight
560
- )
561
- val tallHeight = richTextEditorView.measuredHeight
562
-
563
- assertTrue("Auto-grow height should expand when content grows", tallHeight > shortHeight)
564
- }
565
-
566
- @Test
567
- fun `rich text editor auto grow keeps edit text height aligned with container`() {
568
- val richTextEditorView = RichTextEditorView(RuntimeEnvironment.getApplication())
569
- richTextEditorView.setHeightBehavior(EditorHeightBehavior.AUTO_GROW)
570
- richTextEditorView.editorEditText.setText(
571
- "Line one\nLine two\nLine three\nLine four\nLine five"
572
- )
573
-
574
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
575
- val heightSpec = View.MeasureSpec.makeMeasureSpec(1600, View.MeasureSpec.AT_MOST)
576
- richTextEditorView.measure(widthSpec, heightSpec)
577
- richTextEditorView.layout(
578
- 0,
579
- 0,
580
- richTextEditorView.measuredWidth,
581
- richTextEditorView.measuredHeight
582
- )
583
-
584
- assertEquals(
585
- "EditText should fill the auto-grow container height",
586
- richTextEditorView.measuredHeight,
587
- richTextEditorView.editorEditText.measuredHeight
588
- )
589
- }
590
-
591
- @Test
592
- fun `rich text editor auto grow lays out edit text to container height`() {
593
- val richTextEditorView = RichTextEditorView(RuntimeEnvironment.getApplication())
594
- richTextEditorView.setHeightBehavior(EditorHeightBehavior.AUTO_GROW)
595
- richTextEditorView.editorEditText.setText(
596
- "Line one\nLine two\nLine three\nLine four\nLine five"
597
- )
598
-
599
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
600
- val heightSpec = View.MeasureSpec.makeMeasureSpec(1600, View.MeasureSpec.AT_MOST)
601
- richTextEditorView.measure(widthSpec, heightSpec)
602
- richTextEditorView.layout(
603
- 0,
604
- 0,
605
- richTextEditorView.measuredWidth,
606
- richTextEditorView.measuredHeight
607
- )
608
-
609
- assertEquals(
610
- "EditText should be laid out to the container height in auto-grow mode",
611
- richTextEditorView.height,
612
- richTextEditorView.editorEditText.height
613
- )
614
- }
615
-
616
- @Test
617
- fun `fixed height editor disallows parent intercept while scrolling`() {
618
- val context = RuntimeEnvironment.getApplication()
619
- val parent = InterceptAwareFrameLayout(context)
620
- val richTextEditorView = RichTextEditorView(context)
621
- richTextEditorView.layoutParams = FrameLayout.LayoutParams(
622
- FrameLayout.LayoutParams.MATCH_PARENT,
623
- 200
624
- )
625
- richTextEditorView.setHeightBehavior(EditorHeightBehavior.FIXED)
626
- richTextEditorView.editorEditText.setText((1..40).joinToString("\n") { "Line $it" })
627
- parent.addView(richTextEditorView)
628
-
629
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
630
- val heightSpec = View.MeasureSpec.makeMeasureSpec(200, View.MeasureSpec.EXACTLY)
631
- parent.measure(widthSpec, heightSpec)
632
- parent.layout(0, 0, parent.measuredWidth, parent.measuredHeight)
633
-
634
- assertTrue(
635
- "Expected fixed editor content to overflow vertically",
636
- richTextEditorView.editorScrollView.canScrollVertically(1)
637
- )
638
-
639
- val down = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 10f, 10f, 0)
640
- richTextEditorView.editorScrollView.onTouchEvent(down)
641
- down.recycle()
642
-
643
- assertTrue(
644
- "Fixed-height editor should disallow parent intercept while scrolling",
645
- parent.disallowInterceptRequested
646
- )
647
-
648
- val up = MotionEvent.obtain(0, 16, MotionEvent.ACTION_UP, 10f, 40f, 0)
649
- richTextEditorView.editorScrollView.onTouchEvent(up)
650
- up.recycle()
651
-
652
- assertTrue(
653
- "Fixed-height editor should release parent intercept after the gesture ends",
654
- !parent.disallowInterceptRequested
655
- )
656
- }
657
-
658
- @Test
659
- fun `selected image shows resize overlay at rendered image bounds`() {
660
- val context = RuntimeEnvironment.getApplication()
661
- val view = RichTextEditorView(context)
662
- view.editorEditText.applyRenderJSON(imageRenderJson())
663
-
664
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
665
- val heightSpec = View.MeasureSpec.makeMeasureSpec(240, View.MeasureSpec.EXACTLY)
666
- view.measure(widthSpec, heightSpec)
667
- view.layout(0, 0, view.measuredWidth, view.measuredHeight)
668
-
669
- val text = view.editorEditText.text as? Spanned
670
- assertNotNull("Expected rendered text with spans", text)
671
- text ?: return
672
-
673
- val imageSpan = text.getSpans(0, text.length, BlockImageSpan::class.java).firstOrNull()
674
- assertNotNull("Expected a rendered image span", imageSpan)
675
- imageSpan ?: return
676
-
677
- val spanStart = text.getSpanStart(imageSpan)
678
- val spanEnd = text.getSpanEnd(imageSpan)
679
- view.editorEditText.setSelection(spanStart, spanEnd)
680
- view.editorEditText.onSelectionOrContentMayChange?.invoke()
681
-
682
- val overlayRect = view.imageResizeOverlayRectForTesting()
683
- assertNotNull("Selecting an image should show the resize overlay", overlayRect)
684
- overlayRect ?: return
685
- assertEquals(140f, overlayRect.width(), 1f)
686
- assertEquals(80f, overlayRect.height(), 1f)
687
- }
688
-
689
- @Test
690
- fun `tapping rendered image selects it for resize overlay`() {
691
- val context = RuntimeEnvironment.getApplication()
692
- val view = RichTextEditorView(context)
693
- view.editorEditText.applyRenderJSON(imageRenderJson())
694
-
695
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
696
- val heightSpec = View.MeasureSpec.makeMeasureSpec(240, View.MeasureSpec.EXACTLY)
697
- view.measure(widthSpec, heightSpec)
698
- view.layout(0, 0, view.measuredWidth, view.measuredHeight)
699
-
700
- val text = view.editorEditText.text as? Spanned
701
- assertNotNull("Expected rendered text with spans", text)
702
- text ?: return
703
-
704
- val imageSpan = text.getSpans(0, text.length, BlockImageSpan::class.java).firstOrNull()
705
- assertNotNull("Expected a rendered image span", imageSpan)
706
- imageSpan ?: return
707
-
708
- val spanStart = text.getSpanStart(imageSpan)
709
- val spanEnd = text.getSpanEnd(imageSpan)
710
- val canvasBitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
711
- view.editorEditText.draw(Canvas(canvasBitmap))
712
-
713
- val drawnRect = imageSpan.currentDrawRect()
714
- assertNotNull("Expected drawn image bounds", drawnRect)
715
- drawnRect ?: return
716
- val tapX = drawnRect.centerX()
717
- val tapY = drawnRect.centerY()
718
-
719
- val down = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, tapX, tapY, 0)
720
- view.editorEditText.onTouchEvent(down)
721
- down.recycle()
722
-
723
- val up = MotionEvent.obtain(0, 16, MotionEvent.ACTION_UP, tapX, tapY, 0)
724
- view.editorEditText.onTouchEvent(up)
725
- up.recycle()
726
-
727
- assertEquals(spanStart, view.editorEditText.selectionStart)
728
- assertEquals(spanEnd, view.editorEditText.selectionEnd)
729
-
730
- val overlayRect = view.imageResizeOverlayRectForTesting()
731
- assertNotNull("Tapping an image should show the resize overlay", overlayRect)
732
- overlayRect ?: return
733
- assertEquals(140f, overlayRect.width(), 1f)
734
- assertEquals(80f, overlayRect.height(), 1f)
735
- }
736
-
737
- @Test
738
- fun `disabling image resizing keeps image taps from showing resize overlay`() {
739
- val context = RuntimeEnvironment.getApplication()
740
- val view = RichTextEditorView(context)
741
- view.editorEditText.applyRenderJSON(imageRenderJson())
742
- view.setImageResizingEnabled(false)
743
-
744
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
745
- val heightSpec = View.MeasureSpec.makeMeasureSpec(240, View.MeasureSpec.EXACTLY)
746
- view.measure(widthSpec, heightSpec)
747
- view.layout(0, 0, view.measuredWidth, view.measuredHeight)
748
-
749
- val text = view.editorEditText.text as? Spanned
750
- assertNotNull("Expected rendered text with spans", text)
751
- text ?: return
752
-
753
- val imageSpan = text.getSpans(0, text.length, BlockImageSpan::class.java).firstOrNull()
754
- assertNotNull("Expected a rendered image span", imageSpan)
755
- imageSpan ?: return
756
-
757
- val canvasBitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
758
- view.editorEditText.draw(Canvas(canvasBitmap))
759
-
760
- val drawnRect = imageSpan.currentDrawRect()
761
- assertNotNull("Expected drawn image bounds", drawnRect)
762
- drawnRect ?: return
763
- val tapX = drawnRect.centerX()
764
- val tapY = drawnRect.centerY()
765
-
766
- val down = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, tapX, tapY, 0)
767
- view.editorEditText.onTouchEvent(down)
768
- down.recycle()
769
-
770
- val up = MotionEvent.obtain(0, 16, MotionEvent.ACTION_UP, tapX, tapY, 0)
771
- view.editorEditText.onTouchEvent(up)
772
- up.recycle()
773
-
774
- assertNull("Tapping an image should not show the resize overlay when disabled", view.imageResizeOverlayRectForTesting())
775
- }
776
-
777
- @Test
778
- fun `editor theme contentInsets apply padding in density-scaled pixels`() {
779
- val context = RuntimeEnvironment.getApplication()
780
- val editText = EditorEditText(context)
781
- val density = context.resources.displayMetrics.density
782
- editText.setHeightBehavior(EditorHeightBehavior.AUTO_GROW)
783
- val theme = EditorTheme.fromJson(
784
- """
785
- {
786
- "contentInsets": { "top": 8, "right": 10, "bottom": 12, "left": 14 }
787
- }
788
- """.trimIndent()
789
- )
790
-
791
- editText.applyTheme(theme)
792
-
793
- assertEquals((14f * density).toInt(), editText.paddingLeft)
794
- assertEquals((8f * density).toInt(), editText.paddingTop)
795
- assertEquals((10f * density).toInt(), editText.paddingRight)
796
- assertEquals((12f * density).toInt(), editText.paddingBottom)
797
- }
798
-
799
- @Test
800
- fun `editor theme borderRadius applies to scroll container in density-scaled pixels`() {
801
- val context = RuntimeEnvironment.getApplication()
802
- val richTextEditorView = RichTextEditorView(context)
803
- val density = context.resources.displayMetrics.density
804
- val theme = EditorTheme.fromJson(
805
- """
806
- {
807
- "backgroundColor": "#d7e4ff",
808
- "borderRadius": 18
809
- }
810
- """.trimIndent()
811
- )
812
-
813
- richTextEditorView.applyTheme(theme)
814
-
815
- assertEquals(18f * density, richTextEditorView.appliedCornerRadiusPx, 0.1f)
816
- assertTrue(richTextEditorView.editorViewport.clipToOutline)
817
- }
818
-
819
- @Test
820
- fun `editor theme transparent backgroundColor applies transparent viewport background`() {
821
- val context = RuntimeEnvironment.getApplication()
822
- val richTextEditorView = RichTextEditorView(context)
823
- richTextEditorView.configure(
824
- textSizePx = 16f * context.resources.displayMetrics.density,
825
- textColor = Color.BLACK,
826
- backgroundColor = Color.WHITE
827
- )
828
-
829
- val theme = EditorTheme.fromJson(
830
- """
831
- {
832
- "backgroundColor": "transparent"
833
- }
834
- """.trimIndent()
835
- )
836
-
837
- assertEquals(Color.TRANSPARENT, theme?.backgroundColor)
838
-
839
- richTextEditorView.applyTheme(theme)
840
-
841
- assertEquals(Color.TRANSPARENT, richTextEditorView.appliedBackgroundColorForTesting)
842
- }
843
-
844
- @Test
845
- fun `fixed height editor reserves viewport inset in effective bottom padding`() {
846
- val context = RuntimeEnvironment.getApplication()
847
- val richTextEditorView = RichTextEditorView(context)
848
- richTextEditorView.setHeightBehavior(EditorHeightBehavior.FIXED)
849
- richTextEditorView.applyTheme(
850
- EditorTheme.fromJson(
851
- """
852
- {
853
- "contentInsets": { "bottom": 12 }
854
- }
855
- """.trimIndent()
856
- )
857
- )
858
-
859
- richTextEditorView.setViewportBottomInsetPx(96)
860
-
861
- val density = context.resources.displayMetrics.density
862
- assertEquals((12f * density).toInt() + 96, richTextEditorView.editorScrollView.paddingBottom)
863
- assertEquals(0, richTextEditorView.editorEditText.paddingBottom)
864
- }
865
-
866
- @Test
867
- fun `fixed height editor scrolls vertical contentInsets away while preserving viewport inset`() {
868
- val context = RuntimeEnvironment.getApplication()
869
- val richTextEditorView = RichTextEditorView(context)
870
- val density = context.resources.displayMetrics.density
871
- richTextEditorView.setHeightBehavior(EditorHeightBehavior.FIXED)
872
- richTextEditorView.applyTheme(
873
- EditorTheme.fromJson(
874
- """
875
- {
876
- "contentInsets": { "top": 8, "bottom": 12 }
877
- }
878
- """.trimIndent()
879
- )
880
- )
881
-
882
- richTextEditorView.setViewportBottomInsetPx(96)
883
-
884
- assertTrue(!richTextEditorView.editorScrollView.clipToPadding)
885
- assertEquals((8f * density).toInt(), richTextEditorView.editorScrollView.paddingTop)
886
- assertEquals((12f * density).toInt() + 96, richTextEditorView.editorScrollView.paddingBottom)
887
- assertEquals(0, richTextEditorView.editorEditText.paddingTop)
888
- assertEquals(0, richTextEditorView.editorEditText.paddingBottom)
889
- }
890
-
891
- @Test
892
- fun `caret rect is reported in editor view coordinates`() {
893
- val context = RuntimeEnvironment.getApplication()
894
- val richTextEditorView = RichTextEditorView(context)
895
- richTextEditorView.editorEditText.setText("Hello world")
896
-
897
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
898
- val heightSpec = View.MeasureSpec.makeMeasureSpec(240, View.MeasureSpec.EXACTLY)
899
- richTextEditorView.measure(widthSpec, heightSpec)
900
- richTextEditorView.layout(
901
- 0,
902
- 0,
903
- richTextEditorView.measuredWidth,
904
- richTextEditorView.measuredHeight
905
- )
906
- richTextEditorView.editorEditText.setSelection(5)
907
-
908
- val editTextRect = richTextEditorView.editorEditText.caretRect()
909
- val actual = richTextEditorView.caretRect()
910
-
911
- assertNotNull(editTextRect)
912
- assertNotNull(actual)
913
- assertEquals(
914
- richTextEditorView.editorViewport.left +
915
- richTextEditorView.editorScrollView.left +
916
- richTextEditorView.editorEditText.left +
917
- editTextRect!!.left,
918
- actual!!.left,
919
- 0.1f
920
- )
921
- assertEquals(
922
- richTextEditorView.editorViewport.top +
923
- richTextEditorView.editorScrollView.top +
924
- richTextEditorView.editorEditText.top +
925
- editTextRect.top -
926
- richTextEditorView.editorScrollView.scrollY,
927
- actual.top,
928
- 0.1f
929
- )
930
- assertTrue(actual.height() > 0f)
931
- }
932
-
933
- @Test
934
- fun `painted caret rect is clipped to glyph height on a spacer line`() {
935
- val context = RuntimeEnvironment.getApplication()
936
- val editText = EditorEditText(context)
937
- editText.layoutParams = ViewGroup.LayoutParams(600, 240)
938
- val spanned = SpannableStringBuilder("Hello\nWorld")
939
- spanned.setSpan(
940
- ParagraphSpacerSpan(spacingPx = 60, baseFontSize = 16, textColor = Color.BLACK),
941
- 5,
942
- 6,
943
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
944
- )
945
- editText.setText(spanned)
946
-
947
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
948
- val heightSpec = View.MeasureSpec.makeMeasureSpec(240, View.MeasureSpec.EXACTLY)
949
- editText.measure(widthSpec, heightSpec)
950
- editText.layout(0, 0, editText.measuredWidth, editText.measuredHeight)
951
- editText.setSelection(5) // collapsed caret on the spacer line
952
-
953
- val layout = editText.layout!!
954
- val inflatedLineHeight = (layout.getLineBottom(0) - layout.getLineTop(0)).toFloat()
955
- // The exact rectangle drawCustomCaret paints (content coordinates, caret width).
956
- val caret = editText.customCaretDrawRect()
957
-
958
- assertNotNull("a caret rect should be produced for a collapsed selection", caret)
959
- assertTrue("painted caret should have width", caret!!.width() > 0f)
960
- assertTrue("painted caret should have height", caret.height() > 0f)
961
- assertTrue(
962
- "painted caret height ${caret.height()} must exclude the 60px gap (inflated=$inflatedLineHeight)",
963
- caret.height() < inflatedLineHeight - 20f
964
- )
965
- }
966
-
967
- @Test
968
- fun `caret rect height excludes the paragraph spacer gap`() {
969
- val context = RuntimeEnvironment.getApplication()
970
- val editText = EditorEditText(context)
971
- editText.layoutParams = ViewGroup.LayoutParams(600, 240)
972
- val spanned = SpannableStringBuilder("Hello\nWorld")
973
- // Spacer on the inter-block newline inflates the descent of line 0.
974
- spanned.setSpan(
975
- ParagraphSpacerSpan(spacingPx = 60, baseFontSize = 16, textColor = Color.BLACK),
976
- 5,
977
- 6,
978
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
979
- )
980
- editText.setText(spanned)
981
-
982
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
983
- val heightSpec = View.MeasureSpec.makeMeasureSpec(240, View.MeasureSpec.EXACTLY)
984
- editText.measure(widthSpec, heightSpec)
985
- editText.layout(0, 0, editText.measuredWidth, editText.measuredHeight)
986
- editText.setSelection(5) // caret on the spacer line (line 0)
987
-
988
- val layout = editText.layout!!
989
- val line = 0
990
- val inflatedLineHeight = (layout.getLineBottom(line) - layout.getLineTop(line)).toFloat()
991
- val rect = editText.caretRect()!!
992
-
993
- assertTrue(
994
- "reproduction guard: spacer should inflate the line box",
995
- layout.getLineDescent(line) > editText.paint.fontMetrics.descent
996
- )
997
- assertTrue("caret height should be positive", rect.height() > 0f)
998
- assertTrue(
999
- "caret height ${rect.height()} must exclude the 60px paragraph gap (inflated line height=$inflatedLineHeight)",
1000
- rect.height() < inflatedLineHeight - 20f
1001
- )
1002
- }
1003
-
1004
- @Test
1005
- fun `remote selections expose focused caret geometry without a badge`() {
1006
- val context = RuntimeEnvironment.getApplication()
1007
- val view = RichTextEditorView(context)
1008
- view.setRemoteSelectionEditorIdForTesting(1L)
1009
- view.editorEditText.setText("Hello world")
1010
- view.setRemoteSelectionScalarResolverForTesting { _, docPos -> docPos }
1011
-
1012
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
1013
- val heightSpec = View.MeasureSpec.makeMeasureSpec(240, View.MeasureSpec.EXACTLY)
1014
- view.measure(widthSpec, heightSpec)
1015
- view.layout(0, 0, view.measuredWidth, view.measuredHeight)
1016
-
1017
- view.setRemoteSelections(
1018
- listOf(
1019
- RemoteSelectionDecoration(
1020
- clientId = 7,
1021
- anchor = 6,
1022
- head = 6,
1023
- color = Color.parseColor("#ff6b35"),
1024
- name = "Alice",
1025
- isFocused = true,
1026
- )
1027
- )
1028
- )
1029
-
1030
- val snapshot = view.remoteSelectionDebugSnapshotsForTesting().single()
1031
- assertEquals(7, snapshot.clientId)
1032
- assertNotNull(snapshot.caretRect)
1033
- assertTrue(snapshot.caretRect!!.height() > 0f)
1034
- }
1035
-
1036
- @Test
1037
- fun `unfocused collapsed remote selection does not expose caret or badge geometry`() {
1038
- val context = RuntimeEnvironment.getApplication()
1039
- val view = RichTextEditorView(context)
1040
- view.setRemoteSelectionEditorIdForTesting(1L)
1041
- view.editorEditText.setText("Hello world")
1042
- view.setRemoteSelectionScalarResolverForTesting { _, docPos -> docPos }
1043
-
1044
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
1045
- val heightSpec = View.MeasureSpec.makeMeasureSpec(240, View.MeasureSpec.EXACTLY)
1046
- view.measure(widthSpec, heightSpec)
1047
- view.layout(0, 0, view.measuredWidth, view.measuredHeight)
1048
-
1049
- view.setRemoteSelections(
1050
- listOf(
1051
- RemoteSelectionDecoration(
1052
- clientId = 8,
1053
- anchor = 6,
1054
- head = 6,
1055
- color = Color.parseColor("#007aff"),
1056
- name = "Alice",
1057
- isFocused = false,
1058
- )
1059
- )
1060
- )
1061
-
1062
- val snapshot = view.remoteSelectionDebugSnapshotsForTesting().single()
1063
- assertEquals(8, snapshot.clientId)
1064
- assertTrue(snapshot.caretRect == null)
1065
- }
1066
-
1067
- @Test
1068
- fun `remote selection geometry is cached across redraws`() {
1069
- val context = RuntimeEnvironment.getApplication()
1070
- val view = RichTextEditorView(context)
1071
- view.setRemoteSelectionEditorIdForTesting(1L)
1072
- view.editorEditText.setText("Hello world from remote selections")
1073
-
1074
- var resolverCalls = 0
1075
- view.setRemoteSelectionScalarResolverForTesting { _, docPos ->
1076
- resolverCalls += 1
1077
- docPos
1078
- }
1079
-
1080
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
1081
- val heightSpec = View.MeasureSpec.makeMeasureSpec(240, View.MeasureSpec.EXACTLY)
1082
- view.measure(widthSpec, heightSpec)
1083
- view.layout(0, 0, view.measuredWidth, view.measuredHeight)
1084
-
1085
- view.setRemoteSelections(
1086
- listOf(
1087
- RemoteSelectionDecoration(
1088
- clientId = 11,
1089
- anchor = 6,
1090
- head = 12,
1091
- color = Color.parseColor("#ff9500"),
1092
- name = "Range",
1093
- isFocused = true,
1094
- )
1095
- )
1096
- )
1097
-
1098
- val bitmap = Bitmap.createBitmap(600, 240, Bitmap.Config.ARGB_8888)
1099
- val canvas = Canvas(bitmap)
1100
- resolverCalls = 0
1101
-
1102
- view.draw(canvas)
1103
- view.draw(canvas)
1104
-
1105
- assertEquals(0, resolverCalls)
1106
- }
1107
-
1108
- @Test
1109
- fun `setting identical remote selections does not invalidate cached geometry`() {
1110
- val context = RuntimeEnvironment.getApplication()
1111
- val view = RichTextEditorView(context)
1112
- view.setRemoteSelectionEditorIdForTesting(1L)
1113
- view.editorEditText.setText("Hello world from remote selections")
1114
-
1115
- var resolverCalls = 0
1116
- view.setRemoteSelectionScalarResolverForTesting { _, docPos ->
1117
- resolverCalls += 1
1118
- docPos
1119
- }
1120
-
1121
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
1122
- val heightSpec = View.MeasureSpec.makeMeasureSpec(240, View.MeasureSpec.EXACTLY)
1123
- view.measure(widthSpec, heightSpec)
1124
- view.layout(0, 0, view.measuredWidth, view.measuredHeight)
1125
-
1126
- val initialSelections = listOf(
1127
- RemoteSelectionDecoration(
1128
- clientId = 12,
1129
- anchor = 6,
1130
- head = 12,
1131
- color = Color.parseColor("#34c759"),
1132
- name = "Range",
1133
- isFocused = true,
1134
- )
1135
- )
1136
- view.setRemoteSelections(initialSelections)
1137
- view.remoteSelectionDebugSnapshotsForTesting()
1138
-
1139
- resolverCalls = 0
1140
- val identicalSelections = listOf(
1141
- RemoteSelectionDecoration(
1142
- clientId = 12,
1143
- anchor = 6,
1144
- head = 12,
1145
- color = Color.parseColor("#34c759"),
1146
- name = "Range",
1147
- isFocused = true,
1148
- )
1149
- )
1150
- view.setRemoteSelections(identicalSelections)
1151
- view.remoteSelectionDebugSnapshotsForTesting()
1152
-
1153
- assertEquals(0, resolverCalls)
1154
- }
1155
-
1156
- @Test
1157
- fun `remote selection json parsing tolerates invalid colors`() {
1158
- val context = RuntimeEnvironment.getApplication()
1159
-
1160
- val selections = RemoteSelectionDecoration.fromJson(
1161
- context,
1162
- """
1163
- [
1164
- {
1165
- "clientId": 19,
1166
- "anchor": 2,
1167
- "head": 2,
1168
- "color": "not-a-color",
1169
- "name": "Alice",
1170
- "isFocused": true
1171
- }
1172
- ]
1173
- """.trimIndent()
1174
- )
1175
-
1176
- assertEquals(1, selections.size)
1177
- assertEquals(19, selections.single().clientId)
1178
- }
1179
-
1180
- @Test
1181
- fun `unordered marker scale does not change list item height`() {
1182
- val context = RuntimeEnvironment.getApplication()
1183
- val renderJson = singleBulletListRenderJson()
1184
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
1185
- val heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
1186
-
1187
- fun measureHeight(markerScale: Float): Int {
1188
- val theme = EditorTheme.fromJson(
1189
- """
1190
- {
1191
- "text": { "fontSize": 17 },
1192
- "list": { "markerScale": $markerScale }
1193
- }
1194
- """.trimIndent()
1195
- )
1196
- val editText = EditorEditText(context)
1197
- editText.setText(
1198
- RenderBridge.buildSpannable(
1199
- renderJson,
1200
- 17f,
1201
- Color.BLACK,
1202
- theme,
1203
- 1f
1204
- )
1205
- )
1206
- editText.measure(widthSpec, heightSpec)
1207
- editText.layout(0, 0, editText.measuredWidth, editText.measuredHeight)
1208
- return editText.measuredHeight
1209
- }
1210
-
1211
- val normalHeight = measureHeight(1f)
1212
- val scaledHeight = measureHeight(2f)
1213
-
1214
- assertEquals(normalHeight, scaledHeight)
1215
- }
1216
-
1217
- @Test
1218
- fun `unordered marker scale does not change spacer heavy example height`() {
1219
- val context = RuntimeEnvironment.getApplication()
1220
- val renderJson = exampleRenderJson()
1221
- val widthSpec = View.MeasureSpec.makeMeasureSpec(902, View.MeasureSpec.EXACTLY)
1222
- val heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
1223
-
1224
- fun measureHeight(markerScale: Float): Int {
1225
- val theme = exampleTheme(markerScale)
1226
- val editText = EditorEditText(context)
1227
- editText.setBaseStyle(
1228
- 17f * 2.625f,
1229
- Color.parseColor("#2a2118"),
1230
- Color.parseColor("#f6f1e8")
1231
- )
1232
- editText.applyTheme(theme)
1233
- editText.setText(
1234
- RenderBridge.buildSpannable(
1235
- renderJson,
1236
- 17f,
1237
- Color.parseColor("#2a2118"),
1238
- theme,
1239
- 2.625f
1240
- )
1241
- )
1242
- editText.measure(widthSpec, heightSpec)
1243
- editText.layout(0, 0, editText.measuredWidth, editText.measuredHeight)
1244
- return editText.measuredHeight
1245
- }
1246
-
1247
- val normalHeight = measureHeight(1f)
1248
- val scaledHeight = measureHeight(2f)
1249
-
1250
- assertEquals(normalHeight, scaledHeight)
1251
- }
1252
-
1253
- @Test
1254
- fun `editor auto grow height recomputes from new text before relayout`() {
1255
- val context = RuntimeEnvironment.getApplication()
1256
- val widthSpec = View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)
1257
- val wrapHeightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
1258
-
1259
- val subject = EditorEditText(context)
1260
- subject.layoutParams = ViewGroup.LayoutParams(
1261
- ViewGroup.LayoutParams.MATCH_PARENT,
1262
- ViewGroup.LayoutParams.WRAP_CONTENT
1263
- )
1264
- subject.setText("Short content")
1265
- subject.measure(widthSpec, wrapHeightSpec)
1266
- subject.layout(0, 0, subject.measuredWidth, subject.measuredHeight)
1267
- val shortHeight = subject.resolveAutoGrowHeight()
1268
-
1269
- val tallText = "Line one\nLine two\nLine three\nLine four\nLine five"
1270
- val expectedView = EditorEditText(context)
1271
- expectedView.layoutParams = ViewGroup.LayoutParams(
1272
- ViewGroup.LayoutParams.MATCH_PARENT,
1273
- ViewGroup.LayoutParams.WRAP_CONTENT
1274
- )
1275
- expectedView.setText(tallText)
1276
- expectedView.measure(widthSpec, wrapHeightSpec)
1277
- expectedView.layout(0, 0, expectedView.measuredWidth, expectedView.measuredHeight)
1278
- val expectedTallHeight = expectedView.resolveAutoGrowHeight()
1279
-
1280
- subject.setText(tallText)
1281
-
1282
- val resolvedBeforeRelayout = subject.resolveAutoGrowHeight()
1283
-
1284
- assertTrue(
1285
- "Expected taller content height to exceed original height",
1286
- expectedTallHeight > shortHeight
1287
- )
1288
- assertEquals(expectedTallHeight, resolvedBeforeRelayout)
1289
- }
1290
-
1291
- @Test
1292
- fun `rich text editor auto grow keeps measured spacer content height before layout`() {
1293
- val richTextEditorView = RichTextEditorView(RuntimeEnvironment.getApplication())
1294
- richTextEditorView.setHeightBehavior(EditorHeightBehavior.AUTO_GROW)
1295
- val spannable = RenderBridge.buildSpannable(
1296
- exampleRenderJson(),
1297
- 17f,
1298
- Color.BLACK,
1299
- exampleTheme(),
1300
- 2.625f
1301
- )
1302
- richTextEditorView.editorEditText.setText(spannable)
1303
-
1304
- val widthSpec = View.MeasureSpec.makeMeasureSpec(902, View.MeasureSpec.EXACTLY)
1305
- val heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.EXACTLY)
1306
- richTextEditorView.measure(widthSpec, heightSpec)
1307
-
1308
- assertTrue(
1309
- "Spacer-heavy content should have a positive measured height",
1310
- richTextEditorView.measuredHeight > 0
1311
- )
1312
- assertEquals(
1313
- "Auto-grow container should track the measured child height before layout",
1314
- richTextEditorView.editorEditText.measuredHeight,
1315
- richTextEditorView.measuredHeight
1316
- )
1317
- assertTrue(
1318
- "Pre-layout fallback height should not exceed the measured spacer layout height",
1319
- richTextEditorView.editorEditText.resolveAutoGrowHeight() <= richTextEditorView.measuredHeight
1320
- )
1321
- }
1322
-
1323
- @Test
1324
- fun `example content layout does not end with multiple blank lines`() {
1325
- val editText = EditorEditText(RuntimeEnvironment.getApplication())
1326
- val theme = exampleTheme()
1327
- editText.setBaseStyle(17f * 2.625f, Color.parseColor("#2a2118"), Color.parseColor("#f6f1e8"))
1328
- editText.applyTheme(theme)
1329
- editText.setText(
1330
- RenderBridge.buildSpannable(
1331
- exampleRenderJson(),
1332
- 17f,
1333
- Color.parseColor("#2a2118"),
1334
- theme,
1335
- 2.625f
1336
- )
1337
- )
1338
-
1339
- val widthSpec = View.MeasureSpec.makeMeasureSpec(902, View.MeasureSpec.EXACTLY)
1340
- val heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
1341
- editText.measure(widthSpec, heightSpec)
1342
- editText.layout(0, 0, editText.measuredWidth, editText.measuredHeight)
1343
-
1344
- val layout = editText.layout
1345
- assertTrue("Expected layout for example content", layout != null)
1346
- layout ?: return
1347
-
1348
- val text = editText.text?.toString().orEmpty()
1349
- var trailingBlankLines = 0
1350
- for (line in layout.lineCount - 1 downTo 0) {
1351
- val start = layout.getLineStart(line)
1352
- val end = layout.getLineEnd(line)
1353
- val lineText = text.substring(start, end).replace("\n", "").trim()
1354
- if (lineText.isEmpty()) {
1355
- trailingBlankLines += 1
1356
- continue
1357
- }
1358
- break
1359
- }
1360
-
1361
- val spacerSpans = editText.text?.getSpans(0, text.length, ParagraphSpacerSpan::class.java) ?: emptyArray()
1362
- assertTrue(
1363
- "Trailing blank lines=$trailingBlankLines lineCount=${layout.lineCount} text='${text.replace("\n", "\\n")}' spacerCount=${spacerSpans.size} measuredHeight=${editText.measuredHeight}",
1364
- trailingBlankLines <= 1
1365
- )
1366
- }
1367
- }