From 692abd44213b32d507849cec74ec847c994cc257 Mon Sep 17 00:00:00 2001 From: Ruben Beltran del Rio Date: Thu, 18 Jun 2026 19:06:14 +0200 Subject: Add UIKit Support --- Sources/NorgKeyboardToolbar/EditorSnippet.swift | 2 +- .../NorgKeyboardAccessory.swift | 98 ++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 Sources/NorgKeyboardToolbar/NorgKeyboardAccessory.swift (limited to 'Sources') diff --git a/Sources/NorgKeyboardToolbar/EditorSnippet.swift b/Sources/NorgKeyboardToolbar/EditorSnippet.swift index 4773eac..fe0a469 100644 --- a/Sources/NorgKeyboardToolbar/EditorSnippet.swift +++ b/Sources/NorgKeyboardToolbar/EditorSnippet.swift @@ -1,7 +1,7 @@ import Foundation /// The available editor snippets to show in the toolbar. -enum EditorSnippet: String, CaseIterable, Identifiable, Sendable { +public enum EditorSnippet: String, CaseIterable, Identifiable, Sendable { case heading case task case weakReverse diff --git a/Sources/NorgKeyboardToolbar/NorgKeyboardAccessory.swift b/Sources/NorgKeyboardToolbar/NorgKeyboardAccessory.swift new file mode 100644 index 0000000..3538330 --- /dev/null +++ b/Sources/NorgKeyboardToolbar/NorgKeyboardAccessory.swift @@ -0,0 +1,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.. 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 -- cgit