learning_ai_common_plat/packages/rich-text/src/mention.ts
saravanakumardb1 fc8502ac0c feat(rich-text): @bytelyst/rich-text@0.1.0 on Tiptap v3
RichTextEditor (toolbar + slash menu + async mentions, SSR-safe via
immediatelyRender:false) + RichTextViewer (generateHTML, server-renderable) +
standalone Toolbar. Pure filterSlashItems/filterUsers helpers. 12/12 vitest
(incl. live editor mount + bold toggle in happy-dom); tsc clean; published to
Gitea.
2026-05-28 18:20:34 -07:00

82 lines
2.8 KiB
TypeScript

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<MentionUser[]>;
/**
* 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<MentionUser>) => {
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<MentionUser>) => {
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<MentionUser>) => {
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;
},
};
},
},
});
}