suggestions.js (5449B)
1 import { Plugin, PluginKey } from 'prosemirror-state'; 2 import { Decoration, DecorationSet } from 'prosemirror-view'; 3 4 /** 5 * Create a matcher that matches when a specific character is typed. Useful for @mentions and #tags. 6 * 7 * @param {String} char 8 * @param {Boolean} allowSpaces 9 * @returns {function(*)} 10 */ 11 export function triggerCharacter(char, { allowSpaces = false }) { 12 /** 13 * @param {ResolvedPos} $position 14 */ 15 return $position => { 16 // Matching expressions used for later 17 const suffix = new RegExp(`\\s${char}$`); 18 const regexp = allowSpaces 19 ? new RegExp(`${char}.*?(?=\\s${char}|$)`, 'g') 20 : new RegExp(`(?:^)?${char}[^\\s${char}]*`, 'g'); 21 22 // Lookup the boundaries of the current node 23 const textFrom = $position.before(); 24 const textTo = $position.end(); 25 26 const text = $position.doc.textBetween(textFrom, textTo, '\0', '\0'); 27 28 let match; 29 30 while (match = regexp.exec(text)) { 31 // Javascript doesn't have lookbehinds; this hacks a check that first character is " " or the line beginning 32 const prefix = match.input.slice(Math.max(0, match.index - 1), match.index); 33 if (!/^[\s\0]?$/.test(prefix)) { 34 continue; 35 } 36 37 // The absolute position of the match in the document 38 const from = match.index + $position.start(); 39 let to = from + match[0].length; 40 41 // Edge case handling; if spaces are allowed and we're directly in between two triggers 42 if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) { 43 match[0] += ' '; 44 to++; 45 } 46 47 // If the $position is located within the matched substring, return that range 48 if (from < $position.pos && to >= $position.pos) { 49 return { range: { from, to }, text: match[0] }; 50 } 51 } 52 } 53 } 54 55 /** 56 * @returns {Plugin} 57 */ 58 export function suggestionsPlugin({ 59 matcher = triggerCharacter('#'), 60 suggestionClass = 'ProseMirror-suggestion', 61 onEnter = () => false, 62 onChange = () => false, 63 onExit = () => false, 64 onKeyDown = () => false, 65 debug = false, 66 }) { 67 return new Plugin({ 68 key: new PluginKey('suggestions'), 69 70 view() { 71 return { 72 update: (view, prevState) => { 73 const prev = this.key.getState(prevState); 74 const next = this.key.getState(view.state); 75 76 // See how the state changed 77 const moved = prev.active && next.active && prev.range.from !== next.range.from; 78 const started = !prev.active && next.active; 79 const stopped = prev.active && !next.active; 80 const changed = !started && !stopped && prev.text !== next.text; 81 82 // Trigger the hooks when necessary 83 if (stopped || moved) onExit({ view, range: prev.range, text: prev.text }); 84 if (changed && !moved) onChange({ view, range: next.range, text: next.text }); 85 if (started || moved) onEnter({ view, range: next.range, text: next.text }); 86 }, 87 }; 88 }, 89 90 state: { 91 /** 92 * Initialize the plugin's internal state. 93 * 94 * @returns {Object} 95 */ 96 init() { 97 return { 98 active: false, 99 range: {}, 100 text: null, 101 }; 102 }, 103 104 /** 105 * Apply changes to the plugin state from a view transaction. 106 * 107 * @param {Transaction} tr 108 * @param {Object} prev 109 * 110 * @returns {Object} 111 */ 112 apply(tr, prev) { 113 const { selection } = tr; 114 const next = { ...prev }; 115 116 // We can only be suggesting if there is no selection 117 if (selection.from === selection.to) { 118 // Reset active state if we just left the previous suggestion range 119 if (selection.from < prev.range.from || selection.from > prev.range.to) { 120 next.active = false; 121 } 122 123 // Try to match against where our cursor currently is 124 const $position = selection.$from; 125 const match = matcher($position); 126 127 // If we found a match, update the current state to show it 128 if (match) { 129 next.active = true; 130 next.range = match.range; 131 next.text = match.text; 132 } else { 133 next.active = false; 134 } 135 } else { 136 next.active = false; 137 } 138 139 // Make sure to empty the range if suggestion is inactive 140 if (!next.active) { 141 next.range = {}; 142 next.text = null; 143 } 144 145 return next; 146 }, 147 }, 148 149 props: { 150 /** 151 * Call the keydown hook if suggestion is active. 152 * 153 * @param view 154 * @param event 155 * @returns {boolean} 156 */ 157 handleKeyDown(view, event) { 158 const { active } = this.getState(view.state); 159 160 if (!active) return false; 161 162 return onKeyDown({ view, event }); 163 }, 164 165 /** 166 * Setup decorator on the currently active suggestion. 167 * 168 * @param {EditorState} editorState 169 * 170 * @returns {?DecorationSet} 171 */ 172 decorations(editorState) { 173 const { active, range } = this.getState(editorState); 174 175 if (!active) return null; 176 177 return DecorationSet.create(editorState.doc, [ 178 Decoration.inline(range.from, range.to, { 179 nodeName: 'span', 180 class: suggestionClass, 181 style: debug ? 'background: rgba(0, 0, 255, 0.05); color: blue; border: 2px solid blue;' : null, 182 }), 183 ]); 184 }, 185 }, 186 }); 187 }