feat: add resizable image component and rich text editor with image upload support
This commit is contained in:
@@ -644,6 +644,7 @@ export default function CalendarPage() {
|
|||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") return;
|
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") return;
|
||||||
|
if (target.getAttribute("contenteditable") === "true") return;
|
||||||
if (showEventModal || detailEvent) return;
|
if (showEventModal || detailEvent) return;
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
|
|||||||
@@ -496,3 +496,95 @@ body {
|
|||||||
-webkit-backdrop-filter: none !important;
|
-webkit-backdrop-filter: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* TipTap Rich Text Editor */
|
||||||
|
.tiptap {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap p {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap li {
|
||||||
|
margin: 0.125rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap blockquote {
|
||||||
|
border-left: 3px solid var(--color-border);
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap pre {
|
||||||
|
background-color: var(--color-muted);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap code {
|
||||||
|
background-color: var(--color-muted);
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap p.is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap .ProseMirror-selectednode img {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -192,6 +192,10 @@ export function EventDetailPopover({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") onClose();
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const tag = target?.tagName?.toLowerCase();
|
||||||
|
if (tag === "input" || tag === "textarea" || tag === "select") return;
|
||||||
|
if (target?.getAttribute("contenteditable") === "true") return;
|
||||||
if (e.key === "e" && !noteExpanded) {
|
if (e.key === "e" && !noteExpanded) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onEdit();
|
onEdit();
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ import { TemplatePicker } from "@/components/templates/template-picker";
|
|||||||
import { TemplateForm } from "@/components/templates/template-form";
|
import { TemplateForm } from "@/components/templates/template-form";
|
||||||
import type { EmailTemplate } from "@/lib/template-types";
|
import type { EmailTemplate } from "@/lib/template-types";
|
||||||
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
|
||||||
|
import { RichTextEditor } from "@/components/email/rich-text-editor";
|
||||||
|
|
||||||
|
/** Strip HTML tags and decode entities to get a plain-text version */
|
||||||
|
function htmlToPlainText(html: string): string {
|
||||||
|
const tmp = document.createElement('div');
|
||||||
|
tmp.innerHTML = html;
|
||||||
|
return tmp.textContent || tmp.innerText || '';
|
||||||
|
}
|
||||||
|
|
||||||
export interface ComposerDraftData {
|
export interface ComposerDraftData {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -125,23 +133,28 @@ export function EmailComposer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getInitialBody = () => {
|
const getInitialBody = () => {
|
||||||
const prefix = initialDraftText || "";
|
const prefix = initialDraftText ? `<p>${initialDraftText.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</p>` : "";
|
||||||
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
if (!replyTo?.body && !replyTo?.htmlBody) return prefix;
|
||||||
|
|
||||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||||
const from = replyTo.from?.[0];
|
const from = replyTo.from?.[0];
|
||||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||||
|
|
||||||
// When HTML body is available, don't include quoted text in the textarea
|
// Build quoted content as HTML
|
||||||
// The HTML original will be shown separately below the textarea
|
|
||||||
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||||
return prefix;
|
const quoteHeader = mode === 'forward'
|
||||||
|
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
|
||||||
|
: `On ${date}, ${fromStr} wrote:<br>`;
|
||||||
|
return `${prefix}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'forward') {
|
if (replyTo.body) {
|
||||||
return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`;
|
const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
if (mode === 'forward') {
|
||||||
return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n> ${(replyTo.body || '').split('\n').join('\n> ')}`;
|
return `${prefix}<br><br>---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>${escapedOriginal}`;
|
||||||
|
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||||
|
return `${prefix}<br><br>On ${date}, ${fromStr} wrote:<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return prefix;
|
return prefix;
|
||||||
};
|
};
|
||||||
@@ -157,18 +170,6 @@ export function EmailComposer({
|
|||||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const lastSavedDataRef = useRef<string>("");
|
const lastSavedDataRef = useRef<string>("");
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
|
|
||||||
const autoResizeTextarea = useCallback(() => {
|
|
||||||
const el = textareaRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
el.style.height = 'auto';
|
|
||||||
el.style.height = el.scrollHeight + 'px';
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
autoResizeTextarea();
|
|
||||||
}, [body, autoResizeTextarea]);
|
|
||||||
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean; abortController?: AbortController }>>([]);
|
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean; abortController?: AbortController }>>([]);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
|
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
|
||||||
@@ -367,9 +368,12 @@ export function EmailComposer({
|
|||||||
? substitutePlaceholders(template.body, filledValues)
|
? substitutePlaceholders(template.body, filledValues)
|
||||||
: template.body;
|
: template.body;
|
||||||
|
|
||||||
|
// Convert template plain text body to HTML for the rich text editor
|
||||||
|
const htmlBody = `<p>${filledBody.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</p>`;
|
||||||
|
|
||||||
if (mode === 'compose') {
|
if (mode === 'compose') {
|
||||||
setSubject(filledSubject);
|
setSubject(filledSubject);
|
||||||
setBody(filledBody);
|
setBody(htmlBody);
|
||||||
if (template.defaultRecipients?.to?.length) {
|
if (template.defaultRecipients?.to?.length) {
|
||||||
setTo(template.defaultRecipients.to.join(', ') + ', ');
|
setTo(template.defaultRecipients.to.join(', ') + ', ');
|
||||||
}
|
}
|
||||||
@@ -382,7 +386,7 @@ export function EmailComposer({
|
|||||||
setShowBcc(true);
|
setShowBcc(true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setBody((prev) => filledBody + prev);
|
setBody((prev) => htmlBody + prev);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (template.identityId) {
|
if (template.identityId) {
|
||||||
@@ -394,8 +398,10 @@ export function EmailComposer({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleTemplateKey = (e: KeyboardEvent) => {
|
const handleTemplateKey = (e: KeyboardEvent) => {
|
||||||
const tag = (e.target as HTMLElement)?.tagName?.toLowerCase();
|
const target = e.target as HTMLElement;
|
||||||
|
const tag = target?.tagName?.toLowerCase();
|
||||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||||||
|
if (target?.getAttribute('contenteditable') === 'true') return;
|
||||||
if (e.key === 't' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
if (e.key === 't' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setShowTemplatePicker(true);
|
setShowTemplatePicker(true);
|
||||||
@@ -445,6 +451,18 @@ export function EmailComposer({
|
|||||||
}
|
}
|
||||||
}, [client, t]);
|
}, [client, t]);
|
||||||
|
|
||||||
|
const handleImageUpload = useCallback(async (file: File): Promise<string | null> => {
|
||||||
|
if (!client) return null;
|
||||||
|
try {
|
||||||
|
const { blobId } = await client.uploadBlob(file);
|
||||||
|
return await client.fetchBlobAsObjectUrl(blobId, file.name, file.type);
|
||||||
|
} catch (error) {
|
||||||
|
debug.error(`Failed to upload inline image ${file.name}:`, error);
|
||||||
|
toast.error(t('upload_failed', { filename: file.name }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [client, t]);
|
||||||
|
|
||||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!event.target.files) return;
|
if (!event.target.files) return;
|
||||||
await addFiles(Array.from(event.target.files));
|
await addFiles(Array.from(event.target.files));
|
||||||
@@ -511,7 +529,7 @@ export function EmailComposer({
|
|||||||
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
|
|
||||||
if (!toAddresses.length && !subject && !body) {
|
if (!toAddresses.length && !subject && !htmlToPlainText(body).trim()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,7 +565,7 @@ export function EmailComposer({
|
|||||||
const savedDraftId = await client.createDraft(
|
const savedDraftId = await client.createDraft(
|
||||||
toAddresses,
|
toAddresses,
|
||||||
subject || t('no_subject'),
|
subject || t('no_subject'),
|
||||||
body,
|
htmlToPlainText(body),
|
||||||
ccAddresses,
|
ccAddresses,
|
||||||
bccAddresses,
|
bccAddresses,
|
||||||
currentIdentity?.id,
|
currentIdentity?.id,
|
||||||
@@ -611,7 +629,8 @@ export function EmailComposer({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
|
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
|
||||||
const hasContent = body || attachments.some(att => att.blobId && !att.uploading);
|
const bodyPlainText = htmlToPlainText(body).trim();
|
||||||
|
const hasContent = bodyPlainText || attachments.some(att => att.blobId && !att.uploading);
|
||||||
const canSend = toAddresses.length > 0 && !!subject && hasContent;
|
const canSend = toAddresses.length > 0 && !!subject && hasContent;
|
||||||
|
|
||||||
const getSendTooltip = (): string | undefined => {
|
const getSendTooltip = (): string | undefined => {
|
||||||
@@ -660,26 +679,8 @@ export function EmailComposer({
|
|||||||
: currentIdentity.email
|
: currentIdentity.email
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Append signature from the selected identity
|
// Body is already HTML from the rich text editor.
|
||||||
let finalBody = appendPlainTextSignature(body, currentIdentity);
|
// Build HTML signature block
|
||||||
|
|
||||||
// Append quoted original text for the plain text part in reply/forward
|
|
||||||
if (replyTo && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
|
||||||
const originalText = replyTo.body || '';
|
|
||||||
if (originalText) {
|
|
||||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : '';
|
|
||||||
const fromAddr = replyTo.from?.[0];
|
|
||||||
const fromStr = fromAddr ? `${fromAddr.name || fromAddr.email}` : tCommon('unknown');
|
|
||||||
|
|
||||||
if (mode === 'forward') {
|
|
||||||
finalBody += `\n\n---------- ${t('prefix.forward')} ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
|
|
||||||
} else {
|
|
||||||
finalBody += `\n\nOn ${date}, ${fromStr} wrote:\n> ${originalText.split('\n').join('\n> ')}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build HTML signature block (prefer htmlSignature, fall back to escaped textSignature)
|
|
||||||
const buildSignatureHtml = (): string => {
|
const buildSignatureHtml = (): string => {
|
||||||
if (currentIdentity?.htmlSignature) {
|
if (currentIdentity?.htmlSignature) {
|
||||||
return `<br><br>-- <br>${sanitizeEmailHtml(currentIdentity.htmlSignature)}`;
|
return `<br><br>-- <br>${sanitizeEmailHtml(currentIdentity.htmlSignature)}`;
|
||||||
@@ -690,26 +691,13 @@ export function EmailComposer({
|
|||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build HTML body
|
|
||||||
let finalHtmlBody: string | undefined;
|
|
||||||
const signatureHtml = buildSignatureHtml();
|
const signatureHtml = buildSignatureHtml();
|
||||||
|
|
||||||
if (replyTo?.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
// Build final HTML body: editor content + signature
|
||||||
// Reply/forward with original HTML content
|
const finalHtmlBody = `<div>${body}</div>${signatureHtml}`;
|
||||||
const escapedBody = body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
|
||||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : '';
|
|
||||||
const fromAddr = replyTo.from?.[0];
|
|
||||||
const fromStr = fromAddr ? `${fromAddr.name || fromAddr.email}` : tCommon('unknown');
|
|
||||||
const quoteHeader = mode === 'forward'
|
|
||||||
? `---------- ${t('prefix.forward')} ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
|
|
||||||
: `On ${date}, ${fromStr} wrote:<br>`;
|
|
||||||
|
|
||||||
finalHtmlBody = `<div>${escapedBody}</div>${signatureHtml}<br><div><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote></div>`;
|
// Generate plain text version from the HTML body for multipart/alternative
|
||||||
} else if (signatureHtml) {
|
const finalBody = appendPlainTextSignature(htmlToPlainText(body), currentIdentity);
|
||||||
// New compose or plain-text reply — include HTML body with signature
|
|
||||||
const escapedBody = body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
|
||||||
finalHtmlBody = `<div>${escapedBody}</div>${signatureHtml}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
||||||
@@ -1107,23 +1095,17 @@ export function EmailComposer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body - Rich Text Editor */}
|
||||||
<div className="px-4 py-3">
|
<RichTextEditor
|
||||||
<textarea
|
content={body}
|
||||||
ref={textareaRef}
|
onChange={(html) => {
|
||||||
className={cn(
|
setBody(html);
|
||||||
"w-full resize-none outline-none text-sm bg-transparent text-foreground placeholder:text-muted-foreground rounded min-h-[100px] overflow-hidden",
|
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
|
||||||
validationErrors.body && "ring-2 ring-red-500 dark:ring-red-400"
|
}}
|
||||||
)}
|
onImageUpload={handleImageUpload}
|
||||||
placeholder={t('body_placeholder')}
|
placeholder={t('body_placeholder')}
|
||||||
value={body}
|
hasError={validationErrors.body}
|
||||||
onChange={(e) => {
|
/>
|
||||||
setBody(e.target.value);
|
|
||||||
if (validationErrors.body) setValidationErrors(prev => ({ ...prev, body: false }));
|
|
||||||
}}
|
|
||||||
aria-invalid={validationErrors.body || undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{composerSignatureHtml && (
|
{composerSignatureHtml && (
|
||||||
<div
|
<div
|
||||||
@@ -1131,23 +1113,6 @@ export function EmailComposer({
|
|||||||
dangerouslySetInnerHTML={{ __html: `<div>-- </div>${composerSignatureHtml}` }}
|
dangerouslySetInnerHTML={{ __html: `<div>-- </div>${composerSignatureHtml}` }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Quoted original HTML */}
|
|
||||||
{replyTo?.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward') && (
|
|
||||||
<div className="border-t border-border">
|
|
||||||
<div className="px-4 py-2 text-xs text-muted-foreground">
|
|
||||||
{mode === 'forward'
|
|
||||||
? `---------- ${t('prefix.forward')} ----------`
|
|
||||||
: `${replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : ''}, ${replyTo.from?.[0]?.name || replyTo.from?.[0]?.email || tCommon('unknown')}:`
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="email-reply-quote px-4 pb-3 border-l-2 border-muted-foreground/30 ml-4 max-w-none rounded"
|
|
||||||
style={{ backgroundColor: '#ffffff', color: '#1a1a1a', fontSize: '14px' }}
|
|
||||||
dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(replyTo.htmlBody) }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Attachments */}
|
{/* Attachments */}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Node, mergeAttributes } from "@tiptap/core";
|
||||||
|
import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
import type { NodeViewProps } from "@tiptap/react";
|
||||||
|
|
||||||
|
function ResizableImageView({ node, updateAttributes, selected }: NodeViewProps) {
|
||||||
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
|
const [resizing, setResizing] = useState(false);
|
||||||
|
const startState = useRef<{ x: number; y: number; width: number; height: number; handle: string }>({
|
||||||
|
x: 0, y: 0, width: 0, height: 0, handle: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const onMouseDown = useCallback((e: React.MouseEvent, handle: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const img = imgRef.current;
|
||||||
|
if (!img) return;
|
||||||
|
startState.current = {
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
width: img.offsetWidth,
|
||||||
|
height: img.offsetHeight,
|
||||||
|
handle,
|
||||||
|
};
|
||||||
|
setResizing(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resizing) return;
|
||||||
|
|
||||||
|
const onMouseMove = (e: MouseEvent) => {
|
||||||
|
const { x, width, handle } = startState.current;
|
||||||
|
const dx = e.clientX - x;
|
||||||
|
let newWidth: number;
|
||||||
|
|
||||||
|
if (handle === "right" || handle === "bottom-right" || handle === "top-right") {
|
||||||
|
newWidth = Math.max(50, width + dx);
|
||||||
|
} else {
|
||||||
|
newWidth = Math.max(50, width - dx);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAttributes({ width: Math.round(newWidth) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseUp = () => {
|
||||||
|
setResizing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", onMouseMove);
|
||||||
|
document.addEventListener("mouseup", onMouseUp);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", onMouseMove);
|
||||||
|
document.removeEventListener("mouseup", onMouseUp);
|
||||||
|
};
|
||||||
|
}, [resizing, updateAttributes]);
|
||||||
|
|
||||||
|
const width = node.attrs.width;
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
...(width ? { width: `${width}px` } : {}),
|
||||||
|
maxWidth: "100%",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper as="span" className="inline-block relative" draggable data-drag-handle>
|
||||||
|
<span
|
||||||
|
className={`relative inline-block group ${selected ? "ring-2 ring-primary rounded" : ""}`}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
src={node.attrs.src}
|
||||||
|
alt={node.attrs.alt || ""}
|
||||||
|
title={node.attrs.title || undefined}
|
||||||
|
style={{ width: "100%", height: "auto", display: "block" }}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
{selected && (
|
||||||
|
<>
|
||||||
|
{/* Resize handle: right */}
|
||||||
|
<span
|
||||||
|
onMouseDown={(e) => onMouseDown(e, "right")}
|
||||||
|
className="absolute top-1/2 -right-1.5 -translate-y-1/2 w-3 h-8 bg-primary rounded cursor-ew-resize"
|
||||||
|
/>
|
||||||
|
{/* Resize handle: left */}
|
||||||
|
<span
|
||||||
|
onMouseDown={(e) => onMouseDown(e, "left")}
|
||||||
|
className="absolute top-1/2 -left-1.5 -translate-y-1/2 w-3 h-8 bg-primary rounded cursor-ew-resize"
|
||||||
|
/>
|
||||||
|
{/* Resize handle: bottom-right corner */}
|
||||||
|
<span
|
||||||
|
onMouseDown={(e) => onMouseDown(e, "bottom-right")}
|
||||||
|
className="absolute -bottom-1.5 -right-1.5 w-3 h-3 bg-primary rounded cursor-nwse-resize"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResizableImage = Node.create({
|
||||||
|
name: "image",
|
||||||
|
group: "inline",
|
||||||
|
inline: true,
|
||||||
|
draggable: true,
|
||||||
|
selectable: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: { default: null },
|
||||||
|
alt: { default: null },
|
||||||
|
title: { default: null },
|
||||||
|
width: { default: null },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: "img[src]" }];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
const attrs: Record<string, string> = { ...HTMLAttributes };
|
||||||
|
if (attrs.width) {
|
||||||
|
attrs.style = `width: ${attrs.width}px; max-width: 100%;`;
|
||||||
|
delete attrs.width;
|
||||||
|
}
|
||||||
|
return ["img", mergeAttributes(attrs)];
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(ResizableImageView);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useCallback } from "react";
|
||||||
|
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 { TextStyle } from "@tiptap/extension-text-style";
|
||||||
|
import Color from "@tiptap/extension-color";
|
||||||
|
import { ResizableImage } from "@/components/email/resizable-image";
|
||||||
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Underline as UnderlineIcon,
|
||||||
|
Strikethrough,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
AlignLeft,
|
||||||
|
AlignCenter,
|
||||||
|
AlignRight,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Undo,
|
||||||
|
Redo,
|
||||||
|
Quote,
|
||||||
|
Code,
|
||||||
|
RemoveFormatting,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface RichTextEditorProps {
|
||||||
|
content: string;
|
||||||
|
onChange: (html: string) => void;
|
||||||
|
onImageUpload?: (file: File) => Promise<string | null>;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
hasError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarButton({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
|
className={cn(
|
||||||
|
"p-1.5 rounded hover:bg-accent transition-colors",
|
||||||
|
active && "bg-accent text-accent-foreground",
|
||||||
|
disabled && "opacity-40 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarSeparator() {
|
||||||
|
return <div className="w-px h-5 bg-border mx-0.5" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RichTextEditor({
|
||||||
|
content,
|
||||||
|
onChange,
|
||||||
|
onImageUpload,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
hasError,
|
||||||
|
}: RichTextEditorProps) {
|
||||||
|
const onImageUploadRef = React.useRef(onImageUpload);
|
||||||
|
onImageUploadRef.current = onImageUpload;
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
heading: { levels: [1, 2] },
|
||||||
|
}),
|
||||||
|
Underline,
|
||||||
|
Link.configure({
|
||||||
|
openOnClick: false,
|
||||||
|
HTMLAttributes: { rel: "noopener noreferrer nofollow" },
|
||||||
|
}),
|
||||||
|
TextAlign.configure({
|
||||||
|
types: ["heading", "paragraph"],
|
||||||
|
}),
|
||||||
|
TextStyle,
|
||||||
|
Color,
|
||||||
|
ResizableImage,
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content,
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: "tiptap min-h-[100px] px-4 py-3 text-sm text-foreground",
|
||||||
|
},
|
||||||
|
handleDrop: (view, event) => {
|
||||||
|
const upload = onImageUploadRef.current;
|
||||||
|
if (!upload || !event.dataTransfer?.files?.length) return false;
|
||||||
|
const imageFiles = Array.from(event.dataTransfer.files).filter(f =>
|
||||||
|
f.type.startsWith("image/")
|
||||||
|
);
|
||||||
|
if (imageFiles.length === 0) return false;
|
||||||
|
event.preventDefault();
|
||||||
|
for (const file of imageFiles) {
|
||||||
|
upload(file).then((url) => {
|
||||||
|
if (url) {
|
||||||
|
const { state } = view;
|
||||||
|
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
||||||
|
const node = state.schema.nodes.image.create({ src: url, alt: file.name });
|
||||||
|
const tr = state.tr.insert(pos?.pos ?? state.selection.anchor, node);
|
||||||
|
view.dispatch(tr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
handlePaste: (view, event) => {
|
||||||
|
const upload = onImageUploadRef.current;
|
||||||
|
if (!upload || !event.clipboardData?.files?.length) return false;
|
||||||
|
const imageFiles = Array.from(event.clipboardData.files).filter(f =>
|
||||||
|
f.type.startsWith("image/")
|
||||||
|
);
|
||||||
|
if (imageFiles.length === 0) return false;
|
||||||
|
event.preventDefault();
|
||||||
|
for (const file of imageFiles) {
|
||||||
|
upload(file).then((url) => {
|
||||||
|
if (url) {
|
||||||
|
const { state } = view;
|
||||||
|
const node = state.schema.nodes.image.create({ src: url, alt: file.name });
|
||||||
|
const tr = state.tr.replaceSelectionWith(node);
|
||||||
|
view.dispatch(tr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
onChange(editor.getHTML());
|
||||||
|
},
|
||||||
|
immediatelyRender: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync external content changes (e.g. template application)
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && content !== editor.getHTML()) {
|
||||||
|
editor.commands.setContent(content, { emitUpdate: false });
|
||||||
|
}
|
||||||
|
}, [content, editor]);
|
||||||
|
|
||||||
|
const addLink = useCallback(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
const previousUrl = editor.getAttributes("link").href;
|
||||||
|
const url = window.prompt("URL", 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]);
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return (
|
||||||
|
<div className={cn("min-h-[100px]", className)} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col", hasError && "ring-2 ring-red-500 dark:ring-red-400 rounded", className)}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex flex-wrap items-center gap-0.5 px-3 py-1.5 border-b border-border/50 bg-muted/30">
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("bold")}
|
||||||
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
|
title="Bold"
|
||||||
|
>
|
||||||
|
<Bold className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("italic")}
|
||||||
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
title="Italic"
|
||||||
|
>
|
||||||
|
<Italic className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("underline")}
|
||||||
|
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||||
|
title="Underline"
|
||||||
|
>
|
||||||
|
<UnderlineIcon className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("strike")}
|
||||||
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||||
|
title="Strikethrough"
|
||||||
|
>
|
||||||
|
<Strikethrough className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarSeparator />
|
||||||
|
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("heading", { level: 1 })}
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||||
|
title="Heading 1"
|
||||||
|
>
|
||||||
|
<Heading1 className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("heading", { level: 2 })}
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
|
title="Heading 2"
|
||||||
|
>
|
||||||
|
<Heading2 className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarSeparator />
|
||||||
|
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("bulletList")}
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
title="Bullet List"
|
||||||
|
>
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("orderedList")}
|
||||||
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
title="Ordered List"
|
||||||
|
>
|
||||||
|
<ListOrdered className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("blockquote")}
|
||||||
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
|
title="Quote"
|
||||||
|
>
|
||||||
|
<Quote className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("codeBlock")}
|
||||||
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||||
|
title="Code Block"
|
||||||
|
>
|
||||||
|
<Code className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarSeparator />
|
||||||
|
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive({ textAlign: "left" })}
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
||||||
|
title="Align Left"
|
||||||
|
>
|
||||||
|
<AlignLeft className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive({ textAlign: "center" })}
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign("center").run()}
|
||||||
|
title="Align Center"
|
||||||
|
>
|
||||||
|
<AlignCenter className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive({ textAlign: "right" })}
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
||||||
|
title="Align Right"
|
||||||
|
>
|
||||||
|
<AlignRight className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarSeparator />
|
||||||
|
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("link")}
|
||||||
|
onClick={addLink}
|
||||||
|
title="Link"
|
||||||
|
>
|
||||||
|
<LinkIcon className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarSeparator />
|
||||||
|
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
|
||||||
|
title="Clear Formatting"
|
||||||
|
>
|
||||||
|
<RemoveFormatting className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarSeparator />
|
||||||
|
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().undo().run()}
|
||||||
|
disabled={!editor.can().undo()}
|
||||||
|
title="Undo"
|
||||||
|
>
|
||||||
|
<Undo className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().redo().run()}
|
||||||
|
disabled={!editor.can().redo()}
|
||||||
|
title="Redo"
|
||||||
|
>
|
||||||
|
<Redo className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor */}
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,7 +42,11 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl }: Im
|
|||||||
}, [name, getImageUrl]);
|
}, [name, getImageUrl]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") { onClose(); return; }
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const tag = target?.tagName?.toLowerCase();
|
||||||
|
if (tag === "input" || tag === "textarea" || tag === "select") return;
|
||||||
|
if (target?.getAttribute("contenteditable") === "true") return;
|
||||||
if (e.key === "+" || e.key === "=") setZoom((z) => Math.min(z + 0.25, 5));
|
if (e.key === "+" || e.key === "=") setZoom((z) => Math.min(z + 0.25, 5));
|
||||||
if (e.key === "-") setZoom((z) => Math.max(z - 0.25, 0.25));
|
if (e.key === "-") setZoom((z) => Math.max(z - 0.25, 0.25));
|
||||||
if (e.key === "r") setRotation((r) => r + 90);
|
if (e.key === "r") setRotation((r) => r + 90);
|
||||||
|
|||||||
Generated
+875
-7
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,16 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.13.18",
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
|
"@tiptap/extension-color": "^3.20.4",
|
||||||
|
"@tiptap/extension-image": "^3.20.4",
|
||||||
|
"@tiptap/extension-link": "^3.20.4",
|
||||||
|
"@tiptap/extension-placeholder": "^3.20.4",
|
||||||
|
"@tiptap/extension-text-align": "^3.20.4",
|
||||||
|
"@tiptap/extension-text-style": "^3.20.4",
|
||||||
|
"@tiptap/extension-underline": "^3.20.4",
|
||||||
|
"@tiptap/pm": "^3.20.4",
|
||||||
|
"@tiptap/react": "^3.20.4",
|
||||||
|
"@tiptap/starter-kit": "^3.20.4",
|
||||||
"asn1js": "^3.0.7",
|
"asn1js": "^3.0.7",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user