diff --git a/components/autocomplete/README.md b/components/autocomplete/README.md index 102f88d4532501..206388a53fa8fe 100644 --- a/components/autocomplete/README.md +++ b/components/autocomplete/README.md @@ -14,7 +14,9 @@ Each completer declares: * Raw option data. * How to render an option's label. * An option's keywords, words that will be used to match an option with user input. -* What the completion of an option looks like, including whether it should be inserted in the text or used to replace the current block. +* What the completion of an option looks like, including: + * The kind of completion. Is it an editable, text-based completion or a non-editable completion containing HTML structure? + * How it should be inserted. Should it be inserted in the text or used to replace the current block? In addition, a completer may optionally declare: @@ -77,6 +79,11 @@ There are currently two supported actions: * "insert-at-caret" - Insert the `value` into the text (the default completion action). * "replace" - Replace the current block with the block specified in the `value` property. +An inserted completion is treated one of two ways, depending on its content: + +1. A text-only completion is inserted as a styled editable token. The purpose is to satisfy use cases like simple user mentions (e.g., "@username"). +2. A completion containing HTML is inserted as a non-editable token. This supports the creation of completers that insert arbitrary HTML content. + #### allowNode A function that takes a text node and returns a boolean indicating whether the completer should be considered for that node. @@ -107,7 +114,29 @@ Whether to apply debouncing for the autocompleter. Set to true to enable debounc ### Examples -The following is a contrived completer for fresh fruit. +#### Editable completions + +Here is a contrived completer for feelings. It yields editable tokens like "!resolute". + +```jsx +const feelingCompleter = { + name: 'feeling', + // The prefix that triggers this completer + triggerPrefix: '!', + // The option data + options: [ 'happy', 'hopeful', 'resolute' ], + // Returns a simple, text-only label + getOptionLabel: option => option, + // Declares that options should be matched by their text + getOptionKeywords: option => [ option ], + // Declares completions should be inserted as text + getOptionCompletion: option => '!' + option, +}; +``` + +#### Structured, non-editable completions + +The following is a contrived completer for fresh fruit. It yields an `` element with a `title` attribute. ```jsx const fruitCompleter = { diff --git a/components/autocomplete/index.js b/components/autocomplete/index.js index 57a950dcd7b2ac..48f3fb214a82fd 100644 --- a/components/autocomplete/index.js +++ b/components/autocomplete/index.js @@ -2,7 +2,8 @@ * External dependencies */ import classnames from 'classnames'; -import { escapeRegExp, find, filter, map, debounce } from 'lodash'; +import { escapeRegExp, find, filter, map, debounce, every } from 'lodash'; +import 'element-closest'; /** * WordPress dependencies @@ -207,7 +208,6 @@ export class Autocomplete extends Component { suppress: undefined, open: undefined, query: undefined, - range: undefined, filteredOptions: [], }; } @@ -220,6 +220,7 @@ export class Autocomplete extends Component { this.reset = this.reset.bind( this ); this.resetWhenSuppressed = this.resetWhenSuppressed.bind( this ); this.search = this.search.bind( this ); + this.captureNativeKeyDown = this.captureNativeKeyDown.bind( this ); this.handleKeyDown = this.handleKeyDown.bind( this ); this.getWordRect = this.getWordRect.bind( this ); this.debouncedLoadOptions = debounce( this.loadOptions, 250 ); @@ -231,30 +232,128 @@ export class Autocomplete extends Component { this.node = node; } - insertCompletion( range, replacement ) { - const container = document.createElement( 'div' ); - container.innerHTML = renderToString( replacement ); - while ( container.firstChild ) { - const child = container.firstChild; - container.removeChild( child ); - range.insertNode( child ); - range.setStartAfter( child ); + insertCompletion( range, replacement, completerName ) { + const selection = window.getSelection(); + /* + * If we are replacing a range containing the cursor, + * we need to set the cursor position afterward. + */ + const shouldSetCursor = + selection.focusNode === range.startContainer && + selection.focusOffset >= range.startOffset && + selection.focusNode === range.endContainer && + selection.focusOffset <= range.endOffset; + + const tokenWrapper = document.createElement( 'span' ); + tokenWrapper.innerHTML = renderToString( replacement ); + + // Remember the completer in case the user wants to edit the token. + tokenWrapper.dataset.autocompleter = completerName; + + // Add classes for general and completer-specific styling. + tokenWrapper.classList.add( 'autocomplete-token' ); + tokenWrapper.classList.add( `autocomplete-token-${ completerName }` ); + + if ( ! every( tokenWrapper.childNodes, isTextNode ) ) { + // This represents an autocompletion with arbitrary structure + // and is marked readonly, allowing it to be deleted but not edited. + + // Explicitly communicate we intend this as a readonly token. + tokenWrapper.classList.add( 'readonly-token' ); + + // Make new token readonly. + tokenWrapper.contentEditable = false; } - range.deleteContents(); + + // Wrap insertions as a transaction so insertions can be integrated with undo stacks. + this.props.transactInsertion( () => { + const existingTokenWrapper = this.getTokenWrapperNode( range.startContainer ); + if ( existingTokenWrapper ) { + /** + * If we're within an existing token, we want to replace it rather + * than inserting a completion within a completion. + */ + existingTokenWrapper.parentNode.replaceChild( tokenWrapper, existingTokenWrapper ); + } else { + range.insertNode( tokenWrapper ); + } + + range.setStartAfter( tokenWrapper ); + range.deleteContents(); + + if ( shouldSetCursor ) { + selection.removeAllRanges(); + + const newCursorPosition = document.createRange(); + + /** + * Add a zero-width non-breaking space (ZWNBSP) so we can place cursor + * after. TinyMCE handles ZWNBSP's nicely around token boundaries, + * adding and removing them as necessary as the keyboard is used to + * move the cursor in and out of boundaries. + */ + tokenWrapper.parentNode.insertBefore( + document.createTextNode( '\uFEFF' ), + tokenWrapper.nextSibling + ); + newCursorPosition.setStartAfter( tokenWrapper.nextSibling ); + selection.addRange( newCursorPosition ); + } + } ); + } + + /** + * Gets the token wrapper for a node if it has one. + * + * @param {Node} possibleTokenChild The node for which to find the wrapper. + * + * @return {Node?} The token wrapper or null if there is none. + * @private + */ + getTokenWrapperNode( possibleTokenChild ) { + const element = isTextNode( possibleTokenChild ) ? + possibleTokenChild.parentNode : + possibleTokenChild; + const withinToken = element.matches( '.autocomplete-token, .autocomplete-token *' ); + + return withinToken ? element.closest( '.autocomplete-token' ) : null; + } + + /** + * Gets the completer associated with the specified autocomplete token wrapper. + * + * @param {Node} tokenWrapperNode The wrapper node. + * + * @return {(Completer|undefined)} The completer associated with the wrapper or + * undefined if one is not found. + * @private + */ + getCompleterFromTokenWrapper( tokenWrapperNode ) { + const completerName = tokenWrapperNode.dataset.autocompleter; + return find( this.props.completers, ( c ) => completerName === c.name ); } select( option ) { const { onReplace } = this.props; - const { open, range, query } = this.state; - const { getOptionCompletion } = open || {}; + const { open, query } = this.state; + const { getOptionCompletion, name: completerName } = open || {}; if ( option.isDisabled ) { return; } - this.reset(); - if ( getOptionCompletion ) { + /** + * We have to find the completion range on-demand because we have observed the editor + * to occasionally replace text nodes referred to by saved ranges. This changed the + * ranges to refer to a text node's parent rather than the node itself and resulted + * in incorrectly inserted completions. + */ + const range = this.getLatestCompletionRange(); + if ( ! range ) { + return; + } + const completion = getOptionCompletion( option.value, range, query ); const { action, value } = @@ -265,17 +364,19 @@ export class Autocomplete extends Component { if ( 'replace' === action ) { onReplace( [ value ] ); } else if ( 'insert-at-caret' === action ) { - this.insertCompletion( range, value ); + this.insertCompletion( range, value, completerName ); } else if ( 'backcompat' === action ) { // NOTE: This block should be removed once we no longer support the old completer interface. const onSelect = value; const deprecatedOptionObject = option.value; const selectionResult = onSelect( deprecatedOptionObject.value, range, query ); if ( selectionResult !== undefined ) { - this.insertCompletion( range, selectionResult ); + this.insertCompletion( range, selectionResult, completerName ); } } } + + this.reset(); } reset() { @@ -310,6 +411,35 @@ export class Autocomplete extends Component { return null; } + /** + * Gets the current completion range based on the cursor and the list of completers. + * + * The purpose of this function is to get the current completion range. + * Originally, we saved the completion range when we identified the completer to use, + * but we found that the editor occasionally replaces text nodes included by the range which + * caused the range to refer to a parent element and not to the intended text nodes. + * This resulted in incorrect insertion of completion results. + * + * @return {?Range} A DOM range representing the current completion range, if one exists. + * @private + */ + getLatestCompletionRange() { + const contentEditableDescendant = this.node && this.node.querySelector( '[contenteditable=true]' ); + if ( ! contentEditableDescendant ) { + return null; + } + + const cursor = this.getCursor( contentEditableDescendant ); + const { open } = this.state; + + if ( ! cursor || ! open ) { + return null; + } + + const { range = null } = this.findMatch( contentEditableDescendant, cursor, [ open ], open ) || {}; + return range; + } + // this method is separate so it can be overridden in tests createRange( startNode, startOffset, endNode, endOffset ) { const range = document.createRange(); @@ -458,7 +588,7 @@ export class Autocomplete extends Component { } search( event ) { - const { completers } = this.props; + let { completers } = this.props; const { open: wasOpen, suppress: wasSuppress, query: wasQuery } = this.state; const container = event.target; @@ -467,9 +597,20 @@ export class Autocomplete extends Component { if ( ! cursor ) { return; } + + /** + * If the cursor is within a autocompletion token, we only consider the same completer + * because we want to support editing but not inserting a completion within a completion. + */ + const tokenNode = this.getTokenWrapperNode( cursor.node ); + if ( tokenNode ) { + const currentCompleter = this.getCompleterFromTokenWrapper( tokenNode ); + completers = currentCompleter ? [ currentCompleter ] : []; + } + // look for the trigger prefix and search query just before the cursor location const match = this.findMatch( container, cursor, completers, wasOpen ); - const { open, query, range } = match || {}; + const { open, query } = match || {}; // asynchronously load the options for the open completer if ( open && ( ! wasOpen || open.idx !== wasOpen.idx || query !== wasQuery ) ) { if ( open.isDebounced ) { @@ -486,7 +627,7 @@ export class Autocomplete extends Component { const suppress = ( open && wasSuppress === open.idx ) ? wasSuppress : undefined; // update the state if ( wasOpen || open ) { - this.setState( { selectedIndex: 0, filteredOptions, suppress, search, open, query, range } ); + this.setState( { selectedIndex: 0, filteredOptions, suppress, search, open, query } ); } // announce the count of filtered options but only if they have loaded if ( open && this.state[ 'options_' + open.idx ] ) { @@ -494,7 +635,7 @@ export class Autocomplete extends Component { } } - handleKeyDown( event ) { + captureNativeKeyDown( event ) { const { open, suppress, selectedIndex, filteredOptions } = this.state; if ( ! open ) { return; @@ -558,8 +699,28 @@ export class Autocomplete extends Component { event.stopPropagation(); } + handleKeyDown( event ) { + const { keyCode, ctrlKey, shiftKey, altKey, metaKey } = event; + const { open, suppress, query } = this.state; + const completerIsOpenAndUnsuppressed = open && suppress !== open.idx; + + if ( ! completerIsOpenAndUnsuppressed ) { + return; + } + + if ( keyCode === SPACE && ! ( ctrlKey || shiftKey || altKey || metaKey ) ) { + // Insert a completion when the user spaces after typing an exact option match. + const exactMatchSearch = new RegExp( '^' + escapeRegExp( query ) + '$', 'i' ); + const currentCompleterOptions = this.state[ 'options_' + open.idx ]; + const [ exactMatch ] = filterOptions( exactMatchSearch, currentCompleterOptions ); + if ( exactMatch ) { + this.select( exactMatch ); + } + } + } + getWordRect() { - const { range } = this.state; + const range = this.getLatestCompletionRange(); if ( ! range ) { return; } @@ -574,7 +735,7 @@ export class Autocomplete extends Component { // and avoid RichText getting the event from TinyMCE, hence we must // register a native event handler. const handler = isListening ? 'addEventListener' : 'removeEventListener'; - this.node[ handler ]( 'keydown', this.handleKeyDown, true ); + this.node[ handler ]( 'keydown', this.captureNativeKeyDown, true ); } componentDidUpdate( prevProps, prevState ) { @@ -604,6 +765,7 @@ export class Autocomplete extends Component {
@@ -645,6 +807,12 @@ export class Autocomplete extends Component { } } +Autocomplete.defaultProps = { + transactInsertion( f ) { + f(); + }, +}; + export default compose( [ withSpokenMessages, withInstanceId, diff --git a/components/autocomplete/style.scss b/components/autocomplete/style.scss index 2928c7626881c1..d85500e9b34e9a 100644 --- a/components/autocomplete/style.scss +++ b/components/autocomplete/style.scss @@ -34,3 +34,17 @@ @include button-style__hover; } } + +.autocomplete-token:not(.readonly-token) { + border: 1px solid $light-gray-800; + background: $light-gray-300; + // Set the color since a some selected rich text colors + // may not look good against the token background color. + color: #444; + border-radius: 10px; + padding: 2px 4px; +} + +.readonly-token[data-mce-selected] { + background: $blue-medium-highlight; +} diff --git a/editor/components/rich-text/index.js b/editor/components/rich-text/index.js index 3fcec36dcaba5b..49865403bb4ec2 100644 --- a/editor/components/rich-text/index.js +++ b/editor/components/rich-text/index.js @@ -120,6 +120,7 @@ export class RichText extends Component { this.onPaste = this.onPaste.bind( this ); this.onCreateUndoLevel = this.onCreateUndoLevel.bind( this ); this.setFocusedElement = this.setFocusedElement.bind( this ); + this.transactAutocompletion = this.transactAutocompletion.bind( this ); this.state = { formats: {}, @@ -536,6 +537,20 @@ export class RichText extends Component { } } + /** + * Takes a function to insert an autocompletion and runs it as an undoable action. + * + * @param {Function} insertCompletion A function that inserts a completion. + * @private + */ + transactAutocompletion( insertCompletion ) { + if ( this.editor ) { + this.editor.undoManager.transact( insertCompletion ); + } else { + insertCompletion(); + } + } + scrollToRect( rect ) { const { top: caretTop } = rect; const container = getScrollContainer( this.editor.getBody() ); @@ -892,7 +907,11 @@ export class RichText extends Component { containerRef={ this.containerRef } /> } - + { ( { isExpanded, listBoxId, activeId } ) => ( editorNode.removeEventListener( 'keydown', handleKeyDown, true ); + + /** + * Begins listening for input and keyup on Backspace keydown. + * + * @param {KeyboardEvent} event The keydown event. + */ + function handleKeyDown( event ) { + if ( event.keyCode === BACKSPACE && event.repeat ) { + // Only apply the fix for the first keydown before keyup. + return; + } + + dispatchedInput = false; + keyDownAnchorNode = window.getSelection().anchorNode; + + document.addEventListener( 'input', noteInput, true ); + document.addEventListener( 'keyup', fixInputOnKeyUp, true ); + } + + /** + * Remembers if an input event was dispatched. + */ + function noteInput() { + dispatchedInput = true; + } + + /** + * Manually dispatches an input event if needed on Backspace keyup. + * + * @param {KeyboardEvent} event The keyup event. + */ + function fixInputOnKeyUp( event ) { + if ( event.keyCode !== BACKSPACE ) { + return; + } + + document.removeEventListener( 'input', noteInput, true ); + document.removeEventListener( 'keyup', fixInputOnKeyUp, true ); + + const selectionAnchorChanged = window.getSelection().anchorNode !== keyDownAnchorNode; + + // If the selection anchor changed, the Backspace likely modified the content, + // and if we haven't seen a corresponding `input` event, dispatch one. + if ( selectionAnchorChanged && ! dispatchedInput && InputEvent ) { + event.target.dispatchEvent( + // NOTE: We can rely on the InputEvent constructor because the IE11 + // contenteditable `input` fix already dispatches `input` for backspace. + new InputEvent( 'input', { bubbles: true } ) + ); + } + } +} + const IS_PLACEHOLDER_VISIBLE_ATTR_NAME = 'data-is-placeholder-visible'; export default class TinyMCE extends Component { constructor() { @@ -161,14 +235,15 @@ export default class TinyMCE extends Component { browser_spellcheck: true, entity_encoding: 'raw', convert_urls: false, - inline_boundaries_selector: 'a[href],code,b,i,strong,em,del,ins,sup,sub', + inline_boundaries_selector: 'a[href],code,b,i,strong,em,del,ins,sup,sub,.autocomplete-token', plugins: [], formats: { strikethrough: { inline: 'del' }, }, } ); - settings.plugins.push( 'paste' ); + settings.plugins.push( 'paste', 'noneditable' ); + settings.noneditable_noneditable_class = 'readonly-token'; tinymce.init( { ...settings, @@ -195,6 +270,15 @@ export default class TinyMCE extends Component { if ( editorNode && needsInternetExplorerInputFix( editorNode ) ) { this.removeInternetExplorerInputFix = applyInternetExplorerInputFix( editorNode ); } + + if ( this.removeBackspaceInputFix ) { + this.removeBackspaceInputFix(); + this.removeBackspaceInputFix = null; + } + + if ( editorNode ) { + this.removeBackspaceInputFix = applyBackspaceInputFix( editorNode ); + } } render() { diff --git a/lib/client-assets.php b/lib/client-assets.php index c601e50f86170b..c70f44ec54f3c0 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -325,6 +325,7 @@ function gutenberg_register_scripts_and_styles() { 'hr', 'lists', 'media', + 'noneditable', 'paste', 'tabfocus', 'textcolor', @@ -410,6 +411,7 @@ function gutenberg_register_scripts_and_styles() { 'wp-viewport', 'wp-tinymce', 'tinymce-latest-lists', + 'tinymce-latest-noneditable', 'tinymce-latest-paste', 'tinymce-latest-table', 'wp-nux', @@ -628,6 +630,11 @@ function gutenberg_register_vendor_scripts() { 'https://unpkg.com/tinymce@' . $tinymce_version . '/plugins/lists/plugin' . $suffix . '.js', array( 'wp-tinymce' ) ); + gutenberg_register_vendor_script( + 'tinymce-latest-noneditable', + 'https://unpkg.com/tinymce@' . $tinymce_version . '/plugins/noneditable/plugin' . $suffix . '.js', + array( 'wp-tinymce' ) + ); gutenberg_register_vendor_script( 'tinymce-latest-paste', 'https://unpkg.com/tinymce@' . $tinymce_version . '/plugins/paste/plugin' . $suffix . '.js', diff --git a/test/e2e/specs/autocompletion.test.js b/test/e2e/specs/autocompletion.test.js new file mode 100644 index 00000000000000..3f05c0d1ef9ee9 --- /dev/null +++ b/test/e2e/specs/autocompletion.test.js @@ -0,0 +1,91 @@ +/** + * Internal dependencies + */ +import '../support/bootstrap'; +import { newPost, newDesktopBrowserPage } from '../support/utils'; + +describe( 'autocompletion', () => { + beforeAll( async () => { + await newDesktopBrowserPage(); + await newPost(); + } ); + + it( 'adds a token followed by a zero-width non-breaking space and the cursor', async () => { + await page.click( '.editor-default-block-appender' ); + + const contentEditableHandle = ( + await page.evaluateHandle( () => document.activeElement ) + ); + + await contentEditableHandle.asElement().type( '@' ); + const optionNode = await page.waitForSelector( '.components-autocomplete__result', { timeout: 10000 } ); + optionNode.click(); + + // Wait for content update. + await page.waitForFunction( + ( contentEditableNode ) => contentEditableNode.textContent.length > 0, + { timeout: 1000 }, + contentEditableHandle + ); + + // Confirm we contain the selection. + expect( await page.evaluate( + ( contentEditableNode ) => { + const { anchorNode, isCollapsed } = window.getSelection(); + return contentEditableNode.contains( anchorNode ) && isCollapsed; + }, + contentEditableHandle + ) ).toBeTruthy(); + + // Confirm the expected content. + expect( await page.evaluate( + ( contentEditableNode ) => contentEditableNode.textContent, + contentEditableHandle + ) ).toMatch( /^@\w+\uFEFF$/ ); + + // Selection is placed after a single zero-width space text node. + const selectionAnchorHandle = await page.evaluateHandle( () => window.getSelection().anchorNode ); + expect( await page.evaluate( + ( contentEditableNode, anchorNode ) => contentEditableNode === anchorNode, + contentEditableHandle, + selectionAnchorHandle + ) ).toBeTruthy(); + const zeroWidthNonbreakingSpace = '\uFEFF'; + const selectionAnchorOffset = await page.evaluate( () => window.getSelection().anchorOffset ); + expect( await page.evaluate( + ( anchorNode, anchorOffset ) => { + const lastChildNodeIndex = anchorNode.childNodes.length - 1; + return anchorOffset === lastChildNodeIndex; + }, + selectionAnchorHandle, + selectionAnchorOffset + ) ).toBeTruthy(); + const spaceNodeHandle = await page.evaluateHandle( + ( anchorNode, anchorOffset ) => anchorNode.childNodes[ anchorOffset ].previousSibling, + selectionAnchorHandle, + selectionAnchorOffset + ); + expect( await page.evaluate( + ( spaceNode ) => spaceNode.textContent, + spaceNodeHandle + ) ).toBe( zeroWidthNonbreakingSpace ); + + // The autocomplete token precedes the space. + const tokenHandle = await page.evaluateHandle( + ( spaceNode ) => spaceNode.previousSibling, + spaceNodeHandle + ); + expect( await page.evaluate( + ( tokenNode ) => tokenNode.nodeType === window.Node.ELEMENT_NODE, + tokenHandle + ) ).toBeTruthy(); + expect( await page.evaluate( + ( tokenNode ) => tokenNode.classList.contains( 'autocomplete-token' ), + tokenHandle + ) ).toBeTruthy(); + expect( await page.evaluate( + ( tokenNode ) => /^@\w+$/.test( tokenNode.textContent ), + tokenHandle + ) ).toBeTruthy(); + } ); +} );