"use client" import { Extension, type Editor, type Range } from "@tiptap/core" import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion" import { useEffect, useLayoutEffect, useState, useRef } from "react" import { createPortal } from "react-dom" import { createRoot, type Root } from "react-dom/client" import { useFloating, offset, flip, shift, autoUpdate, } from "@floating-ui/react" import { cn } from "@lib/utils" export interface SuggestionItem { title: string description: string searchTerms?: string[] icon: React.ReactNode command: (props: { editor: Editor; range: Range }) => void } interface CommandListProps { items: SuggestionItem[] command: (item: SuggestionItem) => void selectedIndex: number } function CommandList({ items, command, selectedIndex }: CommandListProps) { const containerRef = useRef(null) useEffect(() => { const selectedElement = containerRef.current?.querySelector( `[data-index="${selectedIndex}"]`, ) selectedElement?.scrollIntoView({ block: "nearest" }) }, [selectedIndex]) if (items.length === 0) { return (
No results
) } return (
{items.map((item, index) => ( ))}
) } interface CommandMenuProps { items: SuggestionItem[] command: (item: SuggestionItem) => void clientRect: (() => DOMRect | null) | null selectedIndex: number } function CommandMenu({ items, command, clientRect, selectedIndex, }: CommandMenuProps) { const [mounted, setMounted] = useState(false) const { refs, floatingStyles } = useFloating({ placement: "bottom-start", middleware: [offset(8), flip(), shift()], whileElementsMounted: autoUpdate, }) useLayoutEffect(() => { setMounted(true) }, []) useEffect(() => { const rect = clientRect?.() if (rect) { refs.setReference({ getBoundingClientRect: () => rect, }) } }, [clientRect, refs]) if (!mounted) return null return createPortal(
, document.body, ) } export function createSlashCommand(items: SuggestionItem[]) { let component: { updateProps: (props: CommandMenuProps) => void destroy: () => void element: HTMLElement } | null = null let root: Root | null = null let selectedIndex = 0 let currentItems: SuggestionItem[] = [] const renderMenu = (props: { items: SuggestionItem[] command: (item: SuggestionItem) => void clientRect: (() => DOMRect | null) | null }) => { root?.render( , ) } const suggestion: Omit, "editor"> = { char: "/", items: ({ query }) => { return items.filter( (item) => item.title.toLowerCase().includes(query.toLowerCase()) || item.searchTerms?.some((term) => term.toLowerCase().includes(query.toLowerCase()), ), ) }, command: ({ editor, range, props }) => { props.command({ editor, range }) }, render: () => { let currentCommand: ((item: SuggestionItem) => void) | null = null let currentClientRect: (() => DOMRect | null) | null = null return { onStart: (props) => { selectedIndex = 0 currentItems = props.items as SuggestionItem[] currentCommand = props.command as (item: SuggestionItem) => void currentClientRect = props.clientRect ?? null const element = document.createElement("div") document.body.appendChild(element) root = createRoot(element) if (currentCommand) { renderMenu({ items: currentItems, command: currentCommand, clientRect: currentClientRect, }) } component = { element, updateProps: (newProps: CommandMenuProps) => { root?.render( , ) }, destroy: () => { root?.unmount() element.remove() root = null }, } }, onUpdate: (props) => { currentItems = props.items as SuggestionItem[] currentCommand = props.command as (item: SuggestionItem) => void currentClientRect = props.clientRect ?? null if (selectedIndex >= currentItems.length) { selectedIndex = Math.max(0, currentItems.length - 1) } if (currentCommand) { component?.updateProps({ items: currentItems, command: currentCommand, clientRect: currentClientRect, selectedIndex, }) } }, onKeyDown: (props) => { const { event } = props if (event.key === "Escape") { component?.destroy() component = null return true } if (event.key === "ArrowUp") { selectedIndex = selectedIndex <= 0 ? currentItems.length - 1 : selectedIndex - 1 if (currentCommand) { component?.updateProps({ items: currentItems, command: currentCommand, clientRect: currentClientRect, selectedIndex, }) } return true } if (event.key === "ArrowDown") { selectedIndex = selectedIndex >= currentItems.length - 1 ? 0 : selectedIndex + 1 if (currentCommand) { component?.updateProps({ items: currentItems, command: currentCommand, clientRect: currentClientRect, selectedIndex, }) } return true } if (event.key === "Enter") { const item = currentItems[selectedIndex] if (item && currentCommand) { currentCommand(item) } return true } return false }, onExit: () => { component?.destroy() component = null }, } }, } return Extension.create({ name: "slashCommand", addOptions() { return { suggestion, } }, addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, ...this.options.suggestion, }), ] }, }) }