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>
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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()) {
|
||||
editor.commands.setContent(content, 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 (
|
||||
<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')}>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" /></svg>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} label={t('common.redo')}>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 10H11a8 8 0 00-8 8v2m18-10l-6 6m6-6l-6-6" /></svg>
|
||||
</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')}>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><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>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} label={t('mail.italic')}>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 4l-7 16M10 4L3 20M4 8h6M14 8h6" /></svg>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} label="Underline">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 3v8a6 6 0 006 6 6 6 0 006-6V3M4 21h16" /></svg>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} label="Strikethrough">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 12h16M9 5l6 14M15 5l-6 14" /></svg>
|
||||
</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">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16M8 6h.01M8 12h.01M8 18h.01" /></svg>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} label="Numbered list">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 6h12M7 12h12M7 18h12M3 6h.01M3 12h.01M3 18h.01" /></svg>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} label="Quote">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h6v6H3zM3 13h6v6H3zM15 5h6v6h-6zM15 13h6v6h-6z" /></svg>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().toggleCode().run()} active={editor.isActive('code')} label="Inline code">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 7l-5 5 5 5M14 7l5 5-5 5" /></svg>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive('codeBlock')} label="Code block">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l-3 3 3 3M16 9l3 3-3 3M13 5l-2 14" /></svg>
|
||||
</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">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h10M4 18h12" /></svg>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().setTextAlign('center').run()} active={editor.isActive({ textAlign: 'center' })} label="Align center">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M7 12h10M5 18h14" /></svg>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().setTextAlign('right').run()} active={editor.isActive({ textAlign: 'right' })} label="Align right">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M10 12h10M8 18h12" /></svg>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().setTextAlign('justify').run()} active={editor.isActive({ textAlign: 'justify' })} label="Justify">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg>
|
||||
</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">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 8l5 8 5-8M12 16v4" /></svg>
|
||||
<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')}>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><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>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={addImage} label="Insert image">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="w-px h-6 bg-secondary-200 mx-1" />
|
||||
|
||||
{/* Clear formatting */}
|
||||
<ToolbarButton onClick={() => editor.chain().focus().unsetAllMarks().run()} label="Clear formatting">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M9 7l3-1m0 0l3 9a5.002 5.002 0 006.001 0M15 6l-3 1" /></svg>
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
|
||||
{/* Editor area */}
|
||||
<div className="overflow-y-auto max-h-96 min-h-48">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -91,3 +91,66 @@
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* TipTap editor placeholder */
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: #94a3b8;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* TipTap editor base styles */
|
||||
.ProseMirror {
|
||||
outline: none;
|
||||
}
|
||||
.ProseMirror ul,
|
||||
.ProseMirror ol {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.ProseMirror ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
.ProseMirror ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
.ProseMirror blockquote {
|
||||
padding-left: 1rem;
|
||||
border-left: 3px solid #e2e8f0;
|
||||
margin-left: 0;
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
}
|
||||
.ProseMirror pre {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.ProseMirror code {
|
||||
background: #f1f5f9;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.ProseMirror pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
.ProseMirror img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.ProseMirror a {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ProseMirror h1 { font-size: 1.5em; font-weight: 700; margin: 0.5em 0; }
|
||||
.ProseMirror h2 { font-size: 1.25em; font-weight: 700; margin: 0.5em 0; }
|
||||
.ProseMirror h3 { font-size: 1.1em; font-weight: 600; margin: 0.5em 0; }
|
||||
|
||||
Reference in New Issue
Block a user