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