├── .babelrc
├── .gitignore
├── CHANGELOG.md
├── README.md
├── examples
└── issue
│ ├── .babelrc
│ ├── README.md
│ ├── app.js
│ ├── components
│ ├── Draft.scss
│ ├── IssueEditor.js
│ └── IssueEditor.scss
│ ├── index.html
│ ├── package.json
│ ├── plugin
│ ├── IssueEntry.js
│ ├── addIssueModifier.js
│ ├── findIssueSuggestionStrategy.js
│ ├── index.js
│ ├── issueSuggestionsEntryStyles.scss
│ ├── issueSuggestionsFilter.js
│ ├── issueSuggestionsStyles.scss
│ └── utils
│ │ ├── getSearchText.js
│ │ └── getWordAt.js
│ └── webpack.config.babel.js
├── package.json
├── src
├── CompletionSuggestions
│ └── index.js
├── CompletionSuggestionsPortal
│ └── index.js
├── index.js
└── utils
│ ├── __test__
│ └── getWordAt.js
│ ├── decodeOffsetKey.js
│ ├── defaultSuggestionsFilter.js
│ ├── getSearchText.js
│ ├── getWordAt.js
│ └── positionSuggestions.js
└── webpack.config.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["react", "es2015", "stage-0"]
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .idea
3 |
4 | # Dependency directory
5 | # Commenting this out is preferred by some people, see
6 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git-
7 | node_modules
8 |
9 | # Browserify cache & build files
10 | bundle.js
11 | .bundle.js
12 |
13 | # build
14 | /lib/
15 |
16 | # NPM debug
17 | npm-debug.log
18 | npm-debug.log*
19 |
20 | docs/public
21 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | All notable changes to this project will be documented in this file.
4 | This project adheres to [Semantic Versioning](http://semver.org/).
5 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # DraftJS AutoComplete Plugin Creator
2 |
3 | [](https://www.npmjs.com/package/draft-js-autocomplete-plugin-creator)
4 |
5 | The goal of this plugin creator to is to make developing custom autocompletion plugins for
6 | [draft-js-plugins](https://github.com/draft-js-plugins/draft-js-plugins) easier and consolidate common
7 | logic internal to the completion logic. Much of the original logic comes from the [Mention Plugin](https://github.com/draft-js-plugins/draft-js-plugins/tree/master/draft-js-mention-plugin).
8 |
9 | ### API
10 |
11 | API documentation is in progress, for now please see the examples.
12 |
--------------------------------------------------------------------------------
/examples/issue/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["react", "es2015", "stage-0"]
3 | }
4 |
--------------------------------------------------------------------------------
/examples/issue/README.md:
--------------------------------------------------------------------------------
1 | # Issue Suggestion Example
2 |
3 | This example shows how to build a github issue style autocompletion plugin for draft-js-plugins
4 |
5 | This plugin, unlike the Emoji or Mention plugin from draft-js-plugins doesn't create an Entity when the suggestion
6 | is selected. Instead, it simply inserts `#` followed by the issue number in plaintext. This makes the suggestions show
7 | up again if the cursor is over the issue number, just like Github Issues.
8 |
9 | To view the example:
10 |
11 | 1. `npm install` in the root directory of the project (up one folder)
12 | 1. `npm install` in this folder
13 | 2. `npm start` in this foler
14 | 3. Browse to http://localhost:8080
15 |
--------------------------------------------------------------------------------
/examples/issue/app.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import Editor from 'draft-js-plugins-editor';
4 | import { List } from 'immutable';
5 |
6 | import IssueEditor from './components/IssueEditor';
7 |
8 | ReactDOM.render(
9 |
10 |
Issue Plugin Example for AutoComplete Plugin Creator
11 |
12 | ,
13 | document.getElementById('mount')
14 | )
15 |
--------------------------------------------------------------------------------
/examples/issue/components/Draft.scss:
--------------------------------------------------------------------------------
1 | /**
2 | * @providesModule DraftEditor
3 | * @permanent
4 | */
5 |
6 | /**
7 | * We inherit the height of the container by default
8 | */
9 |
10 | .DraftEditor-root,
11 | .DraftEditor-editorContainer,
12 | .public-DraftEditor-content {
13 | height: inherit;
14 | text-align: initial;
15 | }
16 |
17 | .DraftEditor-root {
18 | position: relative;
19 | }
20 |
21 | /**
22 | * Zero-opacity background used to allow focus in IE. Otherwise, clicks
23 | * fall through to the placeholder.
24 | */
25 |
26 | .DraftEditor-editorContainer {
27 | background-color: rgba(255, 255, 255, 0);
28 | /* Repair mysterious missing Safari cursor */
29 | border-left: 0.1px solid transparent;
30 | position: relative;
31 | z-index: 1;
32 | }
33 |
34 | .public-DraftEditor-content {
35 | outline: none;
36 | white-space: pre-wrap;
37 | }
38 |
39 | .public-DraftEditor-block {
40 | position: relative;
41 | }
42 |
43 | .DraftEditor-alignLeft .public-DraftStyleDefault-block {
44 | text-align: left;
45 | }
46 |
47 | .DraftEditor-alignLeft .public-DraftEditorPlaceholder-root {
48 | left: 0;
49 | text-align: left;
50 | }
51 |
52 | .DraftEditor-alignCenter .public-DraftStyleDefault-block {
53 | text-align: center;
54 | }
55 |
56 | .DraftEditor-alignCenter .public-DraftEditorPlaceholder-root {
57 | margin: 0 auto;
58 | text-align: center;
59 | width: 100%;
60 | }
61 |
62 | .DraftEditor-alignRight .public-DraftStyleDefault-block {
63 | text-align: right;
64 | }
65 |
66 | .DraftEditor-alignRight .public-DraftEditorPlaceholder-root {
67 | right: 0;
68 | text-align: right;
69 | }
70 | /**
71 | * @providesModule DraftEditorPlaceholder
72 | */
73 |
74 | .public-DraftEditorPlaceholder-root {
75 | color: #9197a3;
76 | position: absolute;
77 | z-index: 0;
78 | }
79 |
80 | .public-DraftEditorPlaceholder-hasFocus {
81 | color: #bdc1c9;
82 | }
83 |
84 | .DraftEditorPlaceholder-hidden {
85 | display: none;
86 | }
87 | /**
88 | * @providesModule DraftStyleDefault
89 | */
90 |
91 | .public-DraftStyleDefault-block {
92 | position: relative;
93 | white-space: pre-wrap;
94 | }
95 |
96 | /* @noflip */
97 |
98 | .public-DraftStyleDefault-ltr {
99 | direction: ltr;
100 | text-align: left;
101 | }
102 |
103 | /* @noflip */
104 |
105 | .public-DraftStyleDefault-rtl {
106 | direction: rtl;
107 | text-align: right;
108 | }
109 |
110 | /**
111 | * These rules provide appropriate text direction for counter pseudo-elements.
112 | */
113 |
114 | /* @noflip */
115 |
116 | .public-DraftStyleDefault-listLTR {
117 | direction: ltr;
118 | }
119 |
120 | /* @noflip */
121 |
122 | .public-DraftStyleDefault-listRTL {
123 | direction: rtl;
124 | }
125 |
126 | /**
127 | * Default spacing for list container elements. Override with CSS as needed.
128 | */
129 |
130 | .public-DraftStyleDefault-ul,
131 | .public-DraftStyleDefault-ol {
132 | margin: 16px 0;
133 | padding: 0;
134 | }
135 |
136 | /**
137 | * Default counters and styles are provided for five levels of nesting.
138 | * If you require nesting beyond that level, you should use your own CSS
139 | * classes to do so. If you care about handling RTL languages, the rules you
140 | * create should look a lot like these.
141 | */
142 |
143 | /* @noflip */
144 |
145 | .public-DraftStyleDefault-depth0.public-DraftStyleDefault-listLTR {
146 | margin-left: 1.5em;
147 | }
148 |
149 | /* @noflip */
150 |
151 | .public-DraftStyleDefault-depth0.public-DraftStyleDefault-listRTL {
152 | margin-right: 1.5em;
153 | }
154 |
155 | /* @noflip */
156 |
157 | .public-DraftStyleDefault-depth1.public-DraftStyleDefault-listLTR {
158 | margin-left: 3em;
159 | }
160 |
161 | /* @noflip */
162 |
163 | .public-DraftStyleDefault-depth1.public-DraftStyleDefault-listRTL {
164 | margin-right: 3em;
165 | }
166 |
167 | /* @noflip */
168 |
169 | .public-DraftStyleDefault-depth2.public-DraftStyleDefault-listLTR {
170 | margin-left: 4.5em;
171 | }
172 |
173 | /* @noflip */
174 |
175 | .public-DraftStyleDefault-depth2.public-DraftStyleDefault-listRTL {
176 | margin-right: 4.5em;
177 | }
178 |
179 | /* @noflip */
180 |
181 | .public-DraftStyleDefault-depth3.public-DraftStyleDefault-listLTR {
182 | margin-left: 6em;
183 | }
184 |
185 | /* @noflip */
186 |
187 | .public-DraftStyleDefault-depth3.public-DraftStyleDefault-listRTL {
188 | margin-right: 6em;
189 | }
190 |
191 | /* @noflip */
192 |
193 | .public-DraftStyleDefault-depth4.public-DraftStyleDefault-listLTR {
194 | margin-left: 7.5em;
195 | }
196 |
197 | /* @noflip */
198 |
199 | .public-DraftStyleDefault-depth4.public-DraftStyleDefault-listRTL {
200 | margin-right: 7.5em;
201 | }
202 |
203 | /**
204 | * Only use `square` list-style after the first two levels.
205 | */
206 |
207 | .public-DraftStyleDefault-unorderedListItem {
208 | list-style-type: square;
209 | position: relative;
210 | }
211 |
212 | .public-DraftStyleDefault-unorderedListItem.public-DraftStyleDefault-depth0 {
213 | list-style-type: disc;
214 | }
215 |
216 | .public-DraftStyleDefault-unorderedListItem.public-DraftStyleDefault-depth1 {
217 | list-style-type: circle;
218 | }
219 |
220 | /**
221 | * Ordered list item counters are managed with CSS, since all list nesting is
222 | * purely visual.
223 | */
224 |
225 | .public-DraftStyleDefault-orderedListItem {
226 | list-style-type: none;
227 | position: relative;
228 | }
229 |
230 | /* @noflip */
231 |
232 | .public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-listLTR:before {
233 | left: -36px;
234 | position: absolute;
235 | text-align: right;
236 | width: 30px;
237 | }
238 |
239 | /* @noflip */
240 |
241 | .public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-listRTL:before {
242 | position: absolute;
243 | right: -36px;
244 | text-align: left;
245 | width: 30px;
246 | }
247 |
248 | /**
249 | * Counters are reset in JavaScript. If you need different counter styles,
250 | * override these rules. If you need more nesting, create your own rules to
251 | * do so.
252 | */
253 |
254 | .public-DraftStyleDefault-orderedListItem:before {
255 | content: counter(ol0) ". ";
256 | counter-increment: ol0;
257 | }
258 |
259 | .public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth1:before {
260 | content: counter(ol1) ". ";
261 | counter-increment: ol1;
262 | }
263 |
264 | .public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth2:before {
265 | content: counter(ol2) ". ";
266 | counter-increment: ol2;
267 | }
268 |
269 | .public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth3:before {
270 | content: counter(ol3) ". ";
271 | counter-increment: ol3;
272 | }
273 |
274 | .public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth4:before {
275 | content: counter(ol4) ". ";
276 | counter-increment: ol4;
277 | }
278 |
279 | .public-DraftStyleDefault-depth0.public-DraftStyleDefault-reset {
280 | counter-reset: ol0;
281 | }
282 |
283 | .public-DraftStyleDefault-depth1.public-DraftStyleDefault-reset {
284 | counter-reset: ol1;
285 | }
286 |
287 | .public-DraftStyleDefault-depth2.public-DraftStyleDefault-reset {
288 | counter-reset: ol2;
289 | }
290 |
291 | .public-DraftStyleDefault-depth3.public-DraftStyleDefault-reset {
292 | counter-reset: ol3;
293 | }
294 |
295 | .public-DraftStyleDefault-depth4.public-DraftStyleDefault-reset {
296 | counter-reset: ol4;
297 | }
298 |
--------------------------------------------------------------------------------
/examples/issue/components/IssueEditor.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import { EditorState } from 'draft-js';
3 | import Editor from 'draft-js-plugins-editor';
4 | import { List, fromJS } from 'immutable';
5 |
6 | import createIssueSuggestionPlugin, { defaultSuggestionsFilter } from '../plugin';
7 | import './IssueEditor.scss';
8 | import './Draft.scss';
9 |
10 | const issueSuggestionPlugin = createIssueSuggestionPlugin();
11 | const { CompletionSuggestions } = issueSuggestionPlugin;
12 | const plugins = [issueSuggestionPlugin];
13 |
14 | const suggestions = fromJS([
15 | {
16 | id: 1,
17 | subject: 'New Cool Feature',
18 | },
19 | {
20 | id: 2,
21 | subject: 'Bug',
22 | },
23 | {
24 | id: 3,
25 | subject: 'Improve Documentation',
26 | },
27 | ]);
28 |
29 | export default class IssueEditor extends React.Component {
30 | constructor(props) {
31 | super(props);
32 |
33 | this.state = {
34 | editorState: EditorState.createEmpty(),
35 | suggestions: List(),
36 | };
37 | }
38 |
39 | onChange = (editorState, cb) => this.setState({ editorState }, cb);
40 |
41 | onIssueSearchChange = ({ value }) => {
42 | const searchValue = value.substring(1, value.length);
43 | this.setState({
44 | suggestions: defaultSuggestionsFilter(searchValue, suggestions),
45 | });
46 | };
47 |
48 | focus = () => this.refs.editor.focus();
49 |
50 | render() {
51 | return (
52 |
69 | );
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/examples/issue/components/IssueEditor.scss:
--------------------------------------------------------------------------------
1 | .editor {
2 | box-sizing: border-box;
3 | border: 1px solid #ddd;
4 | cursor: text;
5 | padding: 16px;
6 | border-radius: 2px;
7 | margin-bottom: 2em;
8 | box-shadow: inset 0px 1px 8px -3px #ABABAB;
9 | background: #fefefe;
10 | }
11 |
12 | .editor :global(.public-DraftEditor-content) {
13 | min-height: 140px;
14 | }
15 |
--------------------------------------------------------------------------------
/examples/issue/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Issue Plugin Example
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/examples/issue/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "issue-plugin-example",
3 | "version": "0.0.0",
4 | "dependencies": {
5 | "draft-js-plugins-editor": "^1.1.0",
6 | "draft-js": "^0.9.1",
7 | "immutable": ">=3.7.6",
8 | "react": "15.1.0",
9 | "react-dom": "15.1.0"
10 | },
11 | "devDependencies": {
12 | "css-loader": "^0.23.1",
13 | "extract-text-webpack-plugin": "^1.0.1",
14 | "html-webpack-plugin": "^2.22.0",
15 | "node-sass": "^3.8.0",
16 | "sass-loader": "^4.0.0",
17 | "style-loader": "^0.13.1",
18 | "webpack": "^1.13.1",
19 | "webpack-dev-server": "^1.14.1"
20 | },
21 | "scripts": {
22 | "start": "webpack-dev-server --config webpack.config.babel.js --progress"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/examples/issue/plugin/IssueEntry.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react';
2 | import Immutable from 'immutable';
3 |
4 | import './issueSuggestionsEntryStyles.scss';
5 |
6 | export default class IssueEntry extends Component {
7 |
8 | static propTypes = {
9 | completion: PropTypes.instanceOf(Immutable.Map).isRequired,
10 | index: PropTypes.number.isRequired,
11 | isFocused: PropTypes.bool.isRequired,
12 | onCompletionFocus: PropTypes.func.isRequired,
13 | onCompletionSelect: PropTypes.func.isRequired,
14 | };
15 |
16 | constructor(props) {
17 | super(props);
18 | this.mouseDown = false;
19 | }
20 |
21 | componentDidUpdate() {
22 | this.mouseDown = false;
23 | }
24 |
25 | onMouseUp = () => {
26 | if (this.mouseDown) {
27 | this.mouseDown = false;
28 | this.props.onCompletionSelect(this.props.completion);
29 | }
30 | };
31 |
32 | onMouseDown = (event) => {
33 | // Note: important to avoid a content edit change
34 | event.preventDefault();
35 |
36 | this.mouseDown = true;
37 | };
38 |
39 | onMouseEnter = () => {
40 | this.props.onCompletionFocus(this.props.index);
41 | };
42 |
43 | render() {
44 | const className = this.props.isFocused ? 'issueSuggestionsEntryFocused' : 'issueSuggestionsEntry';
45 | return (
46 |
53 |
54 | {`#${this.props.completion.get('id')}`}
55 | {` ${this.props.completion.get('subject')}`}
56 |
57 |
58 | );
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/examples/issue/plugin/addIssueModifier.js:
--------------------------------------------------------------------------------
1 | import { Modifier, EditorState } from 'draft-js';
2 |
3 | import getSearchText from './utils/getSearchText';
4 |
5 | export default (editorState, issue) => {
6 | const currentSelectionState = editorState.getSelection();
7 | const { begin, end } = getSearchText(editorState, currentSelectionState);
8 |
9 | // get selection of the issue search text
10 | const issueTextSelection = currentSelectionState.merge({
11 | anchorOffset: begin,
12 | focusOffset: end,
13 | });
14 |
15 | let issueReplacedContent = Modifier.replaceText(
16 | editorState.getCurrentContent(),
17 | issueTextSelection,
18 | `#${issue.get('id')}`,
19 | );
20 |
21 | // If the issue is inserted at the end, a space is appended right after for
22 | // a smooth writing experience.
23 | const blockKey = issueTextSelection.getAnchorKey();
24 | const blockSize = editorState.getCurrentContent().getBlockForKey(blockKey).getLength();
25 | if (blockSize === end) {
26 | issueReplacedContent = Modifier.insertText(
27 | issueReplacedContent,
28 | issueReplacedContent.getSelectionAfter(),
29 | ' ',
30 | );
31 | }
32 |
33 | const newEditorState = EditorState.push(
34 | editorState,
35 | issueReplacedContent,
36 | 'insert-issue',
37 | );
38 | return EditorState.forceSelection(newEditorState, issueReplacedContent.getSelectionAfter());
39 | };
40 |
--------------------------------------------------------------------------------
/examples/issue/plugin/findIssueSuggestionStrategy.js:
--------------------------------------------------------------------------------
1 | import findWithRegex from 'find-with-regex';
2 |
3 | const ISSUE_REGEX = /(\s|^)#[^\s]*/g;
4 |
5 | export default (contentBlock, callback) => {
6 | findWithRegex(ISSUE_REGEX, contentBlock, callback);
7 | };
8 |
--------------------------------------------------------------------------------
/examples/issue/plugin/index.js:
--------------------------------------------------------------------------------
1 | import createCompletionPlugin from 'draft-js-autocomplete-plugin-creator';
2 |
3 | import issueSuggestionsStrategy from './findIssueSuggestionStrategy';
4 | import suggestionsFilter from './issueSuggestionsFilter';
5 | import addIssueModifier from './addIssueModifier';
6 | import IssueEntry from './IssueEntry';
7 |
8 | import './issueSuggestionsEntryStyles.scss';
9 | import './issueSuggestionsStyles.scss';
10 |
11 | const createIssueSuggestionPlugin = (config = {}) => {
12 | const defaultTheme = {
13 | issueSuggestions: 'issueSuggestions',
14 | };
15 | const completionPlugin = createCompletionPlugin(
16 | issueSuggestionsStrategy,
17 | addIssueModifier,
18 | IssueEntry,
19 | 'issueSuggestions',
20 | );
21 | const configWithTheme = {
22 | theme: defaultTheme,
23 | ...config,
24 | };
25 | return completionPlugin(configWithTheme);
26 | };
27 |
28 | export default createIssueSuggestionPlugin;
29 |
30 | export const defaultSuggestionsFilter = suggestionsFilter;
31 |
--------------------------------------------------------------------------------
/examples/issue/plugin/issueSuggestionsEntryStyles.scss:
--------------------------------------------------------------------------------
1 | .issueSuggestionsEntry {
2 | padding: 2px 0 0 2px;
3 | transition: background-color 0.4s cubic-bezier(0.27, 1.27, 0.48, 0.56);
4 | text-align: left;
5 | }
6 |
7 | .issueSuggestionsEntry:active {
8 | background-color: #CCE7FF;
9 | }
10 |
11 | .issueSuggestionsEntryFocused {
12 | @extend .issueSuggestionsEntry;
13 | background-color: #E6F3FF;
14 | }
15 |
16 | .issueSuggestionsEntryText {
17 | display: inline-block;
18 | margin-left: 2px;
19 | max-width: 360px;
20 | overflow: hidden;
21 | text-overflow: ellipsis;
22 | white-space: nowrap;
23 | }
24 |
25 | .bold {
26 | font-weight: bold;
27 | }
28 |
--------------------------------------------------------------------------------
/examples/issue/plugin/issueSuggestionsFilter.js:
--------------------------------------------------------------------------------
1 | const issueSuggestionsFilter = (searchValue, issues) => {
2 | console.log(issues.get(0).get('id'));
3 | const lowerSearch = searchValue.toLowerCase();
4 | return issues.filter(i => String(i.get('id')).startsWith(lowerSearch) ||
5 | i.get('subject').replace(/\s+/g, '').toLowerCase().indexOf(lowerSearch) !== -1)
6 | .take(5);
7 | };
8 |
9 | export default issueSuggestionsFilter;
10 |
--------------------------------------------------------------------------------
/examples/issue/plugin/issueSuggestionsStyles.scss:
--------------------------------------------------------------------------------
1 | .issueSuggestions {
2 | background: #FFFFFF;
3 | border: 1px solid #EEEEEE;
4 | border-radius: 2px;
5 | box-shadow: 0 4px 30px 0 rgba(220, 220, 220, 1);
6 | box-sizing: border-box;
7 | cursor: pointer;
8 | display: flex;
9 | flex-direction: column;
10 | margin-top: 1.75em;
11 | max-width: 440px;
12 | min-width: 220px;
13 | position: fixed;
14 | transform: scale(0);
15 | z-index: 2;
16 | }
17 |
--------------------------------------------------------------------------------
/examples/issue/plugin/utils/getSearchText.js:
--------------------------------------------------------------------------------
1 | import getWordAt from './getWordAt';
2 |
3 | const getSearchText = (editorState, selection) => {
4 | const anchorKey = selection.getAnchorKey();
5 | const anchorOffset = selection.getAnchorOffset() - 1;
6 | const currentContent = editorState.getCurrentContent();
7 | const currentBlock = currentContent.getBlockForKey(anchorKey);
8 | const blockText = currentBlock.getText();
9 | return getWordAt(blockText, anchorOffset);
10 | };
11 |
12 | export default getSearchText;
13 |
--------------------------------------------------------------------------------
/examples/issue/plugin/utils/getWordAt.js:
--------------------------------------------------------------------------------
1 | const getWordAt = (string, position) => {
2 | // Perform type conversions.
3 | const str = String(string);
4 | const pos = Number(position) >>> 0;
5 |
6 | // Search for the word's beginning and end.
7 | const left = str.slice(0, pos + 1).search(/\S+$/);
8 | const right = str.slice(pos).search(/\s/);
9 |
10 | // The last word in the string is a special case.
11 | if (right < 0) {
12 | return {
13 | word: str.slice(left),
14 | begin: left,
15 | end: str.length,
16 | };
17 | }
18 |
19 | // Return the word, using the located bounds to extract it from the string.
20 | return {
21 | word: str.slice(left, right + pos),
22 | begin: left,
23 | end: right + pos,
24 | };
25 | };
26 |
27 | export default getWordAt;
28 |
--------------------------------------------------------------------------------
/examples/issue/webpack.config.babel.js:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import fs from 'fs'
3 | import HtmlWebpackPlugin from 'html-webpack-plugin'
4 |
5 | module.exports = {
6 | entry: './app.js',
7 | output: {
8 | path: path.join(__dirname, 'dist'),
9 | filename: 'bundle.js'
10 | },
11 | devServer: {
12 | inline: true,
13 | historyApiFallback: true,
14 | stats: {
15 | colors: true,
16 | hash: false,
17 | version: false,
18 | chunks: false,
19 | children: false
20 | }
21 | },
22 | module: {
23 | loaders: [
24 | {
25 | test: /\.js$/,
26 | loaders: [ 'babel' ],
27 | exclude: /node_modules/,
28 | include: __dirname
29 | },
30 | { test: /\.scss$/, loader: 'style!css!sass' },
31 | ]
32 | },
33 | plugins: [
34 | new HtmlWebpackPlugin({
35 | template: 'index.html', // Load a custom template
36 | inject: 'body' // Inject all scripts into the body
37 | })
38 | ]
39 | }
40 |
41 | // This will make the draft-js-autocomplete-plugin-creator module resolve to the
42 | // latest src instead of using it from npm. Remove this if running
43 | // outside of the source.
44 | const src = path.join(__dirname, '..', '..', 'src')
45 | if (fs.existsSync(src)) {
46 | // Use the latest src
47 | module.exports.resolve = {
48 | root: [
49 | path.join(__dirname, 'node_modules'),
50 | path.join(__dirname, '..', '..', 'node_modules')
51 | ],
52 | alias: { 'draft-js-autocomplete-plugin-creator': src }
53 | };
54 | module.exports.module.loaders.push({
55 | test: /\.js$/,
56 | loaders: [ 'babel' ],
57 | include: src
58 | });
59 | }
60 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "draft-js-autocomplete-plugin-creator",
3 | "version": "0.2.1",
4 | "description": "AutoComplete Plugin Creator for Draft JS Plugins",
5 | "author": {
6 | "name": "Matt Russell",
7 | "email": "matthewjosephrussell@gmail.com"
8 | },
9 | "files": [
10 | "*.md",
11 | "LICENSE",
12 | "lib",
13 | "src"
14 | ],
15 | "repository": {
16 | "type": "git",
17 | "url": "https://github.com/mjrussell/draft-js-autocomplete-plugin-creator.git"
18 | },
19 | "main": "lib/index.js",
20 | "keywords": [
21 | "draft-js-plugins",
22 | "draft",
23 | "react",
24 | "components",
25 | "react-component"
26 | ],
27 | "peerDependencies": {
28 | "draft-js": ">=0.9.1",
29 | "immutable": ">=3.7.6",
30 | "prop-types": "^15.0.0",
31 | "react": "^0.14.0 || ^15.0.0-rc || ^15.0.0 || ^16.0.0-rc || ^16.0.0",
32 | "react-dom": "^0.14.0 || ^15.0.0-rc || ^15.0.0 || ^16.0.0-rc || ^16.0.0"
33 | },
34 | "scripts": {
35 | "clean": "./node_modules/.bin/rimraf lib",
36 | "build": "npm run clean && npm run build:js",
37 | "build:js": "WEBPACK_CONFIG=$(pwd)/webpack.config.js BABEL_DISABLE_CACHE=1 BABEL_ENV=production NODE_ENV=production ./node_modules/.bin/babel --out-dir='lib' --ignore='__test__/*' src",
38 | "prepublish": "npm run build"
39 | },
40 | "license": "MIT",
41 | "dependencies": {
42 | "decorate-component-with-props": "^1.0.2",
43 | "find-with-regex": "^1.0.2",
44 | "union-class-names": "^1.0.0"
45 | },
46 | "devDependencies": {
47 | "babel-cli": "^6.8.0",
48 | "babel-core": "^6.8.0",
49 | "babel-loader": "^6.2.4",
50 | "babel-preset-es2015": "^6.6.0",
51 | "babel-preset-react": "^6.5.0",
52 | "babel-preset-react-hmre": "^1.1.1",
53 | "babel-preset-stage-0": "^6.5.0",
54 | "rimraf": "^2.5.2",
55 | "webpack": "^1.13.1"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/CompletionSuggestions/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import decodeOffsetKey from '../utils/decodeOffsetKey';
5 | import { genKey } from 'draft-js';
6 | import getSearchText from '../utils/getSearchText';
7 |
8 | export default function (addModifier, Entry, suggestionsThemeKey) {
9 | return class CompletionSuggestions extends Component {
10 |
11 | static propTypes = {
12 | entityMutability: PropTypes.oneOf([
13 | 'SEGMENTED',
14 | 'IMMUTABLE',
15 | 'MUTABLE',
16 | ]),
17 | };
18 |
19 | state = {
20 | isActive: false,
21 | focusedOptionIndex: 0,
22 | };
23 |
24 | componentWillMount() {
25 | this.key = genKey();
26 | this.props.callbacks.onChange = this.onEditorStateChange;
27 | }
28 |
29 | componentWillReceiveProps(nextProps) {
30 | if (nextProps.suggestions.size === 0 && this.state.isActive) {
31 | this.closeDropdown();
32 | }
33 | }
34 |
35 | componentDidUpdate = (prevProps, prevState) => {
36 | if (this.refs.popover) {
37 | // In case the list shrinks there should be still an option focused.
38 | // Note: this might run multiple times and deduct 1 until the condition is
39 | // not fullfilled anymore.
40 | const size = this.props.suggestions.size;
41 | if (size > 0 && this.state.focusedOptionIndex >= size) {
42 | this.setState({
43 | focusedOptionIndex: size - 1,
44 | });
45 | }
46 |
47 | const decoratorRect = this.props.store.getPortalClientRect(this.activeOffsetKey);
48 | const newStyles = this.props.positionSuggestions({
49 | decoratorRect,
50 | prevProps,
51 | prevState,
52 | props: this.props,
53 | state: this.state,
54 | popover: this.refs.popover,
55 | });
56 | Object.keys(newStyles).forEach((key) => {
57 | this.refs.popover.style[key] = newStyles[key];
58 | });
59 | }
60 | };
61 |
62 | componentWillUnmount = () => {
63 | this.props.callbacks.onChange = undefined;
64 | };
65 |
66 | onEditorStateChange = (editorState) => {
67 | const searches = this.props.store.getAllSearches();
68 |
69 | // if no search portal is active there is no need to show the popover
70 | if (searches.size === 0) {
71 | return editorState;
72 | }
73 |
74 | const removeList = () => {
75 | this.props.store.resetEscapedSearch();
76 | this.closeDropdown();
77 | return editorState;
78 | };
79 |
80 | // get the current selection
81 | const selection = editorState.getSelection();
82 | const anchorKey = selection.getAnchorKey();
83 | const anchorOffset = selection.getAnchorOffset();
84 |
85 | // the list should not be visible if a range is selected or the editor has no focus
86 | if (!selection.isCollapsed() || !selection.getHasFocus()) return removeList();
87 |
88 | // identify the start & end positon of each search-text
89 | const offsetDetails = searches.map((offsetKey) => decodeOffsetKey(offsetKey));
90 |
91 | // a leave can be empty when it is removed due e.g. using backspace
92 | const leaves = offsetDetails
93 | .filter(({ blockKey }) => blockKey === anchorKey)
94 | .map(({ blockKey, decoratorKey, leafKey }) => (
95 | editorState
96 | .getBlockTree(blockKey)
97 | .getIn([decoratorKey, 'leaves', leafKey])
98 | ));
99 |
100 | // if all leaves are undefined the popover should be removed
101 | if (leaves.every((leave) => leave === undefined)) {
102 | return removeList();
103 | }
104 |
105 | // Checks that the cursor is after the 'autocomplete' character but still somewhere in
106 | // the word (search term). Setting it to allow the cursor to be left of
107 | // the 'autocomplete character' causes troubles due selection confusion.
108 | const selectionIsInsideWord = leaves
109 | .filter((leave) => leave !== undefined)
110 | .map(({ start, end }) => (
111 | start === 0 && anchorOffset === 1 && anchorOffset <= end || // @ is the first character
112 | anchorOffset > start + 1 && anchorOffset <= end // @ is in the text or at the end
113 | ));
114 |
115 | if (selectionIsInsideWord.every((isInside) => isInside === false)) return removeList();
116 |
117 | this.activeOffsetKey = selectionIsInsideWord
118 | .filter(value => value === true)
119 | .keySeq()
120 | .first();
121 |
122 | this.onSearchChange(editorState, selection);
123 |
124 | // make sure the escaped search is reseted in the cursor since the user
125 | // already switched to another completion search
126 | if (!this.props.store.isEscaped(this.activeOffsetKey)) {
127 | this.props.store.resetEscapedSearch();
128 | }
129 |
130 | // If none of the above triggered to close the window, it's safe to assume
131 | // the dropdown should be open. This is useful when a user focuses on another
132 | // input field and then comes back: the dropdown will again.
133 | if (!this.state.isActive && !this.props.store.isEscaped(this.activeOffsetKey)) {
134 | this.openDropdown();
135 | }
136 |
137 | // makes sure the focused index is reseted every time a new selection opens
138 | // or the selection was moved to another completion search
139 | if (this.lastSelectionIsInsideWord === undefined ||
140 | !selectionIsInsideWord.equals(this.lastSelectionIsInsideWord)) {
141 | this.setState({
142 | focusedOptionIndex: 0,
143 | });
144 | }
145 |
146 | this.lastSelectionIsInsideWord = selectionIsInsideWord;
147 |
148 | return editorState;
149 | };
150 |
151 | onSearchChange = (editorState, selection) => {
152 | const searchText = getSearchText(editorState, selection);
153 | const searchValue = searchText.word;
154 | if (this.lastSearchValue !== searchValue) {
155 | this.lastSearchValue = searchValue;
156 | this.props.onSearchChange({ value: searchValue });
157 | }
158 | };
159 |
160 | onDownArrow = (keyboardEvent) => {
161 | keyboardEvent.preventDefault();
162 | const newIndex = this.state.focusedOptionIndex + 1;
163 | this.onCompletionFocus(newIndex >= this.props.suggestions.size ? 0 : newIndex);
164 | };
165 |
166 | onTab = (keyboardEvent) => {
167 | keyboardEvent.preventDefault();
168 | this.commitSelection();
169 | };
170 |
171 | onUpArrow = (keyboardEvent) => {
172 | keyboardEvent.preventDefault();
173 | if (this.props.suggestions.size > 0) {
174 | const newIndex = this.state.focusedOptionIndex - 1;
175 | this.onCompletionFocus(Math.max(newIndex, 0));
176 | }
177 | };
178 |
179 | onEscape = (keyboardEvent) => {
180 | keyboardEvent.preventDefault();
181 |
182 | const activeOffsetKey = this.lastSelectionIsInsideWord
183 | .filter(value => value === true)
184 | .keySeq()
185 | .first();
186 | this.props.store.escapeSearch(activeOffsetKey);
187 | this.closeDropdown();
188 |
189 | // to force a re-render of the outer component to change the aria props
190 | this.props.store.setEditorState(this.props.store.getEditorState());
191 | };
192 |
193 | onCompletionSelect = (completion) => {
194 | this.closeDropdown();
195 | const newEditorState = addModifier(
196 | this.props.store.getEditorState(),
197 | completion,
198 | this.props.entityMutability,
199 | );
200 | this.props.store.setEditorState(newEditorState);
201 | };
202 |
203 | onCompletionFocus = (index) => {
204 | const descendant = `completion-option-${this.key}-${index}`;
205 | this.props.ariaProps.ariaActiveDescendantID = descendant;
206 | this.state.focusedOptionIndex = index;
207 |
208 | // to force a re-render of the outer component to change the aria props
209 | this.props.store.setEditorState(this.props.store.getEditorState());
210 | };
211 |
212 | commitSelection = () => {
213 | this.onCompletionSelect(this.props.suggestions.get(this.state.focusedOptionIndex));
214 | return true;
215 | };
216 |
217 | openDropdown = () => {
218 | // This is a really nasty way of attaching & releasing the key related functions.
219 | // It assumes that the keyFunctions object will not loose its reference and
220 | // by this we can replace inner parameters spread over different modules.
221 | // This better be some registering & unregistering logic. PRs are welcome :)
222 | this.props.callbacks.onDownArrow = this.onDownArrow;
223 | this.props.callbacks.onUpArrow = this.onUpArrow;
224 | this.props.callbacks.onEscape = this.onEscape;
225 | this.props.callbacks.handleReturn = this.commitSelection;
226 | this.props.callbacks.onTab = this.onTab;
227 |
228 | const descendant = `completion-option-${this.key}-${this.state.focusedOptionIndex}`;
229 | this.props.ariaProps.ariaActiveDescendantID = descendant;
230 | this.props.ariaProps.ariaOwneeID = `completions-list-${this.key}`;
231 | this.props.ariaProps.ariaHasPopup = 'true';
232 | this.props.ariaProps.ariaExpanded = 'true';
233 | this.setState({
234 | isActive: true,
235 | });
236 |
237 | if (this.props.onOpen) {
238 | this.props.onOpen();
239 | }
240 | };
241 |
242 | closeDropdown = () => {
243 | // make sure none of these callbacks are triggered
244 | this.props.callbacks.onDownArrow = undefined;
245 | this.props.callbacks.onUpArrow = undefined;
246 | this.props.callbacks.onTab = undefined;
247 | this.props.callbacks.onEscape = undefined;
248 | this.props.callbacks.handleReturn = undefined;
249 | this.props.ariaProps.ariaHasPopup = 'false';
250 | this.props.ariaProps.ariaExpanded = 'false';
251 | this.props.ariaProps.ariaActiveDescendantID = undefined;
252 | this.props.ariaProps.ariaOwneeID = undefined;
253 | this.setState({
254 | isActive: false,
255 | });
256 |
257 | if (this.props.onClose) {
258 | this.props.onClose();
259 | }
260 | };
261 |
262 | render() {
263 | if (!this.state.isActive) {
264 | return null;
265 | }
266 |
267 | const { theme = {} } = this.props;
268 | return (
269 |
276 | {
277 | this.props.suggestions.map((completion, index) => (
278 |
288 | )).toJS()
289 | }
290 |
291 | );
292 | }
293 | };
294 | }
295 |
--------------------------------------------------------------------------------
/src/CompletionSuggestionsPortal/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 |
3 | export default class CompletionSuggestionsPortal extends Component {
4 |
5 | componentWillMount() {
6 | this.props.store.register(this.props.offsetKey);
7 | this.updatePortalClientRect(this.props);
8 |
9 | // trigger a re-render so the MentionSuggestions becomes active
10 | this.props.setEditorState(this.props.getEditorState());
11 | }
12 |
13 | componentWillReceiveProps(nextProps) {
14 | this.updatePortalClientRect(nextProps);
15 | }
16 |
17 | componentWillUnmount() {
18 | this.props.store.unregister(this.props.offsetKey);
19 | }
20 |
21 | updatePortalClientRect(props) {
22 | this.props.store.updatePortalClientRect(
23 | props.offsetKey,
24 | () => (
25 | this.refs.searchPortal.getBoundingClientRect()
26 | ),
27 | );
28 | }
29 |
30 | render() {
31 | return (
32 |
33 | { this.props.children }
34 |
35 | );
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import completionSuggestionsCreator from './CompletionSuggestions';
2 | import CompletionSuggestionsPortal from './CompletionSuggestionsPortal';
3 | import decorateComponentWithProps from 'decorate-component-with-props';
4 | import { Map } from 'immutable';
5 | import suggestionsFilter from './utils/defaultSuggestionsFilter';
6 | import defaultPositionSuggestions from './utils/positionSuggestions';
7 |
8 | const createCompletionPlugin = (
9 | completionSuggestionsStrategy,
10 | addModifier,
11 | SuggestionEntry,
12 | suggestionsThemeKey = 'completionSuggestions',
13 | additionalDecorators = [],
14 | ) => (config = {}) => {
15 | const callbacks = {
16 | keyBindingFn: undefined,
17 | handleKeyCommand: undefined,
18 | onDownArrow: undefined,
19 | onUpArrow: undefined,
20 | onTab: undefined,
21 | onEscape: undefined,
22 | handleReturn: undefined,
23 | onChange: undefined,
24 | };
25 |
26 | const ariaProps = {
27 | ariaHasPopup: 'false',
28 | ariaExpanded: 'false',
29 | ariaOwneeID: undefined,
30 | ariaActiveDescendantID: undefined,
31 | };
32 |
33 | let searches = Map();
34 | let escapedSearch = undefined;
35 | let clientRectFunctions = Map();
36 |
37 | const store = {
38 | getEditorState: undefined,
39 | setEditorState: undefined,
40 | getPortalClientRect: (offsetKey) => clientRectFunctions.get(offsetKey)(),
41 | getAllSearches: () => searches,
42 | isEscaped: (offsetKey) => escapedSearch === offsetKey,
43 | escapeSearch: (offsetKey) => {
44 | escapedSearch = offsetKey;
45 | },
46 |
47 | resetEscapedSearch: () => {
48 | escapedSearch = undefined;
49 | },
50 |
51 | register: (offsetKey) => {
52 | searches = searches.set(offsetKey, offsetKey);
53 | },
54 |
55 | updatePortalClientRect: (offsetKey, func) => {
56 | clientRectFunctions = clientRectFunctions.set(offsetKey, func);
57 | },
58 |
59 | unregister: (offsetKey) => {
60 | searches = searches.delete(offsetKey);
61 | clientRectFunctions = clientRectFunctions.delete(offsetKey);
62 | },
63 | };
64 |
65 | const {
66 | theme = {},
67 | positionSuggestions = defaultPositionSuggestions,
68 | } = config;
69 | const completionSearchProps = {
70 | ariaProps,
71 | callbacks,
72 | theme,
73 | store,
74 | entityMutability: config.entityMutability ? config.entityMutability : 'SEGMENTED',
75 | positionSuggestions,
76 | };
77 | const CompletionSuggestions = completionSuggestionsCreator(addModifier, SuggestionEntry, suggestionsThemeKey);
78 | return {
79 | CompletionSuggestions: decorateComponentWithProps(CompletionSuggestions, completionSearchProps),
80 | decorators: [
81 | {
82 | strategy: completionSuggestionsStrategy,
83 | component: decorateComponentWithProps(CompletionSuggestionsPortal, { store }),
84 | },
85 | ...additionalDecorators,
86 | ],
87 | getAccessibilityProps: () => (
88 | {
89 | role: 'combobox',
90 | ariaAutoComplete: 'list',
91 | ariaHasPopup: ariaProps.ariaHasPopup,
92 | ariaExpanded: ariaProps.ariaExpanded,
93 | ariaActiveDescendantID: ariaProps.ariaActiveDescendantID,
94 | ariaOwneeID: ariaProps.ariaOwneeID,
95 | }
96 | ),
97 |
98 | initialize: ({ getEditorState, setEditorState }) => {
99 | store.getEditorState = getEditorState;
100 | store.setEditorState = setEditorState;
101 | },
102 |
103 | onDownArrow: (keyboardEvent) => callbacks.onDownArrow && callbacks.onDownArrow(keyboardEvent),
104 | onTab: (keyboardEvent) => callbacks.onTab && callbacks.onTab(keyboardEvent),
105 | onUpArrow: (keyboardEvent) => callbacks.onUpArrow && callbacks.onUpArrow(keyboardEvent),
106 | onEscape: (keyboardEvent) => callbacks.onEscape && callbacks.onEscape(keyboardEvent),
107 | handleReturn: (keyboardEvent) => callbacks.handleReturn && callbacks.handleReturn(keyboardEvent),
108 | onChange: (editorState) => {
109 | if (callbacks.onChange) return callbacks.onChange(editorState);
110 | return editorState;
111 | },
112 | };
113 | };
114 |
115 | export default createCompletionPlugin;
116 |
117 | export const defaultSuggestionsFilter = suggestionsFilter;
118 |
--------------------------------------------------------------------------------
/src/utils/__test__/getWordAt.js:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import getWordAt from '../getWordAt';
3 |
4 | describe('getWordAt', () => {
5 | it('finds a word in between sentences', () => {
6 | const expected = {
7 | word: 'is',
8 | begin: 5,
9 | end: 7,
10 | };
11 | expect(getWordAt('this is a test', 5)).to.deep.equal(expected);
12 | });
13 |
14 | it('finds the first word', () => {
15 | const expected = {
16 | word: 'this',
17 | begin: 0,
18 | end: 4,
19 | };
20 | expect(getWordAt('this is a test', 0)).to.deep.equal(expected);
21 | });
22 |
23 | it('finds the last word', () => {
24 | const expected = {
25 | word: 'test',
26 | begin: 10,
27 | end: 14,
28 | };
29 | expect(getWordAt('this is a test', 15)).to.deep.equal(expected);
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/src/utils/decodeOffsetKey.js:
--------------------------------------------------------------------------------
1 | const decodeOffsetKey = (offsetKey) => {
2 | const [blockKey, decoratorKey, leafKey] = offsetKey.split('-');
3 | return {
4 | blockKey,
5 | decoratorKey: parseInt(decoratorKey, 10),
6 | leafKey: parseInt(leafKey, 10),
7 | };
8 | };
9 |
10 | export default decodeOffsetKey;
11 |
--------------------------------------------------------------------------------
/src/utils/defaultSuggestionsFilter.js:
--------------------------------------------------------------------------------
1 | // Get the first 5 suggestions that match
2 | const defaultSuggestionsFilter = (searchValue, suggestions) => {
3 | const value = searchValue.toLowerCase();
4 | const filteredSuggestions = suggestions.filter((suggestion) => (
5 | !value || suggestion.get('name').toLowerCase().indexOf(value) > -1
6 | ));
7 | const size = filteredSuggestions.size < 5 ? filteredSuggestions.size : 5;
8 | return filteredSuggestions.setSize(size);
9 | };
10 |
11 | export default defaultSuggestionsFilter;
12 |
--------------------------------------------------------------------------------
/src/utils/getSearchText.js:
--------------------------------------------------------------------------------
1 | import getWordAt from './getWordAt';
2 |
3 | const getSearchText = (editorState, selection) => {
4 | const anchorKey = selection.getAnchorKey();
5 | const anchorOffset = selection.getAnchorOffset() - 1;
6 | const currentContent = editorState.getCurrentContent();
7 | const currentBlock = currentContent.getBlockForKey(anchorKey);
8 | const blockText = currentBlock.getText();
9 | return getWordAt(blockText, anchorOffset);
10 | };
11 |
12 | export default getSearchText;
13 |
--------------------------------------------------------------------------------
/src/utils/getWordAt.js:
--------------------------------------------------------------------------------
1 | const getWordAt = (string, position) => {
2 | // Perform type conversions.
3 | const str = String(string);
4 | const pos = Number(position) >>> 0;
5 |
6 | // Search for the word's beginning and end.
7 | const left = str.slice(0, pos + 1).search(/\S+$/);
8 | const right = str.slice(pos).search(/\s/);
9 |
10 | // The last word in the string is a special case.
11 | if (right < 0) {
12 | return {
13 | word: str.slice(left),
14 | begin: left,
15 | end: str.length,
16 | };
17 | }
18 |
19 | // Return the word, using the located bounds to extract it from the string.
20 | return {
21 | word: str.slice(left, right + pos),
22 | begin: left,
23 | end: right + pos,
24 | };
25 | };
26 |
27 | export default getWordAt;
28 |
--------------------------------------------------------------------------------
/src/utils/positionSuggestions.js:
--------------------------------------------------------------------------------
1 | const getRelativeParent = (element) => {
2 | if (!element) {
3 | return null;
4 | }
5 |
6 | const position = window.getComputedStyle(element).getPropertyValue('position');
7 | if (position !== 'static') {
8 | return element;
9 | }
10 |
11 | return getRelativeParent(element.parentElement);
12 | };
13 |
14 | const positionSuggestions = ({ decoratorRect, popover, state, props }) => {
15 | const relativeParent = getRelativeParent(popover.parentElement);
16 | const relativeRect = {};
17 |
18 | if (relativeParent) {
19 | relativeRect.scrollLeft = relativeParent.scrollLeft;
20 | relativeRect.scrollTop = relativeParent.scrollTop;
21 |
22 | const relativeParentRect = relativeParent.getBoundingClientRect();
23 | relativeRect.left = decoratorRect.left - relativeParentRect.left;
24 | relativeRect.top = decoratorRect.top - relativeParentRect.top;
25 | } else {
26 | relativeRect.scrollTop = window.pageYOffset || document.documentElement.scrollTop;
27 | relativeRect.scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
28 |
29 | relativeRect.top = decoratorRect.top;
30 | relativeRect.left = decoratorRect.left;
31 | }
32 |
33 | const left = relativeRect.left + relativeRect.scrollLeft;
34 | const top = relativeRect.top + relativeRect.scrollTop;
35 |
36 | let transform;
37 | let transition;
38 | if (state.isActive) {
39 | if (props.suggestions.size > 0) {
40 | transform = 'scale(1)';
41 | transition = 'all 0.25s cubic-bezier(.3,1.2,.2,1)';
42 | } else {
43 | transform = 'scale(0)';
44 | transition = 'all 0.35s cubic-bezier(.3,1,.2,1)';
45 | }
46 | }
47 |
48 | return {
49 | left: `${left}px`,
50 | top: `${top}px`,
51 | transform,
52 | transformOrigin: '1em 0%',
53 | transition,
54 | };
55 | };
56 |
57 | export default positionSuggestions;
58 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-var */
2 | var path = require('path');
3 | var ExtractTextPlugin = require('extract-text-webpack-plugin');
4 | var autoprefixer = require('autoprefixer');
5 |
6 | module.exports = {
7 | output: {
8 | publicPath: '/',
9 | libraryTarget: 'commonjs2', // necessary for the babel plugin
10 | path: path.join(__dirname, 'lib-css'), // where to place webpack files
11 | },
12 | module: {
13 | loaders: [
14 | {
15 | test: /\.css$/,
16 | loader: ExtractTextPlugin.extract('style-loader', 'css-loader?modules&importLoaders=1&localIdentName=draftJsMentionPlugin__[local]__[hash:base64:5]!postcss-loader'),
17 | },
18 | ],
19 | },
20 | postcss: [autoprefixer({ browsers: ['> 1%'] })],
21 | plugins: [
22 | new ExtractTextPlugin(`${path.parse(process.argv[2]).name}.css`),
23 | ],
24 | };
25 |
--------------------------------------------------------------------------------