/** * Rich text HTML editor for email compose, powered by TipTap. * Toolbar: bold, italic, underline, strikethrough, headings, lists, * alignment, text color, link, image, code block, undo/redo. */ import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Underline from '@tiptap/extension-underline'; import Link from '@tiptap/extension-link'; import TextAlign from '@tiptap/extension-text-align'; import { Color } from '@tiptap/extension-color'; import { TextStyle } from '@tiptap/extension-text-style'; import Image from '@tiptap/extension-image'; import Placeholder from '@tiptap/extension-placeholder'; import clsx from 'clsx'; import { AlignCenter, AlignLeft, AlignRight, Bold, ChevronsLeft, Code, Heading, Image as ImageIcon, Italic, Link2, List, Menu, Quote, Redo2, Strikethrough, Underline as UnderlineIcon, Undo2 } from 'lucide-react'; export interface RichTextEditorProps { content: string; onChange: (html: string) => void; placeholder?: string; editable?: boolean; } interface ToolbarButtonProps { onClick: () => void; disabled?: boolean; active?: boolean; label: string; children: React.ReactNode; } function ToolbarButton({ onClick, disabled, active, label, children }: ToolbarButtonProps) { return ( ); } export function RichTextEditor({ content, onChange, placeholder, editable = true }: RichTextEditorProps) { const { t } = useTranslation(); const editor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, }), Underline, Link.configure({ openOnClick: false, HTMLAttributes: { rel: 'noopener noreferrer nofollow' }, }), TextAlign.configure({ types: ['heading', 'paragraph'] }), TextStyle, Color, Image.configure({ inline: true, allowBase64: true }), Placeholder.configure({ placeholder: placeholder || 'Write your email...', }), ], content, editable, onUpdate: ({ editor }) => { onChange(editor.getHTML()); }, editorProps: { attributes: { class: 'prose prose-sm max-w-none min-h-48 focus:outline-none px-4 py-3', 'data-testid': 'rich-text-editor', }, }, }); // Sync external content changes (e.g. template/signature insert, reply/forward/draft load) React.useEffect(() => { if (editor && content !== editor.getHTML()) { editor.commands.setContent(content, { emitUpdate: false }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [content, editor]); const setLink = useCallback(() => { if (!editor) return; const previousUrl = editor.getAttributes('link').href; const url = window.prompt(t('mail.linkUrl'), previousUrl); if (url === null) return; if (url === '') { editor.chain().focus().extendMarkRange('link').unsetLink().run(); return; } editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run(); }, [editor, t]); const addImage = useCallback(() => { if (!editor) return; const url = window.prompt('Image URL'); if (url) { editor.chain().focus().setImage({ src: url }).run(); } }, [editor]); if (!editor) return null; return (
{/* Toolbar */}
{/* Undo / Redo */} editor.chain().focus().undo().run()} disabled={!editor.can().undo()} label={t('common.undo')}> editor.chain().focus().redo().run()} disabled={!editor.can().redo()} label={t('common.redo')}>
{/* Headings */} editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive('heading', { level: 1 })} label="H1"> H1 editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} label="H2"> H2 editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} label="H3"> H3
{/* Inline formatting */} editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} label={t('mail.bold')}> editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} label={t('mail.italic')}> editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} label="Underline"> editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} label="Strikethrough">
{/* Lists */} editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} label="Bullet list"> editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} label="Numbered list"> editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} label="Quote"> editor.chain().focus().toggleCode().run()} active={editor.isActive('code')} label="Inline code"> editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive('codeBlock')} label="Code block">
{/* Alignment */} editor.chain().focus().setTextAlign('left').run()} active={editor.isActive({ textAlign: 'left' })} label="Align left"> editor.chain().focus().setTextAlign('center').run()} active={editor.isActive({ textAlign: 'center' })} label="Align center"> editor.chain().focus().setTextAlign('right').run()} active={editor.isActive({ textAlign: 'right' })} label="Align right"> editor.chain().focus().setTextAlign('justify').run()} active={editor.isActive({ textAlign: 'justify' })} label="Justify">
{/* Color */}
{/* Link & Image */}
{/* Clear formatting */} editor.chain().focus().unsetAllMarks().run()} label="Clear formatting">
{/* Editor area */}
); }