UI升级,功能优化,代码拆分

This commit is contained in:
2026-05-18 10:03:04 +08:00
parent b56c63e166
commit c0d71e57ae
17 changed files with 2302 additions and 509 deletions
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProtoPluginMemos">
<option name="items">
<list>
<MemoItem>
<option name="id" value="16a693e9-ab6a-479b-9f7d-b4af466ec8df" />
<option name="updated" value="1778858615003" />
</MemoItem>
</list>
</option>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+85 -17
View File
@@ -1,6 +1,37 @@
# Huangzhijun's Reader # Huangzhijun's Reader
本地 Markdown 阅读与编辑(Electron + Vite + React + CodeMirror 6)。侧重「长文阅读体验」与「轻量编辑」:多标签、会话恢复、侧栏文件树与大纲、阅读/编辑/分栏三模式、外观与行距可调。 本地 Markdown 阅读与编辑Electron + Vite + React + CodeMirror 6)。侧重「长文阅读体验」与「轻量编辑」:多标签、会话恢复、侧栏文件树与大纲、阅读/编辑/分栏三模式、外观与行距可调。
## 功能特性
### 核心功能
- **多标签编辑**:支持同时打开多个 Markdown 文件,标签页可拖拽排序
- **三种视图模式**:编辑 / 阅读 / 分栏(编辑+预览),一键切换
- **文件树浏览**:打开本地文件夹,侧栏展示目录结构,点击 `.md` / `.txt` 文件即可打开
- **会话恢复**:关闭应用后重新打开,自动恢复上次的标签页、视图模式、外观设置
- **文件历史**:记录最近打开过的 Markdown 文件路径,方便快速访问
### 编辑功能
- **Markdown 格式工具栏**:分组展示常用格式按钮(标题、格式、元素、列表、其他)
- **语法高亮**:编辑器内 Markdown 语法高亮,代码块语言高亮
- **图片插入**:支持插入本地图片,自动解析相对路径
- **表格编辑**:支持 Markdown 表格的快速插入与格式化
- **Mermaid 图表**:支持在 Markdown 中编写 Mermaid 语法,预览时自动渲染
### 阅读体验
- **多套背景主题**:提供多种阅读背景(含「Kindle 纸感」等),可自由切换
- **字体选择**:支持多种中文字体,阅读区与编辑区字体独立设置
- **行距调节**:阅读视图行距可精细调节(1.0–3.0)
- **预览宽度调节**:阅读模式下可拖动调整正文最大宽度
- **大纲导航**:自动解析文档标题层级,点击跳转到对应位置
### 新增功能(v0.2.0
- **自动保存**:开启后,编辑内容在 3 秒无操作后自动保存到文件(仅对有文件路径的标签生效)
- **拖放打开**:支持从系统文件管理器拖放 `.md` / `.txt` 文件到编辑区直接打开
- **导出 HTML**:将当前 Markdown 文档导出为独立 HTML 文件,包含基本样式
- **文档内搜索**`Ctrl+F` 打开搜索栏,支持在编辑/阅读/分栏三种模式下搜索文档内容
- **状态栏**:底部状态栏显示当前文档的行数、字符数(含/不含空白)、词数统计
- **拖放视觉反馈**:拖放文件到编辑区时显示虚线边框高亮
## 设计思路 ## 设计思路
@@ -17,18 +48,25 @@
编辑器与预览共享文档字符串;预览 DOM 为块级元素打上源行号,用于大纲跳转与分栏滚动对齐。 编辑器与预览共享文档字符串;预览 DOM 为块级元素打上源行号,用于大纲跳转与分栏滚动对齐。
5. **持久化** 5. **持久化**
标签内容与未保存状态、当前背景/字体等写入 `localStorage`(会话键 `myreader-session-v1`);打开过的 Markdown 路径记入「历史」列表,与真实文件删除无关。 标签内容与未保存状态、当前背景/字体等写入 `localStorage`(会话键 `myreader-session-v1`);打开过的 Markdown 路径记入「历史」列表,与真实文件删除无关。自动保存开关状态持久化到 `localStorage`
## 使用步骤(简述) ## 使用步骤
1. **安装依赖**:在项目根目录执行 `npm install` 1. **安装依赖**:在项目根目录执行 `npm install`
2. **开发运行**`npm run dev`,在 Electron 窗口内使用。 2. **开发运行**`npm run dev`,在 Electron 窗口内使用。
3. **打开文档**工具栏「打开」、侧栏「打开文件夹」后点击 `.md` 文件,或从历史记录点选。 3. **打开文档**
4. **视图**:工具栏「视图」或使用 `Ctrl+E`**编辑 / 阅读 / 分栏** 间切换。 - 工具栏「打开」选择 `.md` 文件
5. **外观**:工具栏右侧 **阅读行距**、**字体**、**背景**(含「Kindle 纸感」等)按需调整。 - 侧栏「打开文件夹」后点击文件树中的 `.md` / `.txt` 文件
6. **图片**:编辑模式下用工具栏插入本地图片;保存路径为 `file://` 或相对路径时,阅读/分栏预览会按上文规则解析显示。 - 从历史记录点选
7. **图表**:在 Markdown 中使用 ` ```mermaid ` 围栏编写 Mermaid 语法即可在预览中渲染。 - **拖放** `.md` / `.txt` 文件到编辑区
8. **构建与打包**`npm run build` 输出到 `out/``npm run dist` 构建并生成 Windows 安装包至 `release/` 4. **视图切换**工具栏「视图」按钮或 `Ctrl+E`**编辑 / 阅读 / 分栏** 间循环切换。
5. **外观调整**:工具栏右侧可调节阅读行距、字体、背景(含「Kindle 纸感」等)。
6. **图片插入**:编辑模式下用工具栏插入本地图片;保存路径为 `file://` 或相对路径时,预览会按规则解析显示。
7. **Mermaid 图表**:使用 ` ```mermaid ` 围栏编写 Mermaid 语法即可在预览中渲染。
8. **自动保存**:工具栏「自动保存:开/关」按钮或 `Ctrl+Shift+F` 切换自动保存。
9. **导出 HTML**:工具栏「导出 HTML」按钮或 `Ctrl+Shift+E` 导出当前文档。
10. **文档搜索**`Ctrl+F` 打开搜索栏,输入关键词后按 `Enter` 或点击 `↓` 按钮查找。
11. **构建与打包**`npm run build` 输出到 `out/``npm run dist` 构建并生成 Windows 安装包至 `release/`
## 脚本 ## 脚本
@@ -36,19 +74,49 @@
|------|------| |------|------|
| `npm run dev` | 开发调试 | | `npm run dev` | 开发调试 |
| `npm run build` | 构建到 `out/` | | `npm run build` | 构建到 `out/` |
| `npm run typecheck` | TypeScript 检查 | | `npm run typecheck` | TypeScript 类型检查 |
| `npm run dist` | 构建并打 Windows 安装包(`release/` | | `npm run dist` | 构建并打 Windows 安装包(`release/` |
## 快捷键(与应用菜单一致) ## 快捷键
- `Ctrl+O` 打开,`Ctrl+S` 保存,`Ctrl+Shift+S` 另存为 ### 文件操作
- `Ctrl+T` 新标签,`Ctrl+W` 关闭标签 | 快捷键 | 功能 |
- `Ctrl+Tab` / `Ctrl+Shift+Tab` 切换标签 |--------|------|
- `Ctrl+E`:在 **编辑 / 阅读 / 分栏** 三种视图间循环切换 | `Ctrl+O` | 打开文件 |
| `Ctrl+S` | 保存当前标签 |
| `Ctrl+Shift+S` | 另存为 |
| `Ctrl+Shift+O` | 打开文件夹 |
| `Ctrl+Shift+R` | 刷新文件树 |
侧栏可隐藏、可拖动右边缘调整宽度;阅读模式下可拖动预览区右缘调整正文最大宽度。编辑、分栏模式下顶部有 **Markdown 格式工具栏**。侧栏文件列表 **右键**:重命名(文件/文件夹)、删除(仅文件)。 ### 标签管理
| 快捷键 | 功能 |
|--------|------|
| `Ctrl+N` | 新建标签 |
| `Ctrl+W` | 关闭当前标签 |
| `Ctrl+Tab` | 切换到下一个标签 |
| `Ctrl+Shift+Tab` | 切换到上一个标签 |
| `Ctrl+1``9` | 切换到第 19 个标签 |
## 技术栈摘要 ### 视图与面板
| 快捷键 | 功能 |
|--------|------|
| `Ctrl+E` | 循环切换视图模式(编辑/阅读/分栏) |
| `Ctrl+B` | 显示/隐藏侧栏 |
| `Ctrl+Shift+1` | 侧栏切换到文件树 |
| `Ctrl+Shift+2` | 侧栏切换到历史记录 |
| `Ctrl+Shift+3` | 侧栏切换到大纲 |
### 工具
| 快捷键 | 功能 |
|--------|------|
| `Ctrl+F` | 打开/关闭文档搜索 |
| `Ctrl+Shift+F` | 切换自动保存 |
| `Ctrl+Shift+E` | 导出为 HTML |
| `Esc` | 关闭搜索栏 |
侧栏可隐藏、可拖动右边缘调整宽度;阅读模式下可拖动预览区右缘调整正文最大宽度。编辑、分栏模式下顶部有 **Markdown 格式工具栏**(按功能分组)。侧栏文件列表 **右键**:重命名(文件/文件夹)、删除(仅文件)。
## 技术栈
Electron 35、electron-vite、React 19、CodeMirror 6、markdown-it、highlight.js、DOMPurify、mermaid。 Electron 35、electron-vite、React 19、CodeMirror 6、markdown-it、highlight.js、DOMPurify、mermaid。
+498 -207
View File
@@ -2,7 +2,6 @@ import * as React from 'react'
import { EditorPane, type MarkdownEditorHandle } from './EditorPane' import { EditorPane, type MarkdownEditorHandle } from './EditorPane'
import type { EditorView } from '@codemirror/view' import type { EditorView } from '@codemirror/view'
import { MarkdownFormatToolbar } from './MarkdownFormatToolbar' import { MarkdownFormatToolbar } from './MarkdownFormatToolbar'
import { OutlinePanel } from './OutlinePanel'
import { PreviewArticle } from './PreviewArticle' import { PreviewArticle } from './PreviewArticle'
import { import {
extractOutline, extractOutline,
@@ -13,82 +12,24 @@ import {
} from './markdown' } from './markdown'
import { import {
addFileHistory, addFileHistory,
historyFileName,
loadFileHistory, loadFileHistory,
removeFileHistory removeFileHistory
} from './fileHistory' } from './fileHistory'
import { BACKGROUNDS, FONTS, validBackgroundId, validFontId, type ViewMode } from './appearance' import { BACKGROUNDS, FONTS, validBackgroundId, validFontId, type ViewMode } from './appearance'
import { COLOR_SWATCH_ROWS } from './colorPresets' import { COLOR_SWATCH_ROWS } from './colorPresets'
import type { Tab } from './types'
type Tab = { import { clamp, newTab, tabLabel, isDirty, loadViewMode, viewModeLabel, loadPreviewLineHeight, normalizePathKey } from './utils'
id: string import {
filePath: string | null BG_KEY, FONT_KEY, VIEW_KEY, BROWSE_KEY, SIDEBAR_W_KEY, SIDEBAR_HIDDEN_KEY,
content: string STATS_W_KEY, STATS_HIDDEN_KEY, SPLIT_KEY, PREVIEW_W_KEY, PREVIEW_LINE_KEY, SESSION_KEY, AUTO_SAVE_KEY
savedContent: string } from './constants'
} import { TabBar } from './TabBar'
import { Sidebar } from './Sidebar'
const BG_KEY = 'myreader-bg-id' import { StatsPanel } from './StatsPanel'
const FONT_KEY = 'myreader-font-id'
const VIEW_KEY = 'myreader-view'
const LEGACY_READ_KEY = 'myreader-read'
const BROWSE_KEY = 'myreader-browse'
const SIDEBAR_W_KEY = 'myreader-sidebar-w'
const SIDEBAR_HIDDEN_KEY = 'myreader-sidebar-hidden'
const SPLIT_KEY = 'myreader-split'
const PREVIEW_W_KEY = 'myreader-preview-max'
const SESSION_KEY = 'myreader-session-v1'
const PREVIEW_LINE_KEY = 'myreader-preview-line-height'
function loadPreviewLineHeight(): string {
const raw = localStorage.getItem(PREVIEW_LINE_KEY)
if (!raw) return '1.5'
const n = Number.parseFloat(raw)
if (!Number.isFinite(n)) return '1.5'
return String(Math.min(3, Math.max(1, n)))
}
type SessionTabRow = { path: string | null; content: string; saved: string } type SessionTabRow = { path: string | null; content: string; saved: string }
type SessionV1 = { v: 1; activeIndex: number; tabs: SessionTabRow[]; bgId?: string; fontId?: string } type SessionV1 = { v: 1; activeIndex: number; tabs: SessionTabRow[]; bgId?: string; fontId?: string }
function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n))
}
function normalizePathKey(p: string): string {
return p.replace(/\\/g, '/').toLowerCase()
}
function newTab(): Tab {
const id = crypto.randomUUID()
return { id, filePath: null, content: '', savedContent: '' }
}
function tabLabel(t: Tab): string {
if (t.filePath) {
const parts = t.filePath.split(/[/\\]/)
return parts[parts.length - 1] || t.filePath
}
return '未命名'
}
function isDirty(t: Tab): boolean {
return t.content !== t.savedContent
}
function loadViewMode(): ViewMode {
const v = localStorage.getItem(VIEW_KEY) as ViewMode | null
if (v === 'edit' || v === 'read' || v === 'hybrid') return v
if (localStorage.getItem(LEGACY_READ_KEY) === '1') return 'read'
if (localStorage.getItem(LEGACY_READ_KEY) === '0') return 'edit'
return 'read'
}
function viewModeLabel(m: ViewMode): string {
if (m === 'edit') return '编辑'
if (m === 'read') return '阅读'
return '分栏'
}
export default function App(): React.ReactElement { export default function App(): React.ReactElement {
const api = window.myreader const api = window.myreader
const editorRef = React.useRef<MarkdownEditorHandle | null>(null) const editorRef = React.useRef<MarkdownEditorHandle | null>(null)
@@ -123,6 +64,13 @@ export default function App(): React.ReactElement {
const n = Number(localStorage.getItem(SIDEBAR_W_KEY) ?? '260') const n = Number(localStorage.getItem(SIDEBAR_W_KEY) ?? '260')
return Number.isFinite(n) ? clamp(n, 160, 560) : 260 return Number.isFinite(n) ? clamp(n, 160, 560) : 260
}) })
const [statsWidth, setStatsWidth] = React.useState<number>(() => {
const n = Number(localStorage.getItem(STATS_W_KEY) ?? '220')
return Number.isFinite(n) ? clamp(n, 160, 400) : 220
})
const [statsHidden, setStatsHidden] = React.useState<boolean>(
() => localStorage.getItem(STATS_HIDDEN_KEY) === '1'
)
const [splitPct, setSplitPct] = React.useState<number>(() => { const [splitPct, setSplitPct] = React.useState<number>(() => {
const n = Number(localStorage.getItem(SPLIT_KEY) ?? '48') const n = Number(localStorage.getItem(SPLIT_KEY) ?? '48')
return Number.isFinite(n) ? clamp(n, 22, 78) : 48 return Number.isFinite(n) ? clamp(n, 22, 78) : 48
@@ -170,6 +118,13 @@ export default function App(): React.ReactElement {
topPx: number topPx: number
seq: number seq: number
} | null>(null) } | null>(null)
const [autoSave, setAutoSave] = React.useState<boolean>(
() => localStorage.getItem(AUTO_SAVE_KEY) === '1'
)
const [statusText, setStatusText] = React.useState('')
const [searchVisible, setSearchVisible] = React.useState(false)
const [searchQuery, setSearchQuery] = React.useState('')
const [dragOver, setDragOver] = React.useState(false)
const activeTab = tabs.find((t) => t.id === activeId) ?? null const activeTab = tabs.find((t) => t.id === activeId) ?? null
const activeDoc = activeTab?.content ?? '' const activeDoc = activeTab?.content ?? ''
@@ -322,6 +277,14 @@ export default function App(): React.ReactElement {
localStorage.setItem(SIDEBAR_W_KEY, String(sidebarWidth)) localStorage.setItem(SIDEBAR_W_KEY, String(sidebarWidth))
}, [sidebarWidth]) }, [sidebarWidth])
React.useEffect(() => {
localStorage.setItem(STATS_W_KEY, String(statsWidth))
}, [statsWidth])
React.useEffect(() => {
localStorage.setItem(STATS_HIDDEN_KEY, statsHidden ? '1' : '0')
}, [statsHidden])
React.useEffect(() => { React.useEffect(() => {
localStorage.setItem(SPLIT_KEY, String(splitPct)) localStorage.setItem(SPLIT_KEY, String(splitPct))
}, [splitPct]) }, [splitPct])
@@ -334,13 +297,29 @@ export default function App(): React.ReactElement {
if (browseDir) localStorage.setItem(BROWSE_KEY, browseDir) if (browseDir) localStorage.setItem(BROWSE_KEY, browseDir)
}, [browseDir]) }, [browseDir])
React.useEffect(() => {
localStorage.setItem(AUTO_SAVE_KEY, autoSave ? '1' : '0')
}, [autoSave])
React.useEffect(() => {
if (!activeDoc) {
setStatusText('')
return
}
setStatusText('')
}, [activeDoc, activeLineCount])
React.useEffect(() => { React.useEffect(() => {
const onDoc = (): void => { const onDoc = (): void => {
setCtxMenu(null) setCtxMenu(null)
setHistCtxMenu(null) setHistCtxMenu(null)
} }
window.addEventListener('mousedown', onDoc) window.addEventListener('mousedown', onDoc)
return () => window.removeEventListener('mousedown', onDoc) window.addEventListener('scroll', onDoc, true)
return () => {
window.removeEventListener('mousedown', onDoc)
window.removeEventListener('scroll', onDoc, true)
}
}, []) }, [])
React.useEffect(() => { React.useEffect(() => {
@@ -585,12 +564,13 @@ export default function App(): React.ReactElement {
if (!api) return if (!api) return
const r = await api.openFileDialog() const r = await api.openFileDialog()
if (r.canceled || !r.path || r.content === undefined) return if (r.canceled || !r.path || r.content === undefined) return
const parent = await api.parentDirectory(r.path)
setBrowseDir(parent)
const existing = tabsRef.current.find( const existing = tabsRef.current.find(
(t) => r.path && t.filePath && normalizePathKey(t.filePath) === normalizePathKey(r.path) (t) => t.filePath && normalizePathKey(t.filePath) === normalizePathKey(r.path!)
) )
if (existing) { if (existing) {
setActiveId(existing.id) setActiveId(existing.id)
setBrowseDir(await api.parentDirectory(r.path))
recordFileOpen(r.path) recordFileOpen(r.path)
return return
} }
@@ -634,9 +614,21 @@ export default function App(): React.ReactElement {
await saveTabById(activeId) await saveTabById(activeId)
}, [activeId, saveTabById]) }, [activeId, saveTabById])
React.useEffect(() => {
if (!autoSave || !activeId) return
const t = tabsRef.current.find((x) => x.id === activeId)
if (!t || !t.filePath || !isDirty(t)) return
const timer = window.setTimeout(() => {
void saveTabById(activeId)
setStatusText('已自动保存')
window.setTimeout(() => setStatusText(''), 2000)
}, 3000)
return () => window.clearTimeout(timer)
}, [activeDoc, autoSave, activeId, saveTabById])
const saveActiveAs = React.useCallback(async (): Promise<void> => { const saveActiveAs = React.useCallback(async (): Promise<void> => {
if (!api) return if (!api) return
const t = tabs.find((x) => x.id === activeId) const t = tabsRef.current.find((x) => x.id === activeId)
if (!t) return if (!t) return
const d = await api.saveFileDialog(t.filePath ?? undefined) const d = await api.saveFileDialog(t.filePath ?? undefined)
if (d.canceled || !d.path) return if (d.canceled || !d.path) return
@@ -646,7 +638,7 @@ export default function App(): React.ReactElement {
x.id === activeId ? { ...x, filePath: d.path!, savedContent: x.content } : x x.id === activeId ? { ...x, filePath: d.path!, savedContent: x.content } : x
) )
) )
}, [api, activeId, tabs]) }, [api, activeId])
const removeTabById = React.useCallback((tabId: string): void => { const removeTabById = React.useCallback((tabId: string): void => {
setTabs((prev) => { setTabs((prev) => {
@@ -676,6 +668,19 @@ export default function App(): React.ReactElement {
[removeTabById] [removeTabById]
) )
const closeOtherTabs = React.useCallback(
(tabId: string): void => {
setTabs((prev) => prev.filter((t) => t.id === tabId))
setActiveId(tabId)
},
[]
)
const closeAllTabs = React.useCallback((): void => {
setTabs([])
setActiveId('')
}, [])
const newTabAction = React.useCallback((): void => { const newTabAction = React.useCallback((): void => {
setViewMode('edit') setViewMode('edit')
const nt = newTab() const nt = newTab()
@@ -712,12 +717,6 @@ export default function App(): React.ReactElement {
setBrowseDir(r.path) setBrowseDir(r.path)
}, [api]) }, [api])
const goParent = React.useCallback(async (): Promise<void> => {
if (!api || !browseDir || parentDisabled) return
const p = await api.parentDirectory(browseDir)
if (p !== browseDir) setBrowseDir(p)
}, [api, browseDir, parentDisabled])
const startSidebarResize = React.useCallback((e: React.MouseEvent): void => { const startSidebarResize = React.useCallback((e: React.MouseEvent): void => {
e.preventDefault() e.preventDefault()
const startX = e.clientX const startX = e.clientX
@@ -733,6 +732,21 @@ export default function App(): React.ReactElement {
document.addEventListener('mouseup', onUp) document.addEventListener('mouseup', onUp)
}, [sidebarWidth]) }, [sidebarWidth])
const startStatsResize = React.useCallback((e: React.MouseEvent): void => {
e.preventDefault()
const startX = e.clientX
const startW = statsWidth
const onMove = (ev: MouseEvent): void => {
setStatsWidth(clamp(startW - (ev.clientX - startX), 160, 400))
}
const onUp = (): void => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}, [statsWidth])
const startHybridSplitResize = React.useCallback((e: React.MouseEvent): void => { const startHybridSplitResize = React.useCallback((e: React.MouseEvent): void => {
e.preventDefault() e.preventDefault()
const shell = (e.currentTarget as HTMLElement).closest('.hybrid-row') const shell = (e.currentTarget as HTMLElement).closest('.hybrid-row')
@@ -770,6 +784,105 @@ export default function App(): React.ReactElement {
[previewMaxWidth] [previewMaxWidth]
) )
const handleDrop = React.useCallback(
async (e: React.DragEvent): Promise<void> => {
e.preventDefault()
e.stopPropagation()
setDragOver(false)
const files = e.dataTransfer.files
if (!files || files.length === 0) return
for (let i = 0; i < files.length; i++) {
const file = files[i]
if (!file) continue
if (/\.(md|markdown|mdown|mkd|txt)$/i.test(file.name)) {
try {
const content = await file.text()
const filePath: string | null = (file as { path?: string }).path ?? null
if (filePath) {
const key = normalizePathKey(filePath)
const existing = tabsRef.current.find(
(t) => t.filePath && normalizePathKey(t.filePath) === key
)
if (existing) {
setActiveId(existing.id)
if (api) {
const parent = await api.parentDirectory(filePath)
setBrowseDir(parent)
}
continue
}
}
const nt: Tab = {
id: crypto.randomUUID(),
filePath: filePath ?? file.name,
content,
savedContent: content
}
setTabs((prev) => [...prev, nt])
setActiveId(nt.id)
setViewMode('read')
if (filePath && api) {
const parent = await api.parentDirectory(filePath)
setBrowseDir(parent)
}
} catch {
/* ignore */
}
}
}
},
[api]
)
const handleDragOver = React.useCallback((e: React.DragEvent): void => {
e.preventDefault()
e.stopPropagation()
setDragOver(true)
}, [])
const handleDragLeave = React.useCallback((e: React.DragEvent): void => {
e.preventDefault()
const target = e.currentTarget as HTMLElement | null
const related = e.relatedTarget as HTMLElement | null
if (target && related && target.contains(related)) return
setDragOver(false)
}, [])
const exportHtml = React.useCallback((): void => {
if (!activeDoc.trim()) return
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${activeTab?.filePath ? tabLabel(activeTab) : '导出文档'}</title>
<style>
body { max-width: 860px; margin: 40px auto; padding: 0 20px; font-family: Georgia, 'Noto Serif SC', serif; font-size: 16px; line-height: 1.7; color: #333; }
img { max-width: 100%; }
pre { background: #f5f5f5; padding: 16px; border-radius: 6px; overflow-x: auto; }
code { font-family: 'Consolas', monospace; font-size: 0.9em; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
blockquote { border-left: 4px solid #ddd; margin: 0; padding: 0 16px; color: #666; }
</style>
</head>
<body>
${previewHtml}
</body>
</html>`
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${activeTab?.filePath ? tabLabel(activeTab).replace(/\.\w+$/, '') : 'document'}.html`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
setStatusText('已导出 HTML')
window.setTimeout(() => setStatusText(''), 2000)
}, [activeDoc, activeTab, previewHtml])
const deleteFileAt = React.useCallback( const deleteFileAt = React.useCallback(
async (filePath: string): Promise<void> => { async (filePath: string): Promise<void> => {
if (!api) return if (!api) return
@@ -905,6 +1018,94 @@ export default function App(): React.ReactElement {
void api.parentDirectory(activeTab.filePath).then(setBrowseDir) void api.parentDirectory(activeTab.filePath).then(setBrowseDir)
}, [activeId, activeTab?.filePath, api]) }, [activeId, activeTab?.filePath, api])
React.useEffect(() => {
const onKeyDown = (e: KeyboardEvent): void => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault()
setSearchVisible((v) => !v)
}
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
void saveActive()
}
if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
e.preventDefault()
newTabAction()
}
if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
e.preventDefault()
requestCloseTab(activeId)
}
if ((e.ctrlKey || e.metaKey) && e.key === 'e') {
e.preventDefault()
cycleViewMode()
}
if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
e.preventDefault()
setSidebarHidden((v) => !v)
}
if ((e.ctrlKey || e.metaKey) && e.key === 'o') {
e.preventDefault()
void openFileDialog()
}
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 's') {
e.preventDefault()
void saveActiveAs()
}
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'e') {
e.preventDefault()
exportHtml()
}
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'f') {
e.preventDefault()
setAutoSave((v) => !v)
}
if ((e.ctrlKey || e.metaKey) && e.key === 'Tab') {
e.preventDefault()
const idx = tabs.findIndex((t) => t.id === activeId)
if (e.shiftKey) {
const prev = idx <= 0 ? tabs.length - 1 : idx - 1
setActiveId(tabs[prev]?.id ?? activeId)
} else {
const next = idx >= tabs.length - 1 ? 0 : idx + 1
setActiveId(tabs[next]?.id ?? activeId)
}
}
if ((e.ctrlKey || e.metaKey) && /^[1-9]$/.test(e.key)) {
e.preventDefault()
const num = Number.parseInt(e.key, 10)
const target = tabs[num - 1]
if (target) setActiveId(target.id)
}
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'o') {
e.preventDefault()
void openFolderDialog()
}
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'r') {
e.preventDefault()
void refreshDir()
}
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === '1') {
e.preventDefault()
setSidebarTab('files')
}
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === '2') {
e.preventDefault()
setSidebarTab('history')
}
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === '3') {
e.preventDefault()
setSidebarTab('outline')
}
if (e.key === 'Escape' && searchVisible) {
setSearchVisible(false)
setSearchQuery('')
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [searchVisible, saveActive, newTabAction, activeId, requestCloseTab, cycleViewMode, openFileDialog, saveActiveAs, exportHtml, openFolderDialog, refreshDir])
if (!api) { if (!api) {
return ( return (
<div className="app-root"> <div className="app-root">
@@ -1194,7 +1395,10 @@ export default function App(): React.ReactElement {
{histCtxMenu ? ( {histCtxMenu ? (
<div <div
className="ctx-menu" className="ctx-menu"
style={{ left: histCtxMenu.x, top: histCtxMenu.y }} style={{
left: Math.min(histCtxMenu.x, window.innerWidth - 140),
top: Math.min(histCtxMenu.y, window.innerHeight - 100)
}}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
> >
<button <button
@@ -1213,7 +1417,10 @@ export default function App(): React.ReactElement {
{ctxMenu ? ( {ctxMenu ? (
<div <div
className="ctx-menu" className="ctx-menu"
style={{ left: ctxMenu.x, top: ctxMenu.y }} style={{
left: Math.min(ctxMenu.x, window.innerWidth - 140),
top: Math.min(ctxMenu.y, window.innerHeight - 100)
}}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
> >
<button <button
@@ -1236,31 +1443,23 @@ export default function App(): React.ReactElement {
) : null} ) : null}
<header className="tab-bar"> <header className="tab-bar">
{tabs.map((t) => ( <TabBar
<div key={t.id} className={`tab ${t.id === activeId ? 'active' : ''}`}> tabs={tabs}
<button type="button" className="tab-main" onClick={() => setActiveId(t.id)}> activeId={activeId}
<span className="tab-title">{tabLabel(t)}</span> onSelect={setActiveId}
{isDirty(t) ? <span className="dot"></span> : null} onClose={requestCloseTab}
</button> onCloseOthers={closeOtherTabs}
<button onCloseAll={closeAllTabs}
type="button" />
className="tab-close"
aria-label="关闭标签"
onClick={(e) => {
e.stopPropagation()
requestCloseTab(t.id)
}}
>
×
</button>
</div>
))}
</header> </header>
<div className="toolbar"> <div className="toolbar">
<button type="button" onClick={() => void openFileDialog()}> <button type="button" onClick={() => void openFileDialog()}>
</button> </button>
<button type="button" onClick={() => void openFolderDialog()}>
</button>
<button type="button" onClick={() => void saveActive()}> <button type="button" onClick={() => void saveActive()}>
</button> </button>
@@ -1276,6 +1475,25 @@ export default function App(): React.ReactElement {
<button type="button" onClick={() => setSidebarHidden((v) => !v)}> <button type="button" onClick={() => setSidebarHidden((v) => !v)}>
{sidebarHidden ? '显示侧栏' : '隐藏侧栏'} {sidebarHidden ? '显示侧栏' : '隐藏侧栏'}
</button> </button>
<button type="button" onClick={() => setStatsHidden((v) => !v)}>
{statsHidden ? '显示统计' : '隐藏统计'}
</button>
<button
type="button"
onClick={exportHtml}
disabled={!activeDoc.trim()}
title="导出当前文档为 HTML 文件"
>
HTML
</button>
<button
type="button"
onClick={() => setAutoSave((v) => !v)}
title={autoSave ? '自动保存已开启(3秒无操作后保存)' : '自动保存已关闭'}
style={autoSave ? { borderColor: 'var(--accent)', color: 'var(--accent)' } : undefined}
>
{autoSave ? '自动保存:开' : '自动保存:关'}
</button>
<div className="toolbar-tail"> <div className="toolbar-tail">
<label className="bg-select"> <label className="bg-select">
@@ -1325,111 +1543,30 @@ export default function App(): React.ReactElement {
{!sidebarHidden ? ( {!sidebarHidden ? (
<> <>
<aside className="sidebar" style={{ width: sidebarWidth, minWidth: sidebarWidth }}> <aside className="sidebar" style={{ width: sidebarWidth, minWidth: sidebarWidth }}>
<div className="sidebar-tabs"> <Sidebar
<button sidebarTab={sidebarTab}
type="button" onSidebarTabChange={setSidebarTab}
className={sidebarTab === 'files' ? 'on' : ''} browseDir={browseDir}
onClick={() => setSidebarTab('files')} dirEntries={dirEntries}
> dirError={dirError}
parentDisabled={parentDisabled}
</button> onNavigateDir={setBrowseDir}
<button onOpenFile={(path) => void openPathInTab(path)}
type="button" onContextMenu={(e, path, kind) => {
className={sidebarTab === 'history' ? 'on' : ''} e.preventDefault()
onClick={() => setSidebarTab('history')} setCtxMenu({ x: e.clientX, y: e.clientY, path, kind })
>
</button>
<button
type="button"
className={sidebarTab === 'outline' ? 'on' : ''}
onClick={() => setSidebarTab('outline')}
>
</button>
</div>
{sidebarTab === 'files' ? (
<div className="file-pane">
<div className="path-row" title={browseDir}>
<div className="path-row-actions">
<button type="button" disabled={parentDisabled} onClick={() => void goParent()}>
</button>
<button type="button" onClick={() => void openFolderDialog()}>
</button>
</div>
<span className="path">{browseDir || '…'}</span>
</div>
{dirError ? <div className="dir-error">{dirError}</div> : null}
<ul className="dir-list">
{dirEntries.map((e) => (
<li key={e.path}>
<button
type="button"
className="dir-item"
data-kind={e.kind}
onClick={() => {
if (e.kind === 'dir') {
setBrowseDir(e.path)
} else {
void openPathInTab(e.path)
}
}} }}
onContextMenu={(ev) => { fileHistory={fileHistory}
ev.preventDefault() onHistoryOpen={(path) => void openPathInTab(path)}
setCtxMenu({ onHistoryContextMenu={(e, path) => {
x: ev.clientX, e.preventDefault()
y: ev.clientY, setHistCtxMenu({ x: e.clientX, y: e.clientY, path })
path: e.path,
kind: e.kind
})
}} }}
> outlineItems={outline}
<span onOutlineSelect={navigateToOutline}
className={ tabs={tabs}
e.kind === 'dir' ? 'entry-kind entry-kind--dir' : 'entry-kind entry-kind--file' activeId={activeId}
} />
>
{e.kind === 'dir' ? '夹' : '文'}
</span>
<span className="entry-name">{e.name}</span>
</button>
</li>
))}
</ul>
</div>
) : sidebarTab === 'history' ? (
<div className="file-pane history-pane">
{fileHistory.length === 0 ? (
<p className="outline-empty"></p>
) : (
<ul className="history-list">
{fileHistory.map((path) => (
<li key={path}>
<button
type="button"
className="history-item"
title={path}
onClick={() => void openPathInTab(path)}
onContextMenu={(ev) => {
ev.preventDefault()
setHistCtxMenu({ x: ev.clientX, y: ev.clientY, path })
}}
>
<span className="history-item-name">{historyFileName(path)}</span>
<span className="history-item-path">{path}</span>
</button>
</li>
))}
</ul>
)}
</div>
) : (
<div className="file-pane outline-pane">
<OutlinePanel items={outline} onSelect={navigateToOutline} />
</div>
)}
</aside> </aside>
<div <div
className="sidebar-resize" className="sidebar-resize"
@@ -1448,7 +1585,125 @@ export default function App(): React.ReactElement {
</button> </button>
)} )}
<section className="content"> {searchVisible ? (
<div className="search-bar">
<input
type="text"
className="search-input"
placeholder="搜索文档内容…(Enter 查找下一个,Esc 关闭)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setSearchVisible(false)
setSearchQuery('')
}
if (e.key === 'Enter') {
e.preventDefault()
if (!searchQuery.trim()) return
if (viewMode === 'read') {
const article = readPreviewArticleRef.current
if (article) {
const text = article.textContent || ''
const idx = text.toLowerCase().indexOf(searchQuery.toLowerCase())
if (idx >= 0) {
const range = document.createRange()
const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT)
let currentIdx = 0
let node: Text | null = null
while ((node = walker.nextNode() as Text | null)) {
const len = node.textContent?.length || 0
if (currentIdx + len > idx) {
range.setStart(node, idx - currentIdx)
range.setEnd(node, idx - currentIdx + searchQuery.length)
range.startContainer.parentElement?.scrollIntoView({ behavior: 'smooth', block: 'center' })
break
}
currentIdx += len
}
}
}
return
}
if (viewMode === 'edit') {
editorRef.current?.searchText(searchQuery)
return
}
const view = hybridEditorViewRef.current
if (view) {
const doc = view.state.doc.toString()
const idx = doc.toLowerCase().indexOf(searchQuery.toLowerCase())
if (idx >= 0) {
view.dispatch({
selection: { anchor: idx, head: idx + searchQuery.length },
scrollIntoView: true
})
}
}
}
}}
autoFocus
/>
<button
type="button"
className="search-next-btn"
title="查找下一个"
onClick={() => {
if (!searchQuery.trim()) return
if (viewMode === 'read') {
const article = readPreviewArticleRef.current
if (article) {
const text = article.textContent || ''
const idx = text.toLowerCase().indexOf(searchQuery.toLowerCase())
if (idx >= 0) {
const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT)
let currentIdx = 0
let node: Text | null = null
while ((node = walker.nextNode() as Text | null)) {
const len = node.textContent?.length || 0
if (currentIdx + len > idx) {
node.parentElement?.scrollIntoView({ behavior: 'smooth', block: 'center' })
break
}
currentIdx += len
}
}
}
return
}
if (viewMode === 'edit') {
editorRef.current?.searchText(searchQuery)
return
}
const view = hybridEditorViewRef.current
if (view) {
const doc = view.state.doc.toString()
const idx = doc.toLowerCase().indexOf(searchQuery.toLowerCase())
if (idx >= 0) {
view.dispatch({
selection: { anchor: idx, head: idx + searchQuery.length },
scrollIntoView: true
})
}
}
}}
>
</button>
<button
type="button"
className="search-close-btn"
onClick={() => {
setSearchVisible(false)
setSearchQuery('')
}}
>
</button>
</div>
) : null}
<section className={`content${dragOver ? ' content-drag-over' : ''}`} onDrop={handleDrop} onDragOver={handleDragOver} onDragLeave={handleDragLeave}>
{(viewMode === 'edit' || viewMode === 'hybrid') && activeTab ? ( {(viewMode === 'edit' || viewMode === 'hybrid') && activeTab ? (
<MarkdownFormatToolbar editorRef={editorRef} /> <MarkdownFormatToolbar editorRef={editorRef} />
) : null} ) : null}
@@ -1466,7 +1721,13 @@ export default function App(): React.ReactElement {
</div> </div>
) : ( ) : (
<div className="content-empty"> <div className="content-empty">
使 <div className="content-empty-icon">📖</div>
<div>使</div>
<div className="content-empty-hint"> .md · Ctrl+F · Ctrl+S </div>
<div className="content-drop-zone">
<span className="content-drop-zone-icon">📂</span>
<span> Markdown </span>
</div>
</div> </div>
) )
) : null} ) : null}
@@ -1486,7 +1747,13 @@ export default function App(): React.ReactElement {
/> />
) : ( ) : (
<div className="content-empty"> <div className="content-empty">
使 <div className="content-empty-icon"></div>
<div>使</div>
<div className="content-empty-hint"> .md · Ctrl+F · Ctrl+S </div>
<div className="content-drop-zone">
<span className="content-drop-zone-icon">📂</span>
<span> Markdown </span>
</div>
</div> </div>
) )
) : null} ) : null}
@@ -1534,12 +1801,36 @@ export default function App(): React.ReactElement {
</div> </div>
) : ( ) : (
<div className="content-empty"> <div className="content-empty">
使 <div className="content-empty-icon">📝</div>
<div>使</div>
<div className="content-empty-hint"> .md · Ctrl+F · Ctrl+S </div>
<div className="content-drop-zone">
<span className="content-drop-zone-icon">📂</span>
<span> Markdown </span>
</div>
</div> </div>
) )
) : null} ) : null}
</section> </section>
{activeTab && !statsHidden ? (
<StatsPanel
doc={activeDoc}
width={statsWidth}
onResizeStart={startStatsResize}
/>
) : null}
{statusText ? <div className="status-bar">{statusText}</div> : null}
</div> </div>
{dragOver ? (
<div className="global-drop-overlay">
<div className="global-drop-overlay-box">
<span className="global-drop-overlay-icon">📂</span>
<span className="global-drop-overlay-text"> Markdown </span>
</div>
</div>
) : null}
</div> </div>
) )
} }
+19 -1
View File
@@ -13,6 +13,8 @@ import {
export type MarkdownEditorHandle = { export type MarkdownEditorHandle = {
applyAction: (id: string) => void | Promise<void> applyAction: (id: string) => void | Promise<void>
searchText: (query: string) => boolean
getView: () => EditorView | null
} }
type Props = { type Props = {
@@ -286,7 +288,22 @@ export const EditorPane = React.forwardRef<MarkdownEditorHandle, Props>(
requestImageDetails: requestImageDetailsRef.current, requestImageDetails: requestImageDetailsRef.current,
requestColor: requestColorRef.current requestColor: requestColorRef.current
}) })
},
searchText: (query: string): boolean => {
const view = viewRef.current
if (!view || !query.trim()) return false
const doc = view.state.doc.toString()
const idx = doc.toLowerCase().indexOf(query.toLowerCase())
if (idx >= 0) {
view.dispatch({
selection: { anchor: idx, head: idx + query.length },
scrollIntoView: true
})
return true
} }
return false
},
getView: (): EditorView | null => viewRef.current
}), }),
[] []
) )
@@ -347,9 +364,10 @@ export const EditorPane = React.forwardRef<MarkdownEditorHandle, Props>(
if (!view) return if (!view) return
const cur = view.state.doc.toString() const cur = view.state.doc.toString()
if (cur !== value) { if (cur !== value) {
const main = view.state.selection.main
view.dispatch({ view.dispatch({
changes: { from: 0, to: cur.length, insert: value }, changes: { from: 0, to: cur.length, insert: value },
selection: { anchor: 0 }, selection: main.empty ? { anchor: main.from } : { anchor: main.from, head: main.to },
scrollIntoView: true scrollIntoView: true
}) })
} }
+47 -15
View File
@@ -1,34 +1,59 @@
import * as React from 'react' import * as React from 'react'
import type { MarkdownEditorHandle } from './EditorPane' import type { MarkdownEditorHandle } from './EditorPane'
const BUTTONS: { id: string; label: string }[] = [ const GROUPS: { label: string; buttons: { id: string; label: string }[] }[] = [
{
label: '标题',
buttons: [
{ id: 'h1', label: 'H1' }, { id: 'h1', label: 'H1' },
{ id: 'h2', label: 'H2' }, { id: 'h2', label: 'H2' },
{ id: 'h3', label: 'H3' }, { id: 'h3', label: 'H3' },
{ id: 'h4', label: 'H4' }, { id: 'h4', label: 'H4' },
{ id: 'h5', label: 'H5' }, { id: 'h5', label: 'H5' },
{ id: 'h6', label: 'H6' }, { id: 'h6', label: 'H6' }
{ id: 'bold', label: '加粗' }, ]
{ id: 'italic', label: '斜体' }, },
{ id: 'strike', label: '删除线' }, {
{ id: 'code', label: '行内代码' }, label: '格式',
{ id: 'codeBlock', label: '代码块' }, buttons: [
{ id: 'bold', label: 'B' },
{ id: 'italic', label: 'I' },
{ id: 'strike', label: 'S' },
{ id: 'code', label: '`' },
{ id: 'mark', label: '高亮' },
{ id: 'color', label: '颜色' }
]
},
{
label: '元素',
buttons: [
{ id: 'link', label: '链接' }, { id: 'link', label: '链接' },
{ id: 'image', label: '图片' }, { id: 'image', label: '图片' },
{ id: 'ul', label: '无序列表' }, { id: 'codeBlock', label: '代码块' },
{ id: 'ol', label: '有序列表' },
{ id: 'task', label: '任务' },
{ id: 'quote', label: '引用' },
{ id: 'hr', label: '分隔线' },
{ id: 'table', label: '表格' }, { id: 'table', label: '表格' },
{ id: 'mark', label: '高亮' }, { id: 'hr', label: '分隔线' },
{ id: 'color', label: '颜色' }, { id: 'quote', label: '引用' }
]
},
{
label: '列表',
buttons: [
{ id: 'ul', label: '无序' },
{ id: 'ol', label: '有序' },
{ id: 'task', label: '任务' }
]
},
{
label: '其他',
buttons: [
{ id: 'sup', label: '上标' }, { id: 'sup', label: '上标' },
{ id: 'sub', label: '下标' }, { id: 'sub', label: '下标' },
{ id: 'kbd', label: '按键' }, { id: 'kbd', label: '按键' },
{ id: 'details', label: '折叠' }, { id: 'details', label: '折叠' },
{ id: 'footnote', label: '脚注' } { id: 'footnote', label: '脚注' }
] ]
}
]
type Props = { type Props = {
editorRef: React.RefObject<MarkdownEditorHandle | null> editorRef: React.RefObject<MarkdownEditorHandle | null>
@@ -38,7 +63,11 @@ export function MarkdownFormatToolbar({ editorRef }: Props): React.ReactElement
return ( return (
<div className="md-format-wrap"> <div className="md-format-wrap">
<div className="md-format-bar"> <div className="md-format-bar">
{BUTTONS.map((b) => ( {GROUPS.map((group) => (
<div key={group.label} className="md-format-group">
<span className="md-format-group-label">{group.label}</span>
<div className="md-format-group-btns">
{group.buttons.map((b) => (
<button <button
key={b.id} key={b.id}
type="button" type="button"
@@ -50,5 +79,8 @@ export function MarkdownFormatToolbar({ editorRef }: Props): React.ReactElement
))} ))}
</div> </div>
</div> </div>
))}
</div>
</div>
) )
} }
+12 -1
View File
@@ -74,7 +74,18 @@ export function OutlinePanel({ items, onSelect }: Props): React.ReactElement {
} }
if (items.length === 0) { if (items.length === 0) {
return <p className="outline-empty"></p> return (
<div className="outline-panel-wrap">
<div className="outline-toolbar">
<span className="outline-toolbar-title"></span>
</div>
<div className="sidebar-empty">
<span className="sidebar-empty-icon">📑</span>
<span className="sidebar-empty-text"></span>
<span className="sidebar-empty-hint">使 # </span>
</div>
</div>
)
} }
const collapsedCount = collapsed.size const collapsedCount = collapsed.size
+228
View File
@@ -0,0 +1,228 @@
import * as React from 'react'
import type { Tab } from './types'
import { OutlinePanel } from './OutlinePanel'
import type { OutlineItem } from './markdown'
type DirEntry = { name: string; path: string; kind: 'file' | 'dir' }
type Props = {
sidebarTab: 'files' | 'history' | 'outline'
onSidebarTabChange: (tab: 'files' | 'history' | 'outline') => void
browseDir: string
dirEntries: DirEntry[]
dirError: string | null
parentDisabled: boolean
onNavigateDir: (path: string) => void
onOpenFile: (path: string) => void
onContextMenu: (e: React.MouseEvent, path: string, kind: 'file' | 'dir') => void
fileHistory: string[]
onHistoryOpen: (path: string) => void
onHistoryContextMenu: (e: React.MouseEvent, path: string) => void
outlineItems: OutlineItem[]
onOutlineSelect: (item: OutlineItem) => void
tabs: Tab[]
activeId: string
}
export function Sidebar({
sidebarTab,
onSidebarTabChange,
browseDir,
dirEntries,
dirError,
parentDisabled,
onNavigateDir,
onOpenFile,
onContextMenu,
fileHistory,
onHistoryOpen,
onHistoryContextMenu,
outlineItems,
onOutlineSelect,
tabs,
activeId
}: Props): React.ReactElement {
const [filter, setFilter] = React.useState('')
const filteredEntries = React.useMemo(() => {
if (!filter) return dirEntries
const lower = filter.toLowerCase()
return dirEntries.filter((e) => e.name.toLowerCase().includes(lower))
}, [dirEntries, filter])
const activeFilePath = React.useMemo(() => {
const t = tabs.find((x) => x.id === activeId)
return t?.filePath ?? null
}, [tabs, activeId])
return (
<div className="sidebar-content">
<div className="sidebar-tabs">
<button
type="button"
className={sidebarTab === 'files' ? 'on' : ''}
onClick={() => onSidebarTabChange('files')}
>
</button>
<button
type="button"
className={sidebarTab === 'history' ? 'on' : ''}
onClick={() => onSidebarTabChange('history')}
>
</button>
<button
type="button"
className={sidebarTab === 'outline' ? 'on' : ''}
onClick={() => onSidebarTabChange('outline')}
>
</button>
</div>
{sidebarTab === 'files' ? (
<FilePanel
browseDir={browseDir}
dirEntries={filteredEntries}
dirError={dirError}
parentDisabled={parentDisabled}
onNavigateDir={onNavigateDir}
onOpenFile={onOpenFile}
onContextMenu={onContextMenu}
filter={filter}
onFilterChange={setFilter}
activeFilePath={activeFilePath}
/>
) : sidebarTab === 'history' ? (
<HistoryPanel
fileHistory={fileHistory}
onOpen={onHistoryOpen}
onContextMenu={onHistoryContextMenu}
/>
) : (
<OutlinePanel items={outlineItems} onSelect={onOutlineSelect} />
)}
</div>
)
}
function FilePanel({
browseDir,
dirEntries,
dirError,
parentDisabled,
onNavigateDir,
onOpenFile,
onContextMenu,
filter,
onFilterChange,
activeFilePath
}: {
browseDir: string
dirEntries: DirEntry[]
dirError: string | null
parentDisabled: boolean
onNavigateDir: (path: string) => void
onOpenFile: (path: string) => void
onContextMenu: (e: React.MouseEvent, path: string, kind: 'file' | 'dir') => void
filter: string
onFilterChange: (v: string) => void
activeFilePath: string | null
}): React.ReactElement {
return (
<div className="file-panel">
<div className="path-row">
<button
type="button"
className="path-btn"
disabled={parentDisabled}
onClick={() => {
const parts = browseDir.replace(/\\/g, '/').replace(/\/+$/, '').split('/')
parts.pop()
onNavigateDir(parts.join('/') || '/')
}}
title="上级目录"
>
</button>
<span className="path-label" title={browseDir}>{browseDir}</span>
</div>
<div className="file-filter">
<input
type="text"
className="file-filter-input"
placeholder="筛选文件..."
value={filter}
onChange={(e) => onFilterChange(e.target.value)}
/>
</div>
<div className="dir-list">
{dirError ? (
<div className="sidebar-empty">
<div className="sidebar-empty-icon"></div>
<div className="sidebar-empty-text">{dirError}</div>
</div>
) : dirEntries.length === 0 ? (
<div className="sidebar-empty">
<div className="sidebar-empty-icon">📂</div>
<div className="sidebar-empty-text"></div>
<div className="sidebar-empty-hint"> Markdown </div>
</div>
) : (
dirEntries.map((e) => (
<div
key={e.path}
className={`dir-item${e.kind === 'file' && e.path === activeFilePath ? ' dir-item--active' : ''}`}
onClick={() => {
if (e.kind === 'dir') onNavigateDir(e.path)
else onOpenFile(e.path)
}}
onContextMenu={(ev) => onContextMenu(ev, e.path, e.kind)}
>
<span className="dir-item-icon">{e.kind === 'dir' ? '📁' : '📄'}</span>
<span className="dir-item-name">{e.name}</span>
</div>
))
)}
</div>
</div>
)
}
function HistoryPanel({
fileHistory,
onOpen,
onContextMenu
}: {
fileHistory: string[]
onOpen: (path: string) => void
onContextMenu: (e: React.MouseEvent, path: string) => void
}): React.ReactElement {
return (
<div className="history-panel">
{fileHistory.length === 0 ? (
<div className="sidebar-empty">
<div className="sidebar-empty-icon">🕐</div>
<div className="sidebar-empty-text"></div>
<div className="sidebar-empty-hint"></div>
</div>
) : (
fileHistory.map((p) => (
<div
key={p}
className="history-item"
onClick={() => onOpen(p)}
onContextMenu={(ev) => onContextMenu(ev, p)}
>
<span className="history-item-icon">📄</span>
<div className="history-item-body">
<span className="history-item-name">{p.split(/[/\\]/).pop()}</span>
<span className="history-item-path">{p}</span>
</div>
</div>
))
)}
</div>
)
}
+183
View File
@@ -0,0 +1,183 @@
import * as React from 'react'
type Props = {
doc: string
width: number
onResizeStart: (e: React.MouseEvent) => void
}
function wordCount(s: string): number {
return s.replace(/[\u4e00-\u9fff]/g, ' $& ').split(/[\s]+/).filter(Boolean).length
}
function codeBlockCount(s: string): number {
return Math.floor(((s.match(/```/g) || []).length) / 2)
}
function imageCount(s: string): number {
return (s.match(/!\[.*?\]\(.*?\)/g) || []).length
}
function linkCount(s: string): number {
return (s.match(/\[.*?\]\(.*?\)/g) || []).length
}
function tableCount(s: string): number {
return (s.match(/^\|.+\|.+\|/gm) || []).length
}
function blockquoteCount(s: string): number {
return (s.match(/^>\s/gm) || []).length
}
function listItemCount(s: string): number {
return (s.match(/^(?:[-*+]|\d+\.)\s/gm) || []).length
}
function mermaidCount(s: string): number {
return (s.match(/```mermaid\n?/gi) || []).length
}
function headingCount(s: string): number {
return (s.match(/^#{1,6}\s/gm) || []).length
}
function boldCount(s: string): number {
return (s.match(/\*\*[^*]+\*\*/g) || []).length
}
function italicCount(s: string): number {
return (s.match(/(?<!\*)\*[^*\n]+\*(?!\*)/g) || []).length
}
function inlineCodeCount(s: string): number {
return (s.match(/`[^`\n]+`/g) || []).length
}
function taskCount(s: string): { done: number; total: number } {
const tasks = s.match(/^\s*[-*+]\s+\[([ x])\]/gm) || []
const done = tasks.filter((t) => t.includes('[x]')).length
return { done, total: tasks.length }
}
function docSize(s: string): string {
const sizeKB = new Blob([s]).size / 1024
return sizeKB < 1 ? '<1 KB' : `${Math.round(sizeKB)} KB`
}
export function StatsPanel({ doc, width, onResizeStart }: Props): React.ReactElement {
return (
<>
<div className="stats-resize" onMouseDown={onResizeStart} title="拖动调整统计栏宽度" />
<aside className="stats-panel" style={{ width, minWidth: width }}>
<div className="stats-panel-header">
<span className="stats-panel-header-icon">📊</span>
</div>
<div className="stats-panel-body">
<div className="stats-panel-section">
<div className="stats-panel-section-title"></div>
<div className="stats-panel-grid">
<div className="stats-panel-item">
<span className="stats-panel-value">{doc.length.toLocaleString()}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{doc.replace(/\s/g, '').length.toLocaleString()}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{wordCount(doc).toLocaleString()}</span>
<span className="stats-panel-label"></span>
</div>
</div>
</div>
<div className="stats-panel-section">
<div className="stats-panel-section-title"></div>
<div className="stats-panel-grid">
<div className="stats-panel-item">
<span className="stats-panel-value">{doc === '' ? 0 : doc.split('\n').length}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{doc.split(/\n\s*\n/).filter((p) => p.trim().length > 0).length}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{Math.max(1, Math.round(wordCount(doc) / 300))}</span>
<span className="stats-panel-label"></span>
</div>
</div>
</div>
<div className="stats-panel-section">
<div className="stats-panel-section-title"></div>
<div className="stats-panel-grid">
<div className="stats-panel-item">
<span className="stats-panel-value">{codeBlockCount(doc)}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{imageCount(doc)}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{linkCount(doc)}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{tableCount(doc)}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{blockquoteCount(doc)}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{listItemCount(doc)}</span>
<span className="stats-panel-label"></span>
</div>
</div>
</div>
<div className="stats-panel-section">
<div className="stats-panel-section-title"></div>
<div className="stats-panel-grid">
<div className="stats-panel-item">
<span className="stats-panel-value">{mermaidCount(doc)}</span>
<span className="stats-panel-label">Mermaid</span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{docSize(doc)}</span>
<span className="stats-panel-label"></span>
</div>
</div>
</div>
<div className="stats-panel-section">
<div className="stats-panel-section-title"></div>
<div className="stats-panel-grid">
<div className="stats-panel-item">
<span className="stats-panel-value">{headingCount(doc)}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{boldCount(doc)}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{italicCount(doc)}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{inlineCodeCount(doc)}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{taskCount(doc).done}/{taskCount(doc).total}</span>
<span className="stats-panel-label"></span>
</div>
</div>
</div>
</div>
</aside>
</>
)
}
+115
View File
@@ -0,0 +1,115 @@
import * as React from 'react'
import type { Tab } from './types'
import { tabLabel, isDirty } from './utils'
type Props = {
tabs: Tab[]
activeId: string
onSelect: (id: string) => void
onClose: (id: string) => void
onCloseOthers: (id: string) => void
onCloseAll: () => void
}
export function TabBar({ tabs, activeId, onSelect, onClose, onCloseOthers, onCloseAll }: Props): React.ReactElement {
const barRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
const el = barRef.current
if (!el) return
const active = el.querySelector('.tab.active') as HTMLElement | null
if (active) {
active.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
}
}, [activeId])
return (
<div className="tab-bar" ref={barRef}>
{tabs.map((t) => (
<TabItem
key={t.id}
tab={t}
active={t.id === activeId}
onSelect={() => onSelect(t.id)}
onClose={() => onClose(t.id)}
onCloseOthers={() => onCloseOthers(t.id)}
onCloseAll={onCloseAll}
/>
))}
</div>
)
}
type TabItemProps = {
tab: Tab
active: boolean
onSelect: () => void
onClose: () => void
onCloseOthers: () => void
onCloseAll: () => void
}
function TabItem({ tab, active, onSelect, onClose, onCloseOthers, onCloseAll }: TabItemProps): React.ReactElement {
const [menuOpen, setMenuOpen] = React.useState(false)
const [menuPos, setMenuPos] = React.useState({ x: 0, y: 0 })
const menuRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (!menuOpen) return
const close = (e: MouseEvent): void => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false)
}
}
const closeOnScroll = (): void => setMenuOpen(false)
document.addEventListener('mousedown', close)
window.addEventListener('scroll', closeOnScroll, true)
return () => {
document.removeEventListener('mousedown', close)
window.removeEventListener('scroll', closeOnScroll, true)
}
}, [menuOpen])
const handleContextMenu = (e: React.MouseEvent): void => {
e.preventDefault()
e.stopPropagation()
setMenuPos({
x: Math.min(e.clientX, window.innerWidth - 140),
y: Math.min(e.clientY, window.innerHeight - 140)
})
setMenuOpen(true)
}
return (
<div className={`tab${active ? ' active' : ''}`} onContextMenu={handleContextMenu}>
<button type="button" className="tab-main" onClick={onSelect} title={tab.filePath ?? undefined}>
{isDirty(tab) ? <span className="dot">·</span> : null}
<span className="tab-title">{tabLabel(tab)}</span>
</button>
<button
type="button"
className="tab-close"
onClick={(e) => {
e.stopPropagation()
onClose()
}}
title="关闭"
>
</button>
{menuOpen ? (
<div className="tab-context-menu" ref={menuRef} style={{ left: menuPos.x, top: menuPos.y }}>
<button type="button" onClick={() => { onClose(); setMenuOpen(false) }}>
</button>
<button type="button" onClick={() => { onCloseOthers(); setMenuOpen(false) }}>
</button>
<button type="button" onClick={() => { onCloseAll(); setMenuOpen(false) }}>
</button>
</div>
) : null}
</div>
)
}
+62 -62
View File
@@ -328,72 +328,72 @@
:root[data-font='0'] { :root[data-font='0'] {
--font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; --font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-editor: ui-monospace, Menlo, Consolas, monospace; --font-editor: ui-monospace, Menlo, Consolas, monospace;
--font-preview: Georgia, 'Times New Roman', 'Noto Serif SC', serif; --font-preview: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
} }
:root[data-font='1'] { :root[data-font='1'] {
--font-ui: Georgia, 'Times New Roman', serif; --font-ui: Georgia, 'Times New Roman', 'Noto Serif SC', serif;
--font-editor: Consolas, 'Courier New', monospace; --font-editor: Consolas, 'Courier New', monospace;
--font-preview: 'Palatino Linotype', Palatino, 'Noto Serif SC', serif; --font-preview: 'Palatino Linotype', Palatino, 'Noto Serif SC', 'Songti SC', serif;
} }
:root[data-font='2'] { :root[data-font='2'] {
--font-ui: system-ui, sans-serif; --font-ui: system-ui, sans-serif;
--font-editor: 'JetBrains Mono', 'Fira Code', Consolas, monospace; --font-editor: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace;
--font-preview: 'Source Serif 4', Cambria, Georgia, serif; --font-preview: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
} }
:root[data-font='3'] { :root[data-font='3'] {
--font-ui: 'Microsoft YaHei', 'PingFang SC', sans-serif; --font-ui: 'Microsoft YaHei', 'PingFang SC', sans-serif;
--font-editor: 'Sarasa Mono SC', Consolas, monospace; --font-editor: 'Sarasa Mono SC', Consolas, monospace;
--font-preview: 'Songti SC', SimSun, 'Noto Serif SC', serif; --font-preview: 'Songti SC', 'SimSun', 'Noto Serif SC', 'STSong', serif;
} }
:root[data-font='4'] { :root[data-font='4'] {
--font-ui: 'IBM Plex Sans', system-ui, sans-serif; --font-ui: 'IBM Plex Sans', system-ui, sans-serif;
--font-editor: 'IBM Plex Mono', Consolas, monospace; --font-editor: 'IBM Plex Mono', Consolas, monospace;
--font-preview: 'IBM Plex Serif', Georgia, serif; --font-preview: 'IBM Plex Serif', Georgia, 'Noto Serif SC', serif;
} }
:root[data-font='5'] { :root[data-font='5'] {
--font-ui: 'Helvetica Neue', Helvetica, Arial, sans-serif; --font-ui: 'Helvetica Neue', Helvetica, Arial, sans-serif;
--font-editor: Menlo, Monaco, Consolas, monospace; --font-editor: Menlo, Monaco, Consolas, monospace;
--font-preview: 'Helvetica Neue', Arial, sans-serif; --font-preview: 'Helvetica Neue', Helvetica, Arial, 'PingFang SC', sans-serif;
} }
:root[data-font='6'] { :root[data-font='6'] {
--font-ui: Verdana, Geneva, sans-serif; --font-ui: Georgia, 'Times New Roman', serif;
--font-editor: 'Lucida Console', monospace; --font-editor: 'Lucida Console', 'Courier New', monospace;
--font-preview: Georgia, Cambria, serif; --font-preview: Georgia, Cambria, 'Times New Roman', 'Noto Serif SC', serif;
} }
:root[data-font='7'] { :root[data-font='7'] {
--font-ui: 'Trebuchet MS', sans-serif; --font-ui: Verdana, Geneva, Tahoma, sans-serif;
--font-editor: 'Courier New', Courier, monospace; --font-editor: 'Lucida Console', 'Courier New', monospace;
--font-preview: 'Book Antiqua', Palatino, serif; --font-preview: Verdana, Geneva, 'Microsoft YaHei', sans-serif;
} }
:root[data-font='8'] { :root[data-font='8'] {
--font-ui: 'Segoe UI', system-ui, sans-serif; --font-ui: 'Trebuchet MS', 'Segoe UI', sans-serif;
--font-editor: 'Cascadia Code', 'Cascadia Mono', Consolas, monospace; --font-editor: 'Courier New', Courier, monospace;
--font-preview: Constantia, 'Times New Roman', serif; --font-preview: 'Book Antiqua', Palatino, 'Times New Roman', 'Noto Serif SC', serif;
} }
:root[data-font='9'] { :root[data-font='9'] {
--font-ui: 'Segoe UI Variable', 'Segoe UI', sans-serif; --font-ui: 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif;
--font-editor: 'Cascadia Mono', Consolas, monospace; --font-editor: 'Cascadia Mono', 'Cascadia Code', Consolas, monospace;
--font-preview: 'Sitka Text', Georgia, serif; --font-preview: 'Sitka Text', 'Sitka', Georgia, 'Noto Serif SC', serif;
} }
:root[data-font='10'] { :root[data-font='10'] {
--font-ui: 'Noto Sans SC', 'Microsoft YaHei', 'PingFang SC', sans-serif; --font-ui: 'Noto Sans SC', 'Microsoft YaHei', 'PingFang SC', sans-serif;
--font-editor: 'Noto Sans Mono CJK SC', 'Sarasa Mono SC', Consolas, monospace; --font-editor: 'Noto Sans Mono CJK SC', 'Sarasa Mono SC', Consolas, monospace;
--font-preview: 'Noto Serif SC', 'Source Han Serif SC', SimSun, serif; --font-preview: 'Noto Sans SC', 'Microsoft YaHei', 'PingFang SC', sans-serif;
} }
:root[data-font='11'] { :root[data-font='11'] {
--font-ui: 'Hiragino Sans GB', 'Microsoft YaHei', system-ui, sans-serif; --font-ui: 'STKaiti', 'KaiTi', 'Noto Serif SC', serif;
--font-editor: ui-monospace, monospace; --font-editor: 'Sarasa Mono SC', Consolas, monospace;
--font-preview: 'STKaiti', KaiTi, 'Noto Serif SC', serif; --font-preview: 'STKaiti', 'KaiTi', 'Noto Serif SC', 'STSong', serif;
} }
:root[data-font='12'] { :root[data-font='12'] {
--font-ui: 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif; --font-ui: 'PingFang SC', 'Microsoft YaHei', 'Hiragino Sans GB', sans-serif;
--font-editor: 'Menlo', 'Monaco', Consolas, monospace; --font-editor: 'SF Mono', Menlo, Consolas, monospace;
--font-preview: 'Songti SC', SimSun, 'Noto Serif SC', serif; --font-preview: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
} }
:root[data-font='13'] { :root[data-font='13'] {
--font-ui: 'Hiragino Sans GB', 'PingFang SC', sans-serif; --font-ui: 'Hiragino Sans GB', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-editor: 'SF Mono', Menlo, Consolas, monospace; --font-editor: 'SF Mono', Menlo, Consolas, monospace;
--font-preview: 'Hiragino Mincho ProN', 'Songti SC', serif; --font-preview: 'Hiragino Mincho ProN', 'Songti SC', 'Noto Serif SC', serif;
} }
:root[data-font='14'] { :root[data-font='14'] {
--font-ui: 'LXGW WenKai', 'KaiTi', 'Microsoft YaHei', sans-serif; --font-ui: 'LXGW WenKai', 'KaiTi', 'Microsoft YaHei', sans-serif;
@@ -401,89 +401,89 @@
--font-preview: 'LXGW WenKai', 'KaiTi', 'Noto Serif SC', serif; --font-preview: 'LXGW WenKai', 'KaiTi', 'Noto Serif SC', serif;
} }
:root[data-font='15'] { :root[data-font='15'] {
--font-ui: 'HarmonyOS Sans', 'Microsoft YaHei', system-ui, sans-serif; --font-ui: 'HarmonyOS Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
--font-editor: 'HarmonyOS Sans Mono', Consolas, monospace; --font-editor: 'HarmonyOS Sans Mono', 'JetBrains Mono', Consolas, monospace;
--font-preview: 'HarmonyOS Serif', 'Noto Serif SC', serif; --font-preview: 'HarmonyOS Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
} }
:root[data-font='16'] { :root[data-font='16'] {
--font-ui: 'OPPO Sans', 'Microsoft YaHei', system-ui, sans-serif; --font-ui: 'OPPO Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
--font-editor: 'OPPO Sans Mono', Consolas, monospace; --font-editor: 'JetBrains Mono', Consolas, monospace;
--font-preview: 'Source Han Serif SC', SimSun, serif; --font-preview: 'OPPO Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
} }
:root[data-font='17'] { :root[data-font='17'] {
--font-ui: 'MiSans', 'Microsoft YaHei', system-ui, sans-serif; --font-ui: 'MiSans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
--font-editor: 'MiSans Mono', Consolas, monospace; --font-editor: 'JetBrains Mono', Consolas, monospace;
--font-preview: 'Noto Serif SC', 'Source Han Serif SC', serif; --font-preview: 'MiSans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
} }
:root[data-font='18'] { :root[data-font='18'] {
--font-ui: 'Alibaba PuHuiTi', 'Microsoft YaHei', sans-serif; --font-ui: 'Alibaba PuHuiTi', 'Microsoft YaHei', 'PingFang SC', sans-serif;
--font-editor: 'JetBrains Mono', Consolas, monospace; --font-editor: 'JetBrains Mono', Consolas, monospace;
--font-preview: 'Alibaba PuHuiTi', 'Noto Serif SC', serif; --font-preview: 'Alibaba PuHuiTi', 'Microsoft YaHei', 'PingFang SC', sans-serif;
} }
:root[data-font='19'] { :root[data-font='19'] {
--font-ui: 'Smiley Sans', 'Microsoft YaHei', sans-serif; --font-ui: 'Smiley Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
--font-editor: 'JetBrains Mono', Consolas, monospace; --font-editor: 'JetBrains Mono', Consolas, monospace;
--font-preview: 'Smiley Sans', 'Noto Serif SC', serif; --font-preview: 'Smiley Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
} }
:root[data-font='20'] { :root[data-font='20'] {
--font-ui: system-ui, sans-serif; --font-ui: 'Maple Mono', 'Cascadia Code', Consolas, monospace;
--font-editor: 'Maple Mono', 'Cascadia Code', Consolas, monospace; --font-editor: 'Maple Mono', 'Cascadia Code', Consolas, monospace;
--font-preview: 'Source Serif 4', Georgia, serif; --font-preview: 'Maple Mono', 'Cascadia Code', Consolas, monospace;
} }
:root[data-font='21'] { :root[data-font='21'] {
--font-ui: 'Fira Sans', system-ui, sans-serif; --font-ui: 'Fira Sans', system-ui, sans-serif;
--font-editor: 'Fira Code', 'Fira Mono', Consolas, monospace; --font-editor: 'Fira Code', 'Fira Mono', Consolas, monospace;
--font-preview: 'Fira Sans', Georgia, serif; --font-preview: 'Fira Sans', system-ui, 'PingFang SC', sans-serif;
} }
:root[data-font='22'] { :root[data-font='22'] {
--font-ui: 'Roboto', system-ui, sans-serif; --font-ui: 'Roboto', system-ui, sans-serif;
--font-editor: 'Roboto Mono', Consolas, monospace; --font-editor: 'Roboto Mono', Consolas, monospace;
--font-preview: 'Roboto Slab', Georgia, serif; --font-preview: 'Roboto Slab', Georgia, 'Noto Serif SC', serif;
} }
:root[data-font='23'] { :root[data-font='23'] {
--font-ui: 'Open Sans', system-ui, sans-serif; --font-ui: 'Open Sans', system-ui, sans-serif;
--font-editor: 'Source Code Pro', Consolas, monospace; --font-editor: 'Source Code Pro', Consolas, monospace;
--font-preview: 'Merriweather', Georgia, serif; --font-preview: 'Merriweather', Georgia, 'Noto Serif SC', serif;
} }
:root[data-font='24'] { :root[data-font='24'] {
--font-ui: 'Lato', system-ui, sans-serif; --font-ui: 'Lato', system-ui, sans-serif;
--font-editor: 'Ubuntu Mono', Consolas, monospace; --font-editor: 'Ubuntu Mono', Consolas, monospace;
--font-preview: 'Lora', Georgia, serif; --font-preview: 'Lora', Georgia, 'Noto Serif SC', serif;
} }
:root[data-font='25'] { :root[data-font='25'] {
--font-ui: 'Montserrat', system-ui, sans-serif; --font-ui: 'Montserrat', system-ui, sans-serif;
--font-editor: 'Space Mono', Consolas, monospace; --font-editor: 'Space Mono', Consolas, monospace;
--font-preview: 'Playfair Display', Georgia, serif; --font-preview: 'Playfair Display', Georgia, 'Noto Serif SC', serif;
} }
:root[data-font='26'] { :root[data-font='26'] {
--font-ui: 'Merriweather Sans', system-ui, sans-serif; --font-ui: 'Merriweather Sans', system-ui, sans-serif;
--font-editor: 'Inconsolata', Consolas, monospace; --font-editor: 'Inconsolata', Consolas, monospace;
--font-preview: 'Merriweather', Georgia, serif; --font-preview: 'Merriweather', Georgia, 'Noto Serif SC', serif;
} }
:root[data-font='27'] { :root[data-font='27'] {
--font-ui: system-ui, sans-serif; --font-ui: 'Crimson Text', Georgia, 'Times New Roman', serif;
--font-editor: 'Source Code Pro', monospace; --font-editor: 'Source Code Pro', Consolas, monospace;
--font-preview: 'Crimson Text', 'Times New Roman', serif; --font-preview: 'Crimson Text', 'Times New Roman', 'Noto Serif SC', serif;
} }
:root[data-font='28'] { :root[data-font='28'] {
--font-ui: 'Literata', Georgia, serif; --font-ui: 'Literata', Georgia, 'Noto Serif SC', serif;
--font-editor: 'IBM Plex Mono', Consolas, monospace; --font-editor: 'IBM Plex Mono', Consolas, monospace;
--font-preview: 'Literata', Georgia, serif; --font-preview: 'Literata', Georgia, 'Noto Serif SC', serif;
} }
:root[data-font='29'] { :root[data-font='29'] {
--font-ui: system-ui, sans-serif; --font-ui: 'Inconsolata', Consolas, monospace;
--font-editor: 'Inconsolata', 'Consolas', monospace; --font-editor: 'Inconsolata', Consolas, monospace;
--font-preview: Georgia, 'Noto Serif SC', serif; --font-preview: 'Inconsolata', Consolas, monospace;
} }
:root[data-font='30'] { :root[data-font='30'] {
--font-ui: system-ui, sans-serif; --font-ui: 'Anonymous Pro', 'Courier New', monospace;
--font-editor: 'Anonymous Pro', 'Courier New', monospace; --font-editor: 'Anonymous Pro', 'Courier New', monospace;
--font-preview: 'Palatino Linotype', Palatino, serif; --font-preview: 'Anonymous Pro', 'Courier New', monospace;
} }
:root[data-font='31'] { :root[data-font='31'] {
--font-ui: system-ui, sans-serif; --font-ui: 'Fantasque Sans Mono', Consolas, monospace;
--font-editor: 'Fantasque Sans Mono', Consolas, monospace; --font-editor: 'Fantasque Sans Mono', Consolas, monospace;
--font-preview: 'Charter', 'Noto Serif SC', serif; --font-preview: 'Fantasque Sans Mono', Consolas, monospace;
} }
:root[data-bg='4'] .toolbar, :root[data-bg='4'] .toolbar,
+26 -26
View File
@@ -28,37 +28,37 @@ export const BACKGROUNDS: { id: string; name: string }[] = [
/** 字体 id 031,对应 `:root[data-font]`。 */ /** 字体 id 031,对应 `:root[data-font]`。 */
export const FONTS: { id: string; name: string }[] = [ export const FONTS: { id: string; name: string }[] = [
{ id: '0', name: '系统默认' }, { id: '0', name: '系统默认' },
{ id: '1', name: '衬线阅读' }, { id: '1', name: '经典衬线' },
{ id: '2', name: '等宽代码' }, { id: '2', name: '现代等宽' },
{ id: '3', name: '人文宋黑' }, { id: '3', name: '宋体阅读' },
{ id: '4', name: 'IBM Plex' }, { id: '4', name: 'IBM Plex 家族' },
{ id: '5', name: 'Helvetica ' }, { id: '5', name: 'Helvetica 现代' },
{ id: '6', name: 'Georgia ' }, { id: '6', name: 'Georgia 优雅' },
{ id: '7', name: 'Verdana ' }, { id: '7', name: 'Verdana 清晰' },
{ id: '8', name: 'Courier 打字机' }, { id: '8', name: 'Courier 复古' },
{ id: '9', name: 'Segoe UI ' }, { id: '9', name: 'Segoe UI 流畅' },
{ id: '10', name: '思源黑体优先' }, { id: '10', name: '思源黑体' },
{ id: '11', name: '圆体休闲' }, { id: '11', name: '楷体古韵' },
{ id: '12', name: '苹方优先' }, { id: '12', name: '苹方雅致' },
{ id: '13', name: '冬青黑体' }, { id: '13', name: '冬青黑体' },
{ id: '14', name: '霞鹜文楷' }, { id: '14', name: '霞鹜文楷' },
{ id: '15', name: 'HarmonyOS Sans' }, { id: '15', name: 'HarmonyOS 鸿蒙' },
{ id: '16', name: 'OPPO Sans' }, { id: '16', name: 'OPPO Sans' },
{ id: '17', name: '小米兰亭' }, { id: '17', name: 'MiSans 小米' },
{ id: '18', name: '阿里巴巴普惠' }, { id: '18', name: '阿里巴巴普惠' },
{ id: '19', name: '得意黑' }, { id: '19', name: '得意黑' },
{ id: '20', name: 'Maple Mono' }, { id: '20', name: 'Maple Mono 枫叶' },
{ id: '21', name: 'Fira Sans + Mono' }, { id: '21', name: 'Fira 家族' },
{ id: '22', name: 'Roboto ' }, { id: '22', name: 'Roboto 家族' },
{ id: '23', name: 'Open Sans ' }, { id: '23', name: 'Open Sans 开放' },
{ id: '24', name: 'Lato ' }, { id: '24', name: 'Lato 优雅' },
{ id: '25', name: 'Montserrat ' }, { id: '25', name: 'Montserrat 现代' },
{ id: '26', name: 'Merriweather 阅读' }, { id: '26', name: 'Merriweather 阅读' },
{ id: '27', name: 'Crimson Text' }, { id: '27', name: 'Crimson 古典' },
{ id: '28', name: 'Literata 阅读' }, { id: '28', name: 'Literata 电子书' },
{ id: '29', name: 'Inconsolata 等宽' }, { id: '29', name: 'Inconsolata 代码' },
{ id: '30', name: 'Anonymous Pro' }, { id: '30', name: 'Anonymous Pro 复古码' },
{ id: '31', name: 'Fantasque Sans Mono' } { id: '31', name: 'Fantasque 趣味码' }
] ]
export type ViewMode = 'edit' | 'read' | 'hybrid' export type ViewMode = 'edit' | 'read' | 'hybrid'
+14
View File
@@ -0,0 +1,14 @@
export const BG_KEY = 'myreader-bg-id'
export const FONT_KEY = 'myreader-font-id'
export const VIEW_KEY = 'myreader-view'
export const LEGACY_READ_KEY = 'myreader-read'
export const BROWSE_KEY = 'myreader-browse'
export const SIDEBAR_W_KEY = 'myreader-sidebar-w'
export const SIDEBAR_HIDDEN_KEY = 'myreader-sidebar-hidden'
export const STATS_W_KEY = 'myreader-stats-w'
export const STATS_HIDDEN_KEY = 'myreader-stats-hidden'
export const SPLIT_KEY = 'myreader-split'
export const PREVIEW_W_KEY = 'myreader-preview-max'
export const SESSION_KEY = 'myreader-session-v1'
export const PREVIEW_LINE_KEY = 'myreader-preview-line-height'
export const AUTO_SAVE_KEY = 'myreader-auto-save'
+901 -154
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -16,6 +16,14 @@ function isMermaidFence(lang: string, content: string): boolean {
return false return false
} }
function escapeHtmlSafe(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function highlightCodeBlock(content: string, lang: string): string { function highlightCodeBlock(content: string, lang: string): string {
const trimmed = (lang || '').trim() const trimmed = (lang || '').trim()
if (trimmed && hljs.getLanguage(trimmed)) { if (trimmed && hljs.getLanguage(trimmed)) {
@@ -32,7 +40,7 @@ function highlightCodeBlock(content: string, lang: string): string {
/* fall through */ /* fall through */
} }
} }
return md.utils.escapeHtml(content) return escapeHtmlSafe(content)
} }
const md = new MarkdownIt({ const md = new MarkdownIt({
+9
View File
@@ -0,0 +1,9 @@
export type Tab = {
id: string
filePath: string | null
content: string
savedContent: string
}
export type SessionTabRow = { path: string | null; content: string; saved: string }
export type SessionV1 = { v: 1; activeIndex: number; tabs: SessionTabRow[]; bgId?: string; fontId?: string }
+50
View File
@@ -0,0 +1,50 @@
import type { Tab } from './types'
import { PREVIEW_LINE_KEY, LEGACY_READ_KEY, VIEW_KEY } from './constants'
import type { ViewMode } from './appearance'
export function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n))
}
export function normalizePathKey(p: string): string {
return p.replace(/\\/g, '/').toLowerCase()
}
export function newTab(): Tab {
const id = crypto.randomUUID()
return { id, filePath: null, content: '', savedContent: '' }
}
export function tabLabel(t: Tab): string {
if (t.filePath) {
const parts = t.filePath.split(/[/\\]/)
return parts[parts.length - 1] || t.filePath
}
return '未命名'
}
export function isDirty(t: Tab): boolean {
return t.content !== t.savedContent
}
export function loadViewMode(): ViewMode {
const v = localStorage.getItem(VIEW_KEY) as ViewMode | null
if (v === 'edit' || v === 'read' || v === 'hybrid') return v
if (localStorage.getItem(LEGACY_READ_KEY) === '1') return 'read'
if (localStorage.getItem(LEGACY_READ_KEY) === '0') return 'edit'
return 'read'
}
export function viewModeLabel(m: ViewMode): string {
if (m === 'edit') return '编辑'
if (m === 'read') return '阅读'
return '分栏'
}
export function loadPreviewLineHeight(): string {
const raw = localStorage.getItem(PREVIEW_LINE_KEY)
if (!raw) return '1.5'
const n = Number.parseFloat(raw)
if (!Number.isFinite(n)) return '1.5'
return String(Math.min(3, Math.max(1, n)))
}