From df272e38ef2e2c5634c519a21d54c5f5ca1aa480 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:38:07 +0100 Subject: [PATCH] feat: add resizable image component and rich text editor with image upload support --- app/[locale]/calendar/page.tsx | 1 + app/globals.css | 92 ++ components/calendar/event-detail-popover.tsx | 4 + components/email/email-composer.tsx | 159 ++-- components/email/resizable-image.tsx | 135 +++ components/email/rich-text-editor.tsx | 337 +++++++ components/files/image-preview-modal.tsx | 6 +- package-lock.json | 882 ++++++++++++++++++- package.json | 10 + 9 files changed, 1521 insertions(+), 105 deletions(-) create mode 100644 components/email/resizable-image.tsx create mode 100644 components/email/rich-text-editor.tsx diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index ef46445e..c2c839e0 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -644,6 +644,7 @@ export default function CalendarPage() { const handleKey = (e: KeyboardEvent) => { const target = e.target as HTMLElement; if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") return; + if (target.getAttribute("contenteditable") === "true") return; if (showEventModal || detailEvent) return; switch (e.key) { diff --git a/app/globals.css b/app/globals.css index eac64432..f8acf899 100644 --- a/app/globals.css +++ b/app/globals.css @@ -496,3 +496,95 @@ body { -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; +} diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index c5495ad1..7db26f89 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -192,6 +192,10 @@ export function EventDetailPopover({ useEffect(() => { const handleKey = (e: KeyboardEvent) => { 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) { e.preventDefault(); onEdit(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 85a4480b..a463f4ba 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -28,6 +28,14 @@ import { TemplatePicker } from "@/components/templates/template-picker"; import { TemplateForm } from "@/components/templates/template-form"; import type { EmailTemplate } from "@/lib/template-types"; 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 { to: string; @@ -125,23 +133,28 @@ export function EmailComposer({ }; const getInitialBody = () => { - const prefix = initialDraftText || ""; + const prefix = initialDraftText ? `

${initialDraftText.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}

` : ""; if (!replyTo?.body && !replyTo?.htmlBody) return prefix; const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : ""; const from = replyTo.from?.[0]; const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); - // When HTML body is available, don't include quoted text in the textarea - // The HTML original will be shown separately below the textarea + // Build quoted content as HTML if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { - return prefix; + const quoteHeader = mode === 'forward' + ? `---------- Forwarded message ----------
From: ${fromStr}
Date: ${date}
Subject: ${replyTo.subject || ''}

` + : `On ${date}, ${fromStr} wrote:
`; + return `${prefix}
${quoteHeader}
${replyTo.htmlBody}
`; } - if (mode === 'forward') { - return `${prefix}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ""}\n\n${replyTo.body}`; - } else if (mode === 'reply' || mode === 'replyAll') { - return `${prefix}\n\nOn ${date}, ${fromStr} wrote:\n> ${(replyTo.body || '').split('\n').join('\n> ')}`; + if (replyTo.body) { + const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
'); + if (mode === 'forward') { + return `${prefix}

---------- Forwarded message ----------
From: ${fromStr}
Date: ${date}
Subject: ${replyTo.subject || ''}

${escapedOriginal}`; + } else if (mode === 'reply' || mode === 'replyAll') { + return `${prefix}

On ${date}, ${fromStr} wrote:
${escapedOriginal}
`; + } } return prefix; }; @@ -157,18 +170,6 @@ export function EmailComposer({ const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const saveTimeoutRef = useRef(null); const lastSavedDataRef = useRef(""); - const textareaRef = useRef(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>([]); const fileInputRef = useRef(null); const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({}); @@ -367,9 +368,12 @@ export function EmailComposer({ ? substitutePlaceholders(template.body, filledValues) : template.body; + // Convert template plain text body to HTML for the rich text editor + const htmlBody = `

${filledBody.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}

`; + if (mode === 'compose') { setSubject(filledSubject); - setBody(filledBody); + setBody(htmlBody); if (template.defaultRecipients?.to?.length) { setTo(template.defaultRecipients.to.join(', ') + ', '); } @@ -382,7 +386,7 @@ export function EmailComposer({ setShowBcc(true); } } else { - setBody((prev) => filledBody + prev); + setBody((prev) => htmlBody + prev); } if (template.identityId) { @@ -394,8 +398,10 @@ export function EmailComposer({ useEffect(() => { 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 (target?.getAttribute('contenteditable') === 'true') return; if (e.key === 't' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); setShowTemplatePicker(true); @@ -445,6 +451,18 @@ export function EmailComposer({ } }, [client, t]); + const handleImageUpload = useCallback(async (file: File): Promise => { + 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) => { if (!event.target.files) return; 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 bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean); - if (!toAddresses.length && !subject && !body) { + if (!toAddresses.length && !subject && !htmlToPlainText(body).trim()) { return null; } @@ -547,7 +565,7 @@ export function EmailComposer({ const savedDraftId = await client.createDraft( toAddresses, subject || t('no_subject'), - body, + htmlToPlainText(body), ccAddresses, bccAddresses, currentIdentity?.id, @@ -611,7 +629,8 @@ export function EmailComposer({ }, []); 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 getSendTooltip = (): string | undefined => { @@ -660,26 +679,8 @@ export function EmailComposer({ : currentIdentity.email : undefined; - // Append signature from the selected identity - let finalBody = appendPlainTextSignature(body, currentIdentity); - - // 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) + // Body is already HTML from the rich text editor. + // Build HTML signature block const buildSignatureHtml = (): string => { if (currentIdentity?.htmlSignature) { return `

--
${sanitizeEmailHtml(currentIdentity.htmlSignature)}`; @@ -690,26 +691,13 @@ export function EmailComposer({ return ''; }; - // Build HTML body - let finalHtmlBody: string | undefined; const signatureHtml = buildSignatureHtml(); - if (replyTo?.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { - // Reply/forward with original HTML content - const escapedBody = body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
'); - 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')} ----------
From: ${fromStr}
Date: ${date}
Subject: ${replyTo.subject || ''}

` - : `On ${date}, ${fromStr} wrote:
`; + // Build final HTML body: editor content + signature + const finalHtmlBody = `
${body}
${signatureHtml}`; - finalHtmlBody = `
${escapedBody}
${signatureHtml}
${quoteHeader}
${replyTo.htmlBody}
`; - } else if (signatureHtml) { - // New compose or plain-text reply — include HTML body with signature - const escapedBody = body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
'); - finalHtmlBody = `
${escapedBody}
${signatureHtml}`; - } + // Generate plain text version from the HTML body for multipart/alternative + const finalBody = appendPlainTextSignature(htmlToPlainText(body), currentIdentity); try { // S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail @@ -1107,23 +1095,17 @@ export function EmailComposer({ - {/* Body */} -
-