aboutsummaryrefslogtreecommitdiff
path: root/Sources/NorgKeyboardToolbar/NorgKeyboardToolbar.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Sources/NorgKeyboardToolbar/NorgKeyboardToolbar.swift')
-rw-r--r--Sources/NorgKeyboardToolbar/NorgKeyboardToolbar.swift59
1 files changed, 59 insertions, 0 deletions
diff --git a/Sources/NorgKeyboardToolbar/NorgKeyboardToolbar.swift b/Sources/NorgKeyboardToolbar/NorgKeyboardToolbar.swift
new file mode 100644
index 0000000..fdd114b
--- /dev/null
+++ b/Sources/NorgKeyboardToolbar/NorgKeyboardToolbar.swift
@@ -0,0 +1,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