@openeditor/react-native-prose-editor 0.0.6 → 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
@@ -0,0 +1,1086 @@
1
+ package com.openeditor.editor
2
+
3
+ import android.content.Context
4
+ import android.graphics.Canvas
5
+ import android.graphics.Color
6
+ import android.graphics.Paint
7
+ import android.graphics.Rect
8
+ import android.graphics.RectF
9
+ import android.graphics.Typeface
10
+ import android.graphics.drawable.GradientDrawable
11
+ import android.text.SpannableStringBuilder
12
+ import android.text.Spanned
13
+ import android.text.style.BackgroundColorSpan
14
+ import android.text.style.ForegroundColorSpan
15
+ import android.text.style.StrikethroughSpan
16
+ import android.text.style.StyleSpan
17
+ import android.text.style.TypefaceSpan
18
+ import android.text.style.UnderlineSpan
19
+ import android.view.MotionEvent
20
+ import android.view.View
21
+ import android.view.ViewGroup
22
+ import android.util.TypedValue
23
+ import android.widget.ImageView
24
+ import android.widget.LinearLayout
25
+ import android.widget.ScrollView
26
+ import android.widget.TextView
27
+ import org.json.JSONObject
28
+ import uniffi.editor_core.editorDocToScalar
29
+ import uniffi.editor_core.editorGetSelectionState
30
+ import uniffi.editor_core.editorResizeImageAtDocPos
31
+ import uniffi.editor_core.editorSetCellSelection
32
+ import uniffi.editor_core.editorSetNodeSelection
33
+ import uniffi.editor_core.editorSetSelection
34
+ import uniffi.editor_core.editorToggleTaskItemCheckedAtSelectionScalar
35
+
36
+ class NativeBlockEditorSurface(context: Context) : ScrollView(context) {
37
+ var isScrollingEnabled: Boolean = true
38
+
39
+ internal var geometryRevision: Long = 0
40
+ private set
41
+
42
+ private data class PositionSegment(
43
+ val startUtf16: Int,
44
+ val endUtf16: Int,
45
+ val text: String,
46
+ val docPos: Int,
47
+ val docSize: Int,
48
+ val isAtom: Boolean
49
+ )
50
+
51
+ private class TextLeaf(context: Context) : TextView(context) {
52
+ var positionSegments: List<PositionSegment> = emptyList()
53
+
54
+ fun docPositionForOffset(offset: Int): Int? {
55
+ val segment = positionSegments.firstOrNull {
56
+ offset in it.startUtf16..it.endUtf16
57
+ } ?: positionSegments.lastOrNull() ?: return null
58
+ if (segment.isAtom) {
59
+ return segment.docPos + if (offset > (segment.startUtf16 + segment.endUtf16) / 2) {
60
+ segment.docSize
61
+ } else {
62
+ 0
63
+ }
64
+ }
65
+ val localUtf16 = (offset - segment.startUtf16).coerceIn(0, segment.text.length)
66
+ return segment.docPos + segment.text.codePointCount(0, localUtf16)
67
+ }
68
+
69
+ fun nativeOffsetForDocPosition(docPosition: Int): Int? {
70
+ val segment = positionSegments.firstOrNull {
71
+ docPosition in it.docPos..(it.docPos + it.docSize)
72
+ } ?: return null
73
+ if (segment.isAtom) {
74
+ return if (docPosition > segment.docPos) segment.endUtf16 else segment.startUtf16
75
+ }
76
+ val scalarOffset = (docPosition - segment.docPos).coerceIn(
77
+ 0,
78
+ segment.text.codePointCount(0, segment.text.length)
79
+ )
80
+ return segment.startUtf16 + segment.text.offsetByCodePoints(0, scalarOffset)
81
+ }
82
+
83
+ fun caretRectForDocPosition(docPosition: Int, caretWidth: Float): RectF? {
84
+ val textLayout = layout ?: return null
85
+ val offset = nativeOffsetForDocPosition(docPosition)?.coerceIn(0, text.length) ?: return null
86
+ val lookupOffset = offset.coerceAtMost((text.length - 1).coerceAtLeast(0))
87
+ val line = textLayout.getLineForOffset(lookupOffset)
88
+ val left = compoundPaddingLeft + textLayout.getPrimaryHorizontal(offset)
89
+ return RectF(
90
+ left,
91
+ compoundPaddingTop + textLayout.getLineTop(line).toFloat(),
92
+ left + caretWidth,
93
+ compoundPaddingTop + textLayout.getLineBottom(line).toFloat()
94
+ )
95
+ }
96
+
97
+ fun selectionRectsForDocRange(from: Int, to: Int): List<RectF> {
98
+ val textLayout = layout ?: return emptyList()
99
+ val start = nativeOffsetForDocPosition(from)?.coerceIn(0, text.length) ?: return emptyList()
100
+ val end = nativeOffsetForDocPosition(to)?.coerceIn(start, text.length) ?: return emptyList()
101
+ if (start == end) return emptyList()
102
+ val firstLine = textLayout.getLineForOffset(start)
103
+ val lastLine = textLayout.getLineForOffset((end - 1).coerceAtLeast(start))
104
+ return (firstLine..lastLine).mapNotNull { line ->
105
+ val lineStart = maxOf(start, textLayout.getLineStart(line))
106
+ val lineEnd = minOf(end, textLayout.getLineEnd(line))
107
+ if (lineStart >= lineEnd) return@mapNotNull null
108
+ val left = compoundPaddingLeft + textLayout.getPrimaryHorizontal(lineStart)
109
+ val right = compoundPaddingLeft + textLayout.getPrimaryHorizontal(lineEnd)
110
+ RectF(
111
+ minOf(left, right),
112
+ compoundPaddingTop + textLayout.getLineTop(line).toFloat(),
113
+ maxOf(left, right),
114
+ compoundPaddingTop + textLayout.getLineBottom(line).toFloat()
115
+ )
116
+ }
117
+ }
118
+ }
119
+
120
+ private class CellView(context: Context) : LinearLayout(context) {
121
+ var docPos: Int = 0
122
+ var tableId: Int = 0
123
+ var rowIndex: Int = 0
124
+ var columnIndex: Int = 0
125
+ var baseFillColor: Int = Color.WHITE
126
+ }
127
+
128
+ private class BlockImageView(context: Context) : ImageView(context) {
129
+ var docPos: Int = 0
130
+ var preferredWidthPx: Int? = null
131
+ var preferredHeightPx: Int? = null
132
+
133
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
134
+ val available = MeasureSpec.getSize(widthMeasureSpec).coerceAtLeast(1)
135
+ val intrinsicWidth = drawable?.intrinsicWidth?.takeIf { it > 0 } ?: available
136
+ val intrinsicHeight = drawable?.intrinsicHeight?.takeIf { it > 0 } ?: (intrinsicWidth * 0.56f).toInt()
137
+ val requestedWidth = preferredWidthPx ?: intrinsicWidth
138
+ val width = minOf(requestedWidth.coerceAtLeast(1), available)
139
+ val requestedHeight = preferredHeightPx
140
+ ?: ((width.toFloat() / intrinsicWidth.coerceAtLeast(1)) * intrinsicHeight).toInt()
141
+ setMeasuredDimension(width, requestedHeight.coerceAtLeast(1))
142
+ }
143
+ }
144
+
145
+ private val stack = LinearLayout(context).apply {
146
+ orientation = LinearLayout.VERTICAL
147
+ setPadding(0, 0, 0, 0)
148
+ }
149
+ private var currentEditorId: Long = 0L
150
+ private var baseTextSizePx: Float = 16f * resources.displayMetrics.density
151
+ private var baseTextColor: Int = Color.BLACK
152
+ private var isApplyingRemoteUpdate = false
153
+ private var currentTheme: EditorTheme? = null
154
+ private var nextTableId = 1
155
+ private val tableCells = mutableListOf<CellView>()
156
+ private var selectionAnchorCell: CellView? = null
157
+ private var selectionHeadCell: CellView? = null
158
+ private var currentRoot: JSONObject? = null
159
+ /** Canonical render-tree path to the native view that owns that node. */
160
+ private val nodeViews = mutableMapOf<List<Int>, View>()
161
+ private val imageViews = mutableListOf<BlockImageView>()
162
+ private val textLeafViews = mutableListOf<TextLeaf>()
163
+ private var selectedNodePos: Int? = null
164
+ private var selectedTextPos: Int? = null
165
+ private val caretPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0xFF2F80ED.toInt() }
166
+
167
+ var isInputFocused: Boolean = false
168
+ set(value) {
169
+ field = value
170
+ invalidate()
171
+ }
172
+
173
+ var onAppliedUpdate: ((String) -> Unit)? = null
174
+ var onRequestTextInput: ((Int) -> Unit)? = null
175
+ var isEditable: Boolean = true
176
+ set(value) {
177
+ field = value
178
+ refreshImageSelection()
179
+ }
180
+
181
+ init {
182
+ isFillViewport = false
183
+ clipToPadding = false
184
+ addView(
185
+ stack,
186
+ LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
187
+ )
188
+ }
189
+
190
+ override fun onInterceptTouchEvent(event: MotionEvent): Boolean =
191
+ isScrollingEnabled && super.onInterceptTouchEvent(event)
192
+
193
+ override fun onTouchEvent(event: MotionEvent): Boolean =
194
+ isScrollingEnabled && super.onTouchEvent(event)
195
+
196
+ override fun dispatchDraw(canvas: Canvas) {
197
+ super.dispatchDraw(canvas)
198
+ if (!isEditable || !isInputFocused || selectedNodePos != null) return
199
+ selectedCaretRect()?.let { canvas.drawRect(it, caretPaint) }
200
+ }
201
+
202
+ fun bind(editorId: Long) {
203
+ currentEditorId = editorId
204
+ if (editorId == 0L) {
205
+ stack.removeAllViews()
206
+ currentRoot = null
207
+ nodeViews.clear()
208
+ tableCells.clear()
209
+ imageViews.clear()
210
+ textLeafViews.clear()
211
+ selectedNodePos = null
212
+ selectedTextPos = null
213
+ }
214
+ }
215
+
216
+ fun configure(textSizePx: Float, textColor: Int, backgroundColor: Int) {
217
+ baseTextSizePx = textSizePx
218
+ baseTextColor = textColor
219
+ setBackgroundColor(backgroundColor)
220
+ }
221
+
222
+ fun applyTheme(theme: EditorTheme?) {
223
+ currentTheme = theme
224
+ val density = resources.displayMetrics.density
225
+ val topInset = ((theme?.contentInsets?.top ?: 0f) * density).toInt()
226
+ val rightInset = ((theme?.contentInsets?.right ?: 0f) * density).toInt()
227
+ val bottomInset = ((theme?.contentInsets?.bottom ?: 0f) * density).toInt()
228
+ val leftInset = ((theme?.contentInsets?.left ?: 0f) * density).toInt()
229
+ setPadding(leftInset, topInset, rightInset, bottomInset)
230
+ }
231
+
232
+ fun applyUpdateJSON(updateJSON: String, editorId: Long) {
233
+ val update = try { JSONObject(updateJSON) } catch (_: Throwable) { return }
234
+ update.optJSONObject("selection")?.let { selection ->
235
+ selectedNodePos = if (selection.optString("type") == "node") {
236
+ selection.optInt("pos")
237
+ } else {
238
+ null
239
+ }
240
+ selectedTextPos = if (
241
+ selection.optString("type") == "text" &&
242
+ selection.optInt("anchor") == selection.optInt("head")
243
+ ) selection.optInt("anchor") else null
244
+ refreshImageSelection()
245
+ }
246
+ val root = update.optJSONObject("renderTree")?.optJSONObject("root") ?: return
247
+ currentEditorId = editorId
248
+ isApplyingRemoteUpdate = true
249
+ val operations = update.optJSONObject("renderPatch")?.optJSONArray("operations")
250
+ if (currentRoot != null && operations != null) {
251
+ applyStructuralPatch(operations, root)
252
+ } else {
253
+ rebuildAll(root)
254
+ }
255
+ refreshTableCellRegistry()
256
+ refreshImageSelection()
257
+ currentRoot = root
258
+ isApplyingRemoteUpdate = false
259
+ geometryRevision += 1
260
+ requestLayout()
261
+ }
262
+
263
+ private fun rebuildAll(root: JSONObject) {
264
+ stack.removeAllViews()
265
+ nodeViews.clear()
266
+ tableCells.clear()
267
+ selectionAnchorCell = null
268
+ selectionHeadCell = null
269
+ val children = root.optJSONArray("children")
270
+ if (children == null || children.length() == 0) {
271
+ val leaf = textBlockView(root, 0)
272
+ nodeViews[emptyList()] = leaf
273
+ stack.addView(leaf)
274
+ return
275
+ }
276
+ for (index in 0 until children.length()) {
277
+ children.optJSONObject(index)?.let { stack.addView(viewForNode(it, 0, listOf(index))) }
278
+ }
279
+ }
280
+
281
+ private fun applyStructuralPatch(operations: org.json.JSONArray, nextRoot: JSONObject) {
282
+ for (index in 0 until operations.length()) {
283
+ val operation = operations.optJSONObject(index) ?: continue
284
+ val type = operation.optString("type")
285
+ val pathArray = operation.optJSONArray("path")
286
+ val path = buildList {
287
+ for (pathIndex in 0 until (pathArray?.length() ?: 0)) {
288
+ add(pathArray!!.optInt(pathIndex))
289
+ }
290
+ }
291
+ when (type) {
292
+ "updateMetadata" -> updateMetadata(path, nextRoot)
293
+ "replaceNode" -> {
294
+ if (path.isEmpty()) rebuildAll(nextRoot)
295
+ else replaceRenderedOwner(path, nextRoot)
296
+ }
297
+ "spliceChildren" -> {
298
+ if (path.isEmpty()) applyRootSplice(operation, nextRoot)
299
+ else replaceRenderedOwner(path, nextRoot)
300
+ }
301
+ else -> {
302
+ rebuildAll(nextRoot)
303
+ return
304
+ }
305
+ }
306
+ }
307
+ }
308
+
309
+ private fun applyRootSplice(operation: JSONObject, nextRoot: JSONObject) {
310
+ val start = operation.optInt("startIndex", 0).coerceAtLeast(0)
311
+ val deleteCount = operation.optInt("deleteCount", 0).coerceAtLeast(0)
312
+ val insertedCount = operation.optJSONArray("children")?.length() ?: 0
313
+ val nextChildren = nextRoot.optJSONArray("children")
314
+ if (start > stack.childCount || start + deleteCount > stack.childCount ||
315
+ start + insertedCount > (nextChildren?.length() ?: 0)
316
+ ) {
317
+ rebuildAll(nextRoot)
318
+ return
319
+ }
320
+ removeBindingsInTopLevelRange(start until start + deleteCount)
321
+ repeat(deleteCount) { stack.removeViewAt(start) }
322
+ shiftTopLevelBindings(start + deleteCount, insertedCount - deleteCount)
323
+ for (offset in 0 until insertedCount) {
324
+ val childIndex = start + offset
325
+ nextChildren?.optJSONObject(childIndex)?.let {
326
+ stack.addView(viewForNode(it, 0, listOf(childIndex)), childIndex)
327
+ }
328
+ }
329
+ }
330
+
331
+ private fun replaceRenderedOwner(requestedPath: List<Int>, root: JSONObject) {
332
+ val ownerPath = nearestRenderedPath(requestedPath) ?: run {
333
+ rebuildAll(root)
334
+ return
335
+ }
336
+ if (ownerPath.isEmpty()) {
337
+ rebuildAll(root)
338
+ return
339
+ }
340
+ val oldView = nodeViews[ownerPath] ?: run {
341
+ rebuildAll(root)
342
+ return
343
+ }
344
+ val node = nodeAt(ownerPath, root) ?: run {
345
+ rebuildAll(root)
346
+ return
347
+ }
348
+ val parent = oldView.parent as? ViewGroup ?: run {
349
+ rebuildAll(root)
350
+ return
351
+ }
352
+ val childIndex = parent.indexOfChild(oldView)
353
+ if (childIndex < 0) {
354
+ rebuildAll(root)
355
+ return
356
+ }
357
+ removeBindingsUnder(ownerPath)
358
+ val replacement = viewForNode(node, (ownerPath.size - 1).coerceAtLeast(0), ownerPath)
359
+ parent.removeViewAt(childIndex)
360
+ parent.addView(replacement, childIndex)
361
+ }
362
+
363
+ private fun updateMetadata(requestedPath: List<Int>, root: JSONObject) {
364
+ val ownerPath = nearestRenderedPath(requestedPath) ?: return
365
+ val owner = nodeViews[ownerPath] ?: return
366
+ val node = nodeAt(ownerPath, root) ?: return
367
+ if (owner is TextLeaf) owner.positionSegments = positionSegmentsFor(node)
368
+ if (owner is CellView) owner.docPos = node.optInt("docPos", owner.docPos)
369
+ if (owner is BlockImageView) owner.docPos = node.optInt("docPos", owner.docPos)
370
+ }
371
+
372
+ private fun nearestRenderedPath(requestedPath: List<Int>): List<Int>? {
373
+ var candidate = requestedPath
374
+ while (true) {
375
+ if (nodeViews.containsKey(candidate)) return candidate
376
+ if (candidate.isEmpty()) return null
377
+ candidate = candidate.dropLast(1)
378
+ }
379
+ }
380
+
381
+ private fun nodeAt(path: List<Int>, root: JSONObject): JSONObject? {
382
+ var current = root
383
+ for (index in path) {
384
+ current = current.optJSONArray("children")?.optJSONObject(index) ?: return null
385
+ }
386
+ return current
387
+ }
388
+
389
+ private fun removeBindingsUnder(path: List<Int>) {
390
+ nodeViews.keys.removeAll { key -> key.size >= path.size && key.take(path.size) == path }
391
+ }
392
+
393
+ private fun removeBindingsInTopLevelRange(range: IntRange) {
394
+ if (range.isEmpty()) return
395
+ nodeViews.keys.removeAll { key -> key.firstOrNull()?.let(range::contains) == true }
396
+ }
397
+
398
+ private fun shiftTopLevelBindings(start: Int, delta: Int) {
399
+ if (delta == 0) return
400
+ val shifted = nodeViews.entries.associate { (path, view) ->
401
+ if (path.firstOrNull()?.let { it >= start } == true) {
402
+ listOf(path.first() + delta) + path.drop(1) to view
403
+ } else {
404
+ path to view
405
+ }
406
+ }
407
+ nodeViews.clear()
408
+ nodeViews.putAll(shifted)
409
+ }
410
+
411
+ private fun refreshTableCellRegistry() {
412
+ tableCells.clear()
413
+ imageViews.clear()
414
+ textLeafViews.clear()
415
+ fun collect(view: View) {
416
+ if (view is CellView) tableCells += view
417
+ if (view is BlockImageView) imageViews += view
418
+ if (view is TextLeaf) textLeafViews += view
419
+ if (view is ViewGroup) {
420
+ for (index in 0 until view.childCount) collect(view.getChildAt(index))
421
+ }
422
+ }
423
+ for (index in 0 until stack.childCount) collect(stack.getChildAt(index))
424
+ }
425
+
426
+ internal fun topLevelViewForTesting(index: Int): View? =
427
+ if (index in 0 until stack.childCount) stack.getChildAt(index) else null
428
+
429
+ internal val topLevelViewCountForTesting: Int
430
+ get() = stack.childCount
431
+
432
+ private fun viewForNode(node: JSONObject, depth: Int, path: List<Int>): View {
433
+ val rendered = when (node.optString("kind")) {
434
+ "list" -> listView(node, depth, path)
435
+ "listItem" -> listItemView(node, 0, null, depth, path)
436
+ "columns" -> columnsView(node, depth, path)
437
+ "column" -> columnView(node, depth, path)
438
+ "table" -> tableView(node, path)
439
+ "tableRow" -> rowView(node, 0, nextTableId, path)
440
+ "tableCell" -> cellView(node, nextTableId, 0, 0, path)
441
+ "blockAtom" -> atomView(node)
442
+ else -> if (node.optString("kind") == "block") {
443
+ blockContainerView(node, depth, path)
444
+ } else {
445
+ textBlockView(node, depth)
446
+ }
447
+ }
448
+ nodeViews[path] = rendered
449
+ return rendered
450
+ }
451
+
452
+ private fun columnsView(node: JSONObject, depth: Int, path: List<Int>): View {
453
+ val density = resources.displayMetrics.density
454
+ val row = LinearLayout(context).apply {
455
+ orientation = LinearLayout.HORIZONTAL
456
+ isBaselineAligned = false
457
+ setPadding((depth * 8 * density).toInt(), (4 * density).toInt(), 0, (4 * density).toInt())
458
+ }
459
+ val columns = node.optJSONArray("children")
460
+ for (index in 0 until (columns?.length() ?: 0)) {
461
+ columns?.optJSONObject(index)?.let {
462
+ val params = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
463
+ if (index > 0) params.leftMargin = (12 * density).toInt()
464
+ row.addView(viewForNode(it, depth + 1, path + index), params)
465
+ }
466
+ }
467
+ return row
468
+ }
469
+
470
+ private fun columnView(node: JSONObject, depth: Int, path: List<Int>): View {
471
+ val column = LinearLayout(context).apply {
472
+ orientation = LinearLayout.VERTICAL
473
+ }
474
+ val children = node.optJSONArray("children")
475
+ for (index in 0 until (children?.length() ?: 0)) {
476
+ children?.optJSONObject(index)?.let { column.addView(viewForNode(it, depth + 1, path + index)) }
477
+ }
478
+ if (column.childCount == 0) {
479
+ column.addView(textBlockView(node, depth))
480
+ }
481
+ return column
482
+ }
483
+
484
+ private fun listView(node: JSONObject, depth: Int, path: List<Int>): View {
485
+ val density = resources.displayMetrics.density
486
+ val list = LinearLayout(context).apply {
487
+ orientation = LinearLayout.VERTICAL
488
+ setPadding((depth * 12 * density).toInt(), (2 * density).toInt(), 0, (2 * density).toInt())
489
+ }
490
+ val children = node.optJSONArray("children")
491
+ val start = node.optJSONObject("attrs")?.optInt("start", 1) ?: 1
492
+ val nodeType = node.optString("nodeType")
493
+ for (index in 0 until (children?.length() ?: 0)) {
494
+ children?.optJSONObject(index)?.let {
495
+ list.addView(listItemView(it, start + index, nodeType, depth, path + index))
496
+ }
497
+ }
498
+ return list
499
+ }
500
+
501
+ private fun listItemView(
502
+ node: JSONObject,
503
+ index: Int,
504
+ parentListType: String?,
505
+ depth: Int,
506
+ path: List<Int>
507
+ ): View {
508
+ val density = resources.displayMetrics.density
509
+ val row = LinearLayout(context).apply {
510
+ orientation = LinearLayout.HORIZONTAL
511
+ isBaselineAligned = false
512
+ }
513
+ row.addView(
514
+ markerView(node, parentListType, index),
515
+ LinearLayout.LayoutParams((24 * density).toInt(), (24 * density).toInt())
516
+ )
517
+ val content = LinearLayout(context).apply {
518
+ orientation = LinearLayout.VERTICAL
519
+ setPadding((8 * density).toInt(), 0, 0, 0)
520
+ }
521
+ val children = node.optJSONArray("children")
522
+ for (childIndex in 0 until (children?.length() ?: 0)) {
523
+ children?.optJSONObject(childIndex)?.let { content.addView(viewForNode(it, depth + 1, path + childIndex)) }
524
+ }
525
+ if (content.childCount == 0) {
526
+ content.addView(textBlockView(node, depth + 1))
527
+ }
528
+ row.addView(content, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
529
+ return row
530
+ }
531
+
532
+ private fun blockContainerView(node: JSONObject, depth: Int, path: List<Int>): View {
533
+ val density = resources.displayMetrics.density
534
+ if (node.optString("nodeType") == "blockquote") {
535
+ val row = LinearLayout(context).apply {
536
+ orientation = LinearLayout.HORIZONTAL
537
+ setPadding((depth * 8 * density).toInt(), (6 * density).toInt(), 0, (6 * density).toInt())
538
+ }
539
+ row.addView(
540
+ View(context).apply { setBackgroundColor(currentTheme?.blockquote?.borderColor ?: Color.LTGRAY) },
541
+ LinearLayout.LayoutParams(
542
+ (((currentTheme?.blockquote?.borderWidth ?: 3f) * density).toInt()).coerceAtLeast(1),
543
+ ViewGroup.LayoutParams.MATCH_PARENT
544
+ )
545
+ )
546
+ val quoteContent = LinearLayout(context).apply {
547
+ orientation = LinearLayout.VERTICAL
548
+ setPadding((((currentTheme?.blockquote?.markerGap ?: 8f) * density).toInt()), 0, 0, 0)
549
+ }
550
+ val children = node.optJSONArray("children")
551
+ for (index in 0 until (children?.length() ?: 0)) {
552
+ children?.optJSONObject(index)?.let { quoteContent.addView(viewForNode(it, depth + 1, path + index)) }
553
+ }
554
+ row.addView(quoteContent, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
555
+ return row
556
+ }
557
+
558
+ val container = LinearLayout(context).apply {
559
+ orientation = LinearLayout.VERTICAL
560
+ setPadding((depth * 8 * density).toInt(), (4 * density).toInt(), 0, (4 * density).toInt())
561
+ }
562
+ val children = node.optJSONArray("children")
563
+ for (index in 0 until (children?.length() ?: 0)) {
564
+ children?.optJSONObject(index)?.let { container.addView(viewForNode(it, depth + 1, path + index)) }
565
+ }
566
+ if (container.childCount == 0) {
567
+ container.addView(textBlockView(node, depth))
568
+ }
569
+ return container
570
+ }
571
+
572
+ private fun tableView(node: JSONObject, path: List<Int>): View {
573
+ val tableId = nextTableId++
574
+ val table = LinearLayout(context).apply {
575
+ orientation = LinearLayout.VERTICAL
576
+ background = borderDrawable(Color.TRANSPARENT, Color.LTGRAY, 1f)
577
+ }
578
+ val rows = node.optJSONArray("children")
579
+ for (index in 0 until (rows?.length() ?: 0)) {
580
+ rows?.optJSONObject(index)?.let { table.addView(rowView(it, index, tableId, path + index)) }
581
+ }
582
+ table.setOnTouchListener { _, event -> handleTableTouch(event) }
583
+ return table
584
+ }
585
+
586
+ private fun rowView(node: JSONObject, rowIndex: Int, tableId: Int, path: List<Int>): View {
587
+ val row = LinearLayout(context).apply {
588
+ orientation = LinearLayout.HORIZONTAL
589
+ isBaselineAligned = false
590
+ }
591
+ val cells = node.optJSONArray("children")
592
+ for (index in 0 until (cells?.length() ?: 0)) {
593
+ cells?.optJSONObject(index)?.let {
594
+ row.addView(cellView(it, tableId, rowIndex, index, path + index), LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
595
+ }
596
+ }
597
+ return row
598
+ }
599
+
600
+ private fun cellView(node: JSONObject, tableId: Int, rowIndex: Int, columnIndex: Int, path: List<Int>): View {
601
+ val density = resources.displayMetrics.density
602
+ val fillColor = if (node.optString("nodeType") == "tableHeader") 0xFFF4F5F7.toInt() else Color.WHITE
603
+ val cell = CellView(context).apply {
604
+ docPos = node.optInt("docPos", 0)
605
+ this.tableId = tableId
606
+ this.rowIndex = rowIndex
607
+ this.columnIndex = columnIndex
608
+ baseFillColor = fillColor
609
+ orientation = LinearLayout.VERTICAL
610
+ setPadding((8 * density).toInt(), (8 * density).toInt(), (8 * density).toInt(), (8 * density).toInt())
611
+ background = borderDrawable(
612
+ fillColor,
613
+ Color.LTGRAY,
614
+ 0.5f
615
+ )
616
+ isClickable = true
617
+ setOnClickListener { selectCell(this, this) }
618
+ }
619
+ tableCells.add(cell)
620
+ val children = node.optJSONArray("children")
621
+ for (index in 0 until (children?.length() ?: 0)) {
622
+ children?.optJSONObject(index)?.let { cell.addView(viewForNode(it, 0, path + index)) }
623
+ }
624
+ if (cell.childCount == 0) {
625
+ cell.addView(textBlockView(node, 0))
626
+ }
627
+ return cell
628
+ }
629
+
630
+ private fun textBlockView(node: JSONObject, depth: Int): View {
631
+ val rendered = attributedInlineContent(node)
632
+ val leaf = TextLeaf(context).apply leaf@ {
633
+ text = rendered.first
634
+ positionSegments = if (rendered.second.isEmpty()) {
635
+ listOf(PositionSegment(0, 0, "", node.optInt("docPos", 0), node.optInt("docSize", 0), false))
636
+ } else {
637
+ rendered.second
638
+ }
639
+ setTextColor(baseTextColor)
640
+ setTextSize(TypedValue.COMPLEX_UNIT_PX, textSizeFor(node))
641
+ typeface = typefaceFor(node)
642
+ minLines = 1
643
+ background = if (node.optString("nodeType") == "codeBlock" || node.optString("nodeType") == "code_block") {
644
+ roundedDrawable(
645
+ currentTheme?.codeBlock?.backgroundColor ?: 0xFFF4F5F7.toInt(),
646
+ Color.TRANSPARENT,
647
+ 0f,
648
+ currentTheme?.codeBlock?.borderRadius ?: 8f
649
+ )
650
+ } else {
651
+ null
652
+ }
653
+ if (node.optString("nodeType") == "codeBlock" || node.optString("nodeType") == "code_block") {
654
+ val horizontal = (((currentTheme?.codeBlock?.paddingHorizontal ?: 12f) * resources.displayMetrics.density).toInt())
655
+ val vertical = (((currentTheme?.codeBlock?.paddingVertical ?: 8f) * resources.displayMetrics.density).toInt())
656
+ setPadding(horizontal, vertical, horizontal, vertical)
657
+ } else {
658
+ setPadding(0, 0, 0, 0)
659
+ }
660
+ isFocusable = false
661
+ isFocusableInTouchMode = false
662
+ setTextIsSelectable(false)
663
+ setOnTouchListener { view, event ->
664
+ if (event.actionMasked != MotionEvent.ACTION_UP || !this@NativeBlockEditorSurface.isEditable) {
665
+ return@setOnTouchListener true
666
+ }
667
+ val textView = view as TextLeaf
668
+ textView.docPositionForOffset(textView.getOffsetForPosition(event.x, event.y))
669
+ ?.let { onRequestTextInput?.invoke(it) }
670
+ true
671
+ }
672
+ }
673
+ return leaf
674
+ }
675
+
676
+ private fun positionSegmentsFor(node: JSONObject): List<PositionSegment> {
677
+ val segments = attributedInlineContent(node).second
678
+ return if (segments.isEmpty()) {
679
+ listOf(PositionSegment(0, 0, "", node.optInt("docPos", 0), node.optInt("docSize", 0), false))
680
+ } else {
681
+ segments
682
+ }
683
+ }
684
+
685
+ private fun attributedInlineContent(node: JSONObject): Pair<SpannableStringBuilder, List<PositionSegment>> {
686
+ val output = SpannableStringBuilder()
687
+ val segments = mutableListOf<PositionSegment>()
688
+
689
+ fun append(current: JSONObject) {
690
+ val kind = current.optString("kind")
691
+ val docPos = current.optInt("docPos", 0)
692
+ val docSize = current.optInt("docSize", 0)
693
+ if (kind == "text") {
694
+ val value = current.optString("text", "")
695
+ val start = output.length
696
+ output.append(value)
697
+ applyInlineSpans(output, start, output.length, current)
698
+ segments += PositionSegment(start, output.length, value, docPos, docSize, false)
699
+ return
700
+ }
701
+ if (kind == "inlineAtom") {
702
+ val nodeType = current.optString("nodeType")
703
+ val value = if (nodeType == "hardBreak" || nodeType == "hard_break") {
704
+ "\n"
705
+ } else {
706
+ current.optString("label", "\uFFFC")
707
+ }
708
+ val start = output.length
709
+ output.append(value)
710
+ if (nodeType == "mention") {
711
+ output.setSpan(ForegroundColorSpan(currentTheme?.list?.markerColor ?: baseTextColor), start, output.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
712
+ output.setSpan(BackgroundColorSpan(0x1F2F80ED), start, output.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
713
+ }
714
+ segments += PositionSegment(start, output.length, value, docPos, maxOf(docSize, 1), true)
715
+ return
716
+ }
717
+ val children = current.optJSONArray("children") ?: return
718
+ for (index in 0 until children.length()) {
719
+ children.optJSONObject(index)?.let(::append)
720
+ }
721
+ }
722
+
723
+ append(node)
724
+ return output to segments
725
+ }
726
+
727
+ private fun applyInlineSpans(
728
+ output: SpannableStringBuilder,
729
+ start: Int,
730
+ end: Int,
731
+ node: JSONObject
732
+ ) {
733
+ if (start == end) return
734
+ val marks = node.optJSONArray("marks")
735
+ val markTypes = buildSet {
736
+ for (index in 0 until (marks?.length() ?: 0)) {
737
+ marks?.optJSONObject(index)?.optString("type")?.let(::add)
738
+ }
739
+ }
740
+ val style = when {
741
+ "bold" in markTypes && "italic" in markTypes -> Typeface.BOLD_ITALIC
742
+ "bold" in markTypes -> Typeface.BOLD
743
+ "italic" in markTypes -> Typeface.ITALIC
744
+ else -> null
745
+ }
746
+ style?.let { output.setSpan(StyleSpan(it), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) }
747
+ if ("underline" in markTypes || "link" in markTypes) {
748
+ output.setSpan(UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
749
+ }
750
+ if ("strike" in markTypes) {
751
+ output.setSpan(StrikethroughSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
752
+ }
753
+ if ("link" in markTypes) {
754
+ output.setSpan(ForegroundColorSpan(0xFF2F80ED.toInt()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
755
+ }
756
+ if ("code" in markTypes) {
757
+ output.setSpan(TypefaceSpan("monospace"), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
758
+ }
759
+ }
760
+
761
+ private fun atomView(node: JSONObject): View {
762
+ if (isHorizontalRule(node)) {
763
+ val density = resources.displayMetrics.density
764
+ val margin = ((currentTheme?.horizontalRule?.verticalMargin ?: 16f) * density).toInt()
765
+ val thickness = ((currentTheme?.horizontalRule?.thickness ?: 1f) * density).toInt().coerceAtLeast(1)
766
+ return LinearLayout(context).apply {
767
+ orientation = LinearLayout.VERTICAL
768
+ setPadding(0, margin, 0, margin)
769
+ addView(
770
+ View(context).apply {
771
+ setBackgroundColor(currentTheme?.horizontalRule?.color ?: Color.LTGRAY)
772
+ },
773
+ LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, thickness)
774
+ )
775
+ }
776
+ }
777
+ if (node.optString("nodeType") == "image") {
778
+ return imageView(node)
779
+ }
780
+ return TextView(context).apply {
781
+ text = node.optString("label", node.optString("nodeType", "Block"))
782
+ setTextColor(Color.DKGRAY)
783
+ textSize = 14f
784
+ setPadding(12, 8, 12, 8)
785
+ background = borderDrawable(0xFFF2F3F5.toInt(), Color.TRANSPARENT, 0f)
786
+ }
787
+ }
788
+
789
+ private fun imageView(node: JSONObject): View {
790
+ val density = resources.displayMetrics.density
791
+ val attrs = node.optJSONObject("attrs")
792
+ val source = attrs?.optString("src", "").orEmpty()
793
+ val imageView = BlockImageView(context).apply {
794
+ docPos = node.optInt("docPos", 0)
795
+ preferredWidthPx = attrs?.optDouble("width", Double.NaN)
796
+ ?.takeIf { it.isFinite() && it > 0 }
797
+ ?.let { (it * density).toInt() }
798
+ preferredHeightPx = attrs?.optDouble("height", Double.NaN)
799
+ ?.takeIf { it.isFinite() && it > 0 }
800
+ ?.let { (it * density).toInt() }
801
+ scaleType = ImageView.ScaleType.FIT_CENTER
802
+ adjustViewBounds = true
803
+ background = roundedDrawable(0xFFF2F3F5.toInt(), Color.TRANSPARENT, 0f, 8f)
804
+ isClickable = true
805
+ setOnClickListener { selectImage(this) }
806
+ }
807
+ NativeImageLoader.load(source) { bitmap ->
808
+ imageView.post {
809
+ imageView.setImageBitmap(bitmap)
810
+ imageView.requestLayout()
811
+ imageView.invalidate()
812
+ }
813
+ }
814
+ return imageView
815
+ }
816
+
817
+ private fun selectImage(imageView: BlockImageView) {
818
+ if (!isEditable || currentEditorId == 0L) return
819
+ selectedNodePos = imageView.docPos
820
+ editorSetNodeSelection(currentEditorId.toULong(), imageView.docPos.toUInt())
821
+ refreshImageSelection()
822
+ onAppliedUpdate?.invoke(editorGetSelectionState(currentEditorId.toULong()))
823
+ }
824
+
825
+ private fun refreshImageSelection() {
826
+ for (imageView in imageViews) {
827
+ val selected = isEditable && imageView.docPos == selectedNodePos
828
+ imageView.background = roundedDrawable(
829
+ if (imageView.drawable == null) 0xFFF2F3F5.toInt() else Color.TRANSPARENT,
830
+ if (selected) 0xFF2F80ED.toInt() else Color.TRANSPARENT,
831
+ if (selected) 2f else 0f,
832
+ 8f
833
+ )
834
+ }
835
+ }
836
+
837
+ internal fun selectedImageGeometry(): EditorEditText.SelectedImageGeometry? {
838
+ val docPos = selectedNodePos ?: return null
839
+ val imageView = imageViews.firstOrNull { it.docPos == docPos } ?: return null
840
+ if (!isEditable || imageView.width <= 0 || imageView.height <= 0) return null
841
+ val rect = Rect(0, 0, imageView.width, imageView.height)
842
+ offsetDescendantRectToMyCoords(imageView, rect)
843
+ return EditorEditText.SelectedImageGeometry(docPos, RectF(rect))
844
+ }
845
+
846
+ internal fun selectedCaretRect(): RectF? =
847
+ selectedTextPos?.let(::caretRectForDocPosition)
848
+
849
+ internal fun caretRectForDocPosition(docPosition: Int): RectF? {
850
+ val caretWidth = maxOf(2f, resources.displayMetrics.density)
851
+ for (leaf in textLeafViews) {
852
+ val local = leaf.caretRectForDocPosition(docPosition, caretWidth) ?: continue
853
+ return descendantRectInSurface(leaf, local)
854
+ }
855
+ return null
856
+ }
857
+
858
+ internal fun selectionRectsForDocRange(anchor: Int, head: Int): List<RectF> {
859
+ val from = minOf(anchor, head)
860
+ val to = maxOf(anchor, head)
861
+ if (from == to) return emptyList()
862
+ return buildList {
863
+ for (leaf in textLeafViews) {
864
+ val first = leaf.positionSegments.firstOrNull()?.docPos ?: continue
865
+ val lastSegment = leaf.positionSegments.lastOrNull() ?: continue
866
+ val last = lastSegment.docPos + lastSegment.docSize
867
+ val localFrom = maxOf(from, first)
868
+ val localTo = minOf(to, last)
869
+ if (localFrom >= localTo) continue
870
+ leaf.selectionRectsForDocRange(localFrom, localTo).forEach {
871
+ add(descendantRectInSurface(leaf, it))
872
+ }
873
+ }
874
+ }
875
+ }
876
+
877
+ private fun descendantRectInSurface(descendant: View, local: RectF): RectF {
878
+ val origin = Rect(0, 0, 0, 0)
879
+ offsetDescendantRectToMyCoords(descendant, origin)
880
+ return RectF(
881
+ local.left + origin.left,
882
+ local.top + origin.top,
883
+ local.right + origin.left,
884
+ local.bottom + origin.top
885
+ )
886
+ }
887
+
888
+ internal fun maximumImageWidthPx(): Float =
889
+ (width - paddingLeft - paddingRight).coerceAtLeast(48).toFloat()
890
+
891
+ internal fun resizeImage(docPos: Int, widthPx: Float, heightPx: Float) {
892
+ if (!isEditable || currentEditorId == 0L) return
893
+ val density = resources.displayMetrics.density
894
+ val widthDp = maxOf(48, (widthPx / density).toInt())
895
+ val heightDp = maxOf(48, (heightPx / density).toInt())
896
+ val updateJSON = editorResizeImageAtDocPos(
897
+ currentEditorId.toULong(),
898
+ docPos.toUInt(),
899
+ widthDp.toUInt(),
900
+ heightDp.toUInt()
901
+ )
902
+ applyUpdateJSON(updateJSON, currentEditorId)
903
+ onAppliedUpdate?.invoke(updateJSON)
904
+ }
905
+
906
+ private data class TextInfo(val text: String, val docFrom: Int?, val docTo: Int?)
907
+
908
+ private fun textRange(node: JSONObject): TextInfo {
909
+ val builder = StringBuilder()
910
+ var docFrom: Int? = null
911
+ var docTo: Int? = null
912
+ appendTextRange(node, builder) { from, to ->
913
+ if (docFrom == null) docFrom = from
914
+ docTo = to
915
+ }
916
+ return TextInfo(builder.toString(), docFrom, docTo)
917
+ }
918
+
919
+ private fun appendTextRange(
920
+ node: JSONObject,
921
+ builder: StringBuilder,
922
+ onTextRange: (Int?, Int?) -> Unit
923
+ ) {
924
+ if (node.optString("kind") == "text") {
925
+ val text = node.optString("text", "")
926
+ val from = if (node.has("docPos")) node.optInt("docPos") else null
927
+ builder.append(text)
928
+ onTextRange(from, from?.plus(text.codePointCount(0, text.length)))
929
+ return
930
+ }
931
+ val children = node.optJSONArray("children") ?: return
932
+ for (index in 0 until children.length()) {
933
+ val child = children.optJSONObject(index) ?: continue
934
+ appendTextRange(child, builder, onTextRange)
935
+ }
936
+ }
937
+
938
+ private fun handleTableTouch(event: MotionEvent): Boolean {
939
+ if (!isEditable) return false
940
+ val cell = cellAtRaw(event.rawX.toInt(), event.rawY.toInt()) ?: return false
941
+ when (event.actionMasked) {
942
+ MotionEvent.ACTION_DOWN -> {
943
+ parent?.requestDisallowInterceptTouchEvent(true)
944
+ selectionAnchorCell = cell
945
+ selectCell(cell, cell)
946
+ return true
947
+ }
948
+ MotionEvent.ACTION_MOVE -> {
949
+ selectionAnchorCell?.let { selectCell(it, cell) }
950
+ return true
951
+ }
952
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
953
+ selectionAnchorCell?.let { selectCell(it, cell) }
954
+ parent?.requestDisallowInterceptTouchEvent(false)
955
+ return true
956
+ }
957
+ }
958
+ return false
959
+ }
960
+
961
+ private fun cellAtRaw(rawX: Int, rawY: Int): CellView? {
962
+ val location = IntArray(2)
963
+ return tableCells.firstOrNull { cell ->
964
+ cell.getLocationOnScreen(location)
965
+ rawX >= location[0] &&
966
+ rawX <= location[0] + cell.width &&
967
+ rawY >= location[1] &&
968
+ rawY <= location[1] + cell.height
969
+ }
970
+ }
971
+
972
+ private fun selectCell(anchor: CellView, head: CellView) {
973
+ if (!isEditable || currentEditorId == 0L || anchor.docPos <= 0 || head.docPos <= 0) return
974
+ selectionAnchorCell = anchor
975
+ selectionHeadCell = head
976
+ editorSetCellSelection(currentEditorId.toULong(), anchor.docPos.toUInt(), head.docPos.toUInt())
977
+ paintCellSelection(anchor, head)
978
+ onAppliedUpdate?.invoke(editorGetSelectionState(currentEditorId.toULong()))
979
+ }
980
+
981
+ private fun paintCellSelection(anchor: CellView, head: CellView) {
982
+ val sameTable = anchor.tableId == head.tableId
983
+ val minRow = minOf(anchor.rowIndex, head.rowIndex)
984
+ val maxRow = maxOf(anchor.rowIndex, head.rowIndex)
985
+ val minColumn = minOf(anchor.columnIndex, head.columnIndex)
986
+ val maxColumn = maxOf(anchor.columnIndex, head.columnIndex)
987
+ tableCells.forEach { cell ->
988
+ val selected = if (sameTable) {
989
+ cell.tableId == anchor.tableId &&
990
+ cell.rowIndex in minRow..maxRow &&
991
+ cell.columnIndex in minColumn..maxColumn
992
+ } else {
993
+ cell == anchor || cell == head
994
+ }
995
+ cell.background = borderDrawable(
996
+ if (selected) 0xFFE8F1FF.toInt() else cell.baseFillColor,
997
+ if (selected) 0xFF2F80ED.toInt() else Color.LTGRAY,
998
+ if (selected) 1.5f else 0.5f
999
+ )
1000
+ }
1001
+ }
1002
+
1003
+ private fun typefaceFor(node: JSONObject): Typeface =
1004
+ when (node.optString("nodeType")) {
1005
+ "h1", "h2", "h3", "h4", "h5", "h6" -> Typeface.DEFAULT_BOLD
1006
+ "codeBlock", "code_block" -> Typeface.MONOSPACE
1007
+ else -> Typeface.DEFAULT
1008
+ }
1009
+
1010
+ private fun textSizeFor(node: JSONObject): Float =
1011
+ when (node.optString("nodeType")) {
1012
+ "h1" -> spToPx(28f)
1013
+ "h2" -> spToPx(24f)
1014
+ "h3" -> spToPx(21f)
1015
+ "h4" -> spToPx(19f)
1016
+ "h5" -> spToPx(17f)
1017
+ "h6" -> spToPx(16f)
1018
+ else -> baseTextSizePx
1019
+ }
1020
+
1021
+ private fun spToPx(value: Float): Float =
1022
+ TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, value, resources.displayMetrics)
1023
+
1024
+ private fun listMarker(nodeType: String?, index: Int): String =
1025
+ when (nodeType) {
1026
+ "orderedList", "ordered_list" -> "$index."
1027
+ "taskList", "task_list" -> "☐"
1028
+ else -> "•"
1029
+ }
1030
+
1031
+ private fun markerView(node: JSONObject, parentListType: String?, index: Int): View {
1032
+ if (parentListType != "taskList" && parentListType != "task_list") {
1033
+ return TextView(context).apply {
1034
+ text = listMarker(parentListType, index)
1035
+ setTextColor(currentTheme?.list?.markerColor ?: baseTextColor)
1036
+ setTextSize(TypedValue.COMPLEX_UNIT_PX, baseTextSizePx)
1037
+ gravity = android.view.Gravity.CENTER_VERTICAL or android.view.Gravity.RIGHT
1038
+ includeFontPadding = false
1039
+ }
1040
+ }
1041
+ val checked = node.optJSONObject("attrs")?.optBoolean("checked", false) ?: false
1042
+ return TextView(context).apply {
1043
+ text = if (checked) "✓" else ""
1044
+ setTextColor(Color.WHITE)
1045
+ textSize = 12f
1046
+ gravity = android.view.Gravity.CENTER
1047
+ includeFontPadding = false
1048
+ background = roundedDrawable(
1049
+ if (checked) (currentTheme?.list?.markerColor ?: baseTextColor) else Color.TRANSPARENT,
1050
+ currentTheme?.list?.markerColor ?: Color.GRAY,
1051
+ 1.5f,
1052
+ 4f
1053
+ )
1054
+ setOnClickListener { toggleTaskItem(node) }
1055
+ }
1056
+ }
1057
+
1058
+ private fun toggleTaskItem(node: JSONObject) {
1059
+ if (!isEditable || currentEditorId == 0L) return
1060
+ val textInfo = textRange(node)
1061
+ val docPos = textInfo.docFrom ?: (node.optInt("docPos", 0) + 1)
1062
+ val scalar = editorDocToScalar(currentEditorId.toULong(), docPos.toUInt())
1063
+ val updateJSON = editorToggleTaskItemCheckedAtSelectionScalar(
1064
+ currentEditorId.toULong(),
1065
+ scalar,
1066
+ scalar
1067
+ )
1068
+ applyUpdateJSON(updateJSON, currentEditorId)
1069
+ onAppliedUpdate?.invoke(updateJSON)
1070
+ }
1071
+
1072
+ private fun isHorizontalRule(node: JSONObject): Boolean =
1073
+ node.optString("nodeType") == "horizontalRule" || node.optString("nodeType") == "horizontal_rule"
1074
+
1075
+ private fun borderDrawable(fillColor: Int, strokeColor: Int, strokeDp: Float): GradientDrawable =
1076
+ roundedDrawable(fillColor, strokeColor, strokeDp, 0f)
1077
+
1078
+ private fun roundedDrawable(fillColor: Int, strokeColor: Int, strokeDp: Float, radiusDp: Float): GradientDrawable =
1079
+ GradientDrawable().apply {
1080
+ setColor(fillColor)
1081
+ cornerRadius = radiusDp * resources.displayMetrics.density
1082
+ if (strokeDp > 0f) {
1083
+ setStroke((strokeDp * resources.displayMetrics.density).toInt().coerceAtLeast(1), strokeColor)
1084
+ }
1085
+ }
1086
+ }