UNPKG

prosemirror-suggestions

Version:

ProseMirror plugin for suggestions (i.e. mentions, tags)

314 lines (268 loc) 8.99 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var prosemirrorState = require('prosemirror-state'); var prosemirrorView = require('prosemirror-view'); /** * Create a matcher that matches when a specific character is typed. Useful for @mentions and #tags. * * @param {String} char * @param {Boolean} allowSpaces * @returns {function(*)} */ function triggerCharacter(char, allowSpaces) { if ( allowSpaces === void 0 ) allowSpaces = false; /** * @param {ResolvedPos} $position */ return function ($position) { // Matching expressions used for later var suffix = new RegExp(("\\s" + char + "$")); var regexp = allowSpaces ? new RegExp((char + ".*?(?=\\s" + char + "|$)"), 'g') : new RegExp(("(?:^)?" + char + "[^\\s" + char + "]*"), 'g'); // Lookup the boundaries of the current node var textFrom = $position.before(); var textTo = $position.end(); var text = $position.doc.textBetween(textFrom, textTo, '\0', '\0'); var match; while (match = regexp.exec(text)) { // JavaScript doesn't have lookbehinds; this hacks a check that first character is " " or the line beginning var prefix = match.input.slice(Math.max(0, match.index - 1), match.index); if (!/^[\s\0]?$/.test(prefix)) { continue; } // The absolute position of the match in the document var from = match.index + $position.start(); var to = from + match[0].length; // Edge case handling; if spaces are allowed and we're directly in between two triggers if (allowSpaces && suffix.test(text.slice(to - 1, to + 1))) { match[0] += ' '; to++; } // If the $position is located within the matched substring, return that range if (from < $position.pos && to >= $position.pos) { return { range: { from: from, to: to }, text: match[0] }; } } } } /** * @returns {Plugin} */ function suggestionsPlugin(ref) { var matcher = ref.matcher; if ( matcher === void 0 ) matcher = triggerCharacter('#'); var suggestionClass = ref.suggestionClass; if ( suggestionClass === void 0 ) suggestionClass = 'ProseMirror-suggestion'; var onEnter = ref.onEnter; if ( onEnter === void 0 ) onEnter = function () { return false; }; var onChange = ref.onChange; if ( onChange === void 0 ) onChange = function () { return false; }; var onExit = ref.onExit; if ( onExit === void 0 ) onExit = function () { return false; }; var onKeyDown = ref.onKeyDown; if ( onKeyDown === void 0 ) onKeyDown = function () { return false; }; var escapeOnSelectionChange = ref.escapeOnSelectionChange; if ( escapeOnSelectionChange === void 0 ) escapeOnSelectionChange = true; var escapeKeys = ref.escapeKeys; if ( escapeKeys === void 0 ) escapeKeys = ['Escape', 'ArrowRight', 'ArrowLeft']; var debug = ref.debug; if ( debug === void 0 ) debug = false; return new prosemirrorState.Plugin({ key: new prosemirrorState.PluginKey('suggestions'), view: function view() { var this$1 = this; return { update: function (view, prevState) { var prev = this$1.key.getState(prevState); var next = this$1.key.getState(view.state); // See how the state changed var moved = prev.active && next.active && prev.range.from !== next.range.from; var started = !prev.active && next.active; var stopped = prev.active && !next.active; var changed = !started && !stopped && prev.text !== next.text; // Trigger the hooks when necessary if (stopped || moved) { onExit({ view: view, range: prev.range, text: prev.text }); } if (changed && !moved) { onChange({ view: view, range: next.range, text: next.text }); } if (started || moved) { onEnter({ view: view, range: next.range, text: next.text }); } }, }; }, state: { /** * Initialize the plugin's internal state. * * @returns {Object} */ init: function init() { return { active: false, range: {}, text: null, }; }, /** * Apply changes to the plugin state from a view transaction. * * @param {Transaction} tr * @param {Object} prev * * @returns {Object} */ apply: function apply(tr, prev) { var meta = tr.getMeta(this.key); if (meta) { return meta; } var selection = tr.selection; var next = Object.assign({}, prev); if (escapeOnSelectionChange && !tr.docChanged && tr.selectionSet) { // allow user to escape with arrow keys next.active = false; } else if (selection.from === selection.to) { // We can only be suggesting if there is no selection // Reset active state if we just left the previous suggestion range if (selection.from < prev.range.from || selection.from > prev.range.to) { next.active = false; } // Try to match against where our cursor currently is var $position = selection.$from; var match = matcher($position); // If we found a match, update the current state to show it if (match) { next.active = true; next.range = match.range; next.text = match.text; } else { next.active = false; } } else { next.active = false; } // Make sure to empty the range if suggestion is inactive if (!next.active) { next.range = {}; next.text = null; } return next; }, }, props: { /** * Call the keydown hook if suggestion is active. * * @param view * @param event * @returns {boolean} */ handleKeyDown: function handleKeyDown(view, event) { var ref = this.getState(view.state); var active = ref.active; if (!active) { return false } if (escapeKeys.includes(event.key)) { var tr = view.state.tr.setMeta(this.key, { active: false, range: {}, text: null }); view.dispatch(tr); return false; } return onKeyDown({ view: view, event: event }); }, /** * Setup decorator on the currently active suggestion. * * @param {EditorState} editorState * * @returns {?DecorationSet} */ decorations: function decorations(editorState) { var ref = this.getState(editorState); var active = ref.active; var range = ref.range; if (!active) { return null; } return prosemirrorView.DecorationSet.create(editorState.doc, [ prosemirrorView.Decoration.inline(range.from, range.to, { nodeName: 'span', class: suggestionClass, style: debug ? 'background: rgba(0, 0, 255, 0.05); color: blue; border: 2px solid blue;' : null, }) ]); }, }, }); } /** * @type {NodeSpec} */ var tagNodeSpec = { attrs: { id: {}, }, group: 'inline', inline: true, selectable: false, atom: true, /** * @param {Node} node */ toDOM: function (node) { return ['span', { 'class': 'tag', 'data-tag-id': node.attrs.id, }, node.attrs.id]; }, parseDOM: [{ tag: 'span[data-tag-id]', /** * @param {Element} dom * @returns {{id: string}} */ getAttrs: function (dom) { var id = dom.getAttribute('data-tag-id'); return { id: id }; }, }], }; /** * @param {OrderedMap} nodes * @returns {OrderedMap} */ function addTagNodes(nodes) { return nodes.append({ tag: tagNodeSpec, }); } /** * @type {NodeSpec} */ var mentionNodeSpec = { attrs: { type: {}, id: {}, label: {}, }, group: 'inline', inline: true, selectable: false, atom: true, toDOM: function (node) { return ['span', { 'class': 'mention', 'data-mention-type': node.attrs.type, 'data-mention-id': node.attrs.id, }, ("@" + (node.attrs.label))]; }, parseDOM: [{ tag: 'span[data-mention-type][data-mention-id]', /** * @param {Element} dom * @returns {{type: string, id: string, label: string}} */ getAttrs: function (dom) { var type = dom.getAttribute('data-mention-type'); var id = dom.getAttribute('data-mention-id'); var label = dom.innerText; return { type: type, id: id, label: label }; }, }], }; /** * @param {OrderedMap} nodes * @returns {OrderedMap} */ function addMentionNodes(nodes) { return nodes.append({ mention: mentionNodeSpec, }); } exports.suggestionsPlugin = suggestionsPlugin; exports.triggerCharacter = triggerCharacter; exports.addTagNodes = addTagNodes; exports.tagNodeSpec = tagNodeSpec; exports.addMentionNodes = addMentionNodes; exports.mentionNodeSpec = mentionNodeSpec; //# sourceMappingURL=index.js.map