1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
|
#if canImport(SwiftUI)
import SwiftUI
/// `ToolbarContent` that provides one button per `EditorSnippet`.
///
/// It takes the same `text` and `selection` bindings used with `TextEditor`.
///
/// ```swift
/// TextEditor(text: $draft, selection: $selection)
/// .toolbar { NorgKeyboardToolbar(text: $draft, selection: $selection) }
/// ```
///
/// Tapping a button applies the corresponding snippet to `text` and moves the
/// insertion point to the snippet's cursor offset.
@available(iOS 18.0, macOS 15.0, visionOS 2.0, *)
public struct NorgKeyboardToolbar: ToolbarContent {
@Binding private var text: String
@Binding private var selection: TextSelection?
public init(text: Binding<String>, selection: Binding<TextSelection?>) {
self._text = text
self._selection = selection
}
public var body: some ToolbarContent {
ToolbarItemGroup(placement: .keyboard) {
ForEach(EditorSnippet.allCases) { snippet in
Button(snippet.label, systemImage: snippet.systemImage) { insert(snippet) }
.labelStyle(.iconOnly)
}
}
}
private func insert(_ snippet: EditorSnippet) {
let result = snippet.apply(to: text, cursor: cursorOffset())
text = result.text
// Apply the new cursor after the text change commits, otherwise the
// editor resets the insertion point to the end.
DispatchQueue.main.async {
let clamped = min(max(result.cursor, 0), text.count)
selection = TextSelection(insertionPoint: text.index(text.startIndex, offsetBy: clamped))
}
}
private func cursorOffset() -> Int {
guard let selection else { return text.count }
switch selection.indices {
case .selection(let range):
return text.distance(from: text.startIndex, to: range.lowerBound)
case .multiSelection(let rangeSet):
guard let first = rangeSet.ranges.first else { return text.count }
return text.distance(from: text.startIndex, to: first.lowerBound)
@unknown default:
return text.count
}
}
}
#endif
|