@apollohg/react-native-prose-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +160 -0
  2. package/README.md +143 -0
  3. package/android/build.gradle +39 -0
  4. package/android/src/main/assets/editor-icons/MaterialIcons.json +2236 -0
  5. package/android/src/main/assets/editor-icons/MaterialIcons.ttf +0 -0
  6. package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +131 -0
  7. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +1057 -0
  8. package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
  9. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +191 -0
  10. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +325 -0
  11. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +647 -0
  12. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +257 -0
  13. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +714 -0
  14. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +76 -0
  15. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +1044 -0
  16. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +211 -0
  17. package/expo-module.config.json +9 -0
  18. package/ios/EditorAddons.swift +228 -0
  19. package/ios/EditorCore.xcframework/Info.plist +44 -0
  20. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  21. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  22. package/ios/EditorLayoutManager.swift +254 -0
  23. package/ios/EditorTheme.swift +372 -0
  24. package/ios/Generated_editor_core.swift +1143 -0
  25. package/ios/NativeEditorExpoView.swift +1417 -0
  26. package/ios/NativeEditorModule.swift +263 -0
  27. package/ios/PositionBridge.swift +278 -0
  28. package/ios/ReactNativeProseEditor.podspec +49 -0
  29. package/ios/RenderBridge.swift +825 -0
  30. package/ios/RichTextEditorView.swift +1559 -0
  31. package/ios/editor_coreFFI/editor_coreFFI.h +1014 -0
  32. package/ios/editor_coreFFI/module.modulemap +7 -0
  33. package/ios/editor_coreFFI.h +904 -0
  34. package/ios/editor_coreFFI.modulemap +7 -0
  35. package/package.json +66 -0
  36. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  37. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  38. package/rust/android/x86_64/libeditor_core.so +0 -0
  39. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2014 -0
  40. package/src/EditorTheme.ts +130 -0
  41. package/src/EditorToolbar.tsx +620 -0
  42. package/src/NativeEditorBridge.ts +607 -0
  43. package/src/NativeRichTextEditor.tsx +951 -0
  44. package/src/addons.ts +158 -0
  45. package/src/index.ts +63 -0
  46. package/src/schemas.ts +153 -0
  47. package/src/useNativeEditor.ts +173 -0
@@ -0,0 +1,1417 @@
1
+ import ExpoModulesCore
2
+ import UIKit
3
+ import os
4
+
5
+ private struct NativeToolbarState {
6
+ let marks: [String: Bool]
7
+ let nodes: [String: Bool]
8
+ let commands: [String: Bool]
9
+ let allowedMarks: Set<String>
10
+ let insertableNodes: Set<String>
11
+ let canUndo: Bool
12
+ let canRedo: Bool
13
+
14
+ static let empty = NativeToolbarState(
15
+ marks: [:],
16
+ nodes: [:],
17
+ commands: [:],
18
+ allowedMarks: [],
19
+ insertableNodes: [],
20
+ canUndo: false,
21
+ canRedo: false
22
+ )
23
+
24
+ init(
25
+ marks: [String: Bool],
26
+ nodes: [String: Bool],
27
+ commands: [String: Bool],
28
+ allowedMarks: Set<String>,
29
+ insertableNodes: Set<String>,
30
+ canUndo: Bool,
31
+ canRedo: Bool
32
+ ) {
33
+ self.marks = marks
34
+ self.nodes = nodes
35
+ self.commands = commands
36
+ self.allowedMarks = allowedMarks
37
+ self.insertableNodes = insertableNodes
38
+ self.canUndo = canUndo
39
+ self.canRedo = canRedo
40
+ }
41
+
42
+ init?(updateJSON: String) {
43
+ guard let data = updateJSON.data(using: .utf8),
44
+ let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
45
+ else {
46
+ return nil
47
+ }
48
+
49
+ let activeState = raw["activeState"] as? [String: Any] ?? [:]
50
+ let historyState = raw["historyState"] as? [String: Any] ?? [:]
51
+
52
+ self.init(
53
+ marks: NativeToolbarState.boolMap(from: activeState["marks"]),
54
+ nodes: NativeToolbarState.boolMap(from: activeState["nodes"]),
55
+ commands: NativeToolbarState.boolMap(from: activeState["commands"]),
56
+ allowedMarks: Set((activeState["allowedMarks"] as? [String]) ?? []),
57
+ insertableNodes: Set((activeState["insertableNodes"] as? [String]) ?? []),
58
+ canUndo: (historyState["canUndo"] as? Bool) ?? false,
59
+ canRedo: (historyState["canRedo"] as? Bool) ?? false
60
+ )
61
+ }
62
+
63
+ private static func boolMap(from value: Any?) -> [String: Bool] {
64
+ guard let map = value as? [String: Any] else { return [:] }
65
+ var result: [String: Bool] = [:]
66
+ for (key, rawValue) in map {
67
+ if let bool = rawValue as? Bool {
68
+ result[key] = bool
69
+ } else if let number = rawValue as? NSNumber {
70
+ result[key] = number.boolValue
71
+ }
72
+ }
73
+ return result
74
+ }
75
+ }
76
+
77
+ private enum ToolbarCommand: String {
78
+ case indentList
79
+ case outdentList
80
+ case undo
81
+ case redo
82
+ }
83
+
84
+ private enum ToolbarListType: String {
85
+ case bulletList
86
+ case orderedList
87
+ }
88
+
89
+ private enum ToolbarDefaultIconId: String {
90
+ case bold
91
+ case italic
92
+ case underline
93
+ case strike
94
+ case bulletList
95
+ case orderedList
96
+ case indentList
97
+ case outdentList
98
+ case lineBreak
99
+ case horizontalRule
100
+ case undo
101
+ case redo
102
+ }
103
+
104
+ private enum ToolbarItemKind: String {
105
+ case mark
106
+ case list
107
+ case command
108
+ case node
109
+ case action
110
+ case separator
111
+ }
112
+
113
+ private struct NativeToolbarIcon {
114
+ let defaultId: ToolbarDefaultIconId?
115
+ let glyphText: String?
116
+ let iosSymbolName: String?
117
+ let fallbackText: String?
118
+
119
+ private static let defaultSFSymbolNames: [ToolbarDefaultIconId: String] = [
120
+ .bold: "bold",
121
+ .italic: "italic",
122
+ .underline: "underline",
123
+ .strike: "strikethrough",
124
+ .bulletList: "list.bullet",
125
+ .orderedList: "list.number",
126
+ .indentList: "increase.indent",
127
+ .outdentList: "decrease.indent",
128
+ .lineBreak: "return.left",
129
+ .horizontalRule: "minus",
130
+ .undo: "arrow.uturn.backward",
131
+ .redo: "arrow.uturn.forward",
132
+ ]
133
+
134
+ private static let defaultGlyphs: [ToolbarDefaultIconId: String] = [
135
+ .bold: "B",
136
+ .italic: "I",
137
+ .underline: "U",
138
+ .strike: "S",
139
+ .bulletList: "•≡",
140
+ .orderedList: "1.",
141
+ .indentList: "→",
142
+ .outdentList: "←",
143
+ .lineBreak: "↵",
144
+ .horizontalRule: "—",
145
+ .undo: "↩",
146
+ .redo: "↪",
147
+ ]
148
+
149
+ static func defaultIcon(_ id: ToolbarDefaultIconId) -> NativeToolbarIcon {
150
+ NativeToolbarIcon(defaultId: id, glyphText: nil, iosSymbolName: nil, fallbackText: nil)
151
+ }
152
+
153
+ static func glyph(_ text: String) -> NativeToolbarIcon {
154
+ NativeToolbarIcon(defaultId: nil, glyphText: text, iosSymbolName: nil, fallbackText: nil)
155
+ }
156
+
157
+ static func platform(iosSymbolName: String?, fallbackText: String?) -> NativeToolbarIcon {
158
+ NativeToolbarIcon(
159
+ defaultId: nil,
160
+ glyphText: nil,
161
+ iosSymbolName: iosSymbolName,
162
+ fallbackText: fallbackText
163
+ )
164
+ }
165
+
166
+ static func from(jsonValue: Any?) -> NativeToolbarIcon? {
167
+ guard let raw = jsonValue as? [String: Any],
168
+ let rawType = raw["type"] as? String
169
+ else {
170
+ return nil
171
+ }
172
+
173
+ switch rawType {
174
+ case "default":
175
+ guard let rawId = raw["id"] as? String,
176
+ let id = ToolbarDefaultIconId(rawValue: rawId)
177
+ else {
178
+ return nil
179
+ }
180
+ return .defaultIcon(id)
181
+ case "glyph":
182
+ guard let text = raw["text"] as? String, !text.isEmpty else {
183
+ return nil
184
+ }
185
+ return .glyph(text)
186
+ case "platform":
187
+ let iosSymbolName = ((raw["ios"] as? [String: Any]).flatMap { iosRaw -> String? in
188
+ guard (iosRaw["type"] as? String) == "sfSymbol",
189
+ let name = iosRaw["name"] as? String,
190
+ !name.isEmpty
191
+ else {
192
+ return nil
193
+ }
194
+ return name
195
+ })
196
+ let fallbackText = raw["fallbackText"] as? String
197
+ guard iosSymbolName != nil || fallbackText != nil else {
198
+ return nil
199
+ }
200
+ return .platform(iosSymbolName: iosSymbolName, fallbackText: fallbackText)
201
+ default:
202
+ return nil
203
+ }
204
+ }
205
+
206
+ func resolvedSFSymbolName() -> String? {
207
+ if let iosSymbolName, !iosSymbolName.isEmpty {
208
+ return iosSymbolName
209
+ }
210
+ guard let defaultId else { return nil }
211
+ return Self.defaultSFSymbolNames[defaultId]
212
+ }
213
+
214
+ func resolvedGlyphText() -> String? {
215
+ if let glyphText, !glyphText.isEmpty {
216
+ return glyphText
217
+ }
218
+ if let fallbackText, !fallbackText.isEmpty {
219
+ return fallbackText
220
+ }
221
+ guard let defaultId else { return nil }
222
+ return Self.defaultGlyphs[defaultId]
223
+ }
224
+ }
225
+
226
+ private struct NativeToolbarItem {
227
+ let type: ToolbarItemKind
228
+ let key: String?
229
+ let label: String?
230
+ let icon: NativeToolbarIcon?
231
+ let mark: String?
232
+ let listType: ToolbarListType?
233
+ let command: ToolbarCommand?
234
+ let nodeType: String?
235
+ let isActive: Bool
236
+ let isDisabled: Bool
237
+
238
+ static let defaults: [NativeToolbarItem] = [
239
+ NativeToolbarItem(type: .mark, key: nil, label: "Bold", icon: .defaultIcon(.bold), mark: "bold", listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false),
240
+ NativeToolbarItem(type: .mark, key: nil, label: "Italic", icon: .defaultIcon(.italic), mark: "italic", listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false),
241
+ NativeToolbarItem(type: .mark, key: nil, label: "Underline", icon: .defaultIcon(.underline), mark: "underline", listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false),
242
+ NativeToolbarItem(type: .mark, key: nil, label: "Strikethrough", icon: .defaultIcon(.strike), mark: "strike", listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false),
243
+ NativeToolbarItem(type: .separator, key: nil, label: nil, icon: nil, mark: nil, listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false),
244
+ NativeToolbarItem(type: .list, key: nil, label: "Bullet List", icon: .defaultIcon(.bulletList), mark: nil, listType: .bulletList, command: nil, nodeType: nil, isActive: false, isDisabled: false),
245
+ NativeToolbarItem(type: .list, key: nil, label: "Ordered List", icon: .defaultIcon(.orderedList), mark: nil, listType: .orderedList, command: nil, nodeType: nil, isActive: false, isDisabled: false),
246
+ NativeToolbarItem(type: .command, key: nil, label: "Indent List", icon: .defaultIcon(.indentList), mark: nil, listType: nil, command: .indentList, nodeType: nil, isActive: false, isDisabled: false),
247
+ NativeToolbarItem(type: .command, key: nil, label: "Outdent List", icon: .defaultIcon(.outdentList), mark: nil, listType: nil, command: .outdentList, nodeType: nil, isActive: false, isDisabled: false),
248
+ NativeToolbarItem(type: .node, key: nil, label: "Line Break", icon: .defaultIcon(.lineBreak), mark: nil, listType: nil, command: nil, nodeType: "hardBreak", isActive: false, isDisabled: false),
249
+ NativeToolbarItem(type: .node, key: nil, label: "Horizontal Rule", icon: .defaultIcon(.horizontalRule), mark: nil, listType: nil, command: nil, nodeType: "horizontalRule", isActive: false, isDisabled: false),
250
+ NativeToolbarItem(type: .separator, key: nil, label: nil, icon: nil, mark: nil, listType: nil, command: nil, nodeType: nil, isActive: false, isDisabled: false),
251
+ NativeToolbarItem(type: .command, key: nil, label: "Undo", icon: .defaultIcon(.undo), mark: nil, listType: nil, command: .undo, nodeType: nil, isActive: false, isDisabled: false),
252
+ NativeToolbarItem(type: .command, key: nil, label: "Redo", icon: .defaultIcon(.redo), mark: nil, listType: nil, command: .redo, nodeType: nil, isActive: false, isDisabled: false),
253
+ ]
254
+
255
+ static func from(json: String?) -> [NativeToolbarItem] {
256
+ guard let json,
257
+ let data = json.data(using: .utf8),
258
+ let rawItems = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
259
+ else {
260
+ return defaults
261
+ }
262
+
263
+ let parsed = rawItems.compactMap { rawItem -> NativeToolbarItem? in
264
+ guard let rawType = rawItem["type"] as? String,
265
+ let type = ToolbarItemKind(rawValue: rawType)
266
+ else {
267
+ return nil
268
+ }
269
+
270
+ let key = rawItem["key"] as? String
271
+ switch type {
272
+ case .separator:
273
+ return NativeToolbarItem(
274
+ type: .separator,
275
+ key: key,
276
+ label: nil,
277
+ icon: nil,
278
+ mark: nil,
279
+ listType: nil,
280
+ command: nil,
281
+ nodeType: nil,
282
+ isActive: false,
283
+ isDisabled: false
284
+ )
285
+ case .mark:
286
+ guard let mark = rawItem["mark"] as? String,
287
+ let label = rawItem["label"] as? String,
288
+ let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
289
+ else {
290
+ return nil
291
+ }
292
+ return NativeToolbarItem(
293
+ type: .mark,
294
+ key: key,
295
+ label: label,
296
+ icon: icon,
297
+ mark: mark,
298
+ listType: nil,
299
+ command: nil,
300
+ nodeType: nil,
301
+ isActive: false,
302
+ isDisabled: false
303
+ )
304
+ case .list:
305
+ guard let listTypeRaw = rawItem["listType"] as? String,
306
+ let listType = ToolbarListType(rawValue: listTypeRaw),
307
+ let label = rawItem["label"] as? String,
308
+ let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
309
+ else {
310
+ return nil
311
+ }
312
+ return NativeToolbarItem(
313
+ type: .list,
314
+ key: key,
315
+ label: label,
316
+ icon: icon,
317
+ mark: nil,
318
+ listType: listType,
319
+ command: nil,
320
+ nodeType: nil,
321
+ isActive: false,
322
+ isDisabled: false
323
+ )
324
+ case .command:
325
+ guard let commandRaw = rawItem["command"] as? String,
326
+ let command = ToolbarCommand(rawValue: commandRaw),
327
+ let label = rawItem["label"] as? String,
328
+ let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
329
+ else {
330
+ return nil
331
+ }
332
+ return NativeToolbarItem(
333
+ type: .command,
334
+ key: key,
335
+ label: label,
336
+ icon: icon,
337
+ mark: nil,
338
+ listType: nil,
339
+ command: command,
340
+ nodeType: nil,
341
+ isActive: false,
342
+ isDisabled: false
343
+ )
344
+ case .node:
345
+ guard let nodeType = rawItem["nodeType"] as? String,
346
+ let label = rawItem["label"] as? String,
347
+ let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
348
+ else {
349
+ return nil
350
+ }
351
+ return NativeToolbarItem(
352
+ type: .node,
353
+ key: key,
354
+ label: label,
355
+ icon: icon,
356
+ mark: nil,
357
+ listType: nil,
358
+ command: nil,
359
+ nodeType: nodeType,
360
+ isActive: false,
361
+ isDisabled: false
362
+ )
363
+ case .action:
364
+ guard let key,
365
+ let label = rawItem["label"] as? String,
366
+ let icon = NativeToolbarIcon.from(jsonValue: rawItem["icon"])
367
+ else {
368
+ return nil
369
+ }
370
+ return NativeToolbarItem(
371
+ type: .action,
372
+ key: key,
373
+ label: label,
374
+ icon: icon,
375
+ mark: nil,
376
+ listType: nil,
377
+ command: nil,
378
+ nodeType: nil,
379
+ isActive: (rawItem["isActive"] as? Bool) ?? false,
380
+ isDisabled: (rawItem["isDisabled"] as? Bool) ?? false
381
+ )
382
+ }
383
+ }
384
+
385
+ return parsed.isEmpty ? defaults : parsed
386
+ }
387
+
388
+ func resolvedKey(index: Int) -> String {
389
+ if let key {
390
+ return key
391
+ }
392
+ switch type {
393
+ case .mark:
394
+ return "mark:\(mark ?? ""):\(index)"
395
+ case .list:
396
+ return "list:\(listType?.rawValue ?? ""):\(index)"
397
+ case .command:
398
+ return "command:\(command?.rawValue ?? ""):\(index)"
399
+ case .node:
400
+ return "node:\(nodeType ?? ""):\(index)"
401
+ case .action:
402
+ return "action:\(key ?? ""):\(index)"
403
+ case .separator:
404
+ return "separator:\(index)"
405
+ }
406
+ }
407
+ }
408
+
409
+ final class EditorAccessoryToolbarView: UIView {
410
+ private static let baseHeight: CGFloat = 50
411
+ private static let mentionRowHeight: CGFloat = 52
412
+ private static let contentSpacing: CGFloat = 6
413
+ private static let defaultHorizontalInset: CGFloat = 0
414
+ private static let defaultKeyboardOffset: CGFloat = 0
415
+
416
+ private struct ButtonBinding {
417
+ let item: NativeToolbarItem
418
+ let button: UIButton
419
+ }
420
+
421
+ private let chromeView = UIView()
422
+ private let contentStackView = UIStackView()
423
+ private let mentionScrollView = UIScrollView()
424
+ private let mentionStackView = UIStackView()
425
+ private let scrollView = UIScrollView()
426
+ private let stackView = UIStackView()
427
+ private var chromeLeadingConstraint: NSLayoutConstraint?
428
+ private var chromeTrailingConstraint: NSLayoutConstraint?
429
+ private var chromeBottomConstraint: NSLayoutConstraint?
430
+ private var mentionRowHeightConstraint: NSLayoutConstraint?
431
+ private var buttonBindings: [ButtonBinding] = []
432
+ private var separators: [UIView] = []
433
+ private var mentionButtons: [MentionSuggestionChipButton] = []
434
+ private var items: [NativeToolbarItem] = NativeToolbarItem.defaults
435
+ private var currentState = NativeToolbarState.empty
436
+ private var theme: EditorToolbarTheme?
437
+ private var mentionTheme: EditorMentionTheme?
438
+ fileprivate var onPressItem: ((NativeToolbarItem) -> Void)?
439
+ var onSelectMentionSuggestion: ((NativeMentionSuggestion) -> Void)?
440
+ var isShowingMentionSuggestions: Bool {
441
+ !mentionButtons.isEmpty && !mentionScrollView.isHidden && scrollView.isHidden
442
+ }
443
+
444
+ override var intrinsicContentSize: CGSize {
445
+ let contentHeight = mentionButtons.isEmpty ? Self.baseHeight : Self.mentionRowHeight
446
+ return CGSize(
447
+ width: UIView.noIntrinsicMetric,
448
+ height: contentHeight + (theme?.keyboardOffset ?? Self.defaultKeyboardOffset)
449
+ )
450
+ }
451
+
452
+ override init(frame: CGRect) {
453
+ super.init(frame: frame)
454
+ translatesAutoresizingMaskIntoConstraints = false
455
+ autoresizingMask = [.flexibleHeight]
456
+ backgroundColor = .clear
457
+ setupView()
458
+ rebuildButtons()
459
+ }
460
+
461
+ required init?(coder: NSCoder) {
462
+ return nil
463
+ }
464
+
465
+ fileprivate func setItems(_ items: [NativeToolbarItem]) {
466
+ self.items = items
467
+ rebuildButtons()
468
+ }
469
+
470
+ func apply(mentionTheme: EditorMentionTheme?) {
471
+ self.mentionTheme = mentionTheme
472
+ for button in mentionButtons {
473
+ button.apply(theme: mentionTheme)
474
+ }
475
+ }
476
+
477
+ func apply(theme: EditorToolbarTheme?) {
478
+ self.theme = theme
479
+ chromeView.backgroundColor = theme?.backgroundColor ?? .systemBackground
480
+ chromeView.layer.borderColor = (theme?.borderColor ?? UIColor.separator).cgColor
481
+ chromeView.layer.borderWidth = theme?.borderWidth ?? 0.5
482
+ chromeView.layer.cornerRadius = theme?.borderRadius ?? 0
483
+ chromeView.clipsToBounds = (theme?.borderRadius ?? 0) > 0
484
+ chromeLeadingConstraint?.constant = theme?.horizontalInset ?? Self.defaultHorizontalInset
485
+ chromeTrailingConstraint?.constant = -(theme?.horizontalInset ?? Self.defaultHorizontalInset)
486
+ chromeBottomConstraint?.constant = -(theme?.keyboardOffset ?? Self.defaultKeyboardOffset)
487
+ invalidateIntrinsicContentSize()
488
+ for separator in separators {
489
+ separator.backgroundColor = theme?.separatorColor ?? .separator
490
+ }
491
+ for binding in buttonBindings {
492
+ binding.button.layer.cornerRadius = theme?.buttonBorderRadius ?? 8
493
+ }
494
+ for button in mentionButtons {
495
+ button.apply(theme: mentionTheme)
496
+ }
497
+ apply(state: currentState)
498
+ }
499
+
500
+ @discardableResult
501
+ func setMentionSuggestions(_ suggestions: [NativeMentionSuggestion]) -> Bool {
502
+ let hadSuggestions = !mentionButtons.isEmpty
503
+
504
+ mentionButtons.forEach { button in
505
+ mentionStackView.removeArrangedSubview(button)
506
+ button.removeFromSuperview()
507
+ }
508
+ mentionButtons.removeAll()
509
+
510
+ for suggestion in suggestions.prefix(8) {
511
+ let button = MentionSuggestionChipButton(suggestion: suggestion, theme: mentionTheme)
512
+ button.addTarget(self, action: #selector(handleSelectMentionSuggestion(_:)), for: .touchUpInside)
513
+ mentionButtons.append(button)
514
+ mentionStackView.addArrangedSubview(button)
515
+ }
516
+
517
+ let hasSuggestions = !mentionButtons.isEmpty
518
+ mentionScrollView.isHidden = !hasSuggestions
519
+ scrollView.isHidden = hasSuggestions
520
+ mentionRowHeightConstraint?.constant = hasSuggestions ? Self.mentionRowHeight : 0
521
+ invalidateIntrinsicContentSize()
522
+ setNeedsLayout()
523
+ return hadSuggestions != hasSuggestions
524
+ }
525
+
526
+ fileprivate func apply(state: NativeToolbarState) {
527
+ currentState = state
528
+ for binding in buttonBindings {
529
+ let buttonState = buttonState(for: binding.item, state: state)
530
+ binding.button.isEnabled = buttonState.enabled
531
+ binding.button.accessibilityTraits = buttonState.active ? [.button, .selected] : .button
532
+ updateButtonAppearance(
533
+ binding.button,
534
+ enabled: buttonState.enabled,
535
+ active: buttonState.active
536
+ )
537
+ }
538
+ }
539
+
540
+ private func setupView() {
541
+ chromeView.translatesAutoresizingMaskIntoConstraints = false
542
+ chromeView.backgroundColor = .systemBackground
543
+ chromeView.layer.borderColor = UIColor.separator.cgColor
544
+ chromeView.layer.borderWidth = 0.5
545
+ addSubview(chromeView)
546
+
547
+ contentStackView.translatesAutoresizingMaskIntoConstraints = false
548
+ contentStackView.axis = .vertical
549
+ contentStackView.spacing = 0
550
+ chromeView.addSubview(contentStackView)
551
+
552
+ mentionScrollView.translatesAutoresizingMaskIntoConstraints = false
553
+ mentionScrollView.showsHorizontalScrollIndicator = false
554
+ mentionScrollView.alwaysBounceHorizontal = true
555
+ mentionScrollView.isHidden = true
556
+ contentStackView.addArrangedSubview(mentionScrollView)
557
+
558
+ mentionStackView.translatesAutoresizingMaskIntoConstraints = false
559
+ mentionStackView.axis = .horizontal
560
+ mentionStackView.alignment = .fill
561
+ mentionStackView.spacing = 8
562
+ mentionScrollView.addSubview(mentionStackView)
563
+
564
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
565
+ scrollView.showsHorizontalScrollIndicator = false
566
+ scrollView.alwaysBounceHorizontal = true
567
+ contentStackView.addArrangedSubview(scrollView)
568
+
569
+ stackView.translatesAutoresizingMaskIntoConstraints = false
570
+ stackView.axis = .horizontal
571
+ stackView.alignment = .center
572
+ stackView.spacing = 6
573
+ scrollView.addSubview(stackView)
574
+
575
+ let leading = chromeView.leadingAnchor.constraint(
576
+ equalTo: leadingAnchor,
577
+ constant: Self.defaultHorizontalInset
578
+ )
579
+ let trailing = chromeView.trailingAnchor.constraint(
580
+ equalTo: trailingAnchor,
581
+ constant: -Self.defaultHorizontalInset
582
+ )
583
+ let bottom = chromeView.bottomAnchor.constraint(
584
+ equalTo: safeAreaLayoutGuide.bottomAnchor,
585
+ constant: -Self.defaultKeyboardOffset
586
+ )
587
+ chromeLeadingConstraint = leading
588
+ chromeTrailingConstraint = trailing
589
+ chromeBottomConstraint = bottom
590
+ let mentionHeight = mentionScrollView.heightAnchor.constraint(equalToConstant: 0)
591
+ mentionRowHeightConstraint = mentionHeight
592
+
593
+ NSLayoutConstraint.activate([
594
+ chromeView.topAnchor.constraint(equalTo: topAnchor),
595
+ leading,
596
+ trailing,
597
+ bottom,
598
+
599
+ contentStackView.topAnchor.constraint(equalTo: chromeView.topAnchor, constant: 6),
600
+ contentStackView.leadingAnchor.constraint(equalTo: chromeView.leadingAnchor),
601
+ contentStackView.trailingAnchor.constraint(equalTo: chromeView.trailingAnchor),
602
+ contentStackView.bottomAnchor.constraint(equalTo: chromeView.safeAreaLayoutGuide.bottomAnchor, constant: -6),
603
+
604
+ mentionHeight,
605
+
606
+ mentionStackView.topAnchor.constraint(equalTo: mentionScrollView.contentLayoutGuide.topAnchor),
607
+ mentionStackView.leadingAnchor.constraint(equalTo: mentionScrollView.contentLayoutGuide.leadingAnchor, constant: 12),
608
+ mentionStackView.trailingAnchor.constraint(equalTo: mentionScrollView.contentLayoutGuide.trailingAnchor, constant: -12),
609
+ mentionStackView.bottomAnchor.constraint(equalTo: mentionScrollView.contentLayoutGuide.bottomAnchor),
610
+ mentionStackView.heightAnchor.constraint(equalTo: mentionScrollView.frameLayoutGuide.heightAnchor),
611
+
612
+ stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 6),
613
+ stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 12),
614
+ stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -12),
615
+ stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -6),
616
+ stackView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor, constant: -12),
617
+ scrollView.heightAnchor.constraint(equalToConstant: Self.baseHeight),
618
+ ])
619
+
620
+ }
621
+
622
+ private func rebuildButtons() {
623
+ buttonBindings.removeAll()
624
+ separators.removeAll()
625
+ for arrangedSubview in stackView.arrangedSubviews {
626
+ stackView.removeArrangedSubview(arrangedSubview)
627
+ arrangedSubview.removeFromSuperview()
628
+ }
629
+
630
+ let compactItems = items.enumerated().filter { index, item in
631
+ guard item.type == .separator else { return true }
632
+ guard index > 0, index < items.count - 1 else { return false }
633
+ return items[index - 1].type != .separator && items[index + 1].type != .separator
634
+ }.map(\.element)
635
+
636
+ for item in compactItems {
637
+ if item.type == .separator {
638
+ stackView.addArrangedSubview(makeSeparator())
639
+ continue
640
+ }
641
+
642
+ let button = makeButton(item: item)
643
+ buttonBindings.append(ButtonBinding(item: item, button: button))
644
+ stackView.addArrangedSubview(button)
645
+ }
646
+
647
+ apply(theme: theme)
648
+ apply(state: currentState)
649
+ }
650
+
651
+ private func makeButton(item: NativeToolbarItem) -> UIButton {
652
+ let button = UIButton(type: .system)
653
+ button.translatesAutoresizingMaskIntoConstraints = false
654
+ button.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
655
+ button.accessibilityLabel = item.label
656
+ button.layer.cornerRadius = theme?.buttonBorderRadius ?? 8
657
+ button.clipsToBounds = true
658
+ if #available(iOS 15.0, *) {
659
+ var configuration = UIButton.Configuration.plain()
660
+ configuration.contentInsets = NSDirectionalEdgeInsets(
661
+ top: 8,
662
+ leading: 10,
663
+ bottom: 8,
664
+ trailing: 10
665
+ )
666
+ button.configuration = configuration
667
+ } else {
668
+ button.contentEdgeInsets = UIEdgeInsets(top: 8, left: 10, bottom: 8, right: 10)
669
+ }
670
+ if let symbolName = item.icon?.resolvedSFSymbolName(),
671
+ let symbolImage = UIImage(systemName: symbolName)
672
+ {
673
+ button.setImage(symbolImage, for: .normal)
674
+ button.setTitle(nil, for: .normal)
675
+ button.setPreferredSymbolConfiguration(
676
+ UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold),
677
+ forImageIn: .normal
678
+ )
679
+ } else {
680
+ button.setImage(nil, for: .normal)
681
+ button.setTitle(item.icon?.resolvedGlyphText() ?? "?", for: .normal)
682
+ }
683
+ button.widthAnchor.constraint(greaterThanOrEqualToConstant: 36).isActive = true
684
+ button.heightAnchor.constraint(equalToConstant: 36).isActive = true
685
+ button.addAction(UIAction { [weak self] _ in
686
+ self?.onPressItem?(item)
687
+ }, for: .touchUpInside)
688
+ updateButtonAppearance(button, enabled: true, active: false)
689
+ return button
690
+ }
691
+
692
+ private func makeSeparator() -> UIView {
693
+ let separator = UIView()
694
+ separator.translatesAutoresizingMaskIntoConstraints = false
695
+ separator.backgroundColor = .separator
696
+ separator.widthAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
697
+ separator.heightAnchor.constraint(equalToConstant: 22).isActive = true
698
+ separators.append(separator)
699
+ return separator
700
+ }
701
+
702
+ private func buttonState(
703
+ for item: NativeToolbarItem,
704
+ state: NativeToolbarState
705
+ ) -> (enabled: Bool, active: Bool) {
706
+ let isInList = state.nodes["bulletList"] == true || state.nodes["orderedList"] == true
707
+
708
+ switch item.type {
709
+ case .mark:
710
+ let mark = item.mark ?? ""
711
+ return (
712
+ enabled: state.allowedMarks.contains(mark),
713
+ active: state.marks[mark] == true
714
+ )
715
+ case .list:
716
+ switch item.listType {
717
+ case .bulletList:
718
+ return (
719
+ enabled: state.commands["wrapBulletList"] == true,
720
+ active: state.nodes["bulletList"] == true
721
+ )
722
+ case .orderedList:
723
+ return (
724
+ enabled: state.commands["wrapOrderedList"] == true,
725
+ active: state.nodes["orderedList"] == true
726
+ )
727
+ case .none:
728
+ return (enabled: false, active: false)
729
+ }
730
+ case .command:
731
+ switch item.command {
732
+ case .indentList:
733
+ return (
734
+ enabled: isInList && state.commands["indentList"] == true,
735
+ active: false
736
+ )
737
+ case .outdentList:
738
+ return (
739
+ enabled: isInList && state.commands["outdentList"] == true,
740
+ active: false
741
+ )
742
+ case .undo:
743
+ return (enabled: state.canUndo, active: false)
744
+ case .redo:
745
+ return (enabled: state.canRedo, active: false)
746
+ case .none:
747
+ return (enabled: false, active: false)
748
+ }
749
+ case .node:
750
+ let nodeType = item.nodeType ?? ""
751
+ return (
752
+ enabled: state.insertableNodes.contains(nodeType),
753
+ active: state.nodes[nodeType] == true
754
+ )
755
+ case .action:
756
+ return (
757
+ enabled: !item.isDisabled,
758
+ active: item.isActive
759
+ )
760
+ case .separator:
761
+ return (enabled: false, active: false)
762
+ }
763
+ }
764
+
765
+ private func updateButtonAppearance(_ button: UIButton, enabled: Bool, active: Bool) {
766
+ let tintColor: UIColor
767
+ if !enabled {
768
+ tintColor = theme?.buttonDisabledColor ?? .tertiaryLabel
769
+ } else if active {
770
+ tintColor = theme?.buttonActiveColor ?? .systemBlue
771
+ } else {
772
+ tintColor = theme?.buttonColor ?? .secondaryLabel
773
+ }
774
+
775
+ button.tintColor = tintColor
776
+ button.setTitleColor(tintColor, for: .normal)
777
+ button.backgroundColor = active
778
+ ? (theme?.buttonActiveBackgroundColor ?? UIColor.systemBlue.withAlphaComponent(0.12))
779
+ : .clear
780
+ }
781
+
782
+ @objc private func handleSelectMentionSuggestion(_ sender: MentionSuggestionChipButton) {
783
+ onSelectMentionSuggestion?(sender.suggestion)
784
+ }
785
+
786
+ func triggerMentionSuggestionTapForTesting(at index: Int) {
787
+ guard mentionButtons.indices.contains(index) else { return }
788
+ onSelectMentionSuggestion?(mentionButtons[index].suggestion)
789
+ }
790
+ }
791
+
792
+ class NativeEditorExpoView: ExpoView, EditorTextViewDelegate, UIGestureRecognizerDelegate {
793
+
794
+ private static let updateLog = Logger(
795
+ subsystem: "com.apollohg.prose-editor",
796
+ category: "view-command"
797
+ )
798
+
799
+ // MARK: - Subviews
800
+
801
+ let richTextView: RichTextEditorView
802
+ private let accessoryToolbar = EditorAccessoryToolbarView()
803
+ private var toolbarFrameInWindow: CGRect?
804
+ private var didApplyAutoFocus = false
805
+ private var toolbarState = NativeToolbarState.empty
806
+ private var showsToolbar = true
807
+ private var toolbarPlacement = "keyboard"
808
+ private var heightBehavior: EditorHeightBehavior = .fixed
809
+ private var lastAutoGrowWidth: CGFloat = 0
810
+ private var addons = NativeEditorAddons(mentions: nil)
811
+ private var mentionQueryState: MentionQueryState?
812
+ private var lastMentionEventJSON: String?
813
+ private var pendingEditorUpdateJSON: String?
814
+ private var pendingEditorUpdateRevision = 0
815
+ private var appliedEditorUpdateRevision = 0
816
+ private lazy var outsideTapGestureRecognizer: UITapGestureRecognizer = {
817
+ let recognizer = UITapGestureRecognizer(
818
+ target: self,
819
+ action: #selector(handleOutsideTap(_:))
820
+ )
821
+ recognizer.cancelsTouchesInView = false
822
+ recognizer.delegate = self
823
+ return recognizer
824
+ }()
825
+ private weak var gestureWindow: UIWindow?
826
+
827
+ /// Guard flag to suppress echo: when JS applies an update via the view
828
+ /// command, the resulting delegate callback must NOT be re-dispatched
829
+ /// back to JS.
830
+ var isApplyingJSUpdate = false
831
+
832
+ // MARK: - Event Dispatchers (wired by Expo Modules via reflection)
833
+
834
+ let onEditorUpdate = EventDispatcher()
835
+ let onSelectionChange = EventDispatcher()
836
+ let onFocusChange = EventDispatcher()
837
+ let onContentHeightChange = EventDispatcher()
838
+ let onToolbarAction = EventDispatcher()
839
+ let onAddonEvent = EventDispatcher()
840
+ private var lastEmittedContentHeight: CGFloat = 0
841
+
842
+ // MARK: - Initialization
843
+
844
+ required init(appContext: AppContext? = nil) {
845
+ richTextView = RichTextEditorView(frame: .zero)
846
+ super.init(appContext: appContext)
847
+ richTextView.onHeightMayChange = { [weak self] in
848
+ guard let self, self.heightBehavior == .autoGrow else { return }
849
+ self.invalidateIntrinsicContentSize()
850
+ self.superview?.setNeedsLayout()
851
+ self.emitContentHeightIfNeeded(force: true)
852
+ }
853
+ richTextView.textView.editorDelegate = self
854
+ configureAccessoryToolbar()
855
+
856
+ // Observe UITextView focus changes via NotificationCenter.
857
+ NotificationCenter.default.addObserver(
858
+ self,
859
+ selector: #selector(textViewDidBeginEditing(_:)),
860
+ name: UITextView.textDidBeginEditingNotification,
861
+ object: richTextView.textView
862
+ )
863
+ NotificationCenter.default.addObserver(
864
+ self,
865
+ selector: #selector(textViewDidEndEditing(_:)),
866
+ name: UITextView.textDidEndEditingNotification,
867
+ object: richTextView.textView
868
+ )
869
+
870
+ addSubview(richTextView)
871
+ }
872
+
873
+ deinit {
874
+ NotificationCenter.default.removeObserver(self)
875
+ }
876
+
877
+ // MARK: - Layout
878
+
879
+ override var intrinsicContentSize: CGSize {
880
+ guard heightBehavior == .autoGrow else {
881
+ return CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
882
+ }
883
+ return richTextView.intrinsicContentSize
884
+ }
885
+
886
+ override func layoutSubviews() {
887
+ super.layoutSubviews()
888
+ richTextView.frame = bounds
889
+ guard heightBehavior == .autoGrow else { return }
890
+ let currentWidth = bounds.width.rounded(.towardZero)
891
+ guard currentWidth != lastAutoGrowWidth else { return }
892
+ lastAutoGrowWidth = currentWidth
893
+ invalidateIntrinsicContentSize()
894
+ emitContentHeightIfNeeded(force: true)
895
+ }
896
+
897
+ override func didMoveToWindow() {
898
+ super.didMoveToWindow()
899
+ if richTextView.textView.isFirstResponder {
900
+ installOutsideTapRecognizerIfNeeded()
901
+ } else {
902
+ uninstallOutsideTapRecognizer()
903
+ }
904
+ }
905
+
906
+ // MARK: - Editor Binding
907
+
908
+ func setEditorId(_ id: UInt64) {
909
+ richTextView.editorId = id
910
+ if id != 0 {
911
+ let stateJSON = editorGetCurrentState(id: id)
912
+ if let state = NativeToolbarState(updateJSON: stateJSON) {
913
+ toolbarState = state
914
+ accessoryToolbar.apply(state: state)
915
+ } else {
916
+ toolbarState = .empty
917
+ accessoryToolbar.apply(state: .empty)
918
+ }
919
+ } else {
920
+ toolbarState = .empty
921
+ accessoryToolbar.apply(state: .empty)
922
+ }
923
+ refreshMentionQuery()
924
+ }
925
+
926
+ func setThemeJson(_ themeJson: String?) {
927
+ let theme = EditorTheme.from(json: themeJson)
928
+ richTextView.applyTheme(theme)
929
+ accessoryToolbar.apply(theme: theme?.toolbar)
930
+ accessoryToolbar.apply(mentionTheme: theme?.mentions ?? addons.mentions?.theme)
931
+ if richTextView.textView.isFirstResponder,
932
+ richTextView.textView.inputAccessoryView === accessoryToolbar
933
+ {
934
+ richTextView.textView.reloadInputViews()
935
+ }
936
+ }
937
+
938
+ func setAddonsJson(_ addonsJson: String?) {
939
+ addons = NativeEditorAddons.from(json: addonsJson)
940
+ accessoryToolbar.apply(mentionTheme: richTextView.textView.theme?.mentions ?? addons.mentions?.theme)
941
+ refreshMentionQuery()
942
+ }
943
+
944
+ func setEditable(_ editable: Bool) {
945
+ richTextView.textView.isEditable = editable
946
+ updateAccessoryToolbarVisibility()
947
+ }
948
+
949
+ func setAutoFocus(_ autoFocus: Bool) {
950
+ guard autoFocus, !didApplyAutoFocus else { return }
951
+ didApplyAutoFocus = true
952
+ focus()
953
+ }
954
+
955
+ func setShowToolbar(_ showToolbar: Bool) {
956
+ showsToolbar = showToolbar
957
+ updateAccessoryToolbarVisibility()
958
+ }
959
+
960
+ func setToolbarPlacement(_ toolbarPlacement: String?) {
961
+ self.toolbarPlacement = toolbarPlacement == "inline" ? "inline" : "keyboard"
962
+ updateAccessoryToolbarVisibility()
963
+ }
964
+
965
+ func setHeightBehavior(_ rawHeightBehavior: String) {
966
+ let nextBehavior = EditorHeightBehavior(rawValue: rawHeightBehavior) ?? .fixed
967
+ guard nextBehavior != heightBehavior else { return }
968
+ heightBehavior = nextBehavior
969
+ richTextView.heightBehavior = nextBehavior
970
+ invalidateIntrinsicContentSize()
971
+ setNeedsLayout()
972
+ if nextBehavior == .autoGrow {
973
+ emitContentHeightIfNeeded(force: true)
974
+ }
975
+ }
976
+
977
+ private func emitContentHeightIfNeeded(force: Bool = false) {
978
+ guard heightBehavior == .autoGrow else { return }
979
+ let contentHeight = ceil(richTextView.intrinsicContentSize.height)
980
+ guard contentHeight > 0 else { return }
981
+ guard force || abs(contentHeight - lastEmittedContentHeight) > 0.5 else { return }
982
+ lastEmittedContentHeight = contentHeight
983
+ onContentHeightChange(["contentHeight": contentHeight])
984
+ }
985
+
986
+ func setToolbarButtonsJson(_ toolbarButtonsJson: String?) {
987
+ accessoryToolbar.setItems(NativeToolbarItem.from(json: toolbarButtonsJson))
988
+ }
989
+
990
+ func setToolbarFrameJson(_ toolbarFrameJson: String?) {
991
+ guard let toolbarFrameJson,
992
+ let data = toolbarFrameJson.data(using: .utf8),
993
+ let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
994
+ let x = (raw["x"] as? NSNumber)?.doubleValue,
995
+ let y = (raw["y"] as? NSNumber)?.doubleValue,
996
+ let width = (raw["width"] as? NSNumber)?.doubleValue,
997
+ let height = (raw["height"] as? NSNumber)?.doubleValue
998
+ else {
999
+ toolbarFrameInWindow = nil
1000
+ return
1001
+ }
1002
+
1003
+ toolbarFrameInWindow = CGRect(x: x, y: y, width: width, height: height)
1004
+ }
1005
+
1006
+ func setPendingEditorUpdateJson(_ editorUpdateJson: String?) {
1007
+ pendingEditorUpdateJSON = editorUpdateJson
1008
+ }
1009
+
1010
+ func setPendingEditorUpdateRevision(_ editorUpdateRevision: Int) {
1011
+ pendingEditorUpdateRevision = editorUpdateRevision
1012
+ }
1013
+
1014
+ func applyPendingEditorUpdateIfNeeded() {
1015
+ guard pendingEditorUpdateRevision != 0 else { return }
1016
+ guard pendingEditorUpdateRevision != appliedEditorUpdateRevision else { return }
1017
+ guard let updateJSON = pendingEditorUpdateJSON else { return }
1018
+ appliedEditorUpdateRevision = pendingEditorUpdateRevision
1019
+ applyEditorUpdate(updateJSON)
1020
+ }
1021
+
1022
+ // MARK: - View Commands
1023
+
1024
+ /// Apply an editor update from JS. Sets the echo-suppression flag so the
1025
+ /// resulting delegate callback is NOT re-dispatched back to JS.
1026
+ func applyEditorUpdate(_ updateJson: String) {
1027
+ Self.updateLog.debug("[applyEditorUpdate.begin] bytes=\(updateJson.utf8.count)")
1028
+ isApplyingJSUpdate = true
1029
+ richTextView.textView.applyUpdateJSON(updateJson)
1030
+ isApplyingJSUpdate = false
1031
+ Self.updateLog.debug(
1032
+ "[applyEditorUpdate.end] textState=\(self.richTextView.textView.textStorage.string.count)"
1033
+ )
1034
+ }
1035
+
1036
+ // MARK: - Focus Commands
1037
+
1038
+ func focus() {
1039
+ richTextView.textView.becomeFirstResponder()
1040
+ }
1041
+
1042
+ func blur() {
1043
+ richTextView.textView.resignFirstResponder()
1044
+ }
1045
+
1046
+ // MARK: - Focus Notifications
1047
+
1048
+ @objc private func textViewDidBeginEditing(_ notification: Notification) {
1049
+ installOutsideTapRecognizerIfNeeded()
1050
+ refreshMentionQuery()
1051
+ onFocusChange(["isFocused": true])
1052
+ }
1053
+
1054
+ @objc private func textViewDidEndEditing(_ notification: Notification) {
1055
+ uninstallOutsideTapRecognizer()
1056
+ clearMentionQueryStateAndHidePopover()
1057
+ onFocusChange(["isFocused": false])
1058
+ }
1059
+
1060
+ @objc private func handleOutsideTap(_ recognizer: UITapGestureRecognizer) {
1061
+ guard recognizer.state == .ended else { return }
1062
+ guard richTextView.textView.isFirstResponder else { return }
1063
+ blur()
1064
+ }
1065
+
1066
+ private func installOutsideTapRecognizerIfNeeded() {
1067
+ guard let window else { return }
1068
+ if gestureWindow === window, window.gestureRecognizers?.contains(outsideTapGestureRecognizer) == true {
1069
+ return
1070
+ }
1071
+ uninstallOutsideTapRecognizer()
1072
+ window.addGestureRecognizer(outsideTapGestureRecognizer)
1073
+ gestureWindow = window
1074
+ }
1075
+
1076
+ private func uninstallOutsideTapRecognizer() {
1077
+ if let window = gestureWindow {
1078
+ window.removeGestureRecognizer(outsideTapGestureRecognizer)
1079
+ }
1080
+ gestureWindow = nil
1081
+ }
1082
+
1083
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
1084
+ guard gestureRecognizer === outsideTapGestureRecognizer else { return true }
1085
+ if let touchedView = touch.view, touchedView.isDescendant(of: self) {
1086
+ return false
1087
+ }
1088
+ if let touchedView = touch.view, touchedView.isDescendant(of: accessoryToolbar) {
1089
+ return false
1090
+ }
1091
+ if let toolbarFrameInWindow,
1092
+ let window = gestureWindow,
1093
+ toolbarFrameInWindow.contains(touch.location(in: window))
1094
+ {
1095
+ return false
1096
+ }
1097
+ return true
1098
+ }
1099
+
1100
+ // MARK: - EditorTextViewDelegate
1101
+
1102
+ func editorTextView(_ textView: EditorTextView, selectionDidChange anchor: UInt32, head: UInt32) {
1103
+ refreshToolbarStateFromEditorSelection()
1104
+ refreshMentionQuery()
1105
+ onSelectionChange(["anchor": Int(anchor), "head": Int(head)])
1106
+ }
1107
+
1108
+ func editorTextView(_ textView: EditorTextView, didReceiveUpdate updateJSON: String) {
1109
+ if let state = NativeToolbarState(updateJSON: updateJSON) {
1110
+ toolbarState = state
1111
+ accessoryToolbar.apply(state: state)
1112
+ }
1113
+ refreshMentionQuery()
1114
+ guard !isApplyingJSUpdate else { return }
1115
+ Self.updateLog.debug("[didReceiveUpdate] bytes=\(updateJSON.utf8.count)")
1116
+ onEditorUpdate(["updateJson": updateJSON])
1117
+ }
1118
+
1119
+ private func refreshToolbarStateFromEditorSelection() {
1120
+ guard richTextView.editorId != 0 else { return }
1121
+ let stateJSON = editorGetCurrentState(id: richTextView.editorId)
1122
+ guard let state = NativeToolbarState(updateJSON: stateJSON) else { return }
1123
+ toolbarState = state
1124
+ accessoryToolbar.apply(state: state)
1125
+ }
1126
+
1127
+ private func configureAccessoryToolbar() {
1128
+ accessoryToolbar.onPressItem = { [weak self] item in
1129
+ self?.handleToolbarItemPress(item)
1130
+ }
1131
+ accessoryToolbar.onSelectMentionSuggestion = { [weak self] suggestion in
1132
+ self?.insertMentionSuggestion(suggestion)
1133
+ }
1134
+ accessoryToolbar.apply(state: toolbarState)
1135
+ updateAccessoryToolbarVisibility()
1136
+ }
1137
+
1138
+ private func refreshMentionQuery() {
1139
+ guard richTextView.editorId != 0,
1140
+ richTextView.textView.isFirstResponder,
1141
+ let mentions = addons.mentions
1142
+ else {
1143
+ clearMentionQueryStateAndHidePopover()
1144
+ return
1145
+ }
1146
+
1147
+ guard let queryState = currentMentionQueryState(trigger: mentions.trigger) else {
1148
+ emitMentionQueryChange(query: "", trigger: mentions.trigger, anchor: 0, head: 0, isActive: false)
1149
+ clearMentionQueryStateAndHidePopover()
1150
+ return
1151
+ }
1152
+
1153
+ let suggestions = filteredMentionSuggestions(for: queryState, config: mentions)
1154
+ mentionQueryState = queryState
1155
+ accessoryToolbar.apply(mentionTheme: richTextView.textView.theme?.mentions ?? mentions.theme)
1156
+ let didChangeToolbarHeight = accessoryToolbar.setMentionSuggestions(suggestions)
1157
+ if didChangeToolbarHeight,
1158
+ richTextView.textView.isFirstResponder,
1159
+ richTextView.textView.inputAccessoryView === accessoryToolbar
1160
+ {
1161
+ richTextView.textView.reloadInputViews()
1162
+ }
1163
+ emitMentionQueryChange(
1164
+ query: queryState.query,
1165
+ trigger: queryState.trigger,
1166
+ anchor: queryState.anchor,
1167
+ head: queryState.head,
1168
+ isActive: true
1169
+ )
1170
+ }
1171
+
1172
+ private func clearMentionQueryStateAndHidePopover() {
1173
+ mentionQueryState = nil
1174
+ let didChangeToolbarHeight = accessoryToolbar.setMentionSuggestions([])
1175
+ if didChangeToolbarHeight,
1176
+ richTextView.textView.isFirstResponder,
1177
+ richTextView.textView.inputAccessoryView === accessoryToolbar
1178
+ {
1179
+ richTextView.textView.reloadInputViews()
1180
+ }
1181
+ }
1182
+
1183
+ private func emitMentionQueryChange(
1184
+ query: String,
1185
+ trigger: String,
1186
+ anchor: UInt32,
1187
+ head: UInt32,
1188
+ isActive: Bool
1189
+ ) {
1190
+ let payload: [String: Any] = [
1191
+ "type": "mentionsQueryChange",
1192
+ "query": query,
1193
+ "trigger": trigger,
1194
+ "range": [
1195
+ "anchor": Int(anchor),
1196
+ "head": Int(head),
1197
+ ],
1198
+ "isActive": isActive,
1199
+ ]
1200
+ guard let data = try? JSONSerialization.data(withJSONObject: payload),
1201
+ let json = String(data: data, encoding: .utf8)
1202
+ else {
1203
+ return
1204
+ }
1205
+ guard json != lastMentionEventJSON else { return }
1206
+ lastMentionEventJSON = json
1207
+ onAddonEvent(["eventJson": json])
1208
+ }
1209
+
1210
+ private func emitMentionSelect(trigger: String, suggestion: NativeMentionSuggestion) {
1211
+ let payload: [String: Any] = [
1212
+ "type": "mentionsSelect",
1213
+ "trigger": trigger,
1214
+ "suggestionKey": suggestion.key,
1215
+ "attrs": suggestion.attrs,
1216
+ ]
1217
+ guard let data = try? JSONSerialization.data(withJSONObject: payload),
1218
+ let json = String(data: data, encoding: .utf8)
1219
+ else {
1220
+ return
1221
+ }
1222
+ onAddonEvent(["eventJson": json])
1223
+ }
1224
+
1225
+ private func filteredMentionSuggestions(
1226
+ for queryState: MentionQueryState,
1227
+ config: NativeMentionsAddonConfig
1228
+ ) -> [NativeMentionSuggestion] {
1229
+ let query = queryState.query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
1230
+ guard !query.isEmpty else {
1231
+ return config.suggestions
1232
+ }
1233
+
1234
+ return config.suggestions.filter { suggestion in
1235
+ suggestion.title.lowercased().contains(query)
1236
+ || suggestion.label.lowercased().contains(query)
1237
+ || (suggestion.subtitle?.lowercased().contains(query) ?? false)
1238
+ }
1239
+ }
1240
+
1241
+ private func currentMentionQueryState(trigger: String) -> MentionQueryState? {
1242
+ guard let selectedTextRange = richTextView.textView.selectedTextRange,
1243
+ selectedTextRange.isEmpty
1244
+ else {
1245
+ return nil
1246
+ }
1247
+
1248
+ let currentText = richTextView.textView.text ?? ""
1249
+ let cursorUtf16Offset = richTextView.textView.offset(
1250
+ from: richTextView.textView.beginningOfDocument,
1251
+ to: selectedTextRange.start
1252
+ )
1253
+ let visibleCursorScalar = PositionBridge.utf16OffsetToScalar(
1254
+ cursorUtf16Offset,
1255
+ in: currentText
1256
+ )
1257
+
1258
+ guard let visibleQueryState = resolveMentionQueryState(
1259
+ in: currentText,
1260
+ cursorScalar: visibleCursorScalar,
1261
+ trigger: trigger,
1262
+ isCaretInsideMention: isCaretInsideMention(
1263
+ cursorScalar: PositionBridge.textViewToScalar(
1264
+ selectedTextRange.start,
1265
+ in: richTextView.textView
1266
+ )
1267
+ )
1268
+ ) else {
1269
+ return nil
1270
+ }
1271
+
1272
+ let anchorUtf16Offset = PositionBridge.scalarToUtf16Offset(
1273
+ visibleQueryState.anchor,
1274
+ in: currentText
1275
+ )
1276
+ let headUtf16Offset = PositionBridge.scalarToUtf16Offset(
1277
+ visibleQueryState.head,
1278
+ in: currentText
1279
+ )
1280
+
1281
+ return MentionQueryState(
1282
+ query: visibleQueryState.query,
1283
+ trigger: visibleQueryState.trigger,
1284
+ anchor: PositionBridge.utf16OffsetToScalar(
1285
+ anchorUtf16Offset,
1286
+ in: richTextView.textView
1287
+ ),
1288
+ head: PositionBridge.utf16OffsetToScalar(
1289
+ headUtf16Offset,
1290
+ in: richTextView.textView
1291
+ )
1292
+ )
1293
+ }
1294
+
1295
+ private func isCaretInsideMention(cursorScalar: UInt32) -> Bool {
1296
+ let utf16Offset = PositionBridge.scalarToUtf16Offset(
1297
+ cursorScalar,
1298
+ in: richTextView.textView.text ?? ""
1299
+ )
1300
+ let textStorage = richTextView.textView.textStorage
1301
+ guard textStorage.length > 0 else { return false }
1302
+ let candidateOffsets = [
1303
+ min(max(utf16Offset, 0), max(textStorage.length - 1, 0)),
1304
+ min(max(utf16Offset - 1, 0), max(textStorage.length - 1, 0)),
1305
+ ]
1306
+
1307
+ for offset in candidateOffsets where offset >= 0 && offset < textStorage.length {
1308
+ if let nodeType = textStorage.attribute(RenderBridgeAttributes.voidNodeType, at: offset, effectiveRange: nil) as? String,
1309
+ nodeType == "mention" {
1310
+ return true
1311
+ }
1312
+ }
1313
+ return false
1314
+ }
1315
+
1316
+ private func insertMentionSuggestion(_ suggestion: NativeMentionSuggestion) {
1317
+ guard let mentions = addons.mentions,
1318
+ let queryState = mentionQueryState
1319
+ else {
1320
+ return
1321
+ }
1322
+
1323
+ var attrs = suggestion.attrs
1324
+ if attrs["label"] == nil {
1325
+ attrs["label"] = suggestion.label
1326
+ }
1327
+ let payload: [String: Any] = [
1328
+ "type": "doc",
1329
+ "content": [[
1330
+ "type": "mention",
1331
+ "attrs": attrs,
1332
+ ]],
1333
+ ]
1334
+ guard let data = try? JSONSerialization.data(withJSONObject: payload),
1335
+ let json = String(data: data, encoding: .utf8)
1336
+ else {
1337
+ return
1338
+ }
1339
+
1340
+ let updateJSON = editorInsertContentJsonAtSelectionScalar(
1341
+ id: richTextView.editorId,
1342
+ scalarAnchor: queryState.anchor,
1343
+ scalarHead: queryState.head,
1344
+ json: json
1345
+ )
1346
+ richTextView.textView.applyUpdateJSON(updateJSON)
1347
+ emitMentionSelect(trigger: mentions.trigger, suggestion: suggestion)
1348
+ lastMentionEventJSON = nil
1349
+ clearMentionQueryStateAndHidePopover()
1350
+ }
1351
+
1352
+ func setMentionQueryStateForTesting(_ state: MentionQueryState?) {
1353
+ mentionQueryState = state
1354
+ }
1355
+
1356
+ func currentMentionQueryStateForTesting(trigger: String) -> MentionQueryState? {
1357
+ currentMentionQueryState(trigger: trigger)
1358
+ }
1359
+
1360
+ func setMentionSuggestionsForTesting(_ suggestions: [NativeMentionSuggestion]) {
1361
+ accessoryToolbar.setMentionSuggestions(suggestions)
1362
+ }
1363
+
1364
+ func triggerMentionSuggestionTapForTesting(at index: Int) {
1365
+ accessoryToolbar.triggerMentionSuggestionTapForTesting(at: index)
1366
+ }
1367
+ private func updateAccessoryToolbarVisibility() {
1368
+ let nextAccessoryView: UIView? = showsToolbar &&
1369
+ toolbarPlacement == "keyboard" &&
1370
+ richTextView.textView.isEditable
1371
+ ? accessoryToolbar
1372
+ : nil
1373
+ if richTextView.textView.inputAccessoryView !== nextAccessoryView {
1374
+ richTextView.textView.inputAccessoryView = nextAccessoryView
1375
+ if richTextView.textView.isFirstResponder {
1376
+ richTextView.textView.reloadInputViews()
1377
+ }
1378
+ }
1379
+ }
1380
+
1381
+ private func handleListToggle(_ listType: String) {
1382
+ let isActive = toolbarState.nodes[listType] == true
1383
+ richTextView.textView.performToolbarToggleList(listType, isActive: isActive)
1384
+ }
1385
+
1386
+ private func handleToolbarItemPress(_ item: NativeToolbarItem) {
1387
+ switch item.type {
1388
+ case .mark:
1389
+ guard let mark = item.mark else { return }
1390
+ richTextView.textView.performToolbarToggleMark(mark)
1391
+ case .list:
1392
+ guard let listType = item.listType?.rawValue else { return }
1393
+ handleListToggle(listType)
1394
+ case .command:
1395
+ switch item.command {
1396
+ case .indentList:
1397
+ richTextView.textView.performToolbarIndentListItem()
1398
+ case .outdentList:
1399
+ richTextView.textView.performToolbarOutdentListItem()
1400
+ case .undo:
1401
+ richTextView.textView.performToolbarUndo()
1402
+ case .redo:
1403
+ richTextView.textView.performToolbarRedo()
1404
+ case .none:
1405
+ break
1406
+ }
1407
+ case .node:
1408
+ guard let nodeType = item.nodeType else { return }
1409
+ richTextView.textView.performToolbarInsertNode(nodeType)
1410
+ case .action:
1411
+ guard let key = item.key else { return }
1412
+ onToolbarAction(["key": key])
1413
+ case .separator:
1414
+ break
1415
+ }
1416
+ }
1417
+ }