aboutsummaryrefslogtreecommitdiff
path: root/Sources/NorgEditor/NorgEditor.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Sources/NorgEditor/NorgEditor.swift')
-rw-r--r--Sources/NorgEditor/NorgEditor.swift255
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