feat(web): 升级前端依赖并重构邮件编辑器

- 将前端包管理切换为 `pnpm`,新增工作区与锁文件,并同步更新 CI 与 Docker 构建流程。
- 引入 `tiptap` 重构邮件正文编辑器,支持预览、富文本样式、链接/图片编辑与日程卡片插入。
- 发送与定时发送新增确认提示,补充空主题、空正文和附件提醒。
- 更新 `README.md` 与 `SHADCN_RULES.md` 中的安装与检查命令。
This commit is contained in:
LanQin_
2026-06-17 14:58:56 +08:00
parent 6ebbb47012
commit bfc114bec3
10 changed files with 3357 additions and 5349 deletions
+321 -255
View File
@@ -1,9 +1,18 @@
import * as React from "react"
import DOMPurify from "dompurify"
import { type InfiniteData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { Node, mergeAttributes, type Editor } from "@tiptap/core"
import { DOMParser as ProseMirrorDOMParser } from "@tiptap/pm/model"
import { EditorContent, useEditor } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import LinkExtension from "@tiptap/extension-link"
import ImageExtension from "@tiptap/extension-image"
import TextAlign from "@tiptap/extension-text-align"
import Placeholder from "@tiptap/extension-placeholder"
import { BackgroundColor, Color, FontFamily, FontSize, TextStyle } from "@tiptap/extension-text-style"
import { useNavigate } from "react-router-dom"
import type { ImperativePanelHandle } from "react-resizable-panels"
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Clock3, Code2, Copy, Ellipsis, Eraser, FileText, Forward, Highlighter, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, Search, Send, Settings, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react"
import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Clock3, Code2, Copy, Ellipsis, Eraser, Eye, FileText, Forward, Highlighter, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, Search, Send, Settings, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react"
import { api, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend } from "@/lib/api"
import { cn, formatBytes, formatDate, formatDateTime } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme"
@@ -53,6 +62,7 @@ type MailView = "folder" | "starred" | "label" | "scheduled"
type MailListResponse = { items?: MailMessage[]; nextCursor?: string }
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
type MailNotificationState = { latestId: string; latestReceivedAt: string }
type ComposeSendIntent = { title: string; description: string; confirmText: string; onConfirm: () => void }
type MailMenuItem =
| { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number }
| { type: "scheduled"; key: string; label: string; icon: React.ReactNode; count: number }
@@ -1444,6 +1454,7 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
const [draftStatus, setDraftStatus] = React.useState<"idle" | "saving" | "saved" | "error">("idle")
const [lastSavedAt, setLastSavedAt] = React.useState<Date | null>(null)
const [scheduleDialogOpen, setScheduleDialogOpen] = React.useState(false)
const [sendIntent, setSendIntent] = React.useState<ComposeSendIntent | null>(null)
const sendStartedRef = React.useRef(false)
const lastSavedPayloadRef = React.useRef("")
const [showCc, setShowCc] = React.useState(Boolean(draft?.cc))
@@ -1566,13 +1577,30 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
return () => window.clearTimeout(timer)
}, [open, hasDraftContent, composePayload, draftId, qc])
async function submit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!mailbox) {
toast({ title: "请选择发件邮箱" })
function buildSendWarnings(attachmentsCount: number) {
const warnings: string[] = []
const normalizedBody = `${body.text}\n${stripHtml(body.html)}`.toLowerCase()
if (!subjectValue.trim()) warnings.push("这封邮件还没有主题。")
if (!body.text.trim() && !htmlContainsMeaningfulContent(body.html)) warnings.push("正文还是空的。")
if (/(附件|附上|见附件|attached|attachment)/i.test(normalizedBody) && attachmentsCount === 0) warnings.push("正文提到了附件,但还没有添加附件。")
return warnings
}
function confirmOrRun(intent: Omit<ComposeSendIntent, "description"> & { warnings: string[]; defaultDescription?: string }) {
if (intent.warnings.length === 0) {
intent.onConfirm()
return
}
sendStartedRef.current = true
setSendIntent({
title: intent.title,
description: intent.defaultDescription ? `${intent.defaultDescription}\n${intent.warnings.join("\n")}` : intent.warnings.join("\n"),
confirmText: intent.confirmText,
onConfirm: intent.onConfirm,
})
}
async function prepareSend() {
if (!mailbox) return
const attachments = await Promise.all(files.map(fileToAttachment))
const to = splitEmails(toValue)
const cc = showCc ? splitEmails(ccValue) : []
@@ -1584,7 +1612,25 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
const payloads = sendSeparately && separateRecipients.length > 0
? separateRecipients.map((recipient): SendPayload => ({ ...payload, to: [recipient], cc: [], bcc: [] }))
: [payload]
send.mutate(payloads)
confirmOrRun({
title: "确认发送这封邮件?",
confirmText: sendSeparately && payloads.length > 1 ? "继续分别发送" : "继续发送",
warnings: buildSendWarnings(attachments.length),
onConfirm: () => {
sendStartedRef.current = true
setSendIntent(null)
send.mutate(payloads)
},
})
}
async function submit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!mailbox) {
toast({ title: "请选择发件邮箱" })
return
}
await prepareSend()
}
async function scheduleAt(sendAt: string) {
if (!mailbox) {
@@ -1604,7 +1650,17 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
draftId: draftId || undefined,
sendAt,
}
scheduleSend.mutate(payload)
confirmOrRun({
title: "确认定时发送?",
confirmText: "继续定时发送",
defaultDescription: `发送时间:${formatDateTime(sendAt)}`,
warnings: buildSendWarnings(attachments.length),
onConfirm: () => {
sendStartedRef.current = true
setSendIntent(null)
scheduleSend.mutate(payload)
},
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -1667,6 +1723,15 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
</DialogFooter>
</form>
<ScheduleSendDialog open={scheduleDialogOpen} pending={scheduleSend.isPending} onOpenChange={setScheduleDialogOpen} onConfirm={scheduleAt} />
<ConfirmDialog
open={!!sendIntent}
title={sendIntent?.title || ""}
description={sendIntent?.description}
confirmText={sendIntent?.confirmText || "继续"}
pending={send.isPending || scheduleSend.isPending}
onOpenChange={(nextOpen) => { if (!nextOpen) setSendIntent(null) }}
onConfirm={() => sendIntent?.onConfirm()}
/>
</DialogContent>
</Dialog>
)
@@ -1742,45 +1807,8 @@ function ScheduleSendDialog({ open, pending, onOpenChange, onConfirm }: { open:
}
type ComposerValue = { text: string; html: string }
type InsertDialogState = { kind: "link" | "image"; selectedText: string }
type InsertDialogState = { kind: "link" | "image"; selectedText: string; url?: string; alt?: string; editing?: boolean }
type InsertDialogValue = { url: string; text: string; alt: string }
type EditorToolbarState = {
bold: boolean
italic: boolean
underline: boolean
strikeThrough: boolean
unorderedList: boolean
orderedList: boolean
justifyLeft: boolean
justifyCenter: boolean
justifyRight: boolean
fontName: string
fontSize: string
}
type EditorCommand =
| "bold"
| "italic"
| "underline"
| "strikeThrough"
| "insertUnorderedList"
| "insertOrderedList"
| "justifyLeft"
| "justifyCenter"
| "justifyRight"
const defaultToolbarState: EditorToolbarState = {
bold: false,
italic: false,
underline: false,
strikeThrough: false,
unorderedList: false,
orderedList: false,
justifyLeft: false,
justifyCenter: false,
justifyRight: false,
fontName: "",
fontSize: "3",
}
const composerFontOptions = ["Arial", "Georgia", "Times New Roman", "Courier New", "Microsoft YaHei"]
const composerFontSizeOptions = [
["2", "小号"],
@@ -1788,25 +1816,12 @@ const composerFontSizeOptions = [
["4", "中号"],
["5", "大号"],
] as const
const composerFontSizeValueByKey: Record<string, string> = { "2": "13px", "3": "16px", "4": "20px", "5": "24px" }
const composerTextColors = [["#111827", "默认"], ["#dc2626", "红色"], ["#2563eb", "蓝色"], ["#16a34a", "绿色"], ["#9333ea", "紫色"]] as const
const composerHighlightColors = [["transparent", "无高亮"], ["#fef3c7", "黄色"], ["#dcfce7", "绿色"], ["#dbeafe", "蓝色"], ["#fce7f3", "粉色"]] as const
const composerEmojiOptions = ["😀", "😄", "😊", "🙂", "😉", "😍", "😘", "😎", "🤔", "👍", "👏", "🙏", "💪", "🎉", "🔥", "✨", "❤️", "✅", "📌", "📅", "☕", "💡", "🚀", "⭐"]
const composerMenuItemClass = "min-h-9 rounded-md px-3 text-sm transition-colors data-[highlighted]:bg-primary/10 data-[highlighted]:font-semibold data-[highlighted]:text-foreground hover:bg-primary/10 hover:font-semibold hover:text-foreground"
function queryCommandState(command: string) {
try {
return document.queryCommandState(command)
} catch {
return false
}
}
function queryCommandValue(command: string) {
try {
return String(document.queryCommandValue(command) || "")
} catch {
return ""
}
}
function normalizeFontName(value: string) {
const cleaned = value.replace(/["']/g, "").split(",")[0]?.trim() || ""
if (!cleaned || cleaned === "默认字体") return ""
@@ -1846,13 +1861,6 @@ function fontSizeLabel(value: string) {
return composerFontSizeOptions.find(([size]) => size === normalized)?.[1] || "正文"
}
function selectionElementInside(root: HTMLElement | null) {
const selection = window.getSelection()
const node = selection?.anchorNode
if (!root || !node || !root.contains(node)) return null
return node instanceof HTMLElement ? node : node.parentElement
}
function normalizeInsertUrl(value: string, kind: InsertDialogState["kind"]) {
const trimmed = value.trim()
if (!trimmed) return ""
@@ -1860,173 +1868,228 @@ function normalizeInsertUrl(value: string, kind: InsertDialogState["kind"]) {
return allowed.test(trimmed) ? trimmed : `https://${trimmed}`
}
const ScheduleCardNode = Node.create({
name: "scheduleCard",
group: "block",
atom: true,
selectable: true,
draggable: false,
addAttributes() {
return {
title: { default: "" },
time: { default: "" },
duration: { default: "" },
reminder: { default: "" },
repeat: { default: "" },
location: { default: "" },
description: { default: "" },
}
},
parseHTML() {
return [{ tag: "div[data-schedule-card]" }]
},
renderHTML({ HTMLAttributes }) {
const { title, time, duration, reminder, repeat, location, description } = HTMLAttributes
const rows = [
["时间", time],
["持续", duration],
["提醒", reminder],
["重复", repeat],
location ? ["位置", location] : undefined,
description ? ["描述", description] : undefined,
].filter(Boolean) as string[][]
return [
"div",
mergeAttributes(HTMLAttributes, {
"data-schedule-card": "true",
style: "border:1px solid #d4d4d8;border-radius:8px;padding:14px 16px;margin:16px 0;background:#fafafa;",
}),
["div", { style: "font-weight:600;font-size:16px;margin-bottom:10px;" }, title || "日程"],
...rows.map(([label, value]) => ["div", { style: "margin:6px 0;" }, ["span", { style: "color:#71717a;" }, `${label}`], value]),
]
},
})
function composerInitialHtml(defaultValue: string, defaultHtml?: string) {
return sanitizeComposerHtml(defaultHtml !== undefined ? defaultHtml : plainTextToHtml(defaultValue)) || "<p></p>"
}
function composerValueFromEditor(editor: Editor): ComposerValue {
const text = editor.getText({ blockSeparator: "\n" }).replace(/\u00a0/g, " ").trimEnd()
const html = sanitizeComposerHtml(editor.getHTML())
if (!text.trim() && !htmlContainsMeaningfulContent(html)) return { text: "", html: "" }
return { text, html: html || plainTextToHtml(text) }
}
function editorTextSelection(editor: Editor) {
const { from, to, empty } = editor.state.selection
if (empty) return ""
return editor.state.doc.textBetween(from, to, " ").trim()
}
function selectedImageAttributes(editor: Editor) {
const attrs = editor.getAttributes("image") as { src?: string; alt?: string }
return attrs.src ? attrs : null
}
function scheduleToNodeAttributes(schedule: ScheduleDraft) {
const start = parseScheduleStart(schedule)
const end = schedule.allDay ? new Date(start.getTime() + 24 * 60 * 60 * 1000) : new Date(start.getTime() + schedule.durationMinutes * 60 * 1000)
return {
title: schedule.title,
time: schedule.allDay ? formatDate(start.toISOString()) : `${formatDateTime(start.toISOString())} - ${formatTimeOnly(end)}`,
duration: schedule.allDay ? "全天" : durationLabel(schedule.durationMinutes),
reminder: reminderLabel(schedule.reminderMinutes),
repeat: repeatLabel(schedule.repeat),
location: schedule.location,
description: schedule.description,
}
}
function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onChange, onPickFiles, onRemoveFile }: { defaultValue: string; defaultHtml?: string; files: File[]; signatureText: string; onChange: (value: ComposerValue) => void; onPickFiles: (files: File[]) => void; onRemoveFile: (index: number) => void }) {
const editorRef = React.useRef<HTMLDivElement>(null)
const fileInputRef = React.useRef<HTMLInputElement>(null)
const selectionRef = React.useRef<Range | null>(null)
const initialHtmlRef = React.useRef(defaultHtml !== undefined ? DOMPurify.sanitize(defaultHtml) : plainTextToHtml(defaultValue))
const dirtyRef = React.useRef(false)
const lastDefaultRef = React.useRef(defaultValue)
const lastDefaultRef = React.useRef(`${defaultValue}\n${defaultHtml || ""}`)
const [formatOpen, setFormatOpen] = React.useState(true)
const [scheduleOpen, setScheduleOpen] = React.useState(false)
const [emojiOpen, setEmojiOpen] = React.useState(false)
const [insertDialog, setInsertDialog] = React.useState<InsertDialogState | null>(null)
const [toolbarState, setToolbarState] = React.useState<EditorToolbarState>(defaultToolbarState)
const [previewOpen, setPreviewOpen] = React.useState(false)
const [empty, setEmpty] = React.useState(!defaultValue.trim())
React.useEffect(() => {
const next = defaultHtml !== undefined ? htmlComposerValue(defaultHtml) : plainTextComposerValue(defaultValue)
onChange(next)
setEmpty(!next.text.trim())
}, [])
React.useEffect(() => {
if (defaultValue === lastDefaultRef.current) return
lastDefaultRef.current = defaultValue
if (!dirtyRef.current) {
const next = defaultHtml !== undefined ? htmlComposerValue(defaultHtml) : plainTextComposerValue(defaultValue)
if (editorRef.current) editorRef.current.innerHTML = next.html
setEmpty(!next.text.trim())
const [selectionVersion, setSelectionVersion] = React.useState(0)
const editor = useEditor({
extensions: [
StarterKit.configure({ link: false }),
TextStyle,
Color,
BackgroundColor,
FontFamily,
FontSize,
LinkExtension.configure({
openOnClick: false,
enableClickSelection: true,
HTMLAttributes: { target: "_blank", rel: "noopener noreferrer" },
}),
ImageExtension.configure({ allowBase64: true, HTMLAttributes: { style: "max-width:100%;height:auto;border-radius:8px;margin:12px 0;" } }),
TextAlign.configure({ types: ["heading", "paragraph"] }),
Placeholder.configure({ placeholder: "输入正文" }),
ScheduleCardNode,
],
content: composerInitialHtml(defaultValue, defaultHtml),
editorProps: {
attributes: {
class: "mail-html min-h-[280px] overflow-y-auto px-6 py-5 text-base leading-7 outline-none",
"aria-label": "正文",
},
handlePaste(view, event) {
const clipboard = event.clipboardData
if (!clipboard) return false
const html = clipboard.getData("text/html")
const text = clipboard.getData("text/plain")
if (!html && !text) return false
event.preventDefault()
const content = html ? sanitizeComposerHtml(html) : plainTextToHtml(text)
const container = document.createElement("div")
container.innerHTML = content || plainTextToHtml(text)
const slice = ProseMirrorDOMParser.fromSchema(view.state.schema).parseSlice(container)
view.dispatch(view.state.tr.replaceSelection(slice).scrollIntoView())
return true
},
},
onCreate({ editor }) {
const next = composerValueFromEditor(editor)
onChange(next)
}
}, [defaultValue, defaultHtml, onChange])
setEmpty(!next.text.trim() && !htmlContainsMeaningfulContent(next.html))
},
onUpdate({ editor }) {
dirtyRef.current = true
const next = composerValueFromEditor(editor)
onChange(next)
setEmpty(!next.text.trim() && !htmlContainsMeaningfulContent(next.html))
},
onSelectionUpdate() {
setSelectionVersion((value) => value + 1)
},
onTransaction() {
setSelectionVersion((value) => value + 1)
},
})
React.useEffect(() => {
function handleSelectionChange() {
const selection = window.getSelection()
const anchorNode = selection?.anchorNode
if (!anchorNode || !editorRef.current?.contains(anchorNode)) return
saveSelection()
updateToolbarState()
if (!editor) return
const defaultKey = `${defaultValue}\n${defaultHtml || ""}`
if (defaultKey === lastDefaultRef.current) return
lastDefaultRef.current = defaultKey
if (!dirtyRef.current || editor.isEmpty) {
const next = defaultHtml !== undefined ? htmlComposerValue(defaultHtml) : plainTextComposerValue(defaultValue)
editor.commands.setContent(next.html || "<p></p>", { emitUpdate: false })
onChange(next)
setEmpty(!next.text.trim() && !htmlContainsMeaningfulContent(next.html))
}
document.addEventListener("selectionchange", handleSelectionChange)
return () => document.removeEventListener("selectionchange", handleSelectionChange)
}, [])
}, [editor, defaultValue, defaultHtml, onChange])
function focusEditor() {
window.requestAnimationFrame(() => editorRef.current?.focus())
}
function saveSelection() {
const selection = window.getSelection()
const anchorNode = selection?.anchorNode
if (!selection || selection.rangeCount === 0 || !anchorNode || !editorRef.current?.contains(anchorNode)) return
selectionRef.current = selection.getRangeAt(0).cloneRange()
}
function restoreSelection() {
if (!selectionRef.current) return
try {
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(selectionRef.current)
} catch {
selectionRef.current = null
}
}
function syncEditor() {
const next = composerValueFromElement(editorRef.current)
setEmpty(!next.text.trim())
onChange(next)
}
function runCommand(command: string, value?: string) {
dirtyRef.current = true
editorRef.current?.focus()
restoreSelection()
document.execCommand(command, false, value)
saveSelection()
syncEditor()
updateToolbarState()
}
function optimisticToggle(command: EditorCommand) {
const keyByCommand: Record<EditorCommand, keyof EditorToolbarState> = {
bold: "bold",
italic: "italic",
underline: "underline",
strikeThrough: "strikeThrough",
insertUnorderedList: "unorderedList",
insertOrderedList: "orderedList",
justifyLeft: "justifyLeft",
justifyCenter: "justifyCenter",
justifyRight: "justifyRight",
}
const key = keyByCommand[command]
setToolbarState((current) => {
const next = { ...current, [key]: !current[key] }
if (command === "justifyLeft" || command === "justifyCenter" || command === "justifyRight") {
next.justifyLeft = command === "justifyLeft"
next.justifyCenter = command === "justifyCenter"
next.justifyRight = command === "justifyRight"
}
if (command === "insertUnorderedList" && next.unorderedList) next.orderedList = false
if (command === "insertOrderedList" && next.orderedList) next.unorderedList = false
return next
})
runCommand(command)
}
function updateToolbarState() {
const currentElement = selectionElementInside(editorRef.current)
const computedStyle = currentElement ? window.getComputedStyle(currentElement) : null
setToolbarState((current) => ({
...current,
bold: queryCommandState("bold"),
italic: queryCommandState("italic"),
underline: queryCommandState("underline"),
strikeThrough: queryCommandState("strikeThrough"),
unorderedList: queryCommandState("insertUnorderedList"),
orderedList: queryCommandState("insertOrderedList"),
justifyLeft: queryCommandState("justifyLeft"),
justifyCenter: queryCommandState("justifyCenter"),
justifyRight: queryCommandState("justifyRight"),
fontName: normalizeFontName(queryCommandValue("fontName")) || normalizeFontName(computedStyle?.fontFamily || "") || current.fontName,
fontSize: normalizeFontSize(queryCommandValue("fontSize")) || normalizeFontSize(computedStyle?.fontSize || "") || current.fontSize,
}))
}
const textStyleAttributes = editor?.getAttributes("textStyle") as { fontFamily?: string; fontSize?: string; color?: string; backgroundColor?: string } | undefined
const activeFont = normalizeFontName(textStyleAttributes?.fontFamily || "")
const activeFontSize = normalizeFontSize(textStyleAttributes?.fontSize || "") || "3"
const activeColor = textStyleAttributes?.color || ""
const activeHighlight = textStyleAttributes?.backgroundColor || ""
void selectionVersion
function applyFont(font: string) {
runCommand("fontName", font)
setToolbarState((current) => ({ ...current, fontName: font }))
editor?.chain().focus().setFontFamily(font).run()
}
function applyFontSize(size: string) {
runCommand("fontSize", size)
setToolbarState((current) => ({ ...current, fontSize: size }))
const value = composerFontSizeValueByKey[size]
if (value) editor?.chain().focus().setFontSize(value).run()
}
function openInsertDialog(kind: InsertDialogState["kind"]) {
saveSelection()
setInsertDialog({ kind, selectedText: selectionRef.current?.toString().trim() || "" })
if (!editor) return
if (kind === "link") {
const attrs = editor.getAttributes("link") as { href?: string }
setInsertDialog({ kind, selectedText: editorTextSelection(editor), url: attrs.href || "", editing: Boolean(attrs.href) })
return
}
const imageAttrs = selectedImageAttributes(editor)
setInsertDialog({ kind, selectedText: "", url: imageAttrs?.src || "", alt: imageAttrs?.alt || "", editing: Boolean(imageAttrs?.src) })
}
function confirmInsert(value: InsertDialogValue) {
if (!insertDialog) return
if (!editor || !insertDialog) return
const url = normalizeInsertUrl(value.url, insertDialog.kind)
if (!url) return
if (insertDialog.kind === "link") {
const text = value.text.trim() || insertDialog.selectedText || value.url.trim()
runCommand("insertHTML", `<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(text)}</a>`)
if (editor.state.selection.empty && !insertDialog.editing) {
editor.chain().focus().insertContent(`<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(text)}</a>`).run()
} else {
if (value.text.trim() && value.text.trim() !== insertDialog.selectedText) editor.chain().focus().insertContent(escapeHtml(text)).run()
editor.chain().focus().extendMarkRange("link").setLink({ href: url, target: "_blank", rel: "noopener noreferrer" }).run()
}
return
}
runCommand("insertHTML", `<img src="${escapeHtml(url)}" alt="${escapeHtml(value.alt.trim())}" />`)
if (insertDialog.editing) {
editor.chain().focus().updateAttributes("image", { src: url, alt: value.alt.trim() }).run()
return
}
editor.chain().focus().setImage({ src: url, alt: value.alt.trim() }).run()
}
function insertSignature() {
if (!signatureText.trim()) return
runCommand("insertHTML", `<br><br>-- <br>${plainTextToHtmlFragment(signatureText)}`)
if (!editor || !signatureText.trim()) return
editor.chain().focus().insertContent(`<p><br></p><p>-- <br>${plainTextToHtmlFragment(signatureText)}</p>`).run()
}
function insertSchedule(schedule: ScheduleDraft) {
if (!editor) return
const normalized = normalizeSchedule(schedule)
runCommand("insertHTML", scheduleToHtml(normalized))
editor.chain().focus().insertContent({ type: "scheduleCard", attrs: scheduleToNodeAttributes(normalized) }).run()
onPickFiles([scheduleToFile(normalized)])
}
function insertEmoji(emoji: string) {
runCommand("insertText", emoji)
editor?.chain().focus().insertContent(emoji).run()
setEmojiOpen(false)
}
@@ -2039,11 +2102,11 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
return (
<div className="min-h-[420px]">
<Input ref={fileInputRef} type="file" multiple className="hidden" onChange={handlePickedFiles} />
<div className="flex min-h-11 flex-wrap items-center gap-1 border-b px-6 py-2" onMouseDown={saveSelection} onPointerDownCapture={saveSelection}>
<ToolbarButton label="撤销" onClick={() => runCommand("undo")}><Undo2 className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="重做" onClick={() => runCommand("redo")}><Redo2 className="h-4 w-4" /></ToolbarButton>
<div className="flex min-h-11 flex-wrap items-center gap-1 border-b px-6 py-2">
<ToolbarButton label="撤销" disabled={!editor?.can().undo()} onClick={() => editor?.chain().focus().undo().run()}><Undo2 className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="重做" disabled={!editor?.can().redo()} onClick={() => editor?.chain().focus().redo().run()}><Redo2 className="h-4 w-4" /></ToolbarButton>
<Separator orientation="vertical" className="mx-2 h-6" />
<ToolbarTextButton label="图片" icon={<Image className="h-4 w-4" />} onClick={() => openInsertDialog("image")} />
<ToolbarTextButton label="图片" icon={<Image className="h-4 w-4" />} active={editor?.isActive("image")} disabled={!editor} onClick={() => openInsertDialog("image")} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="h-8 gap-1 rounded-md px-2 font-normal hover:bg-accent hover:shadow-sm" onMouseDown={(event) => event.preventDefault()}>
@@ -2054,7 +2117,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => fileInputRef.current?.click()}><Paperclip className="h-4 w-4" /></DropdownMenuItem>
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => openInsertDialog("link")}><Link className="h-4 w-4" /></DropdownMenuItem>
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => openInsertDialog("image")}><Image className="h-4 w-4" /></DropdownMenuItem>
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => runCommand("insertHorizontalRule")}><span className="h-4 w-4 border-t border-current" aria-hidden />线</DropdownMenuItem>
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => editor?.chain().focus().setHorizontalRule().run()}><span className="h-4 w-4 border-t border-current" aria-hidden />线</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ToolbarTextButton label="导入文档" icon={<FileText className="h-4 w-4" />} onClick={() => fileInputRef.current?.click()} />
@@ -2077,24 +2140,25 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
</DropdownMenu>
<ToolbarTextButton label="格式" icon={<Type className="h-4 w-4" />} active={formatOpen} onClick={() => setFormatOpen((value) => !value)} />
<div className="ml-auto flex items-center gap-1">
<ToolbarTextButton label="预览" icon={<Eye className="h-4 w-4" />} active={previewOpen} onClick={() => setPreviewOpen(true)} />
<ToolbarTextButton label="签名" icon={<Signature className="h-4 w-4" />} onClick={insertSignature} disabled={!signatureText.trim()} />
<ToolbarButton label="更多"><Ellipsis className="h-4 w-4" /></ToolbarButton>
</div>
</div>
{formatOpen && (
<div className="flex min-h-14 flex-wrap items-center gap-1 border-b bg-muted/40 px-6 py-2" onMouseDown={saveSelection} onPointerDownCapture={saveSelection}>
<ToolbarButton label="清除格式" onClick={() => runCommand("removeFormat")}><Eraser className="h-4 w-4" /></ToolbarButton>
<div className="flex min-h-14 flex-wrap items-center gap-1 border-b bg-muted/40 px-6 py-2">
<ToolbarButton label="清除格式" disabled={!editor} onClick={() => editor?.chain().focus().unsetAllMarks().clearNodes().run()}><Eraser className="h-4 w-4" /></ToolbarButton>
<Separator orientation="vertical" className="mx-2 h-6" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="sm" className={cn("h-8 min-w-[112px] justify-between rounded-md border border-transparent px-2 font-normal hover:border-border hover:bg-accent hover:shadow-sm", normalizeFontName(toolbarState.fontName) && "border-primary/35 bg-primary/10 text-primary shadow-sm")} onMouseDown={(event) => event.preventDefault()}>
<span className="truncate">{fontLabel(toolbarState.fontName)}</span><ChevronDown className="h-3.5 w-3.5" />
<Button type="button" variant="ghost" size="sm" className={cn("h-8 min-w-[112px] justify-between rounded-md border border-transparent px-2 font-normal hover:border-border hover:bg-accent hover:shadow-sm", activeFont && "border-primary/35 bg-primary/10 text-primary shadow-sm")} onMouseDown={(event) => event.preventDefault()} disabled={!editor}>
<span className="truncate">{fontLabel(activeFont)}</span><ChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{composerFontOptions.map((font) => (
<DropdownMenuItem key={font} className={composerMenuItemClass} onSelect={() => applyFont(font)}>
<Check className={cn("h-4 w-4", normalizeFontName(toolbarState.fontName) === font ? "opacity-100" : "opacity-0")} />
<Check className={cn("h-4 w-4", activeFont === font ? "opacity-100" : "opacity-0")} />
<span style={{ fontFamily: font }}>{font}</span>
</DropdownMenuItem>
))}
@@ -2102,33 +2166,34 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="sm" className={cn("h-8 min-w-[84px] justify-between rounded-md border border-transparent px-2 font-normal hover:border-border hover:bg-accent hover:shadow-sm", toolbarState.fontSize !== "3" && "border-primary/35 bg-primary/10 text-primary shadow-sm")} onMouseDown={(event) => event.preventDefault()}>
{fontSizeLabel(toolbarState.fontSize)}<ChevronDown className="h-3.5 w-3.5" />
<Button type="button" variant="ghost" size="sm" className={cn("h-8 min-w-[84px] justify-between rounded-md border border-transparent px-2 font-normal hover:border-border hover:bg-accent hover:shadow-sm", activeFontSize !== "3" && "border-primary/35 bg-primary/10 text-primary shadow-sm")} onMouseDown={(event) => event.preventDefault()} disabled={!editor}>
{fontSizeLabel(activeFontSize)}<ChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{composerFontSizeOptions.map(([size, label]) => (
<DropdownMenuItem key={size} className={composerMenuItemClass} onSelect={() => applyFontSize(size)}>
<Check className={cn("h-4 w-4", toolbarState.fontSize === size ? "opacity-100" : "opacity-0")} />
<Check className={cn("h-4 w-4", activeFontSize === size ? "opacity-100" : "opacity-0")} />
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Separator orientation="vertical" className="mx-2 h-6" />
<ToolbarButton label="加粗" active={toolbarState.bold} onClick={() => optimisticToggle("bold")}><Bold className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="斜体" active={toolbarState.italic} onClick={() => optimisticToggle("italic")}><Italic className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="下划线" active={toolbarState.underline} onClick={() => optimisticToggle("underline")}><Underline className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="删除线" active={toolbarState.strikeThrough} onClick={() => optimisticToggle("strikeThrough")}><Strikethrough className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="加粗" active={editor?.isActive("bold")} disabled={!editor} onClick={() => editor?.chain().focus().toggleBold().run()}><Bold className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="斜体" active={editor?.isActive("italic")} disabled={!editor} onClick={() => editor?.chain().focus().toggleItalic().run()}><Italic className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="下划线" active={editor?.isActive("underline")} disabled={!editor} onClick={() => editor?.chain().focus().toggleUnderline().run()}><Underline className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="删除线" active={editor?.isActive("strike")} disabled={!editor} onClick={() => editor?.chain().focus().toggleStrike().run()}><Strikethrough className="h-4 w-4" /></ToolbarButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 rounded-md border border-transparent text-muted-foreground hover:border-border hover:bg-accent hover:text-foreground hover:shadow-sm" title="文字颜色" aria-label="文字颜色" onMouseDown={(event) => event.preventDefault()}>
<Button type="button" variant="ghost" size="icon" className={cn("h-8 w-8 rounded-md border border-transparent text-muted-foreground hover:border-border hover:bg-accent hover:text-foreground hover:shadow-sm", activeColor && "border-primary/35 bg-primary/10 text-primary shadow-sm")} title="文字颜色" aria-label="文字颜色" onMouseDown={(event) => event.preventDefault()} disabled={!editor}>
<Type className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-36">
{[["#111827", "默认"], ["#dc2626", "红色"], ["#2563eb", "蓝色"], ["#16a34a", "绿色"]].map(([color, label]) => (
<DropdownMenuItem key={color} className={composerMenuItemClass} onSelect={() => runCommand("foreColor", color)}>
{composerTextColors.map(([color, label]) => (
<DropdownMenuItem key={color} className={composerMenuItemClass} onSelect={() => color === "#111827" ? editor?.chain().focus().unsetColor().run() : editor?.chain().focus().setColor(color).run()}>
<Check className={cn("h-4 w-4", activeColor === color || (!activeColor && color === "#111827") ? "opacity-100" : "opacity-0")} />
<span className="h-3 w-3 rounded-full border" style={{ backgroundColor: color }} />{label}
</DropdownMenuItem>
))}
@@ -2136,47 +2201,40 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 rounded-md border border-transparent text-muted-foreground hover:border-border hover:bg-accent hover:text-foreground hover:shadow-sm" title="高亮" aria-label="高亮" onMouseDown={(event) => event.preventDefault()}>
<Button type="button" variant="ghost" size="icon" className={cn("h-8 w-8 rounded-md border border-transparent text-muted-foreground hover:border-border hover:bg-accent hover:text-foreground hover:shadow-sm", activeHighlight && "border-primary/35 bg-primary/10 text-primary shadow-sm")} title="高亮" aria-label="高亮" onMouseDown={(event) => event.preventDefault()} disabled={!editor}>
<Highlighter className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{[["transparent", "无高亮"], ["#fef3c7", "黄色"], ["#dcfce7", "绿色"], ["#dbeafe", "蓝色"]].map(([color, label]) => (
<DropdownMenuItem key={color} className={composerMenuItemClass} onSelect={() => runCommand("backColor", color)}>
{composerHighlightColors.map(([color, label]) => (
<DropdownMenuItem key={color} className={composerMenuItemClass} onSelect={() => color === "transparent" ? editor?.chain().focus().unsetBackgroundColor().run() : editor?.chain().focus().setBackgroundColor(color).run()}>
<Check className={cn("h-4 w-4", activeHighlight === color || (!activeHighlight && color === "transparent") ? "opacity-100" : "opacity-0")} />
<span className="h-3 w-3 rounded-sm border" style={{ backgroundColor: color }} />{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Separator orientation="vertical" className="mx-2 h-6" />
<ToolbarButton label="无序列表" active={toolbarState.unorderedList} onClick={() => optimisticToggle("insertUnorderedList")}><List className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="有序列表" active={toolbarState.orderedList} onClick={() => optimisticToggle("insertOrderedList")}><ListOrdered className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="减少缩进" onClick={() => runCommand("outdent")}><IndentDecrease className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="增加缩进" onClick={() => runCommand("indent")}><IndentIncrease className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="无序列表" active={editor?.isActive("bulletList")} disabled={!editor} onClick={() => editor?.chain().focus().toggleBulletList().run()}><List className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="有序列表" active={editor?.isActive("orderedList")} disabled={!editor} onClick={() => editor?.chain().focus().toggleOrderedList().run()}><ListOrdered className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="减少缩进" disabled={!editor?.can().liftListItem("listItem")} onClick={() => editor?.chain().focus().liftListItem("listItem").run()}><IndentDecrease className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="增加缩进" disabled={!editor?.can().sinkListItem("listItem")} onClick={() => editor?.chain().focus().sinkListItem("listItem").run()}><IndentIncrease className="h-4 w-4" /></ToolbarButton>
<Separator orientation="vertical" className="mx-2 h-6" />
<ToolbarButton label="左对齐" active={toolbarState.justifyLeft} onClick={() => optimisticToggle("justifyLeft")}><AlignLeft className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="居中" active={toolbarState.justifyCenter} onClick={() => optimisticToggle("justifyCenter")}><AlignCenter className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="右对齐" active={toolbarState.justifyRight} onClick={() => optimisticToggle("justifyRight")}><AlignRight className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="引用" onClick={() => runCommand("formatBlock", "blockquote")}><Quote className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="代码块" onClick={() => runCommand("formatBlock", "pre")}><Code2 className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="左对齐" active={editor?.isActive({ textAlign: "left" })} disabled={!editor} onClick={() => editor?.chain().focus().setTextAlign("left").run()}><AlignLeft className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="居中" active={editor?.isActive({ textAlign: "center" })} disabled={!editor} onClick={() => editor?.chain().focus().setTextAlign("center").run()}><AlignCenter className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="右对齐" active={editor?.isActive({ textAlign: "right" })} disabled={!editor} onClick={() => editor?.chain().focus().setTextAlign("right").run()}><AlignRight className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="引用" active={editor?.isActive("blockquote")} disabled={!editor} onClick={() => editor?.chain().focus().toggleBlockquote().run()}><Quote className="h-4 w-4" /></ToolbarButton>
<ToolbarButton label="代码块" active={editor?.isActive("codeBlock")} disabled={!editor} onClick={() => editor?.chain().focus().toggleCodeBlock().run()}><Code2 className="h-4 w-4" /></ToolbarButton>
</div>
)}
<div className="relative">
{empty && <div className="pointer-events-none absolute left-6 top-6 text-base text-muted-foreground"></div>}
<div
ref={editorRef}
role="textbox"
aria-label="正文"
contentEditable
suppressContentEditableWarning
className="mail-html min-h-[280px] overflow-y-auto px-6 py-5 text-base leading-7 outline-none"
onFocus={() => { saveSelection(); updateToolbarState() }}
onMouseUp={() => { saveSelection(); updateToolbarState() }}
onKeyUp={() => { saveSelection(); updateToolbarState() }}
onInput={() => { dirtyRef.current = true; saveSelection(); syncEditor(); updateToolbarState() }}
onBlur={syncEditor}
dangerouslySetInnerHTML={{ __html: initialHtmlRef.current }}
/>
<div className={cn(
"composer-editor relative",
"[&_.ProseMirror]:min-h-[280px] [&_.ProseMirror]:overflow-y-auto [&_.ProseMirror]:px-6 [&_.ProseMirror]:py-5 [&_.ProseMirror]:text-base [&_.ProseMirror]:leading-7 [&_.ProseMirror]:outline-none",
"[&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-muted-foreground [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)]",
"[&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ul]:pl-6 [&_.ProseMirror_ol]:pl-6 [&_.ProseMirror_blockquote]:border-l-4 [&_.ProseMirror_blockquote]:border-border [&_.ProseMirror_blockquote]:pl-4 [&_.ProseMirror_blockquote]:text-muted-foreground [&_.ProseMirror_pre]:rounded-md [&_.ProseMirror_pre]:bg-muted [&_.ProseMirror_pre]:p-3",
empty && "bg-background"
)}>
<EditorContent editor={editor} />
</div>
{files.length > 0 && (
<div className="border-t px-6 py-3">
@@ -2196,6 +2254,14 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
)}
<InsertContentDialog state={insertDialog} onOpenChange={(open) => { if (!open) setInsertDialog(null) }} onConfirm={confirmInsert} />
<ScheduleDialog open={scheduleOpen} onOpenChange={setScheduleOpen} onConfirm={(schedule) => { insertSchedule(schedule); setScheduleOpen(false) }} />
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent className="w-[min(92vw,44rem)] max-w-none">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="mail-html max-h-[60vh] overflow-y-auto rounded-md border bg-background p-5 text-sm leading-7" dangerouslySetInnerHTML={{ __html: sanitizeComposerHtml(editor?.getHTML() || "") || "<p></p>" }} />
</DialogContent>
</Dialog>
</div>
)
}
@@ -2216,9 +2282,9 @@ function InsertContentDialog({ state, onOpenChange, onConfirm }: { state: Insert
React.useEffect(() => {
if (!state) return
setUrl("")
setUrl(state.url || "")
setText(state.kind === "link" ? state.selectedText : "")
setAlt("")
setAlt(state.alt || "")
}, [state])
function submit(event: React.FormEvent<HTMLFormElement>) {
@@ -2233,7 +2299,7 @@ function InsertContentDialog({ state, onOpenChange, onConfirm }: { state: Insert
<DialogContent className="sm:max-w-md">
<form className="grid gap-4" onSubmit={submit}>
<DialogHeader>
<DialogTitle>{kind === "link" ? "插入链接" : "插入图片"}</DialogTitle>
<DialogTitle>{kind === "link" ? (state?.editing ? "编辑链接" : "插入链接") : (state?.editing ? "编辑图片" : "插入图片")}</DialogTitle>
</DialogHeader>
<div className="grid gap-2">
<Label htmlFor="composer-insert-url">{kind === "link" ? "链接地址" : "图片地址"}</Label>
@@ -2252,7 +2318,7 @@ function InsertContentDialog({ state, onOpenChange, onConfirm }: { state: Insert
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}></Button>
<Button type="submit"></Button>
<Button type="submit">{state?.editing ? "更新" : "插入"}</Button>
</DialogFooter>
</form>
</DialogContent>
@@ -2566,25 +2632,25 @@ function escapeIcs(value: string) {
}
function plainTextComposerValue(value: string): ComposerValue { return { text: value, html: plainTextToHtml(value) } }
function htmlComposerValue(value: string): ComposerValue {
const html = DOMPurify.sanitize(value || "")
const html = sanitizeComposerHtml(value || "")
const text = stripHtml(html)
return { text, html: html || plainTextToHtml(text) }
}
function composerValueFromElement(element: HTMLElement | null): ComposerValue {
const text = (element?.innerText || "").replace(/\u00a0/g, " ").trimEnd()
if (!text.trim()) return { text: "", html: "" }
const html = DOMPurify.sanitize(element?.innerHTML || "")
return { text, html: html || plainTextToHtml(text) }
}
function plainTextToHtml(value: string) {
const normalized = value.replace(/\r\n/g, "\n")
if (!normalized.trim()) return ""
return DOMPurify.sanitize(normalized.split(/\n{2,}/).map((paragraph) => `<p>${plainTextToHtmlFragment(paragraph) || "<br>"}</p>`).join(""))
return sanitizeComposerHtml(normalized.split(/\n{2,}/).map((paragraph) => `<p>${plainTextToHtmlFragment(paragraph) || "<br>"}</p>`).join(""))
}
function plainTextToHtmlFragment(value: string) { return value.split("\n").map((line) => escapeHtml(line)).join("<br>") }
function escapeHtml(value: string) {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;")
}
function sanitizeComposerHtml(value: string) {
return DOMPurify.sanitize(value || "")
}
function htmlContainsMeaningfulContent(html: string) {
return /<(img|hr|table|ul|ol|li|blockquote|pre|div)[\s>]/i.test(html) || stripHtml(html).trim().length > 0
}
function playIncomingMailSound(ref: React.MutableRefObject<AudioContext | null>) {
const AudioContextCtor = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (!AudioContextCtor) return