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'
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 (
<div className="flex h-screen w-full overflow-hidden" style={{ background: 'var(--bg)' }}>
<div className={`sidebar-panel${focusMode ? ' hidden' : ''}`} style={{ width: 260 }}>
<Sidebar />
</div>
<Editor />
</div>
)

View File

@ -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: <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: <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: <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() {
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 <WelcomeView />
}
const readingTime = Math.max(1, Math.ceil(wordCount / 250))
return (
<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="flex items-start gap-3">
<div className="flex items-start gap-2">
<input
value={title}
onChange={e => 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 }}
>
<Star
size={16}
@ -248,19 +306,21 @@ export function Editor() {
style={{ color: activeNote.starred ? '#f59e0b' : 'var(--text-faint)' }}
/>
</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>
{/* Floating bubble menu rendered via portal */}
{/* Floating toolbar */}
{editor && (
<div
id="bubble-menu-portal"
style={{
position: 'fixed',
zIndex: 100,
pointerEvents: 'none',
}}
>
<div style={{ position: 'fixed', zIndex: 100, pointerEvents: 'none' }}>
<FloatingToolbar editor={editor} />
</div>
)}
@ -271,15 +331,8 @@ export function Editor() {
<EditorContent editor={editor} />
{slashOpen && filteredSlash.length > 0 && (
<div
className="absolute z-50"
style={{ top: slashPos.top, left: Math.max(0, slashPos.left) }}
>
<SlashMenu
items={filteredSlash}
selectedIndex={slashIndex}
onSelect={executeSlash}
/>
<div className="absolute z-50" style={{ top: slashPos.top, left: Math.max(0, slashPos.left) }}>
<SlashMenu items={filteredSlash} selectedIndex={slashIndex} onSelect={executeSlash} />
</div>
)}
</div>
@ -288,13 +341,9 @@ export function Editor() {
{/* Footer */}
<div
className="flex items-center justify-between px-12 py-2 text-xs shrink-0"
style={{
borderTop: '1px solid var(--border)',
color: 'var(--text-faint)',
background: 'var(--bg)',
}}
style={{ 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)' }}>
{saveStatus === 'saving' ? '保存中…' : saveStatus === 'unsaved' ? '未保存' : '已自动保存'}
</span>
@ -316,24 +365,19 @@ function FloatingToolbar({ editor }: { editor: NonNullable<ReturnType<typeof use
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 - 100
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)
}
return () => { editor.off('selectionUpdate', update) }
}, [editor])
if (!visible) return null
return (
<div
className="floating-toolbar"
style={{ position: 'fixed', top: pos.top, left: pos.left, pointerEvents: 'auto', zIndex: 100 }}
>
<div 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('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>
@ -350,17 +394,10 @@ function FloatingToolbar({ editor }: { editor: NonNullable<ReturnType<typeof use
}
function ToolbarBtn({ children, active, title, onClick }: {
children: React.ReactNode
active: boolean
title?: string
onClick: () => void
children: React.ReactNode; active: boolean; title?: string; onClick: () => void
}) {
return (
<button
title={title}
onMouseDown={(e) => { e.preventDefault(); onClick() }}
className={`toolbar-btn ${active ? 'active' : ''}`}
>
<button title={title} onMouseDown={(e) => { e.preventDefault(); onClick() }} className={`toolbar-btn ${active ? 'active' : ''}`}>
{children}
</button>
)

View File

@ -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; }

View File

@ -9,6 +9,7 @@ interface AppState {
activeFolderId: string | null | 'all' | 'starred'
searchQuery: string
theme: 'light' | 'dark'
focusMode: boolean
// actions
loadAll: () => Promise<void>
@ -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<AppState>((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<AppState>((set, get) => ({
set({ theme: next })
},
toggleFocusMode: () => set(s => ({ focusMode: !s.focusMode })),
filteredNotes: () => {
const { notes, activeFolderId, searchQuery } = get()
let result = notes