import { Editor, Extension } from "@tiptap/core" import { ReactRenderer } from "@tiptap/react" import Suggestion, { SuggestionProps, SuggestionKeyDownProps, } from "@tiptap/suggestion" import { PluginKey } from "@tiptap/pm/state" import tippy from "tippy.js" import { GROUPS } from "./groups" import { MenuList } from "./menu-list" const EXTENSION_NAME = "slashCommand" let popup: any export const SlashCommand = Extension.create({ name: EXTENSION_NAME, priority: 200, onCreate() { popup = tippy("body", { interactive: true, trigger: "manual", placement: "bottom-start", theme: "slash-command", maxWidth: "16rem", offset: [16, 8], popperOptions: { strategy: "fixed", modifiers: [{ name: "flip", enabled: false }], }, }) }, addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: "/", allowSpaces: true, startOfLine: true, pluginKey: new PluginKey(EXTENSION_NAME), allow: ({ state, range }) => { const $from = state.doc.resolve(range.from) const isRootDepth = $from.depth === 1 const isParagraph = $from.parent.type.name === "paragraph" const isStartOfNode = $from.parent.textContent?.charAt(0) === "/" const isInColumn = this.editor.isActive("column") const afterContent = $from.parent.textContent?.substring( $from.parent.textContent?.indexOf("/"), ) const isValidAfterContent = !afterContent?.endsWith(" ") return ( ((isRootDepth && isParagraph && isStartOfNode) || (isInColumn && isParagraph && isStartOfNode)) && isValidAfterContent ) }, command: ({ editor, props }: { editor: Editor; props: any }) => { const { view, state } = editor const { $head, $from } = view.state.selection const end = $from.pos const from = $head?.nodeBefore ? end - ($head.nodeBefore.text?.substring( $head.nodeBefore.text?.indexOf("/"), ).length ?? 0) : $from.start() const tr = state.tr.deleteRange(from, end) view.dispatch(tr) props.action(editor) view.focus() }, items: ({ query }: { query: string }) => { return GROUPS.map((group) => ({ ...group, commands: group.commands .filter((item) => { const labelNormalized = item.label.toLowerCase().trim() const queryNormalized = query.toLowerCase().trim() if (item.aliases) { const aliases = item.aliases.map((alias) => alias.toLowerCase().trim(), ) return ( labelNormalized.includes(queryNormalized) || aliases.includes(queryNormalized) ) } return labelNormalized.includes(queryNormalized) }) .filter((command) => command.shouldBeHidden ? !command.shouldBeHidden(this.editor) : true, ) .map((command) => ({ ...command, isEnabled: true, })), })).filter((group) => group.commands.length > 0) }, render: () => { let component: any let scrollHandler: (() => void) | null = null return { onStart: (props: SuggestionProps) => { component = new ReactRenderer(MenuList, { props, editor: props.editor, }) const { view } = props.editor // const editorNode = view.dom as HTMLElement const getReferenceClientRect = () => { if (!props.clientRect) { return props.editor.storage[EXTENSION_NAME].rect } const rect = props.clientRect() if (!rect) { return props.editor.storage[EXTENSION_NAME].rect } let yPos = rect.y if ( rect.top + component.element.offsetHeight + 40 > window.innerHeight ) { const diff = rect.top + component.element.offsetHeight - window.innerHeight + 40 yPos = rect.y - diff } // const editorXOffset = editorNode.getBoundingClientRect().x return new DOMRect(rect.x, yPos, rect.width, rect.height) } scrollHandler = () => { popup?.[0].setProps({ getReferenceClientRect, }) } view.dom.parentElement?.addEventListener("scroll", scrollHandler) popup?.[0].setProps({ getReferenceClientRect, appendTo: () => document.body, content: component.element, }) popup?.[0].show() }, onUpdate(props: SuggestionProps) { component.updateProps(props) const { view } = props.editor // const editorNode = view.dom as HTMLElement const getReferenceClientRect = () => { if (!props.clientRect) { return props.editor.storage[EXTENSION_NAME].rect } const rect = props.clientRect() if (!rect) { return props.editor.storage[EXTENSION_NAME].rect } return new DOMRect(rect.x, rect.y, rect.width, rect.height) } const scrollHandler = () => { popup?.[0].setProps({ getReferenceClientRect, }) } view.dom.parentElement?.addEventListener("scroll", scrollHandler) props.editor.storage[EXTENSION_NAME].rect = props.clientRect ? getReferenceClientRect() : { width: 0, height: 0, left: 0, top: 0, right: 0, bottom: 0, } popup?.[0].setProps({ getReferenceClientRect, }) }, onKeyDown(props: SuggestionKeyDownProps) { if (props.event.key === "Escape") { popup?.[0].hide() return true } if (!popup?.[0].state.isShown) { popup?.[0].show() } return component.ref?.onKeyDown(props) }, onExit(props) { popup?.[0].hide() if (scrollHandler) { const { view } = props.editor view.dom.parentElement?.removeEventListener( "scroll", scrollHandler, ) } component.destroy() }, } }, }), ] }, addStorage() { return { rect: { width: 0, height: 0, left: 0, top: 0, right: 0, bottom: 0, }, } }, }) export default SlashCommand