feat(mail): 优化邮件编辑器与邮件同步行为
- 新增已发送文件夹导入测试,并让同步时自动将非收件箱邮件标记为已读。 - 优化编辑器工具栏交互,增强下拉菜单与按钮的悬停/选中态表现,并修复部分按钮点击时的选区丢失。 - 为加粗、列表、对齐等命令加入乐观切换,提升工具栏状态反馈的即时性。
This commit is contained in:
@@ -967,6 +967,80 @@ func TestMaildirSyncImportsRFC822(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMaildirSyncImportsSentFolder(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
root := t.TempDir()
|
||||||
|
a.cfg.MaildirRoot = root
|
||||||
|
adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var mailboxID string
|
||||||
|
if err := a.db.QueryRowContext(ctx, `SELECT id FROM mailboxes WHERE user_id=? AND address=?`, adminUser.ID, "admin@lanqin.local").Scan(&mailboxID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE mailbox_id=?`, mailboxID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sentFolderID, err := a.ensureFolder(ctx, mailboxID, "Sent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mailboxes, err := a.maildirMailboxes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var admin maildirMailbox
|
||||||
|
for _, mb := range mailboxes {
|
||||||
|
if mb.Address == "admin@lanqin.local" {
|
||||||
|
admin = mb
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if admin.ID == "" {
|
||||||
|
t.Fatal("admin mailbox not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Join(root, admin.Domain, admin.LocalPart, "Maildir", ".Sent", "new")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw := strings.Join([]string{
|
||||||
|
"From: admin@lanqin.local",
|
||||||
|
"To: recipient@example.test",
|
||||||
|
"Subject: SMTP sent archive",
|
||||||
|
"Message-Id: <smtp-sent-archive@example.test>",
|
||||||
|
"Date: Sat, 13 Jun 2026 14:00:00 +0000",
|
||||||
|
"MIME-Version: 1.0",
|
||||||
|
"Content-Type: text/plain; charset=utf-8",
|
||||||
|
"",
|
||||||
|
"archived from smtp client",
|
||||||
|
}, "\r\n")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "1749823200.M1P1.sent"), []byte(raw), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := a.syncMaildirOnce(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("imported=%d, want 1", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
var folderID, subject string
|
||||||
|
var read int
|
||||||
|
err = a.db.QueryRow(`SELECT folder_id, subject, is_read FROM messages WHERE mailbox_id=? AND message_id='<smtp-sent-archive@example.test>'`, admin.ID).Scan(&folderID, &subject, &read)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if folderID != sentFolderID || subject != "SMTP sent archive" || read != 1 {
|
||||||
|
t.Fatalf("unexpected sent import folder=%q want=%q subject=%q read=%d", folderID, sentFolderID, subject, read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustDefaultDomainID(t *testing.T, a *App) string {
|
func mustDefaultDomainID(t *testing.T, a *App) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var id string
|
var id string
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
|
|||||||
}
|
}
|
||||||
msg.MailboxID = mb.ID
|
msg.MailboxID = mb.ID
|
||||||
msg.FolderID = folder.ID
|
msg.FolderID = folder.ID
|
||||||
|
msg.IsRead = !strings.EqualFold(folder.Name, "Inbox")
|
||||||
msg.RawPath = path
|
msg.RawPath = path
|
||||||
if msg.MessageUID == "" {
|
if msg.MessageUID == "" {
|
||||||
msg.MessageUID = newID("uid")
|
msg.MessageUID = newID("uid")
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
"flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground data-[highlighted]:bg-accent data-[highlighted]:font-medium data-[highlighted]:text-accent-foreground data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -82,7 +82,7 @@ const DropdownMenuItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
"relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground data-[highlighted]:bg-accent data-[highlighted]:font-medium data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:cursor-default data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
inset && "pl-8",
|
inset && "pl-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -98,7 +98,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground data-[highlighted]:bg-accent data-[highlighted]:font-medium data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:cursor-default data-[disabled]:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
@@ -122,7 +122,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground data-[highlighted]:bg-accent data-[highlighted]:font-medium data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:cursor-default data-[disabled]:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
+70
-28
@@ -1757,6 +1757,16 @@ type EditorToolbarState = {
|
|||||||
fontName: string
|
fontName: string
|
||||||
fontSize: string
|
fontSize: string
|
||||||
}
|
}
|
||||||
|
type EditorCommand =
|
||||||
|
| "bold"
|
||||||
|
| "italic"
|
||||||
|
| "underline"
|
||||||
|
| "strikeThrough"
|
||||||
|
| "insertUnorderedList"
|
||||||
|
| "insertOrderedList"
|
||||||
|
| "justifyLeft"
|
||||||
|
| "justifyCenter"
|
||||||
|
| "justifyRight"
|
||||||
|
|
||||||
const defaultToolbarState: EditorToolbarState = {
|
const defaultToolbarState: EditorToolbarState = {
|
||||||
bold: false,
|
bold: false,
|
||||||
@@ -1779,6 +1789,7 @@ const composerFontSizeOptions = [
|
|||||||
["5", "大号"],
|
["5", "大号"],
|
||||||
] as const
|
] as const
|
||||||
const composerEmojiOptions = ["😀", "😄", "😊", "🙂", "😉", "😍", "😘", "😎", "🤔", "👍", "👏", "🙏", "💪", "🎉", "🔥", "✨", "❤️", "✅", "📌", "📅", "☕", "💡", "🚀", "⭐"]
|
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) {
|
function queryCommandState(command: string) {
|
||||||
try {
|
try {
|
||||||
@@ -1930,6 +1941,33 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
|
|||||||
updateToolbarState()
|
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() {
|
function updateToolbarState() {
|
||||||
const currentElement = selectionElementInside(editorRef.current)
|
const currentElement = selectionElementInside(editorRef.current)
|
||||||
const computedStyle = currentElement ? window.getComputedStyle(currentElement) : null
|
const computedStyle = currentElement ? window.getComputedStyle(currentElement) : null
|
||||||
@@ -2001,29 +2039,29 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-[420px]">
|
<div className="min-h-[420px]">
|
||||||
<Input ref={fileInputRef} type="file" multiple className="hidden" onChange={handlePickedFiles} />
|
<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" onPointerDownCapture={saveSelection}>
|
<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("undo")}><Undo2 className="h-4 w-4" /></ToolbarButton>
|
||||||
<ToolbarButton label="重做" onClick={() => runCommand("redo")}><Redo2 className="h-4 w-4" /></ToolbarButton>
|
<ToolbarButton label="重做" onClick={() => runCommand("redo")}><Redo2 className="h-4 w-4" /></ToolbarButton>
|
||||||
<Separator orientation="vertical" className="mx-2 h-6" />
|
<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" />} onClick={() => openInsertDialog("image")} />
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button type="button" variant="ghost" size="sm" className="h-8 gap-1 rounded-md px-2 font-normal">
|
<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()}>
|
||||||
<Plus className="h-4 w-4" />插入<ChevronDown className="h-3.5 w-3.5" />
|
<Plus className="h-4 w-4" />插入<ChevronDown className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
<DropdownMenuItem onSelect={() => fileInputRef.current?.click()}><Paperclip className="h-4 w-4" />附件</DropdownMenuItem>
|
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => fileInputRef.current?.click()}><Paperclip className="h-4 w-4" />附件</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => openInsertDialog("link")}><Link className="h-4 w-4" />链接</DropdownMenuItem>
|
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => openInsertDialog("link")}><Link className="h-4 w-4" />链接</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => openInsertDialog("image")}><Image className="h-4 w-4" />图片链接</DropdownMenuItem>
|
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => openInsertDialog("image")}><Image className="h-4 w-4" />图片链接</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => runCommand("insertHorizontalRule")}><span className="h-4 w-4 border-t border-current" aria-hidden />分隔线</DropdownMenuItem>
|
<DropdownMenuItem className={composerMenuItemClass} onSelect={() => runCommand("insertHorizontalRule")}><span className="h-4 w-4 border-t border-current" aria-hidden />分隔线</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<ToolbarTextButton label="导入文档" icon={<FileText className="h-4 w-4" />} onClick={() => fileInputRef.current?.click()} />
|
<ToolbarTextButton label="导入文档" icon={<FileText className="h-4 w-4" />} onClick={() => fileInputRef.current?.click()} />
|
||||||
<ToolbarTextButton label="日程" icon={<Calendar className="h-4 w-4" />} onClick={() => setScheduleOpen(true)} />
|
<ToolbarTextButton label="日程" icon={<Calendar className="h-4 w-4" />} onClick={() => setScheduleOpen(true)} />
|
||||||
<DropdownMenu open={emojiOpen} onOpenChange={setEmojiOpen}>
|
<DropdownMenu open={emojiOpen} onOpenChange={setEmojiOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button type="button" variant={emojiOpen ? "secondary" : "ghost"} size="sm" className="h-8 gap-1.5 rounded-md px-2 font-normal">
|
<Button type="button" variant={emojiOpen ? "secondary" : "ghost"} size="sm" className={cn("h-8 gap-1.5 rounded-md px-2 font-normal hover:bg-accent hover:shadow-sm", emojiOpen && "border border-primary/30 bg-primary/10 text-primary")} onMouseDown={(event) => event.preventDefault()}>
|
||||||
<Smile className="h-4 w-4" />表情
|
<Smile className="h-4 w-4" />表情
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -2044,18 +2082,18 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{formatOpen && (
|
{formatOpen && (
|
||||||
<div className="flex min-h-14 flex-wrap items-center gap-1 border-b bg-muted/40 px-6 py-2" onPointerDownCapture={saveSelection}>
|
<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>
|
<ToolbarButton label="清除格式" onClick={() => runCommand("removeFormat")}><Eraser className="h-4 w-4" /></ToolbarButton>
|
||||||
<Separator orientation="vertical" className="mx-2 h-6" />
|
<Separator orientation="vertical" className="mx-2 h-6" />
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button type="button" variant={normalizeFontName(toolbarState.fontName) ? "secondary" : "ghost"} size="sm" className="h-8 min-w-[112px] justify-between rounded-md px-2 font-normal">
|
<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" />
|
<span className="truncate">{fontLabel(toolbarState.fontName)}</span><ChevronDown className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
{composerFontOptions.map((font) => (
|
{composerFontOptions.map((font) => (
|
||||||
<DropdownMenuItem key={font} onSelect={() => applyFont(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", normalizeFontName(toolbarState.fontName) === font ? "opacity-100" : "opacity-0")} />
|
||||||
<span style={{ fontFamily: font }}>{font}</span>
|
<span style={{ fontFamily: font }}>{font}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -2064,13 +2102,13 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button type="button" variant={toolbarState.fontSize !== "3" ? "secondary" : "ghost"} size="sm" className="h-8 min-w-[84px] justify-between rounded-md px-2 font-normal">
|
<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" />
|
{fontSizeLabel(toolbarState.fontSize)}<ChevronDown className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
{composerFontSizeOptions.map(([size, label]) => (
|
{composerFontSizeOptions.map(([size, label]) => (
|
||||||
<DropdownMenuItem key={size} onSelect={() => applyFontSize(size)}>
|
<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", toolbarState.fontSize === size ? "opacity-100" : "opacity-0")} />
|
||||||
{label}
|
{label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -2078,19 +2116,19 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Separator orientation="vertical" className="mx-2 h-6" />
|
<Separator orientation="vertical" className="mx-2 h-6" />
|
||||||
<ToolbarButton label="加粗" active={toolbarState.bold} onClick={() => runCommand("bold")}><Bold className="h-4 w-4" /></ToolbarButton>
|
<ToolbarButton label="加粗" active={toolbarState.bold} onClick={() => optimisticToggle("bold")}><Bold className="h-4 w-4" /></ToolbarButton>
|
||||||
<ToolbarButton label="斜体" active={toolbarState.italic} onClick={() => runCommand("italic")}><Italic 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={() => runCommand("underline")}><Underline 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={() => runCommand("strikeThrough")}><Strikethrough className="h-4 w-4" /></ToolbarButton>
|
<ToolbarButton label="删除线" active={toolbarState.strikeThrough} onClick={() => optimisticToggle("strikeThrough")}><Strikethrough className="h-4 w-4" /></ToolbarButton>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 rounded-md text-muted-foreground hover:text-foreground" title="文字颜色" aria-label="文字颜色">
|
<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()}>
|
||||||
<Type className="h-4 w-4" />
|
<Type className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-36">
|
<DropdownMenuContent align="start" className="w-36">
|
||||||
{[["#111827", "默认"], ["#dc2626", "红色"], ["#2563eb", "蓝色"], ["#16a34a", "绿色"]].map(([color, label]) => (
|
{[["#111827", "默认"], ["#dc2626", "红色"], ["#2563eb", "蓝色"], ["#16a34a", "绿色"]].map(([color, label]) => (
|
||||||
<DropdownMenuItem key={color} onSelect={() => runCommand("foreColor", color)}>
|
<DropdownMenuItem key={color} className={composerMenuItemClass} onSelect={() => runCommand("foreColor", color)}>
|
||||||
<span className="h-3 w-3 rounded-full border" style={{ backgroundColor: color }} />{label}
|
<span className="h-3 w-3 rounded-full border" style={{ backgroundColor: color }} />{label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
@@ -2098,27 +2136,27 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 rounded-md text-muted-foreground hover:text-foreground" title="高亮" aria-label="高亮">
|
<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()}>
|
||||||
<Highlighter className="h-4 w-4" />
|
<Highlighter className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent align="start">
|
||||||
{[["transparent", "无高亮"], ["#fef3c7", "黄色"], ["#dcfce7", "绿色"], ["#dbeafe", "蓝色"]].map(([color, label]) => (
|
{[["transparent", "无高亮"], ["#fef3c7", "黄色"], ["#dcfce7", "绿色"], ["#dbeafe", "蓝色"]].map(([color, label]) => (
|
||||||
<DropdownMenuItem key={color} onSelect={() => runCommand("backColor", color)}>
|
<DropdownMenuItem key={color} className={composerMenuItemClass} onSelect={() => runCommand("backColor", color)}>
|
||||||
<span className="h-3 w-3 rounded-sm border" style={{ backgroundColor: color }} />{label}
|
<span className="h-3 w-3 rounded-sm border" style={{ backgroundColor: color }} />{label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Separator orientation="vertical" className="mx-2 h-6" />
|
<Separator orientation="vertical" className="mx-2 h-6" />
|
||||||
<ToolbarButton label="无序列表" active={toolbarState.unorderedList} onClick={() => runCommand("insertUnorderedList")}><List className="h-4 w-4" /></ToolbarButton>
|
<ToolbarButton label="无序列表" active={toolbarState.unorderedList} onClick={() => optimisticToggle("insertUnorderedList")}><List className="h-4 w-4" /></ToolbarButton>
|
||||||
<ToolbarButton label="有序列表" active={toolbarState.orderedList} onClick={() => runCommand("insertOrderedList")}><ListOrdered 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("outdent")}><IndentDecrease className="h-4 w-4" /></ToolbarButton>
|
||||||
<ToolbarButton label="增加缩进" onClick={() => runCommand("indent")}><IndentIncrease className="h-4 w-4" /></ToolbarButton>
|
<ToolbarButton label="增加缩进" onClick={() => runCommand("indent")}><IndentIncrease className="h-4 w-4" /></ToolbarButton>
|
||||||
<Separator orientation="vertical" className="mx-2 h-6" />
|
<Separator orientation="vertical" className="mx-2 h-6" />
|
||||||
<ToolbarButton label="左对齐" active={toolbarState.justifyLeft} onClick={() => runCommand("justifyLeft")}><AlignLeft className="h-4 w-4" /></ToolbarButton>
|
<ToolbarButton label="左对齐" active={toolbarState.justifyLeft} onClick={() => optimisticToggle("justifyLeft")}><AlignLeft className="h-4 w-4" /></ToolbarButton>
|
||||||
<ToolbarButton label="居中" active={toolbarState.justifyCenter} onClick={() => runCommand("justifyCenter")}><AlignCenter 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={() => runCommand("justifyRight")}><AlignRight 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", "blockquote")}><Quote className="h-4 w-4" /></ToolbarButton>
|
||||||
<ToolbarButton label="代码块" onClick={() => runCommand("formatBlock", "pre")}><Code2 className="h-4 w-4" /></ToolbarButton>
|
<ToolbarButton label="代码块" onClick={() => runCommand("formatBlock", "pre")}><Code2 className="h-4 w-4" /></ToolbarButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -2164,7 +2202,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
|
|||||||
|
|
||||||
function ToolbarTextButton({ label, icon, active, disabled, onClick }: { label: string; icon: React.ReactNode; active?: boolean; disabled?: boolean; onClick?: () => void }) {
|
function ToolbarTextButton({ label, icon, active, disabled, onClick }: { label: string; icon: React.ReactNode; active?: boolean; disabled?: boolean; onClick?: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Button type="button" variant={active ? "secondary" : "ghost"} size="sm" className="h-8 gap-1.5 rounded-md px-2 font-normal" title={label} aria-label={label} aria-pressed={active || undefined} onClick={onClick} disabled={disabled}>
|
<Button type="button" variant={active ? "secondary" : "ghost"} size="sm" className={cn("h-8 gap-1.5 rounded-md px-2 font-normal transition-all hover:bg-accent hover:text-foreground hover:shadow-sm", active && "border border-primary/30 bg-primary/10 text-primary shadow-sm")} title={label} aria-label={label} aria-pressed={active || undefined} onMouseDown={(event) => event.preventDefault()} onClick={onClick} disabled={disabled}>
|
||||||
{icon}{label}
|
{icon}{label}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
@@ -2384,12 +2422,16 @@ function ToolbarButton({ label, children, active, onClick, disabled }: { label:
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={active ? "secondary" : "ghost"}
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn("h-8 w-8 rounded-md", active ? "text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground")}
|
className={cn(
|
||||||
|
"h-8 w-8 rounded-md border border-transparent text-muted-foreground transition-all hover:border-border hover:bg-accent hover:text-foreground hover:shadow-sm",
|
||||||
|
active && "border-primary/35 bg-primary/10 text-primary shadow-sm hover:bg-primary/15 hover:text-primary"
|
||||||
|
)}
|
||||||
title={label}
|
title={label}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-pressed={active || undefined}
|
aria-pressed={active || undefined}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user