@apollohg/react-native-prose-editor 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +18 -0
  2. package/android/build.gradle +23 -0
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +515 -39
  4. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +58 -28
  5. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +25 -0
  6. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +232 -62
  7. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +57 -27
  8. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +147 -78
  9. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +249 -71
  10. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +7 -6
  11. package/dist/EditorToolbar.d.ts +26 -6
  12. package/dist/EditorToolbar.js +299 -65
  13. package/dist/NativeEditorBridge.d.ts +40 -1
  14. package/dist/NativeEditorBridge.js +184 -90
  15. package/dist/NativeRichTextEditor.d.ts +5 -1
  16. package/dist/NativeRichTextEditor.js +201 -78
  17. package/dist/YjsCollaboration.d.ts +2 -0
  18. package/dist/YjsCollaboration.js +142 -20
  19. package/dist/index.d.ts +1 -1
  20. package/dist/schemas.js +12 -0
  21. package/dist/useNativeEditor.d.ts +2 -0
  22. package/dist/useNativeEditor.js +7 -0
  23. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  24. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  25. package/ios/EditorLayoutManager.swift +3 -3
  26. package/ios/Generated_editor_core.swift +87 -0
  27. package/ios/NativeEditorExpoView.swift +488 -178
  28. package/ios/NativeEditorModule.swift +25 -0
  29. package/ios/PositionBridge.swift +310 -75
  30. package/ios/RenderBridge.swift +362 -27
  31. package/ios/RichTextEditorView.swift +2001 -189
  32. package/ios/editor_coreFFI/editor_coreFFI.h +55 -0
  33. package/package.json +11 -2
  34. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  35. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  36. package/rust/android/x86_64/libeditor_core.so +0 -0
  37. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +128 -0
@@ -65,6 +65,11 @@ class NativeEditorExpoView(
65
65
  private var addons = NativeEditorAddons(null)
66
66
  private var mentionQueryState: MentionQueryState? = null
67
67
  private var lastMentionEventJson: String? = null
68
+ private var lastThemeJson: String? = null
69
+ private var lastAddonsJson: String? = null
70
+ private var lastRemoteSelectionsJson: String? = null
71
+ private var lastToolbarItemsJson: String? = null
72
+ private var lastToolbarFrameJson: String? = null
68
73
  private var toolbarState = NativeToolbarState.empty
69
74
  private var showsToolbar = true
70
75
  private var toolbarPlacement = ToolbarPlacement.KEYBOARD
@@ -110,6 +115,8 @@ class NativeEditorExpoView(
110
115
  }
111
116
 
112
117
  fun setThemeJson(themeJson: String?) {
118
+ if (lastThemeJson == themeJson) return
119
+ lastThemeJson = themeJson
113
120
  val theme = EditorTheme.fromJson(themeJson)
114
121
  richTextView.applyTheme(theme)
115
122
  keyboardToolbarView.applyTheme(theme?.toolbar)
@@ -141,12 +148,16 @@ class NativeEditorExpoView(
141
148
  }
142
149
 
143
150
  fun setAddonsJson(addonsJson: String?) {
151
+ if (lastAddonsJson == addonsJson) return
152
+ lastAddonsJson = addonsJson
144
153
  addons = NativeEditorAddons.fromJson(addonsJson)
145
154
  keyboardToolbarView.applyMentionTheme(richTextView.editorEditText.theme?.mentions ?: addons.mentions?.theme)
146
155
  refreshMentionQuery()
147
156
  }
148
157
 
149
158
  fun setRemoteSelectionsJson(remoteSelectionsJson: String?) {
159
+ if (lastRemoteSelectionsJson == remoteSelectionsJson) return
160
+ lastRemoteSelectionsJson = remoteSelectionsJson
150
161
  richTextView.setRemoteSelections(
151
162
  RemoteSelectionDecoration.fromJson(context, remoteSelectionsJson)
152
163
  )
@@ -175,10 +186,14 @@ class NativeEditorExpoView(
175
186
  }
176
187
 
177
188
  fun setToolbarItemsJson(toolbarItemsJson: String?) {
189
+ if (lastToolbarItemsJson == toolbarItemsJson) return
190
+ lastToolbarItemsJson = toolbarItemsJson
178
191
  keyboardToolbarView.setItems(NativeToolbarItem.fromJson(toolbarItemsJson))
179
192
  }
180
193
 
181
194
  fun setToolbarFrameJson(toolbarFrameJson: String?) {
195
+ if (lastToolbarFrameJson == toolbarFrameJson) return
196
+ lastToolbarFrameJson = toolbarFrameJson
182
197
  if (toolbarFrameJson.isNullOrBlank()) {
183
198
  toolbarFrameInWindow = null
184
199
  return
@@ -260,31 +275,44 @@ class NativeEditorExpoView(
260
275
  if (heightBehavior != EditorHeightBehavior.AUTO_GROW) return
261
276
  val editText = richTextView.editorEditText
262
277
  val resolvedEditHeight = editText.resolveAutoGrowHeight()
278
+ val resolvedContainerHeight =
279
+ resolvedEditHeight +
280
+ richTextView.paddingTop +
281
+ richTextView.paddingBottom +
282
+ paddingTop +
283
+ paddingBottom
263
284
  val contentHeight = (
264
285
  when {
265
286
  editText.isLaidOut && (editText.layout?.height ?: 0) > 0 -> {
266
- (editText.layout?.height ?: 0) +
267
- editText.compoundPaddingTop +
268
- editText.compoundPaddingBottom +
269
- richTextView.paddingTop +
270
- richTextView.paddingBottom
287
+ maxOf(
288
+ (editText.layout?.height ?: 0) +
289
+ editText.compoundPaddingTop +
290
+ editText.compoundPaddingBottom +
291
+ richTextView.paddingTop +
292
+ richTextView.paddingBottom +
293
+ paddingTop +
294
+ paddingBottom,
295
+ resolvedContainerHeight
296
+ )
271
297
  }
272
298
  richTextView.measuredHeight > 0 -> {
273
- richTextView.measuredHeight + paddingTop + paddingBottom
299
+ maxOf(
300
+ richTextView.measuredHeight + paddingTop + paddingBottom,
301
+ resolvedContainerHeight
302
+ )
274
303
  }
275
304
  editText.measuredHeight > 0 -> {
276
- editText.measuredHeight +
277
- richTextView.paddingTop +
278
- richTextView.paddingBottom +
279
- paddingTop +
280
- paddingBottom
305
+ maxOf(
306
+ editText.measuredHeight +
307
+ richTextView.paddingTop +
308
+ richTextView.paddingBottom +
309
+ paddingTop +
310
+ paddingBottom,
311
+ resolvedContainerHeight
312
+ )
281
313
  }
282
314
  else -> {
283
- resolvedEditHeight +
284
- richTextView.paddingTop +
285
- richTextView.paddingBottom +
286
- paddingTop +
287
- paddingBottom
315
+ resolvedContainerHeight
288
316
  }
289
317
  }
290
318
  ).coerceAtLeast(0)
@@ -304,20 +332,20 @@ class NativeEditorExpoView(
304
332
  if (android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) {
305
333
  apply.run()
306
334
  } else {
307
- val latch = java.util.concurrent.CountDownLatch(1)
308
- post {
309
- apply.run()
310
- latch.countDown()
335
+ if (!post(apply)) {
336
+ richTextView.post(apply)
311
337
  }
312
- latch.await()
313
338
  }
314
339
  }
315
340
 
316
341
  override fun onSelectionChanged(anchor: Int, head: Int) {
317
- refreshToolbarStateFromEditorSelection()
342
+ val stateJson = refreshToolbarStateFromEditorSelection()
318
343
  refreshMentionQuery()
319
344
  richTextView.refreshRemoteSelections()
320
- val event = mapOf<String, Any>("anchor" to anchor, "head" to head)
345
+ val event = mutableMapOf<String, Any>("anchor" to anchor, "head" to head)
346
+ if (stateJson != null) {
347
+ event["stateJson"] = stateJson
348
+ }
321
349
  onSelectionChange(event)
322
350
  }
323
351
 
@@ -541,13 +569,13 @@ class NativeEditorExpoView(
541
569
  clearMentionQueryState()
542
570
  }
543
571
 
544
- private fun refreshToolbarStateFromEditorSelection() {
545
- if (richTextView.editorId == 0L) return
546
- val state = NativeToolbarState.fromUpdateJson(
547
- editorGetCurrentState(richTextView.editorId.toULong())
548
- ) ?: return
572
+ private fun refreshToolbarStateFromEditorSelection(): String? {
573
+ if (richTextView.editorId == 0L) return null
574
+ val stateJson = editorGetSelectionState(richTextView.editorId.toULong())
575
+ val state = NativeToolbarState.fromUpdateJson(stateJson) ?: return null
549
576
  toolbarState = state
550
577
  keyboardToolbarView.applyState(state)
578
+ return stateJson
551
579
  }
552
580
 
553
581
  private fun ensureKeyboardToolbarAttached() {
@@ -642,6 +670,7 @@ class NativeEditorExpoView(
642
670
  private fun handleToolbarItemPress(item: NativeToolbarItem) {
643
671
  when (item.type) {
644
672
  ToolbarItemKind.mark -> item.mark?.let { richTextView.editorEditText.performToolbarToggleMark(it) }
673
+ ToolbarItemKind.heading -> item.headingLevel?.let { richTextView.editorEditText.performToolbarToggleHeading(it) }
645
674
  ToolbarItemKind.blockquote -> richTextView.editorEditText.performToolbarToggleBlockquote()
646
675
  ToolbarItemKind.list -> item.listType?.name?.let { handleListToggle(it) }
647
676
  ToolbarItemKind.command -> when (item.command) {
@@ -653,6 +682,7 @@ class NativeEditorExpoView(
653
682
  }
654
683
  ToolbarItemKind.node -> item.nodeType?.let { richTextView.editorEditText.performToolbarInsertNode(it) }
655
684
  ToolbarItemKind.action -> item.key?.let { onToolbarAction(mapOf("key" to it)) }
685
+ ToolbarItemKind.group -> Unit
656
686
  ToolbarItemKind.separator -> Unit
657
687
  }
658
688
  }
@@ -63,6 +63,9 @@ class NativeEditorModule : Module() {
63
63
  Function("editorGetJson") { id: Int ->
64
64
  editorGetJson(id.toULong())
65
65
  }
66
+ Function("editorGetContentSnapshot") { id: Int ->
67
+ editorGetContentSnapshot(id.toULong())
68
+ }
66
69
 
67
70
  Function("editorInsertText") { id: Int, pos: Int, text: String ->
68
71
  editorInsertText(id.toULong(), pos.toUInt(), text)
@@ -169,6 +172,19 @@ class NativeEditorModule : Module() {
169
172
  scalarHead.toUInt()
170
173
  )
171
174
  }
175
+ Function(
176
+ "editorToggleHeadingAtSelectionScalar"
177
+ ) { id: Int, scalarAnchor: Int, scalarHead: Int, level: Int ->
178
+ if (level !in 1..6) {
179
+ return@Function "{\"error\":\"invalid heading level\"}"
180
+ }
181
+ editorToggleHeadingAtSelectionScalar(
182
+ id.toULong(),
183
+ scalarAnchor.toUInt(),
184
+ scalarHead.toUInt(),
185
+ level.toUByte()
186
+ )
187
+ }
172
188
  Function(
173
189
  "editorWrapInListAtSelectionScalar"
174
190
  ) { id: Int, scalarAnchor: Int, scalarHead: Int, listType: String ->
@@ -229,6 +245,12 @@ class NativeEditorModule : Module() {
229
245
  Function("editorToggleBlockquote") { id: Int ->
230
246
  editorToggleBlockquote(id.toULong())
231
247
  }
248
+ Function("editorToggleHeading") { id: Int, level: Int ->
249
+ if (level !in 1..6) {
250
+ return@Function "{\"error\":\"invalid heading level\"}"
251
+ }
252
+ editorToggleHeading(id.toULong(), level.toUByte())
253
+ }
232
254
 
233
255
  Function("editorSetSelection") { id: Int, anchor: Int, head: Int ->
234
256
  editorSetSelection(id.toULong(), anchor.toUInt(), head.toUInt())
@@ -239,6 +261,9 @@ class NativeEditorModule : Module() {
239
261
  Function("editorGetSelection") { id: Int ->
240
262
  editorGetSelection(id.toULong())
241
263
  }
264
+ Function("editorGetSelectionState") { id: Int ->
265
+ editorGetSelectionState(id.toULong())
266
+ }
242
267
  Function("editorDocToScalar") { id: Int, docPos: Int ->
243
268
  editorDocToScalar(id.toULong(), docPos.toUInt()).toInt()
244
269
  }
@@ -12,6 +12,7 @@ import android.view.ViewOutlineProvider
12
12
  import android.widget.HorizontalScrollView
13
13
  import android.widget.LinearLayout
14
14
  import androidx.appcompat.widget.AppCompatButton
15
+ import androidx.appcompat.widget.PopupMenu
15
16
  import androidx.appcompat.widget.AppCompatTextView
16
17
  import androidx.appcompat.content.res.AppCompatResources
17
18
  import androidx.core.view.setPadding
@@ -100,6 +101,12 @@ internal enum class ToolbarDefaultIconId {
100
101
  strike,
101
102
  link,
102
103
  image,
104
+ h1,
105
+ h2,
106
+ h3,
107
+ h4,
108
+ h5,
109
+ h6,
103
110
  blockquote,
104
111
  bulletList,
105
112
  orderedList,
@@ -113,14 +120,21 @@ internal enum class ToolbarDefaultIconId {
113
120
 
114
121
  internal enum class ToolbarItemKind {
115
122
  mark,
123
+ heading,
116
124
  blockquote,
117
125
  list,
118
126
  command,
119
127
  node,
120
128
  action,
129
+ group,
121
130
  separator,
122
131
  }
123
132
 
133
+ internal enum class ToolbarGroupPresentation {
134
+ expand,
135
+ menu,
136
+ }
137
+
124
138
  internal data class NativeToolbarIcon(
125
139
  val defaultId: ToolbarDefaultIconId? = null,
126
140
  val glyphText: String? = null,
@@ -135,6 +149,12 @@ internal data class NativeToolbarIcon(
135
149
  ToolbarDefaultIconId.strike to "S",
136
150
  ToolbarDefaultIconId.link to "🔗",
137
151
  ToolbarDefaultIconId.image to "🖼",
152
+ ToolbarDefaultIconId.h1 to "H1",
153
+ ToolbarDefaultIconId.h2 to "H2",
154
+ ToolbarDefaultIconId.h3 to "H3",
155
+ ToolbarDefaultIconId.h4 to "H4",
156
+ ToolbarDefaultIconId.h5 to "H5",
157
+ ToolbarDefaultIconId.h6 to "H6",
138
158
  ToolbarDefaultIconId.blockquote to "❝",
139
159
  ToolbarDefaultIconId.bulletList to "•≡",
140
160
  ToolbarDefaultIconId.orderedList to "1.",
@@ -159,6 +179,12 @@ internal data class NativeToolbarIcon(
159
179
  ToolbarDefaultIconId.outdentList to "format-indent-decrease",
160
180
  ToolbarDefaultIconId.lineBreak to "keyboard-return",
161
181
  ToolbarDefaultIconId.horizontalRule to "horizontal-rule",
182
+ ToolbarDefaultIconId.h1 to "title",
183
+ ToolbarDefaultIconId.h2 to "title",
184
+ ToolbarDefaultIconId.h3 to "title",
185
+ ToolbarDefaultIconId.h4 to "title",
186
+ ToolbarDefaultIconId.h5 to "title",
187
+ ToolbarDefaultIconId.h6 to "title",
162
188
  ToolbarDefaultIconId.undo to "undo",
163
189
  ToolbarDefaultIconId.redo to "redo"
164
190
  )
@@ -284,11 +310,15 @@ internal data class NativeToolbarItem(
284
310
  val label: String? = null,
285
311
  val icon: NativeToolbarIcon? = null,
286
312
  val mark: String? = null,
313
+ val headingLevel: Int? = null,
287
314
  val listType: ToolbarListType? = null,
288
315
  val command: ToolbarCommand? = null,
289
316
  val nodeType: String? = null,
290
317
  val isActive: Boolean = false,
291
- val isDisabled: Boolean = false
318
+ val isDisabled: Boolean = false,
319
+ val presentation: ToolbarGroupPresentation? = null,
320
+ val items: List<NativeToolbarItem> = emptyList(),
321
+ val parentGroupKey: String? = null
292
322
  ) {
293
323
  companion object {
294
324
  val defaults = listOf(
@@ -309,6 +339,105 @@ internal data class NativeToolbarItem(
309
339
  NativeToolbarItem(ToolbarItemKind.command, label = "Redo", icon = NativeToolbarIcon(defaultId = ToolbarDefaultIconId.redo), command = ToolbarCommand.redo)
310
340
  )
311
341
 
342
+ private fun parseItem(
343
+ rawItem: JSONObject,
344
+ allowGroup: Boolean = true,
345
+ allowSeparator: Boolean = true
346
+ ): NativeToolbarItem? {
347
+ val type = runCatching {
348
+ ToolbarItemKind.valueOf(rawItem.getString("type"))
349
+ }.getOrNull() ?: return null
350
+ val key = rawItem.optNullableString("key")
351
+ return when (type) {
352
+ ToolbarItemKind.separator -> {
353
+ if (!allowSeparator) {
354
+ null
355
+ } else {
356
+ NativeToolbarItem(type = type, key = key)
357
+ }
358
+ }
359
+ ToolbarItemKind.mark -> {
360
+ val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
361
+ val mark = rawItem.optNullableString("mark") ?: return null
362
+ val label = rawItem.optNullableString("label") ?: return null
363
+ NativeToolbarItem(type, key, label, icon, mark = mark)
364
+ }
365
+ ToolbarItemKind.heading -> {
366
+ val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
367
+ val level = rawItem.optInt("level", -1)
368
+ if (level !in 1..6) return null
369
+ val label = rawItem.optNullableString("label") ?: return null
370
+ NativeToolbarItem(type, key, label, icon, headingLevel = level)
371
+ }
372
+ ToolbarItemKind.blockquote -> {
373
+ val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
374
+ val label = rawItem.optNullableString("label") ?: return null
375
+ NativeToolbarItem(type, key, label, icon)
376
+ }
377
+ ToolbarItemKind.list -> {
378
+ val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
379
+ val listType = runCatching {
380
+ ToolbarListType.valueOf(rawItem.getString("listType"))
381
+ }.getOrNull() ?: return null
382
+ val label = rawItem.optNullableString("label") ?: return null
383
+ NativeToolbarItem(type, key, label, icon, listType = listType)
384
+ }
385
+ ToolbarItemKind.command -> {
386
+ val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
387
+ val command = runCatching {
388
+ ToolbarCommand.valueOf(rawItem.getString("command"))
389
+ }.getOrNull() ?: return null
390
+ val label = rawItem.optNullableString("label") ?: return null
391
+ NativeToolbarItem(type, key, label, icon, command = command)
392
+ }
393
+ ToolbarItemKind.node -> {
394
+ val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
395
+ val nodeType = rawItem.optNullableString("nodeType") ?: return null
396
+ val label = rawItem.optNullableString("label") ?: return null
397
+ NativeToolbarItem(type, key, label, icon, nodeType = nodeType)
398
+ }
399
+ ToolbarItemKind.action -> {
400
+ val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
401
+ val keyValue = rawItem.optNullableString("key") ?: return null
402
+ val label = rawItem.optNullableString("label") ?: return null
403
+ NativeToolbarItem(
404
+ type = type,
405
+ key = keyValue,
406
+ label = label,
407
+ icon = icon,
408
+ isActive = rawItem.optBoolean("isActive", false),
409
+ isDisabled = rawItem.optBoolean("isDisabled", false)
410
+ )
411
+ }
412
+ ToolbarItemKind.group -> {
413
+ if (!allowGroup) return null
414
+ val keyValue = rawItem.optNullableString("key") ?: return null
415
+ val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: return null
416
+ val label = rawItem.optNullableString("label") ?: return null
417
+ val presentation = rawItem.optNullableString("presentation")?.let {
418
+ runCatching { ToolbarGroupPresentation.valueOf(it) }.getOrNull()
419
+ } ?: ToolbarGroupPresentation.expand
420
+ val rawChildren = rawItem.optJSONArray("items") ?: return null
421
+ val children = mutableListOf<NativeToolbarItem>()
422
+ for (childIndex in 0 until rawChildren.length()) {
423
+ val rawChild = rawChildren.optJSONObject(childIndex) ?: continue
424
+ parseItem(rawChild, allowGroup = false, allowSeparator = false)?.let {
425
+ children += it
426
+ }
427
+ }
428
+ if (children.isEmpty()) return null
429
+ NativeToolbarItem(
430
+ type = type,
431
+ key = keyValue,
432
+ label = label,
433
+ icon = icon,
434
+ presentation = presentation,
435
+ items = children
436
+ )
437
+ }
438
+ }
439
+ }
440
+
312
441
  fun fromJson(json: String?): List<NativeToolbarItem> {
313
442
  if (json.isNullOrBlank()) return defaults
314
443
  val rawArray = try {
@@ -319,61 +448,7 @@ internal data class NativeToolbarItem(
319
448
  val parsed = mutableListOf<NativeToolbarItem>()
320
449
  for (index in 0 until rawArray.length()) {
321
450
  val rawItem = rawArray.optJSONObject(index) ?: continue
322
- val type = runCatching {
323
- ToolbarItemKind.valueOf(rawItem.getString("type"))
324
- }.getOrNull() ?: continue
325
- val key = rawItem.optNullableString("key")
326
- when (type) {
327
- ToolbarItemKind.separator -> parsed.add(NativeToolbarItem(type = type, key = key))
328
- ToolbarItemKind.mark -> {
329
- val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: continue
330
- val mark = rawItem.optNullableString("mark") ?: continue
331
- val label = rawItem.optNullableString("label") ?: continue
332
- parsed.add(NativeToolbarItem(type, key, label, icon, mark = mark))
333
- }
334
- ToolbarItemKind.blockquote -> {
335
- val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: continue
336
- val label = rawItem.optNullableString("label") ?: continue
337
- parsed.add(NativeToolbarItem(type, key, label, icon))
338
- }
339
- ToolbarItemKind.list -> {
340
- val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: continue
341
- val listType = runCatching {
342
- ToolbarListType.valueOf(rawItem.getString("listType"))
343
- }.getOrNull() ?: continue
344
- val label = rawItem.optNullableString("label") ?: continue
345
- parsed.add(NativeToolbarItem(type, key, label, icon, listType = listType))
346
- }
347
- ToolbarItemKind.command -> {
348
- val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: continue
349
- val command = runCatching {
350
- ToolbarCommand.valueOf(rawItem.getString("command"))
351
- }.getOrNull() ?: continue
352
- val label = rawItem.optNullableString("label") ?: continue
353
- parsed.add(NativeToolbarItem(type, key, label, icon, command = command))
354
- }
355
- ToolbarItemKind.node -> {
356
- val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: continue
357
- val nodeType = rawItem.optNullableString("nodeType") ?: continue
358
- val label = rawItem.optNullableString("label") ?: continue
359
- parsed.add(NativeToolbarItem(type, key, label, icon, nodeType = nodeType))
360
- }
361
- ToolbarItemKind.action -> {
362
- val icon = NativeToolbarIcon.fromJson(rawItem.optJSONObject("icon")) ?: continue
363
- val keyValue = rawItem.optNullableString("key") ?: continue
364
- val label = rawItem.optNullableString("label") ?: continue
365
- parsed.add(
366
- NativeToolbarItem(
367
- type = type,
368
- key = keyValue,
369
- label = label,
370
- icon = icon,
371
- isActive = rawItem.optBoolean("isActive", false),
372
- isDisabled = rawItem.optBoolean("isDisabled", false)
373
- )
374
- )
375
- }
376
- }
451
+ parseItem(rawItem)?.let { parsed += it }
377
452
  }
378
453
  return parsed.ifEmpty { defaults }
379
454
  }
@@ -406,6 +481,8 @@ internal class EditorKeyboardToolbarView(context: Context) : HorizontalScrollVie
406
481
  private var state: NativeToolbarState = NativeToolbarState.empty
407
482
  private var items: List<NativeToolbarItem> = NativeToolbarItem.defaults
408
483
  private var mentionSuggestions: List<NativeMentionSuggestion> = emptyList()
484
+ private var expandedGroupKey: String? = null
485
+ private var rebuildGeneration: Int = 0
409
486
  private val bindings = mutableListOf<ButtonBinding>()
410
487
  private val separators = mutableListOf<View>()
411
488
  private val mentionChips = mutableListOf<MentionSuggestionChipView>()
@@ -443,11 +520,14 @@ internal class EditorKeyboardToolbarView(context: Context) : HorizontalScrollVie
443
520
  contentRow,
444
521
  LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
445
522
  )
446
- rebuildContent()
523
+ rebuildContent(preserveScrollPosition = false)
447
524
  }
448
525
 
449
526
  fun setItems(items: List<NativeToolbarItem>) {
450
527
  this.items = compactItems(items)
528
+ if (expandedGroupKey != null && !containsExpandableGroup(this.items, expandedGroupKey)) {
529
+ expandedGroupKey = null
530
+ }
451
531
  if (!isShowingMentionSuggestions) {
452
532
  rebuildContent()
453
533
  }
@@ -491,7 +571,7 @@ internal class EditorKeyboardToolbarView(context: Context) : HorizontalScrollVie
491
571
  fun setMentionSuggestions(suggestions: List<NativeMentionSuggestion>): Boolean {
492
572
  val hadSuggestions = isShowingMentionSuggestions
493
573
  mentionSuggestions = suggestions.take(8)
494
- rebuildContent()
574
+ rebuildContent(preserveScrollPosition = hadSuggestions == isShowingMentionSuggestions)
495
575
  return hadSuggestions != isShowingMentionSuggestions
496
576
  }
497
577
 
@@ -502,6 +582,8 @@ internal class EditorKeyboardToolbarView(context: Context) : HorizontalScrollVie
502
582
  internal fun buttonAtForTesting(index: Int): AppCompatButton? =
503
583
  bindings.getOrNull(index)?.button
504
584
 
585
+ internal fun buttonCountForTesting(): Int = bindings.size
586
+
505
587
  internal fun buttonBackgroundColorAtForTesting(index: Int): Int? =
506
588
  bindings.getOrNull(index)?.button?.let { buttonBackgroundColors[it] }
507
589
 
@@ -511,7 +593,9 @@ internal class EditorKeyboardToolbarView(context: Context) : HorizontalScrollVie
511
593
  internal fun separatorAtForTesting(index: Int): View? =
512
594
  separators.getOrNull(index)
513
595
 
514
- private fun rebuildContent() {
596
+ private fun rebuildContent(preserveScrollPosition: Boolean = true) {
597
+ val targetScrollX = if (preserveScrollPosition) scrollX else 0
598
+ val generation = ++rebuildGeneration
515
599
  bindings.clear()
516
600
  separators.clear()
517
601
  mentionChips.clear()
@@ -525,11 +609,17 @@ internal class EditorKeyboardToolbarView(context: Context) : HorizontalScrollVie
525
609
 
526
610
  updateChrome()
527
611
  applyState(state)
528
- scrollTo(0, 0)
612
+ post {
613
+ if (generation != rebuildGeneration) return@post
614
+ val contentWidth = getChildAt(0)?.width ?: 0
615
+ val viewportWidth = (width - paddingLeft - paddingRight).coerceAtLeast(0)
616
+ val maxScrollX = (contentWidth - viewportWidth).coerceAtLeast(0)
617
+ scrollTo(targetScrollX.coerceIn(0, maxScrollX), 0)
618
+ }
529
619
  }
530
620
 
531
621
  private fun rebuildButtons() {
532
- for (item in compactItems(items)) {
622
+ for (item in visibleItems()) {
533
623
  if (item.type == ToolbarItemKind.separator) {
534
624
  val separator = View(context)
535
625
  configureSeparator(separator)
@@ -548,7 +638,18 @@ internal class EditorKeyboardToolbarView(context: Context) : HorizontalScrollVie
548
638
  isAllCaps = false
549
639
  includeFontPadding = false
550
640
  contentDescription = item.label
551
- setOnClickListener { onPressItem?.invoke(item) }
641
+ setOnClickListener {
642
+ when (item.type) {
643
+ ToolbarItemKind.group -> handleGroupButtonPress(this, item)
644
+ else -> {
645
+ onPressItem?.invoke(item.copy(parentGroupKey = null))
646
+ if (item.parentGroupKey != null && expandedGroupKey == item.parentGroupKey) {
647
+ expandedGroupKey = null
648
+ rebuildContent()
649
+ }
650
+ }
651
+ }
652
+ }
552
653
  elevation = 0f
553
654
  translationZ = 0f
554
655
  stateListAnimator = null
@@ -594,6 +695,59 @@ internal class EditorKeyboardToolbarView(context: Context) : HorizontalScrollVie
594
695
  }
595
696
  }
596
697
 
698
+ private fun visibleItems(): List<NativeToolbarItem> {
699
+ val visible = mutableListOf<NativeToolbarItem>()
700
+ for (item in compactItems(items)) {
701
+ visible += item
702
+ if (
703
+ item.type == ToolbarItemKind.group &&
704
+ (item.presentation ?: ToolbarGroupPresentation.expand) == ToolbarGroupPresentation.expand &&
705
+ expandedGroupKey == item.key
706
+ ) {
707
+ visible += item.items.map { child -> child.copy(parentGroupKey = item.key) }
708
+ }
709
+ }
710
+ return compactItems(visible)
711
+ }
712
+
713
+ private fun containsExpandableGroup(items: List<NativeToolbarItem>, key: String?): Boolean {
714
+ key ?: return false
715
+ return items.any {
716
+ it.type == ToolbarItemKind.group &&
717
+ it.key == key &&
718
+ (it.presentation ?: ToolbarGroupPresentation.expand) == ToolbarGroupPresentation.expand
719
+ }
720
+ }
721
+
722
+ private fun handleGroupButtonPress(anchor: View, item: NativeToolbarItem) {
723
+ if (item.items.isEmpty()) return
724
+ when (item.presentation ?: ToolbarGroupPresentation.expand) {
725
+ ToolbarGroupPresentation.expand -> {
726
+ val key = item.key ?: return
727
+ expandedGroupKey = if (expandedGroupKey == key) null else key
728
+ rebuildContent()
729
+ }
730
+ ToolbarGroupPresentation.menu -> showGroupMenu(anchor, item)
731
+ }
732
+ }
733
+
734
+ private fun showGroupMenu(anchor: View, item: NativeToolbarItem) {
735
+ val popupMenu = PopupMenu(themedContext, anchor)
736
+ item.items.forEachIndexed { index, child ->
737
+ val (enabled, active) = buttonState(child, state)
738
+ val menuItem = popupMenu.menu.add(0, index, index, child.label ?: child.key ?: "Item")
739
+ menuItem.isEnabled = enabled
740
+ menuItem.isCheckable = true
741
+ menuItem.isChecked = active
742
+ }
743
+ popupMenu.setOnMenuItemClickListener { menuItem ->
744
+ val child = item.items.getOrNull(menuItem.itemId) ?: return@setOnMenuItemClickListener false
745
+ onPressItem?.invoke(child)
746
+ true
747
+ }
748
+ popupMenu.show()
749
+ }
750
+
597
751
  private fun updateChrome() {
598
752
  val appearance = theme?.appearance ?: EditorToolbarAppearance.CUSTOM
599
753
  val cornerRadiusPx = (theme?.resolvedBorderRadius() ?: 0f) * density
@@ -736,6 +890,13 @@ internal class EditorKeyboardToolbarView(context: Context) : HorizontalScrollVie
736
890
  val mark = item.mark.orEmpty()
737
891
  Pair(state.allowedMarks.contains(mark), state.marks[mark] == true)
738
892
  }
893
+ ToolbarItemKind.heading -> {
894
+ val level = item.headingLevel ?: return Pair(false, false)
895
+ Pair(
896
+ state.commands["toggleHeading$level"] == true,
897
+ state.nodes["h$level"] == true
898
+ )
899
+ }
739
900
  ToolbarItemKind.blockquote -> Pair(
740
901
  state.commands["toggleBlockquote"] == true,
741
902
  state.nodes["blockquote"] == true
@@ -763,6 +924,15 @@ internal class EditorKeyboardToolbarView(context: Context) : HorizontalScrollVie
763
924
  Pair(state.insertableNodes.contains(nodeType), state.nodes[nodeType] == true)
764
925
  }
765
926
  ToolbarItemKind.action -> Pair(!item.isDisabled, item.isActive)
927
+ ToolbarItemKind.group -> Pair(
928
+ item.items.any { child -> buttonState(child, state).first },
929
+ item.items.any { child -> buttonState(child, state).second } ||
930
+ (
931
+ (item.presentation ?: ToolbarGroupPresentation.expand) ==
932
+ ToolbarGroupPresentation.expand &&
933
+ expandedGroupKey == item.key
934
+ )
935
+ )
766
936
  ToolbarItemKind.separator -> Pair(false, false)
767
937
  }
768
938
  }