@openeditor/react-native-prose-editor 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/LICENSE +160 -0
  2. package/README.md +180 -0
  3. package/android/build.gradle +69 -0
  4. package/android/consumer-rules.pro +18 -0
  5. package/android/src/androidTest/AndroidManifest.xml +8 -0
  6. package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +53 -0
  7. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +241 -0
  8. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +338 -0
  9. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +789 -0
  10. package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +350 -0
  11. package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +5 -0
  12. package/android/src/main/assets/editor-icons/MaterialIcons.json +2236 -0
  13. package/android/src/main/assets/editor-icons/MaterialIcons.ttf +0 -0
  14. package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +50 -0
  15. package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +135 -0
  16. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +4527 -0
  17. package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
  18. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +999 -0
  19. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +474 -0
  20. package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
  21. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +3137 -0
  22. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +561 -0
  23. package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +315 -0
  24. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +1413 -0
  25. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +133 -0
  26. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +315 -0
  27. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +2172 -0
  28. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +399 -0
  29. package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +482 -0
  30. package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +137 -0
  31. package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +321 -0
  32. package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +4202 -0
  33. package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +2581 -0
  34. package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +27 -0
  35. package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +197 -0
  36. package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +84 -0
  37. package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +544 -0
  38. package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +461 -0
  39. package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +2054 -0
  40. package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +1367 -0
  41. package/app.plugin.js +62 -0
  42. package/dist/EditorTheme.d.ts +113 -0
  43. package/dist/EditorTheme.js +29 -0
  44. package/dist/EditorToolbar.d.ts +191 -0
  45. package/dist/EditorToolbar.js +1095 -0
  46. package/dist/NativeEditorBridge.d.ts +325 -0
  47. package/dist/NativeEditorBridge.js +933 -0
  48. package/dist/NativeProseViewer.d.ts +53 -0
  49. package/dist/NativeProseViewer.js +480 -0
  50. package/dist/NativeRichTextEditor.d.ts +183 -0
  51. package/dist/NativeRichTextEditor.js +2574 -0
  52. package/dist/YjsCollaboration.d.ts +90 -0
  53. package/dist/YjsCollaboration.js +743 -0
  54. package/dist/addons.d.ts +100 -0
  55. package/dist/addons.js +82 -0
  56. package/dist/heightCache.d.ts +6 -0
  57. package/dist/heightCache.js +45 -0
  58. package/dist/index.d.ts +10 -0
  59. package/dist/index.js +30 -0
  60. package/dist/schemas.d.ts +37 -0
  61. package/dist/schemas.js +263 -0
  62. package/dist/useNativeEditor.d.ts +42 -0
  63. package/dist/useNativeEditor.js +124 -0
  64. package/expo-module.config.json +9 -0
  65. package/ios/EditorAddons.swift +327 -0
  66. package/ios/EditorCore.xcframework/Info.plist +44 -0
  67. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  68. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  69. package/ios/EditorLayoutManager.swift +774 -0
  70. package/ios/EditorTheme.swift +515 -0
  71. package/ios/Generated_editor_core.swift +1513 -0
  72. package/ios/NativeEditorExpoView.swift +3593 -0
  73. package/ios/NativeEditorModule.swift +624 -0
  74. package/ios/NativeProseViewerExpoView.swift +276 -0
  75. package/ios/PositionBridge.swift +558 -0
  76. package/ios/ReactNativeProseEditor.podspec +49 -0
  77. package/ios/RenderBridge.swift +1708 -0
  78. package/ios/RichTextEditorView.swift +6150 -0
  79. package/ios/Tests/NativePerformanceTests.swift +687 -0
  80. package/ios/Tests/PositionBridgeTests.swift +706 -0
  81. package/ios/Tests/RenderBridgeTests.swift +2236 -0
  82. package/ios/Tests/RichTextEditorViewTests.swift +6548 -0
  83. package/ios/editor_coreFFI/editor_coreFFI.h +1289 -0
  84. package/ios/editor_coreFFI/module.modulemap +7 -0
  85. package/ios/editor_coreFFI.h +904 -0
  86. package/ios/editor_coreFFI.modulemap +7 -0
  87. package/package.json +74 -0
  88. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  89. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  90. package/rust/android/x86_64/libeditor_core.so +0 -0
  91. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2563 -0
@@ -0,0 +1,3593 @@
1
+ import ExpoModulesCore
2
+ import UIKit
3
+
4
+ private final class WeakNativeEditorExpoView {
5
+ weak var view: NativeEditorExpoView?
6
+
7
+ init(_ view: NativeEditorExpoView) {
8
+ self.view = view
9
+ }
10
+ }
11
+
12
+ final class NativeEditorViewRegistry {
13
+ static let shared = NativeEditorViewRegistry()
14
+
15
+ private var viewsByEditorId: [UInt64: WeakNativeEditorExpoView] = [:]
16
+ private var destroyedEditorIds: Set<UInt64> = []
17
+
18
+ private init() {}
19
+
20
+ func markEditorCreated(editorId: UInt64) {
21
+ guard editorId != 0 else { return }
22
+ _ = performOnMain {
23
+ destroyedEditorIds.remove(editorId)
24
+ }
25
+ }
26
+
27
+ func isDestroyed(editorId: UInt64) -> Bool {
28
+ guard editorId != 0 else { return false }
29
+ return performOnMain {
30
+ destroyedEditorIds.contains(editorId)
31
+ }
32
+ }
33
+
34
+ @discardableResult
35
+ func register(editorId: UInt64, view: NativeEditorExpoView) -> Bool {
36
+ guard editorId != 0 else { return false }
37
+ return performOnMain {
38
+ guard !destroyedEditorIds.contains(editorId) else { return false }
39
+ viewsByEditorId[editorId] = WeakNativeEditorExpoView(view)
40
+ return true
41
+ }
42
+ }
43
+
44
+ func unregister(editorId: UInt64, view: NativeEditorExpoView) {
45
+ guard editorId != 0 else { return }
46
+ performOnMain {
47
+ guard viewsByEditorId[editorId]?.view === view else { return }
48
+ viewsByEditorId.removeValue(forKey: editorId)
49
+ }
50
+ }
51
+
52
+ func invalidateDestroyedEditor(editorId: UInt64) {
53
+ guard editorId != 0 else { return }
54
+ performOnMain {
55
+ destroyedEditorIds.insert(editorId)
56
+ guard let view = viewsByEditorId.removeValue(forKey: editorId)?.view else {
57
+ return
58
+ }
59
+ view.handleEditorDestroyed(editorId)
60
+ }
61
+ }
62
+
63
+ func prepareForCommandJSON(editorId: UInt64) -> String {
64
+ let prepare = { () -> String in
65
+ if self.destroyedEditorIds.contains(editorId) {
66
+ return Self.commandPreparationJSON(ready: false, blockedReason: "destroyed")
67
+ }
68
+ guard let view = self.viewsByEditorId[editorId]?.view else {
69
+ self.viewsByEditorId.removeValue(forKey: editorId)
70
+ return Self.commandPreparationJSON(ready: true)
71
+ }
72
+ return view.prepareForEditorCommandJSON()
73
+ }
74
+
75
+ return performOnMain(prepare)
76
+ }
77
+
78
+ static func commandPreparationJSON(
79
+ ready: Bool,
80
+ updateJSON: String? = nil,
81
+ blockedReason: String? = nil
82
+ ) -> String {
83
+ var payload: [String: Any] = ["ready": ready]
84
+ if let updateJSON {
85
+ payload["updateJSON"] = updateJSON
86
+ }
87
+ if let blockedReason {
88
+ payload["blockedReason"] = blockedReason
89
+ }
90
+ guard let data = try? JSONSerialization.data(withJSONObject: payload),
91
+ let json = String(data: data, encoding: .utf8)
92
+ else {
93
+ if let blockedReason {
94
+ return ready
95
+ ? "{\"ready\":true,\"blockedReason\":\"\(blockedReason)\"}"
96
+ : "{\"ready\":false,\"blockedReason\":\"\(blockedReason)\"}"
97
+ }
98
+ return ready ? "{\"ready\":true}" : "{\"ready\":false}"
99
+ }
100
+ return json
101
+ }
102
+
103
+ private func performOnMain<T>(_ work: () -> T) -> T {
104
+ if Thread.isMainThread {
105
+ return work()
106
+ }
107
+ return DispatchQueue.main.sync(execute: work)
108
+ }
109
+ }
110
+
111
+ private struct NativeToolbarState {
112
+ let marks: [String: Bool]
113
+ let nodes: [String: Bool]
114
+ let commands: [String: Bool]
115
+ let allowedMarks: Set<String>
116
+ let insertableNodes: Set<String>
117
+ let canUndo: Bool
118
+ let canRedo: Bool
119
+
120
+ static let empty = NativeToolbarState(
121
+ marks: [:],
122
+ nodes: [:],
123
+ commands: [:],
124
+ allowedMarks: [],
125
+ insertableNodes: [],
126
+ canUndo: false,
127
+ canRedo: false
128
+ )
129
+
130
+ init(
131
+ marks: [String: Bool],
132
+ nodes: [String: Bool],
133
+ commands: [String: Bool],
134
+ allowedMarks: Set<String>,
135
+ insertableNodes: Set<String>,
136
+ canUndo: Bool,
137
+ canRedo: Bool
138
+ ) {
139
+ self.marks = marks
140
+ self.nodes = nodes
141
+ self.commands = commands
142
+ self.allowedMarks = allowedMarks
143
+ self.insertableNodes = insertableNodes
144
+ self.canUndo = canUndo
145
+ self.canRedo = canRedo
146
+ }
147
+
148
+ init?(updateJSON: String) {
149
+ guard let data = updateJSON.data(using: .utf8),
150
+ let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
151
+ else {
152
+ return nil
153
+ }
154
+
155
+ let activeState = raw["activeState"] as? [String: Any] ?? [:]
156
+ let historyState = raw["historyState"] as? [String: Any] ?? [:]
157
+
158
+ self.init(
159
+ marks: NativeToolbarState.boolMap(from: activeState["marks"]),
160
+ nodes: NativeToolbarState.boolMap(from: activeState["nodes"]),
161
+ commands: NativeToolbarState.boolMap(from: activeState["commands"]),
162
+ allowedMarks: Set((activeState["allowedMarks"] as? [String]) ?? []),
163
+ insertableNodes: Set((activeState["insertableNodes"] as? [String]) ?? []),
164
+ canUndo: (historyState["canUndo"] as? Bool) ?? false,
165
+ canRedo: (historyState["canRedo"] as? Bool) ?? false
166
+ )
167
+ }
168
+
169
+ private static func boolMap(from value: Any?) -> [String: Bool] {
170
+ guard let map = value as? [String: Any] else { return [:] }
171
+ var result: [String: Bool] = [:]
172
+ for (key, rawValue) in map {
173
+ if let bool = rawValue as? Bool {
174
+ result[key] = bool
175
+ } else if let number = rawValue as? NSNumber {
176
+ result[key] = number.boolValue
177
+ }
178
+ }
179
+ return result
180
+ }
181
+ }
182
+
183
+ private enum ToolbarCommand: String {
184
+ case indentList
185
+ case outdentList
186
+ case undo
187
+ case redo
188
+ }
189
+
190
+ private enum ToolbarListType: String {
191
+ case bulletList
192
+ case orderedList
193
+ }
194
+
195
+ private enum ToolbarDefaultIconId: String {
196
+ case bold
197
+ case italic
198
+ case underline
199
+ case strike
200
+ case link
201
+ case image
202
+ case h1
203
+ case h2
204
+ case h3
205
+ case h4
206
+ case h5
207
+ case h6
208
+ case blockquote
209
+ case bulletList
210
+ case orderedList
211
+ case indentList
212
+ case outdentList
213
+ case lineBreak
214
+ case horizontalRule
215
+ case undo
216
+ case redo
217
+ }
218
+
219
+ private enum ToolbarItemKind: String {
220
+ case mark
221
+ case heading
222
+ case blockquote
223
+ case list
224
+ case command
225
+ case node
226
+ case action
227
+ case group
228
+ case separator
229
+ }
230
+
231
+ private enum ToolbarGroupPresentation: String {
232
+ case expand
233
+ case menu
234
+ }
235
+
236
+ private enum ToolbarItemPlacement: String {
237
+ case start
238
+ case scroll
239
+ case end
240
+ }
241
+
242
+ private struct NativeToolbarIcon {
243
+ let defaultId: ToolbarDefaultIconId?
244
+ let glyphText: String?
245
+ let iosSymbolName: String?
246
+ let fallbackText: String?
247
+
248
+ private static let defaultSFSymbolNames: [ToolbarDefaultIconId: String] = [
249
+ .bold: "bold",
250
+ .italic: "italic",
251
+ .underline: "underline",
252
+ .strike: "strikethrough",
253
+ .link: "link",
254
+ .image: "photo",
255
+ .blockquote: "text.quote",
256
+ .bulletList: "list.bullet",
257
+ .orderedList: "list.number",
258
+ .indentList: "increase.indent",
259
+ .outdentList: "decrease.indent",
260
+ .lineBreak: "return.left",
261
+ .horizontalRule: "minus",
262
+ .h1: "paragraphsign",
263
+ .h2: "paragraphsign",
264
+ .h3: "paragraphsign",
265
+ .h4: "paragraphsign",
266
+ .h5: "paragraphsign",
267
+ .h6: "paragraphsign",
268
+ .undo: "arrow.uturn.backward",
269
+ .redo: "arrow.uturn.forward",
270
+ ]
271
+
272
+ private static let defaultGlyphs: [ToolbarDefaultIconId: String] = [
273
+ .bold: "B",
274
+ .italic: "I",
275
+ .underline: "U",
276
+ .strike: "S",
277
+ .link: "🔗",
278
+ .image: "🖼",
279
+ .h1: "H1",
280
+ .h2: "H2",
281
+ .h3: "H3",
282
+ .h4: "H4",
283
+ .h5: "H5",
284
+ .h6: "H6",
285
+ .blockquote: "❝",
286
+ .bulletList: "•≡",
287
+ .orderedList: "1.",
288
+ .indentList: "→",
289
+ .outdentList: "←",
290
+ .lineBreak: "↵",
291
+ .horizontalRule: "—",
292
+ .undo: "↩",
293
+ .redo: "↪",
294
+ ]
295
+
296
+ static func defaultIcon(_ id: ToolbarDefaultIconId) -> NativeToolbarIcon {
297
+ NativeToolbarIcon(defaultId: id, glyphText: nil, iosSymbolName: nil, fallbackText: nil)
298
+ }
299
+
300
+ static func glyph(_ text: String) -> NativeToolbarIcon {
301
+ NativeToolbarIcon(defaultId: nil, glyphText: text, iosSymbolName: nil, fallbackText: nil)
302
+ }
303
+
304
+ static func platform(iosSymbolName: String?, fallbackText: String?) -> NativeToolbarIcon {
305
+ NativeToolbarIcon(
306
+ defaultId: nil,
307
+ glyphText: nil,
308
+ iosSymbolName: iosSymbolName,
309
+ fallbackText: fallbackText
310
+ )
311
+ }
312
+
313
+ static func from(jsonValue: Any?) -> NativeToolbarIcon? {
314
+ guard let raw = jsonValue as? [String: Any],
315
+ let rawType = raw["type"] as? String
316
+ else {
317
+ return nil
318
+ }
319
+
320
+ switch rawType {
321
+ case "default":
322
+ guard let rawId = raw["id"] as? String,
323
+ let id = ToolbarDefaultIconId(rawValue: rawId)
324
+ else {
325
+ return nil
326
+ }
327
+ return .defaultIcon(id)
328
+ case "glyph":
329
+ guard let text = raw["text"] as? String, !text.isEmpty else {
330
+ return nil
331
+ }
332
+ return .glyph(text)
333
+ case "platform":
334
+ let iosSymbolName = ((raw["ios"] as? [String: Any]).flatMap { iosRaw -> String? in
335
+ guard (iosRaw["type"] as? String) == "sfSymbol",
336
+ let name = iosRaw["name"] as? String,
337
+ !name.isEmpty
338
+ else {
339
+ return nil
340
+ }
341
+ return name
342
+ })
343
+ let fallbackText = raw["fallbackText"] as? String
344
+ guard iosSymbolName != nil || fallbackText != nil else {
345
+ return nil
346
+ }
347
+ return .platform(iosSymbolName: iosSymbolName, fallbackText: fallbackText)
348
+ default:
349
+ return nil
350
+ }
351
+ }
352
+
353
+ func resolvedSFSymbolName() -> String? {
354
+ if let iosSymbolName, !iosSymbolName.isEmpty {
355
+ return iosSymbolName
356
+ }
357
+ guard let defaultId else { return nil }
358
+ return Self.defaultSFSymbolNames[defaultId]
359
+ }
360
+
361
+ func resolvedGlyphText() -> String? {
362
+ if let glyphText, !glyphText.isEmpty {
363
+ return glyphText
364
+ }
365
+ if let fallbackText, !fallbackText.isEmpty {
366
+ return fallbackText
367
+ }
368
+ guard let defaultId else { return nil }
369
+ return Self.defaultGlyphs[defaultId]
370
+ }
371
+ }
372
+
373
+ private struct NativeToolbarItem {
374
+ let type: ToolbarItemKind
375
+ let key: String?
376
+ let label: String?
377
+ let icon: NativeToolbarIcon?
378
+ let mark: String?
379
+ let headingLevel: Int?
380
+ let listType: ToolbarListType?
381
+ let command: ToolbarCommand?
382
+ let nodeType: String?
383
+ let isActive: Bool
384
+ let isDisabled: Bool
385
+ let placement: ToolbarItemPlacement?
386
+ let presentation: ToolbarGroupPresentation?
387
+ let items: [NativeToolbarItem]
388
+ let parentGroupKey: String?
389
+
390
+ static let defaults: [NativeToolbarItem] = [
391
+ NativeToolbarItem(type: .mark, key: nil, label: "Bold", icon: .defaultIcon(.bold), mark: "bold", headingLevel: nil, listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false, placement: nil, presentation: nil, items: [], parentGroupKey: nil),
392
+ NativeToolbarItem(type: .mark, key: nil, label: "Italic", icon: .defaultIcon(.italic), mark: "italic", headingLevel: nil, listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false, placement: nil, presentation: nil, items: [], parentGroupKey: nil),
393
+ NativeToolbarItem(type: .mark, key: nil, label: "Underline", icon: .defaultIcon(.underline), mark: "underline", headingLevel: nil, listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false, placement: nil, presentation: nil, items: [], parentGroupKey: nil),
394
+ NativeToolbarItem(type: .mark, key: nil, label: "Strikethrough", icon: .defaultIcon(.strike), mark: "strike", headingLevel: nil, listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false, placement: nil, presentation: nil, items: [], parentGroupKey: nil),
395
+ NativeToolbarItem(type: .blockquote, key: nil, label: "Blockquote", icon: .defaultIcon(.blockquote), mark: nil, headingLevel: nil, listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false, placement: nil, presentation: nil, items: [], parentGroupKey: nil),
396
+ NativeToolbarItem(type: .separator, key: nil, label: nil, icon: nil, mark: nil, headingLevel: nil, listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false, placement: nil, presentation: nil, items: [], parentGroupKey: nil),
397
+ 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, placement: nil, presentation: nil, items: [], parentGroupKey: nil),
398
+ 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, placement: nil, presentation: nil, items: [], parentGroupKey: nil),
399
+ 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, placement: nil, presentation: nil, items: [], parentGroupKey: nil),
400
+ 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, placement: nil, presentation: nil, items: [], parentGroupKey: nil),
401
+ 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, placement: nil, presentation: nil, items: [], parentGroupKey: nil),
402
+ 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, placement: nil, presentation: nil, items: [], parentGroupKey: nil),
403
+ NativeToolbarItem(type: .separator, key: nil, label: nil, icon: nil, mark: nil, headingLevel: nil, listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false, placement: nil, presentation: nil, items: [], parentGroupKey: nil),
404
+ NativeToolbarItem(type: .command, key: nil, label: "Undo", icon: .defaultIcon(.undo), mark: nil, headingLevel: nil, listType: nil, command: .undo, nodeType: nil, isActive: false, isDisabled: false, placement: nil, presentation: nil, items: [], parentGroupKey: nil),
405
+ NativeToolbarItem(type: .command, key: nil, label: "Redo", icon: .defaultIcon(.redo), mark: nil, headingLevel: nil, listType: nil, command: .redo, nodeType: nil, isActive: false, isDisabled: false, placement: nil, presentation: nil, items: [], parentGroupKey: nil),
406
+ ]
407
+
408
+ private static func parse(
409
+ rawItem: [String: Any],
410
+ allowGroup: Bool = true,
411
+ allowSeparator: Bool = true
412
+ ) -> NativeToolbarItem? {
413
+ guard let rawType = rawItem["type"] as? String,
414
+ let type = ToolbarItemKind(rawValue: rawType)
415
+ else {
416
+ return nil
417
+ }
418
+
419
+ let key = rawItem["key"] as? String
420
+ let placement = (rawItem["placement"] as? String)
421
+ .flatMap(ToolbarItemPlacement.init(rawValue:))
422
+ switch type {
423
+ case .separator:
424
+ guard allowSeparator else { return nil }
425
+ return NativeToolbarItem(
426
+ type: .separator,
427
+ key: key,
428
+ label: nil,
429
+ icon: nil,
430
+ mark: nil,
431
+ headingLevel: nil,
432
+ listType: nil,
433
+ command: nil,
434
+ nodeType: nil,
435
+ isActive: false,
436
+ isDisabled: false,
437
+ placement: placement,
438
+ presentation: nil,
439
+ items: [],
440
+ parentGroupKey: nil
441
+ )
442
+ case .mark:
443
+ guard let mark = rawItem["mark"] 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: .mark,
451
+ key: key,
452
+ label: label,
453
+ icon: icon,
454
+ mark: mark,
455
+ headingLevel: nil,
456
+ listType: nil,
457
+ command: nil,
458
+ nodeType: nil,
459
+ isActive: false,
460
+ isDisabled: false,
461
+ placement: placement,
462
+ presentation: nil,
463
+ items: [],
464
+ parentGroupKey: nil
465
+ )
466
+ case .heading:
467
+ guard let level = (rawItem["level"] as? NSNumber)?.intValue,
468
+ (1...6).contains(level),
469
+ let label = rawItem["label"] as? String,
470
+ let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
471
+ else {
472
+ return nil
473
+ }
474
+ return NativeToolbarItem(
475
+ type: .heading,
476
+ key: key,
477
+ label: label,
478
+ icon: icon,
479
+ mark: nil,
480
+ headingLevel: level,
481
+ listType: nil,
482
+ command: nil,
483
+ nodeType: nil,
484
+ isActive: false,
485
+ isDisabled: false,
486
+ placement: placement,
487
+ presentation: nil,
488
+ items: [],
489
+ parentGroupKey: nil
490
+ )
491
+ case .blockquote:
492
+ guard let label = rawItem["label"] as? String,
493
+ let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
494
+ else {
495
+ return nil
496
+ }
497
+ return NativeToolbarItem(
498
+ type: .blockquote,
499
+ key: key,
500
+ label: label,
501
+ icon: icon,
502
+ mark: nil,
503
+ headingLevel: nil,
504
+ listType: nil,
505
+ command: nil,
506
+ nodeType: nil,
507
+ isActive: false,
508
+ isDisabled: false,
509
+ placement: placement,
510
+ presentation: nil,
511
+ items: [],
512
+ parentGroupKey: nil
513
+ )
514
+ case .list:
515
+ guard let listTypeRaw = rawItem["listType"] as? String,
516
+ let listType = ToolbarListType(rawValue: listTypeRaw),
517
+ let label = rawItem["label"] as? String,
518
+ let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
519
+ else {
520
+ return nil
521
+ }
522
+ return NativeToolbarItem(
523
+ type: .list,
524
+ key: key,
525
+ label: label,
526
+ icon: icon,
527
+ mark: nil,
528
+ headingLevel: nil,
529
+ listType: listType,
530
+ command: nil,
531
+ nodeType: nil,
532
+ isActive: false,
533
+ isDisabled: false,
534
+ placement: placement,
535
+ presentation: nil,
536
+ items: [],
537
+ parentGroupKey: nil
538
+ )
539
+ case .command:
540
+ guard let commandRaw = rawItem["command"] as? String,
541
+ let command = ToolbarCommand(rawValue: commandRaw),
542
+ let label = rawItem["label"] as? String,
543
+ let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
544
+ else {
545
+ return nil
546
+ }
547
+ return NativeToolbarItem(
548
+ type: .command,
549
+ key: key,
550
+ label: label,
551
+ icon: icon,
552
+ mark: nil,
553
+ headingLevel: nil,
554
+ listType: nil,
555
+ command: command,
556
+ nodeType: nil,
557
+ isActive: false,
558
+ isDisabled: false,
559
+ placement: placement,
560
+ presentation: nil,
561
+ items: [],
562
+ parentGroupKey: nil
563
+ )
564
+ case .node:
565
+ guard let nodeType = rawItem["nodeType"] as? String,
566
+ let label = rawItem["label"] as? String,
567
+ let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
568
+ else {
569
+ return nil
570
+ }
571
+ return NativeToolbarItem(
572
+ type: .node,
573
+ key: key,
574
+ label: label,
575
+ icon: icon,
576
+ mark: nil,
577
+ headingLevel: nil,
578
+ listType: nil,
579
+ command: nil,
580
+ nodeType: nodeType,
581
+ isActive: false,
582
+ isDisabled: false,
583
+ placement: placement,
584
+ presentation: nil,
585
+ items: [],
586
+ parentGroupKey: nil
587
+ )
588
+ case .action:
589
+ guard let key,
590
+ let label = rawItem["label"] as? String,
591
+ let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
592
+ else {
593
+ return nil
594
+ }
595
+ return NativeToolbarItem(
596
+ type: .action,
597
+ key: key,
598
+ label: label,
599
+ icon: icon,
600
+ mark: nil,
601
+ headingLevel: nil,
602
+ listType: nil,
603
+ command: nil,
604
+ nodeType: nil,
605
+ isActive: (rawItem["isActive"] as? Bool) ?? false,
606
+ isDisabled: (rawItem["isDisabled"] as? Bool) ?? false,
607
+ placement: placement,
608
+ presentation: nil,
609
+ items: [],
610
+ parentGroupKey: nil
611
+ )
612
+ case .group:
613
+ guard allowGroup,
614
+ let key,
615
+ let label = rawItem["label"] as? String,
616
+ let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"]),
617
+ let rawChildren = rawItem["items"] as? [[String: Any]]
618
+ else {
619
+ return nil
620
+ }
621
+ let presentation = (rawItem["presentation"] as? String)
622
+ .flatMap(ToolbarGroupPresentation.init(rawValue:))
623
+ ?? .expand
624
+ let children = rawChildren.compactMap {
625
+ parse(rawItem: $0, allowGroup: false, allowSeparator: false)
626
+ }
627
+ guard !children.isEmpty else { return nil }
628
+ return NativeToolbarItem(
629
+ type: .group,
630
+ key: key,
631
+ label: label,
632
+ icon: icon,
633
+ mark: nil,
634
+ headingLevel: nil,
635
+ listType: nil,
636
+ command: nil,
637
+ nodeType: nil,
638
+ isActive: false,
639
+ isDisabled: false,
640
+ placement: placement,
641
+ presentation: presentation,
642
+ items: children,
643
+ parentGroupKey: nil
644
+ )
645
+ }
646
+ }
647
+
648
+ static func from(json: String?) -> [NativeToolbarItem] {
649
+ guard let json,
650
+ let data = json.data(using: .utf8),
651
+ let rawItems = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
652
+ else {
653
+ return defaults
654
+ }
655
+
656
+ let parsed = rawItems.compactMap { parse(rawItem: $0) }
657
+ return parsed.isEmpty ? defaults : parsed
658
+ }
659
+
660
+ func resolvedKey(index: Int) -> String {
661
+ if let key {
662
+ return key
663
+ }
664
+ switch type {
665
+ case .mark:
666
+ return "mark:\(mark ?? ""):\(index)"
667
+ case .heading:
668
+ return "heading:\(headingLevel ?? 0):\(index)"
669
+ case .blockquote:
670
+ return "blockquote:\(index)"
671
+ case .list:
672
+ return "list:\(listType?.rawValue ?? ""):\(index)"
673
+ case .command:
674
+ return "command:\(command?.rawValue ?? ""):\(index)"
675
+ case .node:
676
+ return "node:\(nodeType ?? ""):\(index)"
677
+ case .action:
678
+ return "action:\(key ?? ""):\(index)"
679
+ case .group:
680
+ return "group:\(key ?? ""):\(index)"
681
+ case .separator:
682
+ return "separator:\(index)"
683
+ }
684
+ }
685
+
686
+ func with(parentGroupKey: String?) -> NativeToolbarItem {
687
+ NativeToolbarItem(
688
+ type: type,
689
+ key: key,
690
+ label: label,
691
+ icon: icon,
692
+ mark: mark,
693
+ headingLevel: headingLevel,
694
+ listType: listType,
695
+ command: command,
696
+ nodeType: nodeType,
697
+ isActive: isActive,
698
+ isDisabled: isDisabled,
699
+ placement: placement,
700
+ presentation: presentation,
701
+ items: items,
702
+ parentGroupKey: parentGroupKey
703
+ )
704
+ }
705
+
706
+ func with(parentGroupKey: String?, inheritedPlacement: ToolbarItemPlacement?) -> NativeToolbarItem {
707
+ NativeToolbarItem(
708
+ type: type,
709
+ key: key,
710
+ label: label,
711
+ icon: icon,
712
+ mark: mark,
713
+ headingLevel: headingLevel,
714
+ listType: listType,
715
+ command: command,
716
+ nodeType: nodeType,
717
+ isActive: isActive,
718
+ isDisabled: isDisabled,
719
+ placement: placement ?? inheritedPlacement,
720
+ presentation: presentation,
721
+ items: items,
722
+ parentGroupKey: parentGroupKey
723
+ )
724
+ }
725
+ }
726
+
727
+ final class EditorAccessoryToolbarView: UIInputView {
728
+ private static let baseHeight: CGFloat = 50
729
+ private static let mentionRowHeight: CGFloat = 52
730
+ private static let contentSpacing: CGFloat = 6
731
+ private static let defaultHorizontalInset: CGFloat = 0
732
+ private static let defaultKeyboardOffset: CGFloat = 0
733
+ private static let chromeTransitionDuration: TimeInterval = 0.18
734
+ private static let nativeDisabledButtonOpacity: CGFloat = 0.46
735
+
736
+ private struct VisibleToolbarItemsByPlacement {
737
+ let start: [NativeToolbarItem]
738
+ let scroll: [NativeToolbarItem]
739
+ let end: [NativeToolbarItem]
740
+ }
741
+
742
+ private struct ButtonBinding {
743
+ let item: NativeToolbarItem
744
+ let button: UIButton
745
+ let widthConstraint: NSLayoutConstraint
746
+ let heightConstraint: NSLayoutConstraint
747
+ }
748
+
749
+ private struct BarButtonBinding {
750
+ let item: NativeToolbarItem
751
+ let button: UIBarButtonItem
752
+ }
753
+
754
+ private let chromeView = UIView()
755
+ private let blurView = UIVisualEffectView(effect: nil)
756
+ private let glassTintView = UIView()
757
+ private let nativeToolbarScrollView = UIScrollView()
758
+ private let nativeToolbarView = UIToolbar()
759
+ private let bodyStackView = UIStackView()
760
+ private let startPinnedStackView = UIStackView()
761
+ private let contentStackView = UIStackView()
762
+ private let mentionScrollView = UIScrollView()
763
+ private let mentionStackView = UIStackView()
764
+ private let scrollView = UIScrollView()
765
+ private let stackView = UIStackView()
766
+ private let endPinnedStackView = UIStackView()
767
+ private var chromeLeadingConstraint: NSLayoutConstraint?
768
+ private var chromeTrailingConstraint: NSLayoutConstraint?
769
+ private var chromeBottomConstraint: NSLayoutConstraint?
770
+ private var nativeToolbarWidthConstraint: NSLayoutConstraint?
771
+ private var mentionRowHeightConstraint: NSLayoutConstraint?
772
+ private var nativeToolbarMinHeightConstraint: NSLayoutConstraint?
773
+ private var scrollViewHeightConstraint: NSLayoutConstraint?
774
+ private var nativeToolbarDidInitializeScrollPosition = false
775
+ private var buttonBindings: [ButtonBinding] = []
776
+ private var barButtonBindings: [BarButtonBinding] = []
777
+ private var separators: [UIView] = []
778
+ private var mentionButtons: [MentionSuggestionChipButton] = []
779
+ private var items: [NativeToolbarItem] = NativeToolbarItem.defaults
780
+ private var expandedGroupKey: String?
781
+ private var currentState = NativeToolbarState.empty
782
+ private var theme: EditorToolbarTheme?
783
+ private var mentionTheme: EditorMentionTheme?
784
+ private var didAnimateChromeTransition = false
785
+ fileprivate var onPressItem: ((NativeToolbarItem) -> Void)?
786
+ var onSelectMentionSuggestion: ((NativeMentionSuggestion) -> Void)?
787
+ var isShowingMentionSuggestions: Bool {
788
+ !mentionButtons.isEmpty && !mentionScrollView.isHidden && scrollView.isHidden
789
+ }
790
+ var usesNativeAppearanceForTesting: Bool {
791
+ resolvedAppearance == .native
792
+ }
793
+ var usesUIGlassEffectForTesting: Bool {
794
+ #if compiler(>=6.2)
795
+ if #available(iOS 26.0, *) {
796
+ return blurView.effect is UIGlassEffect
797
+ }
798
+ #endif
799
+ return false
800
+ }
801
+ var chromeBorderWidthForTesting: CGFloat {
802
+ chromeView.layer.borderWidth
803
+ }
804
+ var nativeChromeIsTransparentForTesting: Bool {
805
+ blurView.isHidden
806
+ && glassTintView.isHidden
807
+ && chromeView.layer.borderWidth == 0
808
+ && chromeView.layer.shadowOpacity == 0
809
+ && (chromeView.backgroundColor ?? .clear) == .clear
810
+ }
811
+ var didAnimateChromeTransitionForTesting: Bool {
812
+ didAnimateChromeTransition
813
+ }
814
+ var nativeToolbarVisibleWidthForTesting: CGFloat {
815
+ activeNativeToolbarScrollViewForTesting.bounds.width
816
+ }
817
+ var nativeToolbarContentWidthForTesting: CGFloat {
818
+ if usesNativeBarToolbar {
819
+ return max(nativeToolbarScrollView.contentSize.width, nativeToolbarView.bounds.width)
820
+ }
821
+ return max(scrollView.contentSize.width, stackView.bounds.width)
822
+ }
823
+ var nativeToolbarContentOffsetXForTesting: CGFloat {
824
+ activeNativeToolbarScrollViewForTesting.contentOffset.x
825
+ }
826
+ func setNativeToolbarContentOffsetXForTesting(_ offsetX: CGFloat) {
827
+ activeNativeToolbarScrollViewForTesting.contentOffset.x = offsetX
828
+ }
829
+ var selectedButtonCountForTesting: Int {
830
+ #if compiler(>=6.2)
831
+ if #available(iOS 26.0, *) {
832
+ if usesNativeBarToolbar {
833
+ return barButtonBindings.filter { $0.button.style == .prominent }.count
834
+ }
835
+ }
836
+ #endif
837
+ return buttonBindings.filter(\.button.isSelected).count
838
+ }
839
+ func mentionButtonAtForTesting(_ index: Int) -> MentionSuggestionChipButton? {
840
+ mentionButtons.indices.contains(index) ? mentionButtons[index] : nil
841
+ }
842
+ func buttonCountForTesting() -> Int {
843
+ buttonBindings.count
844
+ }
845
+ func buttonLabelForTesting(_ index: Int) -> String? {
846
+ buttonBindings.indices.contains(index) ? buttonBindings[index].button.accessibilityLabel : nil
847
+ }
848
+ func triggerButtonTapForTesting(_ index: Int) {
849
+ guard buttonBindings.indices.contains(index) else { return }
850
+ buttonBindings[index].button.sendActions(for: .touchUpInside)
851
+ }
852
+
853
+ override var intrinsicContentSize: CGSize {
854
+ let contentHeight = mentionButtons.isEmpty ? resolvedToolbarHeight : Self.mentionRowHeight
855
+ return CGSize(
856
+ width: UIView.noIntrinsicMetric,
857
+ height: contentHeight + resolvedKeyboardOffset
858
+ )
859
+ }
860
+
861
+ convenience init(frame: CGRect) {
862
+ self.init(frame: frame, inputViewStyle: .keyboard)
863
+ }
864
+
865
+ override init(frame: CGRect, inputViewStyle: UIInputView.Style) {
866
+ super.init(frame: frame, inputViewStyle: inputViewStyle)
867
+ translatesAutoresizingMaskIntoConstraints = false
868
+ autoresizingMask = [.flexibleHeight]
869
+ backgroundColor = .clear
870
+ isOpaque = false
871
+ allowsSelfSizing = true
872
+ setupView()
873
+ rebuildButtons()
874
+ }
875
+
876
+ required init?(coder: NSCoder) {
877
+ return nil
878
+ }
879
+
880
+ override func didMoveToSuperview() {
881
+ super.didMoveToSuperview()
882
+ refreshNativeHostTransparencyIfNeeded()
883
+ }
884
+
885
+ override func didMoveToWindow() {
886
+ super.didMoveToWindow()
887
+ refreshNativeHostTransparencyIfNeeded()
888
+ }
889
+
890
+ override func layoutSubviews() {
891
+ super.layoutSubviews()
892
+ updateNativeToolbarMetricsIfNeeded()
893
+ }
894
+
895
+ fileprivate func setItems(_ items: [NativeToolbarItem]) {
896
+ self.items = items
897
+ if let expandedGroupKey,
898
+ !items.contains(where: {
899
+ $0.type == .group && $0.key == expandedGroupKey && ($0.presentation ?? .expand) == .expand
900
+ })
901
+ {
902
+ self.expandedGroupKey = nil
903
+ }
904
+ rebuildButtons()
905
+ }
906
+ func setItemsJSONForTesting(_ json: String) {
907
+ setItems(NativeToolbarItem.from(json: json))
908
+ }
909
+ func applyStateJSONForTesting(_ json: String) {
910
+ guard let state = NativeToolbarState(updateJSON: json) else { return }
911
+ apply(state: state)
912
+ }
913
+
914
+ func apply(mentionTheme: EditorMentionTheme?) {
915
+ self.mentionTheme = mentionTheme
916
+ for button in mentionButtons {
917
+ button.apply(theme: mentionTheme, toolbarAppearance: resolvedAppearance)
918
+ }
919
+ }
920
+
921
+ func apply(theme: EditorToolbarTheme?) {
922
+ apply(theme: theme, animateChrome: false)
923
+ }
924
+
925
+ private func apply(theme: EditorToolbarTheme?, animateChrome: Bool) {
926
+ self.theme = theme
927
+ let usesNativeAppearance = resolvedAppearance == .native
928
+ let usesTransparentMentionChrome = self.usesTransparentMentionChrome
929
+ let hasFloatingGlassButtons = self.usesFloatingGlassButtons
930
+ let usesBarToolbar = usesNativeBarToolbar
931
+ let targetBlurHidden = usesTransparentMentionChrome || usesBarToolbar || !usesNativeAppearance
932
+ let targetBlurAlpha: CGFloat = usesNativeAppearance && !usesTransparentMentionChrome ? resolvedEffectAlpha : 0
933
+ let targetBlurEffect = usesNativeAppearance && !usesTransparentMentionChrome ? resolvedBlurEffect() : nil
934
+ let targetGlassHidden = usesTransparentMentionChrome || usesBarToolbar || !usesNativeAppearance
935
+ let targetGlassBackground = usesNativeAppearance && !usesTransparentMentionChrome
936
+ ? UIColor.systemBackground.withAlphaComponent(resolvedGlassTintAlpha)
937
+ : .clear
938
+ let targetGlassAlpha: CGFloat = targetGlassHidden ? 0 : 1
939
+ let targetBorderColor = usesTransparentMentionChrome ? UIColor.clear : resolvedBorderColor
940
+ let targetBorderWidth: CGFloat = usesTransparentMentionChrome || usesBarToolbar
941
+ ? 0
942
+ : (usesNativeAppearance
943
+ ? (1 / UIScreen.main.scale)
944
+ : resolvedBorderWidth)
945
+ let targetClipsToBounds =
946
+ !usesTransparentMentionChrome
947
+ && ((usesNativeAppearance && !hasFloatingGlassButtons && !usesBarToolbar) || resolvedBorderRadius > 0)
948
+ let targetShadowOpacity: Float =
949
+ usesNativeAppearance && !usesTransparentMentionChrome && !hasFloatingGlassButtons && !usesBarToolbar ? 0.08 : 0
950
+ let targetShadowRadius: CGFloat =
951
+ usesNativeAppearance && !usesTransparentMentionChrome && !hasFloatingGlassButtons && !usesBarToolbar ? 10 : 0
952
+
953
+ chromeView.backgroundColor = usesNativeAppearance
954
+ ? .clear
955
+ : (theme?.backgroundColor ?? .systemBackground)
956
+ chromeView.tintColor = usesNativeAppearance
957
+ ? nil
958
+ : (theme?.buttonColor ?? tintColor)
959
+ chromeView.isOpaque = false
960
+ chromeView.layer.cornerRadius = resolvedBorderRadius
961
+ if #available(iOS 13.0, *) {
962
+ chromeView.layer.cornerCurve = .continuous
963
+ }
964
+ #if compiler(>=6.2)
965
+ if #available(iOS 26.0, *) {
966
+ let cornerConfig: UICornerConfiguration = usesNativeAppearance
967
+ ? .capsule(maximumRadius: 24)
968
+ : .uniformCorners(radius: .fixed(Double(resolvedBorderRadius)))
969
+ chromeView.cornerConfiguration = cornerConfig
970
+ blurView.cornerConfiguration = cornerConfig
971
+ glassTintView.cornerConfiguration = cornerConfig
972
+ }
973
+ #endif
974
+ chromeView.layer.shadowOffset = CGSize(width: 0, height: 2)
975
+ chromeView.layer.shadowColor = UIColor.black.cgColor
976
+
977
+ let applyChromeProperties = {
978
+ self.blurView.alpha = targetBlurAlpha
979
+ self.glassTintView.alpha = targetGlassAlpha
980
+ self.chromeView.layer.borderColor = targetBorderColor.cgColor
981
+ self.chromeView.layer.borderWidth = targetBorderWidth
982
+ self.chromeView.layer.shadowOpacity = targetShadowOpacity
983
+ self.chromeView.layer.shadowRadius = targetShadowRadius
984
+ }
985
+ let finishChromeProperties = {
986
+ self.blurView.isHidden = targetBlurHidden
987
+ self.blurView.effect = targetBlurEffect
988
+ self.blurView.alpha = targetBlurAlpha
989
+ self.glassTintView.isHidden = targetGlassHidden
990
+ self.glassTintView.backgroundColor = targetGlassHidden ? .clear : targetGlassBackground
991
+ self.glassTintView.alpha = targetGlassAlpha
992
+ self.chromeView.layer.borderColor = targetBorderColor.cgColor
993
+ self.chromeView.layer.borderWidth = targetBorderWidth
994
+ self.chromeView.layer.shadowOpacity = targetShadowOpacity
995
+ self.chromeView.layer.shadowRadius = targetShadowRadius
996
+ self.chromeView.clipsToBounds = targetClipsToBounds
997
+ }
998
+
999
+ let shouldAnimateChrome = animateChrome && UIView.areAnimationsEnabled && window != nil
1000
+ didAnimateChromeTransition = shouldAnimateChrome
1001
+ if shouldAnimateChrome {
1002
+ let blurWasHidden = blurView.isHidden
1003
+ let glassWasHidden = glassTintView.isHidden
1004
+ if !targetBlurHidden {
1005
+ blurView.effect = targetBlurEffect
1006
+ }
1007
+ if !targetBlurHidden || !blurWasHidden {
1008
+ blurView.isHidden = false
1009
+ }
1010
+ if blurWasHidden && !targetBlurHidden {
1011
+ blurView.alpha = 0
1012
+ }
1013
+ if !targetGlassHidden {
1014
+ glassTintView.backgroundColor = targetGlassBackground
1015
+ }
1016
+ if !targetGlassHidden || !glassWasHidden {
1017
+ glassTintView.isHidden = false
1018
+ }
1019
+ if glassWasHidden && !targetGlassHidden {
1020
+ glassTintView.alpha = 0
1021
+ }
1022
+ chromeView.clipsToBounds = targetClipsToBounds
1023
+ UIView.animate(
1024
+ withDuration: Self.chromeTransitionDuration,
1025
+ delay: 0,
1026
+ options: [.beginFromCurrentState, .allowUserInteraction, .curveEaseOut],
1027
+ animations: applyChromeProperties,
1028
+ completion: { _ in
1029
+ finishChromeProperties()
1030
+ }
1031
+ )
1032
+ } else {
1033
+ finishChromeProperties()
1034
+ }
1035
+
1036
+ chromeLeadingConstraint?.constant = resolvedHorizontalInset
1037
+ chromeTrailingConstraint?.constant = -resolvedHorizontalInset
1038
+ chromeBottomConstraint?.constant = -resolvedKeyboardOffset
1039
+ nativeToolbarWidthConstraint?.constant = resolvedToolbarHeight
1040
+ nativeToolbarMinHeightConstraint?.constant = resolvedToolbarHeight
1041
+ scrollViewHeightConstraint?.constant = resolvedToolbarHeight
1042
+ nativeToolbarScrollView.isHidden = !(usesBarToolbar && mentionButtons.isEmpty)
1043
+ nativeToolbarView.isHidden = !(usesBarToolbar && mentionButtons.isEmpty)
1044
+ nativeToolbarView.tintColor = usesNativeAppearance
1045
+ ? nil
1046
+ : (theme?.buttonColor ?? tintColor)
1047
+ contentStackView.isHidden = usesBarToolbar && mentionButtons.isEmpty
1048
+ invalidateIntrinsicContentSize()
1049
+ for separator in separators {
1050
+ separator.isHidden = hasFloatingGlassButtons
1051
+ separator.backgroundColor = usesNativeAppearance
1052
+ ? UIColor.separator.withAlphaComponent(0.45)
1053
+ : (theme?.separatorColor ?? .separator)
1054
+ }
1055
+ for binding in buttonBindings {
1056
+ binding.button.layer.cornerRadius = resolvedButtonBorderRadius
1057
+ binding.widthConstraint.constant = resolvedButtonSize
1058
+ binding.heightConstraint.constant = resolvedButtonSize
1059
+ }
1060
+ for button in mentionButtons {
1061
+ button.apply(theme: mentionTheme, toolbarAppearance: resolvedAppearance)
1062
+ }
1063
+ refreshNativeHostTransparencyIfNeeded()
1064
+ updateNativeToolbarMetricsIfNeeded()
1065
+ apply(state: currentState)
1066
+ }
1067
+
1068
+ @discardableResult
1069
+ func setMentionSuggestions(_ suggestions: [NativeMentionSuggestion]) -> Bool {
1070
+ let hadSuggestions = !mentionButtons.isEmpty
1071
+
1072
+ mentionButtons.forEach { button in
1073
+ mentionStackView.removeArrangedSubview(button)
1074
+ button.removeFromSuperview()
1075
+ }
1076
+ mentionButtons.removeAll()
1077
+
1078
+ for suggestion in suggestions.prefix(8) {
1079
+ let button = MentionSuggestionChipButton(
1080
+ suggestion: suggestion,
1081
+ theme: mentionTheme,
1082
+ toolbarAppearance: resolvedAppearance
1083
+ )
1084
+ button.addTarget(self, action: #selector(handleSelectMentionSuggestion(_:)), for: .touchUpInside)
1085
+ mentionButtons.append(button)
1086
+ mentionStackView.addArrangedSubview(button)
1087
+ }
1088
+
1089
+ let hasSuggestions = !mentionButtons.isEmpty
1090
+ mentionScrollView.isHidden = !hasSuggestions
1091
+ scrollView.isHidden = hasSuggestions
1092
+ mentionRowHeightConstraint?.constant = hasSuggestions ? Self.mentionRowHeight : 0
1093
+ apply(theme: theme, animateChrome: hadSuggestions != hasSuggestions)
1094
+ invalidateIntrinsicContentSize()
1095
+ setNeedsLayout()
1096
+ return hadSuggestions != hasSuggestions
1097
+ }
1098
+
1099
+ fileprivate func apply(state: NativeToolbarState) {
1100
+ currentState = state
1101
+ for binding in buttonBindings {
1102
+ let buttonState = buttonState(for: binding.item, state: state)
1103
+ binding.button.isEnabled = buttonState.enabled
1104
+ binding.button.isSelected = buttonState.active
1105
+ binding.button.accessibilityTraits = buttonState.active ? [.button, .selected] : .button
1106
+ if binding.item.type == .group, (binding.item.presentation ?? .expand) == .menu {
1107
+ binding.button.menu = makeGroupMenu(item: binding.item)
1108
+ }
1109
+ updateButtonAppearance(binding.button, item: binding.item, enabled: buttonState.enabled, active: buttonState.active)
1110
+ }
1111
+
1112
+ #if compiler(>=6.2)
1113
+ if #available(iOS 26.0, *), usesNativeBarToolbar {
1114
+ for binding in barButtonBindings {
1115
+ let state = buttonState(for: binding.item, state: currentState)
1116
+ binding.button.isEnabled = state.enabled
1117
+ binding.button.isSelected = state.active
1118
+ binding.button.style = state.active ? .prominent : .plain
1119
+ if binding.item.type == .group, (binding.item.presentation ?? .expand) == .menu {
1120
+ binding.button.menu = makeGroupMenu(item: binding.item)
1121
+ }
1122
+ }
1123
+ }
1124
+ #endif
1125
+ }
1126
+
1127
+ var firstButtonAlphaForTesting: CGFloat {
1128
+ buttonBindings.first?.button.alpha ?? 0
1129
+ }
1130
+ var firstButtonTintColorForTesting: UIColor? {
1131
+ buttonBindings.first?.button.tintColor
1132
+ }
1133
+ func firstButtonTitleColorForTesting(_ state: UIControl.State) -> UIColor? {
1134
+ buttonBindings.first?.button.titleColor(for: state)
1135
+ }
1136
+ var firstButtonTintAdjustmentModeForTesting: UIView.TintAdjustmentMode {
1137
+ buttonBindings.first?.button.tintAdjustmentMode ?? .automatic
1138
+ }
1139
+
1140
+ func applyBoldStateForTesting(active: Bool, enabled: Bool) {
1141
+ apply(
1142
+ state: NativeToolbarState(
1143
+ marks: ["bold": active],
1144
+ nodes: [:],
1145
+ commands: [:],
1146
+ allowedMarks: enabled ? ["bold"] : [],
1147
+ insertableNodes: [],
1148
+ canUndo: false,
1149
+ canRedo: false
1150
+ )
1151
+ )
1152
+ }
1153
+
1154
+ private func setupView() {
1155
+ chromeView.translatesAutoresizingMaskIntoConstraints = false
1156
+ chromeView.backgroundColor = .systemBackground
1157
+ chromeView.layer.borderColor = UIColor.separator.cgColor
1158
+ chromeView.layer.borderWidth = 0.5
1159
+ chromeView.isOpaque = false
1160
+ addSubview(chromeView)
1161
+
1162
+ blurView.translatesAutoresizingMaskIntoConstraints = false
1163
+ blurView.isHidden = true
1164
+ blurView.isUserInteractionEnabled = false
1165
+ blurView.clipsToBounds = true
1166
+ chromeView.addSubview(blurView)
1167
+
1168
+ glassTintView.translatesAutoresizingMaskIntoConstraints = false
1169
+ glassTintView.isHidden = true
1170
+ glassTintView.isUserInteractionEnabled = false
1171
+ chromeView.addSubview(glassTintView)
1172
+
1173
+ nativeToolbarScrollView.translatesAutoresizingMaskIntoConstraints = false
1174
+ nativeToolbarScrollView.isHidden = true
1175
+ nativeToolbarScrollView.backgroundColor = .clear
1176
+ nativeToolbarScrollView.showsHorizontalScrollIndicator = false
1177
+ nativeToolbarScrollView.showsVerticalScrollIndicator = false
1178
+ nativeToolbarScrollView.alwaysBounceHorizontal = true
1179
+ nativeToolbarScrollView.alwaysBounceVertical = false
1180
+ chromeView.addSubview(nativeToolbarScrollView)
1181
+
1182
+ nativeToolbarView.translatesAutoresizingMaskIntoConstraints = false
1183
+ nativeToolbarView.isHidden = true
1184
+ nativeToolbarView.backgroundColor = .clear
1185
+ nativeToolbarView.isTranslucent = true
1186
+ nativeToolbarView.setContentHuggingPriority(.required, for: .vertical)
1187
+ nativeToolbarView.setContentCompressionResistancePriority(.required, for: .vertical)
1188
+ nativeToolbarScrollView.addSubview(nativeToolbarView)
1189
+
1190
+ bodyStackView.translatesAutoresizingMaskIntoConstraints = false
1191
+ bodyStackView.axis = .horizontal
1192
+ bodyStackView.alignment = .fill
1193
+ bodyStackView.spacing = 0
1194
+ chromeView.addSubview(bodyStackView)
1195
+
1196
+ startPinnedStackView.translatesAutoresizingMaskIntoConstraints = false
1197
+ startPinnedStackView.axis = .horizontal
1198
+ startPinnedStackView.alignment = .center
1199
+ startPinnedStackView.spacing = 6
1200
+ startPinnedStackView.setContentHuggingPriority(.required, for: .horizontal)
1201
+ startPinnedStackView.setContentCompressionResistancePriority(.required, for: .horizontal)
1202
+ bodyStackView.addArrangedSubview(startPinnedStackView)
1203
+
1204
+ contentStackView.translatesAutoresizingMaskIntoConstraints = false
1205
+ contentStackView.axis = .vertical
1206
+ contentStackView.spacing = 0
1207
+ contentStackView.setContentHuggingPriority(.defaultLow, for: .horizontal)
1208
+ contentStackView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
1209
+ bodyStackView.addArrangedSubview(contentStackView)
1210
+
1211
+ endPinnedStackView.translatesAutoresizingMaskIntoConstraints = false
1212
+ endPinnedStackView.axis = .horizontal
1213
+ endPinnedStackView.alignment = .center
1214
+ endPinnedStackView.spacing = 6
1215
+ endPinnedStackView.setContentHuggingPriority(.required, for: .horizontal)
1216
+ endPinnedStackView.setContentCompressionResistancePriority(.required, for: .horizontal)
1217
+ bodyStackView.addArrangedSubview(endPinnedStackView)
1218
+
1219
+ mentionScrollView.translatesAutoresizingMaskIntoConstraints = false
1220
+ mentionScrollView.showsHorizontalScrollIndicator = false
1221
+ mentionScrollView.alwaysBounceHorizontal = true
1222
+ mentionScrollView.isHidden = true
1223
+ contentStackView.addArrangedSubview(mentionScrollView)
1224
+
1225
+ mentionStackView.translatesAutoresizingMaskIntoConstraints = false
1226
+ mentionStackView.axis = .horizontal
1227
+ mentionStackView.alignment = .fill
1228
+ mentionStackView.spacing = 8
1229
+ mentionScrollView.addSubview(mentionStackView)
1230
+
1231
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
1232
+ scrollView.showsHorizontalScrollIndicator = false
1233
+ scrollView.alwaysBounceHorizontal = true
1234
+ contentStackView.addArrangedSubview(scrollView)
1235
+
1236
+ stackView.translatesAutoresizingMaskIntoConstraints = false
1237
+ stackView.axis = .horizontal
1238
+ stackView.alignment = .center
1239
+ stackView.spacing = 6
1240
+ scrollView.addSubview(stackView)
1241
+
1242
+ let leading = chromeView.leadingAnchor.constraint(
1243
+ equalTo: leadingAnchor,
1244
+ constant: Self.defaultHorizontalInset
1245
+ )
1246
+ let trailing = chromeView.trailingAnchor.constraint(
1247
+ equalTo: trailingAnchor,
1248
+ constant: -Self.defaultHorizontalInset
1249
+ )
1250
+ let bottom = chromeView.bottomAnchor.constraint(
1251
+ equalTo: safeAreaLayoutGuide.bottomAnchor,
1252
+ constant: -Self.defaultKeyboardOffset
1253
+ )
1254
+ chromeLeadingConstraint = leading
1255
+ chromeTrailingConstraint = trailing
1256
+ chromeBottomConstraint = bottom
1257
+ let mentionHeight = mentionScrollView.heightAnchor.constraint(equalToConstant: 0)
1258
+ mentionRowHeightConstraint = mentionHeight
1259
+ let nativeToolbarWidth = nativeToolbarView.widthAnchor.constraint(greaterThanOrEqualToConstant: resolvedToolbarHeight)
1260
+ nativeToolbarWidthConstraint = nativeToolbarWidth
1261
+ let nativeToolbarMinHeight = nativeToolbarView.heightAnchor.constraint(greaterThanOrEqualToConstant: resolvedToolbarHeight)
1262
+ nativeToolbarMinHeightConstraint = nativeToolbarMinHeight
1263
+ let scrollViewHeight = scrollView.heightAnchor.constraint(equalToConstant: resolvedToolbarHeight)
1264
+ scrollViewHeightConstraint = scrollViewHeight
1265
+
1266
+ NSLayoutConstraint.activate([
1267
+ chromeView.topAnchor.constraint(equalTo: topAnchor),
1268
+ leading,
1269
+ trailing,
1270
+ bottom,
1271
+
1272
+ blurView.topAnchor.constraint(equalTo: chromeView.topAnchor),
1273
+ blurView.leadingAnchor.constraint(equalTo: chromeView.leadingAnchor),
1274
+ blurView.trailingAnchor.constraint(equalTo: chromeView.trailingAnchor),
1275
+ blurView.bottomAnchor.constraint(equalTo: chromeView.bottomAnchor),
1276
+
1277
+ glassTintView.topAnchor.constraint(equalTo: chromeView.topAnchor),
1278
+ glassTintView.leadingAnchor.constraint(equalTo: chromeView.leadingAnchor),
1279
+ glassTintView.trailingAnchor.constraint(equalTo: chromeView.trailingAnchor),
1280
+ glassTintView.bottomAnchor.constraint(equalTo: chromeView.bottomAnchor),
1281
+
1282
+ nativeToolbarScrollView.topAnchor.constraint(equalTo: chromeView.topAnchor),
1283
+ nativeToolbarScrollView.leadingAnchor.constraint(equalTo: chromeView.leadingAnchor),
1284
+ nativeToolbarScrollView.trailingAnchor.constraint(equalTo: chromeView.trailingAnchor),
1285
+ nativeToolbarScrollView.bottomAnchor.constraint(equalTo: chromeView.bottomAnchor),
1286
+
1287
+ nativeToolbarView.topAnchor.constraint(equalTo: nativeToolbarScrollView.contentLayoutGuide.topAnchor),
1288
+ nativeToolbarView.leadingAnchor.constraint(equalTo: nativeToolbarScrollView.contentLayoutGuide.leadingAnchor),
1289
+ nativeToolbarView.trailingAnchor.constraint(equalTo: nativeToolbarScrollView.contentLayoutGuide.trailingAnchor),
1290
+ nativeToolbarView.bottomAnchor.constraint(equalTo: nativeToolbarScrollView.contentLayoutGuide.bottomAnchor),
1291
+ nativeToolbarView.heightAnchor.constraint(equalTo: nativeToolbarScrollView.frameLayoutGuide.heightAnchor),
1292
+ nativeToolbarMinHeight,
1293
+ nativeToolbarWidth,
1294
+
1295
+ bodyStackView.topAnchor.constraint(equalTo: chromeView.topAnchor, constant: 6),
1296
+ bodyStackView.leadingAnchor.constraint(equalTo: chromeView.leadingAnchor),
1297
+ bodyStackView.trailingAnchor.constraint(equalTo: chromeView.trailingAnchor),
1298
+ bodyStackView.bottomAnchor.constraint(equalTo: chromeView.safeAreaLayoutGuide.bottomAnchor, constant: -6),
1299
+
1300
+ mentionHeight,
1301
+
1302
+ mentionStackView.topAnchor.constraint(equalTo: mentionScrollView.contentLayoutGuide.topAnchor),
1303
+ mentionStackView.leadingAnchor.constraint(equalTo: mentionScrollView.contentLayoutGuide.leadingAnchor, constant: 12),
1304
+ mentionStackView.trailingAnchor.constraint(equalTo: mentionScrollView.contentLayoutGuide.trailingAnchor, constant: -12),
1305
+ mentionStackView.bottomAnchor.constraint(equalTo: mentionScrollView.contentLayoutGuide.bottomAnchor),
1306
+ mentionStackView.heightAnchor.constraint(equalTo: mentionScrollView.frameLayoutGuide.heightAnchor),
1307
+
1308
+ stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 6),
1309
+ stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 12),
1310
+ stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -12),
1311
+ stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -6),
1312
+ stackView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor, constant: -12),
1313
+ scrollViewHeight,
1314
+ ])
1315
+
1316
+ }
1317
+
1318
+ private func rebuildButtons() {
1319
+ buttonBindings.removeAll()
1320
+ barButtonBindings.removeAll()
1321
+ separators.removeAll()
1322
+ nativeToolbarDidInitializeScrollPosition = false
1323
+ for arrangedSubview in startPinnedStackView.arrangedSubviews {
1324
+ startPinnedStackView.removeArrangedSubview(arrangedSubview)
1325
+ arrangedSubview.removeFromSuperview()
1326
+ }
1327
+ for arrangedSubview in stackView.arrangedSubviews {
1328
+ stackView.removeArrangedSubview(arrangedSubview)
1329
+ arrangedSubview.removeFromSuperview()
1330
+ }
1331
+ for arrangedSubview in endPinnedStackView.arrangedSubviews {
1332
+ endPinnedStackView.removeArrangedSubview(arrangedSubview)
1333
+ arrangedSubview.removeFromSuperview()
1334
+ }
1335
+ let visibleItems = visibleToolbarItemsByPlacement()
1336
+ rebuildButtons(items: visibleItems.start, in: startPinnedStackView)
1337
+ rebuildButtons(items: visibleItems.scroll, in: stackView)
1338
+ rebuildButtons(items: visibleItems.end, in: endPinnedStackView)
1339
+
1340
+ #if compiler(>=6.2)
1341
+ if #available(iOS 26.0, *) {
1342
+ nativeToolbarView.setItems(makeNativeToolbarItems(from: visibleItems.scroll), animated: false)
1343
+ } else {
1344
+ nativeToolbarView.setItems([], animated: false)
1345
+ }
1346
+ #else
1347
+ nativeToolbarView.setItems([], animated: false)
1348
+ #endif
1349
+
1350
+ updateNativeToolbarMetricsIfNeeded()
1351
+ apply(theme: theme)
1352
+ apply(state: currentState)
1353
+ }
1354
+
1355
+ private func rebuildButtons(items: [NativeToolbarItem], in container: UIStackView) {
1356
+ for item in items {
1357
+ if item.type == .separator {
1358
+ container.addArrangedSubview(makeSeparator())
1359
+ continue
1360
+ }
1361
+
1362
+ container.addArrangedSubview(makeButton(item: item))
1363
+ }
1364
+ }
1365
+
1366
+ private func compactToolbarItems(_ items: [NativeToolbarItem]) -> [NativeToolbarItem] {
1367
+ items.enumerated().filter { index, item in
1368
+ guard item.type == .separator else { return true }
1369
+ guard index > 0, index < items.count - 1 else { return false }
1370
+ return items[index - 1].type != .separator && items[index + 1].type != .separator
1371
+ }.map(\.element)
1372
+ }
1373
+
1374
+ private func visibleToolbarItems() -> [NativeToolbarItem] {
1375
+ var visible: [NativeToolbarItem] = []
1376
+ for item in compactToolbarItems(items) {
1377
+ visible.append(item)
1378
+ if item.type == .group,
1379
+ (item.presentation ?? .expand) == .expand,
1380
+ expandedGroupKey == item.key
1381
+ {
1382
+ visible.append(contentsOf: item.items.map {
1383
+ $0.with(parentGroupKey: item.key, inheritedPlacement: item.placement)
1384
+ })
1385
+ }
1386
+ }
1387
+ return compactToolbarItems(visible)
1388
+ }
1389
+
1390
+ private func visibleToolbarItemsByPlacement() -> VisibleToolbarItemsByPlacement {
1391
+ var start: [NativeToolbarItem] = []
1392
+ var scroll: [NativeToolbarItem] = []
1393
+ var end: [NativeToolbarItem] = []
1394
+
1395
+ for item in visibleToolbarItems() {
1396
+ switch item.placement ?? .scroll {
1397
+ case .start:
1398
+ start.append(item)
1399
+ case .scroll:
1400
+ scroll.append(item)
1401
+ case .end:
1402
+ end.append(item)
1403
+ }
1404
+ }
1405
+
1406
+ return VisibleToolbarItemsByPlacement(
1407
+ start: compactToolbarItems(start),
1408
+ scroll: compactToolbarItems(scroll),
1409
+ end: compactToolbarItems(end)
1410
+ )
1411
+ }
1412
+
1413
+ private func handleToolbarButtonPress(_ item: NativeToolbarItem) {
1414
+ switch item.type {
1415
+ case .group:
1416
+ handleGroupPress(item)
1417
+ default:
1418
+ onPressItem?(item.with(parentGroupKey: nil))
1419
+ if let parentGroupKey = item.parentGroupKey,
1420
+ expandedGroupKey == parentGroupKey
1421
+ {
1422
+ expandedGroupKey = nil
1423
+ rebuildButtons()
1424
+ }
1425
+ }
1426
+ }
1427
+
1428
+ private func handleGroupPress(_ item: NativeToolbarItem) {
1429
+ guard item.type == .group, !item.items.isEmpty else { return }
1430
+ switch item.presentation ?? .expand {
1431
+ case .expand:
1432
+ expandedGroupKey = expandedGroupKey == item.key ? nil : item.key
1433
+ rebuildButtons()
1434
+ case .menu:
1435
+ break
1436
+ }
1437
+ }
1438
+
1439
+ private func makeGroupMenu(item: NativeToolbarItem) -> UIMenu? {
1440
+ guard item.type == .group else { return nil }
1441
+ let actions = item.items.compactMap { child -> UIAction? in
1442
+ let state = buttonState(for: child, state: currentState)
1443
+ let image = child.icon?.resolvedSFSymbolName().flatMap { UIImage(systemName: $0) }
1444
+ let title = child.label ?? child.icon?.resolvedGlyphText() ?? "Item"
1445
+ return UIAction(
1446
+ title: title,
1447
+ image: image,
1448
+ identifier: nil,
1449
+ discoverabilityTitle: child.label,
1450
+ attributes: state.enabled ? [] : [.disabled],
1451
+ state: state.active ? .on : .off
1452
+ ) { [weak self] _ in
1453
+ self?.handleToolbarButtonPress(child)
1454
+ }
1455
+ }
1456
+ guard !actions.isEmpty else { return nil }
1457
+ return UIMenu(title: item.label ?? "", children: actions)
1458
+ }
1459
+
1460
+ private func updateNativeToolbarMetricsIfNeeded() {
1461
+ #if compiler(>=6.2)
1462
+ guard #available(iOS 26.0, *), usesNativeBarToolbar else {
1463
+ nativeToolbarWidthConstraint?.constant = resolvedToolbarHeight
1464
+ nativeToolbarDidInitializeScrollPosition = false
1465
+ return
1466
+ }
1467
+
1468
+ let availableWidth = max(chromeView.bounds.width, bounds.width, 1)
1469
+ let targetHeight = max(chromeView.bounds.height, resolvedToolbarHeight)
1470
+ nativeToolbarView.layoutIfNeeded()
1471
+ let fittingSize = nativeToolbarView.sizeThatFits(
1472
+ CGSize(width: CGFloat.greatestFiniteMagnitude, height: targetHeight)
1473
+ )
1474
+ let contentFrames = nativeToolbarView.subviews.compactMap { subview -> CGRect? in
1475
+ guard !subview.isHidden,
1476
+ subview.alpha > 0.01,
1477
+ subview.bounds.width > 0,
1478
+ subview.bounds.height > 0
1479
+ else {
1480
+ return nil
1481
+ }
1482
+ return subview.frame
1483
+ }
1484
+ let measuredSubviewWidth: CGFloat
1485
+ if let minX = contentFrames.map(\.minX).min(),
1486
+ let maxX = contentFrames.map(\.maxX).max()
1487
+ {
1488
+ measuredSubviewWidth = ceil(maxX + max(0, minX))
1489
+ } else {
1490
+ measuredSubviewWidth = 0
1491
+ }
1492
+ let contentWidth = max(ceil(fittingSize.width), measuredSubviewWidth, availableWidth)
1493
+ nativeToolbarWidthConstraint?.constant = contentWidth
1494
+ nativeToolbarScrollView.alwaysBounceHorizontal = contentWidth > availableWidth
1495
+ let minOffsetX = -nativeToolbarScrollView.adjustedContentInset.left
1496
+ let maxOffsetX = max(
1497
+ minOffsetX,
1498
+ contentWidth - nativeToolbarScrollView.bounds.width + nativeToolbarScrollView.adjustedContentInset.right
1499
+ )
1500
+ let targetOffsetX: CGFloat
1501
+ if nativeToolbarDidInitializeScrollPosition {
1502
+ targetOffsetX = min(max(nativeToolbarScrollView.contentOffset.x, minOffsetX), maxOffsetX)
1503
+ } else {
1504
+ targetOffsetX = minOffsetX
1505
+ nativeToolbarDidInitializeScrollPosition = true
1506
+ }
1507
+ if abs(nativeToolbarScrollView.contentOffset.x - targetOffsetX) > 0.5 {
1508
+ nativeToolbarScrollView.setContentOffset(
1509
+ CGPoint(x: targetOffsetX, y: nativeToolbarScrollView.contentOffset.y),
1510
+ animated: false
1511
+ )
1512
+ }
1513
+ #else
1514
+ nativeToolbarWidthConstraint?.constant = resolvedToolbarHeight
1515
+ nativeToolbarDidInitializeScrollPosition = false
1516
+ #endif
1517
+ }
1518
+
1519
+ #if compiler(>=6.2)
1520
+ @available(iOS 26.0, *)
1521
+ private func makeNativeToolbarItems(from compactItems: [NativeToolbarItem]) -> [UIBarButtonItem] {
1522
+ var toolbarItems: [UIBarButtonItem] = []
1523
+ var previousWasSeparator = true
1524
+
1525
+ for item in compactItems {
1526
+ if item.type == .separator {
1527
+ if !previousWasSeparator, !toolbarItems.isEmpty {
1528
+ let spacer = UIBarButtonItem(systemItem: .fixedSpace)
1529
+ spacer.width = 0
1530
+ toolbarItems.append(spacer)
1531
+ }
1532
+ previousWasSeparator = true
1533
+ continue
1534
+ }
1535
+
1536
+ let state = buttonState(for: item, state: currentState)
1537
+ let barButtonItem = makeNativeBarButtonItem(item: item, enabled: state.enabled, active: state.active)
1538
+ barButtonBindings.append(BarButtonBinding(item: item, button: barButtonItem))
1539
+ toolbarItems.append(barButtonItem)
1540
+ previousWasSeparator = false
1541
+ }
1542
+
1543
+ return toolbarItems
1544
+ }
1545
+
1546
+ @available(iOS 26.0, *)
1547
+ private func makeNativeBarButtonItem(
1548
+ item: NativeToolbarItem,
1549
+ enabled: Bool,
1550
+ active: Bool
1551
+ ) -> UIBarButtonItem {
1552
+ let image = item.icon?.resolvedSFSymbolName().flatMap { UIImage(systemName: $0) }
1553
+ let title = image == nil ? item.icon?.resolvedGlyphText() : nil
1554
+ let barButtonItem: UIBarButtonItem
1555
+ if item.type == .group, (item.presentation ?? .expand) == .menu {
1556
+ barButtonItem = UIBarButtonItem(
1557
+ title: title,
1558
+ image: image,
1559
+ primaryAction: nil,
1560
+ menu: makeGroupMenu(item: item)
1561
+ )
1562
+ } else {
1563
+ let action = UIAction { [weak self] _ in
1564
+ self?.handleToolbarButtonPress(item)
1565
+ }
1566
+ barButtonItem = UIBarButtonItem(title: title, image: image, primaryAction: action, menu: nil)
1567
+ }
1568
+
1569
+ barButtonItem.accessibilityLabel = item.label
1570
+ barButtonItem.isEnabled = enabled
1571
+ barButtonItem.isSelected = active
1572
+ barButtonItem.style = active ? .prominent : .plain
1573
+
1574
+ barButtonItem.sharesBackground = true
1575
+ barButtonItem.hidesSharedBackground = active
1576
+ return barButtonItem
1577
+ }
1578
+ #endif
1579
+
1580
+ private func makeButton(item: NativeToolbarItem) -> UIButton {
1581
+ let button = UIButton(type: .system)
1582
+ button.translatesAutoresizingMaskIntoConstraints = false
1583
+ button.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
1584
+ button.accessibilityLabel = item.label
1585
+ button.layer.cornerRadius = resolvedButtonBorderRadius
1586
+ button.clipsToBounds = true
1587
+ if #available(iOS 15.0, *) {
1588
+ var configuration = UIButton.Configuration.plain()
1589
+ configuration.contentInsets = NSDirectionalEdgeInsets(
1590
+ top: 8,
1591
+ leading: 10,
1592
+ bottom: 8,
1593
+ trailing: 10
1594
+ )
1595
+ button.configuration = configuration
1596
+ } else {
1597
+ button.contentEdgeInsets = UIEdgeInsets(top: 8, left: 10, bottom: 8, right: 10)
1598
+ }
1599
+ if let symbolName = item.icon?.resolvedSFSymbolName(),
1600
+ let symbolImage = UIImage(systemName: symbolName)
1601
+ {
1602
+ button.setImage(symbolImage, for: .normal)
1603
+ button.setTitle(nil, for: .normal)
1604
+ button.setPreferredSymbolConfiguration(
1605
+ UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold),
1606
+ forImageIn: .normal
1607
+ )
1608
+ } else {
1609
+ button.setImage(nil, for: .normal)
1610
+ button.setTitle(item.icon?.resolvedGlyphText() ?? "?", for: .normal)
1611
+ }
1612
+ let buttonSize = resolvedButtonSize
1613
+ let widthConstraint = button.widthAnchor.constraint(greaterThanOrEqualToConstant: buttonSize)
1614
+ let heightConstraint = button.heightAnchor.constraint(equalToConstant: buttonSize)
1615
+ widthConstraint.isActive = true
1616
+ heightConstraint.isActive = true
1617
+ if item.type == .group,
1618
+ (item.presentation ?? .expand) == .menu,
1619
+ #available(iOS 14.0, *)
1620
+ {
1621
+ button.menu = makeGroupMenu(item: item)
1622
+ button.showsMenuAsPrimaryAction = true
1623
+ } else {
1624
+ button.addAction(UIAction { [weak self] _ in
1625
+ self?.handleToolbarButtonPress(item)
1626
+ }, for: .touchUpInside)
1627
+ }
1628
+ updateButtonAppearance(button, item: item, enabled: true, active: false)
1629
+ buttonBindings.append(
1630
+ ButtonBinding(
1631
+ item: item,
1632
+ button: button,
1633
+ widthConstraint: widthConstraint,
1634
+ heightConstraint: heightConstraint
1635
+ )
1636
+ )
1637
+ return button
1638
+ }
1639
+
1640
+ private func makeSeparator() -> UIView {
1641
+ let separator = UIView()
1642
+ separator.translatesAutoresizingMaskIntoConstraints = false
1643
+ separator.backgroundColor = .separator
1644
+ separator.widthAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
1645
+ separator.heightAnchor.constraint(equalToConstant: 22).isActive = true
1646
+ separators.append(separator)
1647
+ return separator
1648
+ }
1649
+
1650
+ private func buttonState(
1651
+ for item: NativeToolbarItem,
1652
+ state: NativeToolbarState
1653
+ ) -> (enabled: Bool, active: Bool) {
1654
+ let isInList = state.nodes["bulletList"] == true || state.nodes["orderedList"] == true
1655
+
1656
+ switch item.type {
1657
+ case .mark:
1658
+ let mark = item.mark ?? ""
1659
+ return (
1660
+ enabled: state.allowedMarks.contains(mark),
1661
+ active: state.marks[mark] == true
1662
+ )
1663
+ case .heading:
1664
+ let level = item.headingLevel ?? 0
1665
+ let headingType = "h\(level)"
1666
+ return (
1667
+ enabled: state.commands["toggleHeading\(level)"] == true,
1668
+ active: state.nodes[headingType] == true
1669
+ )
1670
+ case .blockquote:
1671
+ return (
1672
+ enabled: state.commands["toggleBlockquote"] == true,
1673
+ active: state.nodes["blockquote"] == true
1674
+ )
1675
+ case .list:
1676
+ switch item.listType {
1677
+ case .bulletList:
1678
+ return (
1679
+ enabled: state.commands["wrapBulletList"] == true,
1680
+ active: state.nodes["bulletList"] == true
1681
+ )
1682
+ case .orderedList:
1683
+ return (
1684
+ enabled: state.commands["wrapOrderedList"] == true,
1685
+ active: state.nodes["orderedList"] == true
1686
+ )
1687
+ case .none:
1688
+ return (enabled: false, active: false)
1689
+ }
1690
+ case .command:
1691
+ switch item.command {
1692
+ case .indentList:
1693
+ return (
1694
+ enabled: isInList && state.commands["indentList"] == true,
1695
+ active: false
1696
+ )
1697
+ case .outdentList:
1698
+ return (
1699
+ enabled: isInList && state.commands["outdentList"] == true,
1700
+ active: false
1701
+ )
1702
+ case .undo:
1703
+ return (enabled: state.canUndo, active: false)
1704
+ case .redo:
1705
+ return (enabled: state.canRedo, active: false)
1706
+ case .none:
1707
+ return (enabled: false, active: false)
1708
+ }
1709
+ case .node:
1710
+ let nodeType = item.nodeType ?? ""
1711
+ return (
1712
+ enabled: state.insertableNodes.contains(nodeType),
1713
+ active: state.nodes[nodeType] == true
1714
+ )
1715
+ case .action:
1716
+ return (
1717
+ enabled: !item.isDisabled,
1718
+ active: item.isActive
1719
+ )
1720
+ case .group:
1721
+ let childStates = item.items.map { buttonState(for: $0, state: state) }
1722
+ return (
1723
+ enabled: childStates.contains { $0.enabled },
1724
+ active: childStates.contains { $0.active } ||
1725
+ (
1726
+ (item.presentation ?? .expand) == .expand &&
1727
+ expandedGroupKey == item.key
1728
+ )
1729
+ )
1730
+ case .separator:
1731
+ return (enabled: false, active: false)
1732
+ }
1733
+ }
1734
+
1735
+ private func updateButtonAppearance(
1736
+ _ button: UIButton,
1737
+ item: NativeToolbarItem,
1738
+ enabled: Bool,
1739
+ active: Bool
1740
+ ) {
1741
+ #if compiler(>=6.2)
1742
+ if #available(iOS 26.0, *), usesFloatingGlassButtons {
1743
+ var configuration = active
1744
+ ? UIButton.Configuration.prominentGlass()
1745
+ : UIButton.Configuration.glass()
1746
+ configuration.cornerStyle = .capsule
1747
+ configuration.contentInsets = NSDirectionalEdgeInsets(
1748
+ top: 8,
1749
+ leading: 10,
1750
+ bottom: 8,
1751
+ trailing: 10
1752
+ )
1753
+ configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(
1754
+ pointSize: 16,
1755
+ weight: .semibold
1756
+ )
1757
+ if let symbolName = item.icon?.resolvedSFSymbolName(),
1758
+ let symbolImage = UIImage(systemName: symbolName)
1759
+ {
1760
+ configuration.image = symbolImage
1761
+ configuration.title = nil
1762
+ } else {
1763
+ configuration.image = nil
1764
+ configuration.title = item.icon?.resolvedGlyphText() ?? "?"
1765
+ }
1766
+ button.configuration = configuration
1767
+ button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
1768
+ button.alpha = enabled ? 1 : 0.45
1769
+ return
1770
+ }
1771
+ #endif
1772
+
1773
+ if resolvedAppearance == .native {
1774
+ let tintColor = enabled ? theme?.buttonColor : resolvedNativeDisabledButtonTintColor()
1775
+ button.tintColor = tintColor
1776
+ button.setTitleColor(tintColor, for: .normal)
1777
+ button.setTitleColor(resolvedNativeDisabledButtonTintColor(), for: .disabled)
1778
+ button.tintAdjustmentMode = enabled ? .automatic : .normal
1779
+ button.alpha = 1
1780
+ button.backgroundColor = active
1781
+ ? UIColor.white.withAlphaComponent(0.18)
1782
+ : .clear
1783
+ return
1784
+ }
1785
+
1786
+ let tintColor: UIColor
1787
+ if !enabled {
1788
+ tintColor = theme?.buttonDisabledColor ?? .tertiaryLabel
1789
+ } else if active {
1790
+ tintColor = theme?.buttonActiveColor ?? .systemBlue
1791
+ } else {
1792
+ tintColor = theme?.buttonColor ?? .secondaryLabel
1793
+ }
1794
+
1795
+ button.tintColor = tintColor
1796
+ button.setTitleColor(tintColor, for: .normal)
1797
+ button.alpha = enabled ? 1 : 0.7
1798
+ button.backgroundColor = active
1799
+ ? (theme?.buttonActiveBackgroundColor ?? UIColor.systemBlue.withAlphaComponent(0.12))
1800
+ : .clear
1801
+ }
1802
+
1803
+ private var resolvedAppearance: EditorToolbarAppearance {
1804
+ theme?.appearance ?? .custom
1805
+ }
1806
+
1807
+ private var resolvedHorizontalInset: CGFloat {
1808
+ theme?.resolvedHorizontalInset ?? Self.defaultHorizontalInset
1809
+ }
1810
+
1811
+ private var resolvedKeyboardOffset: CGFloat {
1812
+ theme?.resolvedKeyboardOffset ?? Self.defaultKeyboardOffset
1813
+ }
1814
+
1815
+ private var resolvedBorderRadius: CGFloat {
1816
+ theme?.resolvedBorderRadius ?? 0
1817
+ }
1818
+
1819
+ private var resolvedBorderWidth: CGFloat {
1820
+ theme?.resolvedBorderWidth ?? 0.5
1821
+ }
1822
+
1823
+ private var resolvedToolbarHeight: CGFloat {
1824
+ max(theme?.height ?? Self.baseHeight, 1)
1825
+ }
1826
+
1827
+ private var resolvedButtonSize: CGFloat {
1828
+ if theme?.height == nil {
1829
+ return 36
1830
+ }
1831
+ return max(1, min(40, resolvedToolbarHeight - 4))
1832
+ }
1833
+
1834
+ private var resolvedButtonBorderRadius: CGFloat {
1835
+ theme?.resolvedButtonBorderRadius ?? 8
1836
+ }
1837
+
1838
+ private func resolvedNativeDisabledButtonTintColor() -> UIColor {
1839
+ theme?.buttonDisabledColor ?? resolvedNativeButtonTintColor.withAlphaComponent(Self.nativeDisabledButtonOpacity)
1840
+ }
1841
+
1842
+ private var resolvedNativeButtonTintColor: UIColor {
1843
+ theme?.buttonColor ?? tintColor ?? .label
1844
+ }
1845
+
1846
+ private var usesFloatingGlassButtons: Bool {
1847
+ return false
1848
+ }
1849
+
1850
+ private var usesTransparentMentionChrome: Bool {
1851
+ guard resolvedAppearance == .native, !mentionButtons.isEmpty else { return false }
1852
+ #if compiler(>=6.2)
1853
+ if #available(iOS 26.0, *) {
1854
+ return true
1855
+ }
1856
+ #endif
1857
+ return false
1858
+ }
1859
+
1860
+ private var usesNativeBarToolbar: Bool {
1861
+ return false
1862
+ }
1863
+
1864
+ private var activeNativeToolbarScrollViewForTesting: UIScrollView {
1865
+ usesNativeBarToolbar ? nativeToolbarScrollView : scrollView
1866
+ }
1867
+
1868
+ private func resolvedBlurEffect() -> UIVisualEffect {
1869
+ #if compiler(>=6.2)
1870
+ if #available(iOS 26.0, *) {
1871
+ let effect = UIGlassEffect(style: .regular)
1872
+ effect.isInteractive = true
1873
+ effect.tintColor = resolvedGlassEffectTintColor
1874
+ return effect
1875
+ }
1876
+ #endif
1877
+ if #available(iOS 13.0, *) {
1878
+ return UIBlurEffect(style: .systemUltraThinMaterial)
1879
+ }
1880
+ return UIBlurEffect(style: .extraLight)
1881
+ }
1882
+
1883
+ private var resolvedEffectAlpha: CGFloat {
1884
+ if #available(iOS 26.0, *), resolvedAppearance == .native {
1885
+ return 1
1886
+ }
1887
+ return resolvedAppearance == .native ? 0.72 : 1
1888
+ }
1889
+
1890
+ private var resolvedGlassTintAlpha: CGFloat {
1891
+ if #available(iOS 26.0, *), resolvedAppearance == .native {
1892
+ return 0
1893
+ }
1894
+ return resolvedAppearance == .native ? 0.12 : 0
1895
+ }
1896
+
1897
+ private var resolvedGlassEffectTintColor: UIColor {
1898
+ return .clear
1899
+ }
1900
+
1901
+ private var resolvedBorderColor: UIColor {
1902
+ if resolvedAppearance != .native {
1903
+ return theme?.borderColor ?? UIColor.separator
1904
+ }
1905
+ if #available(iOS 26.0, *) {
1906
+ return .clear
1907
+ }
1908
+ return UIColor.separator.withAlphaComponent(0.22)
1909
+ }
1910
+
1911
+ private func refreshNativeHostTransparencyIfNeeded() {
1912
+ guard usesFloatingGlassButtons else { return }
1913
+ backgroundColor = .clear
1914
+ isOpaque = false
1915
+
1916
+ var ancestor: UIView? = self
1917
+ while let view = ancestor {
1918
+ let className = NSStringFromClass(type(of: view))
1919
+ if view === self || className.contains("UIInput") || className.contains("Accessory") {
1920
+ view.backgroundColor = .clear
1921
+ view.isOpaque = false
1922
+ }
1923
+ ancestor = view.superview
1924
+ }
1925
+ }
1926
+
1927
+ @objc private func handleSelectMentionSuggestion(_ sender: MentionSuggestionChipButton) {
1928
+ onSelectMentionSuggestion?(sender.suggestion)
1929
+ }
1930
+
1931
+ func triggerMentionSuggestionTapForTesting(at index: Int) {
1932
+ guard mentionButtons.indices.contains(index) else { return }
1933
+ onSelectMentionSuggestion?(mentionButtons[index].suggestion)
1934
+ }
1935
+ }
1936
+
1937
+ /// Keeps iOS keyboard integrations on the inputAccessoryView path when the
1938
+ /// visible toolbar is rendered outside the native keyboard accessory.
1939
+ final class EditorAccessoryPlaceholderView: UIView {
1940
+ override init(frame: CGRect) {
1941
+ super.init(
1942
+ frame: CGRect(
1943
+ x: frame.origin.x,
1944
+ y: frame.origin.y,
1945
+ width: frame.width,
1946
+ height: 0
1947
+ )
1948
+ )
1949
+ commonInit()
1950
+ }
1951
+
1952
+ required init?(coder: NSCoder) {
1953
+ return nil
1954
+ }
1955
+
1956
+ override var intrinsicContentSize: CGSize {
1957
+ CGSize(width: UIView.noIntrinsicMetric, height: 0)
1958
+ }
1959
+
1960
+ override func sizeThatFits(_ size: CGSize) -> CGSize {
1961
+ CGSize(width: size.width, height: 0)
1962
+ }
1963
+
1964
+ override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
1965
+ false
1966
+ }
1967
+
1968
+ private func commonInit() {
1969
+ frame.size.height = 0
1970
+ backgroundColor = .clear
1971
+ isOpaque = false
1972
+ isUserInteractionEnabled = false
1973
+ autoresizingMask = [.flexibleWidth]
1974
+ }
1975
+ }
1976
+
1977
+ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognizerDelegate {
1978
+
1979
+ // MARK: - Subviews
1980
+
1981
+ let richTextView: RichTextEditorView
1982
+ private let accessoryToolbar = EditorAccessoryToolbarView(
1983
+ frame: .zero,
1984
+ inputViewStyle: .keyboard
1985
+ )
1986
+ private let accessoryPlaceholder = EditorAccessoryPlaceholderView(frame: .zero)
1987
+ private var toolbarFramesInWindow: [CGRect] = []
1988
+ private var lastToolbarTouchUptime: TimeInterval = -Double.infinity
1989
+ private var didApplyAutoFocus = false
1990
+ private var toolbarState = NativeToolbarState.empty
1991
+ private var toolbarItems: [NativeToolbarItem] = NativeToolbarItem.defaults
1992
+ private var showsToolbar = true
1993
+ private var toolbarPlacement = "keyboard"
1994
+ private var heightBehavior: EditorHeightBehavior = .fixed
1995
+ private var lastAutoGrowWidth: CGFloat = 0
1996
+ private var addons = NativeEditorAddons(mentions: nil)
1997
+ private var mentionQueryState: MentionQueryState?
1998
+ private var lastMentionEventJSON: String?
1999
+ private var desiredThemeJSON: String?
2000
+ private var lastThemeJSON: String?
2001
+ private var lastAddonsJSON: String?
2002
+ private var lastRemoteSelectionsJSON: String?
2003
+ private var lastToolbarItemsJSON: String?
2004
+ private var lastToolbarFrameJSON: String?
2005
+ private var pendingEditorUpdateJSON: String?
2006
+ private var pendingEditorUpdateRevision = 0
2007
+ private var appliedEditorUpdateRevision = 0
2008
+ private var pendingEditorUpdateRetryScheduled = false
2009
+ private var pendingEditorUpdateRetryEditorId: UInt64?
2010
+ private var pendingEditorUpdateRetryGeneration: UInt64 = 0
2011
+ private var pendingViewCommandUpdateJSON: String?
2012
+ private var pendingViewCommandUpdateEditorId: UInt64?
2013
+ private var pendingViewCommandUpdateRetryScheduled = false
2014
+ private var pendingViewCommandUpdateRetryGeneration: UInt64 = 0
2015
+ private var pendingEditableRetryValue: Bool?
2016
+ private var pendingEditableRetryEditorId: UInt64?
2017
+ private var pendingEditableRetryScheduled = false
2018
+ private var pendingEditableRetryGeneration: UInt64 = 0
2019
+ private var pendingThemeRetryJSON: String?
2020
+ private var pendingThemeRetryEditorId: UInt64?
2021
+ private var pendingThemeRetryScheduled = false
2022
+ private var pendingThemeRetryGeneration: UInt64 = 0
2023
+ private var pendingAccessoryRetryActions: [PendingAccessoryRetryAction] = []
2024
+ private var invalidatedAccessoryRetryActions = Set<PendingAccessoryRetryAction>()
2025
+ private var pendingAccessoryRetryEditorId: UInt64?
2026
+ private var pendingAccessoryRetryScheduled = false
2027
+ private var pendingAccessoryRetryGeneration: UInt64 = 0
2028
+ private var pendingMentionSuggestionRetry: PendingMentionSuggestionRetry?
2029
+ private var pendingMentionSuggestionRetryScheduled = false
2030
+ private var pendingMentionSuggestionRetryGeneration: UInt64 = 0
2031
+ private lazy var outsideTapGestureRecognizer: UITapGestureRecognizer = {
2032
+ let recognizer = UITapGestureRecognizer(
2033
+ target: self,
2034
+ action: #selector(handleOutsideTap(_:))
2035
+ )
2036
+ recognizer.cancelsTouchesInView = false
2037
+ recognizer.delegate = self
2038
+ return recognizer
2039
+ }()
2040
+ private weak var gestureWindow: UIWindow?
2041
+
2042
+ /// Guard flag to suppress echo: when JS applies an update via the view
2043
+ /// command, the resulting delegate callback must NOT be re-dispatched
2044
+ /// back to JS.
2045
+ var isApplyingJSUpdate = false
2046
+
2047
+ // MARK: - Event Dispatchers (wired by Expo Modules via reflection)
2048
+
2049
+ let onEditorUpdate = EventDispatcher()
2050
+ let onSelectionChange = EventDispatcher()
2051
+ let onFocusChange = EventDispatcher()
2052
+ let onContentHeightChange = EventDispatcher()
2053
+ let onToolbarAction = EventDispatcher()
2054
+ let onAddonEvent = EventDispatcher()
2055
+ private var lastEmittedContentHeight: CGFloat = 0
2056
+ private var cachedAutoGrowContentHeight: CGFloat = 0
2057
+ private var lastAddonEventJSONForTestingValue: String?
2058
+
2059
+ private enum PendingAccessoryRetryAction: Hashable {
2060
+ case reloadInputViews
2061
+ case refreshMentionQuery
2062
+ case clearMentionQueryState
2063
+ case updateAccessoryToolbarVisibility
2064
+ }
2065
+
2066
+ private struct PendingMentionSuggestionRetry {
2067
+ let suggestionKey: String
2068
+ let editorId: UInt64
2069
+ let trigger: String
2070
+ let query: String
2071
+ let anchor: UInt32
2072
+ let head: UInt32
2073
+ let documentVersion: Int?
2074
+ let textSnapshot: String
2075
+ }
2076
+
2077
+ private struct MentionRetryTextDiff {
2078
+ let start: Int
2079
+ let oldEnd: Int
2080
+ let newEnd: Int
2081
+ }
2082
+
2083
+ // MARK: - Initialization
2084
+
2085
+ required init(appContext: AppContext? = nil) {
2086
+ richTextView = RichTextEditorView(frame: .zero)
2087
+ super.init(appContext: appContext)
2088
+ richTextView.onHeightMayChange = { [weak self] measuredHeight in
2089
+ guard let self, self.heightBehavior == .autoGrow else { return }
2090
+ self.cachedAutoGrowContentHeight = measuredHeight
2091
+ self.invalidateIntrinsicContentSize()
2092
+ self.emitContentHeightIfNeeded(force: true, measuredHeight: measuredHeight)
2093
+ }
2094
+ richTextView.textView.editorDelegate = self
2095
+ configureAccessoryToolbar()
2096
+
2097
+ // Observe UITextView focus changes via NotificationCenter.
2098
+ NotificationCenter.default.addObserver(
2099
+ self,
2100
+ selector: #selector(textViewDidBeginEditing(_:)),
2101
+ name: UITextView.textDidBeginEditingNotification,
2102
+ object: richTextView.textView
2103
+ )
2104
+ NotificationCenter.default.addObserver(
2105
+ self,
2106
+ selector: #selector(textViewDidEndEditing(_:)),
2107
+ name: UITextView.textDidEndEditingNotification,
2108
+ object: richTextView.textView
2109
+ )
2110
+
2111
+ addSubview(richTextView)
2112
+ }
2113
+
2114
+ deinit {
2115
+ NativeEditorViewRegistry.shared.unregister(editorId: richTextView.editorId, view: self)
2116
+ NotificationCenter.default.removeObserver(self)
2117
+ }
2118
+
2119
+ // MARK: - Layout
2120
+
2121
+ override var intrinsicContentSize: CGSize {
2122
+ guard heightBehavior == .autoGrow else {
2123
+ return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
2124
+ }
2125
+ if cachedAutoGrowContentHeight > 0 {
2126
+ return CGSize(width: UIView.noIntrinsicMetric, height: cachedAutoGrowContentHeight)
2127
+ }
2128
+ return richTextView.intrinsicContentSize
2129
+ }
2130
+
2131
+ override func layoutSubviews() {
2132
+ super.layoutSubviews()
2133
+ richTextView.frame = bounds
2134
+ guard heightBehavior == .autoGrow else { return }
2135
+ let currentWidth = bounds.width.rounded(.towardZero)
2136
+ guard currentWidth != lastAutoGrowWidth else { return }
2137
+ lastAutoGrowWidth = currentWidth
2138
+ cachedAutoGrowContentHeight = 0
2139
+ invalidateIntrinsicContentSize()
2140
+ emitContentHeightIfNeeded(force: true)
2141
+ }
2142
+
2143
+ override func didMoveToWindow() {
2144
+ super.didMoveToWindow()
2145
+ if richTextView.textView.isFirstResponder {
2146
+ installOutsideTapRecognizerIfNeeded()
2147
+ } else {
2148
+ uninstallOutsideTapRecognizer()
2149
+ }
2150
+ }
2151
+
2152
+ // MARK: - Editor Binding
2153
+
2154
+ func handleEditorDestroyed(_ editorId: UInt64) {
2155
+ guard editorId != 0 else { return }
2156
+ guard richTextView.editorId == editorId || richTextView.textView.editorId == editorId else {
2157
+ NativeEditorViewRegistry.shared.unregister(editorId: editorId, view: self)
2158
+ return
2159
+ }
2160
+
2161
+ NativeEditorViewRegistry.shared.unregister(editorId: editorId, view: self)
2162
+ clearPendingEditorUpdateRetries()
2163
+ clearPendingViewCommandUpdateRetry()
2164
+ clearPendingEditableRetry()
2165
+ clearPendingThemeRetry()
2166
+ clearPendingAccessoryRetry()
2167
+ clearPendingMentionSuggestionRetry()
2168
+ lastMentionEventJSON = nil
2169
+ _ = richTextView.textView.resignFirstResponder()
2170
+ richTextView.editorId = 0
2171
+ mentionQueryState = nil
2172
+ _ = accessoryToolbar.setMentionSuggestions([])
2173
+ toolbarState = .empty
2174
+ accessoryToolbar.apply(state: .empty)
2175
+ uninstallOutsideTapRecognizer()
2176
+ refreshSystemAssistantToolbarIfNeeded()
2177
+ }
2178
+
2179
+ func setEditorId(_ id: UInt64) {
2180
+ let previousEditorId = richTextView.editorId
2181
+ if id != 0 && NativeEditorViewRegistry.shared.isDestroyed(editorId: id) {
2182
+ if previousEditorId == id {
2183
+ handleEditorDestroyed(id)
2184
+ } else {
2185
+ setEditorId(0)
2186
+ }
2187
+ return
2188
+ }
2189
+ guard previousEditorId != id else {
2190
+ if id != 0 {
2191
+ if !NativeEditorViewRegistry.shared.register(editorId: id, view: self) {
2192
+ handleEditorDestroyed(id)
2193
+ }
2194
+ }
2195
+ return
2196
+ }
2197
+ if previousEditorId != id {
2198
+ NativeEditorViewRegistry.shared.unregister(editorId: previousEditorId, view: self)
2199
+ clearPendingEditorUpdateRetries()
2200
+ clearPendingViewCommandUpdateRetry()
2201
+ clearPendingEditableRetry()
2202
+ clearPendingThemeRetry()
2203
+ clearPendingAccessoryRetry()
2204
+ clearPendingMentionSuggestionRetry()
2205
+ }
2206
+ richTextView.editorId = id
2207
+ if id != 0 {
2208
+ guard NativeEditorViewRegistry.shared.register(editorId: id, view: self) else {
2209
+ handleEditorDestroyed(id)
2210
+ return
2211
+ }
2212
+ }
2213
+ if id != 0 {
2214
+ let stateJSON = editorGetCurrentState(id: id)
2215
+ if let state = NativeToolbarState(updateJSON: stateJSON) {
2216
+ toolbarState = state
2217
+ accessoryToolbar.apply(state: state)
2218
+ } else {
2219
+ toolbarState = .empty
2220
+ accessoryToolbar.apply(state: .empty)
2221
+ }
2222
+ } else {
2223
+ toolbarState = .empty
2224
+ accessoryToolbar.apply(state: .empty)
2225
+ }
2226
+ if desiredThemeJSON != lastThemeJSON {
2227
+ setThemeJson(desiredThemeJSON)
2228
+ }
2229
+ refreshSystemAssistantToolbarIfNeeded()
2230
+ refreshMentionQuery()
2231
+ }
2232
+
2233
+ func setThemeJson(_ themeJson: String?) {
2234
+ desiredThemeJSON = themeJson
2235
+ guard lastThemeJSON != themeJson else {
2236
+ clearPendingThemeRetry()
2237
+ return
2238
+ }
2239
+ let theme = EditorTheme.from(json: themeJson)
2240
+ guard richTextView.applyTheme(theme) else {
2241
+ scheduleThemeRetry(themeJson)
2242
+ return
2243
+ }
2244
+ lastThemeJSON = themeJson
2245
+ clearPendingThemeRetry()
2246
+ accessoryToolbar.apply(theme: theme?.toolbar)
2247
+ accessoryToolbar.apply(mentionTheme: theme?.mentions ?? addons.mentions?.theme)
2248
+ refreshSystemAssistantToolbarIfNeeded()
2249
+ if richTextView.textView.isFirstResponder,
2250
+ (richTextView.textView.inputAccessoryView === accessoryToolbar || shouldUseSystemAssistantToolbar)
2251
+ {
2252
+ reloadInputViewsAfterPreparingOrRetry()
2253
+ }
2254
+ }
2255
+
2256
+ private func clearPendingEditorUpdateRetries() {
2257
+ pendingEditorUpdateJSON = nil
2258
+ pendingEditorUpdateRevision = 0
2259
+ appliedEditorUpdateRevision = 0
2260
+ pendingEditorUpdateRetryScheduled = false
2261
+ pendingEditorUpdateRetryEditorId = nil
2262
+ pendingEditorUpdateRetryGeneration &+= 1
2263
+ }
2264
+
2265
+ private func clearPendingViewCommandUpdateRetry() {
2266
+ pendingViewCommandUpdateJSON = nil
2267
+ pendingViewCommandUpdateEditorId = nil
2268
+ pendingViewCommandUpdateRetryScheduled = false
2269
+ pendingViewCommandUpdateRetryGeneration &+= 1
2270
+ }
2271
+
2272
+ private func clearPendingEditableRetry() {
2273
+ pendingEditableRetryValue = nil
2274
+ pendingEditableRetryEditorId = nil
2275
+ pendingEditableRetryScheduled = false
2276
+ pendingEditableRetryGeneration &+= 1
2277
+ }
2278
+
2279
+ private func clearPendingThemeRetry() {
2280
+ pendingThemeRetryJSON = nil
2281
+ pendingThemeRetryEditorId = nil
2282
+ pendingThemeRetryScheduled = false
2283
+ pendingThemeRetryGeneration &+= 1
2284
+ }
2285
+
2286
+ private func clearPendingAccessoryRetry() {
2287
+ pendingAccessoryRetryActions = []
2288
+ invalidatedAccessoryRetryActions.removeAll()
2289
+ pendingAccessoryRetryEditorId = nil
2290
+ pendingAccessoryRetryScheduled = false
2291
+ pendingAccessoryRetryGeneration &+= 1
2292
+ }
2293
+
2294
+ private func clearPendingMentionSuggestionRetry() {
2295
+ pendingMentionSuggestionRetry = nil
2296
+ pendingMentionSuggestionRetryScheduled = false
2297
+ pendingMentionSuggestionRetryGeneration &+= 1
2298
+ }
2299
+
2300
+ private func scheduleThemeRetry(_ themeJson: String?) {
2301
+ pendingThemeRetryJSON = themeJson
2302
+ pendingThemeRetryEditorId = richTextView.editorId
2303
+ guard !pendingThemeRetryScheduled else { return }
2304
+ pendingThemeRetryScheduled = true
2305
+ pendingThemeRetryGeneration &+= 1
2306
+ let retryGeneration = pendingThemeRetryGeneration
2307
+ DispatchQueue.main.async { [weak self] in
2308
+ guard let self else { return }
2309
+ guard retryGeneration == self.pendingThemeRetryGeneration else { return }
2310
+ let retryJSON = self.pendingThemeRetryJSON
2311
+ self.pendingThemeRetryJSON = nil
2312
+ let retryEditorId = self.pendingThemeRetryEditorId
2313
+ self.pendingThemeRetryEditorId = nil
2314
+ self.pendingThemeRetryScheduled = false
2315
+ guard retryEditorId == self.richTextView.editorId else {
2316
+ self.clearPendingThemeRetry()
2317
+ return
2318
+ }
2319
+ guard retryJSON == self.desiredThemeJSON else {
2320
+ self.clearPendingThemeRetry()
2321
+ return
2322
+ }
2323
+ self.setThemeJson(retryJSON)
2324
+ }
2325
+ }
2326
+
2327
+ private func prepareForInputAccessoryMutationOrRetry(_ action: PendingAccessoryRetryAction) -> Bool {
2328
+ guard richTextView.editorId != 0, richTextView.textView.isFirstResponder else {
2329
+ return true
2330
+ }
2331
+ guard richTextView.textView.prepareForExternalEditorUpdate() else {
2332
+ scheduleAccessoryRetry(action)
2333
+ return false
2334
+ }
2335
+ return true
2336
+ }
2337
+
2338
+ private func reloadInputViewsAfterPreparingOrRetry() {
2339
+ guard prepareForInputAccessoryMutationOrRetry(.reloadInputViews) else { return }
2340
+ richTextView.textView.reloadInputViews()
2341
+ markAccessoryMutationSucceeded(.reloadInputViews)
2342
+ }
2343
+
2344
+ private func scheduleAccessoryRetry(_ action: PendingAccessoryRetryAction) {
2345
+ invalidatedAccessoryRetryActions.remove(action)
2346
+ pendingAccessoryRetryActions.removeAll { $0 == action }
2347
+ pendingAccessoryRetryActions.append(action)
2348
+ pendingAccessoryRetryEditorId = richTextView.editorId
2349
+ guard !pendingAccessoryRetryScheduled else { return }
2350
+ pendingAccessoryRetryScheduled = true
2351
+ pendingAccessoryRetryGeneration &+= 1
2352
+ let retryGeneration = pendingAccessoryRetryGeneration
2353
+ DispatchQueue.main.async { [weak self] in
2354
+ guard let self else { return }
2355
+ guard retryGeneration == self.pendingAccessoryRetryGeneration else { return }
2356
+ guard self.pendingAccessoryRetryEditorId == self.richTextView.editorId else {
2357
+ self.clearPendingAccessoryRetry()
2358
+ return
2359
+ }
2360
+ let actions = self.pendingAccessoryRetryActions
2361
+ self.pendingAccessoryRetryActions = []
2362
+ self.pendingAccessoryRetryEditorId = nil
2363
+ self.pendingAccessoryRetryScheduled = false
2364
+ for index in actions.indices {
2365
+ let action = actions[index]
2366
+ guard retryGeneration == self.pendingAccessoryRetryGeneration else { return }
2367
+ guard !self.invalidatedAccessoryRetryActions.contains(action) else {
2368
+ self.invalidatedAccessoryRetryActions.remove(action)
2369
+ continue
2370
+ }
2371
+ let generationBeforeAction = self.pendingAccessoryRetryGeneration
2372
+ self.performAccessoryRetryAction(action)
2373
+ guard self.pendingAccessoryRetryGeneration == generationBeforeAction else {
2374
+ let remainingIndex = actions.index(after: index)
2375
+ if remainingIndex < actions.endIndex {
2376
+ self.requeueUnprocessedAccessoryRetryActions(actions[remainingIndex...])
2377
+ }
2378
+ return
2379
+ }
2380
+ }
2381
+ self.invalidatedAccessoryRetryActions.subtract(actions)
2382
+ }
2383
+ }
2384
+
2385
+ private func requeueUnprocessedAccessoryRetryActions(
2386
+ _ actions: ArraySlice<PendingAccessoryRetryAction>
2387
+ ) {
2388
+ for action in actions {
2389
+ guard !invalidatedAccessoryRetryActions.contains(action) else {
2390
+ invalidatedAccessoryRetryActions.remove(action)
2391
+ continue
2392
+ }
2393
+ pendingAccessoryRetryActions.removeAll { $0 == action }
2394
+ pendingAccessoryRetryActions.append(action)
2395
+ }
2396
+ if !pendingAccessoryRetryActions.isEmpty {
2397
+ pendingAccessoryRetryEditorId = richTextView.editorId
2398
+ }
2399
+ }
2400
+
2401
+ private func performAccessoryRetryAction(_ action: PendingAccessoryRetryAction) {
2402
+ switch action {
2403
+ case .reloadInputViews:
2404
+ reloadInputViewsAfterPreparingOrRetry()
2405
+ case .refreshMentionQuery:
2406
+ refreshMentionQuery()
2407
+ case .clearMentionQueryState:
2408
+ clearMentionQueryStateAndHidePopover()
2409
+ case .updateAccessoryToolbarVisibility:
2410
+ updateAccessoryToolbarVisibility()
2411
+ }
2412
+ }
2413
+
2414
+ private func markAccessoryMutationSucceeded(_ action: PendingAccessoryRetryAction) {
2415
+ var invalidated: Set<PendingAccessoryRetryAction> = [action]
2416
+ switch action {
2417
+ case .refreshMentionQuery:
2418
+ invalidated.insert(.clearMentionQueryState)
2419
+ case .clearMentionQueryState:
2420
+ if !hasActiveMentionQueryForCurrentAddons() {
2421
+ invalidated.insert(.refreshMentionQuery)
2422
+ }
2423
+ case .reloadInputViews, .updateAccessoryToolbarVisibility:
2424
+ break
2425
+ }
2426
+ invalidatePendingAccessoryRetries(invalidated)
2427
+ }
2428
+
2429
+ private func invalidatePendingAccessoryRetries(_ actions: Set<PendingAccessoryRetryAction>) {
2430
+ guard !actions.isEmpty else { return }
2431
+ invalidatedAccessoryRetryActions.formUnion(actions)
2432
+ pendingAccessoryRetryActions.removeAll { actions.contains($0) }
2433
+ }
2434
+
2435
+ private func hasActiveMentionQueryForCurrentAddons() -> Bool {
2436
+ guard richTextView.editorId != 0,
2437
+ richTextView.textView.isFirstResponder,
2438
+ let mentions = addons.mentions
2439
+ else {
2440
+ return false
2441
+ }
2442
+ return currentMentionQueryState(trigger: mentions.trigger) != nil
2443
+ }
2444
+
2445
+ func setAddonsJson(_ addonsJson: String?) {
2446
+ guard lastAddonsJSON != addonsJson else { return }
2447
+ lastAddonsJSON = addonsJson
2448
+ addons = NativeEditorAddons.from(json: addonsJson)
2449
+ accessoryToolbar.apply(mentionTheme: richTextView.textView.theme?.mentions ?? addons.mentions?.theme)
2450
+ refreshMentionQuery()
2451
+ }
2452
+
2453
+ func setRemoteSelectionsJson(_ remoteSelectionsJson: String?) {
2454
+ guard lastRemoteSelectionsJSON != remoteSelectionsJson else { return }
2455
+ lastRemoteSelectionsJSON = remoteSelectionsJson
2456
+ richTextView.setRemoteSelections(RemoteSelectionDecoration.from(json: remoteSelectionsJson))
2457
+ }
2458
+
2459
+ func setEditable(_ editable: Bool) {
2460
+ if !editable,
2461
+ richTextView.textView.isEditable,
2462
+ richTextView.editorId != 0,
2463
+ !richTextView.textView.prepareForExternalEditorUpdate()
2464
+ {
2465
+ scheduleEditableRetry(editable)
2466
+ return
2467
+ }
2468
+ pendingEditableRetryValue = nil
2469
+ pendingEditableRetryEditorId = nil
2470
+ pendingEditableRetryScheduled = false
2471
+ richTextView.textView.isEditable = editable
2472
+ updateAccessoryToolbarVisibility()
2473
+ }
2474
+
2475
+ private func scheduleEditableRetry(_ editable: Bool) {
2476
+ pendingEditableRetryValue = editable
2477
+ pendingEditableRetryEditorId = richTextView.editorId
2478
+ guard !pendingEditableRetryScheduled else { return }
2479
+ pendingEditableRetryScheduled = true
2480
+ pendingEditableRetryGeneration &+= 1
2481
+ let retryGeneration = pendingEditableRetryGeneration
2482
+ DispatchQueue.main.async { [weak self] in
2483
+ guard let self else { return }
2484
+ guard retryGeneration == self.pendingEditableRetryGeneration else { return }
2485
+ guard let pendingEditable = self.pendingEditableRetryValue else {
2486
+ self.pendingEditableRetryScheduled = false
2487
+ return
2488
+ }
2489
+ guard self.pendingEditableRetryEditorId == self.richTextView.editorId else {
2490
+ self.clearPendingEditableRetry()
2491
+ return
2492
+ }
2493
+ self.pendingEditableRetryValue = nil
2494
+ self.pendingEditableRetryEditorId = nil
2495
+ self.pendingEditableRetryScheduled = false
2496
+ self.setEditable(pendingEditable)
2497
+ }
2498
+ }
2499
+
2500
+ func setAutoFocus(_ autoFocus: Bool) {
2501
+ guard autoFocus, !didApplyAutoFocus else { return }
2502
+ didApplyAutoFocus = true
2503
+ focus()
2504
+ }
2505
+
2506
+ func setAutoCapitalize(_ autoCapitalize: String?) {
2507
+ richTextView.textView.setAutoCapitalize(autoCapitalize)
2508
+ }
2509
+
2510
+ func setAutoCorrect(_ autoCorrect: Bool?) {
2511
+ richTextView.textView.setAutoCorrect(autoCorrect)
2512
+ }
2513
+
2514
+ func setKeyboardType(_ keyboardType: String?) {
2515
+ richTextView.textView.setKeyboardType(keyboardType)
2516
+ }
2517
+
2518
+ func setKeyboardAppearance(_ keyboardAppearance: String?) {
2519
+ richTextView.textView.setKeyboardAppearance(keyboardAppearance)
2520
+ }
2521
+
2522
+ func setShowToolbar(_ showToolbar: Bool) {
2523
+ showsToolbar = showToolbar
2524
+ updateAccessoryToolbarVisibility()
2525
+ }
2526
+
2527
+ func setToolbarPlacement(_ toolbarPlacement: String?) {
2528
+ self.toolbarPlacement = toolbarPlacement == "inline" ? "inline" : "keyboard"
2529
+ updateAccessoryToolbarVisibility()
2530
+ }
2531
+
2532
+ func setHeightBehavior(_ rawHeightBehavior: String) {
2533
+ let nextBehavior = EditorHeightBehavior(rawValue: rawHeightBehavior) ?? .fixed
2534
+ guard nextBehavior != heightBehavior else { return }
2535
+ heightBehavior = nextBehavior
2536
+ if nextBehavior != .autoGrow {
2537
+ cachedAutoGrowContentHeight = 0
2538
+ }
2539
+ richTextView.heightBehavior = nextBehavior
2540
+ invalidateIntrinsicContentSize()
2541
+ setNeedsLayout()
2542
+ if nextBehavior == .autoGrow {
2543
+ emitContentHeightIfNeeded(force: true)
2544
+ }
2545
+ }
2546
+
2547
+ func setAllowImageResizing(_ allowImageResizing: Bool) {
2548
+ richTextView.allowImageResizing = allowImageResizing
2549
+ }
2550
+
2551
+ private func emitContentHeightIfNeeded(force: Bool = false, measuredHeight: CGFloat? = nil) {
2552
+ guard heightBehavior == .autoGrow else { return }
2553
+ let resolvedHeight = measuredHeight
2554
+ ?? (cachedAutoGrowContentHeight > 0 ? cachedAutoGrowContentHeight : richTextView.intrinsicContentSize.height)
2555
+ let contentHeight = ceil(resolvedHeight)
2556
+ guard contentHeight > 0 else { return }
2557
+ guard force || abs(contentHeight - lastEmittedContentHeight) > 0.5 else { return }
2558
+ cachedAutoGrowContentHeight = contentHeight
2559
+ lastEmittedContentHeight = contentHeight
2560
+ onContentHeightChange(["contentHeight": contentHeight])
2561
+ }
2562
+
2563
+ func setToolbarButtonsJson(_ toolbarButtonsJson: String?) {
2564
+ guard lastToolbarItemsJSON != toolbarButtonsJson else { return }
2565
+ lastToolbarItemsJSON = toolbarButtonsJson
2566
+ toolbarItems = NativeToolbarItem.from(json: toolbarButtonsJson)
2567
+ accessoryToolbar.setItems(toolbarItems)
2568
+ refreshSystemAssistantToolbarIfNeeded()
2569
+ }
2570
+
2571
+ func setToolbarFrameJson(_ toolbarFrameJson: String?) {
2572
+ guard lastToolbarFrameJSON != toolbarFrameJson else { return }
2573
+ lastToolbarFrameJSON = toolbarFrameJson
2574
+ guard let toolbarFrameJson,
2575
+ let data = toolbarFrameJson.data(using: .utf8),
2576
+ let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
2577
+ else {
2578
+ toolbarFramesInWindow = []
2579
+ return
2580
+ }
2581
+
2582
+ if let frameDictionaries = raw["frames"] as? [[String: Any]] {
2583
+ toolbarFramesInWindow = frameDictionaries.compactMap(Self.toolbarFrame(from:))
2584
+ return
2585
+ }
2586
+
2587
+ toolbarFramesInWindow = Self.toolbarFrame(from: raw).map { [$0] } ?? []
2588
+ }
2589
+
2590
+ private static func toolbarFrame(from raw: [String: Any]) -> CGRect? {
2591
+ guard let x = (raw["x"] as? NSNumber)?.doubleValue,
2592
+ let y = (raw["y"] as? NSNumber)?.doubleValue,
2593
+ let width = (raw["width"] as? NSNumber)?.doubleValue,
2594
+ let height = (raw["height"] as? NSNumber)?.doubleValue,
2595
+ width > 0,
2596
+ height > 0
2597
+ else {
2598
+ return nil
2599
+ }
2600
+
2601
+ return CGRect(x: x, y: y, width: width, height: height)
2602
+ }
2603
+
2604
+ func setPendingEditorUpdateJson(_ editorUpdateJson: String?) {
2605
+ pendingEditorUpdateJSON = editorUpdateJson
2606
+ }
2607
+
2608
+ func setPendingEditorUpdateRevision(_ editorUpdateRevision: Int) {
2609
+ pendingEditorUpdateRevision = editorUpdateRevision
2610
+ }
2611
+
2612
+ func applyPendingEditorUpdateIfNeeded() {
2613
+ guard pendingEditorUpdateRevision != 0 else { return }
2614
+ guard pendingEditorUpdateRevision != appliedEditorUpdateRevision else { return }
2615
+ guard let updateJSON = pendingEditorUpdateJSON else { return }
2616
+ guard applyEditorUpdate(updateJSON) else {
2617
+ schedulePendingEditorUpdateRetry()
2618
+ return
2619
+ }
2620
+ appliedEditorUpdateRevision = pendingEditorUpdateRevision
2621
+ }
2622
+
2623
+ private func schedulePendingEditorUpdateRetry() {
2624
+ guard !pendingEditorUpdateRetryScheduled else { return }
2625
+ pendingEditorUpdateRetryEditorId = richTextView.editorId
2626
+ pendingEditorUpdateRetryScheduled = true
2627
+ pendingEditorUpdateRetryGeneration &+= 1
2628
+ let retryGeneration = pendingEditorUpdateRetryGeneration
2629
+ DispatchQueue.main.async { [weak self] in
2630
+ guard let self else { return }
2631
+ guard retryGeneration == self.pendingEditorUpdateRetryGeneration else {
2632
+ return
2633
+ }
2634
+ guard self.pendingEditorUpdateRetryEditorId == self.richTextView.editorId else {
2635
+ self.pendingEditorUpdateRetryScheduled = false
2636
+ self.clearPendingEditorUpdateRetries()
2637
+ return
2638
+ }
2639
+ self.pendingEditorUpdateRetryScheduled = false
2640
+ self.pendingEditorUpdateRetryEditorId = nil
2641
+ self.applyPendingEditorUpdateIfNeeded()
2642
+ }
2643
+ }
2644
+
2645
+ // MARK: - View Commands
2646
+
2647
+ /// Apply an editor update from JS. Sets the echo-suppression flag so the
2648
+ /// resulting delegate callback is NOT re-dispatched back to JS.
2649
+ @discardableResult
2650
+ func applyEditorUpdate(_ updateJson: String) -> Bool {
2651
+ guard richTextView.textView.prepareForExternalEditorUpdate() else {
2652
+ scheduleViewCommandUpdateRetry(updateJson)
2653
+ return false
2654
+ }
2655
+ isApplyingJSUpdate = true
2656
+ defer { isApplyingJSUpdate = false }
2657
+ richTextView.textView.applyUpdateJSON(updateJson)
2658
+ return true
2659
+ }
2660
+
2661
+ private func scheduleViewCommandUpdateRetry(_ updateJson: String) {
2662
+ pendingViewCommandUpdateJSON = updateJson
2663
+ pendingViewCommandUpdateEditorId = richTextView.editorId
2664
+ guard !pendingViewCommandUpdateRetryScheduled else { return }
2665
+ pendingViewCommandUpdateRetryScheduled = true
2666
+ pendingViewCommandUpdateRetryGeneration &+= 1
2667
+ let retryGeneration = pendingViewCommandUpdateRetryGeneration
2668
+ DispatchQueue.main.async { [weak self] in
2669
+ guard let self else { return }
2670
+ guard retryGeneration == self.pendingViewCommandUpdateRetryGeneration else {
2671
+ return
2672
+ }
2673
+ guard self.pendingViewCommandUpdateJSON != nil else {
2674
+ self.pendingViewCommandUpdateRetryScheduled = false
2675
+ return
2676
+ }
2677
+ guard self.pendingViewCommandUpdateEditorId == self.richTextView.editorId else {
2678
+ self.pendingViewCommandUpdateJSON = nil
2679
+ self.pendingViewCommandUpdateEditorId = nil
2680
+ self.pendingViewCommandUpdateRetryScheduled = false
2681
+ return
2682
+ }
2683
+ guard self.richTextView.editorId != 0 else {
2684
+ self.pendingViewCommandUpdateJSON = nil
2685
+ self.pendingViewCommandUpdateEditorId = nil
2686
+ self.pendingViewCommandUpdateRetryScheduled = false
2687
+ return
2688
+ }
2689
+ self.pendingViewCommandUpdateJSON = nil
2690
+ self.pendingViewCommandUpdateEditorId = nil
2691
+ self.pendingViewCommandUpdateRetryScheduled = false
2692
+ let updateJSON = editorGetCurrentState(id: self.richTextView.editorId)
2693
+ _ = self.applyEditorUpdate(updateJSON)
2694
+ }
2695
+ }
2696
+
2697
+ func prepareForEditorCommandJSON() -> String {
2698
+ isApplyingJSUpdate = true
2699
+ defer { isApplyingJSUpdate = false }
2700
+ let preparation = richTextView.textView.prepareForExternalEditorCommand()
2701
+ return NativeEditorViewRegistry.commandPreparationJSON(
2702
+ ready: preparation.ready,
2703
+ updateJSON: preparation.updateJSON,
2704
+ blockedReason: preparation.blockedReason
2705
+ )
2706
+ }
2707
+
2708
+ // MARK: - Focus Commands
2709
+
2710
+ func focus() {
2711
+ _ = richTextView.textView.becomeFirstResponder()
2712
+ }
2713
+
2714
+ func blur() {
2715
+ clearRecentToolbarTouch()
2716
+ _ = richTextView.textView.resignFirstResponder()
2717
+ }
2718
+
2719
+ func getCaretRectJson() -> String? {
2720
+ layoutIfNeeded()
2721
+ richTextView.layoutIfNeeded()
2722
+
2723
+ guard let caretRect = richTextView.currentCaretRect() else {
2724
+ return nil
2725
+ }
2726
+ let editorRect = richTextView.convert(caretRect, to: self)
2727
+ let payload: [String: Any] = [
2728
+ "x": editorRect.minX,
2729
+ "y": editorRect.minY,
2730
+ "width": editorRect.width,
2731
+ "height": editorRect.height,
2732
+ "editorWidth": bounds.width,
2733
+ "editorHeight": bounds.height,
2734
+ ]
2735
+ guard let data = try? JSONSerialization.data(withJSONObject: payload),
2736
+ let json = String(data: data, encoding: .utf8)
2737
+ else {
2738
+ return nil
2739
+ }
2740
+ return json
2741
+ }
2742
+
2743
+ // MARK: - Focus Notifications
2744
+
2745
+ @objc private func textViewDidBeginEditing(_ notification: Notification) {
2746
+ installOutsideTapRecognizerIfNeeded()
2747
+ richTextView.textView.refreshSelectionVisualState()
2748
+ refreshMentionQuery()
2749
+ onFocusChange(["isFocused": true])
2750
+ }
2751
+
2752
+ @objc private func textViewDidEndEditing(_ notification: Notification) {
2753
+ if consumeToolbarFocusPreservationForBlur() {
2754
+ DispatchQueue.main.async { [weak self] in
2755
+ _ = self?.richTextView.textView.becomeFirstResponder()
2756
+ }
2757
+ return
2758
+ }
2759
+
2760
+ uninstallOutsideTapRecognizer()
2761
+ richTextView.textView.refreshSelectionVisualState()
2762
+ clearMentionQueryStateAndHidePopover()
2763
+ onFocusChange(["isFocused": false])
2764
+ }
2765
+
2766
+ @objc private func handleOutsideTap(_ recognizer: UITapGestureRecognizer) {
2767
+ guard recognizer.state == .ended else { return }
2768
+ guard richTextView.textView.isFirstResponder else { return }
2769
+ guard let tapWindow = gestureWindow ?? window else { return }
2770
+ let locationInWindow = recognizer.location(in: tapWindow)
2771
+ guard shouldHandleOutsideTap(locationInWindow: locationInWindow, touchedView: nil) else {
2772
+ return
2773
+ }
2774
+ clearRecentToolbarTouch()
2775
+ blur()
2776
+ }
2777
+
2778
+ private func installOutsideTapRecognizerIfNeeded() {
2779
+ guard let window else { return }
2780
+ if gestureWindow === window, window.gestureRecognizers?.contains(outsideTapGestureRecognizer) == true {
2781
+ return
2782
+ }
2783
+ uninstallOutsideTapRecognizer()
2784
+ window.addGestureRecognizer(outsideTapGestureRecognizer)
2785
+ gestureWindow = window
2786
+ }
2787
+
2788
+ private func uninstallOutsideTapRecognizer() {
2789
+ if let window = gestureWindow {
2790
+ window.removeGestureRecognizer(outsideTapGestureRecognizer)
2791
+ }
2792
+ gestureWindow = nil
2793
+ }
2794
+
2795
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
2796
+ guard gestureRecognizer === outsideTapGestureRecognizer else { return true }
2797
+ guard let tapWindow = gestureWindow ?? window else { return true }
2798
+ let locationInWindow = touch.location(in: tapWindow)
2799
+ return prepareOutsideTapForFocusHandling(
2800
+ locationInWindow: locationInWindow,
2801
+ touchedView: touch.view
2802
+ )
2803
+ }
2804
+
2805
+ private func prepareOutsideTapForFocusHandling(
2806
+ locationInWindow: CGPoint,
2807
+ touchedView: UIView?
2808
+ ) -> Bool {
2809
+ if isLocationInStandaloneToolbarFrame(locationInWindow) {
2810
+ markRecentToolbarTouch()
2811
+ }
2812
+ let result = shouldHandleOutsideTap(
2813
+ locationInWindow: locationInWindow,
2814
+ touchedView: touchedView
2815
+ )
2816
+ if result {
2817
+ clearRecentToolbarTouch()
2818
+ }
2819
+ return result
2820
+ }
2821
+
2822
+ private func markRecentToolbarTouch() {
2823
+ lastToolbarTouchUptime = ProcessInfo.processInfo.systemUptime
2824
+ }
2825
+
2826
+ private func clearRecentToolbarTouch() {
2827
+ lastToolbarTouchUptime = -Double.infinity
2828
+ }
2829
+
2830
+ private func shouldPreserveFocusAfterToolbarTouch() -> Bool {
2831
+ ProcessInfo.processInfo.systemUptime - lastToolbarTouchUptime <= 0.75
2832
+ }
2833
+
2834
+ private func consumeToolbarFocusPreservationForBlur() -> Bool {
2835
+ guard shouldPreserveFocusAfterToolbarTouch() else { return false }
2836
+ clearRecentToolbarTouch()
2837
+ return true
2838
+ }
2839
+
2840
+ private func isLocationInStandaloneToolbarFrame(_ locationInWindow: CGPoint) -> Bool {
2841
+ toolbarFramesInWindow.contains(where: { $0.contains(locationInWindow) })
2842
+ }
2843
+
2844
+ private func shouldHandleOutsideTap(
2845
+ locationInWindow: CGPoint,
2846
+ touchedView: UIView?
2847
+ ) -> Bool {
2848
+ if let touchedView, touchedView.isDescendant(of: self) {
2849
+ return false
2850
+ }
2851
+ if let tapWindow = gestureWindow ?? window {
2852
+ let editorFrameInWindow = convert(bounds, to: tapWindow)
2853
+ if editorFrameInWindow.contains(locationInWindow) {
2854
+ return false
2855
+ }
2856
+ }
2857
+ if let touchedView, touchedView.isDescendant(of: accessoryToolbar) {
2858
+ return false
2859
+ }
2860
+ if isLocationInStandaloneToolbarFrame(locationInWindow) {
2861
+ return false
2862
+ }
2863
+ return true
2864
+ }
2865
+
2866
+ // MARK: - EditorTextViewDelegate
2867
+
2868
+ func editorTextView(_ textView: EditorTextView, selectionDidChange anchor: UInt32, head: UInt32) {
2869
+ let stateJSON = refreshToolbarStateFromEditorSelection()
2870
+ refreshSystemAssistantToolbarIfNeeded()
2871
+ refreshMentionQuery()
2872
+ richTextView.refreshRemoteSelections()
2873
+ var event: [String: Any] = ["anchor": Int(anchor), "head": Int(head)]
2874
+ if let stateJSON {
2875
+ event["stateJson"] = stateJSON
2876
+ }
2877
+ onSelectionChange(event)
2878
+ }
2879
+
2880
+ func editorTextView(_ textView: EditorTextView, didReceiveUpdate updateJSON: String) {
2881
+ if let state = NativeToolbarState(updateJSON: updateJSON) {
2882
+ toolbarState = state
2883
+ accessoryToolbar.apply(state: state)
2884
+ refreshSystemAssistantToolbarIfNeeded()
2885
+ }
2886
+ refreshMentionQuery()
2887
+ richTextView.refreshRemoteSelections()
2888
+ guard !isApplyingJSUpdate else { return }
2889
+ onEditorUpdate(["updateJson": updateJSON])
2890
+ }
2891
+
2892
+ @discardableResult
2893
+ private func refreshToolbarStateFromEditorSelection() -> String? {
2894
+ guard richTextView.editorId != 0 else { return nil }
2895
+ let stateJSON = editorGetSelectionState(id: richTextView.editorId)
2896
+ guard let state = NativeToolbarState(updateJSON: stateJSON) else { return nil }
2897
+ toolbarState = state
2898
+ accessoryToolbar.apply(state: state)
2899
+ return stateJSON
2900
+ }
2901
+
2902
+ private func configureAccessoryToolbar() {
2903
+ accessoryToolbar.onPressItem = { [weak self] item in
2904
+ self?.handleToolbarItemPress(item)
2905
+ }
2906
+ accessoryToolbar.onSelectMentionSuggestion = { [weak self] suggestion in
2907
+ self?.insertMentionSuggestion(suggestion)
2908
+ }
2909
+ accessoryToolbar.setItems(toolbarItems)
2910
+ accessoryToolbar.apply(state: toolbarState)
2911
+ updateAccessoryToolbarVisibility()
2912
+ }
2913
+
2914
+ private func refreshMentionQuery() {
2915
+ guard richTextView.editorId != 0,
2916
+ richTextView.textView.isFirstResponder,
2917
+ let mentions = addons.mentions
2918
+ else {
2919
+ clearMentionQueryStateAndHidePopover()
2920
+ return
2921
+ }
2922
+ guard prepareForInputAccessoryMutationOrRetry(.refreshMentionQuery) else { return }
2923
+
2924
+ guard let queryState = currentMentionQueryState(trigger: mentions.trigger) else {
2925
+ emitMentionQueryChange(query: "", trigger: mentions.trigger, anchor: 0, head: 0, isActive: false)
2926
+ clearMentionQueryStateAndHidePopover()
2927
+ return
2928
+ }
2929
+
2930
+ let suggestions = filteredMentionSuggestions(for: queryState, config: mentions)
2931
+ mentionQueryState = queryState
2932
+ accessoryToolbar.apply(mentionTheme: richTextView.textView.theme?.mentions ?? mentions.theme)
2933
+ let didChangeToolbarHeight = accessoryToolbar.setMentionSuggestions(suggestions)
2934
+ refreshSystemAssistantToolbarIfNeeded()
2935
+ if didChangeToolbarHeight,
2936
+ richTextView.textView.isFirstResponder,
2937
+ richTextView.textView.inputAccessoryView === accessoryToolbar
2938
+ {
2939
+ richTextView.textView.reloadInputViews()
2940
+ }
2941
+ markAccessoryMutationSucceeded(.refreshMentionQuery)
2942
+ emitMentionQueryChange(
2943
+ query: queryState.query,
2944
+ trigger: queryState.trigger,
2945
+ anchor: queryState.anchor,
2946
+ head: queryState.head,
2947
+ isActive: true
2948
+ )
2949
+ }
2950
+
2951
+ private func clearMentionQueryStateAndHidePopover() {
2952
+ guard prepareForInputAccessoryMutationOrRetry(.clearMentionQueryState) else { return }
2953
+ mentionQueryState = nil
2954
+ let didChangeToolbarHeight = accessoryToolbar.setMentionSuggestions([])
2955
+ refreshSystemAssistantToolbarIfNeeded()
2956
+ if didChangeToolbarHeight,
2957
+ richTextView.textView.isFirstResponder,
2958
+ richTextView.textView.inputAccessoryView === accessoryToolbar
2959
+ {
2960
+ richTextView.textView.reloadInputViews()
2961
+ }
2962
+ markAccessoryMutationSucceeded(.clearMentionQueryState)
2963
+ }
2964
+
2965
+ private func emitMentionQueryChange(
2966
+ query: String,
2967
+ trigger: String,
2968
+ anchor: UInt32,
2969
+ head: UInt32,
2970
+ isActive: Bool
2971
+ ) {
2972
+ let payload: [String: Any] = [
2973
+ "type": "mentionsQueryChange",
2974
+ "query": query,
2975
+ "trigger": trigger,
2976
+ "range": [
2977
+ "anchor": Int(anchor),
2978
+ "head": Int(head),
2979
+ ],
2980
+ "isActive": isActive,
2981
+ ]
2982
+ guard let data = try? JSONSerialization.data(withJSONObject: payload),
2983
+ let json = String(data: data, encoding: .utf8)
2984
+ else {
2985
+ return
2986
+ }
2987
+ guard json != lastMentionEventJSON else { return }
2988
+ lastMentionEventJSON = json
2989
+ dispatchAddonEvent(json)
2990
+ }
2991
+
2992
+ private func resolvedMentionAttrs(
2993
+ trigger: String,
2994
+ suggestion: NativeMentionSuggestion
2995
+ ) -> [String: Any] {
2996
+ var attrs = suggestion.attrs
2997
+ if attrs["label"] == nil {
2998
+ attrs["label"] = suggestion.label
2999
+ }
3000
+ if attrs["mentionSuggestionChar"] == nil {
3001
+ attrs["mentionSuggestionChar"] = trigger
3002
+ }
3003
+ return attrs
3004
+ }
3005
+
3006
+ private func emitMentionSelect(
3007
+ trigger: String,
3008
+ suggestion: NativeMentionSuggestion,
3009
+ attrs: [String: Any]
3010
+ ) {
3011
+ let payload: [String: Any] = [
3012
+ "type": "mentionsSelect",
3013
+ "trigger": trigger,
3014
+ "suggestionKey": suggestion.key,
3015
+ "attrs": attrs,
3016
+ ]
3017
+ guard let data = try? JSONSerialization.data(withJSONObject: payload),
3018
+ let json = String(data: data, encoding: .utf8)
3019
+ else {
3020
+ return
3021
+ }
3022
+ dispatchAddonEvent(json)
3023
+ }
3024
+
3025
+ private func emitMentionSelectRequest(
3026
+ trigger: String,
3027
+ suggestion: NativeMentionSuggestion,
3028
+ attrs: [String: Any],
3029
+ range: MentionQueryState,
3030
+ preflightUpdateJSON: String? = nil
3031
+ ) {
3032
+ var payload: [String: Any] = [
3033
+ "type": "mentionsSelectRequest",
3034
+ "trigger": trigger,
3035
+ "suggestionKey": suggestion.key,
3036
+ "attrs": attrs,
3037
+ "range": [
3038
+ "anchor": Int(range.anchor),
3039
+ "head": Int(range.head),
3040
+ ],
3041
+ ]
3042
+ if let preflightUpdateJSON {
3043
+ payload["updateJson"] = preflightUpdateJSON
3044
+ }
3045
+ if let documentVersion = documentVersion(fromUpdateJSON: preflightUpdateJSON) {
3046
+ payload["documentVersion"] = documentVersion
3047
+ }
3048
+ guard let data = try? JSONSerialization.data(withJSONObject: payload),
3049
+ let json = String(data: data, encoding: .utf8)
3050
+ else {
3051
+ return
3052
+ }
3053
+ dispatchAddonEvent(json)
3054
+ }
3055
+
3056
+ private func dispatchAddonEvent(_ json: String) {
3057
+ lastAddonEventJSONForTestingValue = json
3058
+ onAddonEvent(["eventJson": json])
3059
+ }
3060
+
3061
+ private func documentVersion(fromUpdateJSON updateJSON: String?) -> Int? {
3062
+ guard let updateJSON,
3063
+ let data = updateJSON.data(using: .utf8),
3064
+ let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
3065
+ else {
3066
+ return nil
3067
+ }
3068
+ if let version = raw["documentVersion"] as? Int {
3069
+ return version
3070
+ }
3071
+ if let number = raw["documentVersion"] as? NSNumber {
3072
+ return number.intValue
3073
+ }
3074
+ return nil
3075
+ }
3076
+
3077
+ private func filteredMentionSuggestions(
3078
+ for queryState: MentionQueryState,
3079
+ config: NativeMentionsAddonConfig
3080
+ ) -> [NativeMentionSuggestion] {
3081
+ let query = queryState.query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
3082
+ guard !query.isEmpty else {
3083
+ return config.suggestions
3084
+ }
3085
+
3086
+ return config.suggestions.filter { suggestion in
3087
+ suggestion.title.lowercased().contains(query)
3088
+ || suggestion.label.lowercased().contains(query)
3089
+ || (suggestion.subtitle?.lowercased().contains(query) ?? false)
3090
+ }
3091
+ }
3092
+
3093
+ private func currentMentionQueryState(trigger: String) -> MentionQueryState? {
3094
+ guard let selectedTextRange = richTextView.textView.selectedTextRange,
3095
+ selectedTextRange.isEmpty
3096
+ else {
3097
+ return nil
3098
+ }
3099
+
3100
+ let currentText = richTextView.textView.text ?? ""
3101
+ let cursorUtf16Offset = richTextView.textView.offset(
3102
+ from: richTextView.textView.beginningOfDocument,
3103
+ to: selectedTextRange.start
3104
+ )
3105
+ let visibleCursorScalar = PositionBridge.utf16OffsetToScalar(
3106
+ cursorUtf16Offset,
3107
+ in: currentText
3108
+ )
3109
+
3110
+ guard let visibleQueryState = resolveMentionQueryState(
3111
+ in: currentText,
3112
+ cursorScalar: visibleCursorScalar,
3113
+ trigger: trigger,
3114
+ isCaretInsideMention: isCaretInsideMention(
3115
+ cursorScalar: PositionBridge.textViewToScalar(
3116
+ selectedTextRange.start,
3117
+ in: richTextView.textView
3118
+ )
3119
+ )
3120
+ ) else {
3121
+ return nil
3122
+ }
3123
+
3124
+ let anchorUtf16Offset = PositionBridge.scalarToUtf16Offset(
3125
+ visibleQueryState.anchor,
3126
+ in: currentText
3127
+ )
3128
+ let headUtf16Offset = PositionBridge.scalarToUtf16Offset(
3129
+ visibleQueryState.head,
3130
+ in: currentText
3131
+ )
3132
+
3133
+ return MentionQueryState(
3134
+ query: visibleQueryState.query,
3135
+ trigger: visibleQueryState.trigger,
3136
+ anchor: PositionBridge.utf16OffsetToScalar(
3137
+ anchorUtf16Offset,
3138
+ in: richTextView.textView
3139
+ ),
3140
+ head: PositionBridge.utf16OffsetToScalar(
3141
+ headUtf16Offset,
3142
+ in: richTextView.textView
3143
+ )
3144
+ )
3145
+ }
3146
+
3147
+ private func isCaretInsideMention(cursorScalar: UInt32) -> Bool {
3148
+ let utf16Offset = PositionBridge.scalarToUtf16Offset(
3149
+ cursorScalar,
3150
+ in: richTextView.textView.text ?? ""
3151
+ )
3152
+ let textStorage = richTextView.textView.textStorage
3153
+ guard textStorage.length > 0 else { return false }
3154
+ let candidateOffsets = [
3155
+ min(max(utf16Offset, 0), max(textStorage.length - 1, 0)),
3156
+ min(max(utf16Offset - 1, 0), max(textStorage.length - 1, 0)),
3157
+ ]
3158
+
3159
+ for offset in candidateOffsets where offset >= 0 && offset < textStorage.length {
3160
+ if let nodeType = textStorage.attribute(RenderBridgeAttributes.voidNodeType, at: offset, effectiveRange: nil) as? String,
3161
+ nodeType == "mention" {
3162
+ return true
3163
+ }
3164
+ }
3165
+ return false
3166
+ }
3167
+
3168
+ private func insertMentionSuggestion(
3169
+ _ suggestion: NativeMentionSuggestion
3170
+ ) {
3171
+ insertMentionSuggestion(suggestionKey: suggestion.key)
3172
+ }
3173
+
3174
+ private func insertMentionSuggestion(
3175
+ retryScope: PendingMentionSuggestionRetry
3176
+ ) {
3177
+ insertMentionSuggestion(
3178
+ suggestionKey: retryScope.suggestionKey,
3179
+ retryScope: retryScope
3180
+ )
3181
+ }
3182
+
3183
+ private func insertMentionSuggestion(
3184
+ suggestionKey: String,
3185
+ retryScope: PendingMentionSuggestionRetry? = nil
3186
+ ) {
3187
+ guard let mentions = addons.mentions,
3188
+ mentionQueryState != nil
3189
+ else {
3190
+ return
3191
+ }
3192
+ if let retryScope,
3193
+ !isMentionSuggestionRetryScopeCurrent(retryScope)
3194
+ {
3195
+ return
3196
+ }
3197
+
3198
+ let scopedQueryState = currentMentionQueryState(trigger: mentions.trigger) ?? mentionQueryState
3199
+ guard let scopedQueryState else {
3200
+ clearMentionQueryStateAndHidePopover()
3201
+ return
3202
+ }
3203
+ let preparation = richTextView.textView.prepareForExternalEditorCommand()
3204
+ guard preparation.ready else {
3205
+ scheduleMentionSuggestionRetry(
3206
+ PendingMentionSuggestionRetry(
3207
+ suggestionKey: suggestionKey,
3208
+ editorId: richTextView.editorId,
3209
+ trigger: mentions.trigger,
3210
+ query: scopedQueryState.query,
3211
+ anchor: scopedQueryState.anchor,
3212
+ head: scopedQueryState.head,
3213
+ documentVersion: currentDocumentVersion(),
3214
+ textSnapshot: richTextView.textView.text ?? ""
3215
+ )
3216
+ )
3217
+ return
3218
+ }
3219
+ let queryState = currentMentionQueryState(trigger: mentions.trigger)
3220
+ ?? (richTextView.textView.isFirstResponder ? nil : mentionQueryState)
3221
+ guard let queryState else {
3222
+ clearMentionQueryStateAndHidePopover()
3223
+ return
3224
+ }
3225
+ if let retryScope,
3226
+ !doesMentionQueryState(
3227
+ queryState,
3228
+ match: retryScope,
3229
+ acceptingPreflightDocumentVersion: documentVersion(fromUpdateJSON: preparation.updateJSON),
3230
+ currentText: richTextView.textView.text ?? ""
3231
+ )
3232
+ {
3233
+ return
3234
+ }
3235
+ guard let currentSuggestion = filteredMentionSuggestions(
3236
+ for: queryState,
3237
+ config: mentions
3238
+ ).first(where: { $0.key == suggestionKey }) else {
3239
+ clearMentionQueryStateAndHidePopover()
3240
+ return
3241
+ }
3242
+ mentionQueryState = queryState
3243
+
3244
+ let attrs = resolvedMentionAttrs(trigger: mentions.trigger, suggestion: currentSuggestion)
3245
+ if mentions.resolveSelectionAttrs || mentions.resolveTheme {
3246
+ emitMentionSelectRequest(
3247
+ trigger: mentions.trigger,
3248
+ suggestion: currentSuggestion,
3249
+ attrs: attrs,
3250
+ range: queryState,
3251
+ preflightUpdateJSON: preparation.updateJSON
3252
+ )
3253
+ lastMentionEventJSON = nil
3254
+ clearMentionQueryStateAndHidePopover()
3255
+ return
3256
+ }
3257
+ let payload: [String: Any] = [
3258
+ "type": "doc",
3259
+ "content": [[
3260
+ "type": "mention",
3261
+ "attrs": attrs,
3262
+ ]],
3263
+ ]
3264
+ guard let data = try? JSONSerialization.data(withJSONObject: payload),
3265
+ let json = String(data: data, encoding: .utf8)
3266
+ else {
3267
+ return
3268
+ }
3269
+
3270
+ let updateJSON = editorInsertContentJsonAtSelectionScalar(
3271
+ id: richTextView.editorId,
3272
+ scalarAnchor: queryState.anchor,
3273
+ scalarHead: queryState.head,
3274
+ json: json
3275
+ )
3276
+ richTextView.textView.applyUpdateJSON(updateJSON)
3277
+ emitMentionSelect(trigger: mentions.trigger, suggestion: currentSuggestion, attrs: attrs)
3278
+ lastMentionEventJSON = nil
3279
+ clearMentionQueryStateAndHidePopover()
3280
+ }
3281
+
3282
+ private func scheduleMentionSuggestionRetry(_ retry: PendingMentionSuggestionRetry) {
3283
+ pendingMentionSuggestionRetry = retry
3284
+ guard !pendingMentionSuggestionRetryScheduled else { return }
3285
+ pendingMentionSuggestionRetryScheduled = true
3286
+ pendingMentionSuggestionRetryGeneration &+= 1
3287
+ let retryGeneration = pendingMentionSuggestionRetryGeneration
3288
+ DispatchQueue.main.async { [weak self] in
3289
+ guard let self else { return }
3290
+ guard retryGeneration == self.pendingMentionSuggestionRetryGeneration else { return }
3291
+ guard let retry = self.pendingMentionSuggestionRetry else {
3292
+ self.pendingMentionSuggestionRetryScheduled = false
3293
+ return
3294
+ }
3295
+ guard retry.editorId == self.richTextView.editorId else {
3296
+ self.clearPendingMentionSuggestionRetry()
3297
+ return
3298
+ }
3299
+ self.pendingMentionSuggestionRetry = nil
3300
+ self.pendingMentionSuggestionRetryScheduled = false
3301
+ self.insertMentionSuggestion(retryScope: retry)
3302
+ }
3303
+ }
3304
+
3305
+ private func isMentionSuggestionRetryScopeCurrent(
3306
+ _ retry: PendingMentionSuggestionRetry
3307
+ ) -> Bool {
3308
+ guard retry.editorId == richTextView.editorId,
3309
+ addons.mentions?.trigger == retry.trigger
3310
+ else {
3311
+ return false
3312
+ }
3313
+ let queryState = currentMentionQueryState(trigger: retry.trigger) ?? mentionQueryState
3314
+ guard let queryState else { return false }
3315
+ guard doesMentionQueryStateMatchRetryIdentity(queryState, match: retry) else {
3316
+ return false
3317
+ }
3318
+ return isMentionSuggestionRetryDocumentVersionCurrent(retry)
3319
+ }
3320
+
3321
+ private func doesMentionQueryState(
3322
+ _ queryState: MentionQueryState,
3323
+ match retry: PendingMentionSuggestionRetry,
3324
+ acceptingPreflightDocumentVersion preflightDocumentVersion: Int? = nil,
3325
+ currentText: String? = nil
3326
+ ) -> Bool {
3327
+ guard doesMentionQueryStateMatchRetryIdentity(queryState, match: retry) else {
3328
+ return false
3329
+ }
3330
+
3331
+ let currentVersion = currentDocumentVersion()
3332
+ var acceptedPreflightVersionChange = false
3333
+ if let retryVersion = retry.documentVersion,
3334
+ let currentVersion,
3335
+ currentVersion != retryVersion
3336
+ {
3337
+ guard let preflightDocumentVersion,
3338
+ currentVersion == preflightDocumentVersion
3339
+ else {
3340
+ return false
3341
+ }
3342
+ acceptedPreflightVersionChange = true
3343
+ }
3344
+
3345
+ if queryState.anchor == retry.anchor && queryState.head == retry.head {
3346
+ return true
3347
+ }
3348
+
3349
+ guard acceptedPreflightVersionChange else {
3350
+ return false
3351
+ }
3352
+
3353
+ guard let currentText,
3354
+ let diff = mentionRetryTextDiff(
3355
+ from: retry.textSnapshot,
3356
+ to: currentText
3357
+ ),
3358
+ let mappedRange = mappedMentionRetryRange(retry, through: diff)
3359
+ else {
3360
+ return false
3361
+ }
3362
+
3363
+ return queryState.anchor == mappedRange.anchor && queryState.head == mappedRange.head
3364
+ }
3365
+
3366
+ private func doesMentionQueryStateMatchRetryIdentity(
3367
+ _ queryState: MentionQueryState,
3368
+ match retry: PendingMentionSuggestionRetry
3369
+ ) -> Bool {
3370
+ queryState.trigger == retry.trigger && queryState.query == retry.query
3371
+ }
3372
+
3373
+ private func isMentionSuggestionRetryDocumentVersionCurrent(
3374
+ _ retry: PendingMentionSuggestionRetry
3375
+ ) -> Bool {
3376
+ let currentVersion = currentDocumentVersion()
3377
+ if let retryVersion = retry.documentVersion,
3378
+ let currentVersion,
3379
+ currentVersion != retryVersion
3380
+ {
3381
+ return false
3382
+ }
3383
+ return true
3384
+ }
3385
+
3386
+ private func mentionRetryTextDiff(
3387
+ from oldText: String,
3388
+ to newText: String
3389
+ ) -> MentionRetryTextDiff? {
3390
+ let oldScalars = Array(oldText.unicodeScalars)
3391
+ let newScalars = Array(newText.unicodeScalars)
3392
+ let sharedLength = min(oldScalars.count, newScalars.count)
3393
+
3394
+ var prefix = 0
3395
+ while prefix < sharedLength,
3396
+ oldScalars[prefix] == newScalars[prefix]
3397
+ {
3398
+ prefix += 1
3399
+ }
3400
+
3401
+ var oldEnd = oldScalars.count
3402
+ var newEnd = newScalars.count
3403
+ while oldEnd > prefix,
3404
+ newEnd > prefix,
3405
+ oldScalars[oldEnd - 1] == newScalars[newEnd - 1]
3406
+ {
3407
+ oldEnd -= 1
3408
+ newEnd -= 1
3409
+ }
3410
+
3411
+ guard prefix != oldEnd || prefix != newEnd else {
3412
+ return nil
3413
+ }
3414
+
3415
+ return MentionRetryTextDiff(
3416
+ start: prefix,
3417
+ oldEnd: oldEnd,
3418
+ newEnd: newEnd
3419
+ )
3420
+ }
3421
+
3422
+ private func mappedMentionRetryRange(
3423
+ _ retry: PendingMentionSuggestionRetry,
3424
+ through diff: MentionRetryTextDiff
3425
+ ) -> (anchor: UInt32, head: UInt32)? {
3426
+ let anchor = Int(retry.anchor)
3427
+ let head = Int(retry.head)
3428
+ guard anchor <= head else { return nil }
3429
+
3430
+ if head <= diff.start {
3431
+ return (retry.anchor, retry.head)
3432
+ }
3433
+
3434
+ if anchor >= diff.oldEnd {
3435
+ let delta = diff.newEnd - diff.oldEnd
3436
+ let mappedAnchor = anchor + delta
3437
+ let mappedHead = head + delta
3438
+ guard mappedAnchor >= 0,
3439
+ mappedHead >= mappedAnchor,
3440
+ mappedHead <= Int(UInt32.max)
3441
+ else {
3442
+ return nil
3443
+ }
3444
+ return (UInt32(mappedAnchor), UInt32(mappedHead))
3445
+ }
3446
+
3447
+ return nil
3448
+ }
3449
+
3450
+ private func currentDocumentVersion() -> Int? {
3451
+ guard richTextView.editorId != 0 else { return nil }
3452
+ return documentVersion(fromUpdateJSON: editorGetCurrentState(id: richTextView.editorId))
3453
+ }
3454
+
3455
+ func setMentionQueryStateForTesting(_ state: MentionQueryState?) {
3456
+ mentionQueryState = state
3457
+ }
3458
+
3459
+ func currentMentionQueryStateForTesting(trigger: String) -> MentionQueryState? {
3460
+ currentMentionQueryState(trigger: trigger)
3461
+ }
3462
+
3463
+ func setMentionSuggestionsForTesting(_ suggestions: [NativeMentionSuggestion]) {
3464
+ accessoryToolbar.setMentionSuggestions(suggestions)
3465
+ }
3466
+
3467
+ func isShowingMentionSuggestionsForTesting() -> Bool {
3468
+ accessoryToolbar.isShowingMentionSuggestions
3469
+ }
3470
+
3471
+ func lastAddonEventJSONForTesting() -> String? {
3472
+ lastAddonEventJSONForTestingValue
3473
+ }
3474
+
3475
+ func triggerMentionSuggestionTapForTesting(at index: Int) {
3476
+ accessoryToolbar.triggerMentionSuggestionTapForTesting(at: index)
3477
+ }
3478
+
3479
+ func inputAccessoryViewForTesting() -> UIView? {
3480
+ richTextView.textView.inputAccessoryView
3481
+ }
3482
+
3483
+ func isUsingAccessoryToolbarForTesting() -> Bool {
3484
+ richTextView.textView.inputAccessoryView === accessoryToolbar
3485
+ }
3486
+
3487
+ func isUsingAccessoryPlaceholderForTesting() -> Bool {
3488
+ richTextView.textView.inputAccessoryView === accessoryPlaceholder
3489
+ }
3490
+
3491
+ func markRecentToolbarTouchForTesting() {
3492
+ markRecentToolbarTouch()
3493
+ }
3494
+
3495
+ func shouldPreserveFocusAfterToolbarTouchForTesting() -> Bool {
3496
+ shouldPreserveFocusAfterToolbarTouch()
3497
+ }
3498
+
3499
+ func consumeToolbarFocusPreservationForTesting() -> Bool {
3500
+ consumeToolbarFocusPreservationForBlur()
3501
+ }
3502
+
3503
+ func prepareOutsideTapForFocusHandlingForTesting(
3504
+ locationInWindow: CGPoint,
3505
+ touchedView: UIView? = nil
3506
+ ) -> Bool {
3507
+ prepareOutsideTapForFocusHandling(
3508
+ locationInWindow: locationInWindow,
3509
+ touchedView: touchedView
3510
+ )
3511
+ }
3512
+
3513
+ private func updateAccessoryToolbarVisibility() {
3514
+ guard prepareForInputAccessoryMutationOrRetry(.updateAccessoryToolbarVisibility) else { return }
3515
+ refreshSystemAssistantToolbarIfNeeded()
3516
+ let nextAccessoryView: UIView?
3517
+ if showsToolbar &&
3518
+ toolbarPlacement == "keyboard" &&
3519
+ richTextView.textView.isEditable &&
3520
+ !shouldUseSystemAssistantToolbar
3521
+ {
3522
+ nextAccessoryView = accessoryToolbar
3523
+ } else if richTextView.textView.isEditable && !shouldUseSystemAssistantToolbar {
3524
+ nextAccessoryView = accessoryPlaceholder
3525
+ } else {
3526
+ nextAccessoryView = nil
3527
+ }
3528
+ if richTextView.textView.inputAccessoryView !== nextAccessoryView {
3529
+ richTextView.textView.inputAccessoryView = nextAccessoryView
3530
+ if richTextView.textView.isFirstResponder {
3531
+ richTextView.textView.reloadInputViews()
3532
+ }
3533
+ }
3534
+ markAccessoryMutationSucceeded(.updateAccessoryToolbarVisibility)
3535
+ }
3536
+
3537
+ private var shouldUseSystemAssistantToolbar: Bool {
3538
+ false
3539
+ }
3540
+
3541
+ private func refreshSystemAssistantToolbarIfNeeded() {
3542
+ guard #available(iOS 26.0, *) else { return }
3543
+
3544
+ let assistantItem = richTextView.textView.inputAssistantItem
3545
+ assistantItem.allowsHidingShortcuts = false
3546
+ assistantItem.leadingBarButtonGroups = []
3547
+ assistantItem.trailingBarButtonGroups = []
3548
+ }
3549
+
3550
+ private func handleListToggle(_ listType: String) {
3551
+ let isActive = toolbarState.nodes[listType] == true
3552
+ richTextView.textView.performToolbarToggleList(listType, isActive: isActive)
3553
+ }
3554
+
3555
+ private func handleToolbarItemPress(_ item: NativeToolbarItem) {
3556
+ switch item.type {
3557
+ case .mark:
3558
+ guard let mark = item.mark else { return }
3559
+ richTextView.textView.performToolbarToggleMark(mark)
3560
+ case .heading:
3561
+ guard let level = item.headingLevel else { return }
3562
+ richTextView.textView.performToolbarToggleHeading(level)
3563
+ case .blockquote:
3564
+ richTextView.textView.performToolbarToggleBlockquote()
3565
+ case .list:
3566
+ guard let listType = item.listType?.rawValue else { return }
3567
+ handleListToggle(listType)
3568
+ case .command:
3569
+ switch item.command {
3570
+ case .indentList:
3571
+ richTextView.textView.performToolbarIndentListItem()
3572
+ case .outdentList:
3573
+ richTextView.textView.performToolbarOutdentListItem()
3574
+ case .undo:
3575
+ richTextView.textView.performToolbarUndo()
3576
+ case .redo:
3577
+ richTextView.textView.performToolbarRedo()
3578
+ case .none:
3579
+ break
3580
+ }
3581
+ case .node:
3582
+ guard let nodeType = item.nodeType else { return }
3583
+ richTextView.textView.performToolbarInsertNode(nodeType)
3584
+ case .action:
3585
+ guard let key = item.key else { return }
3586
+ onToolbarAction(["key": key])
3587
+ case .group:
3588
+ break
3589
+ case .separator:
3590
+ break
3591
+ }
3592
+ }
3593
+ }