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

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 +1445 -0
  30. package/ios/NativeEditorExpoView.swift +3 -3
  31. package/ios/NativeEditorModule.swift +31 -46
  32. package/ios/NativeInputSupport.swift +396 -0
  33. package/ios/PositionBridge.swift +4 -4
  34. package/ios/RichTextEditorView.swift +352 -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,789 +0,0 @@
1
- package com.openeditor.editor
2
-
3
- import android.app.Activity
4
- import android.app.Instrumentation
5
- import android.content.Context
6
- import android.graphics.Color
7
- import android.os.SystemClock
8
- import android.view.MotionEvent
9
- import android.view.View
10
- import android.view.ViewGroup
11
- import android.widget.FrameLayout
12
- import android.widget.LinearLayout
13
- import android.widget.ScrollView
14
- import androidx.test.core.app.ActivityScenario
15
- import androidx.test.ext.junit.runners.AndroidJUnit4
16
- import androidx.test.filters.LargeTest
17
- import androidx.test.platform.app.InstrumentationRegistry
18
- import expo.modules.core.ModuleRegistry
19
- import expo.modules.kotlin.AppContext
20
- import expo.modules.kotlin.ModulesProvider
21
- import expo.modules.kotlin.modules.Module
22
- import java.lang.ref.WeakReference
23
- import java.util.Collections
24
- import java.util.concurrent.atomic.AtomicInteger
25
- import java.util.concurrent.atomic.AtomicReference
26
- import org.json.JSONArray
27
- import org.json.JSONObject
28
- import org.junit.Assert.assertFalse
29
- import org.junit.Assert.assertTrue
30
- import org.junit.Test
31
- import org.junit.runner.RunWith
32
- import uniffi.editor_core.editorCreate
33
- import uniffi.editor_core.editorDestroy
34
-
35
- @RunWith(AndroidJUnit4::class)
36
- @LargeTest
37
- class NativeDeviceOutsideTapTest {
38
- private val instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation()
39
-
40
- @Test
41
- fun tappingOutsideFocusedEditorBlursEditor() {
42
- ActivityScenario.launch(NativeEditorOutsideTapActivity::class.java).use { scenario ->
43
- val editorRef = AtomicReference<NativeEditorExpoView>()
44
- val outsideTargetRef = AtomicReference<View>()
45
- val outsideTapTrace = Collections.synchronizedList(mutableListOf<String>())
46
- val editorId = editorCreate("{}").toLong()
47
-
48
- try {
49
- scenario.onActivity { activity ->
50
- val root = FrameLayout(activity).apply {
51
- setBackgroundColor(Color.WHITE)
52
- isFocusable = true
53
- isFocusableInTouchMode = true
54
- }
55
- val outsideTarget = View(activity).apply {
56
- setBackgroundColor(Color.rgb(238, 238, 238))
57
- }
58
- val expoContext = testExpoContext(activity)
59
- val editor = NativeEditorExpoView(expoContext.context, expoContext.appContext).apply {
60
- clipToPadding = false
61
- setShowToolbar(false)
62
- }
63
-
64
- editor.onFocusChangeForTesting = {}
65
- editor.onAddonEventForTesting = {}
66
- editor.onEditorUpdateForTesting = {}
67
- editor.onEditorReadyForTesting = {}
68
- editor.onOutsideTapTraceForTesting = { event ->
69
- outsideTapTrace.add(event)
70
- }
71
- root.addView(
72
- editor,
73
- FrameLayout.LayoutParams(
74
- ViewGroup.LayoutParams.MATCH_PARENT,
75
- dp(activity, 220)
76
- ).apply {
77
- topMargin = dp(activity, 48)
78
- leftMargin = dp(activity, 16)
79
- rightMargin = dp(activity, 16)
80
- }
81
- )
82
- root.addView(
83
- outsideTarget,
84
- FrameLayout.LayoutParams(
85
- ViewGroup.LayoutParams.MATCH_PARENT,
86
- dp(activity, 180)
87
- ).apply {
88
- topMargin = dp(activity, 320)
89
- leftMargin = dp(activity, 16)
90
- rightMargin = dp(activity, 16)
91
- }
92
- )
93
- activity.setContentView(root)
94
-
95
- editor.setEditorId(editorId)
96
- editor.richTextView.editorEditText.applyUpdateJSON(
97
- renderUpdateJson("Tap outside should blur"),
98
- notifyListener = false
99
- )
100
- editor.richTextView.editorEditText.setSelection(0)
101
-
102
- outsideTargetRef.set(outsideTarget)
103
- editorRef.set(editor)
104
- }
105
-
106
- instrumentation.waitForIdleSync()
107
-
108
- scenario.onActivity { activity ->
109
- editorRef.get().focus()
110
- activity.window.decorView.post {
111
- editorRef.get().installOutsideTapBlurHandlerForTesting()
112
- }
113
- }
114
- instrumentation.waitForIdleSync()
115
- waitUntil("editor should gain focus") {
116
- editorRef.get().richTextView.editorEditText.hasFocus()
117
- }
118
-
119
- tapCenterOnScreen(outsideTargetRef.get())
120
- instrumentation.waitForIdleSync()
121
-
122
- waitUntil(
123
- "editor should lose focus after outside tap",
124
- detail = { outsideTapTrace.joinToString(separator = "\n") }
125
- ) {
126
- !editorRef.get().richTextView.editorEditText.hasFocus()
127
- }
128
-
129
- assertTrue(
130
- outsideTapTrace.joinToString(separator = "\n"),
131
- outsideTapTrace.any { it.contains("OUTSIDE_EDITOR") }
132
- )
133
- assertFalse(editorRef.get().richTextView.editorEditText.hasFocus())
134
- } finally {
135
- scenario.onActivity {
136
- editorRef.get()?.uninstallOutsideTapBlurHandlerForTesting()
137
- }
138
- editorDestroy(editorId.toULong())
139
- }
140
- }
141
- }
142
-
143
- @Test
144
- fun tappingInlineToolbarFramePreservesEditorFocus() {
145
- ActivityScenario.launch(NativeEditorOutsideTapActivity::class.java).use { scenario ->
146
- val editorRef = AtomicReference<NativeEditorExpoView>()
147
- val toolbarTargetRef = AtomicReference<View>()
148
- val outsideTapTrace = Collections.synchronizedList(mutableListOf<String>())
149
- val editorId = editorCreate("{}").toLong()
150
-
151
- try {
152
- scenario.onActivity { activity ->
153
- val root = FrameLayout(activity).apply {
154
- setBackgroundColor(Color.WHITE)
155
- isFocusable = true
156
- isFocusableInTouchMode = true
157
- }
158
- val toolbarTarget = View(activity).apply {
159
- setBackgroundColor(Color.rgb(238, 238, 238))
160
- }
161
- val expoContext = testExpoContext(activity)
162
- val editor = NativeEditorExpoView(expoContext.context, expoContext.appContext).apply {
163
- clipToPadding = false
164
- setShowToolbar(false)
165
- }
166
-
167
- editor.onFocusChangeForTesting = {}
168
- editor.onAddonEventForTesting = {}
169
- editor.onEditorUpdateForTesting = {}
170
- editor.onEditorReadyForTesting = {}
171
- editor.onOutsideTapTraceForTesting = { event ->
172
- outsideTapTrace.add(event)
173
- }
174
- root.addView(
175
- editor,
176
- FrameLayout.LayoutParams(
177
- ViewGroup.LayoutParams.MATCH_PARENT,
178
- dp(activity, 220)
179
- ).apply {
180
- topMargin = dp(activity, 48)
181
- leftMargin = dp(activity, 16)
182
- rightMargin = dp(activity, 16)
183
- }
184
- )
185
- root.addView(
186
- toolbarTarget,
187
- FrameLayout.LayoutParams(
188
- ViewGroup.LayoutParams.MATCH_PARENT,
189
- dp(activity, 52)
190
- ).apply {
191
- topMargin = dp(activity, 320)
192
- leftMargin = dp(activity, 16)
193
- rightMargin = dp(activity, 16)
194
- }
195
- )
196
- activity.setContentView(root)
197
-
198
- editor.setEditorId(editorId)
199
- editor.richTextView.editorEditText.applyUpdateJSON(
200
- renderUpdateJson("Toolbar tap should preserve focus"),
201
- notifyListener = false
202
- )
203
- editor.richTextView.editorEditText.setSelection(0)
204
-
205
- toolbarTargetRef.set(toolbarTarget)
206
- editorRef.set(editor)
207
- }
208
-
209
- instrumentation.waitForIdleSync()
210
-
211
- scenario.onActivity { activity ->
212
- applyToolbarFrameFromView(activity, editorRef.get(), toolbarTargetRef.get())
213
- editorRef.get().focus()
214
- activity.window.decorView.post {
215
- editorRef.get().installOutsideTapBlurHandlerForTesting()
216
- }
217
- }
218
- instrumentation.waitForIdleSync()
219
- waitUntil("editor should gain focus") {
220
- editorRef.get().richTextView.editorEditText.hasFocus()
221
- }
222
-
223
- scenario.onActivity { activity ->
224
- recordTapGeometry(activity, "toolbar", toolbarTargetRef.get(), outsideTapTrace)
225
- }
226
- tapCenterOnScreen(toolbarTargetRef.get(), "toolbar", outsideTapTrace)
227
- SystemClock.sleep(400)
228
- instrumentation.waitForIdleSync()
229
-
230
- assertTrue(
231
- outsideTapTrace.joinToString(separator = "\n"),
232
- outsideTapTrace.any { it.contains("PRESERVE_FOCUS") }
233
- )
234
- assertTrue(
235
- "editor should keep focus after toolbar tap\n" +
236
- outsideTapTrace.joinToString(separator = "\n"),
237
- editorRef.get().richTextView.editorEditText.hasFocus()
238
- )
239
- } finally {
240
- scenario.onActivity {
241
- editorRef.get()?.uninstallOutsideTapBlurHandlerForTesting()
242
- }
243
- editorDestroy(editorId.toULong())
244
- }
245
- }
246
- }
247
-
248
- @Test
249
- fun swipingOutsideFocusedEditorInScrollViewKeepsEditorFocused() {
250
- ActivityScenario.launch(NativeEditorOutsideTapActivity::class.java).use { scenario ->
251
- val editorRef = AtomicReference<NativeEditorExpoView>()
252
- val scrollViewRef = AtomicReference<ScrollView>()
253
- val outsideTargetRef = AtomicReference<View>()
254
- val initialScrollY = AtomicInteger()
255
- val outsideTapTrace = Collections.synchronizedList(mutableListOf<String>())
256
- val editorId = editorCreate("{}").toLong()
257
-
258
- try {
259
- scenario.onActivity { activity ->
260
- val scrollView = ScrollView(activity).apply {
261
- setBackgroundColor(Color.WHITE)
262
- isFillViewport = true
263
- }
264
- val content = LinearLayout(activity).apply {
265
- orientation = LinearLayout.VERTICAL
266
- setBackgroundColor(Color.WHITE)
267
- isFocusable = true
268
- isFocusableInTouchMode = true
269
- }
270
- val expoContext = testExpoContext(activity)
271
- val editor = NativeEditorExpoView(expoContext.context, expoContext.appContext).apply {
272
- clipToPadding = false
273
- setShowToolbar(false)
274
- }
275
- val outsideTarget = View(activity).apply {
276
- setBackgroundColor(Color.rgb(238, 238, 238))
277
- }
278
-
279
- editor.onFocusChangeForTesting = {}
280
- editor.onAddonEventForTesting = {}
281
- editor.onEditorUpdateForTesting = {}
282
- editor.onEditorReadyForTesting = {}
283
- editor.onOutsideTapTraceForTesting = { event ->
284
- outsideTapTrace.add(event)
285
- }
286
-
287
- scrollView.addView(
288
- content,
289
- FrameLayout.LayoutParams(
290
- ViewGroup.LayoutParams.MATCH_PARENT,
291
- ViewGroup.LayoutParams.WRAP_CONTENT
292
- )
293
- )
294
- content.addView(
295
- View(activity),
296
- LinearLayout.LayoutParams(
297
- ViewGroup.LayoutParams.MATCH_PARENT,
298
- dp(activity, 48)
299
- )
300
- )
301
- content.addView(
302
- editor,
303
- LinearLayout.LayoutParams(
304
- ViewGroup.LayoutParams.MATCH_PARENT,
305
- dp(activity, 220)
306
- ).apply {
307
- leftMargin = dp(activity, 16)
308
- rightMargin = dp(activity, 16)
309
- }
310
- )
311
- content.addView(
312
- outsideTarget,
313
- LinearLayout.LayoutParams(
314
- ViewGroup.LayoutParams.MATCH_PARENT,
315
- dp(activity, 720)
316
- ).apply {
317
- leftMargin = dp(activity, 16)
318
- rightMargin = dp(activity, 16)
319
- }
320
- )
321
- content.addView(
322
- View(activity),
323
- LinearLayout.LayoutParams(
324
- ViewGroup.LayoutParams.MATCH_PARENT,
325
- dp(activity, 1200)
326
- )
327
- )
328
- activity.setContentView(scrollView)
329
-
330
- editor.setEditorId(editorId)
331
- editor.richTextView.editorEditText.applyUpdateJSON(
332
- renderUpdateJson("Swipe outside should scroll without blur"),
333
- notifyListener = false
334
- )
335
- editor.richTextView.editorEditText.setSelection(0)
336
-
337
- scrollViewRef.set(scrollView)
338
- outsideTargetRef.set(outsideTarget)
339
- editorRef.set(editor)
340
- }
341
-
342
- instrumentation.waitForIdleSync()
343
-
344
- scenario.onActivity { activity ->
345
- editorRef.get().focus()
346
- activity.window.decorView.post {
347
- editorRef.get().installOutsideTapBlurHandlerForTesting()
348
- }
349
- }
350
- instrumentation.waitForIdleSync()
351
- waitUntil("editor should gain focus") {
352
- editorRef.get().richTextView.editorEditText.hasFocus()
353
- }
354
- waitUntil("outside target should be laid out") {
355
- outsideTargetRef.get().isShown && outsideTargetRef.get().height > 0
356
- }
357
-
358
- scenario.onActivity {
359
- initialScrollY.set(scrollViewRef.get().scrollY)
360
- recordScrollGeometry(
361
- "before swipe",
362
- scrollViewRef.get(),
363
- editorRef.get(),
364
- outsideTargetRef.get(),
365
- outsideTapTrace
366
- )
367
- }
368
- swipeUpFromView(
369
- outsideTargetRef.get(),
370
- startOffsetDp = 120,
371
- distanceDp = 180,
372
- label = "outside scroll",
373
- trace = outsideTapTrace
374
- )
375
- instrumentation.waitForIdleSync()
376
-
377
- waitUntil(
378
- "scroll view should move after outside swipe",
379
- detail = { outsideTapTrace.joinToString(separator = "\n") }
380
- ) {
381
- scrollViewRef.get().scrollY > initialScrollY.get()
382
- }
383
-
384
- SystemClock.sleep(700)
385
- instrumentation.waitForIdleSync()
386
- scenario.onActivity {
387
- recordScrollGeometry(
388
- "after swipe",
389
- scrollViewRef.get(),
390
- editorRef.get(),
391
- outsideTargetRef.get(),
392
- outsideTapTrace
393
- )
394
- }
395
-
396
- assertFalse(
397
- outsideTapTrace.joinToString(separator = "\n"),
398
- outsideTapTrace.any { it.contains("complete blur focusedAfter=false") }
399
- )
400
- assertTrue(
401
- "editor should keep focus after scrolling outside it\n" +
402
- outsideTapTrace.joinToString(separator = "\n"),
403
- editorRef.get().richTextView.editorEditText.hasFocus()
404
- )
405
- } finally {
406
- scenario.onActivity {
407
- editorRef.get()?.uninstallOutsideTapBlurHandlerForTesting()
408
- }
409
- editorDestroy(editorId.toULong())
410
- }
411
- }
412
- }
413
-
414
- @Test
415
- fun swipingChatListBehindStickyComposerKeepsEditorFocused() {
416
- ActivityScenario.launch(NativeEditorOutsideTapActivity::class.java).use { scenario ->
417
- val editorRef = AtomicReference<NativeEditorExpoView>()
418
- val scrollViewRef = AtomicReference<ScrollView>()
419
- val outsideTargetRef = AtomicReference<View>()
420
- val initialScrollY = AtomicInteger()
421
- val outsideTapTrace = Collections.synchronizedList(mutableListOf<String>())
422
- val editorId = editorCreate("{}").toLong()
423
-
424
- try {
425
- scenario.onActivity { activity ->
426
- val root = FrameLayout(activity).apply {
427
- setBackgroundColor(Color.WHITE)
428
- }
429
- val scrollView = ScrollView(activity).apply {
430
- setBackgroundColor(Color.WHITE)
431
- isFillViewport = true
432
- }
433
- val content = LinearLayout(activity).apply {
434
- orientation = LinearLayout.VERTICAL
435
- setBackgroundColor(Color.WHITE)
436
- isFocusable = true
437
- isFocusableInTouchMode = true
438
- }
439
- val outsideTarget = View(activity).apply {
440
- setBackgroundColor(Color.rgb(238, 238, 238))
441
- }
442
- val expoContext = testExpoContext(activity)
443
- val editor = NativeEditorExpoView(expoContext.context, expoContext.appContext).apply {
444
- clipToPadding = false
445
- setShowToolbar(false)
446
- setBackgroundColor(Color.WHITE)
447
- }
448
-
449
- editor.onFocusChangeForTesting = {}
450
- editor.onAddonEventForTesting = {}
451
- editor.onEditorUpdateForTesting = {}
452
- editor.onEditorReadyForTesting = {}
453
- editor.onOutsideTapTraceForTesting = { event ->
454
- outsideTapTrace.add(event)
455
- }
456
-
457
- scrollView.addView(
458
- content,
459
- FrameLayout.LayoutParams(
460
- ViewGroup.LayoutParams.MATCH_PARENT,
461
- ViewGroup.LayoutParams.WRAP_CONTENT
462
- )
463
- )
464
- content.addView(
465
- View(activity),
466
- LinearLayout.LayoutParams(
467
- ViewGroup.LayoutParams.MATCH_PARENT,
468
- dp(activity, 24)
469
- )
470
- )
471
- content.addView(
472
- outsideTarget,
473
- LinearLayout.LayoutParams(
474
- ViewGroup.LayoutParams.MATCH_PARENT,
475
- dp(activity, 680)
476
- ).apply {
477
- leftMargin = dp(activity, 16)
478
- rightMargin = dp(activity, 16)
479
- }
480
- )
481
- content.addView(
482
- View(activity),
483
- LinearLayout.LayoutParams(
484
- ViewGroup.LayoutParams.MATCH_PARENT,
485
- dp(activity, 1600)
486
- )
487
- )
488
- root.addView(
489
- scrollView,
490
- FrameLayout.LayoutParams(
491
- ViewGroup.LayoutParams.MATCH_PARENT,
492
- ViewGroup.LayoutParams.MATCH_PARENT
493
- )
494
- )
495
- root.addView(
496
- editor,
497
- FrameLayout.LayoutParams(
498
- ViewGroup.LayoutParams.MATCH_PARENT,
499
- dp(activity, 180)
500
- ).apply {
501
- gravity = android.view.Gravity.BOTTOM
502
- leftMargin = dp(activity, 16)
503
- rightMargin = dp(activity, 16)
504
- bottomMargin = dp(activity, 16)
505
- }
506
- )
507
- activity.setContentView(root)
508
-
509
- editor.setEditorId(editorId)
510
- editor.richTextView.editorEditText.applyUpdateJSON(
511
- renderUpdateJson("Sticky composer should stay focused while chat scrolls"),
512
- notifyListener = false
513
- )
514
- editor.richTextView.editorEditText.setSelection(0)
515
-
516
- scrollViewRef.set(scrollView)
517
- outsideTargetRef.set(outsideTarget)
518
- editorRef.set(editor)
519
- }
520
-
521
- instrumentation.waitForIdleSync()
522
-
523
- scenario.onActivity { activity ->
524
- editorRef.get().focus()
525
- activity.window.decorView.post {
526
- editorRef.get().installOutsideTapBlurHandlerForTesting()
527
- }
528
- }
529
- instrumentation.waitForIdleSync()
530
- waitUntil("editor should gain focus") {
531
- editorRef.get().richTextView.editorEditText.hasFocus()
532
- }
533
- waitUntil("outside target should be laid out") {
534
- outsideTargetRef.get().isShown && outsideTargetRef.get().height > 0
535
- }
536
-
537
- scenario.onActivity {
538
- initialScrollY.set(scrollViewRef.get().scrollY)
539
- recordScrollGeometry(
540
- "sticky before swipe",
541
- scrollViewRef.get(),
542
- editorRef.get(),
543
- outsideTargetRef.get(),
544
- outsideTapTrace
545
- )
546
- }
547
- swipeUpFromView(
548
- outsideTargetRef.get(),
549
- startOffsetDp = 260,
550
- distanceDp = 220,
551
- label = "chat list scroll",
552
- trace = outsideTapTrace
553
- )
554
- instrumentation.waitForIdleSync()
555
-
556
- waitUntil(
557
- "chat list should scroll behind sticky composer",
558
- detail = { outsideTapTrace.joinToString(separator = "\n") }
559
- ) {
560
- scrollViewRef.get().scrollY > initialScrollY.get()
561
- }
562
-
563
- SystemClock.sleep(700)
564
- instrumentation.waitForIdleSync()
565
- scenario.onActivity {
566
- recordScrollGeometry(
567
- "sticky after swipe",
568
- scrollViewRef.get(),
569
- editorRef.get(),
570
- outsideTargetRef.get(),
571
- outsideTapTrace
572
- )
573
- }
574
-
575
- assertFalse(
576
- outsideTapTrace.joinToString(separator = "\n"),
577
- outsideTapTrace.any { it.contains("complete blur focusedAfter=false") }
578
- )
579
- assertTrue(
580
- "sticky editor should keep focus after chat list scroll\n" +
581
- outsideTapTrace.joinToString(separator = "\n"),
582
- editorRef.get().richTextView.editorEditText.hasFocus()
583
- )
584
- } finally {
585
- scenario.onActivity {
586
- editorRef.get()?.uninstallOutsideTapBlurHandlerForTesting()
587
- }
588
- editorDestroy(editorId.toULong())
589
- }
590
- }
591
- }
592
-
593
- private fun tapCenterOnScreen(
594
- view: View,
595
- label: String? = null,
596
- trace: MutableList<String>? = null
597
- ) {
598
- val location = IntArray(2)
599
- view.getLocationOnScreen(location)
600
- val x = location[0] + view.width / 2f
601
- val y = location[1] + view.height / 2f
602
- if (label != null && trace != null) {
603
- trace.add(
604
- "$label tap center=${x.toInt()},${y.toInt()} " +
605
- "loc=${location[0]},${location[1]} size=${view.width}x${view.height} shown=${view.isShown}"
606
- )
607
- }
608
- val downTime = SystemClock.uptimeMillis()
609
- val down = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, x, y, 0)
610
- val up = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, x, y, 0)
611
- instrumentation.sendPointerSync(down)
612
- instrumentation.sendPointerSync(up)
613
- down.recycle()
614
- up.recycle()
615
- }
616
-
617
- private fun swipeUpFromView(
618
- view: View,
619
- startOffsetDp: Int,
620
- distanceDp: Int,
621
- label: String,
622
- trace: MutableList<String>
623
- ) {
624
- val location = IntArray(2)
625
- view.getLocationOnScreen(location)
626
- val safeHeight = view.height.coerceAtLeast(2)
627
- val startOffsetPx = dp(view.context, startOffsetDp).coerceIn(1, safeHeight - 1)
628
- val distancePx = dp(view.context, distanceDp)
629
- val x = location[0] + view.width / 2f
630
- val startY = location[1] + startOffsetPx.toFloat()
631
- val endY = startY - distancePx
632
- trace.add(
633
- "$label swipe start=${x.toInt()},${startY.toInt()} end=${x.toInt()},${endY.toInt()} " +
634
- "loc=${location[0]},${location[1]} size=${view.width}x${view.height} shown=${view.isShown}"
635
- )
636
-
637
- val downTime = SystemClock.uptimeMillis()
638
- var eventTime = downTime
639
- val down = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_DOWN, x, startY, 0)
640
- instrumentation.sendPointerSync(down)
641
- down.recycle()
642
-
643
- val steps = 6
644
- for (step in 1..steps) {
645
- SystemClock.sleep(16)
646
- eventTime = SystemClock.uptimeMillis()
647
- val fraction = step.toFloat() / steps
648
- val y = startY + (endY - startY) * fraction
649
- val move = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_MOVE, x, y, 0)
650
- instrumentation.sendPointerSync(move)
651
- move.recycle()
652
- }
653
-
654
- SystemClock.sleep(16)
655
- eventTime = SystemClock.uptimeMillis()
656
- val up = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_UP, x, endY, 0)
657
- instrumentation.sendPointerSync(up)
658
- up.recycle()
659
- }
660
-
661
- private fun waitUntil(
662
- description: String,
663
- timeoutMs: Long = 3_000L,
664
- detail: () -> String = { "" },
665
- predicate: () -> Boolean
666
- ) {
667
- val deadline = SystemClock.uptimeMillis() + timeoutMs
668
- while (SystemClock.uptimeMillis() < deadline) {
669
- instrumentation.waitForIdleSync()
670
- if (predicate()) return
671
- SystemClock.sleep(50)
672
- }
673
- val detailText = detail()
674
- assertTrue(
675
- if (detailText.isBlank()) description else "$description\n$detailText",
676
- predicate()
677
- )
678
- }
679
-
680
- private fun applyToolbarFrameFromView(
681
- activity: Activity,
682
- editor: NativeEditorExpoView,
683
- view: View
684
- ) {
685
- val location = IntArray(2)
686
- view.getLocationOnScreen(location)
687
- val visibleWindowFrame = android.graphics.Rect()
688
- view.getWindowVisibleDisplayFrame(visibleWindowFrame)
689
- val density = activity.resources.displayMetrics.density
690
- editor.setToolbarFrameJson(
691
- JSONObject()
692
- .put("x", (location[0] - visibleWindowFrame.left) / density)
693
- .put("y", (location[1] - visibleWindowFrame.top) / density)
694
- .put("width", view.width / density)
695
- .put("height", view.height / density)
696
- .toString()
697
- )
698
- }
699
-
700
- private fun recordTapGeometry(
701
- activity: Activity,
702
- label: String,
703
- view: View,
704
- trace: MutableList<String>
705
- ) {
706
- val location = IntArray(2)
707
- view.getLocationOnScreen(location)
708
- val content = activity.findViewById<FrameLayout>(android.R.id.content)
709
- val lastChild = content.getChildAt(content.childCount - 1)
710
- trace.add(
711
- "$label geometry loc=${location[0]},${location[1]} size=${view.width}x${view.height} " +
712
- "shown=${view.isShown} contentChildren=${content.childCount} " +
713
- "lastChild=${lastChild?.javaClass?.name ?: "null"} lastShown=${lastChild?.isShown}"
714
- )
715
- }
716
-
717
- private fun recordScrollGeometry(
718
- label: String,
719
- scrollView: ScrollView,
720
- editor: NativeEditorExpoView,
721
- outsideTarget: View,
722
- trace: MutableList<String>
723
- ) {
724
- val editorLocation = IntArray(2)
725
- val targetLocation = IntArray(2)
726
- editor.richTextView.editorEditText.getLocationOnScreen(editorLocation)
727
- outsideTarget.getLocationOnScreen(targetLocation)
728
- trace.add(
729
- "$label scrollY=${scrollView.scrollY} " +
730
- "editorTop=${editorLocation[1]} targetTop=${targetLocation[1]} " +
731
- "focused=${editor.richTextView.editorEditText.hasFocus()}"
732
- )
733
- }
734
-
735
- private fun renderUpdateJson(text: String): String =
736
- JSONObject()
737
- .put(
738
- "renderBlocks",
739
- JSONArray().put(
740
- JSONArray()
741
- .put(
742
- JSONObject()
743
- .put("type", "blockStart")
744
- .put("nodeType", "paragraph")
745
- .put("depth", 0)
746
- )
747
- .put(
748
- JSONObject()
749
- .put("type", "textRun")
750
- .put("text", text)
751
- .put("marks", JSONArray())
752
- )
753
- .put(JSONObject().put("type", "blockEnd"))
754
- )
755
- )
756
- .toString()
757
-
758
- private fun dp(context: Context, value: Int): Int =
759
- (value * context.resources.displayMetrics.density).toInt()
760
-
761
- private data class TestExpoContext(
762
- val context: Context,
763
- val appContext: AppContext
764
- )
765
-
766
- private fun testExpoContext(activity: Activity): TestExpoContext {
767
- val reactContext = Class
768
- .forName("com.facebook.react.bridge.BridgeReactContext")
769
- .getConstructor(Context::class.java)
770
- .newInstance(activity) as Context
771
-
772
- reactContext.javaClass
773
- .getMethod("onHostResume", Activity::class.java)
774
- .invoke(reactContext, activity)
775
-
776
- val modulesProvider = object : ModulesProvider {
777
- override fun getModulesList(): List<Class<out Module>> = emptyList()
778
- }
779
- val constructor = AppContext::class.java.constructors.first { constructor ->
780
- constructor.parameterTypes.size == 3
781
- }
782
- val appContext = constructor.newInstance(
783
- modulesProvider,
784
- ModuleRegistry(emptyList(), emptyList()),
785
- WeakReference(reactContext)
786
- ) as AppContext
787
- return TestExpoContext(reactContext, appContext)
788
- }
789
- }