@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.
- package/LICENSE +160 -0
- package/README.md +180 -0
- package/android/build.gradle +69 -0
- package/android/consumer-rules.pro +18 -0
- package/android/src/androidTest/AndroidManifest.xml +8 -0
- package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +53 -0
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +241 -0
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +338 -0
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +789 -0
- package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +350 -0
- package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +5 -0
- package/android/src/main/assets/editor-icons/MaterialIcons.json +2236 -0
- package/android/src/main/assets/editor-icons/MaterialIcons.ttf +0 -0
- package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +50 -0
- package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +135 -0
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +4527 -0
- package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
- package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +999 -0
- package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +474 -0
- package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +3137 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +561 -0
- package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +315 -0
- package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +1413 -0
- package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +133 -0
- package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +315 -0
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +2172 -0
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +399 -0
- package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +482 -0
- package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +137 -0
- package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +321 -0
- package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +4202 -0
- package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +2581 -0
- package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +27 -0
- package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +197 -0
- package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +84 -0
- package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +544 -0
- package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +461 -0
- package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +2054 -0
- package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +1367 -0
- package/app.plugin.js +62 -0
- package/dist/EditorTheme.d.ts +113 -0
- package/dist/EditorTheme.js +29 -0
- package/dist/EditorToolbar.d.ts +191 -0
- package/dist/EditorToolbar.js +1095 -0
- package/dist/NativeEditorBridge.d.ts +325 -0
- package/dist/NativeEditorBridge.js +933 -0
- package/dist/NativeProseViewer.d.ts +53 -0
- package/dist/NativeProseViewer.js +480 -0
- package/dist/NativeRichTextEditor.d.ts +183 -0
- package/dist/NativeRichTextEditor.js +2574 -0
- package/dist/YjsCollaboration.d.ts +90 -0
- package/dist/YjsCollaboration.js +743 -0
- package/dist/addons.d.ts +100 -0
- package/dist/addons.js +82 -0
- package/dist/heightCache.d.ts +6 -0
- package/dist/heightCache.js +45 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +30 -0
- package/dist/schemas.d.ts +37 -0
- package/dist/schemas.js +263 -0
- package/dist/useNativeEditor.d.ts +42 -0
- package/dist/useNativeEditor.js +124 -0
- package/expo-module.config.json +9 -0
- package/ios/EditorAddons.swift +327 -0
- package/ios/EditorCore.xcframework/Info.plist +44 -0
- package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
- package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
- package/ios/EditorLayoutManager.swift +774 -0
- package/ios/EditorTheme.swift +515 -0
- package/ios/Generated_editor_core.swift +1513 -0
- package/ios/NativeEditorExpoView.swift +3593 -0
- package/ios/NativeEditorModule.swift +624 -0
- package/ios/NativeProseViewerExpoView.swift +276 -0
- package/ios/PositionBridge.swift +558 -0
- package/ios/ReactNativeProseEditor.podspec +49 -0
- package/ios/RenderBridge.swift +1708 -0
- package/ios/RichTextEditorView.swift +6150 -0
- package/ios/Tests/NativePerformanceTests.swift +687 -0
- package/ios/Tests/PositionBridgeTests.swift +706 -0
- package/ios/Tests/RenderBridgeTests.swift +2236 -0
- package/ios/Tests/RichTextEditorViewTests.swift +6548 -0
- package/ios/editor_coreFFI/editor_coreFFI.h +1289 -0
- package/ios/editor_coreFFI/module.modulemap +7 -0
- package/ios/editor_coreFFI.h +904 -0
- package/ios/editor_coreFFI.modulemap +7 -0
- package/package.json +74 -0
- package/rust/android/arm64-v8a/libeditor_core.so +0 -0
- package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
- package/rust/android/x86_64/libeditor_core.so +0 -0
- 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
|
+
}
|