diff --git a/src/App.tsx b/src/App.tsx
index 75f021c..c8685b7 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,7 +5,7 @@ import { useAppStore } from './stores/appStore'
import { seedIfEmpty, deduplicateDB } from './db'
export default function App() {
- const { loadAll, theme } = useAppStore()
+ const { loadAll, theme, focusMode } = useAppStore()
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
@@ -19,7 +19,9 @@ export default function App() {
return (
)
diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx
index 5ed3902..28f3ab0 100644
--- a/src/components/editor/Editor.tsx
+++ b/src/components/editor/Editor.tsx
@@ -8,7 +8,6 @@ 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 { WelcomeView } from './WelcomeView'
import TableCell from '@tiptap/extension-table-cell'
import TableHeader from '@tiptap/extension-table-header'
import CharacterCount from '@tiptap/extension-character-count'
@@ -16,14 +15,20 @@ 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,
+ 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 = [
@@ -35,7 +40,11 @@ const SLASH_ITEMS = [
{ 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: '含语法高亮', 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() },
]
@@ -67,7 +76,7 @@ function SlashMenu({ items, selectedIndex, onSelect }: {
}
export function Editor() {
- const { activeNoteId, notes, updateNote, toggleStar } = useAppStore()
+ const { activeNoteId, notes, updateNote, toggleStar, focusMode, toggleFocusMode } = useAppStore()
const activeNote = notes.find(n => n.id === activeNoteId)
const [title, setTitle] = useState(activeNote?.title ?? '')
@@ -88,6 +97,15 @@ export function Editor() {
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')
@@ -101,7 +119,7 @@ export function Editor() {
const editor = useEditor({
extensions: [
- StarterKit,
+ StarterKit.configure({ codeBlock: false }),
Placeholder.configure({ placeholder: '开始写作,或输入 / 呼出命令菜单…' }),
Underline,
TaskList,
@@ -116,6 +134,8 @@ export function Editor() {
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 }) {
@@ -150,6 +170,43 @@ export function Editor() {
}
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
+ },
},
})
@@ -224,11 +281,13 @@ export function Editor() {
return
}
+ const readingTime = Math.max(1, Math.ceil(wordCount / 250))
+
return (
- {/* Title */}
+ {/* Title row */}
-
- {/* Floating bubble menu rendered via portal */}
+ {/* Floating toolbar */}
{editor && (
-