@Mr. Jefferson , did this ever get solved for you? Did you find your own solution or find one elsewhere?
@Frameworks Engineer, do you have a follow-up to OP's reply to your comment?
I ask because I'm having the exact same issue. I'm coding a word processing app with custom behavior that requires running checks before deleting, and then changing attributes around/near the deletion point after deleting. (I have some other custom behaviors that are affected by this same issue but for simplicity will just refer to the deletion one.)
I want to be able to use default NSTextView
behaviors that already come with "free" undo/redo (e.g., typing, deleteBackward(:)
, insertNewline(:)
, but then want to modify attributes for a certain range of the textStorage
afterward. I register my attribute modifications with the undoManager
. Like OP, I can get undo/redo to work flawlessly but only when using only NSTextView
default behavior OR when using only my modifications/undos to the NSTextStorage
. The second I start mixing both, things get ugly. My code looks like this (note: this is for macOS/AppKit but I have the exact same issue so wherever my error lies, it seems cross-platform in scope):
//...
let oldTypingAttributes = textView.typingAttributes
let oldSelectedRange = textView.selectedRange
let selectedRangePostDelete: NSRange
if selectedRange.length == 0 {
// If selection is zero length, then deletion will result in the caret moving one position left
selectedRangePostDelete = NSMakeRange((oldSelectedRange.location - 1), 0)
} else {
// Otherwise, deletion will result in the caret being positioned at the start of the originally-selected range
selectedRangePostDelete = NSMakeRange(oldSelectedRange.location, 0)
}
undoManager?.beginUndoGrouping()
textView.deleteBackward(nil)
// Note: I have also tried setting selectedRangePostDelete HERE - after deleteBackward - by calling textView.selectedRange, but I opted to move it earlier to be certain that my issue wasn't caused by run loop timing issues i.e., to be sure I wasn't querying textView.selectedRange before it had been updated post-deletion... all this said, the issue occurs either way
let changeAttributesRange = NSMakeRange(selectedRangePostDelete.location, aLength)
let oldAttributedString = textStorage.attributedSubstring(from: changeAttributesRange)
textStorage.beginEditing()
textStorage.addAttributes(newAttributes, range: changeAttributesRange)
textStorage.endEditing()
textView.selectedRange = selectedRangePostDelete
let undoHandler: (MyCustomViewRepresentable.Coordinator) -> Void = { [oldAttributedString = oldAttributedString, changeAttributesRange = changeAttributesRange, oldTypingAttributes = oldTypingAttributes, changeAttributesRange = changeAttributesRange] target in
textStorage.beginEditing()
target.textStorage.replaceCharacters(in: changeAttributesRange, with: oldAttributedString)
textStorage.endEditing()
// I have tried setting selection range here in the undo handler to either selectedRangePostDelete or oldSelectedRange. I have also tried setting typingAttributes to oldTypingAttributes. Neither seems to have an effect on the issue I'm seeing.
} // end undoHandler declaration
undoManager?.registerUndo(withTarget: self, handler: undoHandler)
undoManager?.setActionName("Typing")
undoManager?.endUndoGrouping()
//...
With this code, the undo/redo stack seems to get corrupted. Behavior will appear as expected for a few cycles of undo/redo but ultimately starts to produce unwanted behavior after a few cycles.
My use case precludes me from relying only on NSTextView
behavior. So I need to modify the underlying textStorage at times. I realize that I could simply make ALL my changes by modifying textStorage only and prevent/override any default NSTextView modifications to the text, but I really don't want to have to reinvent the wheel if I don't have to. Completely coding my own implementation that modifies textStorage
directly for all possible keyDown events that would edit text would be a bit absurd and seemingly unnecessary. I'd love to use NSTextView's standard behavior whenever it's applicable, which is 90% of the time in my case.
So I'm desperate to figure out how I can I make NSTextView
standard behavior and custom NSTextStorage
modification play nice together and fluidly support undo/redo. Any solution has escaped me thus far. I felt relieved to find someone struggling with the same issue!
For further context, before calling the above block of code, I am overriding my NSTextView's doCommand(by selector: Selector)
and then running
...
if selector == #selector(deleteBackward(_:)) {
coordinator.handleDelete(for: self)
return
}
...
Where handleDelete(for:) calls the big block of code above, and where coordinator
is the coordinator for my custom NSViewRepresentable
class, which builds a view to provide to SwiftUI. The NSViewRepresentable view hierarchy contains the NSTextView
in question.
Note: if you're wondering why in my undo handler closure I call replaceCharacters(in: range, with: oldAttributedSubstring)
rather than something like addAttributes(oldAttributes)
... it's because at the time of the original addAttributes
call, string attributes may differ at various ranges/locations within the range of textStorage in question. To fully restore the textStorage to its original state, I have to completely replace the original attributedSubstring
for that range).