@openeditor/react-native-prose-editor 0.0.2

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 (91) hide show
  1. package/LICENSE +160 -0
  2. package/README.md +180 -0
  3. package/android/build.gradle +69 -0
  4. package/android/consumer-rules.pro +18 -0
  5. package/android/src/androidTest/AndroidManifest.xml +8 -0
  6. package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +53 -0
  7. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +241 -0
  8. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +338 -0
  9. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +789 -0
  10. package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +350 -0
  11. package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +5 -0
  12. package/android/src/main/assets/editor-icons/MaterialIcons.json +2236 -0
  13. package/android/src/main/assets/editor-icons/MaterialIcons.ttf +0 -0
  14. package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +50 -0
  15. package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +135 -0
  16. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +4527 -0
  17. package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
  18. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +999 -0
  19. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +474 -0
  20. package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
  21. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +3137 -0
  22. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +561 -0
  23. package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +315 -0
  24. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +1413 -0
  25. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +133 -0
  26. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +315 -0
  27. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +2172 -0
  28. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +399 -0
  29. package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +482 -0
  30. package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +137 -0
  31. package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +321 -0
  32. package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +4202 -0
  33. package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +2581 -0
  34. package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +27 -0
  35. package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +197 -0
  36. package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +84 -0
  37. package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +544 -0
  38. package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +461 -0
  39. package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +2054 -0
  40. package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +1367 -0
  41. package/app.plugin.js +62 -0
  42. package/dist/EditorTheme.d.ts +113 -0
  43. package/dist/EditorTheme.js +29 -0
  44. package/dist/EditorToolbar.d.ts +191 -0
  45. package/dist/EditorToolbar.js +1095 -0
  46. package/dist/NativeEditorBridge.d.ts +325 -0
  47. package/dist/NativeEditorBridge.js +933 -0
  48. package/dist/NativeProseViewer.d.ts +53 -0
  49. package/dist/NativeProseViewer.js +480 -0
  50. package/dist/NativeRichTextEditor.d.ts +183 -0
  51. package/dist/NativeRichTextEditor.js +2574 -0
  52. package/dist/YjsCollaboration.d.ts +90 -0
  53. package/dist/YjsCollaboration.js +743 -0
  54. package/dist/addons.d.ts +100 -0
  55. package/dist/addons.js +82 -0
  56. package/dist/heightCache.d.ts +6 -0
  57. package/dist/heightCache.js +45 -0
  58. package/dist/index.d.ts +10 -0
  59. package/dist/index.js +30 -0
  60. package/dist/schemas.d.ts +37 -0
  61. package/dist/schemas.js +263 -0
  62. package/dist/useNativeEditor.d.ts +42 -0
  63. package/dist/useNativeEditor.js +124 -0
  64. package/expo-module.config.json +9 -0
  65. package/ios/EditorAddons.swift +327 -0
  66. package/ios/EditorCore.xcframework/Info.plist +44 -0
  67. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  68. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  69. package/ios/EditorLayoutManager.swift +774 -0
  70. package/ios/EditorTheme.swift +515 -0
  71. package/ios/Generated_editor_core.swift +1513 -0
  72. package/ios/NativeEditorExpoView.swift +3593 -0
  73. package/ios/NativeEditorModule.swift +624 -0
  74. package/ios/NativeProseViewerExpoView.swift +276 -0
  75. package/ios/PositionBridge.swift +558 -0
  76. package/ios/ReactNativeProseEditor.podspec +49 -0
  77. package/ios/RenderBridge.swift +1708 -0
  78. package/ios/RichTextEditorView.swift +6150 -0
  79. package/ios/Tests/NativePerformanceTests.swift +687 -0
  80. package/ios/Tests/PositionBridgeTests.swift +706 -0
  81. package/ios/Tests/RenderBridgeTests.swift +2236 -0
  82. package/ios/Tests/RichTextEditorViewTests.swift +6548 -0
  83. package/ios/editor_coreFFI/editor_coreFFI.h +1289 -0
  84. package/ios/editor_coreFFI/module.modulemap +7 -0
  85. package/ios/editor_coreFFI.h +904 -0
  86. package/ios/editor_coreFFI.modulemap +7 -0
  87. package/package.json +74 -0
  88. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  89. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  90. package/rust/android/x86_64/libeditor_core.so +0 -0
  91. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2563 -0
@@ -0,0 +1,1413 @@
1
+ package com.openeditor.editor
2
+
3
+ import android.content.Context
4
+ import android.os.Build
5
+ import android.graphics.Color
6
+ import android.graphics.Typeface
7
+ import android.graphics.drawable.GradientDrawable
8
+ import android.util.TypedValue
9
+ import android.view.Gravity
10
+ import android.view.View
11
+ import android.view.ViewOutlineProvider
12
+ import android.widget.HorizontalScrollView
13
+ import android.widget.LinearLayout
14
+ import androidx.appcompat.R as AppCompatR
15
+ import androidx.appcompat.widget.AppCompatButton
16
+ import androidx.appcompat.widget.PopupMenu
17
+ import androidx.appcompat.widget.AppCompatTextView
18
+ import androidx.appcompat.content.res.AppCompatResources
19
+ import androidx.core.view.setPadding
20
+ import com.google.android.material.R as MaterialR
21
+ import com.google.android.material.color.DynamicColors
22
+ import org.json.JSONObject
23
+ import kotlin.math.roundToInt
24
+
25
+ internal data class NativeToolbarState(
26
+ val marks: Map<String, Boolean>,
27
+ val nodes: Map<String, Boolean>,
28
+ val commands: Map<String, Boolean>,
29
+ val allowedMarks: Set<String>,
30
+ val insertableNodes: Set<String>,
31
+ val canUndo: Boolean,
32
+ val canRedo: Boolean
33
+ ) {
34
+ companion object {
35
+ val empty = NativeToolbarState(
36
+ marks = emptyMap(),
37
+ nodes = emptyMap(),
38
+ commands = emptyMap(),
39
+ allowedMarks = emptySet(),
40
+ insertableNodes = emptySet(),
41
+ canUndo = false,
42
+ canRedo = false
43
+ )
44
+
45
+ fun fromUpdateJson(updateJson: String): NativeToolbarState? {
46
+ val root = try {
47
+ JSONObject(updateJson)
48
+ } catch (_: Exception) {
49
+ return null
50
+ }
51
+ val activeState = root.optJSONObject("activeState") ?: JSONObject()
52
+ val historyState = root.optJSONObject("historyState") ?: JSONObject()
53
+ return NativeToolbarState(
54
+ marks = boolMap(activeState.optJSONObject("marks")),
55
+ nodes = boolMap(activeState.optJSONObject("nodes")),
56
+ commands = boolMap(activeState.optJSONObject("commands")),
57
+ allowedMarks = stringSet(activeState.optJSONArray("allowedMarks")),
58
+ insertableNodes = stringSet(activeState.optJSONArray("insertableNodes")),
59
+ canUndo = historyState.optBoolean("canUndo", false),
60
+ canRedo = historyState.optBoolean("canRedo", false)
61
+ )
62
+ }
63
+
64
+ private fun boolMap(json: JSONObject?): Map<String, Boolean> {
65
+ json ?: return emptyMap()
66
+ val result = mutableMapOf<String, Boolean>()
67
+ val keys = json.keys()
68
+ while (keys.hasNext()) {
69
+ val key = keys.next()
70
+ result[key] = json.optBoolean(key, false)
71
+ }
72
+ return result
73
+ }
74
+
75
+ private fun stringSet(array: org.json.JSONArray?): Set<String> {
76
+ array ?: return emptySet()
77
+ val result = linkedSetOf<String>()
78
+ for (index in 0 until array.length()) {
79
+ array.optString(index, null)?.let { result.add(it) }
80
+ }
81
+ return result
82
+ }
83
+ }
84
+ }
85
+
86
+ internal enum class ToolbarCommand {
87
+ indentList,
88
+ outdentList,
89
+ undo,
90
+ redo,
91
+ }
92
+
93
+ internal enum class ToolbarListType {
94
+ bulletList,
95
+ orderedList,
96
+ }
97
+
98
+ internal enum class ToolbarDefaultIconId {
99
+ bold,
100
+ italic,
101
+ underline,
102
+ strike,
103
+ link,
104
+ image,
105
+ h1,
106
+ h2,
107
+ h3,
108
+ h4,
109
+ h5,
110
+ h6,
111
+ blockquote,
112
+ bulletList,
113
+ orderedList,
114
+ indentList,
115
+ outdentList,
116
+ lineBreak,
117
+ horizontalRule,
118
+ undo,
119
+ redo,
120
+ }
121
+
122
+ internal enum class ToolbarItemKind {
123
+ mark,
124
+ heading,
125
+ blockquote,
126
+ list,
127
+ command,
128
+ node,
129
+ action,
130
+ group,
131
+ separator,
132
+ }
133
+
134
+ internal enum class ToolbarGroupPresentation {
135
+ expand,
136
+ menu,
137
+ }
138
+
139
+ internal enum class ToolbarItemPlacement {
140
+ start,
141
+ scroll,
142
+ end,
143
+ }
144
+
145
+ internal data class NativeToolbarIcon(
146
+ val defaultId: ToolbarDefaultIconId? = null,
147
+ val glyphText: String? = null,
148
+ val fallbackText: String? = null,
149
+ val materialIconName: String? = null
150
+ ) {
151
+ companion object {
152
+ private val defaultGlyphs = mapOf(
153
+ ToolbarDefaultIconId.bold to "B",
154
+ ToolbarDefaultIconId.italic to "I",
155
+ ToolbarDefaultIconId.underline to "U",
156
+ ToolbarDefaultIconId.strike to "S",
157
+ ToolbarDefaultIconId.link to "🔗",
158
+ ToolbarDefaultIconId.image to "🖼",
159
+ ToolbarDefaultIconId.h1 to "H1",
160
+ ToolbarDefaultIconId.h2 to "H2",
161
+ ToolbarDefaultIconId.h3 to "H3",
162
+ ToolbarDefaultIconId.h4 to "H4",
163
+ ToolbarDefaultIconId.h5 to "H5",
164
+ ToolbarDefaultIconId.h6 to "H6",
165
+ ToolbarDefaultIconId.blockquote to "❝",
166
+ ToolbarDefaultIconId.bulletList to "•≡",
167
+ ToolbarDefaultIconId.orderedList to "1.",
168
+ ToolbarDefaultIconId.indentList to "→",
169
+ ToolbarDefaultIconId.outdentList to "←",
170
+ ToolbarDefaultIconId.lineBreak to "↵",
171
+ ToolbarDefaultIconId.horizontalRule to "—",
172
+ ToolbarDefaultIconId.undo to "↩",
173
+ ToolbarDefaultIconId.redo to "↪"
174
+ )
175
+ private val defaultMaterialIcons = mapOf(
176
+ ToolbarDefaultIconId.bold to "format-bold",
177
+ ToolbarDefaultIconId.italic to "format-italic",
178
+ ToolbarDefaultIconId.underline to "format-underlined",
179
+ ToolbarDefaultIconId.strike to "strikethrough-s",
180
+ ToolbarDefaultIconId.link to "link",
181
+ ToolbarDefaultIconId.image to "image",
182
+ ToolbarDefaultIconId.blockquote to "format-quote",
183
+ ToolbarDefaultIconId.bulletList to "format-list-bulleted",
184
+ ToolbarDefaultIconId.orderedList to "format-list-numbered",
185
+ ToolbarDefaultIconId.indentList to "format-indent-increase",
186
+ ToolbarDefaultIconId.outdentList to "format-indent-decrease",
187
+ ToolbarDefaultIconId.lineBreak to "keyboard-return",
188
+ ToolbarDefaultIconId.horizontalRule to "horizontal-rule",
189
+ ToolbarDefaultIconId.h1 to "title",
190
+ ToolbarDefaultIconId.h2 to "title",
191
+ ToolbarDefaultIconId.h3 to "title",
192
+ ToolbarDefaultIconId.h4 to "title",
193
+ ToolbarDefaultIconId.h5 to "title",
194
+ ToolbarDefaultIconId.h6 to "title",
195
+ ToolbarDefaultIconId.undo to "undo",
196
+ ToolbarDefaultIconId.redo to "redo"
197
+ )
198
+
199
+ fun fromJson(raw: JSONObject?): NativeToolbarIcon? {
200
+ raw ?: return null
201
+ return when (raw.optString("type")) {
202
+ "default" -> {
203
+ val id = runCatching {
204
+ ToolbarDefaultIconId.valueOf(raw.getString("id"))
205
+ }.getOrNull() ?: return null
206
+ NativeToolbarIcon(defaultId = id)
207
+ }
208
+ "glyph" -> {
209
+ val text = raw.optString("text")
210
+ if (text.isBlank()) null else NativeToolbarIcon(glyphText = text)
211
+ }
212
+ "platform" -> {
213
+ val materialName = raw.optJSONObject("android")
214
+ ?.takeIf { it.optString("type") == "material" }
215
+ ?.optNullableString("name")
216
+ val fallback = raw.optNullableString("fallbackText")
217
+ if (materialName.isNullOrBlank() && fallback.isNullOrBlank()) {
218
+ null
219
+ } else {
220
+ NativeToolbarIcon(
221
+ fallbackText = fallback,
222
+ materialIconName = materialName
223
+ )
224
+ }
225
+ }
226
+ else -> null
227
+ }
228
+ }
229
+
230
+ fun defaultMaterialIconName(defaultId: ToolbarDefaultIconId?): String? =
231
+ defaultId?.let { defaultMaterialIcons[it] }
232
+ }
233
+
234
+ fun resolvedGlyphText(): String =
235
+ glyphText?.takeIf { it.isNotBlank() }
236
+ ?: fallbackText?.takeIf { it.isNotBlank() }
237
+ ?: defaultId?.let { defaultGlyphs[it] }
238
+ ?: "?"
239
+
240
+ fun resolvedMaterialIconName(): String? =
241
+ materialIconName?.takeIf { it.isNotBlank() }
242
+ ?: Companion.defaultMaterialIconName(defaultId)
243
+ }
244
+
245
+ internal object MaterialIconRegistry {
246
+ private const val FONT_ASSET_PATH = "editor-icons/MaterialIcons.ttf"
247
+ private const val GLYPHMAP_ASSET_PATH = "editor-icons/MaterialIcons.json"
248
+
249
+ @Volatile
250
+ private var typeface: Typeface? = null
251
+
252
+ @Volatile
253
+ private var glyphMap: Map<String, String>? = null
254
+
255
+ fun typeface(context: Context): Typeface? {
256
+ val cached = typeface
257
+ if (cached != null) return cached
258
+ return runCatching {
259
+ Typeface.createFromAsset(context.assets, FONT_ASSET_PATH)
260
+ }.getOrNull()?.also { loaded ->
261
+ typeface = loaded
262
+ }
263
+ }
264
+
265
+ fun glyphForName(context: Context, name: String?): String? {
266
+ if (name.isNullOrBlank()) return null
267
+ val map = glyphMap ?: loadGlyphMap(context).also { loaded ->
268
+ glyphMap = loaded
269
+ }
270
+ return map[name]
271
+ }
272
+
273
+ private fun loadGlyphMap(context: Context): Map<String, String> {
274
+ val assetText = runCatching {
275
+ context.assets.open(GLYPHMAP_ASSET_PATH).bufferedReader().use { it.readText() }
276
+ }.getOrNull() ?: return emptyMap()
277
+
278
+ val json = runCatching { JSONObject(assetText) }.getOrNull() ?: return emptyMap()
279
+ val result = linkedMapOf<String, String>()
280
+ val keys = json.keys()
281
+ while (keys.hasNext()) {
282
+ val key = keys.next()
283
+ val codePoint = json.optInt(key, -1)
284
+ if (codePoint > 0) {
285
+ result[key] = String(Character.toChars(codePoint))
286
+ }
287
+ }
288
+ return result
289
+ }
290
+ }
291
+
292
+ internal data class NativeToolbarResolvedIcon(
293
+ val text: String,
294
+ val typeface: Typeface? = null
295
+ )
296
+
297
+ private fun NativeToolbarIcon.resolveForAndroid(context: Context): NativeToolbarResolvedIcon {
298
+ val materialName = resolvedMaterialIconName()
299
+ val materialGlyph = MaterialIconRegistry.glyphForName(context, materialName)
300
+ val materialTypeface = MaterialIconRegistry.typeface(context)
301
+ if (materialGlyph != null && materialTypeface != null) {
302
+ return NativeToolbarResolvedIcon(
303
+ text = materialGlyph,
304
+ typeface = materialTypeface
305
+ )
306
+ }
307
+
308
+ return NativeToolbarResolvedIcon(
309
+ text = resolvedGlyphText(),
310
+ typeface = null
311
+ )
312
+ }
313
+
314
+ internal data class NativeToolbarItem(
315
+ val type: ToolbarItemKind,
316
+ val key: String? = null,
317
+ val label: String? = null,
318
+ val icon: NativeToolbarIcon? = null,
319
+ val mark: String? = null,
320
+ val headingLevel: Int? = null,
321
+ val listType: ToolbarListType? = null,
322
+ val command: ToolbarCommand? = null,
323
+ val nodeType: String? = null,
324
+ val isActive: Boolean = false,
325
+ val isDisabled: Boolean = false,
326
+ val placement: ToolbarItemPlacement? = null,
327
+ val presentation: ToolbarGroupPresentation? = null,
328
+ val items: List<NativeToolbarItem> = emptyList(),
329
+ val parentGroupKey: String? = null
330
+ ) {
331
+ companion object {
332
+ val defaults = listOf(
333
+ NativeToolbarItem(ToolbarItemKind.mark, label = "Bold", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.bold), mark = "bold"),
334
+ NativeToolbarItem(ToolbarItemKind.mark, label = "Italic", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.italic), mark = "italic"),
335
+ NativeToolbarItem(ToolbarItemKind.mark, label = "Underline", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.underline), mark = "underline"),
336
+ NativeToolbarItem(ToolbarItemKind.mark, label = "Strikethrough", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.strike), mark = "strike"),
337
+ NativeToolbarItem(ToolbarItemKind.blockquote, label = "Blockquote", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.blockquote)),
338
+ NativeToolbarItem(ToolbarItemKind.separator),
339
+ NativeToolbarItem(ToolbarItemKind.list, label = "Bullet List", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.bulletList), listType = ToolbarListType.bulletList),
340
+ NativeToolbarItem(ToolbarItemKind.list, label = "Ordered List", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.orderedList), listType = ToolbarListType.orderedList),
341
+ NativeToolbarItem(ToolbarItemKind.command, label = "Indent List", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.indentList), command = ToolbarCommand.indentList),
342
+ NativeToolbarItem(ToolbarItemKind.command, label = "Outdent List", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.outdentList), command = ToolbarCommand.outdentList),
343
+ NativeToolbarItem(ToolbarItemKind.node, label = "Line Break", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.lineBreak), nodeType = "hardBreak"),
344
+ NativeToolbarItem(ToolbarItemKind.node, label = "Horizontal Rule", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.horizontalRule), nodeType = "horizontalRule"),
345
+ NativeToolbarItem(ToolbarItemKind.separator),
346
+ NativeToolbarItem(ToolbarItemKind.command, label = "Undo", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.undo), command = ToolbarCommand.undo),
347
+ NativeToolbarItem(ToolbarItemKind.command, label = "Redo", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.redo), command = ToolbarCommand.redo)
348
+ )
349
+
350
+ private fun parseItem(
351
+ rawItem: JSONObject,
352
+ allowGroup: Boolean = true,
353
+ allowSeparator: Boolean = true
354
+ ): NativeToolbarItem? {
355
+ val type = runCatching {
356
+ ToolbarItemKind.valueOf(rawItem.getString("type"))
357
+ }.getOrNull() ?: return null
358
+ val key = rawItem.optNullableString("key")
359
+ return when (type) {
360
+ ToolbarItemKind.separator -> {
361
+ if (!allowSeparator) {
362
+ null
363
+ } else {
364
+ NativeToolbarItem(
365
+ type = type,
366
+ key = key,
367
+ placement = rawItem.optNullableString("placement")?.let {
368
+ runCatching { ToolbarItemPlacement.valueOf(it) }.getOrNull()
369
+ }
370
+ )
371
+ }
372
+ }
373
+ ToolbarItemKind.mark -> {
374
+ val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
375
+ val mark = rawItem.optNullableString("mark") ?: return null
376
+ val label = rawItem.optNullableString("label") ?: return null
377
+ NativeToolbarItem(
378
+ type,
379
+ key,
380
+ label,
381
+ icon,
382
+ mark = mark,
383
+ placement = rawItem.optNullableString("placement")?.let {
384
+ runCatching { ToolbarItemPlacement.valueOf(it) }.getOrNull()
385
+ }
386
+ )
387
+ }
388
+ ToolbarItemKind.heading -> {
389
+ val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
390
+ val level = rawItem.optInt("level", -1)
391
+ if (level !in 1..6) return null
392
+ val label = rawItem.optNullableString("label") ?: return null
393
+ NativeToolbarItem(
394
+ type,
395
+ key,
396
+ label,
397
+ icon,
398
+ headingLevel = level,
399
+ placement = rawItem.optNullableString("placement")?.let {
400
+ runCatching { ToolbarItemPlacement.valueOf(it) }.getOrNull()
401
+ }
402
+ )
403
+ }
404
+ ToolbarItemKind.blockquote -> {
405
+ val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
406
+ val label = rawItem.optNullableString("label") ?: return null
407
+ NativeToolbarItem(
408
+ type,
409
+ key,
410
+ label,
411
+ icon,
412
+ placement = rawItem.optNullableString("placement")?.let {
413
+ runCatching { ToolbarItemPlacement.valueOf(it) }.getOrNull()
414
+ }
415
+ )
416
+ }
417
+ ToolbarItemKind.list -> {
418
+ val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
419
+ val listType = runCatching {
420
+ ToolbarListType.valueOf(rawItem.getString("listType"))
421
+ }.getOrNull() ?: return null
422
+ val label = rawItem.optNullableString("label") ?: return null
423
+ NativeToolbarItem(
424
+ type,
425
+ key,
426
+ label,
427
+ icon,
428
+ listType = listType,
429
+ placement = rawItem.optNullableString("placement")?.let {
430
+ runCatching { ToolbarItemPlacement.valueOf(it) }.getOrNull()
431
+ }
432
+ )
433
+ }
434
+ ToolbarItemKind.command -> {
435
+ val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
436
+ val command = runCatching {
437
+ ToolbarCommand.valueOf(rawItem.getString("command"))
438
+ }.getOrNull() ?: return null
439
+ val label = rawItem.optNullableString("label") ?: return null
440
+ NativeToolbarItem(
441
+ type,
442
+ key,
443
+ label,
444
+ icon,
445
+ command = command,
446
+ placement = rawItem.optNullableString("placement")?.let {
447
+ runCatching { ToolbarItemPlacement.valueOf(it) }.getOrNull()
448
+ }
449
+ )
450
+ }
451
+ ToolbarItemKind.node -> {
452
+ val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
453
+ val nodeType = rawItem.optNullableString("nodeType") ?: return null
454
+ val label = rawItem.optNullableString("label") ?: return null
455
+ NativeToolbarItem(
456
+ type,
457
+ key,
458
+ label,
459
+ icon,
460
+ nodeType = nodeType,
461
+ placement = rawItem.optNullableString("placement")?.let {
462
+ runCatching { ToolbarItemPlacement.valueOf(it) }.getOrNull()
463
+ }
464
+ )
465
+ }
466
+ ToolbarItemKind.action -> {
467
+ val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
468
+ val keyValue = rawItem.optNullableString("key") ?: return null
469
+ val label = rawItem.optNullableString("label") ?: return null
470
+ NativeToolbarItem(
471
+ type = type,
472
+ key = keyValue,
473
+ label = label,
474
+ icon = icon,
475
+ placement = rawItem.optNullableString("placement")?.let {
476
+ runCatching { ToolbarItemPlacement.valueOf(it) }.getOrNull()
477
+ },
478
+ isActive = rawItem.optBoolean("isActive", false),
479
+ isDisabled = rawItem.optBoolean("isDisabled", false)
480
+ )
481
+ }
482
+ ToolbarItemKind.group -> {
483
+ if (!allowGroup) return null
484
+ val keyValue = rawItem.optNullableString("key") ?: return null
485
+ val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
486
+ val label = rawItem.optNullableString("label") ?: return null
487
+ val presentation = rawItem.optNullableString("presentation")?.let {
488
+ runCatching { ToolbarGroupPresentation.valueOf(it) }.getOrNull()
489
+ } ?: ToolbarGroupPresentation.expand
490
+ val rawChildren = rawItem.optJSONArray("items") ?: return null
491
+ val children = mutableListOf<NativeToolbarItem>()
492
+ for (childIndex in 0 until rawChildren.length()) {
493
+ val rawChild = rawChildren.optJSONObject(childIndex) ?: continue
494
+ parseItem(rawChild, allowGroup = false, allowSeparator = false)?.let {
495
+ children += it
496
+ }
497
+ }
498
+ if (children.isEmpty()) return null
499
+ NativeToolbarItem(
500
+ type = type,
501
+ key = keyValue,
502
+ label = label,
503
+ icon = icon,
504
+ placement = rawItem.optNullableString("placement")?.let {
505
+ runCatching { ToolbarItemPlacement.valueOf(it) }.getOrNull()
506
+ },
507
+ presentation = presentation,
508
+ items = children
509
+ )
510
+ }
511
+ }
512
+ }
513
+
514
+ fun fromJson(json: String?): List<NativeToolbarItem> {
515
+ if (json.isNullOrBlank()) return defaults
516
+ val rawArray = try {
517
+ org.json.JSONArray(json)
518
+ } catch (_: Exception) {
519
+ return defaults
520
+ }
521
+ val parsed = mutableListOf<NativeToolbarItem>()
522
+ for (index in 0 until rawArray.length()) {
523
+ val rawItem = rawArray.optJSONObject(index) ?: continue
524
+ parseItem(rawItem)?.let { parsed += it }
525
+ }
526
+ return parsed.ifEmpty { defaults }
527
+ }
528
+ }
529
+ }
530
+
531
+ internal class EditorKeyboardToolbarView(context: Context) : HorizontalScrollView(context) {
532
+ private companion object {
533
+ private const val NATIVE_CONTAINER_HEIGHT_DP = 64
534
+ private const val NATIVE_CONTAINER_HORIZONTAL_PADDING_DP = 16
535
+ private const val NATIVE_CONTAINER_VERTICAL_PADDING_DP = 12
536
+ private const val NATIVE_BUTTON_SIZE_DP = 40
537
+ private const val NATIVE_BUTTON_ICON_SIZE_SP = 24f
538
+ private const val NATIVE_ITEM_SPACING_DP = 8
539
+ private const val NATIVE_GROUP_SPACING_DP = 12
540
+ }
541
+
542
+ private data class ButtonBinding(
543
+ val item: NativeToolbarItem,
544
+ val button: AppCompatButton
545
+ )
546
+
547
+ var onPressItem: ((NativeToolbarItem) -> Unit)? = null
548
+ var onSelectMentionSuggestion: ((NativeMentionSuggestion) -> Unit)? = null
549
+
550
+ private val themedContext: Context = DynamicColors.wrapContextIfAvailable(context)
551
+ private val rootRow = LinearLayout(context)
552
+ private val startRow = LinearLayout(context)
553
+ private val centerScrollView = HorizontalScrollView(context)
554
+ private val contentRow = LinearLayout(context)
555
+ private val endRow = LinearLayout(context)
556
+ private var theme: EditorToolbarTheme? = null
557
+ private var mentionTheme: EditorMentionTheme? = null
558
+ private var state: NativeToolbarState = NativeToolbarState.empty
559
+ private var items: List<NativeToolbarItem> = NativeToolbarItem.defaults
560
+ private var mentionSuggestions: List<NativeMentionSuggestion> = emptyList()
561
+ private var expandedGroupKey: String? = null
562
+ private var rebuildGeneration: Int = 0
563
+ private val bindings = mutableListOf<ButtonBinding>()
564
+ private val separators = mutableListOf<View>()
565
+ private val mentionChips = mutableListOf<MentionSuggestionChipView>()
566
+ private val buttonBackgroundColors = mutableMapOf<AppCompatButton, Int>()
567
+ private val density = resources.displayMetrics.density
568
+ internal var appliedAppearance: EditorToolbarAppearance = EditorToolbarAppearance.CUSTOM
569
+ private set
570
+ internal var appliedChromeCornerRadiusPx: Float = 0f
571
+ private set
572
+ internal var appliedChromeStrokeWidthPx: Int = 0
573
+ private set
574
+ internal var appliedChromeElevationPx: Float = 0f
575
+ private set
576
+ internal var appliedChromeColor: Int = Color.TRANSPARENT
577
+ private set
578
+ internal var appliedButtonCornerRadiusPx: Float = 0f
579
+ private set
580
+ val isShowingMentionSuggestions: Boolean
581
+ get() = mentionSuggestions.isNotEmpty()
582
+
583
+ init {
584
+ isHorizontalScrollBarEnabled = false
585
+ overScrollMode = OVER_SCROLL_NEVER
586
+ setBackgroundColor(Color.TRANSPARENT)
587
+ clipToPadding = false
588
+ clipChildren = false
589
+ isFillViewport = true
590
+
591
+ rootRow.orientation = LinearLayout.HORIZONTAL
592
+ rootRow.gravity = Gravity.CENTER_VERTICAL
593
+ rootRow.clipToPadding = false
594
+ rootRow.clipChildren = false
595
+ startRow.orientation = LinearLayout.HORIZONTAL
596
+ startRow.gravity = Gravity.START or Gravity.CENTER_VERTICAL
597
+ endRow.orientation = LinearLayout.HORIZONTAL
598
+ endRow.gravity = Gravity.END or Gravity.CENTER_VERTICAL
599
+ centerScrollView.isHorizontalScrollBarEnabled = false
600
+ centerScrollView.overScrollMode = OVER_SCROLL_NEVER
601
+ centerScrollView.clipToPadding = false
602
+ centerScrollView.clipChildren = false
603
+ contentRow.orientation = LinearLayout.HORIZONTAL
604
+ contentRow.gravity = Gravity.START or Gravity.CENTER_VERTICAL
605
+ contentRow.clipToPadding = false
606
+ contentRow.clipChildren = false
607
+ centerScrollView.addView(
608
+ contentRow,
609
+ LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
610
+ )
611
+ rootRow.addView(
612
+ startRow,
613
+ LinearLayout.LayoutParams(
614
+ LinearLayout.LayoutParams.WRAP_CONTENT,
615
+ LinearLayout.LayoutParams.WRAP_CONTENT
616
+ )
617
+ )
618
+ rootRow.addView(
619
+ centerScrollView,
620
+ LinearLayout.LayoutParams(
621
+ 0,
622
+ LinearLayout.LayoutParams.WRAP_CONTENT,
623
+ 1f
624
+ )
625
+ )
626
+ rootRow.addView(
627
+ endRow,
628
+ LinearLayout.LayoutParams(
629
+ LinearLayout.LayoutParams.WRAP_CONTENT,
630
+ LinearLayout.LayoutParams.WRAP_CONTENT
631
+ )
632
+ )
633
+ addView(
634
+ rootRow,
635
+ LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
636
+ )
637
+ rebuildContent(preserveScrollPosition = false)
638
+ }
639
+
640
+ fun setItems(items: List<NativeToolbarItem>) {
641
+ this.items = compactItems(items)
642
+ if (expandedGroupKey != null && !containsExpandableGroup(this.items, expandedGroupKey)) {
643
+ expandedGroupKey = null
644
+ }
645
+ if (!isShowingMentionSuggestions) {
646
+ rebuildContent()
647
+ }
648
+ }
649
+
650
+ fun applyTheme(theme: EditorToolbarTheme?) {
651
+ this.theme = theme
652
+ updateChrome()
653
+ separators.forEach { separator ->
654
+ separator.setBackgroundColor(resolveSeparatorColor())
655
+ }
656
+ bindings.forEach { binding ->
657
+ updateButtonAppearance(
658
+ binding.button,
659
+ enabled = buttonState(binding.item, state).first,
660
+ active = buttonState(binding.item, state).second
661
+ )
662
+ }
663
+ mentionChips.forEach { chip ->
664
+ chip.applyTheme(mentionTheme, theme?.appearance ?: EditorToolbarAppearance.CUSTOM)
665
+ }
666
+ }
667
+
668
+ fun applyMentionTheme(theme: EditorMentionTheme?) {
669
+ mentionTheme = theme
670
+ mentionChips.forEach { chip ->
671
+ chip.applyTheme(theme, this.theme?.appearance ?: EditorToolbarAppearance.CUSTOM)
672
+ }
673
+ }
674
+
675
+ fun applyState(state: NativeToolbarState) {
676
+ this.state = state
677
+ bindings.forEach { binding ->
678
+ val (enabled, active) = buttonState(binding.item, state)
679
+ binding.button.isEnabled = enabled
680
+ binding.button.isSelected = active
681
+ updateButtonAppearance(binding.button, enabled, active)
682
+ }
683
+ }
684
+
685
+ fun setMentionSuggestions(suggestions: List<NativeMentionSuggestion>): Boolean {
686
+ val hadSuggestions = isShowingMentionSuggestions
687
+ mentionSuggestions = suggestions.take(8)
688
+ rebuildContent(preserveScrollPosition = hadSuggestions == isShowingMentionSuggestions)
689
+ return hadSuggestions != isShowingMentionSuggestions
690
+ }
691
+
692
+ fun triggerMentionSuggestionTapForTesting(index: Int) {
693
+ mentionChips.getOrNull(index)?.performClick()
694
+ }
695
+
696
+ internal fun buttonAtForTesting(index: Int): AppCompatButton? =
697
+ bindings.getOrNull(index)?.button
698
+
699
+ internal fun buttonCountForTesting(): Int = bindings.size
700
+
701
+ internal fun buttonBackgroundColorAtForTesting(index: Int): Int? =
702
+ bindings.getOrNull(index)?.button?.let { buttonBackgroundColors[it] }
703
+
704
+ internal fun mentionChipAtForTesting(index: Int): MentionSuggestionChipView? =
705
+ mentionChips.getOrNull(index)
706
+
707
+ internal fun separatorAtForTesting(index: Int): View? =
708
+ separators.getOrNull(index)
709
+
710
+ private fun rebuildContent(preserveScrollPosition: Boolean = true) {
711
+ val targetScrollX = if (preserveScrollPosition) centerScrollView.scrollX else 0
712
+ val generation = ++rebuildGeneration
713
+ bindings.clear()
714
+ separators.clear()
715
+ mentionChips.clear()
716
+ contentRow.removeAllViews()
717
+ startRow.removeAllViews()
718
+ endRow.removeAllViews()
719
+
720
+ if (isShowingMentionSuggestions) {
721
+ val visibleItems = visibleItemsByPlacement()
722
+ rebuildButtonPlacement(visibleItems.start, startRow)
723
+ rebuildMentionSuggestions()
724
+ rebuildButtonPlacement(visibleItems.end, endRow)
725
+ } else {
726
+ rebuildButtons()
727
+ }
728
+
729
+ updateChrome()
730
+ applyState(state)
731
+ post {
732
+ if (generation != rebuildGeneration) return@post
733
+ val contentWidth = contentRow.width
734
+ val viewportWidth = (centerScrollView.width - centerScrollView.paddingLeft - centerScrollView.paddingRight).coerceAtLeast(0)
735
+ val maxScrollX = (contentWidth - viewportWidth).coerceAtLeast(0)
736
+ centerScrollView.scrollTo(targetScrollX.coerceIn(0, maxScrollX), 0)
737
+ }
738
+ }
739
+
740
+ private fun rebuildButtons() {
741
+ val visibleItems = visibleItemsByPlacement()
742
+ rebuildButtonPlacement(visibleItems.start, startRow)
743
+ rebuildButtonPlacement(visibleItems.scroll, contentRow)
744
+ rebuildButtonPlacement(visibleItems.end, endRow)
745
+ }
746
+
747
+ private fun rebuildButtonPlacement(items: List<NativeToolbarItem>, container: LinearLayout) {
748
+ for (item in items) {
749
+ if (item.type == ToolbarItemKind.separator) {
750
+ val separator = View(context)
751
+ configureSeparator(separator)
752
+ separators.add(separator)
753
+ container.addView(separator)
754
+ continue
755
+ }
756
+
757
+ val button = AppCompatButton(themedContext).apply {
758
+ val resolvedIcon = item.icon?.resolveForAndroid(themedContext)
759
+ ?: NativeToolbarResolvedIcon("?")
760
+ text = resolvedIcon.text
761
+ typeface = resolvedIcon.typeface ?: Typeface.DEFAULT
762
+ gravity = Gravity.CENTER
763
+ background = GradientDrawable()
764
+ isAllCaps = false
765
+ includeFontPadding = false
766
+ contentDescription = item.label
767
+ setOnClickListener {
768
+ when (item.type) {
769
+ ToolbarItemKind.group -> handleGroupButtonPress(this, item)
770
+ else -> {
771
+ onPressItem?.invoke(item.copy(parentGroupKey = null))
772
+ if (item.parentGroupKey != null && expandedGroupKey == item.parentGroupKey) {
773
+ expandedGroupKey = null
774
+ rebuildContent()
775
+ }
776
+ }
777
+ }
778
+ }
779
+ elevation = 0f
780
+ translationZ = 0f
781
+ stateListAnimator = null
782
+ }
783
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
784
+ button.foreground = resolveDrawableAttr(android.R.attr.selectableItemBackgroundBorderless)
785
+ }
786
+ val params = LinearLayout.LayoutParams(
787
+ LinearLayout.LayoutParams.WRAP_CONTENT,
788
+ LinearLayout.LayoutParams.WRAP_CONTENT
789
+ )
790
+ button.layoutParams = params
791
+ applyButtonLayout(button, appearance = theme?.appearance ?: EditorToolbarAppearance.CUSTOM)
792
+ bindings.add(ButtonBinding(item, button))
793
+ container.addView(button)
794
+ }
795
+ }
796
+
797
+ private fun rebuildMentionSuggestions() {
798
+ for (suggestion in mentionSuggestions) {
799
+ val chip = MentionSuggestionChipView(context, suggestion).apply {
800
+ applyTheme(mentionTheme, theme?.appearance ?: EditorToolbarAppearance.CUSTOM)
801
+ setOnClickListener { onSelectMentionSuggestion?.invoke(suggestion) }
802
+ }
803
+ val params = LinearLayout.LayoutParams(
804
+ LinearLayout.LayoutParams.WRAP_CONTENT,
805
+ LinearLayout.LayoutParams.WRAP_CONTENT
806
+ )
807
+ params.marginEnd = dp(8)
808
+ chip.layoutParams = params
809
+ mentionChips.add(chip)
810
+ contentRow.addView(chip)
811
+ }
812
+ }
813
+
814
+ private fun compactItems(items: List<NativeToolbarItem>): List<NativeToolbarItem> {
815
+ return items.filterIndexed { index, item ->
816
+ if (item.type != ToolbarItemKind.separator) return@filterIndexed true
817
+ index > 0 &&
818
+ index < items.lastIndex &&
819
+ items[index - 1].type != ToolbarItemKind.separator &&
820
+ items[index + 1].type != ToolbarItemKind.separator
821
+ }
822
+ }
823
+
824
+ private fun visibleItems(): List<NativeToolbarItem> {
825
+ val visible = mutableListOf<NativeToolbarItem>()
826
+ for (item in compactItems(items)) {
827
+ visible += item
828
+ if (
829
+ item.type == ToolbarItemKind.group &&
830
+ (item.presentation ?: ToolbarGroupPresentation.expand) == ToolbarGroupPresentation.expand &&
831
+ expandedGroupKey == item.key
832
+ ) {
833
+ visible += item.items.map { child ->
834
+ child.copy(parentGroupKey = item.key, placement = child.placement ?: item.placement)
835
+ }
836
+ }
837
+ }
838
+ return compactItems(visible)
839
+ }
840
+
841
+ private data class VisibleToolbarItemsByPlacement(
842
+ val start: List<NativeToolbarItem>,
843
+ val scroll: List<NativeToolbarItem>,
844
+ val end: List<NativeToolbarItem>
845
+ )
846
+
847
+ private fun visibleItemsByPlacement(): VisibleToolbarItemsByPlacement {
848
+ val start = mutableListOf<NativeToolbarItem>()
849
+ val scroll = mutableListOf<NativeToolbarItem>()
850
+ val end = mutableListOf<NativeToolbarItem>()
851
+ for (item in visibleItems()) {
852
+ when (item.placement ?: ToolbarItemPlacement.scroll) {
853
+ ToolbarItemPlacement.start -> start += item
854
+ ToolbarItemPlacement.end -> end += item
855
+ ToolbarItemPlacement.scroll -> scroll += item
856
+ }
857
+ }
858
+ return VisibleToolbarItemsByPlacement(
859
+ start = compactItems(start),
860
+ scroll = compactItems(scroll),
861
+ end = compactItems(end)
862
+ )
863
+ }
864
+
865
+ private fun containsExpandableGroup(items: List<NativeToolbarItem>, key: String?): Boolean {
866
+ key ?: return false
867
+ return items.any {
868
+ it.type == ToolbarItemKind.group &&
869
+ it.key == key &&
870
+ (it.presentation ?: ToolbarGroupPresentation.expand) == ToolbarGroupPresentation.expand
871
+ }
872
+ }
873
+
874
+ private fun handleGroupButtonPress(anchor: View, item: NativeToolbarItem) {
875
+ if (item.items.isEmpty()) return
876
+ when (item.presentation ?: ToolbarGroupPresentation.expand) {
877
+ ToolbarGroupPresentation.expand -> {
878
+ val key = item.key ?: return
879
+ expandedGroupKey = if (expandedGroupKey == key) null else key
880
+ rebuildContent()
881
+ }
882
+ ToolbarGroupPresentation.menu -> showGroupMenu(anchor, item)
883
+ }
884
+ }
885
+
886
+ private fun showGroupMenu(anchor: View, item: NativeToolbarItem) {
887
+ val popupMenu = PopupMenu(themedContext, anchor)
888
+ item.items.forEachIndexed { index, child ->
889
+ val (enabled, active) = buttonState(child, state)
890
+ val menuItem = popupMenu.menu.add(0, index, index, child.label ?: child.key ?: "Item")
891
+ menuItem.isEnabled = enabled
892
+ menuItem.isCheckable = true
893
+ menuItem.isChecked = active
894
+ }
895
+ popupMenu.setOnMenuItemClickListener { menuItem ->
896
+ val child = item.items.getOrNull(menuItem.itemId) ?: return@setOnMenuItemClickListener false
897
+ onPressItem?.invoke(child)
898
+ true
899
+ }
900
+ popupMenu.show()
901
+ }
902
+
903
+ private fun updateChrome() {
904
+ val appearance = theme?.appearance ?: EditorToolbarAppearance.CUSTOM
905
+ val cornerRadiusPx = (theme?.resolvedBorderRadius() ?: 0f) * density
906
+ val strokeWidthPx = if (appearance == EditorToolbarAppearance.NATIVE) {
907
+ 0
908
+ } else {
909
+ ((theme?.resolvedBorderWidth() ?: 1f) * density).roundToInt().coerceAtLeast(1)
910
+ }
911
+ val drawable = GradientDrawable().apply {
912
+ shape = GradientDrawable.RECTANGLE
913
+ cornerRadius = cornerRadiusPx
914
+ setColor(
915
+ if (appearance == EditorToolbarAppearance.NATIVE) {
916
+ resolveColorAttr(
917
+ MaterialR.attr.colorSurfaceContainer,
918
+ MaterialR.attr.colorSurfaceContainerLow,
919
+ MaterialR.attr.colorSurface,
920
+ android.R.attr.colorBackground
921
+ )
922
+ } else {
923
+ theme?.backgroundColor ?: resolveColorAttr(
924
+ MaterialR.attr.colorSurface,
925
+ android.R.attr.colorBackground
926
+ )
927
+ }
928
+ )
929
+ if (strokeWidthPx > 0) {
930
+ setStroke(strokeWidthPx, theme?.borderColor ?: resolveSeparatorColor())
931
+ }
932
+ }
933
+ appliedAppearance = appearance
934
+ appliedChromeCornerRadiusPx = cornerRadiusPx
935
+ appliedChromeStrokeWidthPx = strokeWidthPx
936
+ appliedChromeElevationPx = 0f
937
+ appliedChromeColor = if (appearance == EditorToolbarAppearance.NATIVE) {
938
+ resolveColorAttr(
939
+ MaterialR.attr.colorSurfaceContainer,
940
+ MaterialR.attr.colorSurfaceContainerLow,
941
+ MaterialR.attr.colorSurface,
942
+ android.R.attr.colorBackground
943
+ )
944
+ } else {
945
+ theme?.backgroundColor ?: resolveColorAttr(
946
+ MaterialR.attr.colorSurface,
947
+ android.R.attr.colorBackground
948
+ )
949
+ }
950
+ background = drawable
951
+ outlineProvider = ViewOutlineProvider.BACKGROUND
952
+ clipToOutline = cornerRadiusPx > 0f
953
+ elevation = appliedChromeElevationPx
954
+ updateContainerLayout(appearance)
955
+ separators.forEach(::configureSeparator)
956
+ }
957
+
958
+ private fun updateButtonAppearance(button: AppCompatButton, enabled: Boolean, active: Boolean) {
959
+ val appearance = theme?.appearance ?: EditorToolbarAppearance.CUSTOM
960
+ applyButtonLayout(button, appearance)
961
+ val textColor = if (appearance == EditorToolbarAppearance.NATIVE) {
962
+ when {
963
+ !enabled -> withAlpha(
964
+ resolveColorAttr(
965
+ MaterialR.attr.colorOnSurface,
966
+ android.R.attr.textColorPrimary
967
+ ),
968
+ 0.38f
969
+ )
970
+ active -> resolveColorAttr(
971
+ MaterialR.attr.colorOnSecondaryContainer,
972
+ MaterialR.attr.colorOnPrimaryContainer,
973
+ MaterialR.attr.colorOnSurface,
974
+ android.R.attr.textColorPrimary
975
+ )
976
+ else -> resolveColorAttr(
977
+ MaterialR.attr.colorOnSurfaceVariant,
978
+ MaterialR.attr.colorOnSurface,
979
+ android.R.attr.textColorSecondary
980
+ )
981
+ }
982
+ } else {
983
+ when {
984
+ !enabled -> theme?.buttonDisabledColor ?: withAlpha(
985
+ resolveColorAttr(MaterialR.attr.colorOnSurface, android.R.attr.textColorPrimary),
986
+ 0.38f
987
+ )
988
+ active -> theme?.buttonActiveColor ?: resolveColorAttr(
989
+ AppCompatR.attr.colorPrimary,
990
+ android.R.attr.textColorPrimary
991
+ )
992
+ else -> theme?.buttonColor ?: resolveColorAttr(
993
+ MaterialR.attr.colorOnSurfaceVariant,
994
+ MaterialR.attr.colorOnSurface,
995
+ android.R.attr.textColorSecondary
996
+ )
997
+ }
998
+ }
999
+ val backgroundColor = if (appearance == EditorToolbarAppearance.NATIVE) {
1000
+ if (active) {
1001
+ resolveColorAttr(
1002
+ MaterialR.attr.colorSecondaryContainer,
1003
+ MaterialR.attr.colorPrimaryContainer,
1004
+ MaterialR.attr.colorSurfaceVariant,
1005
+ android.R.attr.colorAccent
1006
+ )
1007
+ } else {
1008
+ Color.TRANSPARENT
1009
+ }
1010
+ } else if (active) {
1011
+ theme?.buttonActiveBackgroundColor ?: resolveColorAttr(
1012
+ MaterialR.attr.colorPrimaryContainer,
1013
+ MaterialR.attr.colorSecondaryContainer,
1014
+ MaterialR.attr.colorSurfaceVariant,
1015
+ android.R.attr.colorAccent
1016
+ )
1017
+ } else {
1018
+ Color.TRANSPARENT
1019
+ }
1020
+ val buttonCornerRadiusPx = (theme?.resolvedButtonBorderRadius() ?: 6f) * density
1021
+ val drawable = GradientDrawable().apply {
1022
+ shape = GradientDrawable.RECTANGLE
1023
+ cornerRadius = buttonCornerRadiusPx
1024
+ setColor(backgroundColor)
1025
+ }
1026
+ appliedButtonCornerRadiusPx = buttonCornerRadiusPx
1027
+ buttonBackgroundColors[button] = backgroundColor
1028
+ button.background = drawable
1029
+ button.setTextColor(textColor)
1030
+ button.alpha = if (enabled || appearance == EditorToolbarAppearance.NATIVE) 1f else 0.7f
1031
+ button.refreshDrawableState()
1032
+ button.invalidate()
1033
+ }
1034
+
1035
+ private fun buttonState(
1036
+ item: NativeToolbarItem,
1037
+ state: NativeToolbarState
1038
+ ): Pair<Boolean, Boolean> {
1039
+ val isInList = state.nodes["bulletList"] == true || state.nodes["orderedList"] == true
1040
+ return when (item.type) {
1041
+ ToolbarItemKind.mark -> {
1042
+ val mark = item.mark.orEmpty()
1043
+ Pair(state.allowedMarks.contains(mark), state.marks[mark] == true)
1044
+ }
1045
+ ToolbarItemKind.heading -> {
1046
+ val level = item.headingLevel ?: return Pair(false, false)
1047
+ Pair(
1048
+ state.commands["toggleHeading$level"] == true,
1049
+ state.nodes["h$level"] == true
1050
+ )
1051
+ }
1052
+ ToolbarItemKind.blockquote -> Pair(
1053
+ state.commands["toggleBlockquote"] == true,
1054
+ state.nodes["blockquote"] == true
1055
+ )
1056
+ ToolbarItemKind.list -> when (item.listType) {
1057
+ ToolbarListType.bulletList -> Pair(
1058
+ state.commands["wrapBulletList"] == true,
1059
+ state.nodes["bulletList"] == true
1060
+ )
1061
+ ToolbarListType.orderedList -> Pair(
1062
+ state.commands["wrapOrderedList"] == true,
1063
+ state.nodes["orderedList"] == true
1064
+ )
1065
+ null -> Pair(false, false)
1066
+ }
1067
+ ToolbarItemKind.command -> when (item.command) {
1068
+ ToolbarCommand.indentList -> Pair(isInList && state.commands["indentList"] == true, false)
1069
+ ToolbarCommand.outdentList -> Pair(isInList && state.commands["outdentList"] == true, false)
1070
+ ToolbarCommand.undo -> Pair(state.canUndo, false)
1071
+ ToolbarCommand.redo -> Pair(state.canRedo, false)
1072
+ null -> Pair(false, false)
1073
+ }
1074
+ ToolbarItemKind.node -> {
1075
+ val nodeType = item.nodeType.orEmpty()
1076
+ Pair(state.insertableNodes.contains(nodeType), state.nodes[nodeType] == true)
1077
+ }
1078
+ ToolbarItemKind.action -> Pair(!item.isDisabled, item.isActive)
1079
+ ToolbarItemKind.group -> Pair(
1080
+ item.items.any { child -> buttonState(child, state).first },
1081
+ item.items.any { child -> buttonState(child, state).second } ||
1082
+ (
1083
+ (item.presentation ?: ToolbarGroupPresentation.expand) ==
1084
+ ToolbarGroupPresentation.expand &&
1085
+ expandedGroupKey == item.key
1086
+ )
1087
+ )
1088
+ ToolbarItemKind.separator -> Pair(false, false)
1089
+ }
1090
+ }
1091
+
1092
+ private fun dp(value: Int): Int = (value * density).toInt()
1093
+
1094
+ private fun resolveColorAttr(vararg attrs: Int): Int =
1095
+ resolveColorAttrOrNull(*attrs) ?: Color.TRANSPARENT
1096
+
1097
+ private fun resolveColorAttrOrNull(vararg attrs: Int): Int? {
1098
+ val typedValue = TypedValue()
1099
+ for (attr in attrs) {
1100
+ if (!themedContext.theme.resolveAttribute(attr, typedValue, true)) {
1101
+ continue
1102
+ }
1103
+ if (typedValue.resourceId != 0) {
1104
+ AppCompatResources.getColorStateList(themedContext, typedValue.resourceId)
1105
+ ?.defaultColor
1106
+ ?.let { return it }
1107
+ } else if (typedValue.type in TypedValue.TYPE_FIRST_COLOR_INT..TypedValue.TYPE_LAST_COLOR_INT) {
1108
+ return typedValue.data
1109
+ }
1110
+ }
1111
+ return null
1112
+ }
1113
+
1114
+ private fun resolveDrawableAttr(attr: Int) =
1115
+ TypedValue().let { typedValue ->
1116
+ if (!themedContext.theme.resolveAttribute(attr, typedValue, true) || typedValue.resourceId == 0) {
1117
+ null
1118
+ } else {
1119
+ AppCompatResources.getDrawable(themedContext, typedValue.resourceId)
1120
+ }
1121
+ }
1122
+
1123
+ private fun resolveSeparatorColor(): Int =
1124
+ theme?.separatorColor
1125
+ ?: theme?.borderColor
1126
+ ?: resolveColorAttr(
1127
+ MaterialR.attr.colorOutlineVariant,
1128
+ MaterialR.attr.colorOutline,
1129
+ android.R.attr.textColorHint
1130
+ )
1131
+
1132
+ private fun updateContainerLayout(appearance: EditorToolbarAppearance) {
1133
+ val isNative = appearance == EditorToolbarAppearance.NATIVE
1134
+ val toolbarHeightDp = resolvedToolbarHeightDp(isNative)
1135
+ val buttonSizeDp = resolvedButtonSizeDp(isNative, toolbarHeightDp)
1136
+ val horizontalPadding = dp(
1137
+ if (isNative) {
1138
+ NATIVE_CONTAINER_HORIZONTAL_PADDING_DP
1139
+ } else {
1140
+ 12
1141
+ }
1142
+ )
1143
+ val verticalPadding = dp(resolvedVerticalPaddingDp(isNative, toolbarHeightDp, buttonSizeDp).roundToInt())
1144
+ rootRow.setPadding(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding)
1145
+ rootRow.minimumHeight = dp(toolbarHeightDp.roundToInt())
1146
+ startRow.gravity = Gravity.START or Gravity.CENTER_VERTICAL
1147
+ contentRow.gravity = Gravity.START or Gravity.CENTER_VERTICAL
1148
+ endRow.gravity = Gravity.END or Gravity.CENTER_VERTICAL
1149
+ }
1150
+
1151
+ private fun applyButtonLayout(button: AppCompatButton, appearance: EditorToolbarAppearance) {
1152
+ val isNative = appearance == EditorToolbarAppearance.NATIVE
1153
+ val toolbarHeightDp = resolvedToolbarHeightDp(isNative)
1154
+ val sizePx = dp(resolvedButtonSizeDp(isNative, toolbarHeightDp).roundToInt())
1155
+ button.textSize = if (isNative) NATIVE_BUTTON_ICON_SIZE_SP else 16f
1156
+ button.minWidth = sizePx
1157
+ button.minimumWidth = sizePx
1158
+ button.minHeight = sizePx
1159
+ button.minimumHeight = sizePx
1160
+ button.setPadding(
1161
+ if (isNative) 0 else dp(10),
1162
+ if (isNative) 0 else dp(8),
1163
+ if (isNative) 0 else dp(10),
1164
+ if (isNative) 0 else dp(8)
1165
+ )
1166
+ (button.layoutParams as? LinearLayout.LayoutParams)?.let { params ->
1167
+ params.marginEnd = dp(if (isNative) NATIVE_ITEM_SPACING_DP else 6)
1168
+ button.layoutParams = params
1169
+ }
1170
+ }
1171
+
1172
+ private fun configureSeparator(separator: View) {
1173
+ val appearance = theme?.appearance ?: EditorToolbarAppearance.CUSTOM
1174
+ val params = if (appearance == EditorToolbarAppearance.NATIVE) {
1175
+ LinearLayout.LayoutParams(dp(1), dp(24)).apply {
1176
+ marginStart = dp(NATIVE_GROUP_SPACING_DP / 2)
1177
+ marginEnd = dp(NATIVE_GROUP_SPACING_DP / 2)
1178
+ }
1179
+ } else {
1180
+ LinearLayout.LayoutParams(dp(1), dp(22)).apply {
1181
+ marginStart = dp(6)
1182
+ marginEnd = dp(6)
1183
+ }
1184
+ }
1185
+ separator.layoutParams = params
1186
+ separator.setBackgroundColor(
1187
+ if (appearance == EditorToolbarAppearance.NATIVE) {
1188
+ withAlpha(resolveSeparatorColor(), 0.6f)
1189
+ } else {
1190
+ resolveSeparatorColor()
1191
+ }
1192
+ )
1193
+ }
1194
+
1195
+ private fun resolvedToolbarHeightDp(isNative: Boolean): Float =
1196
+ theme?.height ?: if (isNative) {
1197
+ NATIVE_CONTAINER_HEIGHT_DP.toFloat()
1198
+ } else {
1199
+ 60f
1200
+ }
1201
+
1202
+ private fun resolvedButtonSizeDp(isNative: Boolean, toolbarHeightDp: Float): Float {
1203
+ val defaultSizeDp = if (isNative) NATIVE_BUTTON_SIZE_DP.toFloat() else 36f
1204
+ if (theme?.height == null) {
1205
+ return defaultSizeDp
1206
+ }
1207
+ return maxOf(1f, minOf(defaultSizeDp, toolbarHeightDp - 4f))
1208
+ }
1209
+
1210
+ private fun resolvedVerticalPaddingDp(
1211
+ isNative: Boolean,
1212
+ toolbarHeightDp: Float,
1213
+ buttonSizeDp: Float
1214
+ ): Float {
1215
+ if (theme?.height == null) {
1216
+ return if (isNative) {
1217
+ NATIVE_CONTAINER_VERTICAL_PADDING_DP.toFloat()
1218
+ } else {
1219
+ 12f
1220
+ }
1221
+ }
1222
+ return maxOf(0f, (toolbarHeightDp - buttonSizeDp) / 2f)
1223
+ }
1224
+ }
1225
+
1226
+ private fun withAlpha(color: Int, alphaFraction: Float): Int {
1227
+ val alpha = (alphaFraction.coerceIn(0f, 1f) * 255).roundToInt()
1228
+ return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color))
1229
+ }
1230
+
1231
+ internal class MentionSuggestionChipView(
1232
+ context: Context,
1233
+ val suggestion: NativeMentionSuggestion
1234
+ ) : LinearLayout(context) {
1235
+ private val titleView = AppCompatTextView(context)
1236
+ private val subtitleView = AppCompatTextView(context)
1237
+ private var theme: EditorMentionTheme? = null
1238
+ private var toolbarAppearance: EditorToolbarAppearance = EditorToolbarAppearance.CUSTOM
1239
+ private val density = resources.displayMetrics.density
1240
+
1241
+ init {
1242
+ orientation = VERTICAL
1243
+ gravity = Gravity.CENTER_VERTICAL
1244
+ minimumHeight = dp(40)
1245
+ setPadding(dp(12), dp(8), dp(12), dp(8))
1246
+ isClickable = true
1247
+ isFocusable = true
1248
+
1249
+ titleView.apply {
1250
+ text = suggestion.label
1251
+ setTypeface(typeface, Typeface.BOLD)
1252
+ textSize = 14f
1253
+ includeFontPadding = false
1254
+ }
1255
+ addView(
1256
+ titleView,
1257
+ LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
1258
+ )
1259
+
1260
+ subtitleView.apply {
1261
+ text = suggestion.subtitle
1262
+ textSize = 12f
1263
+ includeFontPadding = false
1264
+ visibility = if (suggestion.subtitle.isNullOrBlank()) View.GONE else View.VISIBLE
1265
+ }
1266
+ addView(
1267
+ subtitleView,
1268
+ LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
1269
+ )
1270
+
1271
+ setOnTouchListener { _, motionEvent ->
1272
+ when (motionEvent.actionMasked) {
1273
+ android.view.MotionEvent.ACTION_DOWN,
1274
+ android.view.MotionEvent.ACTION_MOVE -> updateAppearance(highlighted = true)
1275
+ android.view.MotionEvent.ACTION_CANCEL,
1276
+ android.view.MotionEvent.ACTION_UP -> updateAppearance(highlighted = false)
1277
+ }
1278
+ false
1279
+ }
1280
+
1281
+ applyTheme(null)
1282
+ }
1283
+
1284
+ fun applyTheme(
1285
+ theme: EditorMentionTheme?,
1286
+ toolbarAppearance: EditorToolbarAppearance = EditorToolbarAppearance.CUSTOM
1287
+ ) {
1288
+ this.theme = theme
1289
+ this.toolbarAppearance = toolbarAppearance
1290
+ val hasSubtitle = !suggestion.subtitle.isNullOrBlank()
1291
+ subtitleView.visibility = if (hasSubtitle) View.VISIBLE else View.GONE
1292
+ background = GradientDrawable().apply {
1293
+ shape = GradientDrawable.RECTANGLE
1294
+ cornerRadius = (if (toolbarAppearance == EditorToolbarAppearance.NATIVE) 20f else (theme?.borderRadius ?: 12f)) * density
1295
+ setColor(
1296
+ if (toolbarAppearance == EditorToolbarAppearance.NATIVE) {
1297
+ Color.TRANSPARENT
1298
+ } else {
1299
+ theme?.backgroundColor ?: resolveColorAttr(
1300
+ MaterialR.attr.colorSurfaceContainerLow,
1301
+ MaterialR.attr.colorSurfaceVariant,
1302
+ MaterialR.attr.colorSurface,
1303
+ android.R.attr.colorBackground
1304
+ )
1305
+ }
1306
+ )
1307
+ val strokeWidth = if (toolbarAppearance == EditorToolbarAppearance.NATIVE) {
1308
+ 0
1309
+ } else {
1310
+ ((theme?.borderWidth ?: 0f) * density).toInt()
1311
+ }
1312
+ if (strokeWidth > 0) {
1313
+ setStroke(strokeWidth, theme?.borderColor ?: Color.TRANSPARENT)
1314
+ }
1315
+ }
1316
+ updateAppearance(highlighted = false)
1317
+ }
1318
+
1319
+ private fun updateAppearance(highlighted: Boolean) {
1320
+ val backgroundDrawable = background as? GradientDrawable
1321
+ val backgroundColor = if (toolbarAppearance == EditorToolbarAppearance.NATIVE) {
1322
+ if (highlighted) {
1323
+ resolveColorAttr(
1324
+ MaterialR.attr.colorSecondaryContainer,
1325
+ MaterialR.attr.colorPrimaryContainer,
1326
+ MaterialR.attr.colorSurfaceVariant,
1327
+ android.R.attr.colorAccent
1328
+ )
1329
+ } else {
1330
+ Color.TRANSPARENT
1331
+ }
1332
+ } else if (highlighted) {
1333
+ theme?.optionHighlightedBackgroundColor ?: resolveColorAttr(
1334
+ MaterialR.attr.colorSecondaryContainer,
1335
+ MaterialR.attr.colorPrimaryContainer,
1336
+ MaterialR.attr.colorSurfaceVariant,
1337
+ android.R.attr.colorAccent
1338
+ )
1339
+ } else {
1340
+ theme?.backgroundColor ?: resolveColorAttr(
1341
+ MaterialR.attr.colorSurfaceContainerLow,
1342
+ MaterialR.attr.colorSurfaceVariant,
1343
+ MaterialR.attr.colorSurface,
1344
+ android.R.attr.colorBackground
1345
+ )
1346
+ }
1347
+ backgroundDrawable?.setColor(backgroundColor)
1348
+ titleView.setTextColor(
1349
+ if (toolbarAppearance == EditorToolbarAppearance.NATIVE && !highlighted) {
1350
+ resolveColorAttr(
1351
+ MaterialR.attr.colorOnSurface,
1352
+ android.R.attr.textColorPrimary
1353
+ )
1354
+ } else if (highlighted) {
1355
+ theme?.optionHighlightedTextColor
1356
+ ?: theme?.optionTextColor
1357
+ ?: resolveColorAttr(
1358
+ MaterialR.attr.colorOnSecondaryContainer,
1359
+ MaterialR.attr.colorOnPrimaryContainer,
1360
+ MaterialR.attr.colorOnSurface,
1361
+ android.R.attr.textColorPrimary
1362
+ )
1363
+ } else {
1364
+ theme?.optionTextColor
1365
+ ?: theme?.textColor
1366
+ ?: resolveColorAttr(
1367
+ MaterialR.attr.colorOnSurface,
1368
+ android.R.attr.textColorPrimary
1369
+ )
1370
+ }
1371
+ )
1372
+ subtitleView.setTextColor(
1373
+ if (toolbarAppearance == EditorToolbarAppearance.NATIVE) {
1374
+ resolveColorAttr(
1375
+ MaterialR.attr.colorOnSurfaceVariant,
1376
+ android.R.attr.textColorSecondary
1377
+ )
1378
+ } else {
1379
+ theme?.optionSecondaryTextColor ?: resolveColorAttr(
1380
+ MaterialR.attr.colorOnSurfaceVariant,
1381
+ android.R.attr.textColorSecondary
1382
+ )
1383
+ }
1384
+ )
1385
+ }
1386
+
1387
+ fun usesNativeAppearanceForTesting(): Boolean =
1388
+ toolbarAppearance == EditorToolbarAppearance.NATIVE
1389
+
1390
+ private fun dp(value: Int): Int = (value * density).toInt()
1391
+
1392
+ private fun resolveColorAttr(vararg attrs: Int): Int {
1393
+ val typedValue = TypedValue()
1394
+ for (attr in attrs) {
1395
+ if (!context.theme.resolveAttribute(attr, typedValue, true)) {
1396
+ continue
1397
+ }
1398
+ if (typedValue.resourceId != 0) {
1399
+ AppCompatResources.getColorStateList(context, typedValue.resourceId)
1400
+ ?.defaultColor
1401
+ ?.let { return it }
1402
+ } else if (typedValue.type in TypedValue.TYPE_FIRST_COLOR_INT..TypedValue.TYPE_LAST_COLOR_INT) {
1403
+ return typedValue.data
1404
+ }
1405
+ }
1406
+ return Color.TRANSPARENT
1407
+ }
1408
+ }
1409
+
1410
+ private fun JSONObject.optNullableString(key: String): String? {
1411
+ if (!has(key) || isNull(key)) return null
1412
+ return optString(key).takeUnless { it == "null" }
1413
+ }