2026-07-16 17:14:14 +02:00
|
|
|
/**
|
|
|
|
|
* 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';
|
2026-07-23 03:21:05 +02:00
|
|
|
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';
|
2026-07-16 17:14:14 +02:00
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
className={clsx(
|
|
|
|
|
'p-1.5 rounded hover:bg-secondary-100 transition-colors',
|
|
|
|
|
active && 'bg-primary-50 text-primary-600',
|
|
|
|
|
disabled && 'opacity-40 cursor-not-allowed',
|
|
|
|
|
)}
|
|
|
|
|
title={label}
|
|
|
|
|
aria-label={label}
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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()) {
|
2026-07-19 17:41:39 +02:00
|
|
|
editor.commands.setContent(content, { emitUpdate: false });
|
2026-07-16 17:14:14 +02:00
|
|
|
}
|
|
|
|
|
// 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 (
|
|
|
|
|
<div className="border border-secondary-300 rounded-md overflow-hidden" data-testid="rich-text-editor-container">
|
|
|
|
|
{/* Toolbar */}
|
|
|
|
|
<div className="flex flex-wrap items-center gap-0.5 border-b border-secondary-200 bg-secondary-50 px-2 py-1" data-testid="rich-text-toolbar">
|
|
|
|
|
{/* Undo / Redo */}
|
|
|
|
|
<ToolbarButton onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} label={t('common.undo')}>
|
2026-07-23 03:21:05 +02:00
|
|
|
<Undo2 className="w-4 h-4" strokeWidth={2} />
|
2026-07-16 17:14:14 +02:00
|
|
|
</ToolbarButton>
|
|
|
|
|
<ToolbarButton onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} label={t('common.redo')}>
|
2026-07-23 03:21:05 +02:00
|
|
|
<Redo2 className="w-4 h-4" strokeWidth={2} />
|
2026-07-16 17:14:14 +02:00
|
|
|
</ToolbarButton>
|
|
|
|
|
|
|
|
|
|
<div className="w-px h-6 bg-secondary-200 mx-1" />
|
|
|
|
|
|
|
|
|
|
{/* Headings */}
|
|
|
|
|
<ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive('heading', { level: 1 })} label="H1">
|
|
|
|
|
<span className="text-sm font-bold w-4 text-center block">H1</span>
|
|
|
|
|
</ToolbarButton>
|
|
|
|
|
<ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} label="H2">
|
|
|
|
|
<span className="text-sm font-bold w-4 text-center block">H2</span>
|
|
|
|
|
</ToolbarButton>
|
|
|
|
|
<ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} label="H3">
|
|
|
|
|
<span className="text-sm font-bold w-4 text-center block">H3</span>
|
|
|
|
|
</ToolbarButton>
|
|
|
|
|
|
|
|
|
|
<div className="w-px h-6 bg-secondary-200 mx-1" />
|
|
|
|
|
|
|
|
|
|
{/* Inline formatting */}
|
|
|
|
|
<ToolbarButton onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} label={t('mail.bold')}>
|
2026-07-23 03:21:05 +02:00
|
|
|
<Bold className="w-4 h-4" strokeWidth={2} />
|
2026-07-16 17:14:14 +02:00
|
|
|
</ToolbarButton>
|
|
|
|
|
<ToolbarButton onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} label={t('mail.italic')}>
|
2026-07-23 03:21:05 +02:00
|
|
|
<Italic className="w-4 h-4" strokeWidth={2} />
|
2026-07-16 17:14:14 +02:00
|
|
|
</ToolbarButton>
|
|
|
|
|
<ToolbarButton onClick={() => editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} label="Underline">
|
2026-07-23 03:21:05 +02:00
|
|
|
<UnderlineIcon className="w-4 h-4" strokeWidth={2} />
|
2026-07-16 17:14:14 +02:00
|
|
|
</ToolbarButton>
|
|
|
|
|
<ToolbarButton onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} label="Strikethrough">
|
2026-07-23 03:21:05 +02:00
|
|
|
<Strikethrough className="w-4 h-4" strokeWidth={2} />
|
2026-07-16 17:14:14 +02:00
|
|
|
</ToolbarButton>
|
|
|
|
|
|
|
|
|
|
<div className="w-px h-6 bg-secondary-200 mx-1" />
|
|
|
|
|
|
|
|
|
|
{/* Lists */}
|
|
|
|
|
<ToolbarButton onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} label="Bullet list">
|
2026-07-23 03:21:05 +02:00
|
|
|
<Menu className="w-4 h-4" strokeWidth={2} />
|
2026-07-16 17:14:14 +02:00
|
|
|
</ToolbarButton>
|
|
|
|
|
<ToolbarButton onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} label="Numbered list">
|
2026-07-23 03:21:05 +02:00
|
|
|
<List className="w-4 h-4" strokeWidth={2} />
|
2026-07-16 17:14:14 +02:00
|
|
|
</ToolbarButton>
|
|
|
|
|
<ToolbarButton onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} label="Quote">
|
2026-07-23 03:21:05 +02:00
|
|
|
<Quote className="w-4 h-4" strokeWidth={2} />
|
2026-07-16 17:14:14 +02:00
|
|
|
</ToolbarButton>
|
|
|
|
|
<ToolbarButton onClick={() => editor.chain().focus().toggleCode().run()} active={editor.isActive('code')} label="Inline code">
|
2026-07-23 03:21:05 +02:00
|
|
|
<ChevronsLeft className="w-4 h-4" strokeWidth={2} />
|
2026-07-16 17:14:14 +02:00
|
|
|
</ToolbarButton>
|
|
|
|
|
<ToolbarButton onClick={() => editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive('codeBlock')} label="Code block">
|
2026-07-23 03:21:05 +02:00
|
|
|
<Code className="w-4 h-4" strokeWidth={2} />
|
2026-07-16 17:14:14 +02:00
|
|
|
</ToolbarButton>
|
|
|
|
|
|
|
|
|
|
<div className="w-px h-6 bg-secondary-200 mx-1" />
|
|
|
|
|
|
|
|
|
|
{/* Alignment */}
|
|
|
|
|
<ToolbarButton onClick={() => editor.chain().focus().setTextAlign('left').run()} active={editor.isActive({ textAlign: 'left' })} label="Align left">
|
2026-07-23 03:21:05 +02:00
|
|
|
<AlignLeft className="w-4 h-4" strokeWidth={2} />
|
2026-07-16 17:14:14 +02:00
|
|
|
</ToolbarButton>
|
|
|
|
|
<ToolbarButton onClick={() => editor.chain().focus().setTextAlign('center').run()} active={editor.isActive({ textAlign: 'center' })} label="Align center">
|
2026-07-23 03:21:05 +02:00
|
|
|
<AlignCenter className="w-4 h-4" strokeWidth={2} />
|
2026-07-16 17:14:14 +02:00
|
|
|
</ToolbarButton>
|
|
|
|
|
<ToolbarButton onClick={() => editor.chain().focus().setTextAlign('right').run()} active={editor.isActive({ textAlign: 'right' })} label="Align right">
|
2026-07-23 03:21:05 +02:00
|
|
|
<AlignRight className="w-4 h-4" strokeWidth={2} />
|
2026-07-16 17:14:14 +02:00
|
|
|
</ToolbarButton>
|
|
|
|
|
<ToolbarButton onClick={() => editor.chain().focus().setTextAlign('justify').run()} active={editor.isActive({ textAlign: 'justify' })} label="Justify">
|
2026-07-23 03:21:05 +02:00
|
|
|
<Menu className="w-4 h-4" strokeWidth={2} />
|
2026-07-16 17:14:14 +02:00
|
|
|
</ToolbarButton>
|
|
|
|
|
|
|
|
|
|
<div className="w-px h-6 bg-secondary-200 mx-1" />
|
|
|
|
|
|
|
|
|
|
{/* Color */}
|
|
|
|
|
<label className="p-1.5 rounded hover:bg-secondary-100 cursor-pointer" title="Text color">
|
2026-07-23 03:21:05 +02:00
|
|
|
<Heading className="w-4 h-4" strokeWidth={2} />
|
2026-07-16 17:14:14 +02:00
|
|
|
<input
|
|
|
|
|
type="color"
|
|
|
|
|
className="sr-only"
|
|
|
|
|
onChange={(e) => editor.chain().focus().setColor(e.target.value).run()}
|
|
|
|
|
title="Text color"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
<div className="w-px h-6 bg-secondary-200 mx-1" />
|
|
|
|
|
|
|
|
|
|
{/* Link & Image */}
|
|
|
|
|
<ToolbarButton onClick={setLink} active={editor.isActive('link')} label={t('mail.insertLink')}>
|
2026-07-23 03:21:05 +02:00
|
|
|
<Link2 className="w-4 h-4" strokeWidth={2} />
|
2026-07-16 17:14:14 +02:00
|
|
|
</ToolbarButton>
|
|
|
|
|
<ToolbarButton onClick={addImage} label="Insert image">
|
2026-07-23 03:21:05 +02:00
|
|
|
<ImageIcon className="w-4 h-4" strokeWidth={2} />
|
2026-07-16 17:14:14 +02:00
|
|
|
</ToolbarButton>
|
|
|
|
|
|
|
|
|
|
<div className="w-px h-6 bg-secondary-200 mx-1" />
|
|
|
|
|
|
|
|
|
|
{/* Clear formatting */}
|
|
|
|
|
<ToolbarButton onClick={() => editor.chain().focus().unsetAllMarks().run()} label="Clear formatting">
|
2026-07-23 03:21:05 +02:00
|
|
|
<Quote className="w-4 h-4" strokeWidth={2} />
|
2026-07-16 17:14:14 +02:00
|
|
|
</ToolbarButton>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Editor area */}
|
|
|
|
|
<div className="overflow-y-auto max-h-96 min-h-48">
|
|
|
|
|
<EditorContent editor={editor} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|