@apollohg/react-native-prose-editor 0.1.0

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 (47) hide show
  1. package/LICENSE +160 -0
  2. package/README.md +143 -0
  3. package/android/build.gradle +39 -0
  4. package/android/src/main/assets/editor-icons/MaterialIcons.json +2236 -0
  5. package/android/src/main/assets/editor-icons/MaterialIcons.ttf +0 -0
  6. package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +131 -0
  7. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +1057 -0
  8. package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
  9. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +191 -0
  10. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +325 -0
  11. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +647 -0
  12. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +257 -0
  13. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +714 -0
  14. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +76 -0
  15. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +1044 -0
  16. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +211 -0
  17. package/expo-module.config.json +9 -0
  18. package/ios/EditorAddons.swift +228 -0
  19. package/ios/EditorCore.xcframework/Info.plist +44 -0
  20. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  21. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  22. package/ios/EditorLayoutManager.swift +254 -0
  23. package/ios/EditorTheme.swift +372 -0
  24. package/ios/Generated_editor_core.swift +1143 -0
  25. package/ios/NativeEditorExpoView.swift +1417 -0
  26. package/ios/NativeEditorModule.swift +263 -0
  27. package/ios/PositionBridge.swift +278 -0
  28. package/ios/ReactNativeProseEditor.podspec +49 -0
  29. package/ios/RenderBridge.swift +825 -0
  30. package/ios/RichTextEditorView.swift +1559 -0
  31. package/ios/editor_coreFFI/editor_coreFFI.h +1014 -0
  32. package/ios/editor_coreFFI/module.modulemap +7 -0
  33. package/ios/editor_coreFFI.h +904 -0
  34. package/ios/editor_coreFFI.modulemap +7 -0
  35. package/package.json +66 -0
  36. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  37. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  38. package/rust/android/x86_64/libeditor_core.so +0 -0
  39. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2014 -0
  40. package/src/EditorTheme.ts +130 -0
  41. package/src/EditorToolbar.tsx +620 -0
  42. package/src/NativeEditorBridge.ts +607 -0
  43. package/src/NativeRichTextEditor.tsx +951 -0
  44. package/src/addons.ts +158 -0
  45. package/src/index.ts +63 -0
  46. package/src/schemas.ts +153 -0
  47. package/src/useNativeEditor.ts +173 -0
@@ -0,0 +1,714 @@
1
+ package com.apollohg.editor
2
+
3
+ import android.content.Context
4
+ import android.graphics.Color
5
+ import android.graphics.Typeface
6
+ import android.graphics.drawable.GradientDrawable
7
+ import android.view.Gravity
8
+ import android.view.View
9
+ import android.widget.HorizontalScrollView
10
+ import android.widget.LinearLayout
11
+ import androidx.appcompat.widget.AppCompatButton
12
+ import androidx.appcompat.widget.AppCompatTextView
13
+ import androidx.core.view.setPadding
14
+ import org.json.JSONObject
15
+
16
+ internal data class NativeToolbarState(
17
+ val marks: Map<String, Boolean>,
18
+ val nodes: Map<String, Boolean>,
19
+ val commands: Map<String, Boolean>,
20
+ val allowedMarks: Set<String>,
21
+ val insertableNodes: Set<String>,
22
+ val canUndo: Boolean,
23
+ val canRedo: Boolean
24
+ ) {
25
+ companion object {
26
+ val empty = NativeToolbarState(
27
+ marks = emptyMap(),
28
+ nodes = emptyMap(),
29
+ commands = emptyMap(),
30
+ allowedMarks = emptySet(),
31
+ insertableNodes = emptySet(),
32
+ canUndo = false,
33
+ canRedo = false
34
+ )
35
+
36
+ fun fromUpdateJson(updateJson: String): NativeToolbarState? {
37
+ val root = try {
38
+ JSONObject(updateJson)
39
+ } catch (_: Exception) {
40
+ return null
41
+ }
42
+ val activeState = root.optJSONObject("activeState") ?: JSONObject()
43
+ val historyState = root.optJSONObject("historyState") ?: JSONObject()
44
+ return NativeToolbarState(
45
+ marks = boolMap(activeState.optJSONObject("marks")),
46
+ nodes = boolMap(activeState.optJSONObject("nodes")),
47
+ commands = boolMap(activeState.optJSONObject("commands")),
48
+ allowedMarks = stringSet(activeState.optJSONArray("allowedMarks")),
49
+ insertableNodes = stringSet(activeState.optJSONArray("insertableNodes")),
50
+ canUndo = historyState.optBoolean("canUndo", false),
51
+ canRedo = historyState.optBoolean("canRedo", false)
52
+ )
53
+ }
54
+
55
+ private fun boolMap(json: JSONObject?): Map<String, Boolean> {
56
+ json ?: return emptyMap()
57
+ val result = mutableMapOf<String, Boolean>()
58
+ val keys = json.keys()
59
+ while (keys.hasNext()) {
60
+ val key = keys.next()
61
+ result[key] = json.optBoolean(key, false)
62
+ }
63
+ return result
64
+ }
65
+
66
+ private fun stringSet(array: org.json.JSONArray?): Set<String> {
67
+ array ?: return emptySet()
68
+ val result = linkedSetOf<String>()
69
+ for (index in 0 until array.length()) {
70
+ array.optString(index, null)?.let { result.add(it) }
71
+ }
72
+ return result
73
+ }
74
+ }
75
+ }
76
+
77
+ internal enum class ToolbarCommand {
78
+ indentList,
79
+ outdentList,
80
+ undo,
81
+ redo,
82
+ }
83
+
84
+ internal enum class ToolbarListType {
85
+ bulletList,
86
+ orderedList,
87
+ }
88
+
89
+ internal enum class ToolbarDefaultIconId {
90
+ bold,
91
+ italic,
92
+ underline,
93
+ strike,
94
+ bulletList,
95
+ orderedList,
96
+ indentList,
97
+ outdentList,
98
+ lineBreak,
99
+ horizontalRule,
100
+ undo,
101
+ redo,
102
+ }
103
+
104
+ internal enum class ToolbarItemKind {
105
+ mark,
106
+ list,
107
+ command,
108
+ node,
109
+ action,
110
+ separator,
111
+ }
112
+
113
+ internal data class NativeToolbarIcon(
114
+ val defaultId: ToolbarDefaultIconId? = null,
115
+ val glyphText: String? = null,
116
+ val fallbackText: String? = null,
117
+ val materialIconName: String? = null
118
+ ) {
119
+ companion object {
120
+ private val defaultGlyphs = mapOf(
121
+ ToolbarDefaultIconId.bold to "B",
122
+ ToolbarDefaultIconId.italic to "I",
123
+ ToolbarDefaultIconId.underline to "U",
124
+ ToolbarDefaultIconId.strike to "S",
125
+ ToolbarDefaultIconId.bulletList to "•≡",
126
+ ToolbarDefaultIconId.orderedList to "1.",
127
+ ToolbarDefaultIconId.indentList to "→",
128
+ ToolbarDefaultIconId.outdentList to "←",
129
+ ToolbarDefaultIconId.lineBreak to "↵",
130
+ ToolbarDefaultIconId.horizontalRule to "—",
131
+ ToolbarDefaultIconId.undo to "↩",
132
+ ToolbarDefaultIconId.redo to "↪"
133
+ )
134
+ private val defaultMaterialIcons = mapOf(
135
+ ToolbarDefaultIconId.bold to "format-bold",
136
+ ToolbarDefaultIconId.italic to "format-italic",
137
+ ToolbarDefaultIconId.underline to "format-underlined",
138
+ ToolbarDefaultIconId.strike to "strikethrough-s",
139
+ ToolbarDefaultIconId.bulletList to "format-list-bulleted",
140
+ ToolbarDefaultIconId.orderedList to "format-list-numbered",
141
+ ToolbarDefaultIconId.indentList to "format-indent-increase",
142
+ ToolbarDefaultIconId.outdentList to "format-indent-decrease",
143
+ ToolbarDefaultIconId.lineBreak to "keyboard-return",
144
+ ToolbarDefaultIconId.horizontalRule to "horizontal-rule",
145
+ ToolbarDefaultIconId.undo to "undo",
146
+ ToolbarDefaultIconId.redo to "redo"
147
+ )
148
+
149
+ fun fromJson(raw: JSONObject?): NativeToolbarIcon? {
150
+ raw ?: return null
151
+ return when (raw.optString("type")) {
152
+ "default" -> {
153
+ val id = runCatching {
154
+ ToolbarDefaultIconId.valueOf(raw.getString("id"))
155
+ }.getOrNull() ?: return null
156
+ NativeToolbarIcon(defaultId = id)
157
+ }
158
+ "glyph" -> {
159
+ val text = raw.optString("text")
160
+ if (text.isBlank()) null else NativeToolbarIcon(glyphText = text)
161
+ }
162
+ "platform" -> {
163
+ val materialName = raw.optJSONObject("android")
164
+ ?.takeIf { it.optString("type") == "material" }
165
+ ?.optNullableString("name")
166
+ val fallback = raw.optNullableString("fallbackText")
167
+ if (materialName.isNullOrBlank() && fallback.isNullOrBlank()) {
168
+ null
169
+ } else {
170
+ NativeToolbarIcon(
171
+ fallbackText = fallback,
172
+ materialIconName = materialName
173
+ )
174
+ }
175
+ }
176
+ else -> null
177
+ }
178
+ }
179
+
180
+ fun defaultMaterialIconName(defaultId: ToolbarDefaultIconId?): String? =
181
+ defaultId?.let { defaultMaterialIcons[it] }
182
+ }
183
+
184
+ fun resolvedGlyphText(): String =
185
+ glyphText?.takeIf { it.isNotBlank() }
186
+ ?: fallbackText?.takeIf { it.isNotBlank() }
187
+ ?: defaultId?.let { defaultGlyphs[it] }
188
+ ?: "?"
189
+
190
+ fun resolvedMaterialIconName(): String? =
191
+ materialIconName?.takeIf { it.isNotBlank() }
192
+ ?: Companion.defaultMaterialIconName(defaultId)
193
+ }
194
+
195
+ internal object MaterialIconRegistry {
196
+ private const val FONT_ASSET_PATH = "editor-icons/MaterialIcons.ttf"
197
+ private const val GLYPHMAP_ASSET_PATH = "editor-icons/MaterialIcons.json"
198
+
199
+ @Volatile
200
+ private var typeface: Typeface? = null
201
+
202
+ @Volatile
203
+ private var glyphMap: Map<String, String>? = null
204
+
205
+ fun typeface(context: Context): Typeface? {
206
+ val cached = typeface
207
+ if (cached != null) return cached
208
+ return runCatching {
209
+ Typeface.createFromAsset(context.assets, FONT_ASSET_PATH)
210
+ }.getOrNull()?.also { loaded ->
211
+ typeface = loaded
212
+ }
213
+ }
214
+
215
+ fun glyphForName(context: Context, name: String?): String? {
216
+ if (name.isNullOrBlank()) return null
217
+ val map = glyphMap ?: loadGlyphMap(context).also { loaded ->
218
+ glyphMap = loaded
219
+ }
220
+ return map[name]
221
+ }
222
+
223
+ private fun loadGlyphMap(context: Context): Map<String, String> {
224
+ val assetText = runCatching {
225
+ context.assets.open(GLYPHMAP_ASSET_PATH).bufferedReader().use { it.readText() }
226
+ }.getOrNull() ?: return emptyMap()
227
+
228
+ val json = runCatching { JSONObject(assetText) }.getOrNull() ?: return emptyMap()
229
+ val result = linkedMapOf<String, String>()
230
+ val keys = json.keys()
231
+ while (keys.hasNext()) {
232
+ val key = keys.next()
233
+ val codePoint = json.optInt(key, -1)
234
+ if (codePoint > 0) {
235
+ result[key] = String(Character.toChars(codePoint))
236
+ }
237
+ }
238
+ return result
239
+ }
240
+ }
241
+
242
+ internal data class NativeToolbarResolvedIcon(
243
+ val text: String,
244
+ val typeface: Typeface? = null
245
+ )
246
+
247
+ private fun NativeToolbarIcon.resolveForAndroid(context: Context): NativeToolbarResolvedIcon {
248
+ val materialName = resolvedMaterialIconName()
249
+ val materialGlyph = MaterialIconRegistry.glyphForName(context, materialName)
250
+ val materialTypeface = MaterialIconRegistry.typeface(context)
251
+ if (materialGlyph != null && materialTypeface != null) {
252
+ return NativeToolbarResolvedIcon(
253
+ text = materialGlyph,
254
+ typeface = materialTypeface
255
+ )
256
+ }
257
+
258
+ return NativeToolbarResolvedIcon(
259
+ text = resolvedGlyphText(),
260
+ typeface = null
261
+ )
262
+ }
263
+
264
+ internal data class NativeToolbarItem(
265
+ val type: ToolbarItemKind,
266
+ val key: String? = null,
267
+ val label: String? = null,
268
+ val icon: NativeToolbarIcon? = null,
269
+ val mark: String? = null,
270
+ val listType: ToolbarListType? = null,
271
+ val command: ToolbarCommand? = null,
272
+ val nodeType: String? = null,
273
+ val isActive: Boolean = false,
274
+ val isDisabled: Boolean = false
275
+ ) {
276
+ companion object {
277
+ val defaults = listOf(
278
+ NativeToolbarItem(ToolbarItemKind.mark, label = "Bold", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.bold), mark = "bold"),
279
+ NativeToolbarItem(ToolbarItemKind.mark, label = "Italic", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.italic), mark = "italic"),
280
+ NativeToolbarItem(ToolbarItemKind.mark, label = "Underline", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.underline), mark = "underline"),
281
+ NativeToolbarItem(ToolbarItemKind.mark, label = "Strikethrough", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.strike), mark = "strike"),
282
+ NativeToolbarItem(ToolbarItemKind.separator),
283
+ NativeToolbarItem(ToolbarItemKind.list, label = "Bullet List", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.bulletList), listType = ToolbarListType.bulletList),
284
+ NativeToolbarItem(ToolbarItemKind.list, label = "Ordered List", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.orderedList), listType = ToolbarListType.orderedList),
285
+ NativeToolbarItem(ToolbarItemKind.command, label = "Indent List", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.indentList), command = ToolbarCommand.indentList),
286
+ NativeToolbarItem(ToolbarItemKind.command, label = "Outdent List", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.outdentList), command = ToolbarCommand.outdentList),
287
+ NativeToolbarItem(ToolbarItemKind.node, label = "Line Break", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.lineBreak), nodeType = "hardBreak"),
288
+ NativeToolbarItem(ToolbarItemKind.node, label = "Horizontal Rule", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.horizontalRule), nodeType = "horizontalRule"),
289
+ NativeToolbarItem(ToolbarItemKind.separator),
290
+ NativeToolbarItem(ToolbarItemKind.command, label = "Undo", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.undo), command = ToolbarCommand.undo),
291
+ NativeToolbarItem(ToolbarItemKind.command, label = "Redo", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.redo), command = ToolbarCommand.redo)
292
+ )
293
+
294
+ fun fromJson(json: String?): List<NativeToolbarItem> {
295
+ if (json.isNullOrBlank()) return defaults
296
+ val rawArray = try {
297
+ org.json.JSONArray(json)
298
+ } catch (_: Exception) {
299
+ return defaults
300
+ }
301
+ val parsed = mutableListOf<NativeToolbarItem>()
302
+ for (index in 0 until rawArray.length()) {
303
+ val rawItem = rawArray.optJSONObject(index) ?: continue
304
+ val type = runCatching {
305
+ ToolbarItemKind.valueOf(rawItem.getString("type"))
306
+ }.getOrNull() ?: continue
307
+ val key = rawItem.optNullableString("key")
308
+ when (type) {
309
+ ToolbarItemKind.separator -> parsed.add(NativeToolbarItem(type = type, key = key))
310
+ ToolbarItemKind.mark -> {
311
+ val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: continue
312
+ val mark = rawItem.optNullableString("mark") ?: continue
313
+ val label = rawItem.optNullableString("label") ?: continue
314
+ parsed.add(NativeToolbarItem(type, key, label, icon, mark = mark))
315
+ }
316
+ ToolbarItemKind.list -> {
317
+ val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: continue
318
+ val listType = runCatching {
319
+ ToolbarListType.valueOf(rawItem.getString("listType"))
320
+ }.getOrNull() ?: continue
321
+ val label = rawItem.optNullableString("label") ?: continue
322
+ parsed.add(NativeToolbarItem(type, key, label, icon, listType = listType))
323
+ }
324
+ ToolbarItemKind.command -> {
325
+ val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: continue
326
+ val command = runCatching {
327
+ ToolbarCommand.valueOf(rawItem.getString("command"))
328
+ }.getOrNull() ?: continue
329
+ val label = rawItem.optNullableString("label") ?: continue
330
+ parsed.add(NativeToolbarItem(type, key, label, icon, command = command))
331
+ }
332
+ ToolbarItemKind.node -> {
333
+ val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: continue
334
+ val nodeType = rawItem.optNullableString("nodeType") ?: continue
335
+ val label = rawItem.optNullableString("label") ?: continue
336
+ parsed.add(NativeToolbarItem(type, key, label, icon, nodeType = nodeType))
337
+ }
338
+ ToolbarItemKind.action -> {
339
+ val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: continue
340
+ val keyValue = rawItem.optNullableString("key") ?: continue
341
+ val label = rawItem.optNullableString("label") ?: continue
342
+ parsed.add(
343
+ NativeToolbarItem(
344
+ type = type,
345
+ key = keyValue,
346
+ label = label,
347
+ icon = icon,
348
+ isActive = rawItem.optBoolean("isActive", false),
349
+ isDisabled = rawItem.optBoolean("isDisabled", false)
350
+ )
351
+ )
352
+ }
353
+ }
354
+ }
355
+ return parsed.ifEmpty { defaults }
356
+ }
357
+ }
358
+ }
359
+
360
+ internal class EditorKeyboardToolbarView(context: Context) : HorizontalScrollView(context) {
361
+ private data class ButtonBinding(
362
+ val item: NativeToolbarItem,
363
+ val button: AppCompatButton
364
+ )
365
+
366
+ var onPressItem: ((NativeToolbarItem) -> Unit)? = null
367
+ var onSelectMentionSuggestion: ((NativeMentionSuggestion) -> Unit)? = null
368
+
369
+ private val contentRow = LinearLayout(context)
370
+ private var theme: EditorToolbarTheme? = null
371
+ private var mentionTheme: EditorMentionTheme? = null
372
+ private var state: NativeToolbarState = NativeToolbarState.empty
373
+ private var items: List<NativeToolbarItem> = NativeToolbarItem.defaults
374
+ private var mentionSuggestions: List<NativeMentionSuggestion> = emptyList()
375
+ private val bindings = mutableListOf<ButtonBinding>()
376
+ private val separators = mutableListOf<View>()
377
+ private val mentionChips = mutableListOf<MentionSuggestionChipView>()
378
+ private val density = resources.displayMetrics.density
379
+ internal var appliedChromeCornerRadiusPx: Float = 0f
380
+ private set
381
+ internal var appliedChromeStrokeWidthPx: Int = 0
382
+ private set
383
+ internal var appliedButtonCornerRadiusPx: Float = 0f
384
+ private set
385
+ val isShowingMentionSuggestions: Boolean
386
+ get() = mentionSuggestions.isNotEmpty()
387
+
388
+ init {
389
+ isHorizontalScrollBarEnabled = false
390
+ overScrollMode = OVER_SCROLL_NEVER
391
+ setBackgroundColor(Color.TRANSPARENT)
392
+ clipToPadding = false
393
+
394
+ contentRow.orientation = LinearLayout.HORIZONTAL
395
+ contentRow.gravity = Gravity.CENTER_VERTICAL
396
+ contentRow.setPadding(dp(12))
397
+ addView(
398
+ contentRow,
399
+ LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
400
+ )
401
+ rebuildContent()
402
+ }
403
+
404
+ fun setItems(items: List<NativeToolbarItem>) {
405
+ this.items = compactItems(items)
406
+ if (!isShowingMentionSuggestions) {
407
+ rebuildContent()
408
+ }
409
+ }
410
+
411
+ fun applyTheme(theme: EditorToolbarTheme?) {
412
+ this.theme = theme
413
+ updateChrome()
414
+ separators.forEach { separator ->
415
+ separator.setBackgroundColor(theme?.separatorColor ?: Color.parseColor("#E5E5EA"))
416
+ }
417
+ bindings.forEach { binding ->
418
+ updateButtonAppearance(
419
+ binding.button,
420
+ enabled = buttonState(binding.item, state).first,
421
+ active = buttonState(binding.item, state).second
422
+ )
423
+ }
424
+ mentionChips.forEach { chip ->
425
+ chip.applyTheme(mentionTheme)
426
+ }
427
+ }
428
+
429
+ fun applyMentionTheme(theme: EditorMentionTheme?) {
430
+ mentionTheme = theme
431
+ mentionChips.forEach { chip ->
432
+ chip.applyTheme(theme)
433
+ }
434
+ }
435
+
436
+ fun applyState(state: NativeToolbarState) {
437
+ this.state = state
438
+ bindings.forEach { binding ->
439
+ val (enabled, active) = buttonState(binding.item, state)
440
+ binding.button.isEnabled = enabled
441
+ binding.button.isSelected = active
442
+ updateButtonAppearance(binding.button, enabled, active)
443
+ }
444
+ }
445
+
446
+ fun setMentionSuggestions(suggestions: List<NativeMentionSuggestion>): Boolean {
447
+ val hadSuggestions = isShowingMentionSuggestions
448
+ mentionSuggestions = suggestions.take(8)
449
+ rebuildContent()
450
+ return hadSuggestions != isShowingMentionSuggestions
451
+ }
452
+
453
+ fun triggerMentionSuggestionTapForTesting(index: Int) {
454
+ mentionChips.getOrNull(index)?.performClick()
455
+ }
456
+
457
+ private fun rebuildContent() {
458
+ bindings.clear()
459
+ separators.clear()
460
+ mentionChips.clear()
461
+ contentRow.removeAllViews()
462
+
463
+ if (isShowingMentionSuggestions) {
464
+ rebuildMentionSuggestions()
465
+ } else {
466
+ rebuildButtons()
467
+ }
468
+
469
+ updateChrome()
470
+ applyState(state)
471
+ scrollTo(0, 0)
472
+ }
473
+
474
+ private fun rebuildButtons() {
475
+ for (item in compactItems(items)) {
476
+ if (item.type == ToolbarItemKind.separator) {
477
+ val separator = View(context)
478
+ val params = LinearLayout.LayoutParams(dp(1), dp(22))
479
+ params.marginStart = dp(6)
480
+ params.marginEnd = dp(6)
481
+ separator.layoutParams = params
482
+ separator.setBackgroundColor(theme?.separatorColor ?: Color.parseColor("#E5E5EA"))
483
+ separators.add(separator)
484
+ contentRow.addView(separator)
485
+ continue
486
+ }
487
+
488
+ val button = AppCompatButton(context).apply {
489
+ val resolvedIcon = item.icon?.resolveForAndroid(context)
490
+ ?: NativeToolbarResolvedIcon("?")
491
+ text = resolvedIcon.text
492
+ typeface = resolvedIcon.typeface ?: Typeface.DEFAULT
493
+ textSize = 16f
494
+ minWidth = dp(36)
495
+ minimumWidth = dp(36)
496
+ minHeight = dp(36)
497
+ minimumHeight = dp(36)
498
+ gravity = Gravity.CENTER
499
+ setPadding(dp(10), dp(8), dp(10), dp(8))
500
+ background = GradientDrawable()
501
+ isAllCaps = false
502
+ includeFontPadding = false
503
+ contentDescription = item.label
504
+ setOnClickListener { onPressItem?.invoke(item) }
505
+ }
506
+ val params = LinearLayout.LayoutParams(
507
+ LinearLayout.LayoutParams.WRAP_CONTENT,
508
+ LinearLayout.LayoutParams.WRAP_CONTENT
509
+ )
510
+ params.marginEnd = dp(6)
511
+ button.layoutParams = params
512
+ bindings.add(ButtonBinding(item, button))
513
+ contentRow.addView(button)
514
+ }
515
+ }
516
+
517
+ private fun rebuildMentionSuggestions() {
518
+ for (suggestion in mentionSuggestions) {
519
+ val chip = MentionSuggestionChipView(context, suggestion).apply {
520
+ applyTheme(mentionTheme)
521
+ setOnClickListener { onSelectMentionSuggestion?.invoke(suggestion) }
522
+ }
523
+ val params = LinearLayout.LayoutParams(
524
+ LinearLayout.LayoutParams.WRAP_CONTENT,
525
+ LinearLayout.LayoutParams.WRAP_CONTENT
526
+ )
527
+ params.marginEnd = dp(8)
528
+ chip.layoutParams = params
529
+ mentionChips.add(chip)
530
+ contentRow.addView(chip)
531
+ }
532
+ }
533
+
534
+ private fun compactItems(items: List<NativeToolbarItem>): List<NativeToolbarItem> {
535
+ return items.filterIndexed { index, item ->
536
+ if (item.type != ToolbarItemKind.separator) return@filterIndexed true
537
+ index > 0 &&
538
+ index < items.lastIndex &&
539
+ items[index - 1].type != ToolbarItemKind.separator &&
540
+ items[index + 1].type != ToolbarItemKind.separator
541
+ }
542
+ }
543
+
544
+ private fun updateChrome() {
545
+ val cornerRadiusPx = (theme?.borderRadius ?: 0f) * density
546
+ val strokeWidthPx = ((theme?.borderWidth ?: 1f) * density).toInt().coerceAtLeast(1)
547
+ val drawable = GradientDrawable().apply {
548
+ shape = GradientDrawable.RECTANGLE
549
+ cornerRadius = cornerRadiusPx
550
+ setColor(theme?.backgroundColor ?: Color.WHITE)
551
+ setStroke(strokeWidthPx, theme?.borderColor ?: Color.parseColor("#E5E5EA"))
552
+ }
553
+ appliedChromeCornerRadiusPx = cornerRadiusPx
554
+ appliedChromeStrokeWidthPx = strokeWidthPx
555
+ background = drawable
556
+ elevation = 0f
557
+ }
558
+
559
+ private fun updateButtonAppearance(button: AppCompatButton, enabled: Boolean, active: Boolean) {
560
+ val textColor = when {
561
+ !enabled -> theme?.buttonDisabledColor ?: Color.parseColor("#C7C7CC")
562
+ active -> theme?.buttonActiveColor ?: Color.parseColor("#007AFF")
563
+ else -> theme?.buttonColor ?: Color.parseColor("#666666")
564
+ }
565
+ val backgroundColor = if (active) {
566
+ theme?.buttonActiveBackgroundColor ?: Color.parseColor("#1F007AFF")
567
+ } else {
568
+ Color.TRANSPARENT
569
+ }
570
+ val buttonCornerRadiusPx = (theme?.buttonBorderRadius ?: 6f) * density
571
+ val drawable = GradientDrawable().apply {
572
+ shape = GradientDrawable.RECTANGLE
573
+ cornerRadius = buttonCornerRadiusPx
574
+ setColor(backgroundColor)
575
+ }
576
+ appliedButtonCornerRadiusPx = buttonCornerRadiusPx
577
+ button.background = drawable
578
+ button.setTextColor(textColor)
579
+ button.alpha = if (enabled) 1f else 0.7f
580
+ }
581
+
582
+ private fun buttonState(
583
+ item: NativeToolbarItem,
584
+ state: NativeToolbarState
585
+ ): Pair<Boolean, Boolean> {
586
+ val isInList = state.nodes["bulletList"] == true || state.nodes["orderedList"] == true
587
+ return when (item.type) {
588
+ ToolbarItemKind.mark -> {
589
+ val mark = item.mark.orEmpty()
590
+ Pair(state.allowedMarks.contains(mark), state.marks[mark] == true)
591
+ }
592
+ ToolbarItemKind.list -> when (item.listType) {
593
+ ToolbarListType.bulletList -> Pair(
594
+ state.commands["wrapBulletList"] == true,
595
+ state.nodes["bulletList"] == true
596
+ )
597
+ ToolbarListType.orderedList -> Pair(
598
+ state.commands["wrapOrderedList"] == true,
599
+ state.nodes["orderedList"] == true
600
+ )
601
+ null -> Pair(false, false)
602
+ }
603
+ ToolbarItemKind.command -> when (item.command) {
604
+ ToolbarCommand.indentList -> Pair(isInList && state.commands["indentList"] == true, false)
605
+ ToolbarCommand.outdentList -> Pair(isInList && state.commands["outdentList"] == true, false)
606
+ ToolbarCommand.undo -> Pair(state.canUndo, false)
607
+ ToolbarCommand.redo -> Pair(state.canRedo, false)
608
+ null -> Pair(false, false)
609
+ }
610
+ ToolbarItemKind.node -> {
611
+ val nodeType = item.nodeType.orEmpty()
612
+ Pair(state.insertableNodes.contains(nodeType), state.nodes[nodeType] == true)
613
+ }
614
+ ToolbarItemKind.action -> Pair(!item.isDisabled, item.isActive)
615
+ ToolbarItemKind.separator -> Pair(false, false)
616
+ }
617
+ }
618
+
619
+ private fun dp(value: Int): Int = (value * density).toInt()
620
+ }
621
+
622
+ private class MentionSuggestionChipView(
623
+ context: Context,
624
+ val suggestion: NativeMentionSuggestion
625
+ ) : LinearLayout(context) {
626
+ private val titleView = AppCompatTextView(context)
627
+ private val subtitleView = AppCompatTextView(context)
628
+ private var theme: EditorMentionTheme? = null
629
+ private val density = resources.displayMetrics.density
630
+
631
+ init {
632
+ orientation = VERTICAL
633
+ gravity = Gravity.CENTER_VERTICAL
634
+ minimumHeight = dp(40)
635
+ setPadding(dp(12), dp(8), dp(12), dp(8))
636
+ isClickable = true
637
+ isFocusable = true
638
+
639
+ titleView.apply {
640
+ text = suggestion.label
641
+ setTypeface(typeface, Typeface.BOLD)
642
+ textSize = 14f
643
+ includeFontPadding = false
644
+ }
645
+ addView(
646
+ titleView,
647
+ LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
648
+ )
649
+
650
+ subtitleView.apply {
651
+ text = suggestion.subtitle
652
+ textSize = 12f
653
+ includeFontPadding = false
654
+ visibility = if (suggestion.subtitle.isNullOrBlank()) View.GONE else View.VISIBLE
655
+ }
656
+ addView(
657
+ subtitleView,
658
+ LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
659
+ )
660
+
661
+ setOnTouchListener { _, motionEvent ->
662
+ when (motionEvent.actionMasked) {
663
+ android.view.MotionEvent.ACTION_DOWN,
664
+ android.view.MotionEvent.ACTION_MOVE -> updateAppearance(highlighted = true)
665
+ android.view.MotionEvent.ACTION_CANCEL,
666
+ android.view.MotionEvent.ACTION_UP -> updateAppearance(highlighted = false)
667
+ }
668
+ false
669
+ }
670
+
671
+ applyTheme(null)
672
+ }
673
+
674
+ fun applyTheme(theme: EditorMentionTheme?) {
675
+ this.theme = theme
676
+ val hasSubtitle = !suggestion.subtitle.isNullOrBlank()
677
+ subtitleView.visibility = if (hasSubtitle) View.VISIBLE else View.GONE
678
+ background = GradientDrawable().apply {
679
+ shape = GradientDrawable.RECTANGLE
680
+ cornerRadius = (theme?.borderRadius ?: 12f) * density
681
+ setColor(theme?.backgroundColor ?: Color.parseColor("#F2F2F7"))
682
+ val strokeWidth = ((theme?.borderWidth ?: 0f) * density).toInt()
683
+ if (strokeWidth > 0) {
684
+ setStroke(strokeWidth, theme?.borderColor ?: Color.TRANSPARENT)
685
+ }
686
+ }
687
+ updateAppearance(highlighted = false)
688
+ }
689
+
690
+ private fun updateAppearance(highlighted: Boolean) {
691
+ val backgroundDrawable = background as? GradientDrawable
692
+ val backgroundColor = if (highlighted) {
693
+ theme?.optionHighlightedBackgroundColor ?: Color.parseColor("#1F007AFF")
694
+ } else {
695
+ theme?.backgroundColor ?: Color.parseColor("#F2F2F7")
696
+ }
697
+ backgroundDrawable?.setColor(backgroundColor)
698
+ titleView.setTextColor(
699
+ if (highlighted) {
700
+ theme?.optionHighlightedTextColor ?: theme?.optionTextColor ?: Color.BLACK
701
+ } else {
702
+ theme?.optionTextColor ?: theme?.textColor ?: Color.BLACK
703
+ }
704
+ )
705
+ subtitleView.setTextColor(theme?.optionSecondaryTextColor ?: Color.DKGRAY)
706
+ }
707
+
708
+ private fun dp(value: Int): Int = (value * density).toInt()
709
+ }
710
+
711
+ private fun JSONObject.optNullableString(key: String): String? {
712
+ if (!has(key) || isNull(key)) return null
713
+ return optString(key).takeUnless { it == "null" }
714
+ }