UI升级,功能优化,代码拆分
This commit is contained in:
Generated
+13
@@ -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
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,6 +1,37 @@
|
||||
# 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 为块级元素打上源行号,用于大纲跳转与分栏滚动对齐。
|
||||
|
||||
5. **持久化**
|
||||
标签内容与未保存状态、当前背景/字体等写入 `localStorage`(会话键 `myreader-session-v1`);打开过的 Markdown 路径记入「历史」列表,与真实文件删除无关。
|
||||
标签内容与未保存状态、当前背景/字体等写入 `localStorage`(会话键 `myreader-session-v1`);打开过的 Markdown 路径记入「历史」列表,与真实文件删除无关。自动保存开关状态持久化到 `localStorage`。
|
||||
|
||||
## 使用步骤(简述)
|
||||
## 使用步骤
|
||||
|
||||
1. **安装依赖**:在项目根目录执行 `npm install`。
|
||||
2. **开发运行**:`npm run dev`,在 Electron 窗口内使用。
|
||||
3. **打开文档**:工具栏「打开」、侧栏「打开文件夹」后点击 `.md` 文件,或从历史记录点选。
|
||||
4. **视图**:工具栏「视图」或使用 `Ctrl+E` 在 **编辑 / 阅读 / 分栏** 间切换。
|
||||
5. **外观**:工具栏右侧 **阅读行距**、**字体**、**背景**(含「Kindle 纸感」等)按需调整。
|
||||
6. **图片**:编辑模式下用工具栏插入本地图片;保存路径为 `file://` 或相对路径时,阅读/分栏预览会按上文规则解析显示。
|
||||
7. **图表**:在 Markdown 中使用 ` ```mermaid ` 围栏编写 Mermaid 语法即可在预览中渲染。
|
||||
8. **构建与打包**:`npm run build` 输出到 `out/`;`npm run dist` 构建并生成 Windows 安装包至 `release/`。
|
||||
3. **打开文档**:
|
||||
- 工具栏「打开」选择 `.md` 文件
|
||||
- 侧栏「打开文件夹」后点击文件树中的 `.md` / `.txt` 文件
|
||||
- 从历史记录点选
|
||||
- **拖放** `.md` / `.txt` 文件到编辑区
|
||||
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 build` | 构建到 `out/` |
|
||||
| `npm run typecheck` | TypeScript 检查 |
|
||||
| `npm run typecheck` | TypeScript 类型检查 |
|
||||
| `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` | 切换到第 1–9 个标签 |
|
||||
|
||||
## 技术栈摘要
|
||||
### 视图与面板
|
||||
| 快捷键 | 功能 |
|
||||
|--------|------|
|
||||
| `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。
|
||||
|
||||
|
||||
+498
-207
@@ -2,7 +2,6 @@ import * as React from 'react'
|
||||
import { EditorPane, type MarkdownEditorHandle } from './EditorPane'
|
||||
import type { EditorView } from '@codemirror/view'
|
||||
import { MarkdownFormatToolbar } from './MarkdownFormatToolbar'
|
||||
import { OutlinePanel } from './OutlinePanel'
|
||||
import { PreviewArticle } from './PreviewArticle'
|
||||
import {
|
||||
extractOutline,
|
||||
@@ -13,82 +12,24 @@ import {
|
||||
} from './markdown'
|
||||
import {
|
||||
addFileHistory,
|
||||
historyFileName,
|
||||
loadFileHistory,
|
||||
removeFileHistory
|
||||
} from './fileHistory'
|
||||
import { BACKGROUNDS, FONTS, validBackgroundId, validFontId, type ViewMode } from './appearance'
|
||||
import { COLOR_SWATCH_ROWS } from './colorPresets'
|
||||
|
||||
type Tab = {
|
||||
id: string
|
||||
filePath: string | null
|
||||
content: string
|
||||
savedContent: string
|
||||
}
|
||||
|
||||
const BG_KEY = 'myreader-bg-id'
|
||||
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)))
|
||||
}
|
||||
import type { Tab } from './types'
|
||||
import { clamp, newTab, tabLabel, isDirty, loadViewMode, viewModeLabel, loadPreviewLineHeight, normalizePathKey } from './utils'
|
||||
import {
|
||||
BG_KEY, FONT_KEY, VIEW_KEY, BROWSE_KEY, SIDEBAR_W_KEY, SIDEBAR_HIDDEN_KEY,
|
||||
STATS_W_KEY, STATS_HIDDEN_KEY, SPLIT_KEY, PREVIEW_W_KEY, PREVIEW_LINE_KEY, SESSION_KEY, AUTO_SAVE_KEY
|
||||
} from './constants'
|
||||
import { TabBar } from './TabBar'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { StatsPanel } from './StatsPanel'
|
||||
|
||||
type SessionTabRow = { path: string | null; content: string; saved: 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 {
|
||||
const api = window.myreader
|
||||
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')
|
||||
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 n = Number(localStorage.getItem(SPLIT_KEY) ?? '48')
|
||||
return Number.isFinite(n) ? clamp(n, 22, 78) : 48
|
||||
@@ -170,6 +118,13 @@ export default function App(): React.ReactElement {
|
||||
topPx: number
|
||||
seq: number
|
||||
} | 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 activeDoc = activeTab?.content ?? ''
|
||||
@@ -322,6 +277,14 @@ export default function App(): React.ReactElement {
|
||||
localStorage.setItem(SIDEBAR_W_KEY, String(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(() => {
|
||||
localStorage.setItem(SPLIT_KEY, String(splitPct))
|
||||
}, [splitPct])
|
||||
@@ -334,13 +297,29 @@ export default function App(): React.ReactElement {
|
||||
if (browseDir) localStorage.setItem(BROWSE_KEY, browseDir)
|
||||
}, [browseDir])
|
||||
|
||||
React.useEffect(() => {
|
||||
localStorage.setItem(AUTO_SAVE_KEY, autoSave ? '1' : '0')
|
||||
}, [autoSave])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!activeDoc) {
|
||||
setStatusText('')
|
||||
return
|
||||
}
|
||||
setStatusText('')
|
||||
}, [activeDoc, activeLineCount])
|
||||
|
||||
React.useEffect(() => {
|
||||
const onDoc = (): void => {
|
||||
setCtxMenu(null)
|
||||
setHistCtxMenu(null)
|
||||
}
|
||||
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(() => {
|
||||
@@ -585,12 +564,13 @@ export default function App(): React.ReactElement {
|
||||
if (!api) return
|
||||
const r = await api.openFileDialog()
|
||||
if (r.canceled || !r.path || r.content === undefined) return
|
||||
const parent = await api.parentDirectory(r.path)
|
||||
setBrowseDir(parent)
|
||||
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) {
|
||||
setActiveId(existing.id)
|
||||
setBrowseDir(await api.parentDirectory(r.path))
|
||||
recordFileOpen(r.path)
|
||||
return
|
||||
}
|
||||
@@ -634,9 +614,21 @@ export default function App(): React.ReactElement {
|
||||
await saveTabById(activeId)
|
||||
}, [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> => {
|
||||
if (!api) return
|
||||
const t = tabs.find((x) => x.id === activeId)
|
||||
const t = tabsRef.current.find((x) => x.id === activeId)
|
||||
if (!t) return
|
||||
const d = await api.saveFileDialog(t.filePath ?? undefined)
|
||||
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
|
||||
)
|
||||
)
|
||||
}, [api, activeId, tabs])
|
||||
}, [api, activeId])
|
||||
|
||||
const removeTabById = React.useCallback((tabId: string): void => {
|
||||
setTabs((prev) => {
|
||||
@@ -676,6 +668,19 @@ export default function App(): React.ReactElement {
|
||||
[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 => {
|
||||
setViewMode('edit')
|
||||
const nt = newTab()
|
||||
@@ -712,12 +717,6 @@ export default function App(): React.ReactElement {
|
||||
setBrowseDir(r.path)
|
||||
}, [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 => {
|
||||
e.preventDefault()
|
||||
const startX = e.clientX
|
||||
@@ -733,6 +732,21 @@ export default function App(): React.ReactElement {
|
||||
document.addEventListener('mouseup', onUp)
|
||||
}, [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 => {
|
||||
e.preventDefault()
|
||||
const shell = (e.currentTarget as HTMLElement).closest('.hybrid-row')
|
||||
@@ -770,6 +784,105 @@ export default function App(): React.ReactElement {
|
||||
[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(
|
||||
async (filePath: string): Promise<void> => {
|
||||
if (!api) return
|
||||
@@ -905,6 +1018,94 @@ export default function App(): React.ReactElement {
|
||||
void api.parentDirectory(activeTab.filePath).then(setBrowseDir)
|
||||
}, [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) {
|
||||
return (
|
||||
<div className="app-root">
|
||||
@@ -1194,7 +1395,10 @@ export default function App(): React.ReactElement {
|
||||
{histCtxMenu ? (
|
||||
<div
|
||||
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()}
|
||||
>
|
||||
<button
|
||||
@@ -1213,7 +1417,10 @@ export default function App(): React.ReactElement {
|
||||
{ctxMenu ? (
|
||||
<div
|
||||
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()}
|
||||
>
|
||||
<button
|
||||
@@ -1236,31 +1443,23 @@ export default function App(): React.ReactElement {
|
||||
) : null}
|
||||
|
||||
<header className="tab-bar">
|
||||
{tabs.map((t) => (
|
||||
<div key={t.id} className={`tab ${t.id === activeId ? 'active' : ''}`}>
|
||||
<button type="button" className="tab-main" onClick={() => setActiveId(t.id)}>
|
||||
<span className="tab-title">{tabLabel(t)}</span>
|
||||
{isDirty(t) ? <span className="dot">•</span> : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tab-close"
|
||||
aria-label="关闭标签"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
requestCloseTab(t.id)
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeId={activeId}
|
||||
onSelect={setActiveId}
|
||||
onClose={requestCloseTab}
|
||||
onCloseOthers={closeOtherTabs}
|
||||
onCloseAll={closeAllTabs}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className="toolbar">
|
||||
<button type="button" onClick={() => void openFileDialog()}>
|
||||
打开
|
||||
</button>
|
||||
<button type="button" onClick={() => void openFolderDialog()}>
|
||||
打开文件夹
|
||||
</button>
|
||||
<button type="button" onClick={() => void saveActive()}>
|
||||
保存
|
||||
</button>
|
||||
@@ -1276,6 +1475,25 @@ export default function App(): React.ReactElement {
|
||||
<button type="button" onClick={() => setSidebarHidden((v) => !v)}>
|
||||
{sidebarHidden ? '显示侧栏' : '隐藏侧栏'}
|
||||
</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">
|
||||
<label className="bg-select">
|
||||
行距
|
||||
@@ -1325,111 +1543,30 @@ export default function App(): React.ReactElement {
|
||||
{!sidebarHidden ? (
|
||||
<>
|
||||
<aside className="sidebar" style={{ width: sidebarWidth, minWidth: sidebarWidth }}>
|
||||
<div className="sidebar-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={sidebarTab === 'files' ? 'on' : ''}
|
||||
onClick={() => setSidebarTab('files')}
|
||||
>
|
||||
文件
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={sidebarTab === 'history' ? 'on' : ''}
|
||||
onClick={() => setSidebarTab('history')}
|
||||
>
|
||||
历史
|
||||
</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)
|
||||
}
|
||||
<Sidebar
|
||||
sidebarTab={sidebarTab}
|
||||
onSidebarTabChange={setSidebarTab}
|
||||
browseDir={browseDir}
|
||||
dirEntries={dirEntries}
|
||||
dirError={dirError}
|
||||
parentDisabled={parentDisabled}
|
||||
onNavigateDir={setBrowseDir}
|
||||
onOpenFile={(path) => void openPathInTab(path)}
|
||||
onContextMenu={(e, path, kind) => {
|
||||
e.preventDefault()
|
||||
setCtxMenu({ x: e.clientX, y: e.clientY, path, kind })
|
||||
}}
|
||||
onContextMenu={(ev) => {
|
||||
ev.preventDefault()
|
||||
setCtxMenu({
|
||||
x: ev.clientX,
|
||||
y: ev.clientY,
|
||||
path: e.path,
|
||||
kind: e.kind
|
||||
})
|
||||
fileHistory={fileHistory}
|
||||
onHistoryOpen={(path) => void openPathInTab(path)}
|
||||
onHistoryContextMenu={(e, path) => {
|
||||
e.preventDefault()
|
||||
setHistCtxMenu({ x: e.clientX, y: e.clientY, path })
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
e.kind === 'dir' ? 'entry-kind entry-kind--dir' : 'entry-kind entry-kind--file'
|
||||
}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
outlineItems={outline}
|
||||
onOutlineSelect={navigateToOutline}
|
||||
tabs={tabs}
|
||||
activeId={activeId}
|
||||
/>
|
||||
</aside>
|
||||
<div
|
||||
className="sidebar-resize"
|
||||
@@ -1448,7 +1585,125 @@ export default function App(): React.ReactElement {
|
||||
</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 ? (
|
||||
<MarkdownFormatToolbar editorRef={editorRef} />
|
||||
) : null}
|
||||
@@ -1466,7 +1721,13 @@ export default function App(): React.ReactElement {
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
) : null}
|
||||
@@ -1486,7 +1747,13 @@ export default function App(): React.ReactElement {
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
) : null}
|
||||
@@ -1534,12 +1801,36 @@ export default function App(): React.ReactElement {
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{activeTab && !statsHidden ? (
|
||||
<StatsPanel
|
||||
doc={activeDoc}
|
||||
width={statsWidth}
|
||||
onResizeStart={startStatsResize}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{statusText ? <div className="status-bar">{statusText}</div> : null}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
|
||||
export type MarkdownEditorHandle = {
|
||||
applyAction: (id: string) => void | Promise<void>
|
||||
searchText: (query: string) => boolean
|
||||
getView: () => EditorView | null
|
||||
}
|
||||
|
||||
type Props = {
|
||||
@@ -286,7 +288,22 @@ export const EditorPane = React.forwardRef<MarkdownEditorHandle, Props>(
|
||||
requestImageDetails: requestImageDetailsRef.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
|
||||
const cur = view.state.doc.toString()
|
||||
if (cur !== value) {
|
||||
const main = view.state.selection.main
|
||||
view.dispatch({
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,34 +1,59 @@
|
||||
import * as React from 'react'
|
||||
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: 'h2', label: 'H2' },
|
||||
{ id: 'h3', label: 'H3' },
|
||||
{ id: 'h4', label: 'H4' },
|
||||
{ id: 'h5', label: 'H5' },
|
||||
{ id: 'h6', label: 'H6' },
|
||||
{ id: 'bold', label: '加粗' },
|
||||
{ id: 'italic', label: '斜体' },
|
||||
{ id: 'strike', label: '删除线' },
|
||||
{ id: 'code', label: '行内代码' },
|
||||
{ id: 'codeBlock', label: '代码块' },
|
||||
{ id: 'h6', label: 'H6' }
|
||||
]
|
||||
},
|
||||
{
|
||||
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: 'image', label: '图片' },
|
||||
{ id: 'ul', label: '无序列表' },
|
||||
{ id: 'ol', label: '有序列表' },
|
||||
{ id: 'task', label: '任务' },
|
||||
{ id: 'quote', label: '引用' },
|
||||
{ id: 'hr', label: '分隔线' },
|
||||
{ id: 'codeBlock', label: '代码块' },
|
||||
{ id: 'table', label: '表格' },
|
||||
{ id: 'mark', label: '高亮' },
|
||||
{ id: 'color', label: '颜色' },
|
||||
{ id: 'hr', label: '分隔线' },
|
||||
{ id: 'quote', label: '引用' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '列表',
|
||||
buttons: [
|
||||
{ id: 'ul', label: '无序' },
|
||||
{ id: 'ol', label: '有序' },
|
||||
{ id: 'task', label: '任务' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '其他',
|
||||
buttons: [
|
||||
{ id: 'sup', label: '上标' },
|
||||
{ id: 'sub', label: '下标' },
|
||||
{ id: 'kbd', label: '按键' },
|
||||
{ id: 'details', label: '折叠' },
|
||||
{ id: 'footnote', label: '脚注' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
type Props = {
|
||||
editorRef: React.RefObject<MarkdownEditorHandle | null>
|
||||
@@ -38,7 +63,11 @@ export function MarkdownFormatToolbar({ editorRef }: Props): React.ReactElement
|
||||
return (
|
||||
<div className="md-format-wrap">
|
||||
<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
|
||||
key={b.id}
|
||||
type="button"
|
||||
@@ -50,5 +79,8 @@ export function MarkdownFormatToolbar({ editorRef }: Props): React.ReactElement
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,18 @@ export function OutlinePanel({ items, onSelect }: Props): React.ReactElement {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -328,72 +328,72 @@
|
||||
:root[data-font='0'] {
|
||||
--font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--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'] {
|
||||
--font-ui: Georgia, 'Times New Roman', serif;
|
||||
--font-ui: Georgia, 'Times New Roman', 'Noto Serif SC', serif;
|
||||
--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'] {
|
||||
--font-ui: system-ui, sans-serif;
|
||||
--font-editor: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
||||
--font-preview: 'Source Serif 4', Cambria, Georgia, serif;
|
||||
--font-editor: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace;
|
||||
--font-preview: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
||||
}
|
||||
:root[data-font='3'] {
|
||||
--font-ui: 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
||||
--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'] {
|
||||
--font-ui: 'IBM Plex Sans', system-ui, sans-serif;
|
||||
--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'] {
|
||||
--font-ui: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
--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'] {
|
||||
--font-ui: Verdana, Geneva, sans-serif;
|
||||
--font-editor: 'Lucida Console', monospace;
|
||||
--font-preview: Georgia, Cambria, serif;
|
||||
--font-ui: Georgia, 'Times New Roman', serif;
|
||||
--font-editor: 'Lucida Console', 'Courier New', monospace;
|
||||
--font-preview: Georgia, Cambria, 'Times New Roman', 'Noto Serif SC', serif;
|
||||
}
|
||||
:root[data-font='7'] {
|
||||
--font-ui: 'Trebuchet MS', sans-serif;
|
||||
--font-editor: 'Courier New', Courier, monospace;
|
||||
--font-preview: 'Book Antiqua', Palatino, serif;
|
||||
--font-ui: Verdana, Geneva, Tahoma, sans-serif;
|
||||
--font-editor: 'Lucida Console', 'Courier New', monospace;
|
||||
--font-preview: Verdana, Geneva, 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
:root[data-font='8'] {
|
||||
--font-ui: 'Segoe UI', system-ui, sans-serif;
|
||||
--font-editor: 'Cascadia Code', 'Cascadia Mono', Consolas, monospace;
|
||||
--font-preview: Constantia, 'Times New Roman', serif;
|
||||
--font-ui: 'Trebuchet MS', 'Segoe UI', sans-serif;
|
||||
--font-editor: 'Courier New', Courier, monospace;
|
||||
--font-preview: 'Book Antiqua', Palatino, 'Times New Roman', 'Noto Serif SC', serif;
|
||||
}
|
||||
:root[data-font='9'] {
|
||||
--font-ui: 'Segoe UI Variable', 'Segoe UI', sans-serif;
|
||||
--font-editor: 'Cascadia Mono', Consolas, monospace;
|
||||
--font-preview: 'Sitka Text', Georgia, serif;
|
||||
--font-ui: 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif;
|
||||
--font-editor: 'Cascadia Mono', 'Cascadia Code', Consolas, monospace;
|
||||
--font-preview: 'Sitka Text', 'Sitka', Georgia, 'Noto Serif SC', serif;
|
||||
}
|
||||
:root[data-font='10'] {
|
||||
--font-ui: 'Noto Sans SC', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
||||
--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'] {
|
||||
--font-ui: 'Hiragino Sans GB', 'Microsoft YaHei', system-ui, sans-serif;
|
||||
--font-editor: ui-monospace, monospace;
|
||||
--font-preview: 'STKaiti', KaiTi, 'Noto Serif SC', serif;
|
||||
--font-ui: 'STKaiti', 'KaiTi', 'Noto Serif SC', serif;
|
||||
--font-editor: 'Sarasa Mono SC', Consolas, monospace;
|
||||
--font-preview: 'STKaiti', 'KaiTi', 'Noto Serif SC', 'STSong', serif;
|
||||
}
|
||||
:root[data-font='12'] {
|
||||
--font-ui: 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
|
||||
--font-editor: 'Menlo', 'Monaco', Consolas, monospace;
|
||||
--font-preview: 'Songti SC', SimSun, 'Noto Serif SC', serif;
|
||||
--font-ui: 'PingFang SC', 'Microsoft YaHei', 'Hiragino Sans GB', sans-serif;
|
||||
--font-editor: 'SF Mono', Menlo, Consolas, monospace;
|
||||
--font-preview: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
: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-preview: 'Hiragino Mincho ProN', 'Songti SC', serif;
|
||||
--font-preview: 'Hiragino Mincho ProN', 'Songti SC', 'Noto Serif SC', serif;
|
||||
}
|
||||
:root[data-font='14'] {
|
||||
--font-ui: 'LXGW WenKai', 'KaiTi', 'Microsoft YaHei', sans-serif;
|
||||
@@ -401,89 +401,89 @@
|
||||
--font-preview: 'LXGW WenKai', 'KaiTi', 'Noto Serif SC', serif;
|
||||
}
|
||||
:root[data-font='15'] {
|
||||
--font-ui: 'HarmonyOS Sans', 'Microsoft YaHei', system-ui, sans-serif;
|
||||
--font-editor: 'HarmonyOS Sans Mono', Consolas, monospace;
|
||||
--font-preview: 'HarmonyOS Serif', 'Noto Serif SC', serif;
|
||||
--font-ui: 'HarmonyOS Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
||||
--font-editor: 'HarmonyOS Sans Mono', 'JetBrains Mono', Consolas, monospace;
|
||||
--font-preview: 'HarmonyOS Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
||||
}
|
||||
:root[data-font='16'] {
|
||||
--font-ui: 'OPPO Sans', 'Microsoft YaHei', system-ui, sans-serif;
|
||||
--font-editor: 'OPPO Sans Mono', Consolas, monospace;
|
||||
--font-preview: 'Source Han Serif SC', SimSun, serif;
|
||||
--font-ui: 'OPPO Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
||||
--font-editor: 'JetBrains Mono', Consolas, monospace;
|
||||
--font-preview: 'OPPO Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
||||
}
|
||||
:root[data-font='17'] {
|
||||
--font-ui: 'MiSans', 'Microsoft YaHei', system-ui, sans-serif;
|
||||
--font-editor: 'MiSans Mono', Consolas, monospace;
|
||||
--font-preview: 'Noto Serif SC', 'Source Han Serif SC', serif;
|
||||
--font-ui: 'MiSans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
||||
--font-editor: 'JetBrains Mono', Consolas, monospace;
|
||||
--font-preview: 'MiSans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
||||
}
|
||||
: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-preview: 'Alibaba PuHuiTi', 'Noto Serif SC', serif;
|
||||
--font-preview: 'Alibaba PuHuiTi', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
||||
}
|
||||
: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-preview: 'Smiley Sans', 'Noto Serif SC', serif;
|
||||
--font-preview: 'Smiley Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
||||
}
|
||||
: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-preview: 'Source Serif 4', Georgia, serif;
|
||||
--font-preview: 'Maple Mono', 'Cascadia Code', Consolas, monospace;
|
||||
}
|
||||
:root[data-font='21'] {
|
||||
--font-ui: 'Fira Sans', system-ui, sans-serif;
|
||||
--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'] {
|
||||
--font-ui: 'Roboto', system-ui, sans-serif;
|
||||
--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'] {
|
||||
--font-ui: 'Open Sans', system-ui, sans-serif;
|
||||
--font-editor: 'Source Code Pro', Consolas, monospace;
|
||||
--font-preview: 'Merriweather', Georgia, serif;
|
||||
--font-preview: 'Merriweather', Georgia, 'Noto Serif SC', serif;
|
||||
}
|
||||
:root[data-font='24'] {
|
||||
--font-ui: 'Lato', system-ui, sans-serif;
|
||||
--font-editor: 'Ubuntu Mono', Consolas, monospace;
|
||||
--font-preview: 'Lora', Georgia, serif;
|
||||
--font-preview: 'Lora', Georgia, 'Noto Serif SC', serif;
|
||||
}
|
||||
:root[data-font='25'] {
|
||||
--font-ui: 'Montserrat', system-ui, sans-serif;
|
||||
--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'] {
|
||||
--font-ui: 'Merriweather Sans', system-ui, sans-serif;
|
||||
--font-editor: 'Inconsolata', Consolas, monospace;
|
||||
--font-preview: 'Merriweather', Georgia, serif;
|
||||
--font-preview: 'Merriweather', Georgia, 'Noto Serif SC', serif;
|
||||
}
|
||||
:root[data-font='27'] {
|
||||
--font-ui: system-ui, sans-serif;
|
||||
--font-editor: 'Source Code Pro', monospace;
|
||||
--font-preview: 'Crimson Text', 'Times New Roman', serif;
|
||||
--font-ui: 'Crimson Text', Georgia, 'Times New Roman', serif;
|
||||
--font-editor: 'Source Code Pro', Consolas, monospace;
|
||||
--font-preview: 'Crimson Text', 'Times New Roman', 'Noto Serif SC', serif;
|
||||
}
|
||||
: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-preview: 'Literata', Georgia, serif;
|
||||
--font-preview: 'Literata', Georgia, 'Noto Serif SC', serif;
|
||||
}
|
||||
:root[data-font='29'] {
|
||||
--font-ui: system-ui, sans-serif;
|
||||
--font-editor: 'Inconsolata', 'Consolas', monospace;
|
||||
--font-preview: Georgia, 'Noto Serif SC', serif;
|
||||
--font-ui: 'Inconsolata', Consolas, monospace;
|
||||
--font-editor: 'Inconsolata', Consolas, monospace;
|
||||
--font-preview: 'Inconsolata', Consolas, monospace;
|
||||
}
|
||||
: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-preview: 'Palatino Linotype', Palatino, serif;
|
||||
--font-preview: 'Anonymous Pro', 'Courier New', monospace;
|
||||
}
|
||||
: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-preview: 'Charter', 'Noto Serif SC', serif;
|
||||
--font-preview: 'Fantasque Sans Mono', Consolas, monospace;
|
||||
}
|
||||
|
||||
:root[data-bg='4'] .toolbar,
|
||||
|
||||
@@ -28,37 +28,37 @@ export const BACKGROUNDS: { id: string; name: string }[] = [
|
||||
/** 字体 id 0–31,对应 `:root[data-font]`。 */
|
||||
export const FONTS: { id: string; name: string }[] = [
|
||||
{ id: '0', name: '系统默认' },
|
||||
{ id: '1', name: '衬线阅读' },
|
||||
{ id: '2', name: '等宽代码' },
|
||||
{ id: '3', name: '人文宋黑' },
|
||||
{ id: '4', name: 'IBM Plex' },
|
||||
{ id: '5', name: 'Helvetica 系' },
|
||||
{ id: '6', name: 'Georgia 系' },
|
||||
{ id: '7', name: 'Verdana 系' },
|
||||
{ id: '8', name: 'Courier 打字机' },
|
||||
{ id: '9', name: 'Segoe UI 系' },
|
||||
{ id: '10', name: '思源黑体优先' },
|
||||
{ id: '11', name: '圆体休闲' },
|
||||
{ id: '12', name: '苹方优先' },
|
||||
{ id: '1', name: '经典衬线' },
|
||||
{ id: '2', name: '现代等宽' },
|
||||
{ id: '3', name: '宋体阅读' },
|
||||
{ id: '4', name: 'IBM Plex 家族' },
|
||||
{ id: '5', name: 'Helvetica 现代' },
|
||||
{ id: '6', name: 'Georgia 优雅' },
|
||||
{ id: '7', name: 'Verdana 清晰' },
|
||||
{ id: '8', name: 'Courier 复古' },
|
||||
{ id: '9', name: 'Segoe UI 流畅' },
|
||||
{ id: '10', name: '思源黑体' },
|
||||
{ id: '11', name: '楷体古韵' },
|
||||
{ id: '12', name: '苹方雅致' },
|
||||
{ id: '13', name: '冬青黑体' },
|
||||
{ id: '14', name: '霞鹜文楷' },
|
||||
{ id: '15', name: 'HarmonyOS Sans' },
|
||||
{ id: '15', name: 'HarmonyOS 鸿蒙' },
|
||||
{ id: '16', name: 'OPPO Sans' },
|
||||
{ id: '17', name: '小米兰亭' },
|
||||
{ id: '18', name: '阿里巴巴普惠' },
|
||||
{ id: '17', name: 'MiSans 小米' },
|
||||
{ id: '18', name: '阿里巴巴普惠体' },
|
||||
{ id: '19', name: '得意黑' },
|
||||
{ id: '20', name: 'Maple Mono' },
|
||||
{ id: '21', name: 'Fira Sans + Mono' },
|
||||
{ id: '22', name: 'Roboto 系' },
|
||||
{ id: '23', name: 'Open Sans 系' },
|
||||
{ id: '24', name: 'Lato 系' },
|
||||
{ id: '25', name: 'Montserrat 系' },
|
||||
{ id: '20', name: 'Maple Mono 枫叶' },
|
||||
{ id: '21', name: 'Fira 家族' },
|
||||
{ id: '22', name: 'Roboto 家族' },
|
||||
{ id: '23', name: 'Open Sans 开放' },
|
||||
{ id: '24', name: 'Lato 优雅' },
|
||||
{ id: '25', name: 'Montserrat 现代' },
|
||||
{ id: '26', name: 'Merriweather 阅读' },
|
||||
{ id: '27', name: 'Crimson Text' },
|
||||
{ id: '28', name: 'Literata 阅读' },
|
||||
{ id: '29', name: 'Inconsolata 等宽' },
|
||||
{ id: '30', name: 'Anonymous Pro' },
|
||||
{ id: '31', name: 'Fantasque Sans Mono' }
|
||||
{ id: '27', name: 'Crimson 古典' },
|
||||
{ id: '28', name: 'Literata 电子书' },
|
||||
{ id: '29', name: 'Inconsolata 代码' },
|
||||
{ id: '30', name: 'Anonymous Pro 复古码' },
|
||||
{ id: '31', name: 'Fantasque 趣味码' }
|
||||
]
|
||||
|
||||
export type ViewMode = 'edit' | 'read' | 'hybrid'
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,14 @@ function isMermaidFence(lang: string, content: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
function escapeHtmlSafe(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function highlightCodeBlock(content: string, lang: string): string {
|
||||
const trimmed = (lang || '').trim()
|
||||
if (trimmed && hljs.getLanguage(trimmed)) {
|
||||
@@ -32,7 +40,7 @@ function highlightCodeBlock(content: string, lang: string): string {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return md.utils.escapeHtml(content)
|
||||
return escapeHtmlSafe(content)
|
||||
}
|
||||
|
||||
const md = new MarkdownIt({
|
||||
|
||||
@@ -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 }
|
||||
@@ -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)))
|
||||
}
|
||||
Reference in New Issue
Block a user