Undo/Redo Mechanism
NghiaTranUIT opened this issue ยท comments
Suggesting a New Feature
The undo/redo mechanism is a must-have functionality on any kind of design tool. So it's high priority to implement this feature carefully.
Initial Design
In order to implement the Undo/Redo mechanism, we take an advantage of Command Design Pattern, which is one of famous pattern from Gang Of Four.
Command
Command
is abstract protocol, which defines the receiver and how to execute and undo an action
All of change actions logic, such as UpdateValueCommand, UpdateColorCommand, UpdateShadowCommand, ... should be encapsulated into the Command
's adopting class.
Global Command Queue
In order to support Undo/Redo mechanism globally, we define the queue, which holds 20 commands at most (configurable). It simply appends new command and pop out the latest command if it exceeds the threshold.
Undo/Redo action
We define currentIndex, which is responsible for current action has been taken place.As a result, the Undo
action simply invokes the redo
action of current action, then moving the pointer back.
Redo
action simply invokes the execute
action of next command in the queue.
New action while in middle of actions
If the current pointer is in middle of the queue. It means the user does couple of redo, then executing new command.I suggest to drop all ahead actions and replace with new one. It's same behavior with Sketch or Photoshop.
Proposed Implementation in Swift
Command
protocol defines essential actions.
protocol Command {
var receiver: Receivable { get }
func execute()
func undo()
}
Subclass
of Command
is responsible for passing the execute
action and undo
action to receiver
. Command
subclass doesn't know concrete implementation, it's de-coupling and could be reused easily in any Lona Component.
For instance, UpdateShadowCommand
could pass effortlessly the action to the TextLabel (use NSAttributeString) or UIView (CALayer or shadow property) and doesn't care how to support Shadow on specific views.
struct UpdateShadowCommand: Command {
private let newShadow: NSShadow
private let oldShadow: NSShadow
private let _receiver: UpdateShadowReceivable
var receiver: Receivable { return _receiver }
init(receiver: UpdateShadowReceivable, newShadow: NSShadow, oldShadow: NSShadow) {
self._receiver = receiver
self.newShadow = newShadow
self.oldShadow = oldShadow
}
func execute() {
_receiver.update(shadow: newShadow)
}
func undo() {
_receiver.update(shadow: oldShadow)
}
}
On the other hand, Receivable
is placeholder protocol, which represent any kind object or view in Lona.
protocol Receivable {}
protocol UpdateShadowReceivable: Receivable {
func update(shadow: NSShadow)
}
For instance, TextLabel adopts UpdateShadowReceivable
and UpdateTextStylyReceivable
. After that, TextLabel could actually implement the logic.
class TextLabel {
private var shadow: NSShadow?
var textStyle: String = "April"
}
extension TextLabel: UpdateShadowReceivable {
func update(shadow: NSShadow) {
self.shadow = shadow
// Render NSShadow by NSAttributeString
}
}
extension TextLabel: UpdatedTextStyleReceivable {
func update(textStyle: String) {
self.textStyle = textStyle
// Render TextStyle by NSFont
}
}
Box also adopts the UpdateShadowReceivable
protocol and write the logic as well.
class Box: NSView {
private var shadow: NSShadow?
}
extension Box: UpdateShadowReceivable {
func update(shadow: NSShadow) {
self.shadow = shadow
// Render Shadow by shadow layer property
}
}
CommandManager
is the big guy, who put the dirty hand into the real work. Basically, it manages the command queue by appending or dropping certain commands. CommandManager
also offers the shared global instance, so we could leverage it in any part of the codebase.
The implement is straightforward. It looks at the currentCommandIndex and do appropriate action on the queue in order to achieve Undo/Redo behavior.
class CommandManager: Commander {
static let shared = CommandManager()
private var queue: [Command] = []
private var currentCommandIndex: Int = -1
func execute(_ command: Command) {
// In middle
// Replace the right array with new command
if currentCommandIndex < queue.count - 1 {
let _range = NSRange(location: currentCommandIndex, length: queue.count - currentCommandIndex)
let range = Range(_range)!
queue.removeSubrange(range)
}
queue.append(command)
command.execute()
currentCommandIndex += 1
}
func undo() {
guard ... {}
let command = queue[currentCommandIndex]
command.undo()
currentCommandIndex -= 1
}
func redo() {
guard ... {}
currentCommandIndex += 1
let command = queue[currentCommandIndex]
command.execute()
}
}
How to use
let commander = CommandManager.shared
let text = TextLabel()
// Do actions
let action = UpdateTextStyleCommand(receiver: text, new: "SF_20", old: text.textStyle)
commander.execute(action)
// Undo
commander.undo()
// Undo
commander.redo()
Plan
- Implement the foundation of the Undo/Redo.
- Write tests and make sure they passes all ordinary cases and certain edge cases.
- Convert all available actions to Commands
- Adopts
Receivable
and provide the concrete implementations. - Support Undo/Redo globally.
References
Looking forward to hearing your feedback @dabbott ๐ธ
It will be so amazing to have undo/redo support! It can be very frustrating without it... making a change and then forgetting how to get back to where I was.
I think the Command pattern is a great choice here ๐
The main things that come to mind:
- It seems like Apple provides a pretty comprehensive Undo manager, with built-in support for AppKit features. I haven't tried using it before, but it does seem like it provides a lot features we'd want out-of-the-box (
NSTextView
integration,NSMenu
integration, command grouping, etc). It also seems like they've thought about a lot of the tricky coordination stuff, e.g. managing a separate undo stack perNSDocument
and perNSTextView
. It looks like it's an implementation of the Command pattern... do you think we'd be able to use this? - In most places, we'll want to make sure our undo/redo logic applies to the data models, rather than the views. E.g. when we want to update a shadow, we should update the
shadow
property of theCSLayer
model:
https://github.com/airbnb/Lona/blob/f4e766a5ebf407a7ebf0f774f59ce615b16fdcbd/LonaStudio/Models/CSLayer.swift#L316-L319
And then trigger a UI update within the mainViewController
:
https://github.com/airbnb/Lona/blob/f4e766a5ebf407a7ebf0f774f59ce615b16fdcbd/LonaStudio/Workspace/ViewController.swift#L413-L414
I suspect the mainViewController
will need to handle most undo/redo actions, since it contains both the data models and the UI views. We'll also need to keep a separate undo stack perNSDocument
, so we'll have to figure out how to coordinate that with theViewController
. - It could make sense to use closures for encapsulating Command logic, rather than a protocol that various classes implement. E.g.
The advantage of this is that we don't have to explicitly create different implementations of the
struct Command { let undo: () -> Void let redo: () -> Void }
Command
protocol. It'll also be easier to colocate the code for performing and undoing a command (since we don't have to make a separate function for each). The disadvantage is that there isn't as much information when debugging (e.g. there isn't anewShadow
andoldShadow
property to inspect). This may depend on whether we use Apple's undo manager, so we should figure that out first.
Thanks for the detailed writeup @NghiaTranUIT, that made it very easy to understand. I'm really excited about this one -- Lona will be a lot easier to use once it has undo/redo!
Anyway, what do you think?
(PS: I'm very close to .component -> Swift code generation... it's gonna be SO good!)
As a previous Mac desktop developer in another life, I agree with your assessment. Keep things simple the existing architecture as (eg. NSUndoManager) as much as possible.
I agree with you guys @dabbott and @ilanvolow. I forgot that Apple's already provided us the power tool like NSUndoManager ๐
I'm doing some research about it then heading back to see the 3rd's option is acceptable or not. Since I prefer to create different implementations of the Command
protocol than general-purpose protocol.
Thank you for your feedback ๐ ๐
Sorry for doing this work later than I expected due to busy work at Zalora.
I've spent time for researching and doing little experience on UndoManager.
It's the potential candidate in order to replace my CommandManager
since I support seamlessly must-have functionalities like grouping command as long as Cocoa Kit integration.
They offer cumbersome func, such as
func registerUndo(withTarget target: Any, selector: Selector, object anObject: Any?)
IMO, It's super cumbersome ๐ฅ
I prefer to use their convention closure
func setShadow(_ shadow: CShadow) {
let shadow = CSShadow()
undoManager?.registerUndo(withTarget: self, handler: { [oldShadow = shadow] (MyViewController) -> (target) in
target.setShadow(oldShadow)
})
}
However, sometime it could be a boilerplate. So I will mix-up two approach by creating closure or initilaizing the UpdateShadowCommand
.
Let try first then evaluating the effort and trade-off ๐
I'm working on it now to see how to tackle it efficiently ๐
Done!