#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, selection: Binding) { 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