diff --git a/extensions/FilterMention/index.tsx b/extensions/FilterMention/index.tsx new file mode 100644 index 00000000..9ebad2f5 --- /dev/null +++ b/extensions/FilterMention/index.tsx @@ -0,0 +1,179 @@ +import { mergeAttributes, Node } from '@tiptap/core' +import { + CustomSuggestion, + SuggestionOptions, +} from '~extensions/CustomSuggestion' +import { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { PluginKey } from '@tiptap/pm/state' + +export type MentionOptions = { + HTMLAttributes: Record + renderLabel: (props: { + options: MentionOptions + node: ProseMirrorNode + }) => string + suggestion: Omit +} + +export const MentionPluginKey = new PluginKey('mention') + +export const FilterMention = Node.create({ + name: 'mention', + + addOptions() { + return { + HTMLAttributes: {}, + renderLabel({ options, node }) { + return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}` + }, + suggestion: { + char: '@', + pluginKey: MentionPluginKey, + command: ({ editor, range, props }) => { + // increase range.to by one when the next node is of type "text" + // and starts with a space character + const nodeAfter = editor.view.state.selection.$to.nodeAfter + const overrideSpace = nodeAfter?.text?.startsWith(' ') + + if (overrideSpace) { + range.to += 1 + } + + editor + .chain() + .focus() + .insertContentAt(range, [ + { + type: this.name, + attrs: props, + }, + { + type: 'text', + text: ' ', + }, + ]) + + .run() + + window.getSelection()?.collapseToEnd() + }, + allow: ({ state, range }) => { + const $from = state.doc.resolve(range.from) + const type = state.schema.nodes[this.name] + const allow = !!$from.parent.type.contentMatch.matchType(type) + + return allow + }, + }, + } + }, + + group: 'inline', + + inline: true, + + selectable: false, + + atom: true, + + addAttributes() { + return { + id: { + default: null, + parseHTML: (element) => element.getAttribute('data-id'), + renderHTML: (attributes) => { + if (!attributes.id) { + return {} + } + + return { + 'data-id': attributes.id, + } + }, + }, + + label: { + default: null, + parseHTML: (element) => element.getAttribute('data-label'), + renderHTML: (attributes) => { + if (!attributes.label) { + return {} + } + + return { + 'data-label': attributes.label, + } + }, + }, + } + }, + + parseHTML() { + return [ + { + tag: `span[data-type="${this.name}"]`, + }, + ] + }, + + renderHTML({ node, HTMLAttributes }) { + return [ + 'div', + mergeAttributes( + { 'data-type': this.name }, + { 'data-element': node.attrs.id.element.slug }, + { tabindex: -1 }, + this.options.HTMLAttributes, + HTMLAttributes + ), + this.options.renderLabel({ + options: this.options, + node, + }), + ] + }, + + renderText({ node }) { + return this.options.renderLabel({ + options: this.options, + node, + }) + }, + + addKeyboardShortcuts() { + return { + Backspace: () => + this.editor.commands.command(({ tr, state }) => { + let isMention = false + const { selection } = state + const { empty, anchor } = selection + + if (!empty) { + return false + } + + state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => { + if (node.type.name === this.name) { + isMention = true + tr.insertText('', pos, pos + node.nodeSize) + + return false + } + }) + + return isMention + }), + } + }, + + addProseMirrorPlugins() { + return [ + CustomSuggestion({ + editor: this.editor, + ...this.options.suggestion, + }), + ] + }, +}) + +export default FilterMention