import { BlockSchema, InlineContentSchema, StyleSchema } from "@blocknote/core"; import { SuggestionMenu as SuggestionMenuExtension, SuggestionMenuOptions, filterSuggestionItems, } from "@blocknote/core/extensions"; import { autoPlacement, offset, shift, size } from "@floating-ui/react"; import { FC, useEffect, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { GenericPopover, GenericPopoverReference, } from "../Popovers/GenericPopover.js"; import { SuggestionMenu } from "./SuggestionMenu.js"; import { SuggestionMenuWrapper } from "./SuggestionMenuWrapper.js"; import { getDefaultReactSlashMenuItems } from "./getDefaultReactSlashMenuItems.js"; import { DefaultReactSuggestionItem, SuggestionMenuProps } from "./types.js"; type ArrayElement = A extends readonly (infer T)[] ? T : never; type ItemType Promise> = ArrayElement>>; export function SuggestionMenuController< // This is a bit hacky, but only way I found to make types work so the optionality // of suggestionMenuComponent depends on the return type of getItems GetItemsType extends (query: string) => Promise = ( query: string, ) => Promise, >( props: { triggerCharacter: string; getItems?: GetItemsType; shouldOpen?: SuggestionMenuOptions["shouldOpen"]; minQueryLength?: number; floatingUIOptions?: FloatingUIOptions; } & (ItemType extends DefaultReactSuggestionItem ? { // can be undefined suggestionMenuComponent?: FC< SuggestionMenuProps> >; onItemClick?: (item: ItemType) => void; } : { // getItems doesn't return DefaultSuggestionItem, so suggestionMenuComponent is required suggestionMenuComponent: FC< SuggestionMenuProps> >; onItemClick: (item: ItemType) => void; }), ) { const editor = useBlockNoteEditor< BlockSchema, InlineContentSchema, StyleSchema >(); const { triggerCharacter, suggestionMenuComponent, shouldOpen, minQueryLength, onItemClick, getItems, } = props; const onItemClickOrDefault = useMemo(() => { return ( onItemClick || ((item: ItemType) => { item.onItemClick(editor); }) ); }, [editor, onItemClick]); const getItemsOrDefault = useMemo(() => { return ( getItems || ((async (query: string) => filterSuggestionItems( getDefaultReactSlashMenuItems(editor), query, )) as any as typeof getItems) ); }, [editor, getItems])!; const suggestionMenu = useExtension(SuggestionMenuExtension); useEffect(() => { suggestionMenu.addSuggestionMenu({ triggerCharacter, shouldOpen }); }, [suggestionMenu, triggerCharacter, shouldOpen]); const state = useExtensionState(SuggestionMenuExtension); const reference = useExtensionState(SuggestionMenuExtension, { selector: (state) => ({ // Use first child as the editor DOM element may itself be scrollable. // For FloatingUI to auto-update the position during scrolling, the // `contextElement` must be a descendant of the scroll container. element: (editor.domElement?.firstChild || undefined) as | Element | undefined, getBoundingClientRect: () => state?.referencePos || new DOMRect(), }) satisfies GenericPopoverReference, }); const floatingUIOptions = useMemo( () => ({ ...props.floatingUIOptions, useFloatingOptions: { open: state?.show && state?.triggerCharacter === triggerCharacter, onOpenChange: (open) => { if (!open) { suggestionMenu.closeMenu(); } }, placement: "bottom-start", middleware: [ offset(10), // Flips the menu placement to maximize the space available, and prevents // the menu from being cut off by the confines of the screen. autoPlacement({ allowedPlacements: ["bottom-start", "top-start"], padding: 10, }), shift(), size({ apply({ elements, availableHeight }) { elements.floating.style.maxHeight = `${Math.max(0, availableHeight)}px`; }, padding: 10, }), ], ...props.floatingUIOptions?.useFloatingOptions, }, focusManagerProps: { disabled: true, ...props.floatingUIOptions?.focusManagerProps, }, elementProps: { // Prevents editor blurring when clicking the scroll bar. onMouseDownCapture: (event) => event.preventDefault(), style: { zIndex: 80, }, ...props.floatingUIOptions?.elementProps, }, }), [ props.floatingUIOptions, state?.show, state?.triggerCharacter, suggestionMenu, triggerCharacter, ], ); if ( !state || (!state.ignoreQueryLength && minQueryLength && (state.query.startsWith(" ") || state.query.length < minQueryLength)) ) { return null; } return ( {triggerCharacter && ( > } onItemClick={onItemClickOrDefault} /> )} ); }