diff options
| author | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-06-19 19:16:25 +0200 |
|---|---|---|
| committer | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-06-19 22:32:15 +0200 |
| commit | 25fc48fb17b53fe1c155c4f53f673ed91fdd8f74 (patch) | |
| tree | de96add2a7ae8c5cc79a354fbffa26e80349cc15 /Sources/NorgEditor/NorgEditor.swift | |
Initial extraction from Norganize
Diffstat (limited to 'Sources/NorgEditor/NorgEditor.swift')
| -rw-r--r-- | Sources/NorgEditor/NorgEditor.swift | 255 |
1 files changed, 255 insertions, 0 deletions
diff --git a/Sources/NorgEditor/NorgEditor.swift b/Sources/NorgEditor/NorgEditor.swift new file mode 100644 index 0000000..d026daa --- /dev/null +++ b/Sources/NorgEditor/NorgEditor.swift @@ -0,0 +1,255 @@ +#if canImport(UIKit) || canImport(AppKit) + import SwiftUI + + /// A SwiftUI source editor for `.norg` documents with syntax highlighting + /// and context-aware indentation. + /// + /// ```swift + /// NorgEditor(text: $draft) + /// .norgEditorTheme(myTheme) + /// ``` + public struct NorgEditor { + + @Binding private var text: String + @Environment(\.norgEditorTheme) private var theme + + public init(text: Binding<String>) { + self._text = text + } + } +#endif + +#if canImport(UIKit) + import UIKit + import NorgKeyboardToolbar + + extension NorgEditor: UIViewRepresentable { + + public func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.delegate = context.coordinator + textView.backgroundColor = .clear + textView.textContainerInset = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0) + textView.textContainer.lineFragmentPadding = 0 + textView.alwaysBounceVertical = true + textView.contentInsetAdjustmentBehavior = .never + + textView.installNorgKeyboardAccessory() + + textView.smartQuotesType = .yes + textView.smartDashesType = .no + textView.smartInsertDeleteType = .no + textView.dataDetectorTypes = [] + textView.autocorrectionType = .default + textView.spellCheckingType = .default + textView.autocapitalizationType = .sentences + + #if os(iOS) + context.coordinator.observeKeyboard(for: textView) + #endif + textView.text = text + context.coordinator.apply(theme, to: textView) + return textView + } + + public func updateUIView(_ textView: UITextView, context: Context) { + context.coordinator.parent = self + + if textView.text != text { + textView.text = text + context.coordinator.highlight(textView) + } + } + + public func makeCoordinator() -> Coordinator { Coordinator(self) } + + public final class Coordinator: NSObject, UITextViewDelegate { + fileprivate var parent: NorgEditor + private var theme: EditorTheme = .default + private var isAutoIndenting = false + + init(_ parent: NorgEditor) { self.parent = parent } + + deinit { NotificationCenter.default.removeObserver(self) } + + // MARK: - Auto-indentation + + public func textView( + _ textView: UITextView, + shouldChangeTextIn range: NSRange, + replacementText text: String + ) -> Bool { + guard !isAutoIndenting, text == "\n" else { return true } + + let indent = NorgAutoIndent.indentation( + for: textView.text, newlineAt: range.location) + guard !indent.isEmpty else { return true } + + guard + let start = textView.position( + from: textView.beginningOfDocument, offset: range.location), + let end = textView.position(from: start, offset: range.length), + let textRange = textView.textRange(from: start, to: end) + else { return true } + + isAutoIndenting = true + textView.replace(textRange, withText: "\n" + indent) + isAutoIndenting = false + return false + } + + // MARK: - Highlighting + + func apply(_ theme: EditorTheme, to textView: UITextView) { + self.theme = theme + textView.font = theme.font + textView.typingAttributes = [ + .font: theme.font, + .foregroundColor: theme.textColor, + ] + highlight(textView) + } + + func highlight(_ textView: UITextView) { + guard textView.markedTextRange == nil else { return } + let storage = textView.textStorage + let selection = textView.selectedRange + storage.beginEditing() + theme.applyHighlighting(to: storage) + storage.endEditing() + textView.selectedRange = selection + } + + public func textViewDidChange(_ textView: UITextView) { + parent.text = textView.text + highlight(textView) + } + + // MARK: - Keyboard avoidance + + #if os(iOS) + private weak var textView: UITextView? + + func observeKeyboard(for textView: UITextView) { + self.textView = textView + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardFrameChanged(_:)), + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil + ) + } + + @objc private func keyboardFrameChanged(_ note: Notification) { + guard let textView, let window = textView.window, + let endFrame = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect + else { return } + + let keyboardInWindow = window.convert(endFrame, from: window.screen.fixedCoordinateSpace) + let textViewInWindow = textView.convert(textView.bounds, to: window) + let overlap = max(0, textViewInWindow.maxY - keyboardInWindow.minY) + + textView.contentInset.bottom = overlap + textView.verticalScrollIndicatorInsets.bottom = overlap + } + #endif + } + } +#elseif canImport(AppKit) + import AppKit + + extension NorgEditor: NSViewRepresentable { + + public func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSTextView.scrollableTextView() + scrollView.drawsBackground = false + guard let textView = scrollView.documentView as? NSTextView else { return scrollView } + + textView.delegate = context.coordinator + textView.drawsBackground = false + textView.isRichText = false + textView.allowsUndo = true + textView.textContainerInset = NSSize(width: 0, height: 8) + textView.textContainer?.lineFragmentPadding = 0 + + textView.isAutomaticQuoteSubstitutionEnabled = true + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.isAutomaticLinkDetectionEnabled = false + + textView.string = text + context.coordinator.apply(theme, to: textView) + return scrollView + } + + public func updateNSView(_ scrollView: NSScrollView, context: Context) { + context.coordinator.parent = self + guard let textView = scrollView.documentView as? NSTextView else { return } + + if textView.string != text { + textView.string = text + context.coordinator.highlight(textView) + } + } + + public func makeCoordinator() -> Coordinator { Coordinator(self) } + + // AppKit's `NSTextViewDelegate` isn't `@MainActor`-annotated (UIKit's is), + // so isolation isn't inferred for these callbacks even though the text view + // only ever calls them on the main thread; state the isolation explicitly. + @MainActor + public final class Coordinator: NSObject, NSTextViewDelegate { + fileprivate var parent: NorgEditor + private var theme: EditorTheme = .default + private var isAutoIndenting = false + + init(_ parent: NorgEditor) { self.parent = parent } + + // MARK: - Auto-indentation + + public func textView( + _ textView: NSTextView, + shouldChangeTextIn affectedCharRange: NSRange, + replacementString: String? + ) -> Bool { + guard !isAutoIndenting, replacementString == "\n" else { return true } + + let indent = NorgAutoIndent.indentation( + for: textView.string, newlineAt: affectedCharRange.location) + guard !indent.isEmpty else { return true } + + isAutoIndenting = true + textView.insertText("\n" + indent, replacementRange: affectedCharRange) + isAutoIndenting = false + return false + } + + // MARK: - Highlighting + + func apply(_ theme: EditorTheme, to textView: NSTextView) { + self.theme = theme + textView.font = theme.font + textView.typingAttributes = [ + .font: theme.font, + .foregroundColor: theme.textColor, + ] + highlight(textView) + } + + func highlight(_ textView: NSTextView) { + guard !textView.hasMarkedText(), let storage = textView.textStorage else { return } + let selection = textView.selectedRange() + storage.beginEditing() + theme.applyHighlighting(to: storage) + storage.endEditing() + textView.setSelectedRange(selection) + } + + public func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { return } + parent.text = textView.string + highlight(textView) + } + } + } +#endif |