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

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>
);
}