0

I am making a small program using SwiftUI that allows users to create rich text "notes" in an NSTextView. I have enabled all of the formatting features from NSTextView, including the ability to work with images. The program is only for macOS and not for iOS/iPadOS.

The problem I am facing is that whenever the user types anything in the NSTextView, the caret moves to the end and all formatting and images disappear.

Since I am just using the standard formatting options provided by Apple, I have not subclassed NSTextStorage or anything like that. My use-case should be pretty simple.

The program is tiny so far and the entire source code is on GitHub (https://github.com/eiskalteschatten/ScratchPad), but I'll post the relevant code here.

This is my NSViewRepresentable class for the NSTextView:

import SwiftUI

struct RichTextEditor: NSViewRepresentable {
    @EnvironmentObject var noteModel: NoteModel
    
    func makeNSView(context: Context) -> NSScrollView {
        let scrollView = NSTextView.scrollableTextView()
        
        guard let textView = scrollView.documentView as? NSTextView else {
            return scrollView
        }

        textView.isRichText = true
        textView.allowsUndo = true
        textView.allowsImageEditing = true
        textView.allowsDocumentBackgroundColorChange = true
        textView.allowsCharacterPickerTouchBarItem = true
        textView.isAutomaticLinkDetectionEnabled = true
        textView.displaysLinkToolTips = true
        textView.isAutomaticDataDetectionEnabled = true
        textView.isAutomaticTextReplacementEnabled = true
        textView.isAutomaticDashSubstitutionEnabled = true
        textView.isAutomaticSpellingCorrectionEnabled = true
        textView.isAutomaticQuoteSubstitutionEnabled = true
        textView.isAutomaticTextCompletionEnabled = true
        textView.isContinuousSpellCheckingEnabled = true
        textView.usesAdaptiveColorMappingForDarkAppearance = true
        textView.usesInspectorBar = true
        textView.usesRuler = true
        textView.usesFindBar = true
        textView.usesFontPanel = true
        textView.importsGraphics = true
        
        textView.delegate = context.coordinator
        context.coordinator.textView = textView
        
        return scrollView
    }
    
    func updateNSView(_ nsView: NSScrollView, context: Context) {
        context.coordinator.textView?.textStorage?.setAttributedString(noteModel.noteContents)
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    class Coordinator: NSObject, NSTextViewDelegate {
        var parent: RichTextEditor
        var textView : NSTextView?
        
        init(_ parent: RichTextEditor) {
            self.parent = parent
        }
        
        func textDidChange(_ notification: Notification) {
            guard let _textView = notification.object as? NSTextView else {
                return
            }
            
            self.parent.noteModel.noteContents = _textView.attributedString()
        }
    }
}

On GitHub: https://github.com/eiskalteschatten/ScratchPad/blob/main/ScratchPad/Notes/RichTextEditor.swift

And this is my NoteModel class responsible for managing the NSTextView content:

import SwiftUI
import Combine

final class NoteModel: ObservableObject {
    private var switchingPages = false

    @Published var pageNumber = UserDefaults.standard.value(forKey: "pageNumber") as? Int ?? 1 {
        didSet {
            UserDefaults.standard.set(pageNumber, forKey: "pageNumber")
            switchingPages = true
            noteContents = NSAttributedString(string: "")
            openNote()
            switchingPages = false
        }
    }
    
    @Published var noteContents = NSAttributedString(string: "") {
        didSet {
            if !switchingPages {
                saveNote()
            }
        }
    }
    
    private var noteName: String {
        return "\(NoteManager.NOTE_NAME_PREFIX)\(pageNumber).rtfd"
    }
    
    init() {
        openNote()
    }
    
    private func openNote() {
        // This is necessary, but macOS seems to recover the stale bookmark automatically, so don't handle it for now
        var isStale = false
        
        guard let bookmarkData = UserDefaults.standard.object(forKey: "storageLocationBookmarkData") as? Data,
              let storageLocation = try? URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
        else {
            ErrorHandling.showErrorToUser("No storage location for your notes could be found!", informativeText: "Please try re-selecting your storage location in the settings.")
            return
        }
        
        let fullURL = storageLocation.appendingPathComponent(noteName)
        let options = [NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.rtfd]
        
        do {
            guard storageLocation.startAccessingSecurityScopedResource() else {
                ErrorHandling.showErrorToUser("ScratchPad is not allowed to access the storage location for your notes!", informativeText: "Please try re-selecting your storage location in the settings.")
                return
            }
            
            if let _ = try? fullURL.checkResourceIsReachable() {
                let attributedString = try NSAttributedString(url: fullURL, options: options, documentAttributes: nil)
                noteContents = attributedString
            }
            
            fullURL.stopAccessingSecurityScopedResource()
        } catch {
            print(error)
            ErrorHandling.showErrorToUser(error.localizedDescription)
        }
    }
    
    private func saveNote() {
        // This is necessary, but macOS seems to recover the stale bookmark automatically, so don't handle it for now
        var isStale = false
                
        guard let bookmarkData = UserDefaults.standard.object(forKey: "storageLocationBookmarkData") as? Data,
              let storageLocation = try? URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
        else {
            ErrorHandling.showErrorToUser("No storage location for your notes could be found!", informativeText: "Please try re-selecting your storage location in the settings.")
            return
        }
        
        let fullURL = storageLocation.appendingPathComponent(noteName)

        do {
            guard storageLocation.startAccessingSecurityScopedResource() else {
                ErrorHandling.showErrorToUser("ScratchPad is not allowed to access the storage location for your notes!", informativeText: "Please try re-selecting your storage location in the settings.")
                return
            }
            
            let rtdf = noteContents.rtfdFileWrapper(from: .init(location: 0, length: noteContents.length))
            try rtdf?.write(to: fullURL, options: .atomic, originalContentsURL: nil)
            fullURL.stopAccessingSecurityScopedResource()
        } catch {
            print(error)
            ErrorHandling.showErrorToUser(error.localizedDescription)
        }
    }
}

On GitHub: https://github.com/eiskalteschatten/ScratchPad/blob/main/ScratchPad/Notes/NoteModel.swift

Does anyone have any idea why this is happening and/or how to fix it?

I have found these similar issues, but they don't really help me much:

Edit: I forgot to mention that I'm using macOS Ventura, but am targeting 12.0 or higher.

Edit #2: I have significantly updated the question to reflect what I've found through more debugging.

Alex Seifert
  • 113
  • 3
  • 14

0 Answers0