Draft Js. Editor with hashtags and mentions. Part 1

I’m not a web developer. I almost never work with JavaScript, Node JS or HTML. I only use it rarely when I need to implement minor things when necessary. But recently I had the chance to try React, a library from Facebook for UI and their text editor framework Draft.JS.

My first time was too painful so I decided to write an article for people who want to give it a try but don’t have any idea how to start.

If you are not familiar with React, this article will help you to start.

In this article, I will assume you already have a simple React application and you know how to build and run it.

We will create a simple editor, that supports mentions and hashtags and shows a list of suggestions.

Let’s start with the Draft.JS installation.

npm install — save draft-js react react-dom

Next, let’s create a new file and call it **autocomplete.js**. In this file, create an Editor class and call it AutocompleteEditor.

export class AutocompleteEditor extends Editor {
constructor(props) {
super(props);
}

render() {
return (
< Editor/>
);
}
}

As you can see, we have overridden the render method which we don’t need right now, but we’ll use it later.

Now let’s change the render method in our React object in the **index.js**.

render() {
return (<div><AutocompleteEditor/></div>);
}

If you open your HTML file, you will see a white screen where you can write text. Nothing interesting so far.

To be able to show a list of mentions and hashtags we need to watch out for changes in the Editor. Let’s change our React object.

constructor() {
super();
this.state = {
editorState: EditorState.createEmpty(),
autocompleteState: null,
};
  this.onChange = (editorState) => this.setState({
editorState
});
  this.onAutocompleteChange = (autocompleteState) => this.setState({
autocompleteState
});
}

render() {
return (
<div>
<AutocompleteEditor
editorState = {
this.state.editorState
}
onChange = {
this.onChange
}
onAutocompleteChange = {
this.onAutocompleteChange
}
/>
</div>
);
}

We added the autocompleteState variable. This variable is going to contain all the necessary information we need to show a list of suggestions. Also, we need to keep the EditorState object updated. To do that, we use the onChange method. This is the same thing we have to do with the autocompleteState object. To make all this work, we need to pass these objects to our AutocompleteEditor in our render method.

Now we have to change the AutocompleteEditor class.

constructor(props) {
super(props);
this.autocompleteState = null;

//Called when EditorState changed.
this.onChange = (editorState) => {
const {
onChange,
onAutocompleteChange
} = this.props;
//Call parent onChange method.
onChange(editorState);
};
}
render() {
const {
onChange,
editorState
} = this.props;
  return ( 
< Editor
//set current EditorState to Editor.
editorState = {editorState}
//set onChange method to Editor.
onChange = {this.onChange}
/>
);
}

Each time the editorState changes, The editorState in parent React object will be updated.

Now open the html file, run the console and try to write something in Editor.

The next thing we want to do is to update our autocompleteState and check if we have the conditions to show the list of suggestions. 
Let’s add a method *getAutocompleteState* that will return the current autocomplete state or null if the autocomplete is impossible in the current editor state.

getAutocompleteState(invalidate = true) {
if (!invalidate) {
return this.autocompleteState;
}
var type = null;
var trigger = null;
//Get range for latest hash tag trigger symbol.
const tagRange = this.getAutocompleteRange(triggers.TAG_TRIGGER);
//Get range for latest mention tag trigger symbol.
const personRange =
this.getAutocompleteRange(triggers.PERSON_TRIGGER);
//Find what trigger is latest.
if (!tagRange && !personRange) {
this.autocompleteState = null;
return null;
}
var range = null;
if (!tagRange) {
range = personRange;
type = triggers.PERSON;
trigger = triggers.PERSON_TRIGGER;
}
  if (!personRange) {
range = tagRange;
type = triggers.TAG;
trigger = triggers.TAG_TRIGGER;
}
  if (!range) {
range = tagRange.start > personRange.start ? tagRange
: personRange;
type = tagRange.start > personRange.start ? triggers.TAG
: triggers.PERSON;
trigger = tagRange.start > personRange.start
? triggers.TAG_TRIGGER : triggers.PERSON_TRIGGER;
}
  //Get left and top coordinates of range.
//This point will be used to draw suggestion list.
const tempRange = window.getSelection().getRangeAt(0)
.cloneRange();
tempRange.setStart(tempRange.startContainer, range.start);
const rangeRect = tempRange.getBoundingClientRect();
let [left, top] = [rangeRect.left, rangeRect.bottom];
  //Create autocompleteState.
this.autocompleteState = {
trigger, //Trigger symbol. “@” or “#”
type, //Type of trigger. Can be TAG or PERSON.
left, //The left point of range.
top, //The top point of range.
text: range.text, //Current text in selected range.
selectedIndex: 0 //Selected index in list. 0 for new list.
};
return this.autocompleteState;
};
//Get range of possible mention or hashtag.
getAutocompleteRange(trigger) {
const selection = window.getSelection();
if (selection.rangeCount === 0) {
return null;
}
  if (this.hasEntityAtSelection()) {
return null;
}
  const range = selection.getRangeAt(0);
let text = range.startContainer.textContent;
text = text.substring(0, range.startOffset);
const index = text.lastIndexOf(trigger);
if (index === -1) {
return null;
}
text = text.substring(index);
return {
text,
start: index,
end: range.startOffset
};
};

hasEntityAtSelection() {
const {
editorState
} = this.props;
  const selection = editorState.getSelection();
//If there is no focus, return.
if (!selection.getHasFocus()) {
return false;
}
  const contentState = editorState.getCurrentContent();
const block = contentState
.getBlockForKey(selection.getStartKey());
return !!block.getEntityAt(selection.getStartOffset() — 1);
};

We use the method above to check if there is a tag or a mention symbol in the text, and it returns the autocompleteState containing the coordinates for showing the suggestion list, the trigger(“@” or “#”) and the string that we want to complete.

After the editor state was changed, we are going to get a new autocompleteState and send it back to our parent object.

this.onChange = (editorState) => {
const {
onChange,
onAutocompleteChange
} = this.props;
onChange(editorState);
if (onAutocompleteChange) {
window.requestAnimationFrame(() => {
onAutocompleteChange(this.getAutocompleteState());
});
};
}

The next step is to create an object that renders a list of suggestions. I called that object the SuggestionList. This object gets a suggestionState variable with the necessary information: the left and top position of the list, the list of suggestion strings and the selected index.

const SuggestionList = React.createClass({
render: function() {
const {
suggestionsState
} = this.props;
const {
left,
top,
array,
selectedIndex
} = suggestionsState;
    const style = Object.assign({}, styles.suggestions, {
position: ‘absolute’,
left,
top
});
if (!array) {
return null;
}
//Normilize index. It’s necessary if index is out of bound.
const normalizedIndex = normalizeIndex(
selectedIndex, array.length
);
return (< ul style = {style} > {
array.map((person, index) => {
const {
suggestionsState
} = this.props;
const style
= index === normalizedIndex
? styles.selectedPerson
: styles.person;
return (
< li key = { person } style = { style } >
{ person }
< /li>
);
}, this)
} < /ul>);
}
});

Now we can change the render of our React object.

render() {
return ( < div style = { styles.root } >
{ this.renderAutocomplete() }
< div style = { styles.editor } >
< AutocompleteEditor editorState = { this.state.editorState }
onChange = { this.onChange }
onAutocompleteChange = { this.onAutocompleteChange }
onInsert = { this.onInsert }
/>
< /div> < /div>
);
}
//Render suggestion list.
renderAutocomplete() {
const {
autocompleteState,
onSuggestionClick
} = this.state;
if (!autocompleteState) {
return null;
}
//Get list of suggestions according to existing string.
filteredArrayTemp =
this.getFilteredArray(autocompleteState.type,
autocompleteState.text);
autocompleteState.array = filteredArrayTemp;
//Set suggestionState to SuggestionList.
return
<SuggestionList suggestionsState = { autocompleteState }/>;
};

Now, rebuild the application and run. I already added some styles.

In the next article I will show you how to add keyboard events to control the suggestion list with the keyboard and we will see how to change the editorState object programmatically.

Draft-js editor with hashtags and mentions part 2.

You can find the full working example with the source code on my GitHub
And you can see the result here