1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-20 06:01:24 +02:00

Simplify the mentions example (#3079)

* Simplify the mentions example

* Fix wrong method call in onChange

* Return after inserting the mention
This commit is contained in:
Kamil Kamiński
2019-11-10 01:58:29 +01:00
committed by Ian Storm Taylor
parent dd6438436d
commit df743127ca
3 changed files with 106 additions and 275 deletions

View File

@@ -1,109 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { css } from 'emotion'
const SuggestionList = React.forwardRef((props, ref) => (
<ul
{...props}
ref={ref}
className={css`
background: #fff;
list-style: none;
margin: 0;
padding: 0;
position: absolute;
`}
/>
))
const Suggestion = props => (
<li
{...props}
className={css`
align-items: center;
border-left: 1px solid #ddd;
border-right: 1px solid #ddd;
border-top: 1px solid #ddd;
display: flex;
height: 32px;
padding: 4px 8px;
&:hover {
background: #87cefa;
}
&:last-of-type {
border-bottom: 1px solid #ddd;
}
`}
/>
)
const DEFAULT_POSITION = {
top: -10000,
left: -10000,
}
/**
* Suggestions is a PureComponent because we need to prevent updates when x/ y
* Are just going to be the same value. Otherwise we will update forever.
*/
class Suggestions extends React.PureComponent {
menuRef = React.createRef()
state = DEFAULT_POSITION
/**
* On update, update the menu.
*/
componentDidMount = () => {
this.updateMenu()
}
componentDidUpdate = () => {
this.updateMenu()
}
render() {
const root = window.document.getElementById('root')
return ReactDOM.createPortal(
<SuggestionList
ref={this.menuRef}
style={{
top: this.state.top,
left: this.state.left,
}}
>
{this.props.users.map(user => {
return (
<Suggestion key={user.id} onClick={() => this.props.onSelect(user)}>
{user.username}
</Suggestion>
)
})}
</SuggestionList>,
root
)
}
updateMenu() {
const anchor = window.document.querySelector(this.props.anchor)
if (!anchor) {
return this.setState(DEFAULT_POSITION)
}
const anchorRect = anchor.getBoundingClientRect()
this.setState({
top: anchorRect.bottom + window.pageYOffset,
left: anchorRect.left + window.pageXOffset,
})
}
}
export default Suggestions

View File

@@ -11,12 +11,7 @@ production implementation:
2. Linkifying the mentions - There isn't really a good place to link to for 2. Linkifying the mentions - There isn't really a good place to link to for
this example. But in most cases you would probably want to link to the this example. But in most cases you would probably want to link to the
user's profile on click. user's profile on click.
3. Keyboard accessibility - it adds quite a bit of complexity to the 3. Plugin Mentions - in reality, you will probably want to put mentions into a
implementation to add this, as it involves capturing keyboard events like up
/ down / enter and proxying them into the `Suggestions` component using a
`ref`. I've left this out because this is already a pretty confusing use
case.
4. Plugin Mentions - in reality, you will probably want to put mentions into a
plugin, and make them configurable to support more than one kind of mention, plugin, and make them configurable to support more than one kind of mention,
like users and hashtags. As you can see below it is a bit unweildy to bolt like users and hashtags. As you can see below it is a bit unweildy to bolt
all this directly to the editor. all this directly to the editor.
@@ -27,12 +22,34 @@ https://en.wikipedia.org/wiki/List_of_Star_Wars_characters
import { Editor } from 'slate-react' import { Editor } from 'slate-react'
import { Value } from 'slate' import { Value } from 'slate'
import _ from 'lodash'
import React from 'react' import React from 'react'
import initialValue from './value.json'
import users from './users.json' import users from './users.json'
import Suggestions from './Suggestions'
/**
* @typedef {Object} User
* @property {string} username
* @property {string} id
*/
const initialEditorValue = {
object: 'value',
document: {
object: 'document',
nodes: [
{
object: 'block',
type: 'paragraph',
nodes: [
{
object: 'text',
text: 'Try mentioning some users, like Luke or Leia.',
},
],
},
],
},
}
/** /**
* @type {String} * @type {String}
@@ -40,26 +57,6 @@ import Suggestions from './Suggestions'
const USER_MENTION_NODE_TYPE = 'userMention' const USER_MENTION_NODE_TYPE = 'userMention'
/**
* The annotation type that the menu will position itself against. The
* "context" is just the current text after the @ symbol.
* @type {String}
*/
const CONTEXT_ANNOTATION_TYPE = 'mentionContext'
/**
* Get a unique key for the search highlight annotations.
*
* @return {String}
*/
let n = 0
function getMentionKey() {
return `highlight_${n++}`
}
const schema = { const schema = {
inlines: { inlines: {
[USER_MENTION_NODE_TYPE]: { [USER_MENTION_NODE_TYPE]: {
@@ -79,12 +76,12 @@ const schema = {
const CAPTURE_REGEX = /@(\S*)$/ const CAPTURE_REGEX = /@(\S*)$/
/** /**
* Get get the potential mention input. * Get the potential mention input.
* *
* @type {Value} * @param {Value} value
*/ */
function getInput(value) { function getMentionInput(value) {
// In some cases, like if the node that was selected gets deleted, // In some cases, like if the node that was selected gets deleted,
// `startText` can be null. // `startText` can be null.
if (!value.startText) { if (!value.startText) {
@@ -98,6 +95,39 @@ function getInput(value) {
return result == null ? null : result[1] return result == null ? null : result[1]
} }
/**
* Determine if the current selection has valid ancestors for a context. In our
* case, we want to make sure that the mention is only a direct child of a
* paragraph. In this simple example it isn't that important, but in a complex
* editor you wouldn't want it to be a child of another inline like a link.
*
* @param {Value} value
*/
function hasValidAncestors(value) {
const { document, selection } = value
// In this simple case, we only want mentions to live inside a paragraph.
// This check can be adjusted for more complex rich text implementations.
return document.getParent(selection.start.key).type === 'paragraph'
}
/**
* Get an array of users that match the given search query
*
* @param {string} searchQuery
*
* @returns {User[]} array of users matching the `searchQuery`
*/
function searchUsers(searchQuery) {
if (!searchQuery) return
const regex = RegExp(`^${searchQuery}`, 'gi')
return users.filter(({ username }) => username.match(regex))
}
/** /**
* @extends React.Component * @extends React.Component
*/ */
@@ -110,8 +140,7 @@ class MentionsExample extends React.Component {
*/ */
state = { state = {
users: [], value: Value.fromJSON(initialEditorValue),
value: Value.fromJSON(initialValue),
} }
/** /**
@@ -126,38 +155,17 @@ class MentionsExample extends React.Component {
<Editor <Editor
spellCheck spellCheck
autoFocus autoFocus
placeholder="Try mentioning some people..."
value={this.state.value} value={this.state.value}
onChange={this.onChange} onChange={this.onChange}
ref={this.editorRef} ref={this.editorRef}
renderInline={this.renderInline} renderInline={this.renderInline}
renderBlock={this.renderBlock} renderBlock={this.renderBlock}
renderAnnotation={this.renderAnnotation}
schema={schema} schema={schema}
/> />
<Suggestions
anchor=".mention-context"
users={this.state.users}
onSelect={this.insertMention}
/>
</div> </div>
) )
} }
renderAnnotation(props, editor, next) {
if (props.annotation.type === CONTEXT_ANNOTATION_TYPE) {
return (
// Adding the className here is important so that the `Suggestions`
// component can find an anchor.
<span {...props.attributes} className="mention-context">
{props.children}
</span>
)
}
return next()
}
renderBlock(props, editor, next) { renderBlock(props, editor, next) {
const { attributes, node } = props const { attributes, node } = props
@@ -183,14 +191,12 @@ class MentionsExample extends React.Component {
/** /**
* Replaces the current "context" with a user mention node corresponding to * Replaces the current "context" with a user mention node corresponding to
* the given user. * the given user.
* @param {Object} user * @param {User} user
* @param {string} user.id
* @param {string} user.username
*/ */
insertMention = user => { insertMention(user) {
const value = this.state.value const value = this.state.value
const inputValue = getInput(value) const inputValue = getMentionInput(value)
const editor = this.editorRef.current const editor = this.editorRef.current
// Delete the captured value, including the `@` symbol // Delete the captured value, including the `@` symbol
@@ -221,104 +227,56 @@ class MentionsExample extends React.Component {
} }
/** /**
* On change, save the new `value`. * On change, save the new `value` and look for mentions.
* *
* @param {Editor} editor * @param {Change} editor
*/ */
onChange = change => { onChange = change => {
const inputValue = getInput(change.value) this.setState({ value: change.value })
if (inputValue !== this.lastInputValue) { const mentionInputValue = getMentionInput(change.value)
this.lastInputValue = inputValue
if (hasValidAncestors(change.value)) { if (!mentionInputValue || !hasValidAncestors(change.value)) {
this.search(inputValue)
}
const { selection } = change.value
let annotations = change.value.annotations.filter(
annotation => annotation.type !== CONTEXT_ANNOTATION_TYPE
)
if (inputValue && hasValidAncestors(change.value)) {
const key = getMentionKey()
annotations = annotations.set(key, {
anchor: {
key: selection.start.key,
offset: selection.start.offset - inputValue.length,
},
focus: {
key: selection.start.key,
offset: selection.start.offset,
},
type: CONTEXT_ANNOTATION_TYPE,
key: getMentionKey(),
})
}
this.setState({ value: change.value }, () => {
// We need to set annotations after the value flushes into the editor.
this.editorRef.current.setAnnotations(annotations)
})
return return
} }
const searchResult = searchUsers(mentionInputValue)
if (!searchResult.length) {
return
}
if (searchResult.length === 1) {
this.insertMention(searchResult[0])
return
}
if (searchResult.length <= 5) {
const username = window.prompt(
`Type in the rest of the name, or just more of it. You can choose from these: ${searchResult
.map(u => u.username)
.join(', ')}`,
mentionInputValue
)
if (
!username ||
username === mentionInputValue ||
!username.startsWith(mentionInputValue)
) {
return
}
const nextSearchResult = searchUsers(username)
if (nextSearchResult.length === 1) {
this.insertMention(nextSearchResult[0])
} else {
this.setState({ value: change.value }) this.setState({ value: change.value })
} }
/**
* Get an array of users that match the given search query
*
* @type {String}
*/
search(searchQuery) {
// We don't want to show the wrong users for the current search query, so
// wipe them out.
this.setState({
users: [],
})
if (!searchQuery) return
// In order to make this seem like an API call, add a set timeout for some
// async.
setTimeout(() => {
// WARNING: In a production environment you should escape the search query.
const regex = RegExp(`^${searchQuery}`, 'gi')
// If you want to get fancy here, you can add some emphasis to the part
// of the string that matches.
const result = _.filter(users, user => {
return user.username.match(regex)
})
this.setState({
// Only return the first 5 results
users: result.slice(0, 5),
})
}, 50)
} }
} }
/**
* Determine if the current selection has valid ancestors for a context. In our
* case, we want to make sure that the mention is only a direct child of a
* paragraph. In this simple example it isn't that important, but in a complex
* editor you wouldn't want it to be a child of another inline like a link.
*
* @param {Value} value
*/
function hasValidAncestors(value) {
const { document, selection } = value
// In this simple case, we only want mentions to live inside a paragraph.
// This check can be adjusted for more complex rich text implementations.
return document.getParent(selection.start.key).type === 'paragraph'
} }
export default MentionsExample export default MentionsExample

View File

@@ -1,18 +0,0 @@
{
"object": "value",
"document": {
"object": "document",
"nodes": [
{
"object": "block",
"type": "paragraph",
"nodes": [
{
"object": "text",
"text": "Try mentioning some users, like Luke or Leia."
}
]
}
]
}
}