|  | 
|  | 1 | +// | 
|  | 2 | +// CEUndoManager.swift | 
|  | 3 | +// CodeEditTextView | 
|  | 4 | +// | 
|  | 5 | +// Created by Khan Winter on 7/8/23. | 
|  | 6 | +// | 
|  | 7 | + | 
|  | 8 | +import STTextView | 
|  | 9 | +import AppKit | 
|  | 10 | +import TextStory | 
|  | 11 | + | 
|  | 12 | +/// Maintains a history of edits applied to the editor and allows for undo/redo actions using those edits. | 
|  | 13 | +///  | 
|  | 14 | +/// This object also groups edits into sequences that make for a better undo/redo editing experience such as: | 
|  | 15 | +/// - Breaking undo groups on newlines | 
|  | 16 | +/// - Grouping pasted text | 
|  | 17 | +/// | 
|  | 18 | +/// If needed, the automatic undo grouping can be overridden using the `beginGrouping()` and `endGrouping()` methods. | 
|  | 19 | +class CEUndoManager { | 
|  | 20 | + /// An `UndoManager` subclass that forwards relevant actions to a `CEUndoManager`. | 
|  | 21 | + /// Allows for objects like `STTextView` to use the `UndoManager` API | 
|  | 22 | + /// while CETV manages the undo/redo actions. | 
|  | 23 | + class DelegatedUndoManager: UndoManager { | 
|  | 24 | + weak var parent: CEUndoManager? | 
|  | 25 | + | 
|  | 26 | + override var canUndo: Bool { parent?.canUndo ?? false } | 
|  | 27 | + override var canRedo: Bool { parent?.canRedo ?? false } | 
|  | 28 | + | 
|  | 29 | + func registerMutation(_ mutation: TextMutation) { | 
|  | 30 | + parent?.registerMutation(mutation) | 
|  | 31 | + removeAllActions() | 
|  | 32 | + } | 
|  | 33 | + | 
|  | 34 | + override func undo() { | 
|  | 35 | + parent?.undo() | 
|  | 36 | + } | 
|  | 37 | + | 
|  | 38 | + override func redo() { | 
|  | 39 | + parent?.redo() | 
|  | 40 | + } | 
|  | 41 | + | 
|  | 42 | + override func registerUndo(withTarget target: Any, selector: Selector, object anObject: Any?) { | 
|  | 43 | + // no-op, but just in case to save resources: | 
|  | 44 | + removeAllActions() | 
|  | 45 | + } | 
|  | 46 | + } | 
|  | 47 | + | 
|  | 48 | + /// Represents a group of mutations that should be treated as one mutation when undoing/redoing. | 
|  | 49 | + private struct UndoGroup { | 
|  | 50 | + struct Mutation { | 
|  | 51 | + var mutation: TextMutation | 
|  | 52 | + var inverse: TextMutation | 
|  | 53 | + } | 
|  | 54 | + | 
|  | 55 | + var mutations: [Mutation] | 
|  | 56 | + } | 
|  | 57 | + | 
|  | 58 | + public let manager: DelegatedUndoManager | 
|  | 59 | + public var isUndoing: Bool = false | 
|  | 60 | + public var isRedoing: Bool = false | 
|  | 61 | + | 
|  | 62 | + public var canUndo: Bool { | 
|  | 63 | + !undoStack.isEmpty | 
|  | 64 | + } | 
|  | 65 | + public var canRedo: Bool { | 
|  | 66 | + !redoStack.isEmpty | 
|  | 67 | + } | 
|  | 68 | + | 
|  | 69 | + /// A stack of operations that can be undone. | 
|  | 70 | + private var undoStack: [UndoGroup] = [] | 
|  | 71 | + /// A stack of operations that can be redone. | 
|  | 72 | + private var redoStack: [UndoGroup] = [] | 
|  | 73 | + | 
|  | 74 | + private unowned let textView: STTextView | 
|  | 75 | + private(set) var isGrouping: Bool = false | 
|  | 76 | + | 
|  | 77 | + public init(textView: STTextView) { | 
|  | 78 | + self.textView = textView | 
|  | 79 | + self.manager = DelegatedUndoManager() | 
|  | 80 | + manager.parent = self | 
|  | 81 | + } | 
|  | 82 | + | 
|  | 83 | + /// Performs an undo operation if there is one available. | 
|  | 84 | + public func undo() { | 
|  | 85 | + guard let item = undoStack.popLast() else { | 
|  | 86 | + return | 
|  | 87 | + } | 
|  | 88 | + isUndoing = true | 
|  | 89 | + for mutation in item.mutations.reversed() { | 
|  | 90 | + textView.applyMutationNoUndo(mutation.inverse) | 
|  | 91 | + } | 
|  | 92 | + redoStack.append(item) | 
|  | 93 | + isUndoing = false | 
|  | 94 | + } | 
|  | 95 | + | 
|  | 96 | + /// Performs a redo operation if there is one available. | 
|  | 97 | + public func redo() { | 
|  | 98 | + guard let item = redoStack.popLast() else { | 
|  | 99 | + return | 
|  | 100 | + } | 
|  | 101 | + isRedoing = true | 
|  | 102 | + for mutation in item.mutations { | 
|  | 103 | + textView.applyMutationNoUndo(mutation.mutation) | 
|  | 104 | + } | 
|  | 105 | + undoStack.append(item) | 
|  | 106 | + isRedoing = false | 
|  | 107 | + } | 
|  | 108 | + | 
|  | 109 | + /// Clears the undo/redo stacks. | 
|  | 110 | + public func clearStack() { | 
|  | 111 | + undoStack.removeAll() | 
|  | 112 | + redoStack.removeAll() | 
|  | 113 | + } | 
|  | 114 | + | 
|  | 115 | + /// Registers a mutation into the undo stack. | 
|  | 116 | + /// | 
|  | 117 | + /// Calling this method while the manager is in an undo/redo operation will result in a no-op. | 
|  | 118 | + /// - Parameter mutation: The mutation to register for undo/redo | 
|  | 119 | + public func registerMutation(_ mutation: TextMutation) { | 
|  | 120 | + if (mutation.range.length == 0 && mutation.string.isEmpty) || isUndoing || isRedoing { return } | 
|  | 121 | + let newMutation = UndoGroup.Mutation(mutation: mutation, inverse: textView.inverseMutation(for: mutation)) | 
|  | 122 | + if !undoStack.isEmpty, let lastMutation = undoStack.last?.mutations.last { | 
|  | 123 | + if isGrouping || shouldContinueGroup(newMutation, lastMutation: lastMutation) { | 
|  | 124 | + undoStack[undoStack.count - 1].mutations.append(newMutation) | 
|  | 125 | + } else { | 
|  | 126 | + undoStack.append(UndoGroup(mutations: [newMutation])) | 
|  | 127 | + } | 
|  | 128 | + } else { | 
|  | 129 | + undoStack.append( | 
|  | 130 | + UndoGroup(mutations: [newMutation]) | 
|  | 131 | + ) | 
|  | 132 | + } | 
|  | 133 | + | 
|  | 134 | + redoStack.removeAll() | 
|  | 135 | + } | 
|  | 136 | + | 
|  | 137 | + /// Groups all incoming mutations. | 
|  | 138 | + public func beginGrouping() { | 
|  | 139 | + isGrouping = true | 
|  | 140 | + } | 
|  | 141 | + | 
|  | 142 | + /// Stops grouping all incoming mutations. | 
|  | 143 | + public func endGrouping() { | 
|  | 144 | + isGrouping = false | 
|  | 145 | + } | 
|  | 146 | + | 
|  | 147 | + /// Determines whether or not two mutations should be grouped. | 
|  | 148 | + /// | 
|  | 149 | + /// Will break group if: | 
|  | 150 | + /// - Last mutation is delete and new is insert, and vice versa *(insert and delete)*. | 
|  | 151 | + /// - Last mutation was not whitespace, new is whitespace *(insert)*. | 
|  | 152 | + /// - New mutation is a newline *(insert and delete)*. | 
|  | 153 | + /// - New mutation is not sequential with the last one *(insert and delete)*. | 
|  | 154 | + /// | 
|  | 155 | + /// - Parameters: | 
|  | 156 | + /// - mutation: The current mutation. | 
|  | 157 | + /// - lastMutation: The last mutation applied to the document. | 
|  | 158 | + /// - Returns: Whether or not the given mutations can be grouped. | 
|  | 159 | + private func shouldContinueGroup(_ mutation: UndoGroup.Mutation, lastMutation: UndoGroup.Mutation) -> Bool { | 
|  | 160 | + // If last mutation was delete & new is insert or vice versa, split group | 
|  | 161 | + if (mutation.mutation.range.length > 0 && lastMutation.mutation.range.length == 0) | 
|  | 162 | + || (mutation.mutation.range.length == 0 && lastMutation.mutation.range.length > 0) { | 
|  | 163 | + return false | 
|  | 164 | + } | 
|  | 165 | + | 
|  | 166 | + if mutation.mutation.string.isEmpty { | 
|  | 167 | + // Deleting | 
|  | 168 | + return ( | 
|  | 169 | + lastMutation.mutation.range.location == mutation.mutation.range.max | 
|  | 170 | + && mutation.inverse.string != "\n" | 
|  | 171 | + ) | 
|  | 172 | + } else { | 
|  | 173 | + // Inserting | 
|  | 174 | + | 
|  | 175 | + // Only attempt this check if the mutations are small enough. | 
|  | 176 | + // If the last mutation was not whitespace, and the new one is, break the group. | 
|  | 177 | + if lastMutation.mutation.string.count < 1024 | 
|  | 178 | + && mutation.mutation.string.count < 1024 | 
|  | 179 | + && !lastMutation.mutation.string.trimmingCharacters(in: .whitespaces).isEmpty | 
|  | 180 | + && mutation.mutation.string.trimmingCharacters(in: .whitespaces).isEmpty { | 
|  | 181 | + return false | 
|  | 182 | + } | 
|  | 183 | + | 
|  | 184 | + return ( | 
|  | 185 | + lastMutation.mutation.range.max + 1 == mutation.mutation.range.location | 
|  | 186 | + && mutation.mutation.string != "\n" | 
|  | 187 | + ) | 
|  | 188 | + } | 
|  | 189 | + } | 
|  | 190 | +} | 
0 commit comments