import { mergeAttributes, Node } from '@tiptap/core' import { ReactRenderer } from '@tiptap/react' import Suggestion, { SuggestionKeyDownProps, SuggestionOptions, SuggestionProps } from '@tiptap/suggestion' import { Node as ProseMirrorNode } from 'prosemirror-model' import { PluginKey } from 'prosemirror-state' import tippy, { GetReferenceClientRect } from 'tippy.js' import WikiLink from './WikiLink' import WikiLinkSuggestionList, { WikiLinkSuggestionListProps } from './WikiLinkSuggestionList' export type WikiLinkSuggestionOptions = { HTMLAttributes: Record, renderLabel: (props: { options: WikiLinkSuggestionOptions, node: ProseMirrorNode, }) => string, suggestion: Omit, } type propType = SuggestionProps // { // editor: any, // clientRect: any, // event: any // } export const suggestion = { items: ({ query }: { query: string }) => { return [ 'Lea Thompson', 'Cyndi Lauper', 'Tom Cruise', 'Madonna', 'Jerry Hall', 'Joan Collins', 'Winona Ryder', 'Christina Applegate', 'Alyssa Milano', 'Molly Ringwald', 'Ally Sheedy', 'Debbie Harry', 'Olivia Newton-John', 'Elton John', 'Michael J. Fox', 'Axl Rose', 'Emilio Estevez', 'Ralph Macchio', 'Rob Lowe', 'Jennifer Grey', 'Mickey Rourke', 'John Cusack', 'Matthew Broderick', 'Justine Bateman', 'Lisa Bonet', ] .filter(item => item.toLowerCase().startsWith(query.toLowerCase())) .slice(0, 5) }, render: () => { let component: any let popup: any return { onStart: (props: propType) => { component = new ReactRenderer(WikiLinkSuggestionList, { props, editor: props.editor, }) if (!props.clientRect) { return } popup = tippy('body', { getReferenceClientRect: props.clientRect as GetReferenceClientRect, appendTo: () => document.body!, content: component.element, showOnCreate: true, interactive: true, trigger: 'manual', placement: 'bottom-start', }) }, onUpdate(props: propType) { component.updateProps(props) if (!props.clientRect) { return } popup[0].setProps({ getReferenceClientRect: props.clientRect, }) }, onKeyDown(props: SuggestionKeyDownProps) { if (props.event.key === 'Escape') { popup[0].hide() return true } return component.ref?.onKeyDown(props) }, onExit() { popup[0].destroy() component.destroy() }, } }, } export const WikiLinkSuggestionPluginKey = new PluginKey('wiki-link-suggestion') export const WikiLinkSuggestion = Node.create({ name: 'wiki-link-suggestion', addOptions() { return { HTMLAttributes: {}, renderLabel({ options, node }) { return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}` }, suggestion: { char: '🔗', allowedPrefixes: null, pluginKey: WikiLinkSuggestionPluginKey, 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 } const baseLocation = window.location.hash.substring(1)?.split('/').slice(0, 4).join('/') editor .chain() .focus() .insertContentAt(range, props.id) .insertContent(' ') .run() const newRange = { ...range, to: range.from + props.id.length } editor.chain().focus().setTextSelection(newRange).setLink({ href: `#${baseLocation}/${props.id}`, target: '_self' }).run() editor .chain() .focus(newRange.to + 1) .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 [ 'span', mergeAttributes({ 'data-type': this.name }, 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 isWikiLinkSuggestion = 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) { isWikiLinkSuggestion = true tr.insertText(this.options.suggestion.char || '', pos, pos + node.nodeSize) return false } }) return isWikiLinkSuggestion }), } }, addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, ...this.options.suggestion, }), ] }, })