Skip to content

Commit 7d63a64

Browse files
Select Undone/Redone Text (#105)
### Description Updates the undo manager to update the text selection when undoing/redoing. Two cases: - Replaced/Inserted text - selects the inserted text - Deleted text - Places cursor at the start of the deleted text, as if the user had just deleted it. ### Related Issues * closes #95 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots https://github.com/user-attachments/assets/55309316-9106-4ce2-ac25-952e2addbbfe
1 parent a8a0e9c commit 7d63a64

File tree

2 files changed

+32
-4
lines changed

2 files changed

+32
-4
lines changed

Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,26 +14,25 @@ extension TextView {
1414

1515
/// Scrolls the upmost selection to the visible rect if `scrollView` is not `nil`.
1616
public func scrollSelectionToVisible() {
17-
guard let scrollView, let selection = getSelection() else {
17+
guard let scrollView else {
1818
return
1919
}
2020

21-
let offsetToScrollTo = offsetNotPivot(selection)
22-
2321
// 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
2422
// can't know the exact rect to make visible without laying out the text. Then, once text is laid out the
2523
// selection rect may be different again. To solve this, we loop until the frame doesn't change after a layout
2624
// pass and scroll to that rect.
2725

2826
var lastFrame: CGRect = .zero
29-
while let boundingRect = layoutManager.rectForOffset(offsetToScrollTo), lastFrame != boundingRect {
27+
while let boundingRect = getSelection()?.boundingRect, lastFrame != boundingRect {
3028
lastFrame = boundingRect
3129
layoutManager.layoutLines()
3230
selectionManager.updateSelectionViews()
3331
selectionManager.drawSelections(in: visibleRect)
3432
}
3533
if lastFrame != .zero {
3634
scrollView.contentView.scrollToVisible(lastFrame)
35+
scrollView.reflectScrolledClipView(scrollView.contentView)
3736
}
3837
}
3938

Sources/CodeEditTextView/Utils/CEUndoManager.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ public class CEUndoManager: UndoManager {
8383
textView.replaceCharacters(in: mutation.inverse.range, with: mutation.inverse.string)
8484
}
8585
textView.textStorage.endEditing()
86+
87+
updateSelectionsForMutations(mutations: item.mutations.map { $0.mutation })
88+
textView.scrollSelectionToVisible()
89+
8690
NotificationCenter.default.post(name: .NSUndoManagerDidUndoChange, object: self)
8791
redoStack.append(item)
8892
_isUndoing = false
@@ -101,16 +105,41 @@ public class CEUndoManager: UndoManager {
101105

102106
_isRedoing = true
103107
NotificationCenter.default.post(name: .NSUndoManagerWillRedoChange, object: self)
108+
textView.selectionManager.removeCursors()
104109
textView.textStorage.beginEditing()
105110
for mutation in item.mutations {
106111
textView.replaceCharacters(in: mutation.mutation.range, with: mutation.mutation.string)
107112
}
108113
textView.textStorage.endEditing()
114+
115+
updateSelectionsForMutations(mutations: item.mutations.map { $0.inverse })
116+
textView.scrollSelectionToVisible()
117+
109118
NotificationCenter.default.post(name: .NSUndoManagerDidRedoChange, object: self)
110119
undoStack.append(item)
111120
_isRedoing = false
112121
}
113122

123+
/// We often undo/redo a group of mutations that contain updated ranges that are next to each other but for a user
124+
/// should be one continuous range. This merges those ranges into a set of disjoint ranges before updating the
125+
/// selection manager.
126+
private func updateSelectionsForMutations(mutations: [TextMutation]) {
127+
if mutations.reduce(0, { $0 + $1.range.length }) == 0 {
128+
if let minimumMutation = mutations.min(by: { $0.range.location < $1.range.location }) {
129+
// If the mutations are only deleting text (no replacement), we just place the cursor at the last range,
130+
// since all the ranges are the same but the other method will return no ranges (empty range).
131+
textView?.selectionManager.setSelectedRange(
132+
NSRange(location: minimumMutation.range.location, length: 0)
133+
)
134+
}
135+
} else {
136+
let mergedRanges = mutations.reduce(into: IndexSet(), { set, mutation in
137+
set.insert(range: mutation.range)
138+
})
139+
textView?.selectionManager.setSelectedRanges(mergedRanges.rangeView.map { NSRange($0) })
140+
}
141+
}
142+
114143
/// Clears the undo/redo stacks.
115144
public func clearStack() {
116145
undoStack.removeAll()

0 commit comments

Comments
 (0)