-
-
Notifications
You must be signed in to change notification settings - Fork 338
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
Conversation
Your Render PR Server URL is https://toolpad-pr-1225.onrender.com. Follow its progress at https://dashboard.render.com/web/srv-cddovdla4995ha90evtg. |
packages/toolpad-app/src/toolpad/AppEditor/PageEditor/ComponentEditor.tsx
Outdated
Show resolved
Hide resolved
packages/toolpad-app/src/toolpad/AppEditor/PageEditor/EditorCanvasHost.tsx
Show resolved
Hide resolved
29b4685
to
e9d364d
Compare
packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx
Outdated
Show resolved
Hide resolved
Your Render PR Server URL is https://toolpad-pr-1225.onrender.com. Follow its progress at https://dashboard.render.com/web/srv-cdfs0ukgqg438ugltuo0. |
@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? |
There was a problem hiding this 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
packages/toolpad-app/src/toolpad/AppEditor/PageEditor/ComponentEditor.tsx
Show resolved
Hide resolved
packages/toolpad-app/src/toolpad/AppEditor/PageEditor/RenderPanel/RenderOverlay.tsx
Show resolved
Hide resolved
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) |
@@ -276,6 +343,9 @@ export interface DomLoader { | |||
saving: boolean; | |||
unsavedChanges: number; | |||
saveError: string | null; | |||
pendingHistoryDom: appDom.AppDom; | |||
undo: appDom.AppDom[]; |
There was a problem hiding this comment.
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
andredo
sound like they could be methods, I'd consider naming theseundoStack
andredoStack
to better convey what they are.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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]; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 withoutpendingHistoryDom
, theundoStack
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.
There was a problem hiding this comment.
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 🤔
This took a lot of shaping, the result is great. We can absolutely build on this. |
@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? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good!
There was a problem hiding this 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) { |
There was a problem hiding this comment.
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
.
Could you share exact steps? I've just tried on the link and keyboard works for me 🤔 |
I had a look, here is the root problem: #1274 (comment). |
I have found a bug using the feature, I have reported it at #226 (comment) |
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