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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
|
#if canImport(UIKit)
import UIKit
// MARK: - Applying snippets to a UITextView
extension EditorSnippet {
/// Applies this snippet to `textView` at its current insertion point,
/// updating the text and cursor, then notifying the text view's delegate
/// (UITextView does not report programmatic edits on its own).
@MainActor
public func apply(to textView: UITextView) {
let text = textView.text ?? ""
let result = apply(
to: text, cursor: Self.characterOffset(of: textView.selectedRange, in: text))
// Replacing `text` wholesale resets the scroll to the top, so preserve
// and restore the content offset around the edit.
let savedOffset = textView.contentOffset
textView.text = result.text
textView.layoutIfNeeded()
textView.selectedRange = Self.range(forCharacterOffset: result.cursor, in: result.text)
textView.setContentOffset(savedOffset, animated: false)
textView.delegate?.textViewDidChange?(textView)
}
/// The character (grapheme) offset of a UTF-16 `NSRange`'s start.
private static func characterOffset(of nsRange: NSRange, in text: String) -> Int {
guard let range = Range(nsRange, in: text) else { return text.count }
return text.distance(from: text.startIndex, to: range.lowerBound)
}
/// A zero-length UTF-16 `NSRange` at the given character offset.
private static func range(forCharacterOffset offset: Int, in text: String) -> NSRange {
let clamped = min(max(offset, 0), text.count)
let index = text.index(text.startIndex, offsetBy: clamped)
return NSRange(index..<index, in: text)
}
}
// MARK: - Input-accessory toolbar
/// A keyboard input-accessory toolbar with one button per ``EditorSnippet``,
/// wired to a `UITextView`. Drop it onto any UIKit text editor:
///
/// ```swift
/// textView.inputAccessoryView = NorgKeyboardAccessory(textView: textView)
/// // or:
/// textView.installNorgKeyboardAccessory()
/// ```
///
/// The toolbar holds the text view weakly, so it does not retain it.
public final class NorgKeyboardAccessory: UIToolbar {
private weak var textView: UITextView?
public init(textView: UITextView) {
self.textView = textView
super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 50))
autoresizingMask = .flexibleWidth
buildItems()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
/// Fixed glyph size so the icons stay consistent regardless of the
/// toolbar's height (a taller bar would otherwise scale them up).
private static let symbolConfiguration = UIImage.SymbolConfiguration(
pointSize: 17, weight: .regular)
private func buildItems() {
let buttons = EditorSnippet.allCases.map { snippet -> UIBarButtonItem in
let image = UIImage(
systemName: snippet.systemImage, withConfiguration: Self.symbolConfiguration)
let action = UIAction(image: image) { [weak self] _ in
guard let textView = self?.textView else { return }
snippet.apply(to: textView)
}
let item = UIBarButtonItem(primaryAction: action)
item.accessibilityLabel = snippet.label
return item
}
// Center the group of buttons with flexible space on both sides.
setItems([.flexibleSpace()] + buttons + [.flexibleSpace()], animated: false)
}
}
extension UITextView {
/// Installs a ``NorgKeyboardAccessory`` as this text view's input accessory.
public func installNorgKeyboardAccessory() {
inputAccessoryView = NorgKeyboardAccessory(textView: self)
}
}
#endif
|