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.
82 lines
2.8 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
},
|
|
},
|
|
});
|
|
}
|