Files
admin/src/components/editor/editorMenuBar.tsx
2026-06-09 16:37:40 +08:00

454 lines
12 KiB
TypeScript

"use client"
import type { Editor } from "@tiptap/react"
import {
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
Bold,
Code,
Code2,
FileCode2,
Heading1,
Heading2,
Heading3,
ImageIcon,
Italic,
LinkIcon,
List,
ListChecks,
ListOrdered,
Minus,
Quote,
Redo,
RemoveFormatting,
Strikethrough,
Table as TableIcon,
Trash2,
Underline as UnderlineIcon,
Undo,
UploadIcon,
YoutubeIcon,
} from "lucide-react"
import { useCallback, useRef, useState } from "react"
import { toast } from "sonner"
import { uploadArticleImage } from "@/actions/article"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
interface EditorMenuBarProps {
editor: Editor | null
onSave?: () => void
saving?: boolean
onImportMdx?: (content: string) => void
}
export default function EditorMenuBar({
editor,
onSave,
saving,
onImportMdx,
}: EditorMenuBarProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const imageInputRef = useRef<HTMLInputElement>(null)
const [imageUploading, setImageUploading] = useState(false)
const handleImageUpload = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!editor) return
const file = e.target.files?.[0]
if (!file) return
setImageUploading(true)
try {
const resp = await uploadArticleImage(file)
if (resp.success) {
editor.chain().focus().setImage({ src: resp.data.url }).run()
toast.success("图片上传成功")
} else {
toast.error(resp.message || "上传失败")
}
} catch {
toast.error("上传失败,网络错误")
} finally {
setImageUploading(false)
e.target.value = ""
}
},
[editor],
)
const addImageByUrl = useCallback(() => {
if (!editor) return
const url = window.prompt("请输入图片链接")
if (url) {
editor.chain().focus().setImage({ src: url }).run()
}
}, [editor])
const addYoutube = useCallback(() => {
if (!editor) return
const url = window.prompt("请输入 YouTube 链接")
if (url) {
editor.chain().focus().setYoutubeVideo({ src: url }).run()
}
}, [editor])
const insertTable = useCallback(() => {
if (!editor) return
editor
.chain()
.focus()
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run()
}, [editor])
const deleteTable = useCallback(() => {
if (!editor) return
editor.chain().focus().deleteTable().run()
}, [editor])
const clearFormatting = useCallback(() => {
if (!editor) return
editor.chain().focus().clearNodes().unsetAllMarks().run()
}, [editor])
if (!editor) return null
const ToolButton = ({
onClick,
isActive,
icon: Icon,
label,
}: {
onClick: () => void
isActive: boolean
icon: React.ComponentType<{ className?: string }>
label: string
}) => (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(isActive && "bg-accent text-accent-foreground")}
onClick={onClick}
>
<Icon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{label}</p>
</TooltipContent>
</Tooltip>
)
return (
<div className="border-b bg-card flex items-center gap-0.5 px-2 py-1 flex-wrap">
<div className="flex items-center gap-0.5">
<ToolButton
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
isActive={editor.isActive("heading", { level: 1 })}
icon={Heading1}
label="标题 1"
/>
<ToolButton
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
isActive={editor.isActive("heading", { level: 2 })}
icon={Heading2}
label="标题 2"
/>
<ToolButton
onClick={() =>
editor.chain().focus().toggleHeading({ level: 3 }).run()
}
isActive={editor.isActive("heading", { level: 3 })}
icon={Heading3}
label="标题 3"
/>
</div>
<div className="w-px h-6 bg-border mx-1" />
<div className="flex items-center gap-0.5">
<ToolButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive("bold")}
icon={Bold}
label="加粗"
/>
<ToolButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive("italic")}
icon={Italic}
label="斜体"
/>
<ToolButton
onClick={() => editor.chain().focus().toggleUnderline().run()}
isActive={editor.isActive("underline")}
icon={UnderlineIcon}
label="下划线"
/>
<ToolButton
onClick={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive("strike")}
icon={Strikethrough}
label="删除线"
/>
<ToolButton
onClick={() => editor.chain().focus().toggleCode().run()}
isActive={editor.isActive("code")}
icon={Code}
label="行内代码"
/>
<ToolButton
onClick={clearFormatting}
isActive={false}
icon={RemoveFormatting}
label="清除格式"
/>
</div>
<div className="w-px h-6 bg-border mx-1" />
<div className="flex items-center gap-0.5">
<ToolButton
onClick={() => editor.chain().focus().setTextAlign("left").run()}
isActive={editor.isActive({ textAlign: "left" })}
icon={AlignLeft}
label="左对齐"
/>
<ToolButton
onClick={() => editor.chain().focus().setTextAlign("center").run()}
isActive={editor.isActive({ textAlign: "center" })}
icon={AlignCenter}
label="居中对齐"
/>
<ToolButton
onClick={() => editor.chain().focus().setTextAlign("right").run()}
isActive={editor.isActive({ textAlign: "right" })}
icon={AlignRight}
label="右对齐"
/>
<ToolButton
onClick={() => editor.chain().focus().setTextAlign("justify").run()}
isActive={editor.isActive({ textAlign: "justify" })}
icon={AlignJustify}
label="两端对齐"
/>
</div>
<div className="w-px h-6 bg-border mx-1" />
<div className="flex items-center gap-0.5">
<ToolButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive("bulletList")}
icon={List}
label="无序列表"
/>
<ToolButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive("orderedList")}
icon={ListOrdered}
label="有序列表"
/>
<ToolButton
onClick={() => editor.chain().focus().toggleTaskList().run()}
isActive={editor.isActive("taskList")}
icon={ListChecks}
label="任务列表"
/>
<ToolButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive("blockquote")}
icon={Quote}
label="引用"
/>
<ToolButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
isActive={editor.isActive("codeBlock")}
icon={Code2}
label="代码块"
/>
</div>
<div className="w-px h-6 bg-border mx-1" />
<div className="flex items-center gap-0.5">
<ToolButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
isActive={false}
icon={Minus}
label="分割线"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={imageUploading}
title="图片"
>
{imageUploading ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<ImageIcon className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => imageInputRef.current?.click()}
disabled={imageUploading}
>
<UploadIcon className="h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={addImageByUrl}>
<LinkIcon className="h-4 w-4" />
<span></span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ToolButton
onClick={addYoutube}
isActive={false}
icon={YoutubeIcon}
label="视频"
/>
<ToolButton
onClick={insertTable}
isActive={editor.isActive("table")}
icon={TableIcon}
label="插入表格"
/>
{editor.isActive("table") && (
<ToolButton
onClick={deleteTable}
isActive={false}
icon={Trash2}
label="删除表格"
/>
)}
</div>
<div className="w-px h-6 bg-border mx-1" />
<div className="flex items-center gap-0.5">
<ToolButton
onClick={() => editor.chain().focus().undo().run()}
isActive={false}
icon={Undo}
label="撤销"
/>
<ToolButton
onClick={() => editor.chain().focus().redo().run()}
isActive={false}
icon={Redo}
label="重做"
/>
</div>
{onImportMdx && (
<>
<div className="w-px h-6 bg-border mx-1" />
<div className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => {
const text = window.prompt("请粘贴 MDX / Markdown 内容")
if (text) {
onImportMdx(text)
}
}}
>
<FileCode2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p> MDX</p>
</TooltipContent>
</Tooltip>
<input
ref={fileInputRef}
type="file"
accept=".md,.mdx,.markdown"
className="hidden"
onChange={e => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
const text = reader.result as string
if (text && onImportMdx) {
onImportMdx(text)
}
}
reader.readAsText(file)
e.target.value = ""
}}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => fileInputRef.current?.click()}
>
<span className="text-xs font-mono font-bold">.MDX</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p> MDX </p>
</TooltipContent>
</Tooltip>
</div>
</>
)}
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
/>
{onSave && (
<>
<div className="flex-1" />
<div className="flex items-center gap-0.5">
<Button type="button" size="sm" onClick={onSave} disabled={saving}>
{saving ? "保存中..." : "保存"}
</Button>
</div>
</>
)}
</div>
)
}