import Mention from '@tiptap/extension-mention'; import type { AnyExtension } from '@tiptap/core'; import type { SuggestionProps, SuggestionKeyDownProps } from '@tiptap/suggestion'; import { ReactRenderer } from '@tiptap/react'; import { MentionList } from './MentionList.js'; export interface MentionUser { id: string; label: string; } /** Resolver that returns matching people for a query (sync or async). */ export type UserSearchFn = (query: string) => MentionUser[] | Promise; /** * Pure, case-insensitive in-memory matcher — handy as a default `UserSearchFn` * and directly unit-testable. Caps results at `limit`. */ export function filterUsers(query: string, users: MentionUser[], limit = 5): MentionUser[] { const q = query.trim().toLowerCase(); const matches = q ? users.filter(u => u.label.toLowerCase().includes(q)) : users; return matches.slice(0, limit); } /** * Build a configured `@`-mention extension backed by an async people search. * Renders a keyboard-navigable popup at the caret. */ export function createMention(search: UserSearchFn): AnyExtension { return Mention.configure({ HTMLAttributes: { class: 'bl-mention' }, suggestion: { char: '@', items: async ({ query }) => await search(query), render: () => { let component: ReactRenderer<{ onKeyDown: (p: SuggestionKeyDownProps) => boolean }> | null = null; let popup: HTMLDivElement | null = null; const position = (props: SuggestionProps) => { if (!popup) return; const rect = props.clientRect?.(); if (!rect) return; popup.style.left = `${rect.left}px`; popup.style.top = `${rect.bottom + 4}px`; }; return { onStart: (props: SuggestionProps) => { component = new ReactRenderer(MentionList, { props, editor: props.editor }); popup = document.createElement('div'); popup.setAttribute('data-mention-menu', 'true'); popup.style.position = 'absolute'; popup.style.zIndex = '50'; popup.appendChild(component.element); document.body.appendChild(popup); position(props); }, onUpdate: (props: SuggestionProps) => { component?.updateProps(props); position(props); }, onKeyDown: (props: SuggestionKeyDownProps) => { if (props.event.key === 'Escape') { popup?.remove(); return true; } return component?.ref?.onKeyDown(props) ?? false; }, onExit: () => { popup?.remove(); popup = null; component?.destroy(); component = null; }, }; }, }, }); }