添加文章管理和文章分组页面
This commit is contained in:
453
src/components/editor/editorMenuBar.tsx
Normal file
453
src/components/editor/editorMenuBar.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user