291 lines
10 KiB
TypeScript
291 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useEditor, EditorContent, ReactNodeViewRenderer } from "@tiptap/react";
|
|
import StarterKit from "@tiptap/starter-kit";
|
|
import { useEffect, useState, useRef } from "react";
|
|
import { AIAssist } from "./ai-assist";
|
|
import { Sparkles } from "lucide-react";
|
|
import { Toolbar } from "./editor/toolbar";
|
|
import { SlashCommand, getSuggestionItems, renderSuggestionItems } from "./editor/slash-command";
|
|
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
|
import { lowlight } from 'lowlight';
|
|
import { CodeBlockComponent } from "./editor/code-block";
|
|
import Link from "@tiptap/extension-link";
|
|
|
|
import Underline from "@tiptap/extension-underline";
|
|
import Subscript from "@tiptap/extension-subscript";
|
|
import Superscript from "@tiptap/extension-superscript";
|
|
import Highlight from "@tiptap/extension-highlight";
|
|
import TaskList from "@tiptap/extension-task-list";
|
|
import TaskItem from "@tiptap/extension-task-item";
|
|
import { Callout } from "./editor/extensions/callout";
|
|
import { AIMark } from "./editor/extensions/ai-mark";
|
|
import { TaskItemComponent } from "./editor/extensions/task-item";
|
|
import { useSettingsStore } from "@/lib/settings-store";
|
|
import { Table } from "@tiptap/extension-table";
|
|
import TableRow from "@tiptap/extension-table-row";
|
|
import TableCell from "@tiptap/extension-table-cell";
|
|
import TableHeader from "@tiptap/extension-table-header";
|
|
import Image from "@tiptap/extension-image";
|
|
import Youtube from "@tiptap/extension-youtube";
|
|
import TextAlign from "@tiptap/extension-text-align";
|
|
import Gapcursor from "@tiptap/extension-gapcursor";
|
|
import { Markdown } from 'tiptap-markdown';
|
|
|
|
interface EditorProps {
|
|
content: string;
|
|
onChange: (content: string) => void;
|
|
onEditorReady?: (editor: any) => void;
|
|
onToggleAI?: () => void;
|
|
onExport?: () => void;
|
|
editable?: boolean;
|
|
}
|
|
|
|
export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, editable = true }: EditorProps) {
|
|
const [showAI, setShowAI] = useState(false);
|
|
const [isGenerating, setIsGenerating] = useState(false);
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
const { fontFamily, fontSize } = useSettingsStore();
|
|
|
|
const editor = useEditor({
|
|
extensions: [
|
|
Gapcursor,
|
|
StarterKit.configure({
|
|
heading: {
|
|
levels: [1, 2, 3],
|
|
},
|
|
codeBlock: false,
|
|
bulletList: {
|
|
keepMarks: true,
|
|
keepAttributes: false,
|
|
},
|
|
orderedList: {
|
|
keepMarks: true,
|
|
keepAttributes: false,
|
|
},
|
|
}),
|
|
SlashCommand.configure({
|
|
suggestion: {
|
|
items: getSuggestionItems,
|
|
render: renderSuggestionItems,
|
|
},
|
|
}),
|
|
CodeBlockLowlight
|
|
.extend({
|
|
addNodeView() {
|
|
return ReactNodeViewRenderer(CodeBlockComponent)
|
|
}
|
|
})
|
|
.configure({ lowlight, defaultLanguage: 'plaintext' }),
|
|
Link.configure({
|
|
openOnClick: false,
|
|
HTMLAttributes: {
|
|
class: 'cursor-pointer text-blue-600 dark:text-blue-400 hover:underline hover:text-blue-800 dark:hover:text-blue-300 transition-colors',
|
|
},
|
|
}),
|
|
Underline,
|
|
Subscript,
|
|
Superscript,
|
|
Highlight.configure({
|
|
multicolor: true,
|
|
}),
|
|
TaskList,
|
|
TaskItem.configure({
|
|
nested: true,
|
|
}).extend({
|
|
addNodeView() {
|
|
return ReactNodeViewRenderer(TaskItemComponent)
|
|
}
|
|
}),
|
|
Callout,
|
|
AIMark,
|
|
Table.configure({
|
|
resizable: true,
|
|
}),
|
|
TableRow,
|
|
TableHeader,
|
|
TableCell,
|
|
Image.configure({
|
|
inline: true,
|
|
allowBase64: true,
|
|
}),
|
|
Youtube.configure({
|
|
controls: false,
|
|
}),
|
|
TextAlign.configure({
|
|
types: ['heading', 'paragraph'],
|
|
}),
|
|
Markdown.configure({
|
|
html: true, // Allow HTML input/output
|
|
transformPastedText: true, // Auto-transform pasted markdown
|
|
transformCopiedText: true, // Auto-transform copied markdown
|
|
})
|
|
],
|
|
content: content,
|
|
onUpdate: ({ editor }) => {
|
|
if (editor.getHTML() !== content) {
|
|
onChange(editor.getHTML());
|
|
}
|
|
},
|
|
editorProps: {
|
|
attributes: {
|
|
class: "prose prose-zinc dark:prose-invert max-w-none focus:outline-none min-h-[300px]",
|
|
style: `font-family: ${fontFamily}; font-size: ${fontSize}px; --editor-line-height: ${useSettingsStore.getState().lineHeight}; --table-line-height: ${useSettingsStore.getState().tableLineHeight};`,
|
|
spellcheck: "false",
|
|
},
|
|
},
|
|
editable: editable,
|
|
immediatelyRender: false,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (editor) {
|
|
editor.setEditable(editable);
|
|
}
|
|
}, [editor, editable]);
|
|
|
|
useEffect(() => {
|
|
if (editor && content !== editor.getHTML()) {
|
|
queueMicrotask(() => {
|
|
editor.commands.setContent(content);
|
|
});
|
|
}
|
|
if (editor && onEditorReady) {
|
|
onEditorReady(editor);
|
|
}
|
|
}, [content, editor, onEditorReady]);
|
|
|
|
const handleStopAI = () => {
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
abortControllerRef.current = null;
|
|
}
|
|
setIsGenerating(false);
|
|
};
|
|
|
|
const handleAISuggest = async (prompt: any) => {
|
|
if (!editor) return;
|
|
|
|
const { aiConfig } = useSettingsStore.getState();
|
|
if (!aiConfig.apiKey) {
|
|
alert("请先在设置中配置 AI API Key");
|
|
return;
|
|
}
|
|
|
|
const { from, to } = editor.state.selection;
|
|
const selectedText = editor.state.doc.textBetween(from, to, " ");
|
|
|
|
// Custom prompt logic
|
|
const systemPrompt = prompt.systemPrompt || "You are a helpful assistant.";
|
|
let userPrompt = selectedText;
|
|
|
|
if (!userPrompt) {
|
|
const pos = from;
|
|
userPrompt = editor.state.doc.textBetween(Math.max(0, pos - 1000), pos, "\n");
|
|
}
|
|
|
|
setShowAI(false);
|
|
setIsGenerating(true);
|
|
abortControllerRef.current = new AbortController();
|
|
|
|
try {
|
|
const response = await fetch("/api/ai/chat", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
config: aiConfig,
|
|
messages: [
|
|
{ role: "system", content: systemPrompt },
|
|
{ role: "user", content: userPrompt }
|
|
]
|
|
}),
|
|
signal: abortControllerRef.current.signal
|
|
});
|
|
|
|
if (!response.ok || !response.body) {
|
|
throw new Error(await response.text());
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
|
|
editor.chain().focus().insertContent("\n\n").toggleMark('aiMark').run();
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
const chunk = decoder.decode(value);
|
|
const lines = chunk.split('\n');
|
|
for (const line of lines) {
|
|
if (line.startsWith('data: ') && line !== 'data: [DONE]') {
|
|
try {
|
|
const data = JSON.parse(line.slice(6));
|
|
const content = data.choices[0]?.delta?.content;
|
|
if (content) {
|
|
editor.commands.insertContent(content);
|
|
}
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
}
|
|
editor.chain().focus().insertContent("\n\n").unsetMark('aiMark').run();
|
|
|
|
} catch (e: any) {
|
|
if (e.name === 'AbortError') {
|
|
editor.chain().focus().insertContent(" [已停止]").unsetMark('aiMark').run();
|
|
} else {
|
|
console.error("AI Error", e);
|
|
alert("AI 请求失败,请检查配置或网络");
|
|
}
|
|
} finally {
|
|
setIsGenerating(false);
|
|
abortControllerRef.current = null;
|
|
}
|
|
};
|
|
|
|
if (!editor) return null;
|
|
|
|
return (
|
|
<div className="relative group/editor flex flex-col min-h-full">
|
|
<Toolbar editor={editor} onToggleAI={onToggleAI} onExport={onExport} />
|
|
|
|
{showAI && (
|
|
<div className="absolute top-12 right-4 z-50">
|
|
<AIAssist
|
|
isOpen={showAI}
|
|
onSuggest={handleAISuggest}
|
|
onClose={() => setShowAI(false)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1 mt-4">
|
|
<EditorContent editor={editor} />
|
|
</div>
|
|
|
|
<button
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
if (isGenerating) {
|
|
handleStopAI();
|
|
} else {
|
|
setShowAI(!showAI);
|
|
}
|
|
}}
|
|
className={`fixed bottom-8 right-8 p-3 rounded-full shadow-lg hover:scale-110 transition-transform z-40 ${isGenerating
|
|
? "bg-red-500 text-white animate-pulse"
|
|
: "bg-primary text-primary-foreground"
|
|
}`}
|
|
title={isGenerating ? "停止生成 (Stop)" : "AI 助手 (AI Assist)"}
|
|
>
|
|
{isGenerating ? <div className="h-5 w-5 bg-current rounded-sm" /> : <Sparkles size={20} />}
|
|
</button>
|
|
|
|
|
|
</div>
|
|
);
|
|
}
|