@openeditor/react-native-prose-editor 0.0.7 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/build.gradle +35 -0
- package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +2 -2
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +138 -597
- package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +13 -3
- package/android/src/main/java/com/apollohg/editor/InputSnapshotSupport.kt +6 -0
- package/android/src/main/java/com/apollohg/editor/NativeBlockEditorSurface.kt +1086 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +1 -1
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +26 -50
- package/android/src/main/java/com/apollohg/editor/NativeImageLoader.kt +89 -0
- package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +31 -66
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +98 -33
- package/dist/EditorToolbar.d.ts +1 -1
- package/dist/EditorToolbar.js +19 -0
- package/dist/NativeEditorBridge.d.ts +62 -35
- package/dist/NativeEditorBridge.js +122 -79
- package/dist/NativeProseViewer.d.ts +13 -34
- package/dist/NativeProseViewer.js +40 -465
- package/dist/NativeRichTextEditor.d.ts +10 -1
- package/dist/NativeRichTextEditor.js +72 -34
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/schemas.d.ts +11 -0
- package/dist/schemas.js +149 -0
- package/dist/useNativeEditor.d.ts +3 -3
- package/dist/useNativeEditor.js +3 -3
- 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/Generated_editor_core.swift +97 -2
- package/ios/NativeBlockEditorSurface.swift +1393 -0
- package/ios/NativeEditorExpoView.swift +3 -3
- package/ios/NativeEditorModule.swift +30 -45
- package/ios/NativeInputSupport.swift +396 -0
- package/ios/PositionBridge.swift +4 -4
- package/ios/RichTextEditorView.swift +333 -786
- package/ios/editor_coreFFI/editor_coreFFI.h +147 -70
- package/package.json +1 -1
- 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 +2087 -1399
- package/android/src/androidTest/AndroidManifest.xml +0 -8
- package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +0 -53
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +0 -241
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +0 -338
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +0 -789
- package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +0 -350
- package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +0 -5
- package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +0 -315
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +0 -2172
- package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +0 -482
- package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +0 -137
- package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +0 -321
- package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +0 -4202
- package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +0 -2581
- package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +0 -27
- package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +0 -197
- package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +0 -84
- package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +0 -544
- package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +0 -461
- package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +0 -2054
- package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +0 -1367
- package/ios/EditorLayoutManager.swift +0 -774
- package/ios/NativeProseViewerExpoView.swift +0 -276
- package/ios/RenderBridge.swift +0 -1708
- package/ios/Tests/NativePerformanceTests.swift +0 -687
- package/ios/Tests/PositionBridgeTests.swift +0 -706
- package/ios/Tests/RenderBridgeTests.swift +0 -2236
- 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
|
+
}
|