├── .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 | [![npm](https://img.shields.io/npm/v/draft-js-autocomplete-plugin-creator.svg)](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 |
53 |
54 | 63 |
64 | 68 |
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 | --------------------------------------------------------------------------------