Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add undo & redo functionality #1225

Merged
merged 22 commits into from
Nov 3, 2022
Merged

Add undo & redo functionality #1225

merged 22 commits into from
Nov 3, 2022

Conversation

bytasv
Copy link
Contributor

@bytasv bytasv commented Oct 28, 2022

Screen.Recording.2022-10-28.at.11.08.04.mov

FYI: if we agree on this approach, I'd like to proceed with integration tests in a separate PR as I suspect it might take a while to implement

@bytasv bytasv requested a review from a team October 28, 2022 08:15
@bytasv bytasv self-assigned this Oct 28, 2022
@render
Copy link

render bot commented Oct 28, 2022

@bytasv bytasv force-pushed the undo-redo branch 2 times, most recently from 29b4685 to e9d364d Compare October 28, 2022 08:57
@bytasv bytasv mentioned this pull request Oct 28, 2022
10 tasks
@render
Copy link

render bot commented Oct 31, 2022

@bytasv bytasv requested a review from Janpot October 31, 2022 13:01
@bytasv
Copy link
Contributor Author

bytasv commented Oct 31, 2022

@Janpot I've added undo/redo buttons as request. Are there any other changes that I should do before we can wrap up this work?

@bytasv bytasv requested a review from apedroferreira October 31, 2022 15:21
Copy link
Member

@apedroferreira apedroferreira left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Just a couple of comments about getMaybeNode

@bytasv
Copy link
Contributor Author

bytasv commented Nov 2, 2022

Hey @gerdadesign I guess that my first 2 comments here were design related? #1225 (comment) Not sure if we should do something about them but let us know your thoughts!

Since this is done according to the latest design 👇 I'd suggest discussion whether grouping undo/redo actions with preview/deploy is the right way or not to take place separate from this PR (in other words not be a blocking factor)
image

@@ -276,6 +343,9 @@ export interface DomLoader {
saving: boolean;
unsavedChanges: number;
saveError: string | null;
pendingHistoryDom: appDom.AppDom;
undo: appDom.AppDom[];
Copy link
Member

@Janpot Janpot Nov 3, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some nits:

  • Perhaps it makes sense to already anticipate that we will take selection in the undo stack (and maybe even other things) and foresee a type that can be extended with non-dom related things?

    interface UndoStackEntry {
      dom: appDom.AppDom,
      // future: 
      //   selection: NodeId
    }
    
    undo: UndoStackEntry[];
  • very much a nit, but in terms of naming, undo and redo sound like they could be methods, I'd consider naming these undoStack and redoStack to better convey what they are.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps it makes sense to already anticipate that we will take selection in the undo stack (and maybe even other things) and foresee a type that can be extended with that?

Hmm, I'd then rather wait for an actual implementation. Initially I was thinking that we should do selection state part of the dom itself. With the approach you are proposing I assume selection and action would happen at the same time, where in other apps selection itself is considered a separate undoable action. If however selection was part of the dom, then we wouldn't need to do any extra handling for the undo/redo

very much a nit, but in terms of naming undo and redo sound like methods, I'd consider naming these undoStack and redoStack to better convey what they are

Makes sense 👍 just renamed them

Copy link
Member

@Janpot Janpot Nov 3, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initially I was thinking that we should do selection state part of the dom itself.

The dom is what we persist on disk and what we regard as the shareable artifact containing a toolpad application. The selection is local state, it shouldn't be part of that.

With the approach you are proposing I assume selection and action would happen at the same time

Selection would add an entry in the undo stack with the same dom but a different selection.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fine, regardless I'd leave this out of scope for this PR then as it's already quite big

@@ -147,6 +159,55 @@ export function domLoaderReducer(state: DomLoader, action: DomAction): DomLoader
}

switch (action.type) {
case 'DOM_UPDATE_HISTORY': {
const updatedUndoStack = [...state.undoStack, state.pendingHistoryDom];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand why we need pendingHistoryDom, can't we just use state.dom here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was my first idea, I did try using state.dom here, but by the time this is called state.dom has updated values, where what I want is values that were in the dom before the actions were called. Storing a reference to last state that hasn't been pushed to history stack resolved this issue.
Just to be 100% sure I tried passing state.dom here after all these changes with throttle/debounce, but it still doesn't work.
Basically if we store state.dom here, we loose 1 point of history, and by using reference to "pending" state it always works as expected

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh ok I see, it just means that the top of the stack contains the current state, and that doing an undo means first popping an item, and then resetting the dom to what's in the top of the stack instead of what we just popped off.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I'm not sure I fully understand whether you meant that this is how it's working now or how it would work if we pushed state.dom instead of pendingHistoryDom but the idea is to keep undo stack populated only with previous states 🤔
Do you see a way that we could avoid using pendingHistoryDom ref? I was trying various things, like passing state to dispatch, etc... but none of them worked predictably and this (current) approach seemed to do the trick

Copy link
Member

@Janpot Janpot Nov 3, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you see a way that we could avoid using pendingHistoryDom ref?

I think it can be done if we slightly alter this philosophy: "the idea is to keep undo stack populated only with previous states" to "the idea is to keep undo stack populated with full history up until the current state". And initialize the undoStack with [dom] instead of []. And then when popping an item off the stack, not use that popped off item to restore the dom, but look at the top item of the trimmed stack.

Basically it would work the same way, only instead of adding an extra property for pendingHistoryDom, the top item in the stack would be pendingHistoryDom. I think there are some small benefits to this method:

  • One less property to keep in sync pendingHistoryDom
  • We will want to give an overview of the history of changes at some point. To do that we will have to add a human readable label to each entry in the stack. In the implementation with pendingHistoryDom there would be an entry missing for the last change, and we'd need to special case the last change. In the implementation without pendingHistoryDom, the undoStack would reflect the whole history up until the last change, and we can just flat render this array.

Then again, I may be wrong in my thinking, it's only based on reading the implementation and thinking about the consequences, I didn't try it out and I may be overlooking some other effects.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Janpot you are correct, thanks, another great tip 💪 I changed the implementation according to your suggestion. I guess the main con of this new approach is that undo/redo methods work slightly differently, but that should be fine 🤔

In the implementation with pendingHistoryDom there would be an entry missing for the last change, and we'd need to special case the last change

I don't think that's true - in the case of pendingHistoryDom initially we would have simply empty array, which means there is no history to undo. At the time we would trigger action and push pendingHistoryDom we could add a label. IMO specifically right now we will have to add extra checks if we want to render history and omit top most entry, i.e. ```
if (undoStack.length < 2) {
// means there is no history to undo
}


We just need to remember in the future that top most item in the undo stack represents current state 🤔 

@bytasv bytasv requested a review from Janpot November 3, 2022 12:57
@Janpot
Copy link
Member

Janpot commented Nov 3, 2022

This took a lot of shaping, the result is great. We can absolutely build on this.

@bytasv
Copy link
Contributor Author

bytasv commented Nov 3, 2022

@Janpot thanks! I also added to the parent issue todo for selection being part of undo/redo

@apedroferreira do you have any feedback that I should address merging this PR or do you think it's good to go?

Copy link
Member

@apedroferreira apedroferreira left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good!

@bytasv bytasv merged commit 0c0b7f8 into master Nov 3, 2022
@bytasv bytasv deleted the undo-redo branch November 3, 2022 14:31
@oliviertassinari oliviertassinari added the new feature New feature or request label Nov 3, 2022
Copy link
Member

@oliviertassinari oliviertassinari left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's nice to have the first version of this feature 👍.

The app bar buttons work on my end in https://master--toolpad.mui.com/_toolpad/app/cl9pj1woq0001l59l5y3kztln/pages/7k195tr. However, I'm not able to use the keyboard on macOS. Is this expected?

@@ -16,13 +18,15 @@ export default function useShortcut(
}

const handleKeydown = (event: KeyboardEvent) => {
if (event.code === code && event.metaKey === metaKey) {
if (event.code === code && event.metaKey === metaKey && event.shiftKey === shiftKey) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it work on Windows? I would expect that we need to use event.ctrlKey.

@bytasv
Copy link
Contributor Author

bytasv commented Nov 3, 2022

It's nice to have the first version of this feature 👍.

The app bar buttons work on my end in https://master--toolpad.mui.com/_toolpad/app/cl9pj1woq0001l59l5y3kztln/pages/7k195tr. However, I'm not able to use the keyboard on macOS. Is this expected?

Could you share exact steps? I've just tried on the link and keyboard works for me 🤔

@oliviertassinari
Copy link
Member

I've just tried on the link and keyboard works for me

I had a look, here is the root problem: #1274 (comment).

@oliviertassinari
Copy link
Member

oliviertassinari commented Nov 10, 2022

I have found a bug using the feature, I have reported it at #226 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants