diff --git a/examples/Components/Routes/Mentions/index.vue b/examples/Components/Routes/Mentions/index.vue new file mode 100644 index 00000000..a81db4b3 --- /dev/null +++ b/examples/Components/Routes/Mentions/index.vue @@ -0,0 +1,66 @@ + + + + + + + Mentions + + + Yeah Philipp Kühn and Hans Pagel. + + + + + + + + \ No newline at end of file diff --git a/examples/Components/Subnavigation/index.vue b/examples/Components/Subnavigation/index.vue index 5122532d..ea9e9afc 100644 --- a/examples/Components/Subnavigation/index.vue +++ b/examples/Components/Subnavigation/index.vue @@ -27,6 +27,9 @@ Embeds + + Mentions + Export HTML or JSON diff --git a/examples/main.js b/examples/main.js index ce2e6ba5..6e7eded6 100644 --- a/examples/main.js +++ b/examples/main.js @@ -12,6 +12,7 @@ import RouteTodoList from 'Components/Routes/TodoList' import RouteMarkdownShortcuts from 'Components/Routes/MarkdownShortcuts' import RouteReadOnly from 'Components/Routes/ReadOnly' import RouteEmbeds from 'Components/Routes/Embeds' +import RouteMentions from 'Components/Routes/Mentions' import RouteExport from 'Components/Routes/Export' const __svg__ = { path: './assets/images/icons/*.svg', name: 'assets/images/[hash].sprite.svg' } @@ -85,6 +86,13 @@ const routes = [ githubUrl: 'https://github.com/heyscrumpy/tiptap/tree/master/examples/Components/Routes/Embeds', }, }, + { + path: '/mentions', + component: RouteMentions, + meta: { + githubUrl: 'https://github.com/heyscrumpy/tiptap/tree/master/examples/Components/Routes/Mentions', + }, + }, { path: '/export', component: RouteExport, diff --git a/packages/tiptap-extensions/package.json b/packages/tiptap-extensions/package.json index adceb514..1885bcd5 100644 --- a/packages/tiptap-extensions/package.json +++ b/packages/tiptap-extensions/package.json @@ -21,6 +21,8 @@ }, "dependencies": { "prosemirror-history": "^1.0.2", + "prosemirror-state": "^1.2.2", + "prosemirror-view": "^1.5.1", "tiptap": "^0.8.0", "tiptap-commands": "^0.2.4" } diff --git a/packages/tiptap-extensions/src/index.js b/packages/tiptap-extensions/src/index.js index 8e176784..c0ced2df 100644 --- a/packages/tiptap-extensions/src/index.js +++ b/packages/tiptap-extensions/src/index.js @@ -5,6 +5,7 @@ export { default as HardBreakNode } from './nodes/HardBreak' export { default as HeadingNode } from './nodes/Heading' export { default as ImageNode } from './nodes/Image' export { default as ListItemNode } from './nodes/ListItem' +export { default as MentionNode } from './nodes/Mention' export { default as OrderedListNode } from './nodes/OrderedList' export { default as TodoItemNode } from './nodes/TodoItem' export { default as TodoListNode } from './nodes/TodoList' diff --git a/packages/tiptap-extensions/src/nodes/Mention.js b/packages/tiptap-extensions/src/nodes/Mention.js new file mode 100644 index 00000000..aa44a164 --- /dev/null +++ b/packages/tiptap-extensions/src/nodes/Mention.js @@ -0,0 +1,66 @@ +import { Node } from 'tiptap' +import { triggerCharacter, suggestionsPlugin } from '../plugins/suggestions' + +export default class BlockquoteNode extends Node { + + get name() { + return 'blockquote' + } + + get schema() { + return { + attrs: { + type: {}, + id: {}, + label: {}, + }, + group: 'inline', + inline: true, + selectable: false, + atom: true, + toDOM: node => [ + '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]', + getAttrs: dom => { + const type = dom.getAttribute('data-mention-type') + const id = dom.getAttribute('data-mention-id') + const label = dom.innerText + return { type, id, label } + }, + }, + ], + } + } + + get plugins() { + return [ + suggestionsPlugin({ + debug: true, + matcher: triggerCharacter('@', { allowSpaces: false }), + onEnter(args) { + console.log('start', args); + }, + onChange(args) { + console.log('change', args); + }, + onExit(args) { + console.log('stop', args); + }, + onKeyDown({ view, event }) { + // console.log(event.key); + return false; + }, + }), + ] + } + +} diff --git a/packages/tiptap-extensions/src/plugins/suggestions.js b/packages/tiptap-extensions/src/plugins/suggestions.js new file mode 100644 index 00000000..dc644b3d --- /dev/null +++ b/packages/tiptap-extensions/src/plugins/suggestions.js @@ -0,0 +1,187 @@ +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Decoration, DecorationSet } from '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(*)} + */ +export function triggerCharacter(char, { allowSpaces = false }) { + /** + * @param {ResolvedPos} $position + */ + return $position => { + // Matching expressions used for later + const suffix = new RegExp(`\\s${char}$`); + const regexp = allowSpaces + ? new RegExp(`${char}.*?(?=\\s${char}|$)`, 'g') + : new RegExp(`(?:^)?${char}[^\\s${char}]*`, 'g'); + + // Lookup the boundaries of the current node + const textFrom = $position.before(); + const textTo = $position.end(); + + const text = $position.doc.textBetween(textFrom, textTo, '\0', '\0'); + + let match; + + while ((match = regexp.exec(text))) { + // Javascript doesn't have lookbehinds; this hacks a check that first character is " " or the line beginning + const 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 + const from = match.index + $position.start(); + let 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, to }, text: match[0] }; + } + } + }; +} + +/** + * @returns {Plugin} + */ +export function suggestionsPlugin({ + matcher = triggerCharacter('#'), + suggestionClass = 'ProseMirror-suggestion', + onEnter = () => false, + onChange = () => false, + onExit = () => false, + onKeyDown = () => false, + debug = false, +}) { + return new Plugin({ + key: new PluginKey('suggestions'), + + view() { + return { + update: (view, prevState) => { + const prev = this.key.getState(prevState); + const next = this.key.getState(view.state); + + // See how the state changed + const moved = prev.active && next.active && prev.range.from !== next.range.from; + const started = !prev.active && next.active; + const stopped = prev.active && !next.active; + const changed = !started && !stopped && prev.text !== next.text; + + // Trigger the hooks when necessary + if (stopped || moved) onExit({ view, range: prev.range, text: prev.text }); + if (changed && !moved) onChange({ view, range: next.range, text: next.text }); + if (started || moved) onEnter({ view, range: next.range, text: next.text }); + }, + }; + }, + + state: { + /** + * Initialize the plugin's internal state. + * + * @returns {Object} + */ + 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(tr, prev) { + const { selection } = tr; + const next = { ...prev }; + + // We can only be suggesting if there is no selection + if (selection.from === selection.to) { + // 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 + const $position = selection.$from; + const 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(view, event) { + const { active } = this.getState(view.state); + + if (!active) return false; + + return onKeyDown({ view, event }); + }, + + /** + * Setup decorator on the currently active suggestion. + * + * @param {EditorState} editorState + * + * @returns {?DecorationSet} + */ + decorations(editorState) { + const { active, range } = this.getState(editorState); + + if (!active) return null; + + return DecorationSet.create(editorState.doc, [ + 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, + }), + ]); + }, + }, + }); +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 9d764c33..64c92446 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7774,7 +7774,7 @@ prosemirror-schema-list@^1.0.1: prosemirror-model "^1.0.0" prosemirror-transform "^1.0.0" -prosemirror-state@^1.0.0, prosemirror-state@^1.2.1: +prosemirror-state@^1.0.0, prosemirror-state@^1.2.1, prosemirror-state@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-1.2.2.tgz#8df26d95fd6fd327c0f9984a760e84d863204154" dependencies: @@ -7801,7 +7801,7 @@ prosemirror-utils@^0.6.5: version "0.6.5" resolved "https://registry.yarnpkg.com/prosemirror-utils/-/prosemirror-utils-0.6.5.tgz#df18e39178d510917838a7337a8b64561324a70b" -prosemirror-view@^1.0.0, prosemirror-view@^1.4.3: +prosemirror-view@^1.0.0, prosemirror-view@^1.4.3, prosemirror-view@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.5.1.tgz#545176a65124a89c9d16571797a9ef54853628c4" dependencies:
+ Yeah Philipp Kühn and Hans Pagel. +