454 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|