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.
85 lines
2.5 KiB
TypeScript
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>
|
|
);
|
|
}
|