Skip to content

Commit 79b6dd2

Browse files
author
Matthew Russell
committed
WIP rendering the mention dropdown inside the text area
1 parent b787f21 commit 79b6dd2

File tree

13 files changed

+283
-10
lines changed

13 files changed

+283
-10
lines changed

site/components/AutoComplete/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ export default class MentionEditorExample extends React.Component {
160160
editorState.getCurrentContent(),
161161
mentionTextSelection,
162162
mention.handle,
163-
null,
163+
null, // no inline style neeeded
164164
entityKey
165165
);
166166

@@ -206,7 +206,7 @@ export default class MentionEditorExample extends React.Component {
206206
}
207207

208208
return (
209-
<div tyle={styles.root}>
209+
<div style={styles.root}>
210210
<div style={styles.editor} onClick={this.focus}>
211211
<Editor
212212
editorState={this.state.editorState}

site/components/UnicornEditor/index.js

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Editor, { createEmpty } from 'draft-js-plugin-editor';
33
import hashtagPlugin from 'draft-js-hashtag-plugin';
44
import stickerPlugin from 'draft-js-sticker-plugin';
55
import linkifyPlugin from 'draft-js-linkify-plugin';
6+
import mentionPlugin from 'draft-js-mention-plugin';
67
import { EditorState } from 'draft-js';
78
import styles from './styles';
89
import stickers from './stickers';
@@ -22,11 +23,16 @@ const plugins = List([
2223

2324
export default class UnicornEditor extends Component {
2425

25-
state = {
26-
editorState: createEmpty(plugins),
27-
readOnly: false,
28-
showState: false,
29-
};
26+
constructor(props) {
27+
super(props);
28+
const mentionPluginInstance = mentionPlugin(this);
29+
30+
this.state = {
31+
editorState: createEmpty(List([mentionPluginInstance])),
32+
readOnly: false,
33+
showState: false,
34+
};
35+
}
3036

3137
onChange = (editorState) => {
3238
this.setState({

site/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
22
import ReactDOM from 'react-dom';
3-
import UnicornEditor from './components/AutoComplete';
3+
import UnicornEditor from './components/UnicornEditor';
44

55
// export for http://fb.me/react-devtools
66
window.React = React;

site/webpack.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ module.exports = {
2424
'draft-js-hashtag-plugin': path.join(__dirname, '..', 'src', 'hashtagPlugin'),
2525
'draft-js-sticker-plugin': path.join(__dirname, '..', 'src', 'stickerPlugin'),
2626
'draft-js-linkify-plugin': path.join(__dirname, '..', 'src', 'linkifyPlugin'),
27+
'draft-js-mention-plugin': path.join(__dirname, '..', 'src', 'mentionPlugin'),
2728
react: path.join(__dirname, 'node_modules', 'react'),
2829
},
2930
extensions: ['', '.js'],

src/mentionPlugin/Mention/index.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import React from 'react';
2+
import { Entity } from 'draft-js';
3+
4+
import styles from './styles';
5+
6+
const Mention = props => {
7+
const { mention } = Entity.get(props.entityKey).getData();
8+
return (
9+
<a href={mention.link} style={styles.mention}>{`@${mention.handle}`}</a>
10+
);
11+
};
12+
13+
export default Mention;

src/mentionPlugin/Mention/styles.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default {
2+
mention: {
3+
color: '#3b5998',
4+
cursor: 'pointer',
5+
display: 'inline',
6+
textDecoration: 'underline',
7+
},
8+
};
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import React, { Component, PropTypes } from 'react';
2+
3+
export default class Dropdown extends Component {
4+
5+
static propTypes = {
6+
children: PropTypes.node.isRequired,
7+
style: PropTypes.object,
8+
};
9+
10+
static defaultProps = {
11+
style: {},
12+
};
13+
14+
render() {
15+
return (
16+
<div className="dropdown-container">
17+
<div className="dropdown-item-container" style={this.props.style}>
18+
{this.props.children}
19+
</div>
20+
</div>
21+
);
22+
}
23+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import React, { Component } from 'react';
2+
3+
// import styles from './styles';
4+
import Dropdown from './Dropdown';
5+
import getRangeBoundingClientRect from 'draft-js/lib/getRangeBoundingClientRect'
6+
7+
import addMention from '../modifiers/addMention';
8+
9+
// TODO move to util
10+
// TODO this is pretty hacky, maybe a better way?
11+
/* eslint-disable */
12+
function getWordAt(str, pos) {
13+
14+
// Perform type conversions.
15+
str = String(str);
16+
pos = Number(pos) >>> 0;
17+
18+
// Search for the word's beginning and end.
19+
var left = str.slice(0, pos + 1).search(/\S+$/),
20+
right = str.slice(pos).search(/\s/);
21+
22+
// The last word in the string is a special case.
23+
if (right < 0) {
24+
return {
25+
word: str.slice(left),
26+
begin: left,
27+
end: str.length,
28+
};
29+
}
30+
31+
// Return the word, using the located bounds to extract it from the string.
32+
return {
33+
word: str.slice(left, right + pos),
34+
begin: left,
35+
end: right + pos,
36+
};
37+
}
38+
/* eslint-enable */
39+
40+
41+
export default (editor, mentions) => {
42+
return class MentionSearch extends Component {
43+
44+
constructor(props) {
45+
super(props);
46+
47+
const editorState = editor.state.editorState;
48+
const content = editorState.getCurrentContent();
49+
const selection = editorState.getSelection();
50+
const currentBlock = content.getBlockForKey(selection.getAnchorKey());
51+
const blockText = currentBlock.getText();
52+
const { word, begin, end } = getWordAt(blockText, selection.getAnchorOffset());
53+
const text = this.props.children[0].props.text;
54+
if (text.startsWith('@')) {
55+
this.mentionSearch = text.substring(1);
56+
} else {
57+
this.mentionSearch = '';
58+
}
59+
this.mentionSearchBegin = begin;
60+
this.mentionSearchEnd = end;
61+
const range = global.getSelection().getRangeAt(0);
62+
this.rect = getRangeBoundingClientRect(range); // TODO use getViewportSelectionRect instead
63+
}
64+
65+
onMentionSelect = mention => () => {
66+
editor.onChange(addMention(
67+
editor.state.editorState,
68+
mention,
69+
this.mentionSearchBegin,
70+
this.mentionSearchEnd
71+
));
72+
};
73+
74+
// Get the first 5 mentions that match
75+
getMentionsForFilter = () => mentions.filter(m => m.handle.startsWith(this.mentionSearch)).slice(0,5)
76+
77+
renderItemForMention = mention => (
78+
<div key={mention.handle}
79+
eventKey={mention.handle}
80+
className="mention-custom-dropdown-item"
81+
onClick={this.onMentionSelect(mention)}
82+
>
83+
<span className="bold">{`@${mention.handle} `}</span>
84+
</div>
85+
);
86+
87+
render() {
88+
const dropdownStyle = {
89+
position: 'static',
90+
border: '1px solid black',
91+
top: this.rect.top,
92+
left: this.rect.left,
93+
marginTop: '11px',
94+
width: '200px',
95+
textAlign: 'left',
96+
};
97+
return (
98+
<div style={{ display: 'inline' }}>
99+
<span>{ this.props.children[0].props.text }</span>
100+
<Dropdown isOpen={this.mentionSearch !== null} style={dropdownStyle}>
101+
{this.getMentionsForFilter().map(this.renderItemForMention)}
102+
</Dropdown>
103+
</div>
104+
);
105+
}
106+
};
107+
};

src/mentionPlugin/index.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import Mention from './Mention';
2+
import MentionSearch from './MentionSearch';
3+
import mentionStrategy from './mentionStrategy';
4+
import mentionSearchStrategy from './mentionSearchStrategy';
5+
6+
// TODO init form unicorn editor
7+
const mentions = [
8+
{ handle: 'mjrussell', link: 'https://twitter.com/mrussell247' },
9+
{ handle: 'nikgraf', link: 'https://twitter.com/nikgraf' },
10+
{ handle: 'dan_abramov', link: 'https://twitter.com/dan_abramov' },
11+
];
12+
13+
export default (editorContext) => ({
14+
compositeDecorators: [
15+
{
16+
strategy: mentionStrategy,
17+
component: Mention,
18+
},
19+
{
20+
strategy: mentionSearchStrategy(editorContext),
21+
component: MentionSearch(editorContext, mentions),
22+
}
23+
],
24+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Entity } from 'draft-js';
2+
3+
// TODO this is pretty hacky, maybe a better way?
4+
/* eslint-disable */
5+
function getWordAt(str, pos) {
6+
7+
// Perform type conversions.
8+
str = String(str);
9+
pos = Number(pos) >>> 0;
10+
11+
// Search for the word's beginning and end.
12+
var left = str.slice(0, pos + 1).search(/\S+$/),
13+
right = str.slice(pos).search(/\s/);
14+
15+
// The last word in the string is a special case.
16+
if (right < 0) {
17+
return {
18+
word: str.slice(left),
19+
begin: left,
20+
end: str.length,
21+
};
22+
}
23+
24+
// Return the word, using the located bounds to extract it from the string.
25+
return {
26+
word: str.slice(left, right + pos),
27+
begin: left,
28+
end: right + pos,
29+
};
30+
}
31+
/* eslint-enable */
32+
33+
const mentionSearchStrategy = editor => (contentBlock, callback) => {
34+
const editorState = editor.state.editorState;
35+
const selection = editorState.getSelection();
36+
const selectionBlockKey = selection.getAnchorKey();
37+
if (selection.isCollapsed() && selectionBlockKey === contentBlock.getKey()) {
38+
const blockText = contentBlock.getText();
39+
const { word, begin, end } = getWordAt(blockText, selection.getAnchorOffset());
40+
const mentionRegex = /\@([\w]*)/;
41+
const matches = word.match(mentionRegex);
42+
const existingEntityKey = contentBlock.getEntityAt(begin);
43+
// const alreadyMention = Entity.get(existingEntityKey).getType() === 'MENTION';
44+
if (!existingEntityKey && matches) {
45+
callback(begin, end);
46+
}
47+
}
48+
};
49+
50+
export default mentionSearchStrategy;

0 commit comments

Comments
 (0)