flotilla/src/lib/tiptap/Suggestions.ts

106 lines
3 KiB
TypeScript
Raw Normal View History

2024-08-26 21:43:43 +00:00
import type {SvelteComponent, ComponentType} from "svelte"
import type {Readable} from "svelte/store"
import tippy, {type Instance} from "tippy.js"
import type {Editor} from "@tiptap/core"
import {PluginKey} from "@tiptap/pm/state"
import Suggestion from "@tiptap/suggestion"
2024-08-30 23:25:41 +00:00
import type {Search} from "@welshman/app"
2024-08-22 23:16:35 +00:00
export type SuggestionsOptions = {
2024-08-26 21:43:43 +00:00
char: string
name: string
editor: Editor
2024-08-22 23:16:35 +00:00
search: Readable<Search<any, any>>
select: (value: any, props: any) => void
2024-08-26 21:43:43 +00:00
allowCreate?: boolean
2024-08-22 23:16:35 +00:00
suggestionComponent: ComponentType
suggestionsComponent: ComponentType
2024-08-20 20:20:45 +00:00
}
2024-08-23 00:25:53 +00:00
export const createSuggestions = (options: SuggestionsOptions) =>
Suggestion({
char: options.char,
editor: options.editor,
pluginKey: new PluginKey(`suggest-${options.name}`),
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
2024-08-26 21:43:43 +00:00
const overrideSpace = nodeAfter?.text?.startsWith(" ")
2024-08-23 00:25:53 +00:00
if (overrideSpace) {
range.to += 1
}
2024-08-20 20:20:45 +00:00
2024-08-23 00:25:53 +00:00
editor
.chain()
.focus()
.insertContentAt(range, [
{type: options.name, attrs: props},
2024-08-26 21:43:43 +00:00
{type: "text", text: " "},
2024-08-23 00:25:53 +00:00
])
.run()
2024-08-20 20:20:45 +00:00
2024-08-23 00:25:53 +00:00
window.getSelection()?.collapseToEnd()
2024-08-20 20:20:45 +00:00
},
2024-08-26 21:43:43 +00:00
allow: ({state, range}) => {
2024-08-23 00:25:53 +00:00
const $from = state.doc.resolve(range.from)
const type = state.schema.nodes[options.name]
2024-08-20 20:20:45 +00:00
2024-08-23 00:25:53 +00:00
return !!$from.parent.type.contentMatch.matchType(type)
},
render: () => {
let popover: Instance[]
let target: HTMLElement
let suggestions: SvelteComponent
const mapProps = (props: any) => ({
term: props.query,
search: options.search,
allowCreate: options.allowCreate,
component: options.suggestionComponent,
select: (value: string) => options.select(value, props),
})
2024-08-20 20:20:45 +00:00
2024-08-23 00:25:53 +00:00
return {
onStart: props => {
target = document.createElement("div")
2024-08-26 21:43:43 +00:00
popover = tippy("body", {
2024-08-23 00:25:53 +00:00
getReferenceClientRect: props.clientRect as any,
appendTo: document.body,
content: target,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
})
2024-08-20 20:20:45 +00:00
2024-08-23 00:25:53 +00:00
suggestions = new options.suggestionsComponent({target, props: mapProps(props)})
},
onUpdate: props => {
suggestions.$set(mapProps(props))
2024-08-22 23:16:35 +00:00
2024-08-23 00:25:53 +00:00
if (props.clientRect) {
popover[0].setProps({
getReferenceClientRect: props.clientRect as any,
2024-08-22 23:16:35 +00:00
})
2024-08-23 00:25:53 +00:00
}
},
onKeyDown: props => {
if (props.event.key === "Escape") {
popover[0].hide()
2024-08-20 20:20:45 +00:00
2024-08-23 00:25:53 +00:00
return true
}
2024-08-20 20:20:45 +00:00
2024-08-23 00:25:53 +00:00
return Boolean(suggestions.onKeyDown?.(props.event))
},
onExit: () => {
popover[0].destroy()
suggestions.$destroy()
},
}
2024-08-20 20:20:45 +00:00
},
})