-
Notifications
You must be signed in to change notification settings - Fork 4.2k
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
Proposal: history "buffer"/overwrite, sync RichText history records #4956
Conversation
ff4ae59
to
8de51ce
Compare
8de51ce
to
a2b08be
Compare
blocks/rich-text/index.js
Outdated
if ( this.editor.isDirty() ) { | ||
this.fireChange(); | ||
this.savedContent = this.getContent(); | ||
this.props.onChange( this.state.empty ? [] : this.savedContent ); |
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.
Minor: I made a small change in the other PR here, I assign []
to this.savedContent if it's empty to avoid updateContent
to be called on the next render.
a2b08be
to
a27fbd8
Compare
I'm thinking instead of creating undo levels on focus/blur, we could somehow check when the block ID changes when updating attributes, and then not overwrite state. A bit similar to #4959. |
I like this idea, I think we can improve #4959 do something like this:
What do you think, it should be pretty easy to implement? |
I agree on that except for the last part... I think it's pretty simplistic to just add a timeout for the same field. For small fields such as inputs and small textareas, this is not really needed, and for bigger fields this won't be enough. We should let the |
I also agree with that, I'm just trying to think how this will look like technically speaking. I don't want us to add a prop or something like that for block authors to deal with. If it's possible without it, that would be great 👍 Edit: I guess it's possible to do it with something like Do you want to work on this? I can't update my PR accordingly if necessary |
Yeah, agree there too. :) In this branch, I used context. |
80da3cd
to
673ffea
Compare
@youknowriad Sorry, didn't see your edit. Yeah, but I guess if we want to look at the action ID and attribute keys, it will have to be a function like you did. Updated the PR with tests. This will now always overwrite the state if the ID and attribute keys are the same. Otherwise an undo level will be added. Undo levels will also be added from the |
4c517aa
to
daab823
Compare
editor/store/reducer.js
Outdated
partialRight( withHistory, { | ||
resetTypes: [ 'SETUP_NEW_POST', 'SETUP_EDITOR' ], | ||
shouldOverwriteState( action, previousAction ) { | ||
if ( ! includes( [ 'UPDATE_BLOCK_ATTRIBUTES', 'EDIT_POST', 'RESET_POST' ], action.type ) ) { |
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.
@aduth I'm adding these action here because they seem to fire after auto saving the post. Ideally saving a post should not interfere with undo levels. Not sure if we should create a separate action for this.
editor/store/reducer.js
Outdated
return action.uid === previousAction.uid && isEqual( attributes, previousAttributes ); | ||
} | ||
|
||
return true; |
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.
Don't you think it should be false by default? and we'd only overwrite the state if the action is UPDATE_BLOCK_ATTRIBUTES
with the same keys?
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.
This would allow us to remove the check you're doing line 123
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.
Hm, then I just have to invert that check?
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.
yes, why can't this function be just:
shouldOverwriteState( action, previousAction ) {
if (
previousAction &&
action.type === 'UPDATE_BLOCK_ATTRIBUTES' &&
action.type === previousAction.type
) {
const attributes = keys( action.attributes );
const previousAttributes = keys( previousAction.attributes );
return action.uid === previousAction.uid && isEqual( attributes, previousAttributes );
}
return false;
},
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 still has checks for other actions too?
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.
no, why do we need to check other actions?
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's why I commented here #4956 (comment). I'm having problems with loading the post and autosave interfering with the undo levels.
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! I missed that one sorry.
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.
Thinking about these actions 'EDIT_POST', 'RESET_POST'
I believe these two actions shouldn't overwrite previous state. If you edit a category, a publish date or anything in the post, it should create an undo level right?
same for reset_post
which means the post is updated globally somewhere.
Maybe we could add a flag to this action when we want to avoid creating undo levels (like post success actions)?
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.
Yep, it definitely a to do.
blocks/rich-text/index.js
Outdated
@@ -373,12 +368,22 @@ export class RichText extends Component { | |||
* Handles any case where the content of the tinyMCE instance has changed. | |||
*/ | |||
onChange() { | |||
if ( ! this.editor.isDirty() ) { |
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.
The dirty checking was causing an issue when you click the "undo" button because onChange
was being called right after the updateContent
call. Which is a not necessary call to onChange
(It was even causing issues).
I wonder if we should keep it to avoid these unnecessary onChange calls.
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.
Wouldn't this.savedContent
checking prevent that?
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.
No, We're not checking it on the onChange handler when the updateContent triggers (clicking undo, splitting paragraphs)
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.
Okay. Looking into it. Calling save here is not great because it will call the expensive version of getContent
... :)
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.
blocks/rich-text/index.js
Outdated
setContent( content = '' ) { | ||
this.editor.setContent( renderToString( content ) ); | ||
} | ||
|
||
getContent() { | ||
if ( this.state.empty ) { |
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.
In #4953 I discovered that sometimes the getContent
/onChange
is called before the this.state.empty
is updated. I think we should not rely on it here and recompute the empty
from the content. I think it's performant enough.
I was also wondering if we chould update this state as part of the onChange
handler (instead of selectionChange) but could be left as a separate enhancement.
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.
Yeah, this is a good point. Changes through setState
are not guaranteed to be applied immediately, so either we need to save it as a class property or just simply add it here. I think the latter sounds great as we can then also remove the selection change event. I can't think of a case where input wouldn't be fired when the field is emptied or filled?
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.
I can't think of a case where input wouldn't be fired when the field is emptied or filled?
Well I discovered that if you make a selection (a word or sentense) and click backspace, it's not triggered. This should be fixed anyway because it's creating a small bug in the side inserter. I was thinking at maybe calling onChange
on keydown
or something?
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.
Yeah, that's why the keyup handler is there, because selectionChange
has the same issue, at least in Chrome. I updated this PR. Nice small performance improvement as well because selectionChange
fires waaay more often.
Left some comments but I like the direction this is taking. |
bafd49b
to
1ec77c6
Compare
editor/utils/with-history/index.js
Outdated
@@ -63,6 +99,14 @@ export default function withHistory( reducer, options = {} ) { | |||
return state; | |||
} | |||
|
|||
if ( shouldOverwriteState( action, previousAction ) ) { |
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.
Maybe we can consolidate the return paths by assigning past
into a variable:
let nextPast = past;
if ( ! shouldOverwriteState( action, previousAction ) ) {
nextPast = [ ...nextPast, present ];
}
return {
past: nextPast,
present: nextPresent,
future: [],
};
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.
What would the benefit be? Does it read clearer to you?
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.
I personally find fewer return paths tends to be more clear, yes.
blocks/rich-text/index.js
Outdated
// from running, but we assume TinyMCE won't do anything on an | ||
// empty undo stack anyways. | ||
if ( onUndo && ( command === 'Undo' || command === 'Redo' ) ) { | ||
defer( onUndo ); |
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.
For Redo we'd want to onRedo
instead?
Also wondering if we need the defer
anymore if we're completely taking over handling of undo history.
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.
😰
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.
Added this. The defer
still seems needed. Encountering errors otherwise.
editor/store/selectors.js
Outdated
@@ -95,7 +95,8 @@ export function isSavingMetaBoxes( state ) { | |||
* @return {boolean} Whether undo history exists. | |||
*/ | |||
export function hasEditorUndo( state ) { | |||
return state.editor.past.length > 0; | |||
const { past, present } = state.editor; | |||
return past.length > 1 || last( past ) !== present; |
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.
Aren't we preventing past
from including present
? When would those be the same?
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.
They should be the same at the moment an undo record is created or at the start of history. This is so that present can be overwritten. Not sure I understand the first question.
Per #4008, it's not only block attributes that we'd want to be guarding against excessive undo history (there, the post title and text editor content updates). |
bd6754f
to
2217c1d
Compare
Pushed some more changes. PR makes some good simplifying changes to the Ready for another review I think. |
Conclusion for self: idea was good but implementation ended up a lot simpler than I thought... 🙈 |
@aduth I see this bug too. The problem here is that there are too many undo levels created still (cross block IDs). This is a bug in master too though, so let's address separately as well. |
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.
This is looking quite good. Left a few minor remarks, but I think we can plan to get this in otherwise.
Nice thing is that with these changes, it starts to become very clear where we're needlessly creating undo history. Found one instance of this with autosave that I'll plan a pull request for shortly.
blocks/rich-text/index.js
Outdated
} | ||
|
||
onAddUndo( { lastLevel } ) { | ||
if ( ! lastLevel ) { |
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.
Might help to include a note and/or reference to the fact that TinyMCE creates an initial undo level, which is why it's checked here.
https://github.com/tinymce/tinymce/blob/a4add29/src/core/main/ts/api/UndoManager.ts#L50-L53
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.
You remind the that there's actually another even in TinyMCE that skips this... I think in previous iteration it was required to catch the initial bookmark, but now that it's no longer setting focus it should be fine to use the change
event.
https://github.com/tinymce/tinymce/blob/a4add29/src/core/main/ts/api/UndoManager.ts#L242-L247
blocks/rich-text/index.js
Outdated
@@ -412,14 +395,25 @@ export class RichText extends Component { | |||
* | |||
* @param {boolean} checkIfDirty Check whether the editor is dirty before calling onChange. | |||
*/ | |||
onChange( checkIfDirty = true ) { |
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.
Since we're removing the argument, we should also remove the JSDoc tag.
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. Surprised there's no listing error for this one.
blocks/rich-text/index.js
Outdated
@@ -412,14 +395,25 @@ export class RichText extends Component { | |||
* | |||
* @param {boolean} checkIfDirty Check whether the editor is dirty before calling onChange. | |||
*/ | |||
onChange( checkIfDirty = true ) { | |||
if ( checkIfDirty && ! this.editor.isDirty() ) { |
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.
I imagine this should no longer be necessary:
gutenberg/blocks/rich-text/index.js
Line 803 in 9cd9c18
this.editor.setDirty( true ); |
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.
You may be right. Don't know if it's trying to communicate something else to TinyMCE after handling formatting ourselves. I don't immediately see anything broken by removing it though.
blocks/rich-text/index.js
Outdated
@@ -756,7 +740,14 @@ export class RichText extends Component { | |||
this.props.value !== prevProps.value && | |||
this.props.value !== this.savedContent | |||
) { | |||
this.updateContent(); |
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.
Curious why this was pulled out of the function and made inline. Ideally we should favor clearly named functions over inline logic in component lifecycle.
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.
Because at the start of this PR it was just one line and then piled up. :) Will move it back to a function.
editor/utils/with-history/index.js
Outdated
shouldCreateUndoLevel = ! past.length || shouldCreateUndoLevel; | ||
|
||
if ( ! shouldCreateUndoLevel && shouldOverwriteState( action, previousAction ) ) { | ||
nextPast = past; |
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.
The idea with the inverse condition in #4956 (comment) is that it avoids wastefully calculating a nextPast
above if we don't intend to use it anyways.
let nextPast = past;
if ( shouldCreateUndoLevel || ! shouldOverwriteState( action, previousAction ) ) {
nextPast = [ ...past, present ];
}
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.
I hadn't thought about that! Thanks for this tip!
Updated based on the last few comments. |
Alright, merging! 🎉 These changes make me really happy! Thanks for all the help @aduth and @youknowriad! |
expect( state.past ).toHaveLength( 2 ); | ||
} ); | ||
|
||
it( 'should not overwrite present history if updating same attributes', () => { |
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.
This likely meant to read: should not overwrite present history if updating different attributes. :)
this.props.onSplit( beforeElement, afterElement ); | ||
} else { | ||
event.preventDefault(); | ||
this.onCreateUndoLevel(); |
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.
Why do we create an undo level here? The behavior of createUndoLevel
is such that we'll trigger onChange
which in turn calls setAttributes
. When we're at the end of a paragraph block and pressing enter (a very common writing flow), we're thus needlessly calling setAttributes
when the content of the original paragraph hasn't changed at all (calling twice in fact, the other a separate issue).
We should either:
- Remove this line
- Document why it's needed with an inline comment
- And ideally try to avoid setting content
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.
See also #7482
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.
Unsure why we're adding one before splitting. Seems safe to remove, perhaps with a test ensuring right history behaviour on split.
this.editor.save(); | ||
// Do not trigger a change event coming from the TinyMCE undo manager. | ||
// Our global state is already up-to-date. | ||
this.editor.undoManager.ignore( () => { |
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.
What would be causing an undo level to be added? I added a breakpoint to TinyMCE's internal UndoManager#add
and it was never triggered as a result of anything which occurs in the callback of this function.
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.
When content is set, but not by MCE?
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.
But specifically the functions called within this block do not appear to add any undo levels from what I have been able to tell. See also #7620. Could we demonstrate by a failing end-to-end test what we need to be accounting for by its presence if it were to be removed?
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.
Worst case there's also a no_events
argument which can be passed with setContent
that may be an alternative to what we have here, if we really don't want cascading effects from setting the content.
Description
This PR proposes:
RichText
instances to create additional history records such as a formatting change, on moving the caret, etc. For this we can use theAddUndo
event TinyMCE fires.RichText
. This is necessary to actually undo the changes in the global state as well, not justRichText
and pile up changes in the global state.How Has This Been Tested?
Try undoing changes in the editor.
Checklist: