@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
@@ -92,6 +92,12 @@ private enum ToolbarDefaultIconId: String {
92
92
  case strike
93
93
  case link
94
94
  case image
95
+ case h1
96
+ case h2
97
+ case h3
98
+ case h4
99
+ case h5
100
+ case h6
95
101
  case blockquote
96
102
  case bulletList
97
103
  case orderedList
@@ -105,14 +111,21 @@ private enum ToolbarDefaultIconId: String {
105
111
 
106
112
  private enum ToolbarItemKind: String {
107
113
  case mark
114
+ case heading
108
115
  case blockquote
109
116
  case list
110
117
  case command
111
118
  case node
112
119
  case action
120
+ case group
113
121
  case separator
114
122
  }
115
123
 
124
+ private enum ToolbarGroupPresentation: String {
125
+ case expand
126
+ case menu
127
+ }
128
+
116
129
  private struct NativeToolbarIcon {
117
130
  let defaultId: ToolbarDefaultIconId?
118
131
  let glyphText: String?
@@ -133,6 +146,12 @@ private struct NativeToolbarIcon {
133
146
  .outdentList: "decrease.indent",
134
147
  .lineBreak: "return.left",
135
148
  .horizontalRule: "minus",
149
+ .h1: "paragraphsign",
150
+ .h2: "paragraphsign",
151
+ .h3: "paragraphsign",
152
+ .h4: "paragraphsign",
153
+ .h5: "paragraphsign",
154
+ .h6: "paragraphsign",
136
155
  .undo: "arrow.uturn.backward",
137
156
  .redo: "arrow.uturn.forward",
138
157
  ]
@@ -144,6 +163,12 @@ private struct NativeToolbarIcon {
144
163
  .strike: "S",
145
164
  .link: "🔗",
146
165
  .image: "🖼",
166
+ .h1: "H1",
167
+ .h2: "H2",
168
+ .h3: "H3",
169
+ .h4: "H4",
170
+ .h5: "H5",
171
+ .h6: "H6",
147
172
  .blockquote: "❝",
148
173
  .bulletList: "•≡",
149
174
  .orderedList: "1.",
@@ -238,178 +263,272 @@ private struct NativeToolbarItem {
238
263
  let label: String?
239
264
  let icon: NativeToolbarIcon?
240
265
  let mark: String?
266
+ let headingLevel: Int?
241
267
  let listType: ToolbarListType?
242
268
  let command: ToolbarCommand?
243
269
  let nodeType: String?
244
270
  let isActive: Bool
245
271
  let isDisabled: Bool
272
+ let presentation: ToolbarGroupPresentation?
273
+ let items: [NativeToolbarItem]
274
+ let parentGroupKey: String?
246
275
 
247
276
  static let defaults: [NativeToolbarItem] = [
248
- NativeToolbarItem(type: .mark, key: nil, label: "Bold", icon: .defaultIcon(.bold), mark: "bold", listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false),
249
- NativeToolbarItem(type: .mark, key: nil, label: "Italic", icon: .defaultIcon(.italic), mark: "italic", listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false),
250
- NativeToolbarItem(type: .mark, key: nil, label: "Underline", icon: .defaultIcon(.underline), mark: "underline", listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false),
251
- NativeToolbarItem(type: .mark, key: nil, label: "Strikethrough", icon: .defaultIcon(.strike), mark: "strike", listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false),
252
- NativeToolbarItem(type: .blockquote, key: nil, label: "Blockquote", icon: .defaultIcon(.blockquote), mark: nil, listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false),
253
- NativeToolbarItem(type: .separator, key: nil, label: nil, icon: nil, mark: nil, listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false),
254
- NativeToolbarItem(type: .list, key: nil, label: "Bullet List", icon: .defaultIcon(.bulletList), mark: nil, listType: .bulletList, command: nil, nodeType: nil, isActive: false, isDisabled: false),
255
- NativeToolbarItem(type: .list, key: nil, label: "Ordered List", icon: .defaultIcon(.orderedList), mark: nil, listType: .orderedList, command: nil, nodeType: nil, isActive: false, isDisabled: false),
256
- NativeToolbarItem(type: .command, key: nil, label: "Indent List", icon: .defaultIcon(.indentList), mark: nil, listType: nil, command: .indentList, nodeType: nil, isActive: false, isDisabled: false),
257
- NativeToolbarItem(type: .command, key: nil, label: "Outdent List", icon: .defaultIcon(.outdentList), mark: nil, listType: nil, command: .outdentList, nodeType: nil, isActive: false, isDisabled: false),
258
- NativeToolbarItem(type: .node, key: nil, label: "Line Break", icon: .defaultIcon(.lineBreak), mark: nil, listType: nil, command: nil, nodeType: "hardBreak", isActive: false, isDisabled: false),
259
- NativeToolbarItem(type: .node, key: nil, label: "Horizontal Rule", icon: .defaultIcon(.horizontalRule), mark: nil, listType: nil, command: nil, nodeType: "horizontalRule", isActive: false, isDisabled: false),
260
- NativeToolbarItem(type: .separator, key: nil, label: nil, icon: nil, mark: nil, listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false),
261
- NativeToolbarItem(type: .command, key: nil, label: "Undo", icon: .defaultIcon(.undo), mark: nil, listType: nil, command: .undo, nodeType: nil, isActive: false, isDisabled: false),
262
- NativeToolbarItem(type: .command, key: nil, label: "Redo", icon: .defaultIcon(.redo), mark: nil, listType: nil, command: .redo, nodeType: nil, isActive: false, isDisabled: false),
277
+ NativeToolbarItem(type: .mark, key: nil, label: "Bold", icon: .defaultIcon(.bold), mark: "bold", headingLevel: nil, listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false, presentation: nil, items: [], parentGroupKey: nil),
278
+ NativeToolbarItem(type: .mark, key: nil, label: "Italic", icon: .defaultIcon(.italic), mark: "italic", headingLevel: nil, listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false, presentation: nil, items: [], parentGroupKey: nil),
279
+ NativeToolbarItem(type: .mark, key: nil, label: "Underline", icon: .defaultIcon(.underline), mark: "underline", headingLevel: nil, listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false, presentation: nil, items: [], parentGroupKey: nil),
280
+ NativeToolbarItem(type: .mark, key: nil, label: "Strikethrough", icon: .defaultIcon(.strike), mark: "strike", headingLevel: nil, listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false, presentation: nil, items: [], parentGroupKey: nil),
281
+ NativeToolbarItem(type: .blockquote, key: nil, label: "Blockquote", icon: .defaultIcon(.blockquote), mark: nil, headingLevel: nil, listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false, presentation: nil, items: [], parentGroupKey: nil),
282
+ NativeToolbarItem(type: .separator, key: nil, label: nil, icon: nil, mark: nil, headingLevel: nil, listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false, presentation: nil, items: [], parentGroupKey: nil),
283
+ NativeToolbarItem(type: .list, key: nil, label: "Bullet List", icon: .defaultIcon(.bulletList), mark: nil, headingLevel: nil, listType: .bulletList, command: nil, nodeType: nil, isActive: false, isDisabled: false, presentation: nil, items: [], parentGroupKey: nil),
284
+ NativeToolbarItem(type: .list, key: nil, label: "Ordered List", icon: .defaultIcon(.orderedList), mark: nil, headingLevel: nil, listType: .orderedList, command: nil, nodeType: nil, isActive: false, isDisabled: false, presentation: nil, items: [], parentGroupKey: nil),
285
+ NativeToolbarItem(type: .command, key: nil, label: "Indent List", icon: .defaultIcon(.indentList), mark: nil, headingLevel: nil, listType: nil, command: .indentList, nodeType: nil, isActive: false, isDisabled: false, presentation: nil, items: [], parentGroupKey: nil),
286
+ NativeToolbarItem(type: .command, key: nil, label: "Outdent List", icon: .defaultIcon(.outdentList), mark: nil, headingLevel: nil, listType: nil, command: .outdentList, nodeType: nil, isActive: false, isDisabled: false, presentation: nil, items: [], parentGroupKey: nil),
287
+ NativeToolbarItem(type: .node, key: nil, label: "Line Break", icon: .defaultIcon(.lineBreak), mark: nil, headingLevel: nil, listType: nil, command: nil, nodeType: "hardBreak", isActive: false, isDisabled: false, presentation: nil, items: [], parentGroupKey: nil),
288
+ NativeToolbarItem(type: .node, key: nil, label: "Horizontal Rule", icon: .defaultIcon(.horizontalRule), mark: nil, headingLevel: nil, listType: nil, command: nil, nodeType: "horizontalRule", isActive: false, isDisabled: false, presentation: nil, items: [], parentGroupKey: nil),
289
+ NativeToolbarItem(type: .separator, key: nil, label: nil, icon: nil, mark: nil, headingLevel: nil, listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false, presentation: nil, items: [], parentGroupKey: nil),
290
+ NativeToolbarItem(type: .command, key: nil, label: "Undo", icon: .defaultIcon(.undo), mark: nil, headingLevel: nil, listType: nil, command: .undo, nodeType: nil, isActive: false, isDisabled: false, presentation: nil, items: [], parentGroupKey: nil),
291
+ NativeToolbarItem(type: .command, key: nil, label: "Redo", icon: .defaultIcon(.redo), mark: nil, headingLevel: nil, listType: nil, command: .redo, nodeType: nil, isActive: false, isDisabled: false, presentation: nil, items: [], parentGroupKey: nil),
263
292
  ]
264
293
 
265
- static func from(json: String?) -> [NativeToolbarItem] {
266
- guard let json,
267
- let data = json.data(using: .utf8),
268
- let rawItems = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
294
+ private static func parse(
295
+ rawItem: [String: Any],
296
+ allowGroup: Bool = true,
297
+ allowSeparator: Bool = true
298
+ ) -> NativeToolbarItem? {
299
+ guard let rawType = rawItem["type"] as? String,
300
+ let type = ToolbarItemKind(rawValue: rawType)
269
301
  else {
270
- return defaults
302
+ return nil
271
303
  }
272
304
 
273
- let parsed = rawItems.compactMap { rawItem -> NativeToolbarItem? in
274
- guard let rawType = rawItem["type"] as? String,
275
- let type = ToolbarItemKind(rawValue: rawType)
305
+ let key = rawItem["key"] as? String
306
+ switch type {
307
+ case .separator:
308
+ guard allowSeparator else { return nil }
309
+ return NativeToolbarItem(
310
+ type: .separator,
311
+ key: key,
312
+ label: nil,
313
+ icon: nil,
314
+ mark: nil,
315
+ headingLevel: nil,
316
+ listType: nil,
317
+ command: nil,
318
+ nodeType: nil,
319
+ isActive: false,
320
+ isDisabled: false,
321
+ presentation: nil,
322
+ items: [],
323
+ parentGroupKey: nil
324
+ )
325
+ case .mark:
326
+ guard let mark = rawItem["mark"] as? String,
327
+ let label = rawItem["label"] as? String,
328
+ let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
276
329
  else {
277
330
  return nil
278
331
  }
279
-
280
- let key = rawItem["key"] as? String
281
- switch type {
282
- case .separator:
283
- return NativeToolbarItem(
284
- type: .separator,
285
- key: key,
286
- label: nil,
287
- icon: nil,
288
- mark: nil,
289
- listType: nil,
290
- command: nil,
291
- nodeType: nil,
292
- isActive: false,
293
- isDisabled: false
294
- )
295
- case .mark:
296
- guard let mark = rawItem["mark"] as? String,
297
- let label = rawItem["label"] as? String,
298
- let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
299
- else {
300
- return nil
301
- }
302
- return NativeToolbarItem(
303
- type: .mark,
304
- key: key,
305
- label: label,
306
- icon: icon,
307
- mark: mark,
308
- listType: nil,
309
- command: nil,
310
- nodeType: nil,
311
- isActive: false,
312
- isDisabled: false
313
- )
314
- case .blockquote:
315
- guard let label = rawItem["label"] as? String,
316
- let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
317
- else {
318
- return nil
319
- }
320
- return NativeToolbarItem(
321
- type: .blockquote,
322
- key: key,
323
- label: label,
324
- icon: icon,
325
- mark: nil,
326
- listType: nil,
327
- command: nil,
328
- nodeType: nil,
329
- isActive: false,
330
- isDisabled: false
331
- )
332
- case .list:
333
- guard let listTypeRaw = rawItem["listType"] as? String,
334
- let listType = ToolbarListType(rawValue: listTypeRaw),
335
- let label = rawItem["label"] as? String,
336
- let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
337
- else {
338
- return nil
339
- }
340
- return NativeToolbarItem(
341
- type: .list,
342
- key: key,
343
- label: label,
344
- icon: icon,
345
- mark: nil,
346
- listType: listType,
347
- command: nil,
348
- nodeType: nil,
349
- isActive: false,
350
- isDisabled: false
351
- )
352
- case .command:
353
- guard let commandRaw = rawItem["command"] as? String,
354
- let command = ToolbarCommand(rawValue: commandRaw),
355
- let label = rawItem["label"] as? String,
356
- let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
357
- else {
358
- return nil
359
- }
360
- return NativeToolbarItem(
361
- type: .command,
362
- key: key,
363
- label: label,
364
- icon: icon,
365
- mark: nil,
366
- listType: nil,
367
- command: command,
368
- nodeType: nil,
369
- isActive: false,
370
- isDisabled: false
371
- )
372
- case .node:
373
- guard let nodeType = rawItem["nodeType"] as? String,
374
- let label = rawItem["label"] as? String,
375
- let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
376
- else {
377
- return nil
378
- }
379
- return NativeToolbarItem(
380
- type: .node,
381
- key: key,
382
- label: label,
383
- icon: icon,
384
- mark: nil,
385
- listType: nil,
386
- command: nil,
387
- nodeType: nodeType,
388
- isActive: false,
389
- isDisabled: false
390
- )
391
- case .action:
392
- guard let key,
393
- let label = rawItem["label"] as? String,
394
- let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
395
- else {
396
- return nil
397
- }
398
- return NativeToolbarItem(
399
- type: .action,
400
- key: key,
401
- label: label,
402
- icon: icon,
403
- mark: nil,
404
- listType: nil,
405
- command: nil,
406
- nodeType: nil,
407
- isActive: (rawItem["isActive"] as? Bool) ?? false,
408
- isDisabled: (rawItem["isDisabled"] as? Bool) ?? false
409
- )
332
+ return NativeToolbarItem(
333
+ type: .mark,
334
+ key: key,
335
+ label: label,
336
+ icon: icon,
337
+ mark: mark,
338
+ headingLevel: nil,
339
+ listType: nil,
340
+ command: nil,
341
+ nodeType: nil,
342
+ isActive: false,
343
+ isDisabled: false,
344
+ presentation: nil,
345
+ items: [],
346
+ parentGroupKey: nil
347
+ )
348
+ case .heading:
349
+ guard let level = (rawItem["level"] as? NSNumber)?.intValue,
350
+ (1...6).contains(level),
351
+ let label = rawItem["label"] as? String,
352
+ let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
353
+ else {
354
+ return nil
355
+ }
356
+ return NativeToolbarItem(
357
+ type: .heading,
358
+ key: key,
359
+ label: label,
360
+ icon: icon,
361
+ mark: nil,
362
+ headingLevel: level,
363
+ listType: nil,
364
+ command: nil,
365
+ nodeType: nil,
366
+ isActive: false,
367
+ isDisabled: false,
368
+ presentation: nil,
369
+ items: [],
370
+ parentGroupKey: nil
371
+ )
372
+ case .blockquote:
373
+ guard let label = rawItem["label"] as? String,
374
+ let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
375
+ else {
376
+ return nil
377
+ }
378
+ return NativeToolbarItem(
379
+ type: .blockquote,
380
+ key: key,
381
+ label: label,
382
+ icon: icon,
383
+ mark: nil,
384
+ headingLevel: nil,
385
+ listType: nil,
386
+ command: nil,
387
+ nodeType: nil,
388
+ isActive: false,
389
+ isDisabled: false,
390
+ presentation: nil,
391
+ items: [],
392
+ parentGroupKey: nil
393
+ )
394
+ case .list:
395
+ guard let listTypeRaw = rawItem["listType"] as? String,
396
+ let listType = ToolbarListType(rawValue: listTypeRaw),
397
+ let label = rawItem["label"] as? String,
398
+ let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
399
+ else {
400
+ return nil
401
+ }
402
+ return NativeToolbarItem(
403
+ type: .list,
404
+ key: key,
405
+ label: label,
406
+ icon: icon,
407
+ mark: nil,
408
+ headingLevel: nil,
409
+ listType: listType,
410
+ command: nil,
411
+ nodeType: nil,
412
+ isActive: false,
413
+ isDisabled: false,
414
+ presentation: nil,
415
+ items: [],
416
+ parentGroupKey: nil
417
+ )
418
+ case .command:
419
+ guard let commandRaw = rawItem["command"] as? String,
420
+ let command = ToolbarCommand(rawValue: commandRaw),
421
+ let label = rawItem["label"] as? String,
422
+ let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
423
+ else {
424
+ return nil
425
+ }
426
+ return NativeToolbarItem(
427
+ type: .command,
428
+ key: key,
429
+ label: label,
430
+ icon: icon,
431
+ mark: nil,
432
+ headingLevel: nil,
433
+ listType: nil,
434
+ command: command,
435
+ nodeType: nil,
436
+ isActive: false,
437
+ isDisabled: false,
438
+ presentation: nil,
439
+ items: [],
440
+ parentGroupKey: nil
441
+ )
442
+ case .node:
443
+ guard let nodeType = rawItem["nodeType"] as? String,
444
+ let label = rawItem["label"] as? String,
445
+ let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
446
+ else {
447
+ return nil
448
+ }
449
+ return NativeToolbarItem(
450
+ type: .node,
451
+ key: key,
452
+ label: label,
453
+ icon: icon,
454
+ mark: nil,
455
+ headingLevel: nil,
456
+ listType: nil,
457
+ command: nil,
458
+ nodeType: nodeType,
459
+ isActive: false,
460
+ isDisabled: false,
461
+ presentation: nil,
462
+ items: [],
463
+ parentGroupKey: nil
464
+ )
465
+ case .action:
466
+ guard let key,
467
+ let label = rawItem["label"] as? String,
468
+ let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
469
+ else {
470
+ return nil
471
+ }
472
+ return NativeToolbarItem(
473
+ type: .action,
474
+ key: key,
475
+ label: label,
476
+ icon: icon,
477
+ mark: nil,
478
+ headingLevel: nil,
479
+ listType: nil,
480
+ command: nil,
481
+ nodeType: nil,
482
+ isActive: (rawItem["isActive"] as? Bool) ?? false,
483
+ isDisabled: (rawItem["isDisabled"] as? Bool) ?? false,
484
+ presentation: nil,
485
+ items: [],
486
+ parentGroupKey: nil
487
+ )
488
+ case .group:
489
+ guard allowGroup,
490
+ let key,
491
+ let label = rawItem["label"] as? String,
492
+ let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"]),
493
+ let rawChildren = rawItem["items"] as? [[String: Any]]
494
+ else {
495
+ return nil
410
496
  }
497
+ let presentation = (rawItem["presentation"] as? String)
498
+ .flatMap(ToolbarGroupPresentation.init(rawValue:))
499
+ ?? .expand
500
+ let children = rawChildren.compactMap {
501
+ parse(rawItem: $0, allowGroup: false, allowSeparator: false)
502
+ }
503
+ guard !children.isEmpty else { return nil }
504
+ return NativeToolbarItem(
505
+ type: .group,
506
+ key: key,
507
+ label: label,
508
+ icon: icon,
509
+ mark: nil,
510
+ headingLevel: nil,
511
+ listType: nil,
512
+ command: nil,
513
+ nodeType: nil,
514
+ isActive: false,
515
+ isDisabled: false,
516
+ presentation: presentation,
517
+ items: children,
518
+ parentGroupKey: nil
519
+ )
520
+ }
521
+ }
522
+
523
+ static func from(json: String?) -> [NativeToolbarItem] {
524
+ guard let json,
525
+ let data = json.data(using: .utf8),
526
+ let rawItems = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
527
+ else {
528
+ return defaults
411
529
  }
412
530
 
531
+ let parsed = rawItems.compactMap { parse(rawItem: $0) }
413
532
  return parsed.isEmpty ? defaults : parsed
414
533
  }
415
534
 
@@ -420,6 +539,8 @@ private struct NativeToolbarItem {
420
539
  switch type {
421
540
  case .mark:
422
541
  return "mark:\(mark ?? ""):\(index)"
542
+ case .heading:
543
+ return "heading:\(headingLevel ?? 0):\(index)"
423
544
  case .blockquote:
424
545
  return "blockquote:\(index)"
425
546
  case .list:
@@ -430,10 +551,31 @@ private struct NativeToolbarItem {
430
551
  return "node:\(nodeType ?? ""):\(index)"
431
552
  case .action:
432
553
  return "action:\(key ?? ""):\(index)"
554
+ case .group:
555
+ return "group:\(key ?? ""):\(index)"
433
556
  case .separator:
434
557
  return "separator:\(index)"
435
558
  }
436
559
  }
560
+
561
+ func with(parentGroupKey: String?) -> NativeToolbarItem {
562
+ NativeToolbarItem(
563
+ type: type,
564
+ key: key,
565
+ label: label,
566
+ icon: icon,
567
+ mark: mark,
568
+ headingLevel: headingLevel,
569
+ listType: listType,
570
+ command: command,
571
+ nodeType: nodeType,
572
+ isActive: isActive,
573
+ isDisabled: isDisabled,
574
+ presentation: presentation,
575
+ items: items,
576
+ parentGroupKey: parentGroupKey
577
+ )
578
+ }
437
579
  }
438
580
 
439
581
  final class EditorAccessoryToolbarView: UIInputView {
@@ -474,6 +616,7 @@ final class EditorAccessoryToolbarView: UIInputView {
474
616
  private var separators: [UIView] = []
475
617
  private var mentionButtons: [MentionSuggestionChipButton] = []
476
618
  private var items: [NativeToolbarItem] = NativeToolbarItem.defaults
619
+ private var expandedGroupKey: String?
477
620
  private var currentState = NativeToolbarState.empty
478
621
  private var theme: EditorToolbarTheme?
479
622
  private var mentionTheme: EditorMentionTheme?
@@ -524,6 +667,16 @@ final class EditorAccessoryToolbarView: UIInputView {
524
667
  func mentionButtonAtForTesting(_ index: Int) -> MentionSuggestionChipButton? {
525
668
  mentionButtons.indices.contains(index) ? mentionButtons[index] : nil
526
669
  }
670
+ func buttonCountForTesting() -> Int {
671
+ buttonBindings.count
672
+ }
673
+ func buttonLabelForTesting(_ index: Int) -> String? {
674
+ buttonBindings.indices.contains(index) ? buttonBindings[index].button.accessibilityLabel : nil
675
+ }
676
+ func triggerButtonTapForTesting(_ index: Int) {
677
+ guard buttonBindings.indices.contains(index) else { return }
678
+ buttonBindings[index].button.sendActions(for: .touchUpInside)
679
+ }
527
680
 
528
681
  override var intrinsicContentSize: CGSize {
529
682
  let contentHeight = mentionButtons.isEmpty ? Self.baseHeight : Self.mentionRowHeight
@@ -569,8 +722,22 @@ final class EditorAccessoryToolbarView: UIInputView {
569
722
 
570
723
  fileprivate func setItems(_ items: [NativeToolbarItem]) {
571
724
  self.items = items
725
+ if let expandedGroupKey,
726
+ !items.contains(where: {
727
+ $0.type == .group && $0.key == expandedGroupKey && ($0.presentation ?? .expand) == .expand
728
+ })
729
+ {
730
+ self.expandedGroupKey = nil
731
+ }
572
732
  rebuildButtons()
573
733
  }
734
+ func setItemsJSONForTesting(_ json: String) {
735
+ setItems(NativeToolbarItem.from(json: json))
736
+ }
737
+ func applyStateJSONForTesting(_ json: String) {
738
+ guard let state = NativeToolbarState(updateJSON: json) else { return }
739
+ apply(state: state)
740
+ }
574
741
 
575
742
  func apply(mentionTheme: EditorMentionTheme?) {
576
743
  self.mentionTheme = mentionTheme
@@ -687,6 +854,9 @@ final class EditorAccessoryToolbarView: UIInputView {
687
854
  binding.button.isEnabled = buttonState.enabled
688
855
  binding.button.isSelected = buttonState.active
689
856
  binding.button.accessibilityTraits = buttonState.active ? [.button, .selected] : .button
857
+ if binding.item.type == .group, (binding.item.presentation ?? .expand) == .menu {
858
+ binding.button.menu = makeGroupMenu(item: binding.item)
859
+ }
690
860
  updateButtonAppearance(binding.button, item: binding.item, enabled: buttonState.enabled, active: buttonState.active)
691
861
  }
692
862
 
@@ -697,6 +867,9 @@ final class EditorAccessoryToolbarView: UIInputView {
697
867
  binding.button.isEnabled = state.enabled
698
868
  binding.button.isSelected = state.active
699
869
  binding.button.style = state.active ? .prominent : .plain
870
+ if binding.item.type == .group, (binding.item.presentation ?? .expand) == .menu {
871
+ binding.button.menu = makeGroupMenu(item: binding.item)
872
+ }
700
873
  }
701
874
  }
702
875
  #endif
@@ -872,13 +1045,9 @@ final class EditorAccessoryToolbarView: UIInputView {
872
1045
  arrangedSubview.removeFromSuperview()
873
1046
  }
874
1047
 
875
- let compactItems = items.enumerated().filter { index, item in
876
- guard item.type == .separator else { return true }
877
- guard index > 0, index < items.count - 1 else { return false }
878
- return items[index - 1].type != .separator && items[index + 1].type != .separator
879
- }.map(\.element)
1048
+ let visibleItems = visibleToolbarItems()
880
1049
 
881
- for item in compactItems {
1050
+ for item in visibleItems {
882
1051
  if item.type == .separator {
883
1052
  stackView.addArrangedSubview(makeSeparator())
884
1053
  continue
@@ -891,7 +1060,7 @@ final class EditorAccessoryToolbarView: UIInputView {
891
1060
 
892
1061
  #if compiler(>=6.2)
893
1062
  if #available(iOS 26.0, *) {
894
- nativeToolbarView.setItems(makeNativeToolbarItems(from: compactItems), animated: false)
1063
+ nativeToolbarView.setItems(makeNativeToolbarItems(from: visibleItems), animated: false)
895
1064
  } else {
896
1065
  nativeToolbarView.setItems([], animated: false)
897
1066
  }
@@ -904,6 +1073,75 @@ final class EditorAccessoryToolbarView: UIInputView {
904
1073
  apply(state: currentState)
905
1074
  }
906
1075
 
1076
+ private func compactToolbarItems(_ items: [NativeToolbarItem]) -> [NativeToolbarItem] {
1077
+ items.enumerated().filter { index, item in
1078
+ guard item.type == .separator else { return true }
1079
+ guard index > 0, index < items.count - 1 else { return false }
1080
+ return items[index - 1].type != .separator && items[index + 1].type != .separator
1081
+ }.map(\.element)
1082
+ }
1083
+
1084
+ private func visibleToolbarItems() -> [NativeToolbarItem] {
1085
+ var visible: [NativeToolbarItem] = []
1086
+ for item in compactToolbarItems(items) {
1087
+ visible.append(item)
1088
+ if item.type == .group,
1089
+ (item.presentation ?? .expand) == .expand,
1090
+ expandedGroupKey == item.key
1091
+ {
1092
+ visible.append(contentsOf: item.items.map { $0.with(parentGroupKey: item.key) })
1093
+ }
1094
+ }
1095
+ return compactToolbarItems(visible)
1096
+ }
1097
+
1098
+ private func handleToolbarButtonPress(_ item: NativeToolbarItem) {
1099
+ switch item.type {
1100
+ case .group:
1101
+ handleGroupPress(item)
1102
+ default:
1103
+ onPressItem?(item.with(parentGroupKey: nil))
1104
+ if let parentGroupKey = item.parentGroupKey,
1105
+ expandedGroupKey == parentGroupKey
1106
+ {
1107
+ expandedGroupKey = nil
1108
+ rebuildButtons()
1109
+ }
1110
+ }
1111
+ }
1112
+
1113
+ private func handleGroupPress(_ item: NativeToolbarItem) {
1114
+ guard item.type == .group, !item.items.isEmpty else { return }
1115
+ switch item.presentation ?? .expand {
1116
+ case .expand:
1117
+ expandedGroupKey = expandedGroupKey == item.key ? nil : item.key
1118
+ rebuildButtons()
1119
+ case .menu:
1120
+ break
1121
+ }
1122
+ }
1123
+
1124
+ private func makeGroupMenu(item: NativeToolbarItem) -> UIMenu? {
1125
+ guard item.type == .group else { return nil }
1126
+ let actions = item.items.compactMap { child -> UIAction? in
1127
+ let state = buttonState(for: child, state: currentState)
1128
+ let image = child.icon?.resolvedSFSymbolName().flatMap { UIImage(systemName: $0) }
1129
+ let title = child.label ?? child.icon?.resolvedGlyphText() ?? "Item"
1130
+ return UIAction(
1131
+ title: title,
1132
+ image: image,
1133
+ identifier: nil,
1134
+ discoverabilityTitle: child.label,
1135
+ attributes: state.enabled ? [] : [.disabled],
1136
+ state: state.active ? .on : .off
1137
+ ) { [weak self] _ in
1138
+ self?.handleToolbarButtonPress(child)
1139
+ }
1140
+ }
1141
+ guard !actions.isEmpty else { return nil }
1142
+ return UIMenu(title: item.label ?? "", children: actions)
1143
+ }
1144
+
907
1145
  private func updateNativeToolbarMetricsIfNeeded() {
908
1146
  #if compiler(>=6.2)
909
1147
  guard #available(iOS 26.0, *), usesNativeBarToolbar else {
@@ -998,10 +1236,20 @@ final class EditorAccessoryToolbarView: UIInputView {
998
1236
  ) -> UIBarButtonItem {
999
1237
  let image = item.icon?.resolvedSFSymbolName().flatMap { UIImage(systemName: $0) }
1000
1238
  let title = image == nil ? item.icon?.resolvedGlyphText() : nil
1001
- let action = UIAction { [weak self] _ in
1002
- self?.onPressItem?(item)
1239
+ let barButtonItem: UIBarButtonItem
1240
+ if item.type == .group, (item.presentation ?? .expand) == .menu {
1241
+ barButtonItem = UIBarButtonItem(
1242
+ title: title,
1243
+ image: image,
1244
+ primaryAction: nil,
1245
+ menu: makeGroupMenu(item: item)
1246
+ )
1247
+ } else {
1248
+ let action = UIAction { [weak self] _ in
1249
+ self?.handleToolbarButtonPress(item)
1250
+ }
1251
+ barButtonItem = UIBarButtonItem(title: title, image: image, primaryAction: action, menu: nil)
1003
1252
  }
1004
- let barButtonItem = UIBarButtonItem(title: title, image: image, primaryAction: action, menu: nil)
1005
1253
 
1006
1254
  barButtonItem.accessibilityLabel = item.label
1007
1255
  barButtonItem.isEnabled = enabled
@@ -1048,9 +1296,17 @@ final class EditorAccessoryToolbarView: UIInputView {
1048
1296
  }
1049
1297
  button.widthAnchor.constraint(greaterThanOrEqualToConstant: 36).isActive = true
1050
1298
  button.heightAnchor.constraint(equalToConstant: 36).isActive = true
1051
- button.addAction(UIAction { [weak self] _ in
1052
- self?.onPressItem?(item)
1053
- }, for: .touchUpInside)
1299
+ if item.type == .group,
1300
+ (item.presentation ?? .expand) == .menu,
1301
+ #available(iOS 14.0, *)
1302
+ {
1303
+ button.menu = makeGroupMenu(item: item)
1304
+ button.showsMenuAsPrimaryAction = true
1305
+ } else {
1306
+ button.addAction(UIAction { [weak self] _ in
1307
+ self?.handleToolbarButtonPress(item)
1308
+ }, for: .touchUpInside)
1309
+ }
1054
1310
  updateButtonAppearance(button, item: item, enabled: true, active: false)
1055
1311
  return button
1056
1312
  }
@@ -1078,6 +1334,13 @@ final class EditorAccessoryToolbarView: UIInputView {
1078
1334
  enabled: state.allowedMarks.contains(mark),
1079
1335
  active: state.marks[mark] == true
1080
1336
  )
1337
+ case .heading:
1338
+ let level = item.headingLevel ?? 0
1339
+ let headingType = "h\(level)"
1340
+ return (
1341
+ enabled: state.commands["toggleHeading\(level)"] == true,
1342
+ active: state.nodes[headingType] == true
1343
+ )
1081
1344
  case .blockquote:
1082
1345
  return (
1083
1346
  enabled: state.commands["toggleBlockquote"] == true,
@@ -1128,6 +1391,16 @@ final class EditorAccessoryToolbarView: UIInputView {
1128
1391
  enabled: !item.isDisabled,
1129
1392
  active: item.isActive
1130
1393
  )
1394
+ case .group:
1395
+ let childStates = item.items.map { buttonState(for: $0, state: state) }
1396
+ return (
1397
+ enabled: childStates.contains { $0.enabled },
1398
+ active: childStates.contains { $0.active } ||
1399
+ (
1400
+ (item.presentation ?? .expand) == .expand &&
1401
+ expandedGroupKey == item.key
1402
+ )
1403
+ )
1131
1404
  case .separator:
1132
1405
  return (enabled: false, active: false)
1133
1406
  }
@@ -1323,6 +1596,11 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1323
1596
  private var addons = NativeEditorAddons(mentions: nil)
1324
1597
  private var mentionQueryState: MentionQueryState?
1325
1598
  private var lastMentionEventJSON: String?
1599
+ private var lastThemeJSON: String?
1600
+ private var lastAddonsJSON: String?
1601
+ private var lastRemoteSelectionsJSON: String?
1602
+ private var lastToolbarItemsJSON: String?
1603
+ private var lastToolbarFrameJSON: String?
1326
1604
  private var pendingEditorUpdateJSON: String?
1327
1605
  private var pendingEditorUpdateRevision = 0
1328
1606
  private var appliedEditorUpdateRevision = 0
@@ -1351,17 +1629,18 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1351
1629
  let onToolbarAction = EventDispatcher()
1352
1630
  let onAddonEvent = EventDispatcher()
1353
1631
  private var lastEmittedContentHeight: CGFloat = 0
1632
+ private var cachedAutoGrowContentHeight: CGFloat = 0
1354
1633
 
1355
1634
  // MARK: - Initialization
1356
1635
 
1357
1636
  required init(appContext: AppContext? = nil) {
1358
1637
  richTextView = RichTextEditorView(frame: .zero)
1359
1638
  super.init(appContext: appContext)
1360
- richTextView.onHeightMayChange = { [weak self] in
1639
+ richTextView.onHeightMayChange = { [weak self] measuredHeight in
1361
1640
  guard let self, self.heightBehavior == .autoGrow else { return }
1641
+ self.cachedAutoGrowContentHeight = measuredHeight
1362
1642
  self.invalidateIntrinsicContentSize()
1363
- self.superview?.setNeedsLayout()
1364
- self.emitContentHeightIfNeeded(force: true)
1643
+ self.emitContentHeightIfNeeded(force: true, measuredHeight: measuredHeight)
1365
1644
  }
1366
1645
  richTextView.textView.editorDelegate = self
1367
1646
  configureAccessoryToolbar()
@@ -1393,6 +1672,9 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1393
1672
  guard heightBehavior == .autoGrow else {
1394
1673
  return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
1395
1674
  }
1675
+ if cachedAutoGrowContentHeight > 0 {
1676
+ return CGSize(width: UIView.noIntrinsicMetric, height: cachedAutoGrowContentHeight)
1677
+ }
1396
1678
  return richTextView.intrinsicContentSize
1397
1679
  }
1398
1680
 
@@ -1403,6 +1685,7 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1403
1685
  let currentWidth = bounds.width.rounded(.towardZero)
1404
1686
  guard currentWidth != lastAutoGrowWidth else { return }
1405
1687
  lastAutoGrowWidth = currentWidth
1688
+ cachedAutoGrowContentHeight = 0
1406
1689
  invalidateIntrinsicContentSize()
1407
1690
  emitContentHeightIfNeeded(force: true)
1408
1691
  }
@@ -1438,6 +1721,8 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1438
1721
  }
1439
1722
 
1440
1723
  func setThemeJson(_ themeJson: String?) {
1724
+ guard lastThemeJSON != themeJson else { return }
1725
+ lastThemeJSON = themeJson
1441
1726
  let theme = EditorTheme.from(json: themeJson)
1442
1727
  richTextView.applyTheme(theme)
1443
1728
  accessoryToolbar.apply(theme: theme?.toolbar)
@@ -1451,12 +1736,16 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1451
1736
  }
1452
1737
 
1453
1738
  func setAddonsJson(_ addonsJson: String?) {
1739
+ guard lastAddonsJSON != addonsJson else { return }
1740
+ lastAddonsJSON = addonsJson
1454
1741
  addons = NativeEditorAddons.from(json: addonsJson)
1455
1742
  accessoryToolbar.apply(mentionTheme: richTextView.textView.theme?.mentions ?? addons.mentions?.theme)
1456
1743
  refreshMentionQuery()
1457
1744
  }
1458
1745
 
1459
1746
  func setRemoteSelectionsJson(_ remoteSelectionsJson: String?) {
1747
+ guard lastRemoteSelectionsJSON != remoteSelectionsJson else { return }
1748
+ lastRemoteSelectionsJSON = remoteSelectionsJson
1460
1749
  richTextView.setRemoteSelections(RemoteSelectionDecoration.from(json: remoteSelectionsJson))
1461
1750
  }
1462
1751
 
@@ -1485,6 +1774,9 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1485
1774
  let nextBehavior = EditorHeightBehavior(rawValue: rawHeightBehavior) ?? .fixed
1486
1775
  guard nextBehavior != heightBehavior else { return }
1487
1776
  heightBehavior = nextBehavior
1777
+ if nextBehavior != .autoGrow {
1778
+ cachedAutoGrowContentHeight = 0
1779
+ }
1488
1780
  richTextView.heightBehavior = nextBehavior
1489
1781
  invalidateIntrinsicContentSize()
1490
1782
  setNeedsLayout()
@@ -1497,22 +1789,29 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1497
1789
  richTextView.allowImageResizing = allowImageResizing
1498
1790
  }
1499
1791
 
1500
- private func emitContentHeightIfNeeded(force: Bool = false) {
1792
+ private func emitContentHeightIfNeeded(force: Bool = false, measuredHeight: CGFloat? = nil) {
1501
1793
  guard heightBehavior == .autoGrow else { return }
1502
- let contentHeight = ceil(richTextView.intrinsicContentSize.height)
1794
+ let resolvedHeight = measuredHeight
1795
+ ?? (cachedAutoGrowContentHeight > 0 ? cachedAutoGrowContentHeight : richTextView.intrinsicContentSize.height)
1796
+ let contentHeight = ceil(resolvedHeight)
1503
1797
  guard contentHeight > 0 else { return }
1504
1798
  guard force || abs(contentHeight - lastEmittedContentHeight) > 0.5 else { return }
1799
+ cachedAutoGrowContentHeight = contentHeight
1505
1800
  lastEmittedContentHeight = contentHeight
1506
1801
  onContentHeightChange(["contentHeight": contentHeight])
1507
1802
  }
1508
1803
 
1509
1804
  func setToolbarButtonsJson(_ toolbarButtonsJson: String?) {
1805
+ guard lastToolbarItemsJSON != toolbarButtonsJson else { return }
1806
+ lastToolbarItemsJSON = toolbarButtonsJson
1510
1807
  toolbarItems = NativeToolbarItem.from(json: toolbarButtonsJson)
1511
1808
  accessoryToolbar.setItems(toolbarItems)
1512
1809
  refreshSystemAssistantToolbarIfNeeded()
1513
1810
  }
1514
1811
 
1515
1812
  func setToolbarFrameJson(_ toolbarFrameJson: String?) {
1813
+ guard lastToolbarFrameJSON != toolbarFrameJson else { return }
1814
+ lastToolbarFrameJSON = toolbarFrameJson
1516
1815
  guard let toolbarFrameJson,
1517
1816
  let data = toolbarFrameJson.data(using: .utf8),
1518
1817
  let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
@@ -1644,11 +1943,15 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1644
1943
  // MARK: - EditorTextViewDelegate
1645
1944
 
1646
1945
  func editorTextView(_ textView: EditorTextView, selectionDidChange anchor: UInt32, head: UInt32) {
1647
- refreshToolbarStateFromEditorSelection()
1946
+ let stateJSON = refreshToolbarStateFromEditorSelection()
1648
1947
  refreshSystemAssistantToolbarIfNeeded()
1649
1948
  refreshMentionQuery()
1650
1949
  richTextView.refreshRemoteSelections()
1651
- onSelectionChange(["anchor": Int(anchor), "head": Int(head)])
1950
+ var event: [String: Any] = ["anchor": Int(anchor), "head": Int(head)]
1951
+ if let stateJSON {
1952
+ event["stateJson"] = stateJSON
1953
+ }
1954
+ onSelectionChange(event)
1652
1955
  }
1653
1956
 
1654
1957
  func editorTextView(_ textView: EditorTextView, didReceiveUpdate updateJSON: String) {
@@ -1663,12 +1966,14 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1663
1966
  onEditorUpdate(["updateJson": updateJSON])
1664
1967
  }
1665
1968
 
1666
- private func refreshToolbarStateFromEditorSelection() {
1667
- guard richTextView.editorId != 0 else { return }
1668
- let stateJSON = editorGetCurrentState(id: richTextView.editorId)
1669
- guard let state = NativeToolbarState(updateJSON: stateJSON) else { return }
1969
+ @discardableResult
1970
+ private func refreshToolbarStateFromEditorSelection() -> String? {
1971
+ guard richTextView.editorId != 0 else { return nil }
1972
+ let stateJSON = editorGetSelectionState(id: richTextView.editorId)
1973
+ guard let state = NativeToolbarState(updateJSON: stateJSON) else { return nil }
1670
1974
  toolbarState = state
1671
1975
  accessoryToolbar.apply(state: state)
1976
+ return stateJSON
1672
1977
  }
1673
1978
 
1674
1979
  private func configureAccessoryToolbar() {
@@ -1953,6 +2258,9 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1953
2258
  case .mark:
1954
2259
  guard let mark = item.mark else { return }
1955
2260
  richTextView.textView.performToolbarToggleMark(mark)
2261
+ case .heading:
2262
+ guard let level = item.headingLevel else { return }
2263
+ richTextView.textView.performToolbarToggleHeading(level)
1956
2264
  case .blockquote:
1957
2265
  richTextView.textView.performToolbarToggleBlockquote()
1958
2266
  case .list:
@@ -1977,6 +2285,8 @@ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognize
1977
2285
  case .action:
1978
2286
  guard let key = item.key else { return }
1979
2287
  onToolbarAction(["key": key])
2288
+ case .group:
2289
+ break
1980
2290
  case .separator:
1981
2291
  break
1982
2292
  }