feat(M4): 细节打磨——可拖拽/缩放 AI 浮窗、弹窗动画、全局快捷键、翻译功能
- AI 助手改为可自由拖拽移动的浮窗(position: fixed,默认右下角) - 支持 8 方向拖拽缩放(N/S/E/W + 四角),尺寸 280–720 × 320–900px - 弹窗(ModelSettingsModal、AiResultModal)加入 framer-motion 缩放动画 - App.tsx 注册全局快捷键:Cmd+\ 专注模式、Cmd+Shift+J AI 面板、Cmd+N 新建笔记 - 浮动工具栏新增"翻译成英文"按钮(Languages 图标),流式预览后可替换 - 服务端 buildMessages 增加 translate 分支 - AI 面板 textarea 随输入自动扩展高度(最多 5 行) - 欢迎页功能一览补充 AI 续写/润色/摘要/问答四卡片及三条快捷键 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d632e9f6f7
commit
aa1430d4bf
@ -104,7 +104,7 @@ app.delete('/api/models/:id', (c) => {
|
|||||||
|
|
||||||
// ── AI types ──────────────────────────────────────────────────────────────────
|
// ── AI types ──────────────────────────────────────────────────────────────────
|
||||||
type AIStreamRequest = {
|
type AIStreamRequest = {
|
||||||
type: 'continue' | 'polish' | 'summarize' | 'chat'
|
type: 'continue' | 'polish' | 'summarize' | 'translate' | 'chat'
|
||||||
noteContent: string
|
noteContent: string
|
||||||
selection?: string
|
selection?: string
|
||||||
messages?: { role: 'user' | 'assistant'; content: string }[]
|
messages?: { role: 'user' | 'assistant'; content: string }[]
|
||||||
@ -131,6 +131,10 @@ function buildMessages(req: AIStreamRequest): Anthropic.MessageParam[] {
|
|||||||
return [{ role: 'user', content: `请润色以下文字,改善语法、流畅度和表达,保持原意,直接输出润色后的结果,不要有任何解释:\n\n${selection ?? noteContent}` }]
|
return [{ role: 'user', content: `请润色以下文字,改善语法、流畅度和表达,保持原意,直接输出润色后的结果,不要有任何解释:\n\n${selection ?? noteContent}` }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'translate') {
|
||||||
|
return [{ role: 'user', content: `请将以下文字翻译成英文,保持原文风格和格式,直接输出翻译结果,不要有任何解释:\n\n${selection ?? noteContent}` }]
|
||||||
|
}
|
||||||
|
|
||||||
return [{ role: 'user', content: `请为以下内容提炼要点摘要,用简洁的中文输出,不超过 150 字:\n\n${selection ?? noteContent}` }]
|
return [{ role: 'user', content: `请为以下内容提炼要点摘要,用简洁的中文输出,不超过 150 字:\n\n${selection ?? noteContent}` }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
15
src/App.tsx
15
src/App.tsx
@ -6,7 +6,7 @@ import { useAppStore } from './stores/appStore'
|
|||||||
import { seedIfEmpty, deduplicateDB } from './db'
|
import { seedIfEmpty, deduplicateDB } from './db'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadAll, theme, focusMode, aiPanelOpen } = useAppStore()
|
const { loadAll, theme, focusMode, aiPanelOpen, toggleFocusMode, toggleAiPanel, createNote, activeFolderId } = useAppStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', theme)
|
document.documentElement.setAttribute('data-theme', theme)
|
||||||
@ -18,13 +18,24 @@ export default function App() {
|
|||||||
.then(() => loadAll())
|
.then(() => loadAll())
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
const mod = e.metaKey || e.ctrlKey
|
||||||
|
if (mod && e.key === '\\') { e.preventDefault(); toggleFocusMode() }
|
||||||
|
if (mod && e.shiftKey && e.key === 'J') { e.preventDefault(); toggleAiPanel() }
|
||||||
|
if (mod && e.key === 'n') { e.preventDefault(); createNote(activeFolderId as string | null) }
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
}, [toggleFocusMode, toggleAiPanel, createNote, activeFolderId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-full overflow-hidden" style={{ background: 'var(--bg)' }}>
|
<div className="flex h-screen w-full overflow-hidden" style={{ background: 'var(--bg)' }}>
|
||||||
<div className={`sidebar-panel${focusMode ? ' hidden' : ''}`} style={{ width: 260 }}>
|
<div className={`sidebar-panel${focusMode ? ' hidden' : ''}`} style={{ width: 260 }}>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
</div>
|
</div>
|
||||||
<Editor />
|
<Editor />
|
||||||
{aiPanelOpen && !focusMode && <AiPanel />}
|
{aiPanelOpen && <AiPanel />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,30 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
import { X, Send, Sparkles, Square, FileText } from 'lucide-react'
|
import { X, Send, Sparkles, Square, FileText, GripHorizontal } from 'lucide-react'
|
||||||
import { useAppStore } from '../../stores/appStore'
|
import { useAppStore } from '../../stores/appStore'
|
||||||
import { streamAI } from '../../lib/ai'
|
import { streamAI } from '../../lib/ai'
|
||||||
import { extractTextFromJSON } from '../../lib/utils'
|
import { extractTextFromJSON } from '../../lib/utils'
|
||||||
|
|
||||||
type Message = { role: 'user' | 'assistant'; content: string }
|
type Message = { role: 'user' | 'assistant'; content: string }
|
||||||
|
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
|
||||||
|
|
||||||
|
const DEFAULT_W = 360
|
||||||
|
const DEFAULT_H = 520
|
||||||
|
const MIN_W = 280
|
||||||
|
const MIN_H = 320
|
||||||
|
const MAX_W = 720
|
||||||
|
const MAX_H = 900
|
||||||
|
const HANDLE = 6 // resize handle thickness px
|
||||||
|
|
||||||
|
const RESIZE_HANDLES: { dir: ResizeDir; cursor: string; style: React.CSSProperties }[] = [
|
||||||
|
{ dir: 'n', cursor: 'ns-resize', style: { top: 0, left: HANDLE, right: HANDLE, height: HANDLE } },
|
||||||
|
{ dir: 's', cursor: 'ns-resize', style: { bottom: 0, left: HANDLE, right: HANDLE, height: HANDLE } },
|
||||||
|
{ dir: 'e', cursor: 'ew-resize', style: { right: 0, top: HANDLE, bottom: HANDLE, width: HANDLE } },
|
||||||
|
{ dir: 'w', cursor: 'ew-resize', style: { left: 0, top: HANDLE, bottom: HANDLE, width: HANDLE } },
|
||||||
|
{ dir: 'nw', cursor: 'nwse-resize', style: { top: 0, left: 0, width: HANDLE * 2, height: HANDLE * 2 } },
|
||||||
|
{ dir: 'ne', cursor: 'nesw-resize', style: { top: 0, right: 0, width: HANDLE * 2, height: HANDLE * 2 } },
|
||||||
|
{ dir: 'sw', cursor: 'nesw-resize', style: { bottom: 0, left: 0, width: HANDLE * 2, height: HANDLE * 2 } },
|
||||||
|
{ dir: 'se', cursor: 'nwse-resize', style: { bottom: 0, right: 0, width: HANDLE * 2, height: HANDLE * 2 } },
|
||||||
|
]
|
||||||
|
|
||||||
export function AiPanel() {
|
export function AiPanel() {
|
||||||
const { toggleAiPanel, notes, activeNoteId } = useAppStore()
|
const { toggleAiPanel, notes, activeNoteId } = useAppStore()
|
||||||
@ -17,10 +37,75 @@ export function AiPanel() {
|
|||||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
|
const [pos, setPos] = useState(() => ({
|
||||||
|
x: window.innerWidth - DEFAULT_W - 24,
|
||||||
|
y: window.innerHeight - DEFAULT_H - 24,
|
||||||
|
}))
|
||||||
|
const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H })
|
||||||
|
|
||||||
|
// refs for drag/resize so callbacks don't go stale
|
||||||
|
const posRef = useRef(pos)
|
||||||
|
const sizeRef = useRef(size)
|
||||||
|
useEffect(() => { posRef.current = pos }, [pos])
|
||||||
|
useEffect(() => { sizeRef.current = size }, [size])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
|
const onDragStart = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const startX = e.clientX, startY = e.clientY
|
||||||
|
const { x: origX, y: origY } = posRef.current
|
||||||
|
const { w, h } = sizeRef.current
|
||||||
|
|
||||||
|
const onMove = (ev: MouseEvent) => {
|
||||||
|
const nextX = Math.max(0, Math.min(window.innerWidth - w, origX + ev.clientX - startX))
|
||||||
|
const nextY = Math.max(0, Math.min(window.innerHeight - h, origY + ev.clientY - startY))
|
||||||
|
setPos({ x: nextX, y: nextY })
|
||||||
|
}
|
||||||
|
const onUp = () => {
|
||||||
|
window.removeEventListener('mousemove', onMove)
|
||||||
|
window.removeEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
window.addEventListener('mousemove', onMove)
|
||||||
|
window.addEventListener('mouseup', onUp)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onResizeStart = useCallback((e: React.MouseEvent, dir: ResizeDir) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const startX = e.clientX, startY = e.clientY
|
||||||
|
const { x: origX, y: origY } = posRef.current
|
||||||
|
const { w: origW, h: origH } = sizeRef.current
|
||||||
|
|
||||||
|
const onMove = (ev: MouseEvent) => {
|
||||||
|
const dx = ev.clientX - startX
|
||||||
|
const dy = ev.clientY - startY
|
||||||
|
let newX = origX, newY = origY, newW = origW, newH = origH
|
||||||
|
|
||||||
|
if (dir.includes('e')) newW = Math.max(MIN_W, Math.min(MAX_W, origW + dx))
|
||||||
|
if (dir.includes('s')) newH = Math.max(MIN_H, Math.min(MAX_H, origH + dy))
|
||||||
|
if (dir.includes('w')) {
|
||||||
|
newW = Math.max(MIN_W, Math.min(MAX_W, origW - dx))
|
||||||
|
newX = origX + origW - newW
|
||||||
|
}
|
||||||
|
if (dir.includes('n')) {
|
||||||
|
newH = Math.max(MIN_H, Math.min(MAX_H, origH - dy))
|
||||||
|
newY = origY + origH - newH
|
||||||
|
}
|
||||||
|
|
||||||
|
setPos({ x: Math.max(0, newX), y: Math.max(0, newY) })
|
||||||
|
setSize({ w: newW, h: newH })
|
||||||
|
}
|
||||||
|
const onUp = () => {
|
||||||
|
window.removeEventListener('mousemove', onMove)
|
||||||
|
window.removeEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
window.addEventListener('mousemove', onMove)
|
||||||
|
window.addEventListener('mouseup', onUp)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const noteContent = activeNote
|
const noteContent = activeNote
|
||||||
? `标题:${activeNote.title}\n\n${extractTextFromJSON(activeNote.content)}`
|
? `标题:${activeNote.title}\n\n${extractTextFromJSON(activeNote.content)}`
|
||||||
: ''
|
: ''
|
||||||
@ -29,29 +114,20 @@ export function AiPanel() {
|
|||||||
if (!text.trim() || streaming) return
|
if (!text.trim() || streaming) return
|
||||||
const userMsg: Message = { role: 'user', content: text.trim() }
|
const userMsg: Message = { role: 'user', content: text.trim() }
|
||||||
const assistantMsg: Message = { role: 'assistant', content: '' }
|
const assistantMsg: Message = { role: 'assistant', content: '' }
|
||||||
|
|
||||||
setMessages(prev => [...prev, userMsg, assistantMsg])
|
setMessages(prev => [...prev, userMsg, assistantMsg])
|
||||||
setInput('')
|
setInput('')
|
||||||
|
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||||
setStreaming(true)
|
setStreaming(true)
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
abortRef.current = controller
|
abortRef.current = controller
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await streamAI(
|
await streamAI(
|
||||||
{
|
{ type: 'chat', noteContent, messages: messages.concat(userMsg), userMessage: userMsg.content },
|
||||||
type: 'chat',
|
|
||||||
noteContent,
|
|
||||||
messages: messages.concat(userMsg),
|
|
||||||
userMessage: userMsg.content,
|
|
||||||
},
|
|
||||||
(chunk) => {
|
(chunk) => {
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
const next = [...prev]
|
const next = [...prev]
|
||||||
next[next.length - 1] = {
|
next[next.length - 1] = { ...next[next.length - 1], content: next[next.length - 1].content + chunk }
|
||||||
...next[next.length - 1],
|
|
||||||
content: next[next.length - 1].content + chunk,
|
|
||||||
}
|
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -71,30 +147,55 @@ export function AiPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const summarize = () => {
|
|
||||||
if (!noteContent) return
|
|
||||||
send('请为当前笔记生成一份简洁的摘要。')
|
|
||||||
}
|
|
||||||
|
|
||||||
const stop = () => {
|
|
||||||
abortRef.current?.abort()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex flex-col h-full shrink-0"
|
style={{
|
||||||
style={{ width: 320, borderLeft: '1px solid var(--border)', background: 'var(--bg-subtle)' }}
|
position: 'fixed',
|
||||||
|
left: pos.x,
|
||||||
|
top: pos.y,
|
||||||
|
width: size.w,
|
||||||
|
height: size.h,
|
||||||
|
zIndex: 200,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
background: 'var(--bg)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 16,
|
||||||
|
boxShadow: '0 8px 40px rgba(0,0,0,0.18)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Resize handles */}
|
||||||
|
{RESIZE_HANDLES.map(({ dir, cursor, style }) => (
|
||||||
<div
|
<div
|
||||||
|
key={dir}
|
||||||
|
onMouseDown={e => onResizeStart(e, dir)}
|
||||||
|
style={{ position: 'absolute', zIndex: 10, cursor, ...style }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Header / drag handle */}
|
||||||
|
<div
|
||||||
|
onMouseDown={onDragStart}
|
||||||
className="flex items-center justify-between px-4 py-3 shrink-0"
|
className="flex items-center justify-between px-4 py-3 shrink-0"
|
||||||
style={{ borderBottom: '1px solid var(--border)' }}
|
style={{
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
cursor: 'grab',
|
||||||
|
userSelect: 'none',
|
||||||
|
background: 'var(--bg-subtle)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles size={15} style={{ color: 'var(--accent)' }} />
|
<GripHorizontal size={13} style={{ color: 'var(--text-faint)' }} />
|
||||||
|
<Sparkles size={14} style={{ color: 'var(--accent)' }} />
|
||||||
<span className="font-semibold text-sm" style={{ color: 'var(--text)' }}>AI 助手</span>
|
<span className="font-semibold text-sm" style={{ color: 'var(--text)' }}>AI 助手</span>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={toggleAiPanel} className="toolbar-btn" title="关闭">
|
<button
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
onClick={toggleAiPanel}
|
||||||
|
className="toolbar-btn"
|
||||||
|
title="关闭"
|
||||||
|
>
|
||||||
<X size={15} />
|
<X size={15} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -102,9 +203,9 @@ export function AiPanel() {
|
|||||||
{/* Quick actions */}
|
{/* Quick actions */}
|
||||||
<div className="px-3 py-2 shrink-0" style={{ borderBottom: '1px solid var(--border)' }}>
|
<div className="px-3 py-2 shrink-0" style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
<button
|
<button
|
||||||
onClick={summarize}
|
onClick={() => send('请为当前笔记生成一份简洁的摘要。')}
|
||||||
disabled={!activeNote || streaming}
|
disabled={!activeNote || streaming}
|
||||||
className="w-full flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm transition-colors"
|
className="w-full flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--bg-muted)',
|
background: 'var(--bg-muted)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
@ -158,13 +259,17 @@ export function AiPanel() {
|
|||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={e => setInput(e.target.value)}
|
onChange={e => {
|
||||||
onKeyDown={e => {
|
setInput(e.target.value)
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
const el = textareaRef.current
|
||||||
e.preventDefault()
|
if (el) {
|
||||||
send(input)
|
el.style.height = 'auto'
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(input) }
|
||||||
|
}}
|
||||||
placeholder={activeNote ? '向 AI 提问…' : '请先打开笔记'}
|
placeholder={activeNote ? '向 AI 提问…' : '请先打开笔记'}
|
||||||
disabled={!activeNote}
|
disabled={!activeNote}
|
||||||
rows={1}
|
rows={1}
|
||||||
@ -175,12 +280,15 @@ export function AiPanel() {
|
|||||||
color: 'var(--text)',
|
color: 'var(--text)',
|
||||||
maxHeight: 120,
|
maxHeight: 120,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
|
overflow: 'auto',
|
||||||
}}
|
}}
|
||||||
|
onFocus={e => (e.currentTarget.style.borderColor = 'var(--accent)')}
|
||||||
|
onBlur={e => (e.currentTarget.style.borderColor = 'var(--border)')}
|
||||||
/>
|
/>
|
||||||
{streaming ? (
|
{streaming ? (
|
||||||
<button
|
<button
|
||||||
onClick={stop}
|
onClick={() => abortRef.current?.abort()}
|
||||||
className="shrink-0 flex items-center justify-center w-8 h-8 rounded-xl transition-colors"
|
className="shrink-0 flex items-center justify-center w-8 h-8 rounded-xl"
|
||||||
style={{ background: '#ef4444', color: '#fff' }}
|
style={{ background: '#ef4444', color: '#fff' }}
|
||||||
title="停止"
|
title="停止"
|
||||||
>
|
>
|
||||||
@ -190,7 +298,7 @@ export function AiPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => send(input)}
|
onClick={() => send(input)}
|
||||||
disabled={!input.trim() || !activeNote}
|
disabled={!input.trim() || !activeNote}
|
||||||
className="shrink-0 flex items-center justify-center w-8 h-8 rounded-xl transition-colors"
|
className="shrink-0 flex items-center justify-center w-8 h-8 rounded-xl"
|
||||||
style={{
|
style={{
|
||||||
background: input.trim() && activeNote ? 'var(--accent)' : 'var(--bg-muted)',
|
background: input.trim() && activeNote ? 'var(--accent)' : 'var(--bg-muted)',
|
||||||
color: input.trim() && activeNote ? '#fff' : 'var(--text-faint)',
|
color: input.trim() && activeNote ? '#fff' : 'var(--text-faint)',
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
import { X, Plus, Check, Trash2, Bot, ChevronDown, ChevronUp } from 'lucide-react'
|
import { X, Plus, Check, Trash2, Bot, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
|
||||||
type ModelInfo = {
|
type ModelInfo = {
|
||||||
@ -67,15 +68,23 @@ export function ModelSettingsModal({ onClose }: { onClose: () => void }) {
|
|||||||
const activeModel = models.find(m => m.isActive)
|
const activeModel = models.find(m => m.isActive)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<motion.div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
style={{ background: 'rgba(0,0,0,0.45)' }}
|
style={{ background: 'rgba(0,0,0,0.45)' }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
>
|
>
|
||||||
<div
|
<motion.div
|
||||||
className="rounded-2xl shadow-2xl flex flex-col"
|
className="rounded-2xl shadow-2xl flex flex-col"
|
||||||
style={{ background: 'var(--bg)', border: '1px solid var(--border)', width: 480, maxHeight: '80vh' }}
|
style={{ background: 'var(--bg)', border: '1px solid var(--border)', width: 480, maxHeight: '80vh' }}
|
||||||
onClick={e => e.stopPropagation()}
|
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] }}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-5 py-4 shrink-0" style={{ borderBottom: '1px solid var(--border)' }}>
|
<div className="flex items-center justify-between px-5 py-4 shrink-0" style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
@ -210,7 +219,7 @@ export function ModelSettingsModal({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { useEditor, EditorContent } from '@tiptap/react'
|
import { useEditor, EditorContent } from '@tiptap/react'
|
||||||
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
|
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
@ -23,7 +24,7 @@ import {
|
|||||||
Highlighter, List, ListOrdered, Quote,
|
Highlighter, List, ListOrdered, Quote,
|
||||||
Heading1, Heading2, Heading3, Minus, CheckSquare, Table as TableIcon,
|
Heading1, Heading2, Heading3, Minus, CheckSquare, Table as TableIcon,
|
||||||
Type, Star, ImageIcon, Maximize2, Minimize2, Hash, X,
|
Type, Star, ImageIcon, Maximize2, Minimize2, Hash, X,
|
||||||
Sparkles, Zap, Wand2, FileText as FileTextIcon, Square,
|
Sparkles, Zap, Wand2, FileText as FileTextIcon, Square, Languages,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAppStore } from '../../stores/appStore'
|
import { useAppStore } from '../../stores/appStore'
|
||||||
import { countWords } from '../../lib/utils'
|
import { countWords } from '../../lib/utils'
|
||||||
@ -92,7 +93,7 @@ export function Editor() {
|
|||||||
|
|
||||||
// AI modal state
|
// AI modal state
|
||||||
const [aiModal, setAiModal] = useState<{
|
const [aiModal, setAiModal] = useState<{
|
||||||
type: 'polish' | 'summarize'
|
type: 'polish' | 'summarize' | 'translate'
|
||||||
from: number
|
from: number
|
||||||
to: number
|
to: number
|
||||||
selection: string
|
selection: string
|
||||||
@ -316,7 +317,7 @@ export function Editor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAiAction = async (type: 'polish' | 'summarize', from: number, to: number) => {
|
const handleAiAction = async (type: 'polish' | 'summarize' | 'translate', from: number, to: number) => {
|
||||||
if (!editor || !activeNote) return
|
if (!editor || !activeNote) return
|
||||||
const selection = editor.state.doc.textBetween(from, to, '\n')
|
const selection = editor.state.doc.textBetween(from, to, '\n')
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@ -440,7 +441,7 @@ export function Editor() {
|
|||||||
{/* Floating toolbar */}
|
{/* Floating toolbar */}
|
||||||
{editor && (
|
{editor && (
|
||||||
<div style={{ position: 'fixed', zIndex: 100, pointerEvents: 'none' }}>
|
<div style={{ position: 'fixed', zIndex: 100, pointerEvents: 'none' }}>
|
||||||
<FloatingToolbar editor={editor} onPolish={handleAiAction.bind(null, 'polish')} onSummarize={handleAiAction.bind(null, 'summarize')} />
|
<FloatingToolbar editor={editor} onPolish={handleAiAction.bind(null, 'polish')} onSummarize={handleAiAction.bind(null, 'summarize')} onTranslate={handleAiAction.bind(null, 'translate')} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -458,6 +459,7 @@ export function Editor() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI Result Modal */}
|
{/* AI Result Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
{aiModal && (
|
{aiModal && (
|
||||||
<AiResultModal
|
<AiResultModal
|
||||||
type={aiModal.type}
|
type={aiModal.type}
|
||||||
@ -475,6 +477,7 @@ export function Editor() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div
|
<div
|
||||||
@ -490,10 +493,11 @@ export function Editor() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FloatingToolbar({ editor, onPolish, onSummarize }: {
|
function FloatingToolbar({ editor, onPolish, onSummarize, onTranslate }: {
|
||||||
editor: NonNullable<ReturnType<typeof useEditor>>
|
editor: NonNullable<ReturnType<typeof useEditor>>
|
||||||
onPolish: (from: number, to: number) => void
|
onPolish: (from: number, to: number) => void
|
||||||
onSummarize: (from: number, to: number) => void
|
onSummarize: (from: number, to: number) => void
|
||||||
|
onTranslate: (from: number, to: number) => void
|
||||||
}) {
|
}) {
|
||||||
const [visible, setVisible] = useState(false)
|
const [visible, setVisible] = useState(false)
|
||||||
const [selRange, setSelRange] = useState({ from: 0, to: 0 })
|
const [selRange, setSelRange] = useState({ from: 0, to: 0 })
|
||||||
@ -536,6 +540,7 @@ function FloatingToolbar({ editor, onPolish, onSummarize }: {
|
|||||||
<div className="toolbar-divider" />
|
<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={() => 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="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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -551,28 +556,36 @@ function ToolbarBtn({ children, active, title, onClick }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AiResultModal({ type, result, streaming, onInsert, onDiscard }: {
|
function AiResultModal({ type, result, streaming, onInsert, onDiscard }: {
|
||||||
type: 'polish' | 'summarize'
|
type: 'polish' | 'summarize' | 'translate'
|
||||||
result: string
|
result: string
|
||||||
streaming: boolean
|
streaming: boolean
|
||||||
onInsert: () => void
|
onInsert: () => void
|
||||||
onDiscard: () => void
|
onDiscard: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<motion.div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
style={{ background: 'rgba(0,0,0,0.4)' }}
|
style={{ background: 'rgba(0,0,0,0.4)' }}
|
||||||
onClick={onDiscard}
|
onClick={onDiscard}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
>
|
>
|
||||||
<div
|
<motion.div
|
||||||
className="rounded-2xl shadow-2xl flex flex-col"
|
className="rounded-2xl shadow-2xl flex flex-col"
|
||||||
style={{ background: 'var(--bg)', border: '1px solid var(--border)', width: 520, maxHeight: '70vh' }}
|
style={{ background: 'var(--bg)', border: '1px solid var(--border)', width: 520, maxHeight: '70vh' }}
|
||||||
onClick={e => e.stopPropagation()}
|
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 justify-between px-5 py-3 shrink-0" style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles size={14} style={{ color: 'var(--accent)' }} />
|
<Sparkles size={14} style={{ color: 'var(--accent)' }} />
|
||||||
<span className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
<span className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
||||||
{type === 'polish' ? 'AI 润色结果' : 'AI 摘要结果'}
|
{type === 'polish' ? 'AI 润色结果' : type === 'translate' ? 'AI 翻译结果' : 'AI 摘要结果'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onDiscard} className="toolbar-btn"><X size={14} /></button>
|
<button onClick={onDiscard} className="toolbar-btn"><X size={14} /></button>
|
||||||
@ -593,10 +606,10 @@ function AiResultModal({ type, result, streaming, onInsert, onDiscard }: {
|
|||||||
className="px-3 py-1.5 rounded-lg text-sm font-medium"
|
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)' }}
|
style={{ background: result && !streaming ? 'var(--accent)' : 'var(--bg-muted)', color: result && !streaming ? '#fff' : 'var(--text-faint)' }}
|
||||||
>
|
>
|
||||||
{type === 'polish' ? '替换选中内容' : '插入摘要'}
|
{type === 'polish' ? '替换选中内容' : type === 'translate' ? '替换选中内容' : '插入摘要'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import {
|
import {
|
||||||
FileText, Folder, Slash, MousePointer, Save,
|
FileText, Folder, Slash, MousePointer, Save,
|
||||||
Search, Moon, Plus, Hash, Star, Image, Code2,
|
Search, Moon, Plus, Hash, Star, Image, Code2,
|
||||||
BookOpen, Maximize2,
|
BookOpen, Maximize2, Zap, Wand2, Bot,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAppStore } from '../../stores/appStore'
|
import { useAppStore } from '../../stores/appStore'
|
||||||
|
|
||||||
@ -72,6 +72,26 @@ const FEATURES = [
|
|||||||
title: '深色模式',
|
title: '深色模式',
|
||||||
desc: '点击左上角图标一键切换亮色 / 暗色主题',
|
desc: '点击左上角图标一键切换亮色 / 暗色主题',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: <Zap size={18} />,
|
||||||
|
title: 'AI 续写',
|
||||||
|
desc: '点击标题旁 ⚡ 按钮,AI 基于当前上下文流式续写下一段',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Wand2 size={18} />,
|
||||||
|
title: 'AI 润色',
|
||||||
|
desc: '选中文字 → 浮动工具栏 → 润色,流式预览后一键替换原文',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <FileText size={18} />,
|
||||||
|
title: 'AI 摘要',
|
||||||
|
desc: '选中段落生成片段摘要,或在 AI 面板一键生成全文摘要',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Bot size={18} />,
|
||||||
|
title: 'AI 问答',
|
||||||
|
desc: '点击 ✨ 打开 AI 助手面板,针对当前笔记内容自由提问',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const SHORTCUTS: { mac: string[]; win: string[]; desc: string }[] = [
|
const SHORTCUTS: { mac: string[]; win: string[]; desc: string }[] = [
|
||||||
@ -83,6 +103,9 @@ const SHORTCUTS: { mac: string[]; win: string[]; desc: string }[] = [
|
|||||||
{ mac: ['⌘', '⇧', 'Z'], win: ['Ctrl', 'Y'], desc: '重做' },
|
{ mac: ['⌘', '⇧', 'Z'], win: ['Ctrl', 'Y'], desc: '重做' },
|
||||||
{ mac: ['/'], win: ['/'], desc: '命令菜单' },
|
{ mac: ['/'], win: ['/'], desc: '命令菜单' },
|
||||||
{ mac: ['Esc'], win: ['Esc'], desc: '退出专注模式' },
|
{ mac: ['Esc'], win: ['Esc'], desc: '退出专注模式' },
|
||||||
|
{ mac: ['⌘', 'N'], win: ['Ctrl', 'N'], desc: '新建笔记' },
|
||||||
|
{ mac: ['⌘', '\\'], win: ['Ctrl', '\\'], desc: '切换专注模式' },
|
||||||
|
{ mac: ['⌘', '⇧', 'J'], win: ['Ctrl', '⇧', 'J'], desc: '打开 AI 助手' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function detectOS(): 'mac' | 'win' {
|
function detectOS(): 'mac' | 'win' {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { AnimatePresence } from 'framer-motion'
|
||||||
import {
|
import {
|
||||||
Search, Plus, Star, FileText, Folder, FolderOpen,
|
Search, Plus, Star, FileText, Folder, FolderOpen,
|
||||||
ChevronRight, ChevronDown,
|
ChevronRight, ChevronDown,
|
||||||
@ -358,7 +359,9 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
{modelModalOpen && <ModelSettingsModal onClose={() => setModelModalOpen(false)} />}
|
{modelModalOpen && <ModelSettingsModal onClose={() => setModelModalOpen(false)} />}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Drag overlay */}
|
{/* Drag overlay */}
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
export type AIStreamRequest = {
|
export type AIStreamRequest = {
|
||||||
type: 'continue' | 'polish' | 'summarize' | 'chat'
|
type: 'continue' | 'polish' | 'summarize' | 'translate' | 'chat'
|
||||||
noteContent: string
|
noteContent: string
|
||||||
selection?: string
|
selection?: string
|
||||||
messages?: { role: 'user' | 'assistant'; content: string }[]
|
messages?: { role: 'user' | 'assistant'; content: string }[]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user