From 25b707275dfe1039f9601c40e9a925e7c17a8731 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 18 Jun 2025 14:30:15 -0500 Subject: [PATCH 1/3] Update Text Selection On Undo/Redo --- .../Utils/CEUndoManager.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Sources/CodeEditTextView/Utils/CEUndoManager.swift b/Sources/CodeEditTextView/Utils/CEUndoManager.swift index bdf859166..086c68cba 100644 --- a/Sources/CodeEditTextView/Utils/CEUndoManager.swift +++ b/Sources/CodeEditTextView/Utils/CEUndoManager.swift @@ -83,6 +83,9 @@ public class CEUndoManager: UndoManager { textView.replaceCharacters(in: mutation.inverse.range, with: mutation.inverse.string) } textView.textStorage.endEditing() + + updateSelectionsForMutations(mutations: item.mutations.map { $0.mutation }) + NotificationCenter.default.post(name: .NSUndoManagerDidUndoChange, object: self) redoStack.append(item) _isUndoing = false @@ -101,16 +104,36 @@ 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 }) + 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, let last = mutations.last { + // 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(last.range) + } 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() From 7bacce882dd24f8c5a344d21a24cca62f2369f75 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 18 Jun 2025 14:40:53 -0500 Subject: [PATCH 2/3] Update Logic Slightly --- Sources/CodeEditTextView/Utils/CEUndoManager.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Sources/CodeEditTextView/Utils/CEUndoManager.swift b/Sources/CodeEditTextView/Utils/CEUndoManager.swift index 086c68cba..abf84068f 100644 --- a/Sources/CodeEditTextView/Utils/CEUndoManager.swift +++ b/Sources/CodeEditTextView/Utils/CEUndoManager.swift @@ -122,10 +122,14 @@ public class CEUndoManager: UndoManager { /// 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, let last = mutations.last { - // 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(last.range) + 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) From 892839ee4606b6ec77ddd922a19e05261bd0981d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 18 Jun 2025 15:02:07 -0500 Subject: [PATCH 3/3] Fix scrollSelectionToVisible, Scroll to Undone Text --- .../TextView/TextView+ScrollToVisible.swift | 7 +++---- Sources/CodeEditTextView/Utils/CEUndoManager.swift | 2 ++ 2 files changed, 5 insertions(+), 4 deletions(-) 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 abf84068f..722b188ed 100644 --- a/Sources/CodeEditTextView/Utils/CEUndoManager.swift +++ b/Sources/CodeEditTextView/Utils/CEUndoManager.swift @@ -85,6 +85,7 @@ public class CEUndoManager: UndoManager { textView.textStorage.endEditing() updateSelectionsForMutations(mutations: item.mutations.map { $0.mutation }) + textView.scrollSelectionToVisible() NotificationCenter.default.post(name: .NSUndoManagerDidUndoChange, object: self) redoStack.append(item) @@ -112,6 +113,7 @@ public class CEUndoManager: UndoManager { textView.textStorage.endEditing() updateSelectionsForMutations(mutations: item.mutations.map { $0.inverse }) + textView.scrollSelectionToVisible() NotificationCenter.default.post(name: .NSUndoManagerDidRedoChange, object: self) undoStack.append(item)