627 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: <Type size={14} />, action: (e: ReturnType<typeof useEditor>) => e?.chain().focus().setParagraph().run() },
{ label: '标题 1', desc: '大标题', icon: <Heading1 size={14} />, action: (e: ReturnType<typeof useEditor>) => e?.chain().focus().setHeading({ level: 1 }).run() },
{ label: '标题 2', desc: '中标题', icon: <Heading2 size={14} />, action: (e: ReturnType<typeof useEditor>) => e?.chain().focus().setHeading({ level: 2 }).run() },
{ label: '标题 3', desc: '小标题', icon: <Heading3 size={14} />, action: (e: ReturnType<typeof useEditor>) => e?.chain().focus().setHeading({ level: 3 }).run() },
{ label: '无序列表', desc: '• 项目', icon: <List size={14} />, action: (e: ReturnType<typeof useEditor>) => e?.chain().focus().toggleBulletList().run() },
{ label: '有序列表', desc: '1. 项目', icon: <ListOrdered size={14} />, action: (e: ReturnType<typeof useEditor>) => e?.chain().focus().toggleOrderedList().run() },
{ label: '任务列表', desc: '☑ 待办', icon: <CheckSquare size={14} />, action: (e: ReturnType<typeof useEditor>) => e?.chain().focus().toggleTaskList().run() },
{ label: '引用', desc: '引用文本', icon: <Quote size={14} />, action: (e: ReturnType<typeof useEditor>) => e?.chain().focus().toggleBlockquote().run() },
{ label: '代码块', desc: '含语法高亮', icon: <Code size={14} />, action: (e: ReturnType<typeof useEditor>) => e?.chain().focus().toggleCodeBlock().run() },
{ label: '图片', desc: '插入图片 URL', icon: <ImageIcon size={14} />, action: (e: ReturnType<typeof useEditor>) => {
const url = window.prompt('图片地址URL')
if (url) e?.chain().focus().setImage({ src: url }).run()
}},
{ label: '分割线', desc: '插入水平分割线', icon: <Minus size={14} />, action: (e: ReturnType<typeof useEditor>) => e?.chain().focus().setHorizontalRule().run() },
{ label: '表格', desc: '插入表格', icon: <TableIcon size={14} />, action: (e: ReturnType<typeof useEditor>) => 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 (
<div className="slash-menu">
{items.map((item, i) => (
<div
key={item.label}
className={`slash-menu-item ${i === selectedIndex ? 'active' : ''}`}
onMouseDown={(e) => { e.preventDefault(); onSelect(item) }}
>
<div className="slash-menu-item-icon">{item.icon}</div>
<div>
<div className="slash-menu-item-label">{item.label}</div>
<div className="slash-menu-item-desc">{item.desc}</div>
</div>
</div>
))}
</div>
)
}
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<AbortController | null>(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<AbortController | null>(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<number | null>(null)
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const titleTimer = useRef<ReturnType<typeof setTimeout> | 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<ReturnType<typeof useEditor>>) {
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 <WelcomeView />
}
const readingTime = wordCount > 0 ? Math.max(1, Math.ceil(wordCount / 250)) : 0
return (
<div className="flex-1 flex flex-col min-w-0 h-full" style={{ background: 'var(--bg)' }}>
{/* Title row */}
<div className={`px-12 pt-10 pb-0 mx-auto w-full${focusMode ? ' max-w-2xl' : ' max-w-3xl'}`}>
<div className="flex items-start gap-2">
<input
value={title}
onChange={e => 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' }}
/>
<button
onClick={handleAiContinue}
title={aiContinuing ? '停止续写' : 'AI 续写'}
className="toolbar-btn shrink-0 mt-2"
>
{aiContinuing
? <Square size={14} fill="currentColor" style={{ color: 'var(--accent)' }} />
: <Zap size={14} style={{ color: 'var(--text-faint)' }} />}
</button>
<button
onClick={toggleAiPanel}
title={aiPanelOpen ? '关闭 AI 助手 (⌘⇧J)' : '打开 AI 助手 (⌘⇧J)'}
className="toolbar-btn shrink-0 mt-2"
>
<Sparkles size={14} style={{ color: aiPanelOpen ? 'var(--accent)' : 'var(--text-faint)' }} />
</button>
<button
onClick={() => activeNoteId && toggleStar(activeNoteId)}
title={activeNote.starred ? '取消收藏' : '收藏笔记'}
className="toolbar-btn shrink-0 mt-2"
>
<Star
size={16}
fill={activeNote.starred ? '#f59e0b' : 'none'}
style={{ color: activeNote.starred ? '#f59e0b' : 'var(--text-faint)' }}
/>
</button>
<div className="shrink-0 mt-1.5">
<ExportMenu title={title} content={activeNote.content} />
</div>
<button
onClick={toggleFocusMode}
title={focusMode ? '退出专注模式' : '专注模式 (⌘\\)'}
className="toolbar-btn shrink-0 mt-2"
>
{focusMode
? <Minimize2 size={15} style={{ color: 'var(--accent)' }} />
: <Maximize2 size={15} style={{ color: 'var(--text-faint)' }} />}
</button>
</div>
</div>
{/* Tag row */}
<div className={`px-12 pb-2 mx-auto w-full${focusMode ? ' max-w-2xl' : ' max-w-3xl'}`}>
<div className="flex items-center flex-wrap gap-1.5 min-h-[26px]">
{activeNote.tags.map(tag => (
<span
key={tag}
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full"
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
>
<Hash size={10} />
{tag}
<button
onMouseDown={e => { e.preventDefault(); updateNote(activeNoteId!, { tags: activeNote.tags.filter(t => t !== tag) }, { silent: true }) }}
className="hover:opacity-70 transition-opacity"
>
<X size={10} />
</button>
</span>
))}
<input
value={tagInput}
onChange={e => 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 }}
/>
</div>
</div>
{/* Floating toolbar */}
{editor && (
<div style={{ position: 'fixed', zIndex: 100, pointerEvents: 'none' }}>
<FloatingToolbar editor={editor} onPolish={handleAiAction.bind(null, 'polish')} onSummarize={handleAiAction.bind(null, 'summarize')} onTranslate={handleAiAction.bind(null, 'translate')} />
</div>
)}
{/* Editor scroll */}
<div className="flex-1 overflow-y-auto editor-scroll relative px-12 pt-4 pb-4">
<div className={`mx-auto w-full relative${focusMode ? ' max-w-2xl' : ' max-w-3xl'}`}>
<EditorContent editor={editor} />
{slashOpen && filteredSlash.length > 0 && (
<div className="absolute z-50" style={{ top: slashPos.top, left: Math.max(0, slashPos.left) }}>
<SlashMenu items={filteredSlash} selectedIndex={slashIndex} onSelect={executeSlash} />
</div>
)}
</div>
</div>
{/* AI Result Modal */}
<AnimatePresence>
{aiModal && (
<AiResultModal
type={aiModal.type}
result={aiModal.result}
streaming={aiModal.streaming}
onInsert={() => {
if (editor) {
editor.chain().focus().insertContentAt({ from: aiModal.from, to: aiModal.to }, aiModal.result).run()
}
setAiModal(null)
}}
onDiscard={() => {
aiModalAbortRef.current?.abort()
setAiModal(null)
}}
/>
)}
</AnimatePresence>
{/* Footer */}
<div
className="flex items-center justify-between px-12 py-2 text-xs shrink-0"
style={{ borderTop: '1px solid var(--border)', color: 'var(--text-faint)', background: 'var(--bg)' }}
>
<span>{wordCount} {wordCount > 0 ? ` · 约 ${readingTime} 分钟阅读` : ''}</span>
<span style={{ color: saveStatus === 'saved' ? 'var(--text-faint)' : 'var(--accent)' }}>
{saveStatus === 'saving' ? '保存中…' : saveStatus === 'unsaved' ? '未保存' : '已自动保存'}
</span>
</div>
</div>
)
}
function FloatingToolbar({ editor, onPolish, onSummarize, onTranslate }: {
editor: NonNullable<ReturnType<typeof useEditor>>
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 (
<div className="floating-toolbar" style={{ position: 'fixed', top: pos.top, left: pos.left, pointerEvents: 'auto', zIndex: 100 }}>
<ToolbarBtn title="粗体" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}><Bold size={13} /></ToolbarBtn>
<ToolbarBtn title="斜体" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}><Italic size={13} /></ToolbarBtn>
<ToolbarBtn title="下划线" active={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}><UnderlineIcon size={13} /></ToolbarBtn>
<ToolbarBtn title="删除线" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}><Strikethrough size={13} /></ToolbarBtn>
<ToolbarBtn title="行内代码" active={editor.isActive('code')} onClick={() => editor.chain().focus().toggleCode().run()}><Code size={13} /></ToolbarBtn>
<ToolbarBtn title="高亮" active={editor.isActive('highlight')} onClick={() => editor.chain().focus().toggleHighlight().run()}><Highlighter size={13} /></ToolbarBtn>
<div className="toolbar-divider" />
<ToolbarBtn title="H1" active={editor.isActive('heading', { level: 1 })} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}><Heading1 size={13} /></ToolbarBtn>
<ToolbarBtn title="H2" active={editor.isActive('heading', { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}><Heading2 size={13} /></ToolbarBtn>
<div className="toolbar-divider" />
<ToolbarBtn title="引用" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}><Quote size={13} /></ToolbarBtn>
<div className="toolbar-divider" />
<ToolbarBtn title="AI 润色" active={false} onClick={() => onPolish(selRange.from, selRange.to)}><Wand2 size={13} style={{ color: 'var(--accent)' }} /></ToolbarBtn>
<ToolbarBtn title="AI 摘要" active={false} onClick={() => onSummarize(selRange.from, selRange.to)}><FileTextIcon size={13} style={{ color: 'var(--accent)' }} /></ToolbarBtn>
<ToolbarBtn title="翻译成英文" active={false} onClick={() => onTranslate(selRange.from, selRange.to)}><Languages size={13} style={{ color: 'var(--accent)' }} /></ToolbarBtn>
</div>
)
}
function ToolbarBtn({ children, active, title, onClick }: {
children: React.ReactNode; active: boolean; title?: string; onClick: () => void
}) {
return (
<button title={title} onMouseDown={(e) => { e.preventDefault(); onClick() }} className={`toolbar-btn ${active ? 'active' : ''}`}>
{children}
</button>
)
}
function AiResultModal({ type, result, streaming, onInsert, onDiscard }: {
type: 'polish' | 'summarize' | 'translate'
result: string
streaming: boolean
onInsert: () => void
onDiscard: () => void
}) {
return (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.4)' }}
onClick={onDiscard}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
>
<motion.div
className="rounded-2xl shadow-2xl flex flex-col"
style={{ background: 'var(--bg)', border: '1px solid var(--border)', width: 520, maxHeight: '70vh' }}
onClick={e => 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] }}
>
<div className="flex items-center justify-between px-5 py-3 shrink-0" style={{ borderBottom: '1px solid var(--border)' }}>
<div className="flex items-center gap-2">
<Sparkles size={14} style={{ color: 'var(--accent)' }} />
<span className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
{type === 'polish' ? 'AI 润色结果' : type === 'translate' ? 'AI 翻译结果' : 'AI 摘要结果'}
</span>
</div>
<button onClick={onDiscard} className="toolbar-btn"><X size={14} /></button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4 text-sm leading-relaxed whitespace-pre-wrap" style={{ color: 'var(--text)' }}>
{result || <span style={{ color: 'var(--text-faint)' }}></span>}
{streaming && <span className="ai-cursor" />}
</div>
<div className="flex items-center justify-end gap-2 px-5 py-3 shrink-0" style={{ borderTop: '1px solid var(--border)' }}>
<button onClick={onDiscard} className="px-3 py-1.5 rounded-lg text-sm" style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>
</button>
<button
onClick={onInsert}
disabled={!result || streaming}
className="px-3 py-1.5 rounded-lg text-sm font-medium"
style={{ background: result && !streaming ? 'var(--accent)' : 'var(--bg-muted)', color: result && !streaming ? '#fff' : 'var(--text-faint)' }}
>
{type === 'polish' ? '替换选中内容' : type === 'translate' ? '替换选中内容' : '插入摘要'}
</button>
</div>
</motion.div>
</motion.div>
)
}