import { useCallback, useEffect, useRef, useState } from 'react' 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, } from 'lucide-react' import { useAppStore } from '../../stores/appStore' import { countWords } from '../../lib/utils' import { WelcomeView } from './WelcomeView' 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 } = 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 [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') { setSlashOpen(false) 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) 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) } if (!activeNote || activeNoteId === '__welcome__') { return } const readingTime = Math.max(1, Math.ceil(wordCount / 250)) 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' }} /> activeNoteId && toggleStar(activeNoteId)} title={activeNote.starred ? '取消收藏' : '收藏'} className="toolbar-btn shrink-0 mt-2" > {focusMode ? : } {/* Floating toolbar */} {editor && ( )} {/* Editor scroll */} {slashOpen && filteredSlash.length > 0 && ( )} {/* Footer */} {wordCount} 字 · 约 {readingTime} 分钟阅读 {saveStatus === 'saving' ? '保存中…' : saveStatus === 'unsaved' ? '未保存' : '已自动保存'} ) } function FloatingToolbar({ editor }: { editor: NonNullable> }) { const [visible, setVisible] = useState(false) 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 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()}> ) } function ToolbarBtn({ children, active, title, onClick }: { children: React.ReactNode; active: boolean; title?: string; onClick: () => void }) { return ( { e.preventDefault(); onClick() }} className={`toolbar-btn ${active ? 'active' : ''}`}> {children} ) }