Files
flotilla/src/lib/tiptap/common.ts
T
2024-08-20 13:20:45 -07:00

150 lines
4.3 KiB
TypeScript

import tippy, {type Instance} from 'tippy.js'
import {mergeAttributes, Node} from '@tiptap/core'
import {PluginKey} from '@tiptap/pm/state'
import Suggestion from '@tiptap/suggestion'
export type PopoverOptions = {
tippyOptions: Record<string, any>
getLabel?: (id: string) => string
onStart?: (opts: any) => void
onUpdate?: (opts: any) => void
onKeyDown?: (opts: any) => boolean | undefined
onExit?: () => void
}
export const createPopoverNode = (name: string, char: string) => {
const pluginKey = new PluginKey(name)
return Node.create<PopoverOptions>({
name,
group: 'inline',
inline: true,
selectable: false,
atom: true,
addAttributes: () => ({
id: {
default: null,
parseHTML: el => el.getAttribute('data-id'),
renderHTML: ({id}) => id ? {'data-id': id} : {},
},
}),
parseHTML() {
return [{tag: `span[data-type="${this.name}"]`}]
},
renderHTML({node, HTMLAttributes}) {
const label = this.options.getLabel?.(node.attrs.id) || node.attrs.id
return ['span', mergeAttributes({'data-type': this.name}, HTMLAttributes), `${char}${label}`]
},
renderText({node}) {
return `${char}${this.options.getLabel?.(node.attrs.id) || node.attrs.id}`
},
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 [
Suggestion({
char,
pluginKey,
editor: this.editor,
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
},
render: () => {
let popover: Instance[]
let target: HTMLElement
return {
onStart: props => {
target = document.createElement("div")
popover = tippy('body', {
getReferenceClientRect: props.clientRect as any,
appendTo: document.body,
content: target,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
...this.options.tippyOptions,
})
this.options.onStart?.({target, props})
},
onUpdate: props => {
this.options.onUpdate?.({props})
if (props.clientRect) {
popover[0].setProps({
getReferenceClientRect: props.clientRect as any,
})
}
},
onKeyDown: props => {
if (props.event.key === "Escape") {
popover[0].hide()
return true
}
return Boolean(this.options.onKeyDown?.(props))
},
onExit: () => {
popover[0].destroy()
this.options.onExit?.()
},
}
},
}),
]
},
})
}