From 54366cb60c970a4c3846cf5ffdfdbce3d92e6143 Mon Sep 17 00:00:00 2001 From: MikiVL Date: Sat, 2 May 2026 19:24:34 +0800 Subject: [PATCH] feat(M1): complete remaining editor features - Replace StarterKit codeBlock with CodeBlockLowlight (lowlight/common) for syntax highlighting - Add Image extension with URL slash command, drag-and-drop and paste support - Show reading time estimate in editor footer (wordCount / 250 min) - Implement focus mode: sidebar collapses with CSS transition, toggle button in title bar, Esc to exit - Add focusMode state + toggleFocusMode action to Zustand store - Add highlight.js CSS theme (light/dark) and image/sidebar-transition styles Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 6 +- src/components/editor/Editor.tsx | 135 ++++++++++++++++++++----------- src/index.css | 57 +++++++++++++ src/stores/appStore.ts | 5 ++ 4 files changed, 152 insertions(+), 51 deletions(-) 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 */}
-
+
handleTitleChange(e.target.value)} @@ -240,7 +299,6 @@ export function Editor() { onClick={() => activeNoteId && toggleStar(activeNoteId)} title={activeNote.starred ? '取消收藏' : '收藏'} className="toolbar-btn shrink-0 mt-2" - style={{ width: 28, height: 28 }} > +
- {/* Floating bubble menu rendered via portal */} + {/* Floating toolbar */} {editor && ( -
+
)} @@ -271,15 +331,8 @@ export function Editor() { {slashOpen && filteredSlash.length > 0 && ( -
- +
+
)}
@@ -288,13 +341,9 @@ export function Editor() { {/* Footer */}
- {wordCount} 字 + {wordCount} 字 · 约 {readingTime} 分钟阅读 {saveStatus === 'saving' ? '保存中…' : saveStatus === 'unsaved' ? '未保存' : '已自动保存'} @@ -316,24 +365,19 @@ function FloatingToolbar({ editor }: { editor: NonNullable setVisible(false)) - return () => { - editor.off('selectionUpdate', update) - } + 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()}> @@ -350,17 +394,10 @@ function FloatingToolbar({ editor }: { editor: NonNullable void + children: React.ReactNode; active: boolean; title?: string; onClick: () => void }) { return ( - ) diff --git a/src/index.css b/src/index.css index afeab5a..429899c 100644 --- a/src/index.css +++ b/src/index.css @@ -212,3 +212,60 @@ ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { background: var(--text-faint); } + +/* ── Sidebar focus-mode transition ── */ +.sidebar-panel { + transition: width 0.25s ease, opacity 0.2s ease; + overflow: hidden; +} +.sidebar-panel.hidden { + width: 0 !important; + opacity: 0; + pointer-events: none; +} + +/* ── Image in editor ── */ +.ProseMirror img { + max-width: 100%; + border-radius: 6px; + margin: 0.75em 0; + display: block; +} +.ProseMirror img.ProseMirror-selectednode { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* ── Syntax highlighting (highlight.js / lowlight) ── */ +/* Light theme */ +:root { + --hl-comment: #6a737d; + --hl-keyword: #d73a49; + --hl-string: #032f62; + --hl-number: #005cc5; + --hl-function: #6f42c1; + --hl-type: #22863a; + --hl-meta: #e36209; + --hl-builtin: #005cc5; +} +[data-theme="dark"] { + --hl-comment: #8b949e; + --hl-keyword: #ff7b72; + --hl-string: #a5d6ff; + --hl-number: #79c0ff; + --hl-function: #d2a8ff; + --hl-type: #7ee787; + --hl-meta: #ffa657; + --hl-builtin: #79c0ff; +} + +.hljs-comment, .hljs-quote { color: var(--hl-comment); font-style: italic; } +.hljs-keyword, .hljs-selector-tag, .hljs-addition { color: var(--hl-keyword); font-weight: 500; } +.hljs-string, .hljs-attr, .hljs-doctag { color: var(--hl-string); } +.hljs-number, .hljs-literal, .hljs-boolean { color: var(--hl-number); } +.hljs-title, .hljs-function, .hljs-name { color: var(--hl-function); } +.hljs-type, .hljs-class, .hljs-tag { color: var(--hl-type); } +.hljs-built_in, .hljs-variable, .hljs-params { color: var(--hl-builtin); } +.hljs-meta, .hljs-deletion { color: var(--hl-meta); } +.hljs-emphasis { font-style: italic; } +.hljs-strong { font-weight: bold; } diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 0eae1c8..1dd1ebd 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -9,6 +9,7 @@ interface AppState { activeFolderId: string | null | 'all' | 'starred' searchQuery: string theme: 'light' | 'dark' + focusMode: boolean // actions loadAll: () => Promise @@ -25,6 +26,7 @@ interface AppState { setActiveFolder: (id: string | null | 'all' | 'starred') => void setSearch: (q: string) => void toggleTheme: () => void + toggleFocusMode: () => void filteredNotes: () => Note[] } @@ -36,6 +38,7 @@ export const useAppStore = create((set, get) => ({ activeFolderId: 'all', searchQuery: '', theme: (localStorage.getItem('theme') as 'light' | 'dark') || 'light', + focusMode: false, loadAll: async () => { const [notes, folders] = await Promise.all([ @@ -128,6 +131,8 @@ export const useAppStore = create((set, get) => ({ set({ theme: next }) }, + toggleFocusMode: () => set(s => ({ focusMode: !s.focusMode })), + filteredNotes: () => { const { notes, activeFolderId, searchQuery } = get() let result = notes