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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user