import { useCallback, useEffect, useRef, useState } from 'react' import { AnimatePresence, motion } from 'framer-motion' import { useEditor, EditorContent } from '@tiptap/react' import { BubbleMenu } from '@tiptap/extension-bubble-menu' import StarterKit from '@tiptap/starter-kit' import Placeholder from '@tiptap/extension-placeholder' import Underline from '@tiptap/extension-underline' import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' 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 CharacterCount from '@tiptap/extension-character-count' import Typography from '@tiptap/extension-typography' import Link from '@tiptap/extension-link' import Highlight from '@tiptap/extension-highlight' import { TextStyle } from '@tiptap/extension-text-style' import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight' import { Image } from '@tiptap/extension-image' import { createLowlight, common } from 'lowlight' import { Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code, Highlighter, List, ListOrdered, Quote, Heading1, Heading2, Heading3, Minus, CheckSquare, Table as TableIcon, Type, Star, ImageIcon, Maximize2, Minimize2, Hash, X, Sparkles, Zap, Wand2, FileText as FileTextIcon, Square, Languages, } from 'lucide-react' import { useAppStore } from '../../stores/appStore' import { countWords } from '../../lib/utils' import { streamAI } from '../../lib/ai' import { WelcomeView } from './WelcomeView' import { ExportMenu } from './ExportMenu' const lowlight = createLowlight(common) // Slash command items const SLASH_ITEMS = [ { label: '正文', desc: '普通段落', icon: , action: (e: ReturnType) => e?.chain().focus().setParagraph().run() }, { label: '标题 1', desc: '大标题', icon: , action: (e: ReturnType) => e?.chain().focus().setHeading({ level: 1 }).run() }, { label: '标题 2', desc: '中标题', icon: , action: (e: ReturnType) => e?.chain().focus().setHeading({ level: 2 }).run() }, { label: '标题 3', desc: '小标题', icon: , action: (e: ReturnType) => e?.chain().focus().setHeading({ level: 3 }).run() }, { label: '无序列表', desc: '• 项目', icon: , action: (e: ReturnType) => e?.chain().focus().toggleBulletList().run() }, { label: '有序列表', desc: '1. 项目', icon: , action: (e: ReturnType) => e?.chain().focus().toggleOrderedList().run() }, { label: '任务列表', desc: '☑ 待办', icon: , action: (e: ReturnType) => e?.chain().focus().toggleTaskList().run() }, { label: '引用', desc: '引用文本', icon: , action: (e: ReturnType) => e?.chain().focus().toggleBlockquote().run() }, { label: '代码块', desc: '含语法高亮', icon: , action: (e: ReturnType) => e?.chain().focus().toggleCodeBlock().run() }, { label: '图片', desc: '插入图片 URL', icon: , action: (e: ReturnType) => { const url = window.prompt('图片地址(URL)') if (url) e?.chain().focus().setImage({ src: url }).run() }}, { label: '分割线', desc: '插入水平分割线', icon: , action: (e: ReturnType) => e?.chain().focus().setHorizontalRule().run() }, { label: '表格', desc: '插入表格', icon: , action: (e: ReturnType) => e?.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() }, ] type SlashItem = typeof SLASH_ITEMS[0] function SlashMenu({ items, selectedIndex, onSelect }: { items: SlashItem[] selectedIndex: number onSelect: (item: SlashItem) => void }) { return (
{items.map((item, i) => (
{ e.preventDefault(); onSelect(item) }} >
{item.icon}
{item.label}
{item.desc}
))}
) } export function Editor() { const { activeNoteId, notes, updateNote, toggleStar, focusMode, toggleFocusMode, toggleAiPanel, aiPanelOpen } = useAppStore() const activeNote = notes.find(n => n.id === activeNoteId) const [title, setTitle] = useState(activeNote?.title ?? '') const [wordCount, setWordCount] = useState(0) const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved') const [tagInput, setTagInput] = useState('') // AI continue state const [aiContinuing, setAiContinuing] = useState(false) const aiAbortRef = useRef(null) // AI modal state const [aiModal, setAiModal] = useState<{ type: 'polish' | 'summarize' | 'translate' from: number to: number selection: string result: string streaming: boolean } | null>(null) const aiModalAbortRef = useRef(null) const [slashOpen, setSlashOpen] = useState(false) const [slashQuery, setSlashQuery] = useState('') const [slashIndex, setSlashIndex] = useState(0) const [slashPos, setSlashPos] = useState({ top: 0, left: 0 }) const slashStartPos = useRef(null) const saveTimer = useRef | null>(null) const titleTimer = useRef | null>(null) const isLoadingRef = useRef(false) const filteredSlash = SLASH_ITEMS.filter(item => item.label.toLowerCase().includes(slashQuery.toLowerCase()) ) // Exit focus mode with Escape useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === 'Escape' && focusMode) toggleFocusMode() } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [focusMode, toggleFocusMode]) const scheduleNoteSave = useCallback((content: string, wc: number) => { if (!activeNoteId || isLoadingRef.current) return setSaveStatus('unsaved') if (saveTimer.current) clearTimeout(saveTimer.current) saveTimer.current = setTimeout(async () => { setSaveStatus('saving') await updateNote(activeNoteId, { content, wordCount: wc }) setSaveStatus('saved') }, 1000) }, [activeNoteId, updateNote]) const editor = useEditor({ extensions: [ StarterKit.configure({ codeBlock: false }), Placeholder.configure({ placeholder: '开始写作,或输入 / 呼出命令菜单…' }), Underline, TaskList, TaskItem.configure({ nested: true }), Table.configure({ resizable: false }), TableRow, TableCell, TableHeader, CharacterCount, Typography, Highlight.configure({ multicolor: false }), TextStyle, Link.configure({ openOnClick: false }), BubbleMenu.configure({ pluginKey: 'bubbleMenu' }), CodeBlockLowlight.configure({ lowlight }), Image.configure({ allowBase64: true }), ], content: activeNote?.content ? JSON.parse(activeNote.content) : '', onUpdate({ editor: ed }) { if (isLoadingRef.current) return const json = JSON.stringify(ed.getJSON()) const text = ed.getText() const wc = countWords(text) setWordCount(wc) scheduleNoteSave(json, wc) checkSlashCommand(ed) }, editorProps: { handleKeyDown(_view, event) { if (slashOpen) { if (event.key === 'ArrowDown') { setSlashIndex(i => (i + 1) % filteredSlash.length) return true } if (event.key === 'ArrowUp') { setSlashIndex(i => (i - 1 + filteredSlash.length) % filteredSlash.length) return true } if (event.key === 'Enter') { const item = filteredSlash[slashIndex] if (item && editor) executeSlash(item) return true } if (event.key === 'Escape') { if (slashStartPos.current !== null && editor) { const { state, dispatch } = editor.view const { from } = state.selection const tr = state.tr.delete(slashStartPos.current, from) dispatch(tr) } setSlashOpen(false) slashStartPos.current = null return true } } return false }, // Support drag-and-drop image files handleDrop(_view, event) { const files = event.dataTransfer?.files if (!files?.length) return false const file = files[0] if (!file.type.startsWith('image/')) return false event.preventDefault() const reader = new FileReader() reader.onload = () => { if (editor && typeof reader.result === 'string') { editor.chain().focus().setImage({ src: reader.result }).run() } } reader.readAsDataURL(file) return true }, // Support paste image handlePaste(_view, event) { const items = event.clipboardData?.items if (!items) return false for (const item of Array.from(items)) { if (item.type.startsWith('image/')) { const file = item.getAsFile() if (!file) continue event.preventDefault() const reader = new FileReader() reader.onload = () => { if (editor && typeof reader.result === 'string') { editor.chain().focus().setImage({ src: reader.result }).run() } } reader.readAsDataURL(file) return true } } return false }, }, }) function checkSlashCommand(ed: NonNullable>) { const { state } = ed const { selection } = state const { $from } = selection const textBefore = $from.nodeBefore?.text ?? '' const slashIdx = textBefore.lastIndexOf('/') if (slashIdx >= 0) { const query = textBefore.slice(slashIdx + 1) if (!query.includes(' ')) { if (slashStartPos.current === null) { slashStartPos.current = $from.pos - textBefore.length + slashIdx } setSlashQuery(query) setSlashIndex(0) const coords = ed.view.coordsAtPos($from.pos) const editorEl = ed.view.dom.closest('.editor-scroll') if (editorEl) { const rect = editorEl.getBoundingClientRect() setSlashPos({ top: coords.bottom - rect.top + 4, left: coords.left - rect.left }) } setSlashOpen(true) return } } setSlashOpen(false) slashStartPos.current = null } function executeSlash(item: SlashItem) { if (!editor) return const startPos = slashStartPos.current if (startPos !== null) { const { state, dispatch } = editor.view const { from } = state.selection const tr = state.tr.delete(startPos, from) dispatch(tr) } item.action(editor) setSlashOpen(false) slashStartPos.current = null } useEffect(() => { if (!editor || !activeNote) return isLoadingRef.current = true const content = activeNote.content ? JSON.parse(activeNote.content) : { type: 'doc', content: [{ type: 'paragraph' }] } editor.commands.setContent(content) setTitle(activeNote.title) setWordCount(activeNote.wordCount) setSaveStatus('saved') setSlashOpen(false) setTagInput('') setTimeout(() => { isLoadingRef.current = false }, 100) }, [activeNoteId, editor]) const handleTitleChange = (val: string) => { setTitle(val) if (!activeNoteId) return if (titleTimer.current) clearTimeout(titleTimer.current) titleTimer.current = setTimeout(() => { updateNote(activeNoteId, { title: val }) }, 500) } const handleAiContinue = async () => { if (!editor || !activeNote) return if (aiContinuing) { aiAbortRef.current?.abort(); return } const controller = new AbortController() aiAbortRef.current = controller setAiContinuing(true) try { await streamAI( { type: 'continue', noteContent: editor.getText() }, (chunk) => { editor.commands.insertContent(chunk) }, controller.signal, ) } catch (e) { if ((e as Error).name === 'AbortError') return } finally { setAiContinuing(false) aiAbortRef.current = null } } const handleAiAction = async (type: 'polish' | 'summarize' | 'translate', from: number, to: number) => { if (!editor || !activeNote) return const selection = editor.state.doc.textBetween(from, to, '\n') const controller = new AbortController() aiModalAbortRef.current = controller setAiModal({ type, from, to, selection, result: '', streaming: true }) try { await streamAI( { type, noteContent: editor.getText(), selection }, (chunk) => { setAiModal(prev => prev ? { ...prev, result: prev.result + chunk } : null) }, controller.signal, ) } catch (e) { if ((e as Error).name !== 'AbortError') { setAiModal(prev => prev ? { ...prev, result: '❌ 请求失败', streaming: false } : null) return } } setAiModal(prev => prev ? { ...prev, streaming: false } : null) } if (!activeNote || activeNoteId === '__welcome__') { return } const readingTime = wordCount > 0 ? Math.max(1, Math.ceil(wordCount / 250)) : 0 return (
{/* Title row */}
handleTitleChange(e.target.value)} placeholder="无标题" className="flex-1 bg-transparent outline-none text-3xl font-bold min-w-0" style={{ color: 'var(--text)', letterSpacing: '-0.02em' }} />
{/* Tag row */}
{activeNote.tags.map(tag => ( {tag} ))} setTagInput(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault() const val = tagInput.trim() if (val && !activeNote.tags.includes(val)) { updateNote(activeNoteId!, { tags: [...activeNote.tags, val] }, { silent: true }) } setTagInput('') } else if (e.key === 'Backspace' && tagInput === '' && activeNote.tags.length > 0) { updateNote(activeNoteId!, { tags: activeNote.tags.slice(0, -1) }, { silent: true }) } }} placeholder={activeNote.tags.length === 0 ? '添加标签…' : '+'} className="bg-transparent outline-none text-xs" style={{ color: 'var(--text-muted)', minWidth: 60, width: tagInput.length * 8 + 60 }} />
{/* Floating toolbar */} {editor && (
)} {/* Editor scroll */}
{slashOpen && filteredSlash.length > 0 && (
)}
{/* AI Result Modal */} {aiModal && ( { if (editor) { editor.chain().focus().insertContentAt({ from: aiModal.from, to: aiModal.to }, aiModal.result).run() } setAiModal(null) }} onDiscard={() => { aiModalAbortRef.current?.abort() setAiModal(null) }} /> )} {/* Footer */}
{wordCount} 字{wordCount > 0 ? ` · 约 ${readingTime} 分钟阅读` : ''} {saveStatus === 'saving' ? '保存中…' : saveStatus === 'unsaved' ? '未保存' : '已自动保存'}
) } function FloatingToolbar({ editor, onPolish, onSummarize, onTranslate }: { editor: NonNullable> onPolish: (from: number, to: number) => void onSummarize: (from: number, to: number) => void onTranslate: (from: number, to: number) => void }) { const [visible, setVisible] = useState(false) const [selRange, setSelRange] = useState({ from: 0, to: 0 }) const [pos, setPos] = useState({ top: 0, left: 0 }) useEffect(() => { const update = () => { const { state, view } = editor const { selection } = state if (selection.empty) { setVisible(false); return } const { from, to } = selection setSelRange({ from, to }) const start = view.coordsAtPos(from) const end = view.coordsAtPos(to) const top = Math.min(start.top, end.top) - 48 const left = (start.left + end.left) / 2 - 110 setPos({ top: Math.max(8, top), left: Math.max(8, left) }) setVisible(true) } editor.on('selectionUpdate', update) editor.on('blur', () => setVisible(false)) return () => { editor.off('selectionUpdate', update) } }, [editor]) if (!visible) return null return (
editor.chain().focus().toggleBold().run()}> editor.chain().focus().toggleItalic().run()}> editor.chain().focus().toggleUnderline().run()}> editor.chain().focus().toggleStrike().run()}> editor.chain().focus().toggleCode().run()}> editor.chain().focus().toggleHighlight().run()}>
editor.chain().focus().toggleHeading({ level: 1 }).run()}> editor.chain().focus().toggleHeading({ level: 2 }).run()}>
editor.chain().focus().toggleBlockquote().run()}>
onPolish(selRange.from, selRange.to)}> onSummarize(selRange.from, selRange.to)}> onTranslate(selRange.from, selRange.to)}>
) } function ToolbarBtn({ children, active, title, onClick }: { children: React.ReactNode; active: boolean; title?: string; onClick: () => void }) { return ( ) } function AiResultModal({ type, result, streaming, onInsert, onDiscard }: { type: 'polish' | 'summarize' | 'translate' result: string streaming: boolean onInsert: () => void onDiscard: () => void }) { return ( e.stopPropagation()} initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.95, opacity: 0 }} transition={{ duration: 0.18, ease: [0.4, 0, 0.2, 1] }} >
{type === 'polish' ? 'AI 润色结果' : type === 'translate' ? 'AI 翻译结果' : 'AI 摘要结果'}
{result || 生成中…} {streaming && }
) }