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

Proposal: history "buffer"/overwrite, sync RichText history records #4956

Merged
merged 16 commits into from
Feb 16, 2018

Conversation

ellatrix
Copy link
Member

@ellatrix ellatrix commented Feb 8, 2018

Description

This PR proposes:

  • Overwriting/buffering changes in the history reducer when block attributes are updated and the block id and updated attribute keys are the same. This will fix the cases where history records are added for every single character change.
  • With this change it is OK for editable to sync continuously.
  • In addition, we'd like changes RichText instances to create additional history records such as a formatting change, on moving the caret, etc. For this we can use the AddUndo event TinyMCE fires.
  • Completely propagate undo/redo command from RichText. This is necessary to actually undo the changes in the global state as well, not just RichText and pile up changes in the global state.

How Has This Been Tested?

Try undoing changes in the editor.

Checklist:

  • My code is tested.
  • My code follows the WordPress code style.
  • My code has proper inline documentation.

if ( this.editor.isDirty() ) {
this.fireChange();
this.savedContent = this.getContent();
this.props.onChange( this.state.empty ? [] : this.savedContent );
Copy link
Contributor

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.

@ellatrix
Copy link
Member Author

ellatrix commented Feb 9, 2018

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.

@youknowriad
Copy link
Contributor

youknowriad commented Feb 12, 2018

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:

  • If we're updating a different block attribute, we add a new undo state
  • If we're updating a different block, we add a new undo state
  • If we're updating the same block but after a long enough timeout (say 10 seconds), we create new undo state
  • If we're updating the same block and same attribute but in a short timeout, we override the previous state.

What do you think, it should be pretty easy to implement?

@ellatrix
Copy link
Member Author

ellatrix commented Feb 12, 2018

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 RichText component decide when to create history records. TinyMCE will look internally at when formatting is applied, at some special keys, at moving the caret, etc. etc. I'm fine to move this looking over to Gutenberg in e.g. writing flow (or in the future), but setting a timeout won't be enough. For now it seems good to just add a level whenever TinyMCE fires AddUndo, on top of looking at block ID diff.

@youknowriad
Copy link
Contributor

youknowriad commented Feb 12, 2018

For now it seems good to just add a level whenever TinyMCE fires AddUndo, on top of looking at block ID diff.

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 bufferTypes in this PR, but maybe something like the forceUndoStateTypes.

Do you want to work on this? I can't update my PR accordingly if necessary

@ellatrix
Copy link
Member Author

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 👍

Yeah, agree there too. :) In this branch, I used context.

@ellatrix
Copy link
Member Author

@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 RichText component when needed.

@ellatrix ellatrix changed the title Proposal: sync RichText state, history buffer, sync RichText history records Proposal: history "buffer"/overwrite, sync RichText history records Feb 12, 2018
@ellatrix ellatrix force-pushed the try/undo-buffer branch 3 times, most recently from 4c517aa to daab823 Compare February 13, 2018 10:24
partialRight( withHistory, {
resetTypes: [ 'SETUP_NEW_POST', 'SETUP_EDITOR' ],
shouldOverwriteState( action, previousAction ) {
if ( ! includes( [ 'UPDATE_BLOCK_ATTRIBUTES', 'EDIT_POST', 'RESET_POST' ], action.type ) ) {
Copy link
Member Author

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.

@ellatrix ellatrix removed the [Status] In Progress Tracking issues with work in progress label Feb 13, 2018
return action.uid === previousAction.uid && isEqual( attributes, previousAttributes );
}

return true;
Copy link
Contributor

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?

Copy link
Contributor

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

Copy link
Member Author

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?

Copy link
Contributor

@youknowriad youknowriad Feb 14, 2018

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;
},

Copy link
Member Author

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?

Copy link
Contributor

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?

Copy link
Member Author

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.

Copy link
Contributor

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.

Copy link
Contributor

@youknowriad youknowriad Feb 15, 2018

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)?

Copy link
Member Author

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.

@@ -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() ) {
Copy link
Contributor

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.

Copy link
Member Author

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?

Copy link
Contributor

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)

Copy link
Member Author

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... :)

Copy link
Member Author

Choose a reason for hiding this comment

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

setContent( content = '' ) {
this.editor.setContent( renderToString( content ) );
}

getContent() {
if ( this.state.empty ) {
Copy link
Contributor

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.

Copy link
Member Author

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?

Copy link
Contributor

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?

Copy link
Member Author

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.

@youknowriad
Copy link
Contributor

Left some comments but I like the direction this is taking.

@@ -63,6 +99,14 @@ export default function withHistory( reducer, options = {} ) {
return state;
}

if ( shouldOverwriteState( action, previousAction ) ) {
Copy link
Member

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: [],
};

Copy link
Member Author

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?

Copy link
Member

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.

// from running, but we assume TinyMCE won't do anything on an
// empty undo stack anyways.
if ( onUndo && ( command === 'Undo' || command === 'Redo' ) ) {
defer( onUndo );
Copy link
Member

@aduth aduth Feb 13, 2018

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.

Copy link
Member Author

Choose a reason for hiding this comment

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

😰

Copy link
Member Author

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.

@@ -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;
Copy link
Member

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?

Copy link
Member Author

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.

@aduth
Copy link
Member

aduth commented Feb 13, 2018

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).

@aduth aduth added this to the 2.2 milestone Feb 14, 2018
@ellatrix
Copy link
Member Author

Pushed some more changes. PR makes some good simplifying changes to the RichText component too I think. Note that there will still be actions on init and autosave that will still create undo levels. Let's address those separately then.

Ready for another review I think.

@ellatrix ellatrix requested a review from a team February 15, 2018 22:12
@ellatrix
Copy link
Member Author

Conclusion for self: idea was good but implementation ended up a lot simpler than I thought... 🙈

@ellatrix
Copy link
Member Author

When splitting a paragraph mid-way and Undo'ing immediately via Cmd-Z, the new block's content simply disappears.

@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.

Copy link
Member

@aduth aduth left a 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.

}

onAddUndo( { lastLevel } ) {
if ( ! lastLevel ) {
Copy link
Member

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

Copy link
Member Author

@ellatrix ellatrix Feb 16, 2018

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

@@ -412,14 +395,25 @@ export class RichText extends Component {
*
* @param {boolean} checkIfDirty Check whether the editor is dirty before calling onChange.
*/
onChange( checkIfDirty = true ) {
Copy link
Member

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.

Copy link
Member Author

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.

@@ -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() ) {
Copy link
Member

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:

this.editor.setDirty( true );

Copy link
Member Author

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.

@@ -756,7 +740,14 @@ export class RichText extends Component {
this.props.value !== prevProps.value &&
this.props.value !== this.savedContent
) {
this.updateContent();
Copy link
Member

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.

Copy link
Member Author

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.

shouldCreateUndoLevel = ! past.length || shouldCreateUndoLevel;

if ( ! shouldCreateUndoLevel && shouldOverwriteState( action, previousAction ) ) {
nextPast = past;
Copy link
Member

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 ];
}

Copy link
Member Author

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!

@ellatrix
Copy link
Member Author

Updated based on the last few comments.

@ellatrix
Copy link
Member Author

Alright, merging! 🎉 These changes make me really happy! Thanks for all the help @aduth and @youknowriad!

@ellatrix ellatrix merged commit 8897445 into master Feb 16, 2018
expect( state.past ).toHaveLength( 2 );
} );

it( 'should not overwrite present history if updating same attributes', () => {
Copy link
Contributor

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();
Copy link
Member

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

Copy link
Member

Choose a reason for hiding this comment

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

See also #7482

Copy link
Member Author

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( () => {
Copy link
Member

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.

Copy link
Member Author

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?

Copy link
Member

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?

Copy link
Member

@aduth aduth Jun 30, 2018

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Feature] Rich Text Related to the Rich Text component that allows developers to render a contenteditable [Priority] High Used to indicate top priority items that need quick attention
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants