← back to Norma
components/editor/RichTextEditorClient.tsx
321 lines
'use client';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import Underline from '@tiptap/extension-underline';
import TextAlign from '@tiptap/extension-text-align';
import { Color } from '@tiptap/extension-color';
import { TextStyle } from '@tiptap/extension-text-style';
import Placeholder from '@tiptap/extension-placeholder';
import { useEffect, useCallback } from 'react';
import {
Bold,
Italic,
Underline as UnderlineIcon,
Strikethrough,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Link2,
ImageIcon,
AlignLeft,
AlignCenter,
AlignRight,
Undo2,
Redo2,
RemoveFormatting,
} from 'lucide-react';
interface RichTextEditorClientProps {
content: string;
onChange: (html: string) => void;
placeholder?: string;
}
/* ─── Toolbar Button ─────────────────────────────────────────────────────── */
function ToolbarBtn({
onClick,
isActive,
title,
children,
disabled,
}: {
onClick: () => void;
isActive?: boolean;
title: string;
children: React.ReactNode;
disabled?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
aria-label={title}
aria-pressed={isActive}
className="flex items-center justify-center w-7 h-7 rounded transition-colors focus-visible:outline-none focus-visible:ring-2"
style={{
backgroundColor: isActive ? 'rgba(16, 185, 129, 0.2)' : 'transparent',
color: isActive ? 'var(--color-primary)' : 'var(--color-text-secondary)',
border: isActive ? '1px solid rgba(16, 185, 129, 0.4)' : '1px solid transparent',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.4 : 1,
}}
>
{children}
</button>
);
}
/* ─── Divider ────────────────────────────────────────────────────────────── */
function ToolbarDivider() {
return (
<div
className="w-px mx-1 self-stretch"
style={{ backgroundColor: 'var(--color-border)' }}
aria-hidden="true"
/>
);
}
/* ─── RichTextEditorClient ───────────────────────────────────────────────── */
export default function RichTextEditorClient({
content,
onChange,
placeholder = 'Start writing your email...',
}: RichTextEditorClientProps) {
const editor = useEditor({
// Tiptap v2+ in Next.js App Router: avoid SSR hydration mismatch by
// initializing only on the client. Required since Next 15 + React 19.
immediatelyRender: false,
extensions: [
StarterKit,
Link.configure({ openOnClick: false, HTMLAttributes: { rel: 'noopener noreferrer' } }),
Image,
Underline,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Color,
TextStyle,
Placeholder.configure({ placeholder }),
],
content,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
editorProps: {
attributes: {
'aria-label': 'Email body editor',
'aria-multiline': 'true',
role: 'textbox',
},
},
});
/* Sync external content changes (e.g. switching from HTML mode) */
useEffect(() => {
if (!editor) return;
const current = editor.getHTML();
if (current !== content) {
editor.commands.setContent(content);
}
}, [content, editor]);
const setLink = useCallback(() => {
if (!editor) return;
const prev = editor.getAttributes('link').href as string | undefined;
const url = window.prompt('Enter URL', prev ?? 'https://');
if (url === null) return;
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
} else {
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}
}, [editor]);
const insertImage = useCallback(() => {
if (!editor) return;
const url = window.prompt('Enter image URL', 'https://');
if (!url) return;
editor.chain().focus().setImage({ src: url }).run();
}, [editor]);
if (!editor) {
return (
<div
className="flex items-center justify-center"
style={{
minHeight: 300,
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--color-surface-el)',
}}
>
<span className="spinner" aria-label="Loading editor" />
</div>
);
}
return (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--color-surface-el)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* ── Toolbar ──────────────────────────────────────────────────────── */}
<div
className="flex flex-wrap items-center gap-0.5 px-2 py-1.5"
style={{ borderBottom: '1px solid var(--color-border)' }}
role="toolbar"
aria-label="Editor formatting toolbar"
>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title="Bold (Ctrl+B)"
>
<Bold size={13} />
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title="Italic (Ctrl+I)"
>
<Italic size={13} />
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleUnderline().run()}
isActive={editor.isActive('underline')}
title="Underline (Ctrl+U)"
>
<UnderlineIcon size={13} />
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive('strike')}
title="Strikethrough"
>
<Strikethrough size={13} />
</ToolbarBtn>
<ToolbarDivider />
<ToolbarBtn
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
isActive={editor.isActive('heading', { level: 1 })}
title="Heading 1"
>
<Heading1 size={13} />
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive('heading', { level: 2 })}
title="Heading 2"
>
<Heading2 size={13} />
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
isActive={editor.isActive('heading', { level: 3 })}
title="Heading 3"
>
<Heading3 size={13} />
</ToolbarBtn>
<ToolbarDivider />
<ToolbarBtn
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
title="Bullet List"
>
<List size={13} />
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
title="Ordered List"
>
<ListOrdered size={13} />
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')}
title="Blockquote"
>
<Quote size={13} />
</ToolbarBtn>
<ToolbarDivider />
<ToolbarBtn onClick={setLink} isActive={editor.isActive('link')} title="Insert/Edit Link">
<Link2 size={13} />
</ToolbarBtn>
<ToolbarBtn onClick={insertImage} title="Insert Image">
<ImageIcon size={13} />
</ToolbarBtn>
<ToolbarDivider />
<ToolbarBtn
onClick={() => editor.chain().focus().setTextAlign('left').run()}
isActive={editor.isActive({ textAlign: 'left' })}
title="Align Left"
>
<AlignLeft size={13} />
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().setTextAlign('center').run()}
isActive={editor.isActive({ textAlign: 'center' })}
title="Align Center"
>
<AlignCenter size={13} />
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().setTextAlign('right').run()}
isActive={editor.isActive({ textAlign: 'right' })}
title="Align Right"
>
<AlignRight size={13} />
</ToolbarBtn>
<ToolbarDivider />
<ToolbarBtn
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Undo (Ctrl+Z)"
>
<Undo2 size={13} />
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Redo (Ctrl+Y)"
>
<Redo2 size={13} />
</ToolbarBtn>
<ToolbarBtn
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
title="Clear Formatting"
>
<RemoveFormatting size={13} />
</ToolbarBtn>
</div>
{/* ── Editor Area ───────────────────────────────────────────────────── */}
<EditorContent editor={editor} />
</div>
);
}