import { BlockSchema, InlineContentSchema, StyleSchema } from "@blocknote/core"; import { SuggestionMenu, SuggestionMenuOptions, } 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 { getDefaultReactEmojiPickerItems } from "./getDefaultReactEmojiPickerItems.js"; import { GridSuggestionMenu } from "./GridSuggestionMenu.js"; import { GridSuggestionMenuWrapper } from "./GridSuggestionMenuWrapper.js"; import { DefaultReactGridSuggestionItem, GridSuggestionMenuProps, } from "./types.js"; type ArrayElement = A extends readonly (infer T)[] ? T : never; type ItemType Promise> = ArrayElement>>; export function GridSuggestionMenuController< // 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; columns: number; shouldOpen?: SuggestionMenuOptions["shouldOpen"]; minQueryLength?: number; floatingUIOptions?: FloatingUIOptions; } & (ItemType extends DefaultReactGridSuggestionItem ? { // can be undefined gridSuggestionMenuComponent?: FC< GridSuggestionMenuProps> >; onItemClick?: (item: ItemType) => void; } : { // getItems doesn't return DefaultSuggestionItem, so suggestionMenuComponent is required gridSuggestionMenuComponent: FC< GridSuggestionMenuProps> >; onItemClick: (item: ItemType) => void; }), ) { const editor = useBlockNoteEditor< BlockSchema, InlineContentSchema, StyleSchema >(); const { triggerCharacter, gridSuggestionMenuComponent, columns, 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) => await getDefaultReactEmojiPickerItems( editor, query, )) as any as typeof getItems) ); }, [editor, getItems])!; const suggestionMenu = useExtension(SuggestionMenu); useEffect(() => { suggestionMenu.addSuggestionMenu({ triggerCharacter, shouldOpen }); }, [suggestionMenu, triggerCharacter, shouldOpen]); const state = useExtensionState(SuggestionMenu); const reference = useExtensionState(SuggestionMenu, { 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: 70, }, ...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} /> )} ); }