@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,350 +0,0 @@
1
- package com.openeditor.editor
2
-
3
- import android.app.Instrumentation
4
- import android.content.Context
5
- import android.graphics.Bitmap
6
- import android.graphics.Canvas
7
- import android.graphics.Color
8
- import android.os.Bundle
9
- import android.view.View
10
- import android.view.ViewGroup
11
- import androidx.test.core.app.ApplicationProvider
12
- import androidx.test.ext.junit.runners.AndroidJUnit4
13
- import androidx.test.filters.LargeTest
14
- import androidx.test.platform.app.InstrumentationRegistry
15
- import java.util.concurrent.atomic.AtomicReference
16
- import org.junit.Assert.assertEquals
17
- import org.junit.Assert.assertFalse
18
- import org.junit.Assert.assertNotNull
19
- import org.junit.Assert.assertTrue
20
- import org.junit.Test
21
- import org.junit.runner.RunWith
22
- import uniffi.editor_core.*
23
-
24
- @RunWith(AndroidJUnit4::class)
25
- @LargeTest
26
- class NativeDevicePerformanceTest {
27
- private val instrumentation = InstrumentationRegistry.getInstrumentation()
28
- private val context = ApplicationProvider.getApplicationContext<Context>()
29
- private val baseFontSize = 16f
30
- private val textColor = Color.BLACK
31
-
32
- @Test
33
- fun performance_renderBridgeLargeDocument() {
34
- val renderJson = NativePerformanceFixtureFactory.largeRenderJson()
35
-
36
- val stats = measureOperation("renderBridgeLargeDocument") {
37
- val spannable = runOnMainSyncWithResult {
38
- RenderBridge.buildSpannable(
39
- renderJson,
40
- baseFontSize,
41
- textColor,
42
- density = context.resources.displayMetrics.density
43
- )
44
- }
45
-
46
- assertTrue("rendered spannable should not be empty", spannable.isNotEmpty())
47
- if (spannable.isNotEmpty()) {
48
- runOnMainSyncWithResult {
49
- spannable.getSpans(0, minOf(spannable.length, 1), Any::class.java)
50
- }
51
- }
52
- }
53
-
54
- reportStats(stats)
55
- assertTrue("average render time should be positive", stats.averageMillis > 0.0)
56
- }
57
-
58
- @Test
59
- fun performance_applyUpdateJsonLargeDocument() {
60
- val updateJson = NativePerformanceFixtureFactory.largeUpdateJson()
61
- val editText = runOnMainSyncWithResult {
62
- EditorEditText(context).apply {
63
- layoutParams = ViewGroup.LayoutParams(
64
- ViewGroup.LayoutParams.MATCH_PARENT,
65
- ViewGroup.LayoutParams.WRAP_CONTENT
66
- )
67
- setBaseStyle(baseFontSize, textColor, Color.WHITE)
68
- applyUpdateJSON(updateJson, notifyListener = false)
69
- layoutView(this, widthPx = 1080, heightPx = 2400, heightMode = View.MeasureSpec.AT_MOST)
70
- }
71
- }
72
-
73
- val stats = measureOperation("applyUpdateJsonLargeDocument") {
74
- runOnMainSyncWithResult {
75
- editText.applyUpdateJSON(updateJson, notifyListener = false)
76
- layoutView(editText, widthPx = 1080, heightPx = 2400, heightMode = View.MeasureSpec.AT_MOST)
77
- }
78
-
79
- assertFalse("edit text should contain rendered content", editText.text.isNullOrEmpty())
80
- assertNotNull("edit text should have a layout after applying update", editText.layout)
81
- }
82
-
83
- reportStats(stats)
84
- assertTrue("average applyUpdateJSON time should be positive", stats.averageMillis > 0.0)
85
- }
86
-
87
- @Test
88
- fun performance_applyRenderPatchLargeDocument() {
89
- val initialUpdateJson = NativePerformanceFixtureFactory.largeUpdateJson()
90
- val patchedUpdateJson = NativePerformanceFixtureFactory.largePatchedUpdateJson()
91
- val editText = runOnMainSyncWithResult {
92
- EditorEditText(context).apply {
93
- layoutParams = ViewGroup.LayoutParams(
94
- ViewGroup.LayoutParams.MATCH_PARENT,
95
- ViewGroup.LayoutParams.WRAP_CONTENT
96
- )
97
- setBaseStyle(baseFontSize, textColor, Color.WHITE)
98
- }
99
- }
100
-
101
- val stats = measureOperation(
102
- name = "applyRenderPatchLargeDocument",
103
- beforeEach = {
104
- runOnMainSyncWithResult {
105
- editText.applyUpdateJSON(initialUpdateJson, notifyListener = false)
106
- layoutView(editText, widthPx = 1080, heightPx = 2400, heightMode = View.MeasureSpec.AT_MOST)
107
- }
108
- }
109
- ) {
110
- runOnMainSyncWithResult {
111
- editText.applyUpdateJSON(patchedUpdateJson, notifyListener = false)
112
- layoutView(editText, widthPx = 1080, heightPx = 2400, heightMode = View.MeasureSpec.AT_MOST)
113
- }
114
-
115
- assertFalse("edit text should contain rendered content", editText.text.isNullOrEmpty())
116
- }
117
-
118
- reportStats(stats)
119
- assertTrue("average patch apply time should be positive", stats.averageMillis > 0.0)
120
- }
121
-
122
- @Test
123
- fun performance_typingRoundTripLargeDocument() {
124
- val editorId = editorCreate("{}").toLong()
125
- try {
126
- NativePerformanceFixtureFactory.loadLargeDocumentIntoEditor(editorId.toULong())
127
- val editText = runOnMainSyncWithResult {
128
- EditorEditText(context).apply {
129
- layoutParams = ViewGroup.LayoutParams(
130
- ViewGroup.LayoutParams.MATCH_PARENT,
131
- ViewGroup.LayoutParams.WRAP_CONTENT
132
- )
133
- setBaseStyle(baseFontSize, textColor, Color.WHITE)
134
- bindEditor(editorId)
135
- layoutView(this, widthPx = 1080, heightPx = 2400, heightMode = View.MeasureSpec.AT_MOST)
136
- }
137
- }
138
- val typingOffset = NativePerformanceFixtureFactory.typingCursorOffset(editText.text ?: "")
139
-
140
- val stats = measureOperation("typingRoundTripLargeDocument") {
141
- runOnMainSyncWithResult {
142
- editText.setSelection(typingOffset)
143
- editText.handleTextCommit("!")
144
- editText.handleDelete(1, 0)
145
- layoutView(editText, widthPx = 1080, heightPx = 2400, heightMode = View.MeasureSpec.AT_MOST)
146
- }
147
-
148
- assertFalse("edit text should contain rendered content", editText.text.isNullOrEmpty())
149
- assertNotNull("edit text should have a layout after typing", editText.layout)
150
- }
151
-
152
- reportStats(stats)
153
- assertTrue("average typing time should be positive", stats.averageMillis > 0.0)
154
- } finally {
155
- editorDestroy(editorId.toULong())
156
- }
157
- }
158
-
159
- @Test
160
- fun performance_paragraphSplitRoundTripLargeDocument() {
161
- val editorId = editorCreate("{}").toLong()
162
- try {
163
- val largeDocumentJson = NativePerformanceFixtureFactory.largeDocumentJson()
164
- NativePerformanceFixtureFactory.loadLargeDocumentIntoEditor(editorId.toULong())
165
- val editText = runOnMainSyncWithResult {
166
- EditorEditText(context).apply {
167
- layoutParams = ViewGroup.LayoutParams(
168
- ViewGroup.LayoutParams.MATCH_PARENT,
169
- ViewGroup.LayoutParams.WRAP_CONTENT
170
- )
171
- setBaseStyle(baseFontSize, textColor, Color.WHITE)
172
- bindEditor(editorId)
173
- layoutView(this, widthPx = 1080, heightPx = 2400, heightMode = View.MeasureSpec.AT_MOST)
174
- }
175
- }
176
- val splitOffset = NativePerformanceFixtureFactory.typingCursorOffset(editText.text ?: "")
177
-
178
- val stats = measureOperation(
179
- name = "paragraphSplitRoundTripLargeDocument",
180
- beforeEach = {
181
- runOnMainSyncWithResult {
182
- editorSetJson(editorId.toULong(), largeDocumentJson)
183
- editText.applyUpdateJSON(editorGetCurrentState(editorId.toULong()), notifyListener = false)
184
- editText.setSelection(splitOffset)
185
- layoutView(editText, widthPx = 1080, heightPx = 2400, heightMode = View.MeasureSpec.AT_MOST)
186
- }
187
- }
188
- ) {
189
- runOnMainSyncWithResult {
190
- editText.handleTextCommit("\n")
191
- layoutView(editText, widthPx = 1080, heightPx = 2400, heightMode = View.MeasureSpec.AT_MOST)
192
- }
193
-
194
- assertNotNull("edit text should have a layout after paragraph split", editText.layout)
195
- }
196
-
197
- reportStats(stats)
198
- assertTrue("average paragraph split time should be positive", stats.averageMillis > 0.0)
199
- } finally {
200
- editorDestroy(editorId.toULong())
201
- }
202
- }
203
-
204
- @Test
205
- fun performance_selectionScrubLargeDocument() {
206
- val editorId = editorCreate("{}").toLong()
207
- try {
208
- NativePerformanceFixtureFactory.loadLargeDocumentIntoEditor(editorId.toULong())
209
- val editText = runOnMainSyncWithResult {
210
- EditorEditText(context).apply {
211
- layoutParams = ViewGroup.LayoutParams(
212
- ViewGroup.LayoutParams.MATCH_PARENT,
213
- ViewGroup.LayoutParams.WRAP_CONTENT
214
- )
215
- setBaseStyle(baseFontSize, textColor, Color.WHITE)
216
- bindEditor(editorId)
217
- layoutView(this, widthPx = 1080, heightPx = 2400, heightMode = View.MeasureSpec.AT_MOST)
218
- }
219
- }
220
- val scrubOffsets = NativePerformanceFixtureFactory.selectionScrubOffsets(
221
- editText.text ?: "",
222
- points = 48
223
- )
224
-
225
- val stats = measureOperation("selectionScrubLargeDocument") {
226
- runOnMainSyncWithResult {
227
- for (offset in scrubOffsets) {
228
- editText.setSelection(offset)
229
- }
230
- }
231
-
232
- assertEquals(
233
- "selection should land on the final scrub offset",
234
- scrubOffsets.last(),
235
- editText.selectionStart
236
- )
237
- }
238
-
239
- reportStats(stats)
240
- assertTrue("average selection scrub time should be positive", stats.averageMillis > 0.0)
241
- } finally {
242
- editorDestroy(editorId.toULong())
243
- }
244
- }
245
-
246
- @Test
247
- fun performance_remoteSelectionOverlayRefreshMultiPeerLargeDocument() {
248
- val updateJson = NativePerformanceFixtureFactory.largeUpdateJson()
249
- val richTextView = runOnMainSyncWithResult {
250
- RichTextEditorView(context).apply {
251
- configure(textSizePx = baseFontSize, textColor = textColor, backgroundColor = Color.WHITE)
252
- setRemoteSelectionEditorIdForTesting(1L)
253
- setRemoteSelectionScalarResolverForTesting { _, docPos -> docPos }
254
- editorEditText.applyUpdateJSON(updateJson, notifyListener = false)
255
- layoutView(this, widthPx = 1080, heightPx = 1600, heightMode = View.MeasureSpec.EXACTLY)
256
- }
257
- }
258
-
259
- val totalScalar = richTextView.editorEditText.text?.length ?: 0
260
- val selections = NativePerformanceFixtureFactory.remoteSelections(
261
- totalScalar,
262
- peerCount = 24,
263
- selectionWidth = 24
264
- )
265
- runOnMainSyncWithResult {
266
- richTextView.setRemoteSelections(selections)
267
- layoutView(richTextView, widthPx = 1080, heightPx = 1600, heightMode = View.MeasureSpec.EXACTLY)
268
- }
269
-
270
- val bitmap = Bitmap.createBitmap(1080, 1600, Bitmap.Config.ARGB_8888)
271
- val canvas = Canvas(bitmap)
272
-
273
- val stats = measureOperation("remoteSelectionOverlayRefreshMultiPeerLargeDocument") {
274
- val hasVisibleCaret = runOnMainSyncWithResult {
275
- bitmap.eraseColor(Color.TRANSPARENT)
276
- richTextView.setRemoteSelections(selections)
277
- layoutView(richTextView, widthPx = 1080, heightPx = 1600, heightMode = View.MeasureSpec.EXACTLY)
278
- richTextView.draw(canvas)
279
- richTextView.remoteSelectionDebugSnapshotsForTesting().any { it.caretRect != null }
280
- }
281
-
282
- assertTrue("remote selection overlay should resolve visible carets", hasVisibleCaret)
283
- assertEquals("expected one snapshot per peer", selections.size, richTextView.remoteSelectionDebugSnapshotsForTesting().size)
284
- }
285
-
286
- reportStats(stats)
287
- assertTrue("average remote selection refresh time should be positive", stats.averageMillis > 0.0)
288
- }
289
-
290
- private fun layoutView(
291
- view: View,
292
- widthPx: Int,
293
- heightPx: Int,
294
- heightMode: Int
295
- ) {
296
- val widthSpec = View.MeasureSpec.makeMeasureSpec(widthPx, View.MeasureSpec.EXACTLY)
297
- val heightSpec = View.MeasureSpec.makeMeasureSpec(heightPx, heightMode)
298
- view.measure(widthSpec, heightSpec)
299
- view.layout(0, 0, view.measuredWidth, view.measuredHeight)
300
- }
301
-
302
- private fun measureOperation(
303
- name: String,
304
- warmupIterations: Int = 3,
305
- measuredIterations: Int = 5,
306
- beforeEach: (() -> Unit)? = null,
307
- block: () -> Unit
308
- ): TimingStats {
309
- repeat(warmupIterations) {
310
- beforeEach?.invoke()
311
- block()
312
- }
313
-
314
- val samples = MutableList(measuredIterations) {
315
- beforeEach?.invoke()
316
- val startedAt = System.nanoTime()
317
- block()
318
- System.nanoTime() - startedAt
319
- }
320
-
321
- return TimingStats(name, samples)
322
- }
323
-
324
- private fun reportStats(stats: TimingStats) {
325
- instrumentation.sendStatus(
326
- 0,
327
- Bundle().apply {
328
- putString(
329
- Instrumentation.REPORT_KEY_STREAMRESULT,
330
- stats.summaryString(tag = "NativeDevicePerformanceTest") + "\n"
331
- )
332
- }
333
- )
334
- }
335
-
336
- @Suppress("UNCHECKED_CAST")
337
- private fun <T> runOnMainSyncWithResult(block: () -> T): T {
338
- val result = AtomicReference<Any?>()
339
- val error = AtomicReference<Throwable?>()
340
- instrumentation.runOnMainSync {
341
- try {
342
- result.set(block())
343
- } catch (throwable: Throwable) {
344
- error.set(throwable)
345
- }
346
- }
347
- error.get()?.let { throw it }
348
- return result.get() as T
349
- }
350
- }
@@ -1,5 +0,0 @@
1
- package com.openeditor.editor
2
-
3
- import android.app.Activity
4
-
5
- class NativeEditorOutsideTapActivity : Activity()
@@ -1,315 +0,0 @@
1
- package com.openeditor.editor
2
-
3
- import android.content.Intent
4
- import android.content.Context
5
- import android.graphics.Color
6
- import android.net.Uri
7
- import android.view.MotionEvent
8
- import android.view.View
9
- import android.view.ViewGroup
10
- import expo.modules.kotlin.AppContext
11
- import expo.modules.kotlin.viewevent.EventDispatcher
12
- import expo.modules.kotlin.views.ExpoView
13
- import kotlin.math.abs
14
- import org.json.JSONArray
15
-
16
- class NativeProseViewerExpoView(
17
- context: Context,
18
- appContext: AppContext
19
- ) : ExpoView(context, appContext) {
20
-
21
- private val proseView = EditorEditText(context)
22
- private val onContentHeightChange by EventDispatcher<Map<String, Any>>()
23
- @Suppress("unused")
24
- private val onPressLink by EventDispatcher<Map<String, Any>>()
25
- @Suppress("unused")
26
- private val onPressMention by EventDispatcher<Map<String, Any>>()
27
-
28
- private var lastRenderJson: String? = null
29
- private var lastThemeJson: String? = null
30
- private var lastEmittedContentHeight = 0
31
- private var collapsesWhenEmpty = true
32
- private var isCollapsedEmptyContent = false
33
- private var enableLinkTaps = true
34
- private var interceptLinkTaps = false
35
- internal var suppressContentHeightEventsForTesting = false
36
-
37
- init {
38
- proseView.setBaseStyle(
39
- proseView.textSize,
40
- proseView.currentTextColor,
41
- Color.TRANSPARENT
42
- )
43
- proseView.isEditable = false
44
- proseView.inputType = android.text.InputType.TYPE_CLASS_TEXT or
45
- android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE or
46
- android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
47
- proseView.setImageResizingEnabled(false)
48
- proseView.setHeightBehavior(EditorHeightBehavior.AUTO_GROW)
49
- proseView.isFocusable = false
50
- proseView.isFocusableInTouchMode = false
51
- proseView.isCursorVisible = false
52
- proseView.isLongClickable = false
53
- proseView.setTextIsSelectable(false)
54
- proseView.showSoftInputOnFocus = false
55
- proseView.setOnTouchListener { _, event ->
56
- if (event.actionMasked != MotionEvent.ACTION_UP) {
57
- return@setOnTouchListener false
58
- }
59
-
60
- proseView.mentionHitAt(event.x, event.y)?.let { mention ->
61
- onPressMention(mapOf("docPos" to mention.docPos, "label" to mention.label))
62
- return@setOnTouchListener true
63
- }
64
-
65
- if (!enableLinkTaps) {
66
- return@setOnTouchListener false
67
- }
68
-
69
- val link = proseView.linkHitAt(event.x, event.y) ?: return@setOnTouchListener false
70
- if (interceptLinkTaps) {
71
- onPressLink(
72
- mapOf(
73
- "href" to link.href,
74
- "text" to link.text
75
- )
76
- )
77
- return@setOnTouchListener true
78
- }
79
-
80
- return@setOnTouchListener openLink(link.href)
81
- }
82
-
83
- addView(
84
- proseView,
85
- LayoutParams(
86
- ViewGroup.LayoutParams.MATCH_PARENT,
87
- ViewGroup.LayoutParams.WRAP_CONTENT
88
- )
89
- )
90
- }
91
-
92
- fun setRenderJson(renderJson: String?) {
93
- if (lastRenderJson == renderJson) return
94
- lastRenderJson = renderJson
95
- applyRenderJson()
96
- requestLayout()
97
- }
98
-
99
- fun setThemeJson(themeJson: String?) {
100
- if (lastThemeJson == themeJson) return
101
- lastThemeJson = themeJson
102
- proseView.applyTheme(EditorTheme.fromJson(themeJson))
103
- applyRenderJson()
104
- requestLayout()
105
- }
106
-
107
- fun setCollapsesWhenEmpty(collapsesWhenEmpty: Boolean?) {
108
- val nextValue = collapsesWhenEmpty ?: true
109
- if (this.collapsesWhenEmpty == nextValue) return
110
- this.collapsesWhenEmpty = nextValue
111
- updateCollapsedEmptyState()
112
- requestLayout()
113
- emitContentHeightIfNeeded(force = true)
114
- }
115
-
116
- fun setEnableLinkTaps(enableLinkTaps: Boolean?) {
117
- this.enableLinkTaps = enableLinkTaps ?: true
118
- }
119
-
120
- fun setInterceptLinkTaps(interceptLinkTaps: Boolean?) {
121
- this.interceptLinkTaps = interceptLinkTaps ?: false
122
- }
123
-
124
- override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
125
- if (isCollapsedEmptyContent) {
126
- setMeasuredDimension(resolveSize(0, widthMeasureSpec), 0)
127
- emitContentHeightIfNeeded()
128
- return
129
- }
130
-
131
- val childWidthSpec = getChildMeasureSpec(
132
- widthMeasureSpec,
133
- paddingLeft + paddingRight,
134
- proseView.layoutParams.width
135
- )
136
- val childHeightSpec = android.view.View.MeasureSpec.makeMeasureSpec(
137
- 0,
138
- android.view.View.MeasureSpec.UNSPECIFIED
139
- )
140
- proseView.measure(childWidthSpec, childHeightSpec)
141
-
142
- val resolvedContentHeight = proseView.resolveAutoGrowHeight()
143
- val desiredWidth = proseView.measuredWidth + paddingLeft + paddingRight
144
- val desiredHeight = resolvedContentHeight + paddingTop + paddingBottom
145
- val measuredHeight = when (View.MeasureSpec.getMode(heightMeasureSpec)) {
146
- View.MeasureSpec.AT_MOST -> desiredHeight.coerceAtMost(
147
- View.MeasureSpec.getSize(heightMeasureSpec)
148
- )
149
- else -> desiredHeight
150
- }
151
- setMeasuredDimension(
152
- resolveSize(desiredWidth, widthMeasureSpec),
153
- measuredHeight
154
- )
155
- emitContentHeightIfNeeded(measuredContentHeight = desiredHeight)
156
- }
157
-
158
- override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
159
- if (isCollapsedEmptyContent) {
160
- proseView.layout(paddingLeft, paddingTop, right - left - paddingRight, paddingTop)
161
- emitContentHeightIfNeeded()
162
- return
163
- }
164
-
165
- val childLeft = paddingLeft
166
- val childTop = paddingTop
167
- proseView.layout(
168
- childLeft,
169
- childTop,
170
- right - left - paddingRight,
171
- childTop + proseView.measuredHeight
172
- )
173
- emitContentHeightIfNeeded()
174
- }
175
-
176
- private fun applyRenderJson() {
177
- updateCollapsedEmptyState()
178
- proseView.applyRenderJSON(lastRenderJson ?: "[]")
179
- proseView.visibility = if (isCollapsedEmptyContent) View.GONE else View.VISIBLE
180
- }
181
-
182
- private fun updateCollapsedEmptyState() {
183
- isCollapsedEmptyContent = collapsesWhenEmpty &&
184
- renderJsonContainsOnlyEmptyParagraphs(lastRenderJson ?: "[]")
185
- proseView.visibility = if (isCollapsedEmptyContent) View.GONE else View.VISIBLE
186
- }
187
-
188
- private fun emitContentHeightIfNeeded(
189
- force: Boolean = false,
190
- measuredContentHeight: Int? = null
191
- ) {
192
- val contentHeight = if (isCollapsedEmptyContent) {
193
- 0
194
- } else {
195
- (
196
- measuredContentHeight ?: (measureContentHeightPx() + paddingTop + paddingBottom)
197
- ).coerceAtLeast(0)
198
- }
199
- if (contentHeight <= 0 && !isCollapsedEmptyContent) {
200
- return
201
- }
202
- if (!force && contentHeight == lastEmittedContentHeight) {
203
- return
204
- }
205
- lastEmittedContentHeight = contentHeight
206
- if (suppressContentHeightEventsForTesting) {
207
- return
208
- }
209
- onContentHeightChange(mapOf("contentHeight" to contentHeight))
210
- }
211
-
212
- private fun measureContentHeightPx(): Int {
213
- if (isCollapsedEmptyContent) {
214
- return 0
215
- }
216
-
217
- val availableWidthPx = resolveAvailableWidthPx()
218
- if (
219
- proseView.measuredWidth <= 0 ||
220
- abs(proseView.measuredWidth - availableWidthPx) > 1
221
- ) {
222
- val childWidthSpec = View.MeasureSpec.makeMeasureSpec(
223
- availableWidthPx,
224
- View.MeasureSpec.EXACTLY
225
- )
226
- val childHeightSpec = View.MeasureSpec.makeMeasureSpec(
227
- 0,
228
- View.MeasureSpec.UNSPECIFIED
229
- )
230
- proseView.measure(childWidthSpec, childHeightSpec)
231
- }
232
- return proseView.resolveAutoGrowHeight()
233
- }
234
-
235
- private fun resolveAvailableWidthPx(): Int {
236
- val localWidth = width - paddingLeft - paddingRight
237
- if (localWidth > 0) {
238
- return localWidth
239
- }
240
-
241
- val parentWidth = ((parent as? View)?.width ?: 0) - paddingLeft - paddingRight
242
- if (parentWidth > 0) {
243
- return parentWidth
244
- }
245
-
246
- return (resources.displayMetrics.widthPixels - paddingLeft - paddingRight).coerceAtLeast(1)
247
- }
248
-
249
- private fun openLink(href: String): Boolean {
250
- val intent = Intent(Intent.ACTION_VIEW, Uri.parse(href)).apply {
251
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
252
- }
253
- return runCatching {
254
- context.startActivity(intent)
255
- true
256
- }.getOrDefault(false)
257
- }
258
-
259
- companion object {
260
- private const val EMPTY_TEXT_BLOCK_PLACEHOLDER = '\u200B'
261
-
262
- internal fun renderJsonContainsOnlyEmptyParagraphs(renderJson: String): Boolean {
263
- val elements = try {
264
- JSONArray(renderJson)
265
- } catch (_: Exception) {
266
- return false
267
- }
268
-
269
- if (elements.length() == 0) {
270
- return true
271
- }
272
-
273
- var hasParagraph = false
274
- var paragraphIsOpen = false
275
-
276
- for (index in 0 until elements.length()) {
277
- val element = elements.optJSONObject(index) ?: return false
278
- when (element.optString("type", "")) {
279
- "blockStart" -> {
280
- if (
281
- paragraphIsOpen ||
282
- element.optString("nodeType", "") != "paragraph" ||
283
- element.optInt("depth", 0) != 0
284
- ) {
285
- return false
286
- }
287
- paragraphIsOpen = true
288
- hasParagraph = true
289
- }
290
-
291
- "textRun" -> {
292
- val text = element.optString("text", "")
293
- if (
294
- !paragraphIsOpen ||
295
- !text.all { it == EMPTY_TEXT_BLOCK_PLACEHOLDER }
296
- ) {
297
- return false
298
- }
299
- }
300
-
301
- "blockEnd" -> {
302
- if (!paragraphIsOpen) {
303
- return false
304
- }
305
- paragraphIsOpen = false
306
- }
307
-
308
- else -> return false
309
- }
310
- }
311
-
312
- return hasParagraph && !paragraphIsOpen
313
- }
314
- }
315
- }