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:
committed by
Ian Storm Taylor
parent
dd6438436d
commit
df743127ca
@@ -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
|
|
@@ -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
|
||||||
|
@@ -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."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
Reference in New Issue
Block a user