diff --git a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift index 00475ef9f..c4cf1fa6c 100644 --- a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift +++ b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift @@ -14,19 +14,17 @@ extension TextView { /// Scrolls the upmost selection to the visible rect if `scrollView` is not `nil`. public func scrollSelectionToVisible() { - guard let scrollView, let selection = getSelection() else { + guard let scrollView else { return } - let offsetToScrollTo = offsetNotPivot(selection) - // There's a bit of a chicken-and-the-egg issue going on here. We need to know the rect to scroll to, but we // can't know the exact rect to make visible without laying out the text. Then, once text is laid out the // selection rect may be different again. To solve this, we loop until the frame doesn't change after a layout // pass and scroll to that rect. var lastFrame: CGRect = .zero - while let boundingRect = layoutManager.rectForOffset(offsetToScrollTo), lastFrame != boundingRect { + while let boundingRect = getSelection()?.boundingRect, lastFrame != boundingRect { lastFrame = boundingRect layoutManager.layoutLines() selectionManager.updateSelectionViews() @@ -34,6 +32,7 @@ extension TextView { } if lastFrame != .zero { scrollView.contentView.scrollToVisible(lastFrame) + scrollView.reflectScrolledClipView(scrollView.contentView) } } diff --git a/Sources/CodeEditTextView/Utils/CEUndoManager.swift b/Sources/CodeEditTextView/Utils/CEUndoManager.swift index bdf859166..722b188ed 100644 --- a/Sources/CodeEditTextView/Utils/CEUndoManager.swift +++ b/Sources/CodeEditTextView/Utils/CEUndoManager.swift @@ -83,6 +83,10 @@ public class CEUndoManager: UndoManager { textView.replaceCharacters(in: mutation.inverse.range, with: mutation.inverse.string) } textView.textStorage.endEditing() + + updateSelectionsForMutations(mutations: item.mutations.map { $0.mutation }) + textView.scrollSelectionToVisible() + NotificationCenter.default.post(name: .NSUndoManagerDidUndoChange, object: self) redoStack.append(item) _isUndoing = false @@ -101,16 +105,41 @@ public class CEUndoManager: UndoManager { _isRedoing = true NotificationCenter.default.post(name: .NSUndoManagerWillRedoChange, object: self) + textView.selectionManager.removeCursors() textView.textStorage.beginEditing() for mutation in item.mutations { textView.replaceCharacters(in: mutation.mutation.range, with: mutation.mutation.string) } textView.textStorage.endEditing() + + updateSelectionsForMutations(mutations: item.mutations.map { $0.inverse }) + textView.scrollSelectionToVisible() + NotificationCenter.default.post(name: .NSUndoManagerDidRedoChange, object: self) undoStack.append(item) _isRedoing = false } + /// We often undo/redo a group of mutations that contain updated ranges that are next to each other but for a user + /// should be one continuous range. This merges those ranges into a set of disjoint ranges before updating the + /// selection manager. + private func updateSelectionsForMutations(mutations: [TextMutation]) { + if mutations.reduce(0, { $0 + $1.range.length }) == 0 { + if let minimumMutation = mutations.min(by: { $0.range.location < $1.range.location }) { + // If the mutations are only deleting text (no replacement), we just place the cursor at the last range, + // since all the ranges are the same but the other method will return no ranges (empty range). + textView?.selectionManager.setSelectedRange( + NSRange(location: minimumMutation.range.location, length: 0) + ) + } + } else { + let mergedRanges = mutations.reduce(into: IndexSet(), { set, mutation in + set.insert(range: mutation.range) + }) + textView?.selectionManager.setSelectedRanges(mergedRanges.rangeView.map { NSRange($0) }) + } + } + /// Clears the undo/redo stacks. public func clearStack() { undoStack.removeAll()