Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions components/autocomplete/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 `<abbr>` element with a `title` attribute.

```jsx
const fruitCompleter = {
Expand Down
214 changes: 191 additions & 23 deletions components/autocomplete/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -207,7 +208,6 @@ export class Autocomplete extends Component {
suppress: undefined,
open: undefined,
query: undefined,
range: undefined,
filteredOptions: [],
};
}
Expand All @@ -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 );
Expand All @@ -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 *' );
Copy link
Member

Choose a reason for hiding this comment

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

Is the dependency defined in this file? See also #7159.

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks for the reminder. I missed that.

I added import of 'element-closest' to add the Element#matches polyfill for now, but I'm hoping to work on a better way of registering and including polyfills later this week.


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 } =
Expand All @@ -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() {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;

Expand All @@ -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 ) {
Expand All @@ -486,15 +627,15 @@ 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 ] ) {
this.announce( filteredOptions );
}
}

handleKeyDown( event ) {
captureNativeKeyDown( event ) {
const { open, suppress, selectedIndex, filteredOptions } = this.state;
if ( ! open ) {
return;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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 ) {
Expand Down Expand Up @@ -604,6 +765,7 @@ export class Autocomplete extends Component {
<div
ref={ this.bindNode }
onInput={ this.search }
onKeyDown={ this.handleKeyDown }
onClick={ this.resetWhenSuppressed }
className="components-autocomplete"
>
Expand Down Expand Up @@ -645,6 +807,12 @@ export class Autocomplete extends Component {
}
}

Autocomplete.defaultProps = {
transactInsertion( f ) {
f();
},
};

export default compose( [
withSpokenMessages,
withInstanceId,
Expand Down
Loading