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 <noreply@anthropic.com>
This commit is contained in:
MikiVL 2026-05-02 19:24:34 +08:00
parent bf2b16c78c
commit 54366cb60c
4 changed files with 152 additions and 51 deletions

View File

@ -5,7 +5,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 } = useAppStore() const { loadAll, theme, focusMode } = useAppStore()
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', theme) document.documentElement.setAttribute('data-theme', theme)
@ -19,7 +19,9 @@ export default function App() {
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)' }}>
<Sidebar /> <div className={`sidebar-panel${focusMode ? ' hidden' : ''}`} style={{ width: 260 }}>
<Sidebar />
</div>
<Editor /> <Editor />
</div> </div>
) )

View File

@ -8,7 +8,6 @@ import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item' import TaskItem from '@tiptap/extension-task-item'
import { Table } from '@tiptap/extension-table' import { Table } from '@tiptap/extension-table'
import TableRow from '@tiptap/extension-table-row' import TableRow from '@tiptap/extension-table-row'
import { WelcomeView } from './WelcomeView'
import TableCell from '@tiptap/extension-table-cell' import TableCell from '@tiptap/extension-table-cell'
import TableHeader from '@tiptap/extension-table-header' import TableHeader from '@tiptap/extension-table-header'
import CharacterCount from '@tiptap/extension-character-count' import CharacterCount from '@tiptap/extension-character-count'
@ -16,14 +15,20 @@ import Typography from '@tiptap/extension-typography'
import Link from '@tiptap/extension-link' import Link from '@tiptap/extension-link'
import Highlight from '@tiptap/extension-highlight' import Highlight from '@tiptap/extension-highlight'
import { TextStyle } from '@tiptap/extension-text-style' 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 { import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code, Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code,
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, Type, Star, ImageIcon, Maximize2, Minimize2,
} 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'
import { WelcomeView } from './WelcomeView'
const lowlight = createLowlight(common)
// Slash command items // Slash command items
const SLASH_ITEMS = [ const SLASH_ITEMS = [
@ -35,7 +40,11 @@ const SLASH_ITEMS = [
{ label: '有序列表', desc: '1. 项目', icon: <ListOrdered size={14} />, action: (e: ReturnType<typeof useEditor>) => e?.chain().focus().toggleOrderedList().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: <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: <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: '含语法高亮', 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: <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() }, { label: '表格', desc: '插入表格', icon: <TableIcon size={14} />, action: (e: ReturnType<typeof useEditor>) => e?.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
] ]
@ -67,7 +76,7 @@ function SlashMenu({ items, selectedIndex, onSelect }: {
} }
export function Editor() { 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 activeNote = notes.find(n => n.id === activeNoteId)
const [title, setTitle] = useState(activeNote?.title ?? '') const [title, setTitle] = useState(activeNote?.title ?? '')
@ -88,6 +97,15 @@ export function Editor() {
item.label.toLowerCase().includes(slashQuery.toLowerCase()) 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) => { const scheduleNoteSave = useCallback((content: string, wc: number) => {
if (!activeNoteId || isLoadingRef.current) return if (!activeNoteId || isLoadingRef.current) return
setSaveStatus('unsaved') setSaveStatus('unsaved')
@ -101,7 +119,7 @@ export function Editor() {
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit, StarterKit.configure({ codeBlock: false }),
Placeholder.configure({ placeholder: '开始写作,或输入 / 呼出命令菜单…' }), Placeholder.configure({ placeholder: '开始写作,或输入 / 呼出命令菜单…' }),
Underline, Underline,
TaskList, TaskList,
@ -116,6 +134,8 @@ export function Editor() {
TextStyle, TextStyle,
Link.configure({ openOnClick: false }), Link.configure({ openOnClick: false }),
BubbleMenu.configure({ pluginKey: 'bubbleMenu' }), BubbleMenu.configure({ pluginKey: 'bubbleMenu' }),
CodeBlockLowlight.configure({ lowlight }),
Image.configure({ allowBase64: true }),
], ],
content: activeNote?.content ? JSON.parse(activeNote.content) : '', content: activeNote?.content ? JSON.parse(activeNote.content) : '',
onUpdate({ editor: ed }) { onUpdate({ editor: ed }) {
@ -150,6 +170,43 @@ export function Editor() {
} }
return false 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 <WelcomeView /> return <WelcomeView />
} }
const readingTime = Math.max(1, Math.ceil(wordCount / 250))
return ( return (
<div className="flex-1 flex flex-col min-w-0 h-full" style={{ background: 'var(--bg)' }}> <div className="flex-1 flex flex-col min-w-0 h-full" style={{ background: 'var(--bg)' }}>
{/* Title */} {/* Title row */}
<div className="px-12 pt-10 pb-0 max-w-3xl mx-auto w-full"> <div className="px-12 pt-10 pb-0 max-w-3xl mx-auto w-full">
<div className="flex items-start gap-3"> <div className="flex items-start gap-2">
<input <input
value={title} value={title}
onChange={e => handleTitleChange(e.target.value)} onChange={e => handleTitleChange(e.target.value)}
@ -240,7 +299,6 @@ export function Editor() {
onClick={() => activeNoteId && toggleStar(activeNoteId)} onClick={() => activeNoteId && toggleStar(activeNoteId)}
title={activeNote.starred ? '取消收藏' : '收藏'} title={activeNote.starred ? '取消收藏' : '收藏'}
className="toolbar-btn shrink-0 mt-2" className="toolbar-btn shrink-0 mt-2"
style={{ width: 28, height: 28 }}
> >
<Star <Star
size={16} size={16}
@ -248,19 +306,21 @@ export function Editor() {
style={{ color: activeNote.starred ? '#f59e0b' : 'var(--text-faint)' }} style={{ color: activeNote.starred ? '#f59e0b' : 'var(--text-faint)' }}
/> />
</button> </button>
<button
onClick={toggleFocusMode}
title={focusMode ? '退出专注模式 (Esc)' : '专注模式'}
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>
</div> </div>
{/* Floating bubble menu rendered via portal */} {/* Floating toolbar */}
{editor && ( {editor && (
<div <div style={{ position: 'fixed', zIndex: 100, pointerEvents: 'none' }}>
id="bubble-menu-portal"
style={{
position: 'fixed',
zIndex: 100,
pointerEvents: 'none',
}}
>
<FloatingToolbar editor={editor} /> <FloatingToolbar editor={editor} />
</div> </div>
)} )}
@ -271,15 +331,8 @@ export function Editor() {
<EditorContent editor={editor} /> <EditorContent editor={editor} />
{slashOpen && filteredSlash.length > 0 && ( {slashOpen && filteredSlash.length > 0 && (
<div <div className="absolute z-50" style={{ top: slashPos.top, left: Math.max(0, slashPos.left) }}>
className="absolute z-50" <SlashMenu items={filteredSlash} selectedIndex={slashIndex} onSelect={executeSlash} />
style={{ top: slashPos.top, left: Math.max(0, slashPos.left) }}
>
<SlashMenu
items={filteredSlash}
selectedIndex={slashIndex}
onSelect={executeSlash}
/>
</div> </div>
)} )}
</div> </div>
@ -288,13 +341,9 @@ export function Editor() {
{/* Footer */} {/* Footer */}
<div <div
className="flex items-center justify-between px-12 py-2 text-xs shrink-0" className="flex items-center justify-between px-12 py-2 text-xs shrink-0"
style={{ style={{ borderTop: '1px solid var(--border)', color: 'var(--text-faint)', background: 'var(--bg)' }}
borderTop: '1px solid var(--border)',
color: 'var(--text-faint)',
background: 'var(--bg)',
}}
> >
<span>{wordCount} </span> <span>{wordCount} · {readingTime} </span>
<span style={{ color: saveStatus === 'saved' ? 'var(--text-faint)' : 'var(--accent)' }}> <span style={{ color: saveStatus === 'saved' ? 'var(--text-faint)' : 'var(--accent)' }}>
{saveStatus === 'saving' ? '保存中…' : saveStatus === 'unsaved' ? '未保存' : '已自动保存'} {saveStatus === 'saving' ? '保存中…' : saveStatus === 'unsaved' ? '未保存' : '已自动保存'}
</span> </span>
@ -316,24 +365,19 @@ function FloatingToolbar({ editor }: { editor: NonNullable<ReturnType<typeof use
const start = view.coordsAtPos(from) const start = view.coordsAtPos(from)
const end = view.coordsAtPos(to) const end = view.coordsAtPos(to)
const top = Math.min(start.top, end.top) - 48 const top = Math.min(start.top, end.top) - 48
const left = (start.left + end.left) / 2 - 100 const left = (start.left + end.left) / 2 - 110
setPos({ top: Math.max(8, top), left: Math.max(8, left) }) setPos({ top: Math.max(8, top), left: Math.max(8, left) })
setVisible(true) setVisible(true)
} }
editor.on('selectionUpdate', update) editor.on('selectionUpdate', update)
editor.on('blur', () => setVisible(false)) editor.on('blur', () => setVisible(false))
return () => { return () => { editor.off('selectionUpdate', update) }
editor.off('selectionUpdate', update)
}
}, [editor]) }, [editor])
if (!visible) return null if (!visible) return null
return ( return (
<div <div className="floating-toolbar" style={{ position: 'fixed', top: pos.top, left: pos.left, pointerEvents: 'auto', zIndex: 100 }}>
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('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('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('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}><UnderlineIcon size={13} /></ToolbarBtn>
@ -350,17 +394,10 @@ function FloatingToolbar({ editor }: { editor: NonNullable<ReturnType<typeof use
} }
function ToolbarBtn({ children, active, title, onClick }: { function ToolbarBtn({ children, active, title, onClick }: {
children: React.ReactNode children: React.ReactNode; active: boolean; title?: string; onClick: () => void
active: boolean
title?: string
onClick: () => void
}) { }) {
return ( return (
<button <button title={title} onMouseDown={(e) => { e.preventDefault(); onClick() }} className={`toolbar-btn ${active ? 'active' : ''}`}>
title={title}
onMouseDown={(e) => { e.preventDefault(); onClick() }}
className={`toolbar-btn ${active ? 'active' : ''}`}
>
{children} {children}
</button> </button>
) )

View File

@ -212,3 +212,60 @@
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-faint); } ::-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; }

View File

@ -9,6 +9,7 @@ interface AppState {
activeFolderId: string | null | 'all' | 'starred' activeFolderId: string | null | 'all' | 'starred'
searchQuery: string searchQuery: string
theme: 'light' | 'dark' theme: 'light' | 'dark'
focusMode: boolean
// actions // actions
loadAll: () => Promise<void> loadAll: () => Promise<void>
@ -25,6 +26,7 @@ interface AppState {
setActiveFolder: (id: string | null | 'all' | 'starred') => void setActiveFolder: (id: string | null | 'all' | 'starred') => void
setSearch: (q: string) => void setSearch: (q: string) => void
toggleTheme: () => void toggleTheme: () => void
toggleFocusMode: () => void
filteredNotes: () => Note[] filteredNotes: () => Note[]
} }
@ -36,6 +38,7 @@ export const useAppStore = create<AppState>((set, get) => ({
activeFolderId: 'all', activeFolderId: 'all',
searchQuery: '', searchQuery: '',
theme: (localStorage.getItem('theme') as 'light' | 'dark') || 'light', theme: (localStorage.getItem('theme') as 'light' | 'dark') || 'light',
focusMode: false,
loadAll: async () => { loadAll: async () => {
const [notes, folders] = await Promise.all([ const [notes, folders] = await Promise.all([
@ -128,6 +131,8 @@ export const useAppStore = create<AppState>((set, get) => ({
set({ theme: next }) set({ theme: next })
}, },
toggleFocusMode: () => set(s => ({ focusMode: !s.focusMode })),
filteredNotes: () => { filteredNotes: () => {
const { notes, activeFolderId, searchQuery } = get() const { notes, activeFolderId, searchQuery } = get()
let result = notes let result = notes