@apollohg/react-native-prose-editor 0.3.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 (37) 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 +515 -39
  4. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +58 -28
  5. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +25 -0
  6. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +232 -62
  7. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +57 -27
  8. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +147 -78
  9. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +249 -71
  10. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +7 -6
  11. package/dist/EditorToolbar.d.ts +26 -6
  12. package/dist/EditorToolbar.js +299 -65
  13. package/dist/NativeEditorBridge.d.ts +40 -1
  14. package/dist/NativeEditorBridge.js +184 -90
  15. package/dist/NativeRichTextEditor.d.ts +5 -1
  16. package/dist/NativeRichTextEditor.js +201 -78
  17. package/dist/YjsCollaboration.d.ts +2 -0
  18. package/dist/YjsCollaboration.js +142 -20
  19. package/dist/index.d.ts +1 -1
  20. package/dist/schemas.js +12 -0
  21. package/dist/useNativeEditor.d.ts +2 -0
  22. package/dist/useNativeEditor.js +7 -0
  23. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  24. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  25. package/ios/EditorLayoutManager.swift +3 -3
  26. package/ios/Generated_editor_core.swift +87 -0
  27. package/ios/NativeEditorExpoView.swift +488 -178
  28. package/ios/NativeEditorModule.swift +25 -0
  29. package/ios/PositionBridge.swift +310 -75
  30. package/ios/RenderBridge.swift +362 -27
  31. package/ios/RichTextEditorView.swift +2001 -189
  32. package/ios/editor_coreFFI/editor_coreFFI.h +55 -0
  33. package/package.json +11 -2
  34. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  35. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  36. package/rust/android/x86_64/libeditor_core.so +0 -0
  37. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +128 -0
@@ -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)