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
Autocomplete links to user posts #2896
Changes from 10 commits
2667d2d
96ed19e
5db79cd
fe4aa22
2386c1f
d8d91b4
053a478
fc53c3b
46875ad
ad8d036
f5eb752
ad93a3e
29b0a36
a23a608
e2988b4
4c0c793
ca6c469
83d697e
22baa78
3c5350a
94110cd
5220299
158c9d0
8791dd3
79712b8
56c0889
58f703e
ed9bd85
f48d7e1
8ae2e80
cb5aa58
f6234ae
7089532
4f437e3
2207026
2c75ae1
aa420d2
41e10da
5a25e32
c06e95e
dccc61e
3d7ffd5
bdae604
a25f6f7
78221f5
4e995dc
20fa7fb
bbae623
b9871c6
8fc7023
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 |
---|---|---|
@@ -0,0 +1,132 @@ | ||
/** | ||
* Internal dependencies | ||
*/ | ||
import './style.scss'; | ||
import { createBlock, getBlockTypes } from '../api'; | ||
import BlockIcon from '../block-icon'; | ||
|
||
/** | ||
* @typedef {Object} CompleterOption | ||
* @property {Array.<Component>} label list of react components to render. | ||
* @property {Array.<String>} keywords list of key words to search. | ||
* @property {*} value the value that will be passed to onSelect. | ||
*/ | ||
|
||
/** | ||
* @callback FnGetOptions | ||
* @returns {Promise.<Array.<CompleterOption>>} A promise that resolves to the list of completer options. | ||
*/ | ||
|
||
/** | ||
* @callback FnAllowNode | ||
* @param {Node} textNode check if the completer can handle this text node. | ||
* @returns {boolean} true if the completer can handle this text node. | ||
*/ | ||
|
||
/** | ||
* @callback FnAllowContext | ||
* @param {Range} before the range before the auto complete trigger and query. | ||
* @param {Range} range the range of the autocomplete trigger and query. | ||
* @param {Range} after the range after the autocomplete trigger and query. | ||
* @returns {boolean} true if the completer can handle these ranges. | ||
*/ | ||
|
||
/** | ||
* @callback FnOnSelect | ||
* @param {*} value the value of the completer option. | ||
* @param {Range} range the nodes included in the autocomplete trigger and query. | ||
* @param {String} query the text value of the autocomplete query. | ||
*/ | ||
|
||
/** | ||
* @typedef {Object} Completer | ||
* @property {?String} className A class to apply to the popup menu. | ||
* @property {String} triggerPrefix the prefix that will display the menu. | ||
* @property {FnGetOptions} getOptions get the block options in a resolved promise. | ||
* @property {?FnAllowNode} allowNode filter the allowed text nodes in the autocomplete. | ||
* @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates. | ||
* @property {FnOnSelect} onSelect | ||
*/ | ||
|
||
/** | ||
* Returns an "completer" definition for selecting from available blocks to replace the current one. | ||
* The definition can be understood by the Autocomplete component. | ||
* | ||
* @param {Function} onReplace Callback to replace the current block. | ||
* @returns {Completer} Completer object used by the Autocomplete component. | ||
*/ | ||
export function blockAutocompleter( { onReplace } ) { | ||
const options = getBlockTypes().map( ( blockType ) => { | ||
const { name, title, icon, keywords = [] } = blockType; | ||
return { | ||
value: name, | ||
label: [ | ||
<BlockIcon key="icon" icon={ icon } />, | ||
title, | ||
], | ||
keywords: [ ...keywords, title ], | ||
}; | ||
} ); | ||
|
||
const getOptions = () => Promise.resolve( options ); | ||
|
||
const allowContext = ( before, range, after ) => { | ||
return ! ( /\S/.test( before.toString() ) || /\S/.test( after.toString() ) ); | ||
}; | ||
|
||
const onSelect = ( blockName ) => { | ||
onReplace( createBlock( blockName ) ); | ||
}; | ||
|
||
return { | ||
className: 'blocks-block-autocomplete', | ||
triggerPrefix: '/', | ||
getOptions, | ||
allowContext, | ||
onSelect, | ||
}; | ||
} | ||
/** | ||
* Returns a "completer" definition for inserting links to the posts of a user. | ||
* The definition can be understood by the Autocomplete component. | ||
* | ||
* @returns {Completer} Completer object used by the Autocomplete component. | ||
*/ | ||
export function userAutocompleter() { | ||
const getOptions = () => { | ||
return ( new wp.api.collections.Users() ).fetch().then( ( users ) => { | ||
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. We might consider the implications for users who don't have permissions to list other users on a site. Currently this doesn't appear to break anything, but will only ever return administrator users if issued by a non-administrator. Probably fine, with the other option being that we don't enable the autocompleter for these accounts. 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. If you know of a better source for the information I will happily switch to it. |
||
return users.map( ( user ) => { | ||
return { | ||
value: user, | ||
label: [ | ||
<img key="avatar" alt="" src={ user.avatar_urls[ 24 ] } />, | ||
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. We should assign a meaningful 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. The problem with that is that screen readers will (for each item) read out "User avatar" before telling them the actually useful information of the user-name. Unless we can give an actually useful description it's probably more usable if they are just treated as decoration. 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. @afercia do you think we should put descriptive text on the avatar in the users menu or treat it as decorative? 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. @EphoxJames empty |
||
<span key="name" className="name">{ user.name }</span>, | ||
<span key="slug" className="slug">{ user.slug }</span>, | ||
], | ||
keywords: [ user.slug, user.name ], | ||
}; | ||
} ); | ||
} ); | ||
}; | ||
|
||
const allowNode = ( textNode ) => { | ||
return textNode.parentElement.closest( 'a' ) === null; | ||
}; | ||
|
||
const onSelect = ( user, range ) => { | ||
const mention = document.createElement( 'a' ); | ||
mention.href = user.link; | ||
mention.textContent = '@' + user.name; | ||
range.insertNode( mention ); | ||
range.setStartAfter( mention ); | ||
range.deleteContents(); | ||
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 wonder if the Same could be said above for the blocks autocompleter, assuming there's only one action to be done when selecting a block: replacing. What if the <Autocomplete key="editable" completers={ [
blockAutocompleter( block => onReplace( block ) ),
userAutocompleter( user => this.editor.insertContent( linkFromUser( user ) ) ),
] }> 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 guess the difficulty here is that we need to remove what we already typed. Maybe we should do this consistently before calling 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. The advantage of Range is that it is an editor agnostic API and in the past I have been told I can't assume that components will always work with TinyMCE. I specifically did not want the autocomplete code to make any assumptions about what would be done with the range. 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. Also one of the advantages of using range is that TinyMCE knows how to work with ranges. (user, range) => {
this.editor.selection.setRng( range );
this.editor.insertContent( linkFromUser( user ) );
} That would select the already typed content and immediately replace it with the inserted content. |
||
}; | ||
|
||
return { | ||
className: 'blocks-user-autocomplete', | ||
triggerPrefix: '@', | ||
getOptions, | ||
allowNode, | ||
onSelect, | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
.blocks-block-autocomplete .dashicon { | ||
margin-right: 8px; | ||
} | ||
|
||
.blocks-user-autocomplete img { | ||
margin-right: 8px; | ||
} | ||
|
||
.blocks-user-autocomplete .slug { | ||
margin-left: 8px; | ||
color: $dark-gray-100; | ||
} |
This file was deleted.
This file was deleted.
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,17 +8,17 @@ import classnames from 'classnames'; | |
*/ | ||
import { __ } from '@wordpress/i18n'; | ||
import { concatChildren } from '@wordpress/element'; | ||
import { PanelBody } from '@wordpress/components'; | ||
import { Autocomplete, PanelBody } from '@wordpress/components'; | ||
|
||
/** | ||
* Internal dependencies | ||
*/ | ||
import './style.scss'; | ||
import { registerBlockType, createBlock, source, setDefaultBlockName } from '../../api'; | ||
import { blockAutocompleter, userAutocompleter } from '../../autocompleters'; | ||
import AlignmentToolbar from '../../alignment-toolbar'; | ||
import BlockAlignmentToolbar from '../../block-alignment-toolbar'; | ||
import BlockControls from '../../block-controls'; | ||
import BlockAutocomplete from '../../block-autocomplete'; | ||
import Editable from '../../editable'; | ||
import InspectorControls from '../../inspector-controls'; | ||
import ToggleControl from '../../inspector-controls/toggle-control'; | ||
|
@@ -152,7 +152,10 @@ registerBlockType( 'core/paragraph', { | |
</PanelBody> | ||
</InspectorControls> | ||
), | ||
<BlockAutocomplete key="editable" onReplace={ onReplace }> | ||
<Autocomplete key="editable" completers={ [ | ||
blockAutocompleter( { onReplace } ), | ||
userAutocompleter(), | ||
] }> | ||
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. Nice 👍 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. Thinking the "patterns" we have right now in |
||
<Editable | ||
tagName="p" | ||
className={ classnames( 'wp-block-paragraph', className, { | ||
|
@@ -183,8 +186,8 @@ registerBlockType( 'core/paragraph', { | |
onMerge={ mergeBlocks } | ||
onReplace={ onReplace } | ||
placeholder={ placeholder || __( 'New Paragraph' ) } | ||
/> | ||
</BlockAutocomplete>, | ||
/> | ||
</Autocomplete>, | ||
]; | ||
}, | ||
|
||
|
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.
Can't understand the difference between
triggerPrefix
andallowContext
, could these be merged into one function?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.
TLDR: not without making it an annoying API to use.
triggerPrefix is the string that preceeds the query. For example "@" in the case of the user mentions, "/" in the case of block selection and "#" in the case of hash-tags (which aren't implemented yet). In theory you could just search back to the last space or boundary and pass the whole range to the completer definition to check but that that means that ALL completers would have to convert the range into text and check the leading substring. This puts more work on the implementer of the completer which is exactly where we don't want it.
allowContext was added to solve the problem that sometimes the completer has to know what preceeds it and follows it. The majority of the time this doesn't matter but the original implementation of the autocomplete block would require that there was nothing but whitespace before and afterwards (which makes sense because it replaces the whole block). This field is completely optional and most completers won't use it.