mirror of
https://github.com/ianstormtaylor/slate.git
synced 2025-08-19 13:41:19 +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
|
||||
this example. But in most cases you would probably want to link to the
|
||||
user's profile on click.
|
||||
3. Keyboard accessibility - it adds quite a bit of complexity to the
|
||||
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
|
||||
3. 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,
|
||||
like users and hashtags. As you can see below it is a bit unweildy to bolt
|
||||
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 { Value } from 'slate'
|
||||
import _ from 'lodash'
|
||||
import React from 'react'
|
||||
|
||||
import initialValue from './value.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}
|
||||
@@ -40,26 +57,6 @@ import Suggestions from './Suggestions'
|
||||
|
||||
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 = {
|
||||
inlines: {
|
||||
[USER_MENTION_NODE_TYPE]: {
|
||||
@@ -79,12 +76,12 @@ const schema = {
|
||||
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,
|
||||
// `startText` can be null.
|
||||
if (!value.startText) {
|
||||
@@ -98,6 +95,39 @@ function getInput(value) {
|
||||
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
|
||||
*/
|
||||
@@ -110,8 +140,7 @@ class MentionsExample extends React.Component {
|
||||
*/
|
||||
|
||||
state = {
|
||||
users: [],
|
||||
value: Value.fromJSON(initialValue),
|
||||
value: Value.fromJSON(initialEditorValue),
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,38 +155,17 @@ class MentionsExample extends React.Component {
|
||||
<Editor
|
||||
spellCheck
|
||||
autoFocus
|
||||
placeholder="Try mentioning some people..."
|
||||
value={this.state.value}
|
||||
onChange={this.onChange}
|
||||
ref={this.editorRef}
|
||||
renderInline={this.renderInline}
|
||||
renderBlock={this.renderBlock}
|
||||
renderAnnotation={this.renderAnnotation}
|
||||
schema={schema}
|
||||
/>
|
||||
<Suggestions
|
||||
anchor=".mention-context"
|
||||
users={this.state.users}
|
||||
onSelect={this.insertMention}
|
||||
/>
|
||||
</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) {
|
||||
const { attributes, node } = props
|
||||
|
||||
@@ -183,14 +191,12 @@ class MentionsExample extends React.Component {
|
||||
/**
|
||||
* Replaces the current "context" with a user mention node corresponding to
|
||||
* the given user.
|
||||
* @param {Object} user
|
||||
* @param {string} user.id
|
||||
* @param {string} user.username
|
||||
* @param {User} user
|
||||
*/
|
||||
|
||||
insertMention = user => {
|
||||
insertMention(user) {
|
||||
const value = this.state.value
|
||||
const inputValue = getInput(value)
|
||||
const inputValue = getMentionInput(value)
|
||||
const editor = this.editorRef.current
|
||||
|
||||
// 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 => {
|
||||
const inputValue = getInput(change.value)
|
||||
this.setState({ value: change.value })
|
||||
|
||||
if (inputValue !== this.lastInputValue) {
|
||||
this.lastInputValue = inputValue
|
||||
const mentionInputValue = getMentionInput(change.value)
|
||||
|
||||
if (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)
|
||||
})
|
||||
if (!mentionInputValue || !hasValidAncestors(change.value)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setState({ value: change.value })
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
@@ -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