add prosemirror-suggestions
This commit is contained in:
66
examples/Components/Routes/Mentions/index.vue
Normal file
66
examples/Components/Routes/Mentions/index.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<editor class="editor" :extensions="extensions">
|
||||||
|
|
||||||
|
<div class="editor__content" slot="content" slot-scope="props">
|
||||||
|
<h2>
|
||||||
|
Mentions
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Yeah <span data-mention-type="user" data-mention-id="1">Philipp Kühn</span> and <span data-mention-type="user" data-mention-id="2">Hans Pagel</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</editor>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Icon from 'Components/Icon'
|
||||||
|
import { Editor } from 'tiptap'
|
||||||
|
import {
|
||||||
|
BlockquoteNode,
|
||||||
|
BulletListNode,
|
||||||
|
CodeBlockNode,
|
||||||
|
HardBreakNode,
|
||||||
|
HeadingNode,
|
||||||
|
ListItemNode,
|
||||||
|
MentionNode,
|
||||||
|
OrderedListNode,
|
||||||
|
TodoItemNode,
|
||||||
|
TodoListNode,
|
||||||
|
BoldMark,
|
||||||
|
CodeMark,
|
||||||
|
ItalicMark,
|
||||||
|
LinkMark,
|
||||||
|
HistoryExtension,
|
||||||
|
} from 'tiptap-extensions'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Editor,
|
||||||
|
Icon,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
extensions: [
|
||||||
|
new BlockquoteNode(),
|
||||||
|
new BulletListNode(),
|
||||||
|
new CodeBlockNode(),
|
||||||
|
new HardBreakNode(),
|
||||||
|
new HeadingNode({ maxLevel: 3 }),
|
||||||
|
new ListItemNode(),
|
||||||
|
new MentionNode(),
|
||||||
|
new OrderedListNode(),
|
||||||
|
new TodoItemNode(),
|
||||||
|
new TodoListNode(),
|
||||||
|
new BoldMark(),
|
||||||
|
new CodeMark(),
|
||||||
|
new ItalicMark(),
|
||||||
|
new LinkMark(),
|
||||||
|
new HistoryExtension(),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -27,6 +27,9 @@
|
|||||||
<router-link class="subnavigation__link" to="/embeds">
|
<router-link class="subnavigation__link" to="/embeds">
|
||||||
Embeds
|
Embeds
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<router-link class="subnavigation__link" to="/mentions">
|
||||||
|
Mentions
|
||||||
|
</router-link>
|
||||||
<router-link class="subnavigation__link" to="/export">
|
<router-link class="subnavigation__link" to="/export">
|
||||||
Export HTML or JSON
|
Export HTML or JSON
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import RouteTodoList from 'Components/Routes/TodoList'
|
|||||||
import RouteMarkdownShortcuts from 'Components/Routes/MarkdownShortcuts'
|
import RouteMarkdownShortcuts from 'Components/Routes/MarkdownShortcuts'
|
||||||
import RouteReadOnly from 'Components/Routes/ReadOnly'
|
import RouteReadOnly from 'Components/Routes/ReadOnly'
|
||||||
import RouteEmbeds from 'Components/Routes/Embeds'
|
import RouteEmbeds from 'Components/Routes/Embeds'
|
||||||
|
import RouteMentions from 'Components/Routes/Mentions'
|
||||||
import RouteExport from 'Components/Routes/Export'
|
import RouteExport from 'Components/Routes/Export'
|
||||||
|
|
||||||
const __svg__ = { path: './assets/images/icons/*.svg', name: 'assets/images/[hash].sprite.svg' }
|
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',
|
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',
|
path: '/export',
|
||||||
component: RouteExport,
|
component: RouteExport,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-history": "^1.0.2",
|
"prosemirror-history": "^1.0.2",
|
||||||
|
"prosemirror-state": "^1.2.2",
|
||||||
|
"prosemirror-view": "^1.5.1",
|
||||||
"tiptap": "^0.8.0",
|
"tiptap": "^0.8.0",
|
||||||
"tiptap-commands": "^0.2.4"
|
"tiptap-commands": "^0.2.4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export { default as HardBreakNode } from './nodes/HardBreak'
|
|||||||
export { default as HeadingNode } from './nodes/Heading'
|
export { default as HeadingNode } from './nodes/Heading'
|
||||||
export { default as ImageNode } from './nodes/Image'
|
export { default as ImageNode } from './nodes/Image'
|
||||||
export { default as ListItemNode } from './nodes/ListItem'
|
export { default as ListItemNode } from './nodes/ListItem'
|
||||||
|
export { default as MentionNode } from './nodes/Mention'
|
||||||
export { default as OrderedListNode } from './nodes/OrderedList'
|
export { default as OrderedListNode } from './nodes/OrderedList'
|
||||||
export { default as TodoItemNode } from './nodes/TodoItem'
|
export { default as TodoItemNode } from './nodes/TodoItem'
|
||||||
export { default as TodoListNode } from './nodes/TodoList'
|
export { default as TodoListNode } from './nodes/TodoList'
|
||||||
|
|||||||
66
packages/tiptap-extensions/src/nodes/Mention.js
Normal file
66
packages/tiptap-extensions/src/nodes/Mention.js
Normal file
@@ -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;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
187
packages/tiptap-extensions/src/plugins/suggestions.js
Normal file
187
packages/tiptap-extensions/src/plugins/suggestions.js
Normal file
@@ -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,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7774,7 +7774,7 @@ prosemirror-schema-list@^1.0.1:
|
|||||||
prosemirror-model "^1.0.0"
|
prosemirror-model "^1.0.0"
|
||||||
prosemirror-transform "^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"
|
version "1.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-1.2.2.tgz#8df26d95fd6fd327c0f9984a760e84d863204154"
|
resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-1.2.2.tgz#8df26d95fd6fd327c0f9984a760e84d863204154"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -7801,7 +7801,7 @@ prosemirror-utils@^0.6.5:
|
|||||||
version "0.6.5"
|
version "0.6.5"
|
||||||
resolved "https://registry.yarnpkg.com/prosemirror-utils/-/prosemirror-utils-0.6.5.tgz#df18e39178d510917838a7337a8b64561324a70b"
|
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"
|
version "1.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.5.1.tgz#545176a65124a89c9d16571797a9ef54853628c4"
|
resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.5.1.tgz#545176a65124a89c9d16571797a9ef54853628c4"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user