UNPKG

prosemirror-mentions

Version:

ProseMirror plugin for enabling @mentions & #hashtags

456 lines (390 loc) 12.4 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var prosemirrorState = require('prosemirror-state'); var prosemirrorView = require('prosemirror-view'); /** * * @param {String} mentionTrigger * @param {String} hashtagTrigger * @param {bool} allowSpace * @returns {Object} */ function getRegexp(mentionTrigger, hashtagTrigger, allowSpace) { var mention = allowSpace ? new RegExp("(^|\\s)" + mentionTrigger + "([\\w-\\+]+\\s?[\\w-\\+]*)$") : new RegExp("(^|\\s)" + mentionTrigger + "([\\w-\\+]+)$"); // hashtags should never allow spaces. I mean, what's the point of allowing spaces in hashtags? var tag = new RegExp("(^|\\s)" + hashtagTrigger + "([\\w-]+)$"); return { mention: mention, tag: tag }; } /** * * @param {ResolvedPosition} $position https://prosemirror.net/docs/ref/#model.Resolved_Positions * @param {JSONObject} opts * @returns {JSONObject} */ function getMatch($position, opts) { // take current para text content upto cursor start. // this makes the regex simpler and parsing the matches easier. var parastart = $position.before(); const text = $position.doc.textBetween(parastart, $position.pos, "\n", "\0"); var regex = getRegexp(opts.mentionTrigger, opts.hashtagTrigger, opts.allowSpace); // only one of the below matches will be true. var mentionMatch = text.match(regex.mention); var tagMatch = text.match(regex.tag); var match = mentionMatch || tagMatch; // set type of match var type; if (mentionMatch) { type = "mention"; } else if (tagMatch) { type = "tag"; } // if match found, return match with useful information. if (match) { // adjust match.index to remove the matched extra space match.index = match[0].startsWith(" ") ? match.index + 1 : match.index; match[0] = match[0].startsWith(" ") ? match[0].substring(1, match[0].length) : match[0]; // The absolute position of the match in the document var from = $position.start() + match.index; var to = from + match[0].length; var queryText = match[2]; return { range: { from: from, to: to }, queryText: queryText, type: type }; } // else if no match don't return anything. } /** * Util to debounce call to a function. * >>> debounce(function(){}, 1000, this) */ const debounce = function () { var timeoutId = null; return function (func, timeout, context) { context = context || this; clearTimeout(timeoutId); timeoutId = setTimeout(function () { func.apply(context, arguments); }, timeout); return timeoutId; }; }(); var getNewState = function () { return { active: false, range: { from: 0, to: 0 }, type: "", //mention or tag text: "", suggestions: [], index: 0 // current active suggestion index }; }; /** * @param {JSONObject} opts * @returns {Plugin} */ function getMentionsPlugin(opts) { // default options var defaultOpts = { mentionTrigger: "@", hashtagTrigger: "#", allowSpace: true, getSuggestions: (type, text, cb) => { cb([]); }, getSuggestionsHTML: items => '<div class="suggestion-item-list">' + items.map(i => '<div class="suggestion-item">' + i.name + "</div>").join("") + "</div>", activeClass: "suggestion-item-active", suggestionTextClass: "prosemirror-suggestion", maxNoOfSuggestions: 10, delay: 500 }; var opts = Object.assign({}, defaultOpts, opts); // timeoutId for clearing debounced calls var showListTimeoutId = null; // dropdown element var el = document.createElement("div"); // current Idx var showList = function (view, state, suggestions, opts) { el.innerHTML = opts.getSuggestionsHTML(suggestions, state.type); // attach new item event handlers el.querySelectorAll(".suggestion-item").forEach(function (itemNode, index) { itemNode.addEventListener("click", function () { select(view, state, opts); view.focus(); }); // TODO: setIndex() needlessly queries. // We already have the itemNode. SHOULD OPTIMIZE. itemNode.addEventListener("mouseover", function () { setIndex(index, state, opts); }); itemNode.addEventListener("mouseout", function () { setIndex(index, state, opts); }); }); // highlight first element by default - like Facebook. addClassAtIndex(state.index, opts.activeClass); // get current @mention span left and top. // TODO: knock off domAtPos usage. It's not documented and is not officially a public API. // It's used currently, only to optimize the the query for textDOM var node = view.domAtPos(view.state.selection.$from.pos); var paraDOM = node.node; var textDOM = paraDOM.querySelector("." + opts.suggestionTextClass); // TODO: should add null check case for textDOM var offset = textDOM.getBoundingClientRect(); // TODO: think about outsourcing this positioning logic as options document.body.appendChild(el); el.classList.add('suggestion-item-container'); el.style.position = "fixed"; el.style.left = offset.left + "px"; var top = textDOM.offsetHeight + offset.top; el.style.top = top + "px"; el.style.display = "block"; el.style.zIndex = "999999"; }; var hideList = function () { el.style.display = "none"; }; var removeClassAtIndex = function (index, className) { var itemList = el.querySelector(".suggestion-item-list").childNodes; var prevItem = itemList[index]; prevItem.classList.remove(className); }; var addClassAtIndex = function (index, className) { var itemList = el.querySelector(".suggestion-item-list").childNodes; var prevItem = itemList[index]; prevItem.classList.add(className); }; var setIndex = function (index, state, opts) { removeClassAtIndex(state.index, opts.activeClass); state.index = index; addClassAtIndex(state.index, opts.activeClass); }; var goNext = function (view, state, opts) { removeClassAtIndex(state.index, opts.activeClass); state.index++; state.index = state.index === state.suggestions.length ? 0 : state.index; addClassAtIndex(state.index, opts.activeClass); }; var goPrev = function (view, state, opts) { removeClassAtIndex(state.index, opts.activeClass); state.index--; state.index = state.index === -1 ? state.suggestions.length - 1 : state.index; addClassAtIndex(state.index, opts.activeClass); }; var select = function (view, state, opts) { var item = state.suggestions[state.index]; var attrs; if (state.type === "mention") { attrs = { name: item.name, id: item.id, email: item.email }; } else { attrs = { tag: item.tag }; } var node = view.state.schema.nodes[state.type].create(attrs); var tr = view.state.tr.replaceWith(state.range.from, state.range.to, node); //var newState = view.state.apply(tr); //view.updateState(newState); view.dispatch(tr); }; /** * See https://prosemirror.net/docs/ref/#state.Plugin_System * for the plugin properties spec. */ return new prosemirrorState.Plugin({ key: new prosemirrorState.PluginKey("autosuggestions"), // we will need state to track if suggestion dropdown is currently active or not state: { init() { return getNewState(); }, apply(tr, state) { // compute state.active for current transaction and return var newState = getNewState(); var selection = tr.selection; if (selection.from !== selection.to) { return newState; } const $position = selection.$from; const match = getMatch($position, opts); // if match found update state if (match) { newState.active = true; newState.range = match.range; newState.type = match.type; newState.text = match.queryText; } return newState; } }, // We'll need props to hi-jack keydown/keyup & enter events when suggestion dropdown // is active. props: { handleKeyDown(view, e) { var state = this.getState(view.state); // don't handle if no suggestions or not in active mode if (!state.active && !state.suggestions.length) { return false; } // if any of the below keys, override with custom handlers. var down, up, enter, esc; enter = e.keyCode === 13; down = e.keyCode === 40; up = e.keyCode === 38; esc = e.keyCode === 27; if (down) { goNext(view, state, opts); return true; } else if (up) { goPrev(view, state, opts); return true; } else if (enter) { select(view, state, opts); return true; } else if (esc) { clearTimeout(showListTimeoutId); hideList(); this.state = getNewState(); return true; } else { // didn't handle. handover to prosemirror for handling. return false; } }, // to decorate the currently active @mention text in ui decorations(editorState) { const { active, range } = this.getState(editorState); if (!active) return null; return prosemirrorView.DecorationSet.create(editorState.doc, [prosemirrorView.Decoration.inline(range.from, range.to, { nodeName: "span", class: opts.suggestionTextClass })]); } }, // To track down state mutations and add dropdown reactions view() { return { update: view => { var state = this.key.getState(view.state); if (!state.text) { hideList(); clearTimeout(showListTimeoutId); return; } // debounce the call to avoid multiple requests showListTimeoutId = debounce(function () { // get suggestions and set new state opts.getSuggestions(state.type, state.text, function (suggestions) { // update `state` argument with suggestions state.suggestions = suggestions; showList(view, state, suggestions, opts); }); }, opts.delay, this); } }; } }); } /** * See https://prosemirror.net/docs/ref/#model.NodeSpec */ const mentionNode = { group: "inline", inline: true, atom: true, attrs: { id: "", name: "", email: "" }, selectable: false, draggable: false, toDOM: node => { return ["span", { "data-mention-id": node.attrs.id, "data-mention-name": node.attrs.name, "data-mention-email": node.attrs.email, class: "prosemirror-mention-node" }, "@" + node.attrs.name || node.attrs.email]; }, parseDOM: [{ // match tag with following CSS Selector tag: "span[data-mention-id][data-mention-name][data-mention-email]", getAttrs: dom => { var id = dom.getAttribute("data-mention-id"); var name = dom.getAttribute("data-mention-name"); var email = dom.getAttribute("data-mention-email"); return { id: id, name: name, email: email }; } }] }; /** * See https://prosemirror.net/docs/ref/#model.NodeSpec */ const tagNode = { group: "inline", inline: true, atom: true, attrs: { tag: "" }, selectable: false, draggable: false, toDOM: node => { return ["span", { "data-tag": node.attrs.tag, class: "prosemirror-tag-node" }, "#" + node.attrs.tag]; }, parseDOM: [{ // match tag with following CSS Selector tag: "span[data-tag]", getAttrs: dom => { var tag = dom.getAttribute("data-tag"); return { tag: tag }; } }] }; /** * * @param {OrderedMap} nodes * @returns {OrderedMap} */ function addMentionNodes(nodes) { return nodes.append({ mention: mentionNode }); } /** * * @param {OrderedMap} nodes * @returns {OrderedMap} */ function addTagNodes(nodes) { return nodes.append({ tag: tagNode }); } exports.getMentionsPlugin = getMentionsPlugin; exports.addMentionNodes = addMentionNodes; exports.addTagNodes = addTagNodes; exports.tagNode = tagNode; exports.mentionNode = mentionNode; //# sourceMappingURL=index.js.map