import { Extension, Editor, Range } from '@tiptap/core' import { VueRenderer } from '@tiptap/vue-3' import Suggestion, { SuggestionOptions, SuggestionProps, } from '@tiptap/suggestion' import { PluginKey } from '@tiptap/pm/state' import tippy, { Instance as TippyInstance, Props as TippyProps } from 'tippy.js' import { Component as VueComponent } from 'vue' export interface BaseSuggestionItem { title?: string name?: string [key: string]: any } export interface CreateSuggestionExtensionOptions< TItem extends BaseSuggestionItem, > { name: string char: string pluginKey: PluginKey items: (props: { query: string editor: Editor }) => TItem[] | Promise command: (props: { editor: Editor; range: Range; props: TItem }) => void component: VueComponent tippyOptions?: Partial allowSpaces?: boolean startOfLine?: boolean decorationTag?: string decorationClass?: string addOptions?: () => Record } export function createSuggestionExtension( options: CreateSuggestionExtensionOptions, ) { type ExtensionFullOptions = Record & { suggestion: Omit, 'editor'> } return Extension.create({ name: options.name, addOptions() { const customOptions = options.addOptions ? options.addOptions.call(this) : {} return { ...customOptions, suggestion: { char: options.char, pluginKey: options.pluginKey, items: options.items, command: options.command, allowSpaces: options.allowSpaces, startOfLine: options.startOfLine, decorationTag: options.decorationTag || 'span', decorationClass: options.decorationClass || 'suggestion', render: () => { let component: VueRenderer | null let popup: TippyInstance[] | null return { onStart: (props: SuggestionProps) => { component = new VueRenderer(options.component, { editor: props.editor, props: props, }) if (!props.clientRect || !component.element) { return } const defaultTippyOptions: Partial = { getReferenceClientRect: props.clientRect as () => DOMRect, appendTo: () => document.body, content: component.element, showOnCreate: true, interactive: true, trigger: 'manual', placement: 'bottom-start', } popup = tippy('body', { ...defaultTippyOptions, ...options.tippyOptions, }) }, onUpdate(props: SuggestionProps) { component?.updateProps(props) if (!props.clientRect) { return } if (popup && popup[0]) { popup[0].setProps({ getReferenceClientRect: props.clientRect as () => DOMRect, }) } }, onKeyDown(props: { event: KeyboardEvent }): boolean { if (props.event.key === 'Escape') { if (popup && popup[0]) { popup[0].hide() } return true } if ( component && component.ref && typeof (component.ref as any).onKeyDown === 'function' ) { return (component.ref as any).onKeyDown(props) } return false }, onExit() { if (popup && popup[0]) { popup[0].destroy() } if (component) { component.destroy() } popup = null component = null }, } }, } as Omit, 'editor'>, } }, addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, ...this.options.suggestion, }), ] }, }) }