Lona / Lona

A tool for defining design systems and using them to generate cross-platform UI code, Sketch files, and other artifacts.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

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

screen shot 2018-01-13 at 9 55 20 am

All of change actions logic, such as UpdateValueCommand, UpdateColorCommand, UpdateShadowCommand, ... should be encapsulated into the Command's adopting class.

screen shot 2018-01-13 at 9 42 33 am

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.
screen shot 2018-01-13 at 9 43 31 am

Undo/Redo action

screen shot 2018-01-13 at 9 43 42 am

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

screen shot 2018-01-13 at 9 47 55 am

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

  1. Implement the foundation of the Undo/Redo.
  2. Write tests and make sure they passes all ordinary cases and certain edge cases.
  3. Convert all available actions to Commands
  4. Adopts Receivable and provide the concrete implementations.
  5. Support Undo/Redo globally.

References

  1. Wikipedia Command Pattern

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:

  1. 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 per NSDocument and per NSTextView. It looks like it's an implementation of the Command pattern... do you think we'd be able to use this?
  2. 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 the CSLayer model:
    https://github.com/airbnb/Lona/blob/f4e766a5ebf407a7ebf0f774f59ce615b16fdcbd/LonaStudio/Models/CSLayer.swift#L316-L319
    And then trigger a UI update within the main ViewController:
    https://github.com/airbnb/Lona/blob/f4e766a5ebf407a7ebf0f774f59ce615b16fdcbd/LonaStudio/Workspace/ViewController.swift#L413-L414
    I suspect the main ViewController 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 per NSDocument, so we'll have to figure out how to coordinate that with the ViewController.
  3. It could make sense to use closures for encapsulating Command logic, rather than a protocol that various classes implement. E.g.
    struct Command {
      let undo: () -> Void
      let redo: () -> Void
    }
    The advantage of this is that we don't have to explicitly create different implementations of the 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 a newShadow and oldShadow 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!