"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(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 (
{showAI && (
setShowAI(false)} />
)}
); }