Files
NoteAI/src/components/editor.tsx
T
2026-02-24 09:53:19 +08:00

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>
);
}