"use client"; import { BlockNoteEditor } from "@blocknote/core"; import { DefaultReactSuggestionItem, SuggestionMenuController, SuggestionMenuProps } from "@blocknote/react"; import { autoUpdate, flip, shift } from "@floating-ui/react"; import React, { useCallback, useMemo } from "react"; export interface MentionItem { id: string; name: string; entityType: string; icon?: React.ReactNode; } /** * A suggestion item enriched with the entity it points at. Entity NAMES are not * unique (two clues can both be called "New clue"), so a custom * `suggestionMenuComponent` MUST key its rows on `id`, never on `title`. */ export type MentionSuggestionItem = DefaultReactSuggestionItem & { id?: string; entityType?: string; }; export type MentionSearchFn = (query: string, params?: Record) => Promise; export type MentionInsertFn = (id: string, name: string, entityType: string) => void; const MentionInsertContext = React.createContext(null); export const useMentionInsert = () => React.useContext(MentionInsertContext); // BlockNote's SuggestionMenuController auto-closes the menu when the query // grows more than 3 characters past the last query that returned results // (see @blocknote/react `Xt` close-on-empty hook). That breaks multi-word // mention queries like "@John Smith" when no exact match exists, because the // custom suggestion UI still wants to surface a "Create" option. We work // around it by always returning at least one item from getItems(): a sentinel // that's filtered out before reaching the consumer's suggestion menu. const KEEP_OPEN_SENTINEL_TITLE = "__blocknote_mention_keep_open__"; interface BlockNoteEditorSuggestionMenuControllerProps { editor: BlockNoteEditor; mentionSearchFn: MentionSearchFn; mentionSearchParams?: Record; suggestionMenuComponent?: React.FC>; } export function BlockNoteEditorMentionSuggestionMenu({ editor, mentionSearchFn, mentionSearchParams, suggestionMenuComponent, }: BlockNoteEditorSuggestionMenuControllerProps) { const onMentionInsert = useCallback( (id: string, name: string, entityType: string) => { editor.insertInlineContent([ { type: "mention", props: { alias: name, id, entityType }, }, " ", ]); }, [editor], ); const getItems = useCallback( async (query: string): Promise => { const results = await mentionSearchFn(query, mentionSearchParams); const items: MentionSuggestionItem[] = results.map((item) => ({ id: item.id, entityType: item.entityType, title: item.name, subtext: item.entityType, icon: item.icon as React.ReactElement | undefined, onItemClick: () => { editor.insertInlineContent([ { type: "mention", props: { alias: item.name, id: item.id, entityType: item.entityType, }, }, " ", ]); }, })); if (items.length === 0) { items.push({ id: KEEP_OPEN_SENTINEL_TITLE, title: KEEP_OPEN_SENTINEL_TITLE, onItemClick: () => {} }); } return items; }, [editor, mentionSearchFn, mentionSearchParams], ); const wrappedSuggestionMenuComponent = useMemo< React.FC> | undefined >(() => { if (!suggestionMenuComponent) return undefined; const Component = suggestionMenuComponent; const Wrapped: React.FC> = (props) => { const isSentinelOnly = props.items.length === 1 && props.items[0]?.title === KEEP_OPEN_SENTINEL_TITLE; return ( { if (item.title === KEEP_OPEN_SENTINEL_TITLE) return; props.onItemClick?.(item); }} /> ); }; return Wrapped; }, [suggestionMenuComponent]); return (
); }