#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) { 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