-
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
Changes from 12 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,23 @@ 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.onAddUndo = this.onAddUndo.bind( this ); | ||||
this.onCreateUndoLevel = this.onCreateUndoLevel.bind( this ); | ||||
|
||||
this.state = { | ||||
formats: {}, | ||||
empty: ! value || ! value.length, | ||||
selectedNodeId: 0, | ||||
}; | ||||
|
||||
this.isEmpty = ! value || ! value.length; | ||||
} | ||||
|
||||
/** | ||||
|
@@ -161,6 +161,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 +183,15 @@ 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 ); | ||||
editor.on( 'addundo', this.onAddUndo ); | ||||
|
||||
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(); | ||||
} | ||||
} | ||||
|
||||
|
@@ -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 commentThe 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 commentThe reason will be displayed to describe this comment to others. Learn more. Oh. Surprised there's no listing error for this one. |
||||
if ( checkIfDirty && ! this.editor.isDirty() ) { | ||||
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 imagine this should no longer be necessary: gutenberg/blocks/rich-text/index.js Line 803 in 9cd9c18
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. 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. |
||||
|
||||
onChange() { | ||||
this.isEmpty = this.editor.dom.isEmpty( this.editor.getBody() ); | ||||
this.savedContent = this.isEmpty ? [] : this.getContent(); | ||||
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. Unrelated: Is this check necessary anymore since This is all in an effort to avoid 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. I'm not sure. I'm thinking there could be more cases where something would get inserted by MCE or the browser where the field looks empty but isn't actually. Maybe @youknowriad knows more about the introduction of this check here. In any case, this sounds like a good thing to reconsider in a separate PR. |
||||
this.props.onChange( this.savedContent ); | ||||
} | ||||
|
||||
onAddUndo( { lastLevel } ) { | ||||
if ( ! lastLevel ) { | ||||
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. 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 commentThe 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 https://github.com/tinymce/tinymce/blob/a4add29/src/core/main/ts/api/UndoManager.ts#L242-L247 |
||||
return; | ||||
} | ||||
const isEmpty = tinymce.DOM.isEmpty( this.editor.getBody() ); | ||||
this.savedContent = isEmpty ? [] : this.getContent(); | ||||
this.props.onChange( this.savedContent ); | ||||
this.editor.save(); | ||||
|
||||
this.onCreateUndoLevel(); | ||||
} | ||||
|
||||
onCreateUndoLevel() { | ||||
// Always ensure the content is up-to-date. | ||||
this.onChange(); | ||||
this.context.onCreateUndoLevel(); | ||||
} | ||||
|
||||
/** | ||||
|
@@ -547,7 +541,7 @@ export class RichText extends Component { | |||
return; | ||||
} | ||||
|
||||
this.onChange( false ); | ||||
this.onCreateUndoLevel(); | ||||
|
||||
const forward = event.keyCode === DELETE; | ||||
|
||||
|
@@ -586,6 +580,7 @@ export class RichText extends Component { | |||
} | ||||
|
||||
event.preventDefault(); | ||||
this.onCreateUndoLevel(); | ||||
|
||||
const childNodes = Array.from( rootNode.childNodes ); | ||||
const index = dom.nodeIndex( selectedNode ); | ||||
|
@@ -594,10 +589,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(); | ||||
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. Why do we create an undo level here? The behavior of We should either:
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. See also #7482 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. Unsure why we're adding one before splitting. Seems safe to remove, perhaps with a test ensuring right history behaviour on split. |
||||
|
||||
if ( event.shiftKey || ! this.props.onSplit ) { | ||||
this.editor.execCommand( 'InsertLineBreak', false, event ); | ||||
|
@@ -614,8 +609,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(); | ||||
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. Previously we were only invoking 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. Because similarly to |
||||
} | ||||
} | ||||
|
||||
|
@@ -651,10 +648,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 ); | ||||
} | ||||
} | ||||
|
@@ -725,17 +720,6 @@ export class RichText extends Component { | |||
this.setState( { formats, focusPosition, selectedNodeId: this.state.selectedNodeId + 1 } ); | ||||
} | ||||
|
||||
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(); | ||||
} | ||||
|
||||
setContent( content = '' ) { | ||||
this.editor.setContent( renderToString( content ) ); | ||||
} | ||||
|
@@ -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 commentThe 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 commentThe 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. |
||||
// Do not trigger a `addUndo` event. | ||||
this.editor.undoManager.ignore( () => { | ||||
const bookmark = this.editor.selection.getBookmark( 2, true ); | ||||
|
||||
this.savedContent = this.props.value; | ||||
this.setContent( this.savedContent ); | ||||
this.editor.selection.moveToBookmark( bookmark ); | ||||
} ); | ||||
} | ||||
} | ||||
|
||||
|
@@ -827,15 +818,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 +879,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.
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.