feat(mail): replace contentEditable with TipTap rich text editor

- Add RichTextEditor component with TipTap (ProseMirror-based) editor
- Full toolbar: bold, italic, underline, strikethrough, H1/H2/H3, bullet/numbered lists,
  blockquote, inline code, code block, text alignment (left/center/right/justify),
  text color picker, link insert/edit, image insert, clear formatting, undo/redo
- Replace old contentEditable div + document.execCommand in ComposeModal
- Add Placeholder extension for empty editor hint
- Add ProseMirror CSS styles (lists, blockquote, code, headings, links, images)
- Update signature/template insertion to work with new editor state
This commit is contained in:
Agent Zero
2026-07-16 17:14:14 +02:00
parent 2a80aeb0af
commit 178bd84fd6
5 changed files with 974 additions and 78 deletions
+12 -72
View File
@@ -1,5 +1,5 @@
/**
* Compose modal — rich text editor with toolbar (bold, italic, link, template insert).
* Compose modal — rich text editor (TipTap) with template insert.
* Used for new mail, reply, and forward.
*/
@@ -10,6 +10,7 @@ import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { TemplatePicker } from './TemplatePicker';
import { RichTextEditor } from './RichTextEditor';
import type { Mail, MailSignature, SendMailPayload, ReplyPayload, ForwardPayload, MailDraftPayload } from '@/api/mail';
import { uploadAttachment, type UploadedAttachment } from '@/api/mail';
@@ -41,7 +42,6 @@ export function ComposeModal({
onClose,
}: ComposeModalProps) {
const { t } = useTranslation();
const editorRef = useRef<HTMLDivElement>(null);
const [to, setTo] = useState('');
const [cc, setCc] = useState('');
const [bcc, setBcc] = useState('');
@@ -83,36 +83,16 @@ export function ComposeModal({
}
}, [open, mode, replyToMail, forwardMail, draftMail, t]);
const execCommand = useCallback((command: string, value?: string) => {
document.execCommand(command, false, value);
if (editorRef.current) {
setBody(editorRef.current.innerHTML);
}
editorRef.current?.focus();
}, []);
const insertLink = useCallback(() => {
const url = window.prompt(t('mail.linkUrl'));
if (url) {
execCommand('createLink', url);
}
}, [execCommand, t]);
const insertSignature = useCallback((signatureId: string) => {
setSelectedSignatureId(signatureId);
const sig = signatures.find((s) => s.id === signatureId);
if (sig && editorRef.current) {
const current = editorRef.current.innerHTML;
editorRef.current.innerHTML = `${current}<br/><br/>---<br/>${sig.body_html}`;
setBody(editorRef.current.innerHTML);
if (sig) {
setBody((prev) => `${prev}<br/><br/>---<br/>${sig.body_html}`);
}
}, [signatures]);
const handleTemplateSelect = useCallback((templateBody: string, templateSubject: string) => {
if (editorRef.current) {
editorRef.current.innerHTML = templateBody;
setBody(templateBody);
}
setBody(templateBody);
if (templateSubject) {
setSubject(templateSubject);
}
@@ -229,45 +209,11 @@ export function ComposeModal({
return (
<Modal open={open} onClose={onClose} title={title} size="xl" closeOnBackdrop={false} fullScreenMobile>
<div className="space-y-4" data-testid="compose-modal">
{/* Toolbar */}
<div className="flex items-center gap-1 border-b border-secondary-200 pb-2" data-testid="compose-toolbar">
<button
onClick={() => execCommand('bold')}
className="p-2 rounded hover:bg-secondary-100 min-h-touch min-w-touch"
aria-label={t('mail.bold')}
title={t('mail.bold')}
type="button"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 4h8a4 4 0 014 4 4 4 0 01-4 4H6V4z M6 12h9a4 4 0 014 4 4 4 0 01-4 4H6v-8z" />
</svg>
</button>
<button
onClick={() => execCommand('italic')}
className="p-2 rounded hover:bg-secondary-100 min-h-touch min-w-touch"
aria-label={t('mail.italic')}
title={t('mail.italic')}
type="button"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 4l-7 16M10 4L3 20M4 8h6M14 8h6" />
</svg>
</button>
<button
onClick={insertLink}
className="p-2 rounded hover:bg-secondary-100 min-h-touch min-w-touch"
aria-label={t('mail.insertLink')}
title={t('mail.insertLink')}
type="button"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</button>
<div className="w-px h-6 bg-secondary-200 mx-1" />
{/* Template picker toggle (formatting toolbar is in RichTextEditor) */}
<div className="flex items-center gap-2" data-testid="compose-toolbar">
<button
onClick={() => setShowTemplatePicker(!showTemplatePicker)}
className="px-3 py-2 rounded hover:bg-secondary-100 text-sm min-h-touch"
className="px-3 py-1.5 rounded hover:bg-secondary-100 text-sm"
aria-label={t('mail.insertTemplate')}
title={t('mail.insertTemplate')}
type="button"
@@ -329,16 +275,10 @@ export function ComposeModal({
{/* Editor */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.body')}</label>
<div
ref={editorRef}
contentEditable
onInput={(e) => setBody((e.target as HTMLDivElement).innerHTML)}
className="min-h-48 border border-secondary-300 rounded-md p-3 focus:outline-none focus:ring-2 focus:ring-primary-500 overflow-y-auto"
role="textbox"
aria-multiline="true"
aria-label={t('mail.body')}
data-testid="compose-editor"
dangerouslySetInnerHTML={mode === 'reply' && replyToMail ? { __html: body } : undefined}
<RichTextEditor
content={body}
onChange={setBody}
placeholder={t('mail.body')}
/>
</div>