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.
126 lines
3.3 KiB
TypeScript
126 lines
3.3 KiB
TypeScript
import * as React from 'react';
|
|
import { clsx } from 'clsx';
|
|
import type { Editor } from '@tiptap/core';
|
|
|
|
export interface ToolbarProps {
|
|
editor: Editor | null;
|
|
}
|
|
|
|
interface ToolButton {
|
|
label: string;
|
|
/** aria-label / accessible name. */
|
|
name: string;
|
|
isActive?: (e: Editor) => boolean;
|
|
run: (e: Editor) => void;
|
|
}
|
|
|
|
const BUTTONS: ToolButton[] = [
|
|
{
|
|
label: 'B',
|
|
name: 'Bold',
|
|
isActive: e => e.isActive('bold'),
|
|
run: e => e.chain().focus().toggleBold().run(),
|
|
},
|
|
{
|
|
label: 'I',
|
|
name: 'Italic',
|
|
isActive: e => e.isActive('italic'),
|
|
run: e => e.chain().focus().toggleItalic().run(),
|
|
},
|
|
{
|
|
label: 'H1',
|
|
name: 'Heading 1',
|
|
isActive: e => e.isActive('heading', { level: 1 }),
|
|
run: e => e.chain().focus().toggleHeading({ level: 1 }).run(),
|
|
},
|
|
{
|
|
label: 'H2',
|
|
name: 'Heading 2',
|
|
isActive: e => e.isActive('heading', { level: 2 }),
|
|
run: e => e.chain().focus().toggleHeading({ level: 2 }).run(),
|
|
},
|
|
{
|
|
label: '• List',
|
|
name: 'Bullet list',
|
|
isActive: e => e.isActive('bulletList'),
|
|
run: e => e.chain().focus().toggleBulletList().run(),
|
|
},
|
|
{
|
|
label: '1. List',
|
|
name: 'Numbered list',
|
|
isActive: e => e.isActive('orderedList'),
|
|
run: e => e.chain().focus().toggleOrderedList().run(),
|
|
},
|
|
{
|
|
label: '❝',
|
|
name: 'Quote',
|
|
isActive: e => e.isActive('blockquote'),
|
|
run: e => e.chain().focus().toggleBlockquote().run(),
|
|
},
|
|
{
|
|
label: '</>',
|
|
name: 'Code',
|
|
isActive: e => e.isActive('code'),
|
|
run: e => e.chain().focus().toggleCode().run(),
|
|
},
|
|
{
|
|
label: 'Link',
|
|
name: 'Link',
|
|
isActive: e => e.isActive('link'),
|
|
run: e => {
|
|
const prev = (e.getAttributes('link').href as string) ?? '';
|
|
const url = typeof window !== 'undefined' ? window.prompt('Link URL', prev) : prev;
|
|
if (url === null) return;
|
|
if (url === '') {
|
|
e.chain().focus().extendMarkRange('link').unsetLink().run();
|
|
return;
|
|
}
|
|
e.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
|
},
|
|
},
|
|
];
|
|
|
|
/** Token-themed formatting toolbar bound to a Tiptap editor instance. */
|
|
export function Toolbar({ editor }: ToolbarProps) {
|
|
// Re-render on selection/content changes so active states stay correct.
|
|
const [, force] = React.useReducer((x: number) => x + 1, 0);
|
|
React.useEffect(() => {
|
|
if (!editor) return;
|
|
editor.on('transaction', force);
|
|
return () => {
|
|
editor.off('transaction', force);
|
|
};
|
|
}, [editor]);
|
|
|
|
if (!editor) return null;
|
|
|
|
return (
|
|
<div
|
|
role="toolbar"
|
|
aria-label="Formatting"
|
|
className="flex flex-wrap items-center gap-1 border-b border-[var(--bl-border,rgba(0,0,0,0.12))] p-1.5"
|
|
>
|
|
{BUTTONS.map(b => {
|
|
const active = b.isActive?.(editor) ?? false;
|
|
return (
|
|
<button
|
|
key={b.name}
|
|
type="button"
|
|
aria-label={b.name}
|
|
aria-pressed={active}
|
|
onClick={() => b.run(editor)}
|
|
className={clsx(
|
|
'min-w-8 rounded-md px-2 py-1 text-sm font-medium',
|
|
active
|
|
? 'bg-[var(--bl-accent,#6366f1)] text-white'
|
|
: 'text-[var(--bl-text-secondary,#444)] hover:bg-[var(--bl-surface-hover,rgba(0,0,0,0.05))]'
|
|
)}
|
|
>
|
|
{b.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|