@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.
- package/README.md +18 -0
- package/android/build.gradle +23 -0
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +515 -39
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +58 -28
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +25 -0
- package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +232 -62
- package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +57 -27
- package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +147 -78
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +249 -71
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +7 -6
- package/dist/EditorToolbar.d.ts +26 -6
- package/dist/EditorToolbar.js +299 -65
- package/dist/NativeEditorBridge.d.ts +40 -1
- package/dist/NativeEditorBridge.js +184 -90
- package/dist/NativeRichTextEditor.d.ts +5 -1
- package/dist/NativeRichTextEditor.js +201 -78
- package/dist/YjsCollaboration.d.ts +2 -0
- package/dist/YjsCollaboration.js +142 -20
- package/dist/index.d.ts +1 -1
- package/dist/schemas.js +12 -0
- package/dist/useNativeEditor.d.ts +2 -0
- package/dist/useNativeEditor.js +7 -0
- package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
- package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
- package/ios/EditorLayoutManager.swift +3 -3
- package/ios/Generated_editor_core.swift +87 -0
- package/ios/NativeEditorExpoView.swift +488 -178
- package/ios/NativeEditorModule.swift +25 -0
- package/ios/PositionBridge.swift +310 -75
- package/ios/RenderBridge.swift +362 -27
- package/ios/RichTextEditorView.swift +2001 -189
- package/ios/editor_coreFFI/editor_coreFFI.h +55 -0
- package/package.json +11 -2
- package/rust/android/arm64-v8a/libeditor_core.so +0 -0
- package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
- package/rust/android/x86_64/libeditor_core.so +0 -0
- 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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
152
|
+
invalidateGeometry()
|
|
153
|
+
refreshGeometry()
|
|
124
154
|
}
|
|
125
155
|
|
|
126
|
-
fun
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
156
|
+
fun refreshGeometry() {
|
|
157
|
+
ensureGeometry()
|
|
158
|
+
invalidate()
|
|
159
|
+
}
|
|
130
160
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
165
|
+
fun debugSnapshotsForTesting(): List<RemoteSelectionDebugSnapshot> {
|
|
166
|
+
return ensureGeometry().map { geometry ->
|
|
141
167
|
RemoteSelectionDebugSnapshot(
|
|
142
|
-
clientId =
|
|
143
|
-
caretRect =
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
179
|
+
for (entry in geometry) {
|
|
180
|
+
entry.selectionPath?.let { path ->
|
|
181
|
+
selectionPaint.color = entry.selectionColor
|
|
182
|
+
canvas.drawPath(path, selectionPaint)
|
|
183
|
+
}
|
|
162
184
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
270
|
+
layoutWidth = layout.width,
|
|
271
|
+
layoutHeight = layout.height,
|
|
201
272
|
baseX = baseX,
|
|
202
273
|
baseY = baseY,
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
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 (!
|
|
296
|
+
if (!isFocused) return null
|
|
227
297
|
|
|
228
|
-
val
|
|
229
|
-
val
|
|
230
|
-
val
|
|
231
|
-
val
|
|
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)
|