43e1c2f61d
写作体验优化: - 自动保存草稿到 localStorage(debounce 2s,刷新不丢内容) - 浏览器原生全屏专注写作模式 - Markdown 编辑模式(左右分栏,实时预览) - 快捷键:Ctrl+S / Ctrl+Enter 保存,ESC 退出全屏 封面图功能: - PostForm 新增封面图 URL 输入 + 实时预览 - BlogList 文章卡片显示封面缩略图 - PostContent 文章详情页显示封面大图 AI 辅助写作: - OpenAI 兼容接口,SSE 流式返回 - 8 种预设操作:润色、扩写、精简、续写、纠错、翻译、摘要 - 自定义指令输入,结果可复制/替换/追加 其他: - 后台文章列表改为一页10篇 - 前台 /blog 页面添加分页功能(一页10篇)
297 lines
8.5 KiB
TypeScript
297 lines
8.5 KiB
TypeScript
"use client";
|
|
|
|
import { useEditor, EditorContent } from "@tiptap/react";
|
|
import StarterKit from "@tiptap/starter-kit";
|
|
import Image from "@tiptap/extension-image";
|
|
import Link from "@tiptap/extension-link";
|
|
import Placeholder from "@tiptap/extension-placeholder";
|
|
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
|
import { common, createLowlight } from "lowlight";
|
|
import { Toggle } from "@/components/ui/toggle";
|
|
import dynamic from "next/dynamic";
|
|
import {
|
|
Bold,
|
|
Italic,
|
|
Strikethrough,
|
|
Code,
|
|
Heading1,
|
|
Heading2,
|
|
Heading3,
|
|
Quote,
|
|
List,
|
|
ListOrdered,
|
|
Link as LinkIcon,
|
|
ImageIcon,
|
|
CodeSquare,
|
|
Minus,
|
|
Undo2,
|
|
Redo2,
|
|
Maximize2,
|
|
Minimize2,
|
|
FileText,
|
|
} from "lucide-react";
|
|
|
|
const MarkdownEditor = dynamic(() => import("./MarkdownEditor"), { ssr: false });
|
|
|
|
const lowlight = createLowlight(common);
|
|
|
|
interface RichEditorProps {
|
|
value: string;
|
|
onChange: (html: string) => void;
|
|
placeholder?: string;
|
|
isFullscreen?: boolean;
|
|
isMarkdown?: boolean;
|
|
onToggleFullscreen?: () => void;
|
|
onSwitchToMarkdown?: () => void;
|
|
}
|
|
|
|
export default function RichEditor({
|
|
value,
|
|
onChange,
|
|
placeholder = "开始写文章...",
|
|
isFullscreen,
|
|
isMarkdown,
|
|
onToggleFullscreen,
|
|
onSwitchToMarkdown,
|
|
}: RichEditorProps) {
|
|
const editor = useEditor({
|
|
extensions: [
|
|
StarterKit.configure({
|
|
codeBlock: false,
|
|
}),
|
|
Image.configure({
|
|
HTMLAttributes: { class: "rounded-lg my-4" },
|
|
}),
|
|
Link.configure({
|
|
openOnClick: false,
|
|
HTMLAttributes: {
|
|
class: "text-primary underline underline-offset-2",
|
|
},
|
|
}),
|
|
Placeholder.configure({ placeholder }),
|
|
CodeBlockLowlight.configure({ lowlight }),
|
|
],
|
|
content: value,
|
|
onUpdate: ({ editor }) => {
|
|
onChange(editor.getHTML());
|
|
},
|
|
editorProps: {
|
|
attributes: {
|
|
class: "prose-literary min-h-[300px] px-4 py-3 focus:outline-none",
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!editor) return null;
|
|
|
|
function addLink() {
|
|
const url = window.prompt("输入链接地址:");
|
|
if (url) {
|
|
editor
|
|
.chain()
|
|
.focus()
|
|
.extendMarkRange("link")
|
|
.setLink({ href: url })
|
|
.run();
|
|
}
|
|
}
|
|
|
|
function addImage() {
|
|
const url = window.prompt("输入图片地址:");
|
|
if (url) {
|
|
editor.chain().focus().setImage({ src: url }).run();
|
|
}
|
|
}
|
|
|
|
const isFs = !!isFullscreen;
|
|
|
|
return (
|
|
<div className={isFs ? "flex flex-col h-full bg-transparent" : "rounded-xl border border-border bg-card overflow-hidden"}>
|
|
{/* 工具栏 — 始终显示 */}
|
|
<div className={`flex flex-wrap items-center gap-0.5 border-b border-border/50 shrink-0 ${isFs ? "px-4 py-2.5" : "px-2 py-1.5 bg-muted/30"}`}>
|
|
{/* 富文本格式按钮 — Markdown 模式下禁用 */}
|
|
<Toggle
|
|
size="sm"
|
|
pressed={editor.isActive("heading", { level: 1 })}
|
|
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
|
disabled={!!isMarkdown}
|
|
aria-label="标题 1"
|
|
>
|
|
<Heading1 className="h-4 w-4" />
|
|
</Toggle>
|
|
<Toggle
|
|
size="sm"
|
|
pressed={editor.isActive("heading", { level: 2 })}
|
|
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
|
disabled={!!isMarkdown}
|
|
aria-label="标题 2"
|
|
>
|
|
<Heading2 className="h-4 w-4" />
|
|
</Toggle>
|
|
<Toggle
|
|
size="sm"
|
|
pressed={editor.isActive("heading", { level: 3 })}
|
|
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
|
disabled={!!isMarkdown}
|
|
aria-label="标题 3"
|
|
>
|
|
<Heading3 className="h-4 w-4" />
|
|
</Toggle>
|
|
|
|
<div className="w-px h-5 bg-border mx-1" />
|
|
|
|
<Toggle
|
|
size="sm"
|
|
pressed={editor.isActive("bold")}
|
|
onPressedChange={() => editor.chain().focus().toggleBold().run()}
|
|
disabled={!!isMarkdown}
|
|
aria-label="粗体"
|
|
>
|
|
<Bold className="h-4 w-4" />
|
|
</Toggle>
|
|
<Toggle
|
|
size="sm"
|
|
pressed={editor.isActive("italic")}
|
|
onPressedChange={() => editor.chain().focus().toggleItalic().run()}
|
|
disabled={!!isMarkdown}
|
|
aria-label="斜体"
|
|
>
|
|
<Italic className="h-4 w-4" />
|
|
</Toggle>
|
|
<Toggle
|
|
size="sm"
|
|
pressed={editor.isActive("strike")}
|
|
onPressedChange={() => editor.chain().focus().toggleStrike().run()}
|
|
disabled={!!isMarkdown}
|
|
aria-label="删除线"
|
|
>
|
|
<Strikethrough className="h-4 w-4" />
|
|
</Toggle>
|
|
<Toggle
|
|
size="sm"
|
|
pressed={editor.isActive("code")}
|
|
onPressedChange={() => editor.chain().focus().toggleCode().run()}
|
|
disabled={!!isMarkdown}
|
|
aria-label="行内代码"
|
|
>
|
|
<Code className="h-4 w-4" />
|
|
</Toggle>
|
|
|
|
<div className="w-px h-5 bg-border mx-1" />
|
|
|
|
<Toggle
|
|
size="sm"
|
|
pressed={editor.isActive("blockquote")}
|
|
onPressedChange={() => editor.chain().focus().toggleBlockquote().run()}
|
|
disabled={!!isMarkdown}
|
|
aria-label="引用"
|
|
>
|
|
<Quote className="h-4 w-4" />
|
|
</Toggle>
|
|
<Toggle
|
|
size="sm"
|
|
pressed={editor.isActive("bulletList")}
|
|
onPressedChange={() => editor.chain().focus().toggleBulletList().run()}
|
|
disabled={!!isMarkdown}
|
|
aria-label="无序列表"
|
|
>
|
|
<List className="h-4 w-4" />
|
|
</Toggle>
|
|
<Toggle
|
|
size="sm"
|
|
pressed={editor.isActive("orderedList")}
|
|
onPressedChange={() => editor.chain().focus().toggleOrderedList().run()}
|
|
disabled={!!isMarkdown}
|
|
aria-label="有序列表"
|
|
>
|
|
<ListOrdered className="h-4 w-4" />
|
|
</Toggle>
|
|
|
|
<div className="w-px h-5 bg-border mx-1" />
|
|
|
|
<Toggle
|
|
size="sm"
|
|
pressed={editor.isActive("codeBlock")}
|
|
onPressedChange={() => editor.chain().focus().toggleCodeBlock().run()}
|
|
disabled={!!isMarkdown}
|
|
aria-label="代码块"
|
|
>
|
|
<CodeSquare className="h-4 w-4" />
|
|
</Toggle>
|
|
<Toggle size="sm" onPressedChange={addLink} disabled={!!isMarkdown} aria-label="链接">
|
|
<LinkIcon className="h-4 w-4" />
|
|
</Toggle>
|
|
<Toggle size="sm" onPressedChange={addImage} disabled={!!isMarkdown} aria-label="图片">
|
|
<ImageIcon className="h-4 w-4" />
|
|
</Toggle>
|
|
<Toggle
|
|
size="sm"
|
|
onPressedChange={() => editor.chain().focus().setHorizontalRule().run()}
|
|
disabled={!!isMarkdown}
|
|
aria-label="分割线"
|
|
>
|
|
<Minus className="h-4 w-4" />
|
|
</Toggle>
|
|
|
|
<div className="w-px h-5 bg-border mx-1" />
|
|
|
|
<Toggle
|
|
size="sm"
|
|
onPressedChange={() => editor.chain().focus().undo().run()}
|
|
disabled={!!isMarkdown || !editor.can().undo()}
|
|
aria-label="撤销"
|
|
>
|
|
<Undo2 className="h-4 w-4" />
|
|
</Toggle>
|
|
<Toggle
|
|
size="sm"
|
|
onPressedChange={() => editor.chain().focus().redo().run()}
|
|
disabled={!!isMarkdown || !editor.can().redo()}
|
|
aria-label="重做"
|
|
>
|
|
<Redo2 className="h-4 w-4" />
|
|
</Toggle>
|
|
|
|
<div className="flex-1" />
|
|
|
|
{/* Markdown 切换 */}
|
|
{onSwitchToMarkdown && (
|
|
<Toggle
|
|
size="sm"
|
|
pressed={!!isMarkdown}
|
|
onPressedChange={onSwitchToMarkdown}
|
|
aria-label={isMarkdown ? "切换到富文本" : "Markdown 模式"}
|
|
title={isMarkdown ? "切换到富文本" : "Markdown 模式"}
|
|
>
|
|
<FileText className="h-4 w-4" />
|
|
</Toggle>
|
|
)}
|
|
|
|
{/* 全屏切换 */}
|
|
{onToggleFullscreen && (
|
|
<Toggle
|
|
size="sm"
|
|
onPressedChange={onToggleFullscreen}
|
|
aria-label={isFullscreen ? "退出全屏" : "全屏写作"}
|
|
title={isFullscreen ? "退出全屏" : "全屏写作"}
|
|
>
|
|
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
|
</Toggle>
|
|
)}
|
|
</div>
|
|
|
|
{/* 内容区域 — 根据模式切换 */}
|
|
<div className={isFs ? "flex-1 overflow-auto" : ""}>
|
|
{isMarkdown ? (
|
|
<MarkdownEditor
|
|
value={value}
|
|
onChange={onChange}
|
|
/>
|
|
) : (
|
|
<EditorContent editor={editor} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|