/** * 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 (