@apollohg/react-native-prose-editor 0.4.0 → 0.4.1

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 (30) hide show
  1. package/README.md +18 -0
  2. package/android/build.gradle +23 -0
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +502 -39
  4. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +56 -28
  5. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +6 -0
  6. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +57 -27
  7. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +147 -78
  8. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +249 -71
  9. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +7 -6
  10. package/dist/NativeEditorBridge.d.ts +36 -1
  11. package/dist/NativeEditorBridge.js +173 -94
  12. package/dist/NativeRichTextEditor.d.ts +2 -0
  13. package/dist/NativeRichTextEditor.js +160 -53
  14. package/dist/YjsCollaboration.d.ts +2 -0
  15. package/dist/YjsCollaboration.js +142 -20
  16. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  17. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  18. package/ios/EditorLayoutManager.swift +3 -3
  19. package/ios/Generated_editor_core.swift +41 -0
  20. package/ios/NativeEditorExpoView.swift +43 -11
  21. package/ios/NativeEditorModule.swift +6 -0
  22. package/ios/PositionBridge.swift +310 -75
  23. package/ios/RenderBridge.swift +362 -27
  24. package/ios/RichTextEditorView.swift +1983 -187
  25. package/ios/editor_coreFFI/editor_coreFFI.h +33 -0
  26. package/package.json +11 -2
  27. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  28. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  29. package/rust/android/x86_64/libeditor_core.so +0 -0
  30. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +63 -0
@@ -65,6 +65,11 @@ class NativeEditorExpoView(
65
65
  private var addons = NativeEditorAddons(null)
66
66
  private var mentionQueryState: MentionQueryState? = null
67
67
  private var lastMentionEventJson: String? = null
68
+ private var lastThemeJson: String? = null
69
+ private var lastAddonsJson: String? = null
70
+ private var lastRemoteSelectionsJson: String? = null
71
+ private var lastToolbarItemsJson: String? = null
72
+ private var lastToolbarFrameJson: String? = null
68
73
  private var toolbarState = NativeToolbarState.empty
69
74
  private var showsToolbar = true
70
75
  private var toolbarPlacement = ToolbarPlacement.KEYBOARD
@@ -110,6 +115,8 @@ class NativeEditorExpoView(
110
115
  }
111
116
 
112
117
  fun setThemeJson(themeJson: String?) {
118
+ if (lastThemeJson == themeJson) return
119
+ lastThemeJson = themeJson
113
120
  val theme = EditorTheme.fromJson(themeJson)
114
121
  richTextView.applyTheme(theme)
115
122
  keyboardToolbarView.applyTheme(theme?.toolbar)
@@ -141,12 +148,16 @@ class NativeEditorExpoView(
141
148
  }
142
149
 
143
150
  fun setAddonsJson(addonsJson: String?) {
151
+ if (lastAddonsJson == addonsJson) return
152
+ lastAddonsJson = addonsJson
144
153
  addons = NativeEditorAddons.fromJson(addonsJson)
145
154
  keyboardToolbarView.applyMentionTheme(richTextView.editorEditText.theme?.mentions ?: addons.mentions?.theme)
146
155
  refreshMentionQuery()
147
156
  }
148
157
 
149
158
  fun setRemoteSelectionsJson(remoteSelectionsJson: String?) {
159
+ if (lastRemoteSelectionsJson == remoteSelectionsJson) return
160
+ lastRemoteSelectionsJson = remoteSelectionsJson
150
161
  richTextView.setRemoteSelections(
151
162
  RemoteSelectionDecoration.fromJson(context, remoteSelectionsJson)
152
163
  )
@@ -175,10 +186,14 @@ class NativeEditorExpoView(
175
186
  }
176
187
 
177
188
  fun setToolbarItemsJson(toolbarItemsJson: String?) {
189
+ if (lastToolbarItemsJson == toolbarItemsJson) return
190
+ lastToolbarItemsJson = toolbarItemsJson
178
191
  keyboardToolbarView.setItems(NativeToolbarItem.fromJson(toolbarItemsJson))
179
192
  }
180
193
 
181
194
  fun setToolbarFrameJson(toolbarFrameJson: String?) {
195
+ if (lastToolbarFrameJson == toolbarFrameJson) return
196
+ lastToolbarFrameJson = toolbarFrameJson
182
197
  if (toolbarFrameJson.isNullOrBlank()) {
183
198
  toolbarFrameInWindow = null
184
199
  return
@@ -260,31 +275,44 @@ class NativeEditorExpoView(
260
275
  if (heightBehavior != EditorHeightBehavior.AUTO_GROW) return
261
276
  val editText = richTextView.editorEditText
262
277
  val resolvedEditHeight = editText.resolveAutoGrowHeight()
278
+ val resolvedContainerHeight =
279
+ resolvedEditHeight +
280
+ richTextView.paddingTop +
281
+ richTextView.paddingBottom +
282
+ paddingTop +
283
+ paddingBottom
263
284
  val contentHeight = (
264
285
  when {
265
286
  editText.isLaidOut && (editText.layout?.height ?: 0) > 0 -> {
266
- (editText.layout?.height ?: 0) +
267
- editText.compoundPaddingTop +
268
- editText.compoundPaddingBottom +
269
- richTextView.paddingTop +
270
- richTextView.paddingBottom
287
+ maxOf(
288
+ (editText.layout?.height ?: 0) +
289
+ editText.compoundPaddingTop +
290
+ editText.compoundPaddingBottom +
291
+ richTextView.paddingTop +
292
+ richTextView.paddingBottom +
293
+ paddingTop +
294
+ paddingBottom,
295
+ resolvedContainerHeight
296
+ )
271
297
  }
272
298
  richTextView.measuredHeight > 0 -> {
273
- richTextView.measuredHeight + paddingTop + paddingBottom
299
+ maxOf(
300
+ richTextView.measuredHeight + paddingTop + paddingBottom,
301
+ resolvedContainerHeight
302
+ )
274
303
  }
275
304
  editText.measuredHeight > 0 -> {
276
- editText.measuredHeight +
277
- richTextView.paddingTop +
278
- richTextView.paddingBottom +
279
- paddingTop +
280
- paddingBottom
305
+ maxOf(
306
+ editText.measuredHeight +
307
+ richTextView.paddingTop +
308
+ richTextView.paddingBottom +
309
+ paddingTop +
310
+ paddingBottom,
311
+ resolvedContainerHeight
312
+ )
281
313
  }
282
314
  else -> {
283
- resolvedEditHeight +
284
- richTextView.paddingTop +
285
- richTextView.paddingBottom +
286
- paddingTop +
287
- paddingBottom
315
+ resolvedContainerHeight
288
316
  }
289
317
  }
290
318
  ).coerceAtLeast(0)
@@ -304,20 +332,20 @@ class NativeEditorExpoView(
304
332
  if (android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) {
305
333
  apply.run()
306
334
  } else {
307
- val latch = java.util.concurrent.CountDownLatch(1)
308
- post {
309
- apply.run()
310
- latch.countDown()
335
+ if (!post(apply)) {
336
+ richTextView.post(apply)
311
337
  }
312
- latch.await()
313
338
  }
314
339
  }
315
340
 
316
341
  override fun onSelectionChanged(anchor: Int, head: Int) {
317
- refreshToolbarStateFromEditorSelection()
342
+ val stateJson = refreshToolbarStateFromEditorSelection()
318
343
  refreshMentionQuery()
319
344
  richTextView.refreshRemoteSelections()
320
- val event = mapOf<String, Any>("anchor" to anchor, "head" to head)
345
+ val event = mutableMapOf<String, Any>("anchor" to anchor, "head" to head)
346
+ if (stateJson != null) {
347
+ event["stateJson"] = stateJson
348
+ }
321
349
  onSelectionChange(event)
322
350
  }
323
351
 
@@ -541,13 +569,13 @@ class NativeEditorExpoView(
541
569
  clearMentionQueryState()
542
570
  }
543
571
 
544
- private fun refreshToolbarStateFromEditorSelection() {
545
- if (richTextView.editorId == 0L) return
546
- val state = NativeToolbarState.fromUpdateJson(
547
- editorGetCurrentState(richTextView.editorId.toULong())
548
- ) ?: return
572
+ private fun refreshToolbarStateFromEditorSelection(): String? {
573
+ if (richTextView.editorId == 0L) return null
574
+ val stateJson = editorGetSelectionState(richTextView.editorId.toULong())
575
+ val state = NativeToolbarState.fromUpdateJson(stateJson) ?: return null
549
576
  toolbarState = state
550
577
  keyboardToolbarView.applyState(state)
578
+ return stateJson
551
579
  }
552
580
 
553
581
  private fun ensureKeyboardToolbarAttached() {
@@ -63,6 +63,9 @@ class NativeEditorModule : Module() {
63
63
  Function("editorGetJson") { id: Int ->
64
64
  editorGetJson(id.toULong())
65
65
  }
66
+ Function("editorGetContentSnapshot") { id: Int ->
67
+ editorGetContentSnapshot(id.toULong())
68
+ }
66
69
 
67
70
  Function("editorInsertText") { id: Int, pos: Int, text: String ->
68
71
  editorInsertText(id.toULong(), pos.toUInt(), text)
@@ -258,6 +261,9 @@ class NativeEditorModule : Module() {
258
261
  Function("editorGetSelection") { id: Int ->
259
262
  editorGetSelection(id.toULong())
260
263
  }
264
+ Function("editorGetSelectionState") { id: Int ->
265
+ editorGetSelectionState(id.toULong())
266
+ }
261
267
  Function("editorDocToScalar") { id: Int, docPos: Int ->
262
268
  editorDocToScalar(id.toULong(), docPos.toUInt()).toInt()
263
269
  }
@@ -8,25 +8,22 @@ import android.icu.text.BreakIterator
8
8
  * cursor inside a composed character.
9
9
  */
10
10
  object PositionBridge {
11
+ private data class ConversionTable(
12
+ val utf16ToScalar: IntArray,
13
+ val scalarToUtf16: IntArray,
14
+ )
15
+
16
+ private val cacheLock = Any()
17
+ @Volatile private var cachedText: String? = null
18
+ @Volatile private var cachedTable: ConversionTable? = null
11
19
 
12
20
  /**
13
21
  * Counts code points from the start of the string up to the given UTF-16 offset.
14
22
  */
15
23
  fun utf16ToScalar(utf16Offset: Int, text: String): Int {
16
- if (utf16Offset <= 0) return 0
17
-
18
- val endIndex = minOf(utf16Offset, text.length)
19
- var scalarCount = 0
20
- var utf16Pos = 0
21
-
22
- while (utf16Pos < endIndex) {
23
- val codePoint = Character.codePointAt(text, utf16Pos)
24
- val charCount = Character.charCount(codePoint)
25
- utf16Pos += charCount
26
- scalarCount++
27
- }
28
-
29
- return scalarCount
24
+ val conversionTable = conversionTableFor(text)
25
+ val endIndex = utf16Offset.coerceIn(0, conversionTable.utf16ToScalar.size - 1)
26
+ return conversionTable.utf16ToScalar[endIndex]
30
27
  }
31
28
 
32
29
  /**
@@ -35,19 +32,9 @@ object PositionBridge {
35
32
  fun scalarToUtf16(scalarOffset: Int, text: String): Int {
36
33
  if (scalarOffset <= 0) return 0
37
34
 
38
- var utf16Len = 0
39
- var scalarsSeen = 0
40
-
41
- var i = 0
42
- while (i < text.length && scalarsSeen < scalarOffset) {
43
- val codePoint = Character.codePointAt(text, i)
44
- val charCount = Character.charCount(codePoint)
45
- utf16Len += charCount
46
- scalarsSeen++
47
- i += charCount
48
- }
49
-
50
- return utf16Len
35
+ val conversionTable = conversionTableFor(text)
36
+ val clampedScalar = scalarOffset.coerceIn(0, conversionTable.scalarToUtf16.size - 1)
37
+ return conversionTable.scalarToUtf16[clampedScalar]
51
38
  }
52
39
 
53
40
  /**
@@ -73,4 +60,47 @@ object PositionBridge {
73
60
  val nextBoundary = breakIterator.following(clampedOffset)
74
61
  return if (nextBoundary == BreakIterator.DONE) text.length else nextBoundary
75
62
  }
63
+
64
+ private fun conversionTableFor(text: String): ConversionTable {
65
+ val lastText = cachedText
66
+ val lastTable = cachedTable
67
+ if (lastText == text && lastTable != null) {
68
+ return lastTable
69
+ }
70
+
71
+ synchronized(cacheLock) {
72
+ val synchronizedText = cachedText
73
+ val synchronizedTable = cachedTable
74
+ if (synchronizedText == text && synchronizedTable != null) {
75
+ return synchronizedTable
76
+ }
77
+
78
+ val scalarCount = text.codePointCount(0, text.length)
79
+ val utf16ToScalar = IntArray(text.length + 1)
80
+ val scalarToUtf16 = IntArray(scalarCount + 1)
81
+
82
+ var utf16Pos = 0
83
+ var scalarPos = 0
84
+ while (utf16Pos < text.length) {
85
+ val codePoint = Character.codePointAt(text, utf16Pos)
86
+ val charCount = Character.charCount(codePoint)
87
+ val nextUtf16Pos = utf16Pos + charCount
88
+ scalarPos += 1
89
+
90
+ for (offset in (utf16Pos + 1)..nextUtf16Pos) {
91
+ utf16ToScalar[offset] = scalarPos
92
+ }
93
+ scalarToUtf16[scalarPos] = nextUtf16Pos
94
+ utf16Pos = nextUtf16Pos
95
+ }
96
+
97
+ return ConversionTable(
98
+ utf16ToScalar = utf16ToScalar,
99
+ scalarToUtf16 = scalarToUtf16,
100
+ ).also {
101
+ cachedText = text
102
+ cachedTable = it
103
+ }
104
+ }
105
+ }
76
106
  }
@@ -10,7 +10,6 @@ import android.util.AttributeSet
10
10
  import android.util.TypedValue
11
11
  import android.view.View
12
12
  import androidx.appcompat.content.res.AppCompatResources
13
- import com.google.android.material.R as MaterialR
14
13
  import org.json.JSONArray
15
14
  import uniffi.editor_core.editorDocToScalar
16
15
 
@@ -61,8 +60,8 @@ data class RemoteSelectionDecoration(
61
60
  private fun resolveFallbackColor(context: Context): Int {
62
61
  val typedValue = TypedValue()
63
62
  val attrs = intArrayOf(
64
- MaterialR.attr.colorPrimary,
65
- MaterialR.attr.colorSecondary,
63
+ androidx.appcompat.R.attr.colorPrimary,
64
+ androidx.appcompat.R.attr.colorAccent,
66
65
  android.R.attr.colorAccent,
67
66
  android.R.attr.textColorPrimary
68
67
  )
@@ -93,14 +92,40 @@ class RemoteSelectionOverlayView @JvmOverloads constructor(
93
92
  attrs: AttributeSet? = null,
94
93
  defStyleAttr: Int = 0,
95
94
  ) : View(context, attrs, defStyleAttr) {
95
+ private data class CachedSelectionGeometry(
96
+ val clientId: Int,
97
+ val selectionPath: Path?,
98
+ val selectionColor: Int,
99
+ val caretRect: RectF?,
100
+ val caretColor: Int,
101
+ )
102
+
103
+ private data class GeometrySnapshot(
104
+ val editorId: Long,
105
+ val text: String,
106
+ val layoutWidth: Int,
107
+ val layoutHeight: Int,
108
+ val baseX: Int,
109
+ val baseY: Int,
110
+ val width: Int,
111
+ val height: Int,
112
+ val selections: List<RemoteSelectionDecoration>,
113
+ )
114
+
115
+ private data class GeometryContext(
116
+ val snapshot: GeometrySnapshot,
117
+ val layout: android.text.Layout,
118
+ val caretWidth: Float,
119
+ )
96
120
 
97
121
  private var editorView: RichTextEditorView? = null
98
122
  private var remoteSelections: List<RemoteSelectionDecoration> = emptyList()
123
+ private var cachedSnapshot: GeometrySnapshot? = null
124
+ private var cachedGeometry: List<CachedSelectionGeometry> = emptyList()
99
125
  internal var editorIdOverrideForTesting: Long? = null
100
126
  internal var docToScalarResolver: (Long, Int) -> Int = { editorId, docPos ->
101
127
  editorDocToScalar(editorId.toULong(), docPos.toUInt()).toInt()
102
128
  }
103
- private val selectionPath = Path()
104
129
  private val selectionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
105
130
  style = Paint.Style.FILL
106
131
  }
@@ -116,60 +141,76 @@ class RemoteSelectionOverlayView @JvmOverloads constructor(
116
141
 
117
142
  fun bind(editorView: RichTextEditorView) {
118
143
  this.editorView = editorView
144
+ invalidateGeometry()
119
145
  }
120
146
 
121
147
  fun setRemoteSelections(selections: List<RemoteSelectionDecoration>) {
148
+ if (remoteSelections == selections) {
149
+ return
150
+ }
122
151
  remoteSelections = selections
123
- invalidate()
152
+ invalidateGeometry()
153
+ refreshGeometry()
124
154
  }
125
155
 
126
- fun debugSnapshotsForTesting(): List<RemoteSelectionDebugSnapshot> {
127
- val editorView = editorView ?: return emptyList()
128
- val editorId = resolvedEditorId(editorView)
129
- if (editorId == 0L || remoteSelections.isEmpty()) return emptyList()
156
+ fun refreshGeometry() {
157
+ ensureGeometry()
158
+ invalidate()
159
+ }
130
160
 
131
- val editText = editorView.editorEditText
132
- val layout = editText.layout ?: return emptyList()
133
- val text = editText.text?.toString() ?: return emptyList()
134
- val baseX = (editorView.editorViewport.left + editorView.editorScrollView.left + editText.left).toFloat() +
135
- editText.compoundPaddingLeft
136
- val baseY = (editorView.editorViewport.top + editorView.editorScrollView.top + editText.top).toFloat() +
137
- editText.compoundPaddingTop - editorView.editorScrollView.scrollY
138
- val caretWidth = maxOf(2f, 2f * resources.displayMetrics.density / 2f)
161
+ fun hasSelectionsOrCachedGeometry(): Boolean {
162
+ return remoteSelections.isNotEmpty() || cachedGeometry.isNotEmpty()
163
+ }
139
164
 
140
- return remoteSelections.map { selection ->
165
+ fun debugSnapshotsForTesting(): List<RemoteSelectionDebugSnapshot> {
166
+ return ensureGeometry().map { geometry ->
141
167
  RemoteSelectionDebugSnapshot(
142
- clientId = selection.clientId,
143
- caretRect = caretRectForSelection(
144
- selection = selection,
145
- editorId = editorId,
146
- text = text,
147
- layout = layout,
148
- baseX = baseX,
149
- baseY = baseY,
150
- caretWidth = caretWidth,
151
- ),
168
+ clientId = geometry.clientId,
169
+ caretRect = geometry.caretRect?.let(::RectF),
152
170
  )
153
171
  }
154
172
  }
155
173
 
156
174
  override fun onDraw(canvas: Canvas) {
157
175
  super.onDraw(canvas)
176
+ val geometry = ensureGeometry()
177
+ if (geometry.isEmpty()) return
158
178
 
159
- val editorView = editorView ?: return
160
- val editorId = resolvedEditorId(editorView)
161
- if (editorId == 0L || remoteSelections.isEmpty()) return
179
+ for (entry in geometry) {
180
+ entry.selectionPath?.let { path ->
181
+ selectionPaint.color = entry.selectionColor
182
+ canvas.drawPath(path, selectionPaint)
183
+ }
162
184
 
163
- val editText = editorView.editorEditText
164
- val layout = editText.layout ?: return
165
- val text = editText.text?.toString() ?: return
166
- val baseX = (editorView.editorViewport.left + editorView.editorScrollView.left + editText.left).toFloat() +
167
- editText.compoundPaddingLeft
168
- val baseY = (editorView.editorViewport.top + editorView.editorScrollView.top + editText.top).toFloat() +
169
- editText.compoundPaddingTop - editorView.editorScrollView.scrollY
170
- val caretWidth = maxOf(2f, 2f * resources.displayMetrics.density / 2f)
185
+ entry.caretRect?.let { caretRect ->
186
+ caretPaint.color = entry.caretColor
187
+ val cornerRadius = maxOf(1f, caretRect.width() / 2f)
188
+ canvas.drawRoundRect(
189
+ caretRect.left,
190
+ caretRect.top,
191
+ caretRect.right,
192
+ caretRect.bottom,
193
+ cornerRadius,
194
+ cornerRadius,
195
+ caretPaint
196
+ )
197
+ }
198
+ }
199
+ }
171
200
 
172
- for (selection in remoteSelections) {
201
+ private fun ensureGeometry(): List<CachedSelectionGeometry> {
202
+ val context = buildGeometryContext() ?: run {
203
+ cachedSnapshot = null
204
+ cachedGeometry = emptyList()
205
+ return emptyList()
206
+ }
207
+ if (cachedSnapshot == context.snapshot) {
208
+ return cachedGeometry
209
+ }
210
+
211
+ val text = context.snapshot.text
212
+ val editorId = context.snapshot.editorId
213
+ val geometry = remoteSelections.map { selection ->
173
214
  val startDoc = minOf(selection.anchor, selection.head)
174
215
  val endDoc = maxOf(selection.anchor, selection.head)
175
216
  val startScalar = docToScalarResolver(editorId, startDoc)
@@ -177,59 +218,87 @@ class RemoteSelectionOverlayView @JvmOverloads constructor(
177
218
  val startUtf16 = PositionBridge.scalarToUtf16(startScalar, text).coerceIn(0, text.length)
178
219
  val endUtf16 = PositionBridge.scalarToUtf16(endScalar, text).coerceIn(0, text.length)
179
220
 
180
- selectionPaint.color = withAlpha(selection.color, 0.18f)
181
- caretPaint.color = selection.color
182
-
183
- if (startUtf16 != endUtf16) {
184
- selectionPath.reset()
185
- layout.getSelectionPath(startUtf16, endUtf16, selectionPath)
186
- canvas.save()
187
- canvas.translate(baseX, baseY)
188
- canvas.drawPath(selectionPath, selectionPaint)
189
- canvas.restore()
221
+ val selectionPath = if (startUtf16 != endUtf16) {
222
+ Path().apply {
223
+ context.layout.getSelectionPath(startUtf16, endUtf16, this)
224
+ offset(context.snapshot.baseX.toFloat(), context.snapshot.baseY.toFloat())
225
+ }
226
+ } else {
227
+ null
190
228
  }
191
229
 
192
- if (!selection.isFocused) {
193
- continue
194
- }
230
+ CachedSelectionGeometry(
231
+ clientId = selection.clientId,
232
+ selectionPath = selectionPath,
233
+ selectionColor = withAlpha(selection.color, 0.18f),
234
+ caretRect = caretRectForOffset(
235
+ endUtf16 = endUtf16,
236
+ textLength = text.length,
237
+ layout = context.layout,
238
+ baseX = context.snapshot.baseX.toFloat(),
239
+ baseY = context.snapshot.baseY.toFloat(),
240
+ caretWidth = context.caretWidth,
241
+ isFocused = selection.isFocused,
242
+ ),
243
+ caretColor = selection.color,
244
+ )
245
+ }
246
+
247
+ cachedSnapshot = context.snapshot
248
+ cachedGeometry = geometry
249
+ return geometry
250
+ }
251
+
252
+ private fun buildGeometryContext(): GeometryContext? {
253
+ val editorView = editorView ?: return null
254
+ val editorId = resolvedEditorId(editorView)
255
+ if (editorId == 0L || remoteSelections.isEmpty()) return null
195
256
 
196
- val caretRect = caretRectForSelection(
197
- selection = selection,
257
+ val editText = editorView.editorEditText
258
+ val layout = editText.layout ?: return null
259
+ val text = editText.text?.toString() ?: return null
260
+ val baseX = editorView.editorViewport.left + editorView.editorScrollView.left + editText.left +
261
+ editText.compoundPaddingLeft
262
+ val baseY = editorView.editorViewport.top + editorView.editorScrollView.top + editText.top +
263
+ editText.compoundPaddingTop - editorView.editorScrollView.scrollY
264
+ val caretWidth = maxOf(2f, resources.displayMetrics.density)
265
+
266
+ return GeometryContext(
267
+ snapshot = GeometrySnapshot(
198
268
  editorId = editorId,
199
269
  text = text,
200
- layout = layout,
270
+ layoutWidth = layout.width,
271
+ layoutHeight = layout.height,
201
272
  baseX = baseX,
202
273
  baseY = baseY,
203
- caretWidth = caretWidth,
204
- ) ?: continue
205
- canvas.drawRoundRect(
206
- caretRect.left,
207
- caretRect.top,
208
- caretRect.right,
209
- caretRect.bottom,
210
- caretWidth / 2f,
211
- caretWidth / 2f,
212
- caretPaint
213
- )
214
- }
274
+ width = width,
275
+ height = height,
276
+ selections = remoteSelections,
277
+ ),
278
+ layout = layout,
279
+ caretWidth = caretWidth,
280
+ )
281
+ }
282
+
283
+ private fun invalidateGeometry() {
284
+ cachedSnapshot = null
215
285
  }
216
286
 
217
- private fun caretRectForSelection(
218
- selection: RemoteSelectionDecoration,
219
- editorId: Long,
220
- text: String,
287
+ private fun caretRectForOffset(
288
+ endUtf16: Int,
289
+ textLength: Int,
221
290
  layout: android.text.Layout,
222
291
  baseX: Float,
223
292
  baseY: Float,
224
293
  caretWidth: Float,
294
+ isFocused: Boolean,
225
295
  ): RectF? {
226
- if (!selection.isFocused) return null
296
+ if (!isFocused) return null
227
297
 
228
- val endDoc = maxOf(selection.anchor, selection.head)
229
- val endScalar = docToScalarResolver(editorId, endDoc)
230
- val endUtf16 = PositionBridge.scalarToUtf16(endScalar, text).coerceIn(0, text.length)
231
- val line = layout.getLineForOffset(endUtf16.coerceAtMost(maxOf(text.length - 1, 0)))
232
- val horizontal = layout.getPrimaryHorizontal(endUtf16)
298
+ val clampedOffset = endUtf16.coerceIn(0, textLength)
299
+ val lineLookupOffset = clampedOffset.coerceAtMost(maxOf(textLength - 1, 0))
300
+ val line = layout.getLineForOffset(lineLookupOffset)
301
+ val horizontal = layout.getPrimaryHorizontal(clampedOffset)
233
302
  val caretLeft = baseX + horizontal
234
303
  val caretTop = baseY + layout.getLineTop(line)
235
304
  val caretBottom = baseY + layout.getLineBottom(line)