learning_ai_common_plat/packages/rich-text/src/RichTextEditor.tsx
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

85 lines
2.5 KiB
TypeScript

'use client';
import * as React from 'react';
import { clsx } from 'clsx';
import { EditorContent, useEditor } from '@tiptap/react';
import type { AnyExtension, JSONContent } from '@tiptap/core';
import { buildExtensions } from './extensions.js';
import { SlashCommands } from './slashMenu.js';
import { createMention, type UserSearchFn } from './mention.js';
import { Toolbar } from './Toolbar.js';
export interface RichTextEditorProps {
/** Initial document — Tiptap JSON or an HTML string. */
content?: JSONContent | string;
/** Called with the latest JSON document on every change. */
onChange?: (doc: JSONContent) => void;
/** Placeholder shown when empty. */
placeholder?: string;
/** Enable the `/` slash command menu (default true). */
enableSlashMenu?: boolean;
/** Provide to enable `@`-mentions backed by this people search. */
mentionSearch?: UserSearchFn;
/** Read-only mode. */
editable?: boolean;
className?: string;
ariaLabel?: string;
}
/**
* Full editor surface: token-themed toolbar + Tiptap editing area, with an
* optional slash menu and async mentions. SSR-safe (`immediatelyRender:
* false`) so it hydrates cleanly under Next.
*/
export function RichTextEditor({
content,
onChange,
placeholder,
enableSlashMenu = true,
mentionSearch,
editable = true,
className,
ariaLabel = 'Rich text editor',
}: RichTextEditorProps) {
const extra = React.useMemo<AnyExtension[]>(() => {
const list: AnyExtension[] = [];
if (enableSlashMenu) list.push(SlashCommands);
if (mentionSearch) list.push(createMention(mentionSearch));
// Extensions are fixed for a mount; deliberately not reactive.
return list;
}, []);
const editor = useEditor({
immediatelyRender: false,
editable,
extensions: buildExtensions({ placeholder, extra }),
content,
editorProps: {
attributes: {
role: 'textbox',
'aria-multiline': 'true',
'aria-label': ariaLabel,
class: 'bl-rich-text-content min-h-40 p-3 outline-none',
},
},
onUpdate: ({ editor: e }) => onChange?.(e.getJSON()),
});
React.useEffect(() => {
editor?.setEditable(editable);
}, [editor, editable]);
return (
<div
className={clsx(
'overflow-hidden rounded-xl border border-[var(--bl-border,rgba(0,0,0,0.12))] bg-[var(--bl-surface-card,#fff)]',
className
)}
>
{editable && <Toolbar editor={editor} />}
<EditorContent editor={editor} />
</div>
);
}