prosemirror-suggestions
Version:
ProseMirror plugin for suggestions (i.e. mentions, tags)
314 lines (268 loc) • 8.99 kB
JavaScript
'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