-
Notifications
You must be signed in to change notification settings - Fork 4.3k
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
Changes from all commits
3573516
be5268e
aad0c3c
3ae5775
5b7ceab
36b0d55
a052e3a
7e0c360
f11607c
d1da833
2217c1d
9cd9c18
bbe1dff
2c7d283
6d4cd20
9750e4b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,6 @@ | ||
/** | ||
* External dependencies | ||
*/ | ||
import tinymce from 'tinymce'; | ||
import classnames from 'classnames'; | ||
import { | ||
last, | ||
|
@@ -131,22 +130,22 @@ export class RichText extends Component { | |
this.getSettings = this.getSettings.bind( this ); | ||
this.onSetup = this.onSetup.bind( this ); | ||
this.onChange = this.onChange.bind( this ); | ||
this.onInput = this.onChange.bind( this, false ); | ||
this.onNewBlock = this.onNewBlock.bind( this ); | ||
this.onNodeChange = this.onNodeChange.bind( this ); | ||
this.onKeyDown = this.onKeyDown.bind( this ); | ||
this.onKeyUp = this.onKeyUp.bind( this ); | ||
this.changeFormats = this.changeFormats.bind( this ); | ||
this.onSelectionChange = this.onSelectionChange.bind( this ); | ||
this.maybePropagateUndo = this.maybePropagateUndo.bind( this ); | ||
this.onPropagateUndo = this.onPropagateUndo.bind( this ); | ||
this.onPastePreProcess = this.onPastePreProcess.bind( this ); | ||
this.onPaste = this.onPaste.bind( this ); | ||
this.onCreateUndoLevel = this.onCreateUndoLevel.bind( this ); | ||
|
||
this.state = { | ||
formats: {}, | ||
empty: ! value || ! value.length, | ||
selectedNodeId: 0, | ||
}; | ||
|
||
this.isEmpty = ! value || ! value.length; | ||
} | ||
|
||
/** | ||
|
@@ -161,6 +160,9 @@ export class RichText extends Component { | |
return ( this.props.getSettings || identity )( { | ||
...settings, | ||
forced_root_block: this.props.multiline || false, | ||
// Allow TinyMCE to keep one undo level for comparing changes. | ||
// Prevent it otherwise from accumulating any history. | ||
custom_undo_redo_levels: 1, | ||
} ); | ||
} | ||
|
||
|
@@ -180,16 +182,16 @@ export class RichText extends Component { | |
} ); | ||
|
||
editor.on( 'init', this.onInit ); | ||
editor.on( 'focusout', this.onChange ); | ||
editor.on( 'NewBlock', this.onNewBlock ); | ||
editor.on( 'nodechange', this.onNodeChange ); | ||
editor.on( 'keydown', this.onKeyDown ); | ||
editor.on( 'keyup', this.onKeyUp ); | ||
editor.on( 'selectionChange', this.onSelectionChange ); | ||
editor.on( 'BeforeExecCommand', this.maybePropagateUndo ); | ||
editor.on( 'BeforeExecCommand', this.onPropagateUndo ); | ||
editor.on( 'PastePreProcess', this.onPastePreProcess, true /* Add before core handlers */ ); | ||
editor.on( 'paste', this.onPaste, true /* Add before core handlers */ ); | ||
editor.on( 'input', this.onInput ); | ||
editor.on( 'input', this.onChange ); | ||
// The change event in TinyMCE fires every time an undo level is added. | ||
editor.on( 'change', this.onCreateUndoLevel ); | ||
|
||
patterns.apply( this, [ editor ] ); | ||
|
||
|
@@ -242,42 +244,23 @@ export class RichText extends Component { | |
} ); | ||
} | ||
|
||
/** | ||
* Handles the global selection change event. | ||
*/ | ||
onSelectionChange() { | ||
const isActive = document.activeElement === this.editor.getBody(); | ||
// We must check this because selectionChange is a global event. | ||
if ( ! isActive ) { | ||
return; | ||
} | ||
|
||
const isEmpty = tinymce.DOM.isEmpty( this.editor.getBody() ); | ||
if ( this.state.empty !== isEmpty ) { | ||
this.setState( { empty: isEmpty } ); | ||
} | ||
} | ||
|
||
/** | ||
* Handles an undo event from tinyMCE. | ||
* | ||
* When user attempts Undo when empty Undo stack, propagate undo | ||
* action to context handler. The compromise here is that: TinyMCE | ||
* handles Undo until change, at which point `editor.save` resets | ||
* history. If no history exists, let context handler have a turn. | ||
* Defer in case an immediate undo causes TinyMCE to be destroyed, | ||
* if other undo behaviors test presence of an input field. | ||
* | ||
* @param {UndoEvent} event The undo event as triggered by tinyMCE. | ||
* @param {UndoEvent} event The undo event as triggered by TinyMCE. | ||
*/ | ||
maybePropagateUndo( event ) { | ||
const { onUndo } = this.context; | ||
if ( onUndo && event.command === 'Undo' && ! this.editor.undoManager.hasUndo() ) { | ||
onPropagateUndo( event ) { | ||
const { onUndo, onRedo } = this.context; | ||
const { command } = event; | ||
|
||
if ( command === 'Undo' && onUndo ) { | ||
defer( onUndo ); | ||
event.preventDefault(); | ||
} | ||
|
||
// We could return false here to stop other TinyMCE event handlers | ||
// from running, but we assume TinyMCE won't do anything on an | ||
// empty undo stack anyways. | ||
if ( command === 'Redo' && onRedo ) { | ||
defer( onRedo ); | ||
event.preventDefault(); | ||
} | ||
} | ||
|
||
|
@@ -409,17 +392,18 @@ export class RichText extends Component { | |
|
||
/** | ||
* Handles any case where the content of the tinyMCE instance has changed. | ||
* | ||
* @param {boolean} checkIfDirty Check whether the editor is dirty before calling onChange. | ||
*/ | ||
onChange( checkIfDirty = true ) { | ||
if ( checkIfDirty && ! this.editor.isDirty() ) { | ||
return; | ||
} | ||
const isEmpty = tinymce.DOM.isEmpty( this.editor.getBody() ); | ||
this.savedContent = isEmpty ? [] : this.getContent(); | ||
|
||
onChange() { | ||
this.isEmpty = this.editor.dom.isEmpty( this.editor.getBody() ); | ||
this.savedContent = this.isEmpty ? [] : this.getContent(); | ||
this.props.onChange( this.savedContent ); | ||
this.editor.save(); | ||
} | ||
|
||
onCreateUndoLevel() { | ||
// Always ensure the content is up-to-date. | ||
this.onChange(); | ||
this.context.onCreateUndoLevel(); | ||
} | ||
|
||
/** | ||
|
@@ -547,7 +531,7 @@ export class RichText extends Component { | |
return; | ||
} | ||
|
||
this.onChange( false ); | ||
this.onCreateUndoLevel(); | ||
|
||
const forward = event.keyCode === DELETE; | ||
|
||
|
@@ -586,6 +570,7 @@ export class RichText extends Component { | |
} | ||
|
||
event.preventDefault(); | ||
this.onCreateUndoLevel(); | ||
|
||
const childNodes = Array.from( rootNode.childNodes ); | ||
const index = dom.nodeIndex( selectedNode ); | ||
|
@@ -594,10 +579,10 @@ export class RichText extends Component { | |
const beforeElement = nodeListToReact( beforeNodes, createTinyMCEElement ); | ||
const afterElement = nodeListToReact( afterNodes, createTinyMCEElement ); | ||
|
||
this.setContent( beforeElement ); | ||
this.props.onSplit( beforeElement, afterElement ); | ||
} else { | ||
event.preventDefault(); | ||
this.onCreateUndoLevel(); | ||
|
||
if ( event.shiftKey || ! this.props.onSplit ) { | ||
this.editor.execCommand( 'InsertLineBreak', false, event ); | ||
|
@@ -614,8 +599,10 @@ export class RichText extends Component { | |
* @param {number} keyCode The key code that has been pressed on the keyboard. | ||
*/ | ||
onKeyUp( { keyCode } ) { | ||
// The input event does not fire when the whole field is selected and | ||
// BACKSPACE is pressed. | ||
if ( keyCode === BACKSPACE ) { | ||
this.onSelectionChange(); | ||
this.onChange(); | ||
} | ||
} | ||
|
||
|
@@ -651,10 +638,8 @@ export class RichText extends Component { | |
|
||
const beforeElement = nodeListToReact( beforeFragment.childNodes, createTinyMCEElement ); | ||
const afterElement = nodeListToReact( filterEmptyNodes( afterFragment.childNodes ), createTinyMCEElement ); | ||
this.setContent( beforeElement ); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not entirely clear why this change was warranted, but in basic usage splitting a paragraph in the middle works as I'd expect. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, before we had to set the content to make sure it was synced by the time props.onSplit happens. |
||
this.props.onSplit( beforeElement, afterElement, ...blocks ); | ||
} else { | ||
this.setContent( [] ); | ||
this.props.onSplit( [], [], ...blocks ); | ||
} | ||
} | ||
|
@@ -726,14 +711,15 @@ export class RichText extends Component { | |
} | ||
|
||
updateContent() { | ||
const bookmark = this.editor.selection.getBookmark( 2, true ); | ||
this.savedContent = this.props.value; | ||
this.setContent( this.savedContent ); | ||
this.editor.selection.moveToBookmark( bookmark ); | ||
|
||
// Saving the editor on updates avoid unecessary onChanges calls | ||
// These calls can make the focus jump | ||
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 commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 commentThe 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 commentThe reason will be displayed to describe this comment to others. Learn more. Worst case there's also a |
||
const bookmark = this.editor.selection.getBookmark( 2, true ); | ||
|
||
this.savedContent = this.props.value; | ||
this.setContent( this.savedContent ); | ||
this.editor.selection.moveToBookmark( bookmark ); | ||
} ); | ||
} | ||
|
||
setContent( content = '' ) { | ||
|
@@ -808,8 +794,6 @@ export class RichText extends Component { | |
this.setState( ( state ) => ( { | ||
formats: merge( {}, state.formats, formats ), | ||
} ) ); | ||
|
||
this.editor.setDirty( true ); | ||
} | ||
|
||
render() { | ||
|
@@ -827,15 +811,14 @@ export class RichText extends Component { | |
isSelected = false, | ||
formatters, | ||
} = this.props; | ||
const { empty } = this.state; | ||
|
||
const ariaProps = pickAriaProps( this.props ); | ||
|
||
// Generating a key that includes `tagName` ensures that if the tag | ||
// changes, we unmount and destroy the previous TinyMCE element, then | ||
// mount and initialize a new child element in its place. | ||
const key = [ 'editor', Tagname ].join(); | ||
const isPlaceholderVisible = placeholder && ( ! isSelected || keepPlaceholderOnFocus ) && empty; | ||
const isPlaceholderVisible = placeholder && ( ! isSelected || keepPlaceholderOnFocus ) && this.isEmpty; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm thinking this change could potentially create an issue if the block is not rerendered when the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Won't this component always rerender if the value props change as well? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, it should unless the parent component is "uncontrolled" or something. Granted we don't use it this way though There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As noted above, I'd like if we could avoid assigning There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds good, but I don't want to go too deep into empty checks in this PR. |
||
const classes = classnames( wrapperClassName, 'blocks-rich-text' ); | ||
|
||
const formatToolbar = ( | ||
|
@@ -889,7 +872,9 @@ export class RichText extends Component { | |
|
||
RichText.contextTypes = { | ||
onUndo: noop, | ||
onRedo: noop, | ||
canUserUseUnfilteredHTML: noop, | ||
onCreateUndoLevel: noop, | ||
}; | ||
|
||
RichText.defaultProps = { | ||
|
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 triggeronChange
which in turn callssetAttributes
. When we're at the end of a paragraph block and pressing enter (a very common writing flow), we're thus needlessly callingsetAttributes
when the content of the original paragraph hasn't changed at all (calling twice in fact, the other a separate issue).We should either:
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.