diff --git a/.idea/protoPluginMemos.xml b/.idea/protoPluginMemos.xml new file mode 100644 index 0000000..c0a3a74 --- /dev/null +++ b/.idea/protoPluginMemos.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 623e284..cd10803 100644 --- a/README.md +++ b/README.md @@ -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,22 +74,52 @@ |------|------| | `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。 --- -仓库 npm 包名仍为 `myreader`;安装后显示名与窗口标题为 **Huangzhijun's Reader**。 +仓库 npm 包名仍为 `myreader`;安装后显示名与窗口标题为 **Huangzhijun's Reader**。 \ No newline at end of file diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e040d8a..fefa2d9 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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(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(() => { + const n = Number(localStorage.getItem(STATS_W_KEY) ?? '220') + return Number.isFinite(n) ? clamp(n, 160, 400) : 220 + }) + const [statsHidden, setStatsHidden] = React.useState( + () => localStorage.getItem(STATS_HIDDEN_KEY) === '1' + ) const [splitPct, setSplitPct] = React.useState(() => { 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( + () => 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 => { 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 => { - 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 => { + 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 = ` + + + + +${activeTab?.filePath ? tabLabel(activeTab) : '导出文档'} + + + +${previewHtml} + +` + 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 => { 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 (
@@ -1194,7 +1395,10 @@ export default function App(): React.ReactElement { {histCtxMenu ? (
e.stopPropagation()} > - -
- ))} +
+ @@ -1276,6 +1475,25 @@ export default function App(): React.ReactElement { + + +
) : (
- 请从侧栏选择文件、使用工具栏「打开」,或点击「新标签」开始编辑。 +
📝
+
请从侧栏选择文件、使用工具栏「打开」,或点击「新标签」开始编辑。
+
支持拖放 .md 文件到此处打开 · Ctrl+F 搜索 · Ctrl+S 保存
+
+ 📂 + 拖放 Markdown 文件到此处打开 +
) ) : null} + + {activeTab && !statsHidden ? ( + + ) : null} + + {statusText ?
{statusText}
: null}
+ {dragOver ? ( +
+
+ 📂 + 释放以打开 Markdown 文件 +
+
+ ) : null}
) } diff --git a/src/renderer/src/EditorPane.tsx b/src/renderer/src/EditorPane.tsx index f369391..c8a9ff2 100644 --- a/src/renderer/src/EditorPane.tsx +++ b/src/renderer/src/EditorPane.tsx @@ -13,6 +13,8 @@ import { export type MarkdownEditorHandle = { applyAction: (id: string) => void | Promise + searchText: (query: string) => boolean + getView: () => EditorView | null } type Props = { @@ -286,7 +288,22 @@ export const EditorPane = React.forwardRef( 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( 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 }) } diff --git a/src/renderer/src/MarkdownFormatToolbar.tsx b/src/renderer/src/MarkdownFormatToolbar.tsx index 7378e66..9661319 100644 --- a/src/renderer/src/MarkdownFormatToolbar.tsx +++ b/src/renderer/src/MarkdownFormatToolbar.tsx @@ -1,33 +1,58 @@ import * as React from 'react' import type { MarkdownEditorHandle } from './EditorPane' -const BUTTONS: { id: string; label: string }[] = [ - { 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: 'link', label: '链接' }, - { id: 'image', label: '图片' }, - { id: 'ul', label: '无序列表' }, - { id: 'ol', label: '有序列表' }, - { id: 'task', label: '任务' }, - { id: 'quote', label: '引用' }, - { id: 'hr', label: '分隔线' }, - { id: 'table', label: '表格' }, - { id: 'mark', label: '高亮' }, - { id: 'color', label: '颜色' }, - { id: 'sup', label: '上标' }, - { id: 'sub', label: '下标' }, - { id: 'kbd', label: '按键' }, - { id: 'details', label: '折叠' }, - { id: 'footnote', label: '脚注' } +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' } + ] + }, + { + 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: 'codeBlock', label: '代码块' }, + { id: 'table', 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 = { @@ -38,15 +63,22 @@ export function MarkdownFormatToolbar({ editorRef }: Props): React.ReactElement return (
- {BUTTONS.map((b) => ( - + {GROUPS.map((group) => ( +
+ {group.label} +
+ {group.buttons.map((b) => ( + + ))} +
+
))}
diff --git a/src/renderer/src/OutlinePanel.tsx b/src/renderer/src/OutlinePanel.tsx index cc98e50..82037eb 100644 --- a/src/renderer/src/OutlinePanel.tsx +++ b/src/renderer/src/OutlinePanel.tsx @@ -74,7 +74,18 @@ export function OutlinePanel({ items, onSelect }: Props): React.ReactElement { } if (items.length === 0) { - return

当前文档没有标题

+ return ( +
+
+ 大纲 +
+
+ 📑 + 当前文档没有标题 + 在文档中使用 # 标题语法即可在此显示 +
+
+ ) } const collapsedCount = collapsed.size diff --git a/src/renderer/src/Sidebar.tsx b/src/renderer/src/Sidebar.tsx new file mode 100644 index 0000000..a0fa038 --- /dev/null +++ b/src/renderer/src/Sidebar.tsx @@ -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 ( +
+
+ + + +
+ + {sidebarTab === 'files' ? ( + + ) : sidebarTab === 'history' ? ( + + ) : ( + + )} +
+ ) +} + +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 ( +
+
+ + {browseDir} +
+
+ onFilterChange(e.target.value)} + /> +
+
+ {dirError ? ( +
+
⚠️
+
{dirError}
+
+ ) : dirEntries.length === 0 ? ( +
+
📂
+
此目录为空
+
拖放 Markdown 文件到窗口打开
+
+ ) : ( + dirEntries.map((e) => ( +
{ + if (e.kind === 'dir') onNavigateDir(e.path) + else onOpenFile(e.path) + }} + onContextMenu={(ev) => onContextMenu(ev, e.path, e.kind)} + > + {e.kind === 'dir' ? '📁' : '📄'} + {e.name} +
+ )) + )} +
+
+ ) +} + +function HistoryPanel({ + fileHistory, + onOpen, + onContextMenu +}: { + fileHistory: string[] + onOpen: (path: string) => void + onContextMenu: (e: React.MouseEvent, path: string) => void +}): React.ReactElement { + return ( +
+ {fileHistory.length === 0 ? ( +
+
🕐
+
暂无历史记录
+
打开文件后会自动记录
+
+ ) : ( + fileHistory.map((p) => ( +
onOpen(p)} + onContextMenu={(ev) => onContextMenu(ev, p)} + > + 📄 +
+ {p.split(/[/\\]/).pop()} + {p} +
+
+ )) + )} +
+ ) +} \ No newline at end of file diff --git a/src/renderer/src/StatsPanel.tsx b/src/renderer/src/StatsPanel.tsx new file mode 100644 index 0000000..1eea7bf --- /dev/null +++ b/src/renderer/src/StatsPanel.tsx @@ -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(/(? 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 ( + <> +
+ + + ) +} \ No newline at end of file diff --git a/src/renderer/src/TabBar.tsx b/src/renderer/src/TabBar.tsx new file mode 100644 index 0000000..b9de1bf --- /dev/null +++ b/src/renderer/src/TabBar.tsx @@ -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(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 ( +
+ {tabs.map((t) => ( + onSelect(t.id)} + onClose={() => onClose(t.id)} + onCloseOthers={() => onCloseOthers(t.id)} + onCloseAll={onCloseAll} + /> + ))} +
+ ) +} + +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(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 ( +
+ + + {menuOpen ? ( +
+ + + +
+ ) : null} +
+ ) +} \ No newline at end of file diff --git a/src/renderer/src/appearance-styles.css b/src/renderer/src/appearance-styles.css index 1f56ca8..ec08717 100644 --- a/src/renderer/src/appearance-styles.css +++ b/src/renderer/src/appearance-styles.css @@ -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, diff --git a/src/renderer/src/appearance.ts b/src/renderer/src/appearance.ts index c417292..48579b2 100644 --- a/src/renderer/src/appearance.ts +++ b/src/renderer/src/appearance.ts @@ -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' diff --git a/src/renderer/src/constants.ts b/src/renderer/src/constants.ts new file mode 100644 index 0000000..a237f8e --- /dev/null +++ b/src/renderer/src/constants.ts @@ -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' \ No newline at end of file diff --git a/src/renderer/src/index.css b/src/renderer/src/index.css index 33b4334..1c09976 100644 --- a/src/renderer/src/index.css +++ b/src/renderer/src/index.css @@ -16,6 +16,13 @@ body, body { font-family: var(--font-ui, system-ui, sans-serif); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +::selection { + background: color-mix(in srgb, var(--accent) 30%, transparent); + color: var(--text); } .app-root { @@ -26,6 +33,34 @@ body { color: var(--text); background: var(--app-bg); font-family: var(--font-ui, system-ui, sans-serif); + transition: background 0.3s ease; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--muted) 30%, transparent); + border-radius: 4px; + border: 2px solid transparent; + background-clip: content-box; + transition: background 0.2s; +} + +::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--muted) 50%, transparent); + border: 2px solid transparent; + background-clip: content-box; +} + +::-webkit-scrollbar-corner { + background: transparent; } /* Kindle 纸感:叠在纯色底上,避免覆盖 index 中的 background 简写 */ @@ -52,7 +87,7 @@ html[data-bg='22'] .app-root { flex-wrap: nowrap; align-items: stretch; gap: 4px; - padding: 6px 8px 0; + padding: 8px 10px 0; border-bottom: 1px solid var(--border); background: var(--panel-bg); position: relative; @@ -60,33 +95,52 @@ html[data-bg='22'] .app-root { overflow-x: auto; overflow-y: hidden; scrollbar-width: thin; + box-shadow: 0 1px 4px color-mix(in srgb, #000 3%, transparent); } .tab-bar::-webkit-scrollbar { - height: 6px; + height: 5px; } .tab-bar::-webkit-scrollbar-thumb { - background: color-mix(in srgb, var(--muted) 50%, transparent); + background: color-mix(in srgb, var(--muted) 40%, transparent); border-radius: 3px; } .tab { display: inline-flex; - flex: 0 0 auto; + flex: 0 0 160px; align-items: stretch; - max-width: 320px; + min-width: 120px; + max-width: 200px; border: 1px solid transparent; border-bottom: none; - border-radius: 6px 6px 0 0; + border-radius: 8px 8px 0 0; background: transparent; color: inherit; overflow: hidden; + transition: background 0.15s; +} + +.tab:not(.active):hover { + background: var(--hover); } .tab.active { background: var(--app-bg); border-color: var(--border); + box-shadow: 0 -2px 0 var(--accent), 0 1px 0 var(--app-bg); + position: relative; +} + +.tab.active::after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 1px; + background: var(--app-bg); } .tab-main { @@ -95,7 +149,7 @@ html[data-bg='22'] .app-root { display: inline-flex; align-items: center; gap: 6px; - padding: 6px 4px 6px 10px; + padding: 7px 4px 7px 12px; border: none; background: transparent; color: inherit; @@ -110,33 +164,65 @@ html[data-bg='22'] .app-root { background: transparent; color: var(--muted); cursor: pointer; - font-size: 16px; + font-size: 15px; line-height: 1; + border-radius: 4px; + transition: color 0.12s, background 0.12s; } .tab-close:hover { - color: var(--text); - background: var(--hover); + color: #e53e3e; + background: color-mix(in srgb, #e53e3e 10%, transparent); } .tab-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: 15px; - font-weight: 600; + font-size: 13px; + font-weight: 500; letter-spacing: 0.01em; } .tab.active .tab-title { - font-size: 16px; - font-weight: 700; + font-weight: 600; } .tab .dot { color: var(--accent); - font-size: 18px; + font-size: 20px; line-height: 1; + margin-right: 2px; +} + +.tab-context-menu { + position: fixed; + z-index: 9999; + background: var(--panel-bg); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + padding: 4px; + min-width: 120px; +} + +.tab-context-menu button { + display: block; + width: 100%; + padding: 7px 14px; + border: none; + background: transparent; + color: var(--text); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; + border-radius: 5px; + transition: background 0.12s; +} + +.tab-context-menu button:hover { + background: var(--hover); } .toolbar { @@ -145,12 +231,13 @@ html[data-bg='22'] .app-root { flex-wrap: wrap; gap: 8px; align-items: center; - padding: 8px 10px; + padding: 10px 14px; border-bottom: 1px solid var(--border); background: var(--panel-bg); position: relative; z-index: 120; isolation: isolate; + box-shadow: 0 1px 4px color-mix(in srgb, #000 3%, transparent); } .toolbar button, @@ -164,15 +251,24 @@ html[data-bg='22'] .app-root { } .toolbar button { - padding: 6px 10px; - border-radius: 6px; + padding: 7px 14px; + border-radius: 7px; border: 1px solid var(--border); background: var(--btn-bg); color: var(--text); + font-size: 13px; + font-weight: 500; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s, transform 0.1s; } .toolbar button:hover { border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 8%, var(--btn-bg)); + box-shadow: 0 1px 4px color-mix(in srgb, var(--accent) 15%, transparent); +} + +.toolbar button:active { + transform: scale(0.97); } .toolbar-tail { @@ -185,8 +281,8 @@ html[data-bg='22'] .app-root { } .bg-select { - flex: 0 0 7.25rem; - width: 7.25rem; + flex: 0 0 7.5rem; + width: 7.5rem; display: flex; flex-direction: column; align-items: stretch; @@ -201,7 +297,7 @@ html[data-bg='22'] .app-root { width: 100%; min-width: 0; box-sizing: border-box; - padding: 3px 6px; + padding: 4px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--btn-bg); @@ -210,6 +306,13 @@ html[data-bg='22'] .app-root { z-index: 130; font: inherit; font-size: 12px; + transition: border-color 0.15s; +} + +.bg-select select:focus, +.bg-select .toolbar-mini-input:focus { + outline: none; + border-color: var(--accent); } .bg-select .toolbar-mini-input { @@ -228,6 +331,7 @@ html[data-bg='22'] .app-root { display: flex; position: relative; overflow: hidden; + background: color-mix(in srgb, var(--bg) 98%, var(--accent)); } .sidebar { @@ -240,6 +344,15 @@ html[data-bg='22'] .app-root { flex-direction: column; min-height: 0; overflow: hidden; + box-shadow: 2px 0 8px color-mix(in srgb, #000 3%, transparent); +} + +.sidebar-content { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; } .sidebar-resize { @@ -247,6 +360,7 @@ html[data-bg='22'] .app-root { cursor: col-resize; flex: 0 0 5px; background: transparent; + transition: background 0.15s; } .sidebar-resize:hover { @@ -255,18 +369,20 @@ html[data-bg='22'] .app-root { } .sidebar-reopen { - flex: 0 0 22px; - width: 22px; + flex: 0 0 26px; + width: 26px; border: none; border-right: 1px solid var(--border); background: var(--panel-bg); color: var(--text); cursor: pointer; font-size: 16px; + transition: background 0.15s, color 0.15s; } .sidebar-reopen:hover { background: var(--hover); + color: var(--accent); } .sidebar-tabs { @@ -276,16 +392,59 @@ html[data-bg='22'] .app-root { .sidebar-tabs button { flex: 1; - padding: 8px; + padding: 11px 8px; border: none; background: transparent; color: var(--muted); border-bottom: 2px solid transparent; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} + +.sidebar-tabs button:hover { + color: var(--text); + background: var(--hover); } .sidebar-tabs button.on { - color: var(--text); + color: var(--accent); border-bottom-color: var(--accent); + font-weight: 600; + background: color-mix(in srgb, var(--accent) 4%, transparent); +} + +.sidebar-tabs button.on:hover { + background: color-mix(in srgb, var(--accent) 8%, transparent); +} + +.file-filter { + padding: 8px 12px; + border-bottom: 1px solid var(--border); +} + +.file-filter-input { + width: 100%; + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--btn-bg); + color: var(--text); + font-size: 13px; + font-family: inherit; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.file-filter-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); +} + +.file-filter-input::placeholder { + color: var(--muted); + opacity: 0.6; } .file-pane, @@ -300,7 +459,7 @@ html[data-bg='22'] .app-root { flex-direction: row; align-items: center; gap: 8px; - padding: 8px; + padding: 10px 12px; border-bottom: 1px solid var(--border); flex-wrap: wrap; } @@ -319,11 +478,19 @@ html[data-bg='22'] .app-root { } .path-row button { - padding: 4px 8px; - border-radius: 4px; + padding: 6px 12px; + border-radius: 6px; border: 1px solid var(--border); background: var(--btn-bg); color: var(--text); + font-size: 12px; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} + +.path-row button:hover:not(:disabled) { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 6%, var(--btn-bg)); } .path-row button:disabled { @@ -341,28 +508,81 @@ html[data-bg='22'] .app-root { } .dir-error { - padding: 8px; + padding: 10px; color: #c62828; font-size: 13px; } +.dir-list-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px 4px; + flex-shrink: 0; +} + +.dir-list-title { + flex: 1; + font-size: 12px; + font-weight: 600; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dir-list-count { + font-size: 11px; + color: var(--muted); + white-space: nowrap; +} + .dir-list, .outline { list-style: none; margin: 0; - padding: 4px 0; + padding: 6px 0; +} + +.file-panel, +.history-panel { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.history-panel { + overflow: auto; +} + +.dir-list { + flex: 1; + min-height: 0; + overflow: auto; + list-style: none; + margin: 0; + padding: 6px 0; } .dir-item { width: 100%; text-align: left; - padding: 6px 10px; + padding: 8px 14px; border: none; background: transparent; color: inherit; display: flex; align-items: center; gap: 8px; + font-size: 13px; + border-radius: 6px; + transition: background 0.12s; +} + +.dir-item:hover { + background: var(--hover); } .outline-title, @@ -375,7 +595,7 @@ html[data-bg='22'] .app-root { display: flex; align-items: stretch; gap: 10px; - padding: 4px 8px 4px 6px; + padding: 5px 10px 5px 8px; border-radius: 8px; transition: background 0.12s ease; } @@ -393,8 +613,8 @@ html[data-bg='22'] .app-root { display: inline-flex; align-items: center; justify-content: center; - min-width: 30px; - height: 30px; + min-width: 32px; + height: 32px; padding: 0 6px; border-radius: 8px; border: 2px solid var(--border); @@ -403,6 +623,7 @@ html[data-bg='22'] .app-root { font-weight: 700; line-height: 1; box-sizing: border-box; + transition: border-color 0.15s, background 0.15s; } .outline-level--leaf { @@ -449,8 +670,8 @@ html[data-bg='22'] .app-root { } .outline-level--h1 { - min-width: 34px; - height: 34px; + min-width: 36px; + height: 36px; font-size: 15px; border-width: 2px; } @@ -460,13 +681,13 @@ html[data-bg='22'] .app-root { } .outline-level--h2 { - min-width: 32px; - height: 32px; + min-width: 34px; + height: 34px; } .outline-level--h3 { - min-width: 30px; - height: 28px; + min-width: 32px; + height: 30px; font-weight: 650; } @@ -477,8 +698,8 @@ html[data-bg='22'] .app-root { .outline-level--h4, .outline-level--h5, .outline-level--h6 { - min-width: 28px; - height: 26px; + min-width: 30px; + height: 28px; font-weight: 600; color: var(--muted); border-color: color-mix(in srgb, var(--border) 90%, var(--muted)); @@ -494,7 +715,7 @@ html[data-bg='22'] .app-root { flex: 1; min-width: 0; text-align: left; - padding: 6px 4px 6px 0; + padding: 7px 6px 7px 0; border: none; background: transparent; color: inherit; @@ -506,20 +727,21 @@ html[data-bg='22'] .app-root { -webkit-box-orient: vertical; white-space: normal; word-break: break-word; + transition: color 0.12s; } .outline-entry--h1 .outline-title { - font-weight: 650; + font-weight: 700; font-size: 14px; } .outline-entry--h2 .outline-title { - font-weight: 600; - font-size: 13px; + font-weight: 650; + font-size: 13.5px; } .outline-entry--h3 .outline-title { - font-weight: 550; + font-weight: 600; font-size: 13px; } @@ -527,12 +749,12 @@ html[data-bg='22'] .app-root { .outline-entry--h5 .outline-title, .outline-entry--h6 .outline-title { font-weight: 500; - font-size: 12px; + font-size: 12.5px; color: var(--muted); } .outline-panel { - padding: 6px 4px; + padding: 0; } .outline-panel-wrap { @@ -555,56 +777,62 @@ html[data-bg='22'] .app-root { .outline-toolbar { display: flex; align-items: center; - gap: 6px; - padding: 8px 8px 6px; + gap: 8px; + padding: 10px 14px 8px; border-bottom: 1px solid var(--border); flex-shrink: 0; } .outline-toolbar-title { flex: 1; - font-size: 12px; - color: var(--muted); + font-size: 13px; + font-weight: 600; + color: var(--text); } .outline-toolbar-btn { - padding: 3px 8px; + padding: 4px 10px; font-size: 11px; - border-radius: 4px; + border-radius: 5px; border: 1px solid var(--border); background: var(--btn-bg); color: var(--text); cursor: pointer; font: inherit; + transition: border-color 0.15s, background 0.15s; } .outline-toolbar-btn:hover:not(:disabled) { border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 8%, var(--btn-bg)); } .outline-toolbar-btn:disabled { - opacity: 0.45; + opacity: 0.4; cursor: default; } .outline-toolbar-meta { color: var(--muted); font-weight: 400; + font-size: 11px; } .outline-tree--root { overflow: auto; flex: 1; min-height: 0; + padding: 6px 8px; } .outline-tree-row { display: flex; align-items: center; - gap: 4px; + gap: 6px; min-width: 0; - padding: 2px 6px 2px 4px; + padding: 4px 10px 4px 8px; border-radius: 6px; + transition: background 0.12s ease; } .outline-tree-row:hover { @@ -612,32 +840,38 @@ html[data-bg='22'] .app-root { } .outline-fold-btn { - flex: 0 0 22px; - width: 22px; - height: 22px; + flex: 0 0 24px; + width: 24px; + height: 24px; padding: 0; border: 1px solid var(--border); - border-radius: 4px; + border-radius: 5px; background: var(--btn-bg); color: var(--accent); - font-size: 12px; + font-size: 11px; line-height: 1; cursor: pointer; - font: inherit; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, border-color 0.15s, transform 0.15s; } .outline-fold-btn:hover { border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 10%, var(--btn-bg)); + background: color-mix(in srgb, var(--accent) 12%, var(--btn-bg)); + transform: scale(1.08); } .outline-fold-btn.is-collapsed { color: var(--muted); + border-color: var(--border); + background: transparent; } .outline-fold-spacer { - flex: 0 0 22px; - width: 22px; + flex: 0 0 24px; + width: 24px; } .outline-tree-title { @@ -648,22 +882,25 @@ html[data-bg='22'] .app-root { background: transparent; color: var(--text); font: inherit; - padding: 4px 6px; - border-radius: 4px; + padding: 5px 8px; + border-radius: 5px; cursor: pointer; - line-height: 1.35; + line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + transition: color 0.12s, background 0.12s; } .outline-tree-title:hover { color: var(--accent); + background: color-mix(in srgb, var(--accent) 6%, transparent); } .outline-tree-title--h1 { font-weight: 700; font-size: 14px; + letter-spacing: 0.01em; } .outline-tree-title--h2 { @@ -691,9 +928,26 @@ html[data-bg='22'] .app-root { } .outline-tree .outline-tree { - margin: 2px 0 4px 6px; - padding: 2px 0 2px 12px; - border-left: 2px solid color-mix(in srgb, var(--accent) 22%, var(--border)); + margin: 2px 0 4px 8px; + padding: 2px 0 2px 14px; + border-left: 2px solid color-mix(in srgb, var(--accent) 18%, var(--border)); + position: relative; +} + +.outline-tree .outline-tree::before { + content: ''; + position: absolute; + left: -2px; + top: 0; + bottom: 0; + width: 2px; + border-radius: 1px; + background: linear-gradient( + to bottom, + color-mix(in srgb, var(--accent) 30%, transparent), + color-mix(in srgb, var(--accent) 8%, transparent) 60%, + transparent + ); } .outline-tree-node { @@ -701,15 +955,52 @@ html[data-bg='22'] .app-root { } .outline-empty { - margin: 12px 10px; + margin: 16px 12px; font-size: 13px; color: var(--muted); + text-align: center; + padding: 20px 12px; + border: 1px dashed var(--border); + border-radius: 8px; + background: color-mix(in srgb, var(--muted) 4%, transparent); +} + +.sidebar-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 32px 20px; + text-align: center; + min-height: 0; +} + +.sidebar-empty-icon { + font-size: 36px; + opacity: 0.4; + margin-bottom: 4px; +} + +.sidebar-empty-text { + font-size: 14px; + font-weight: 500; + color: var(--muted); +} + +.sidebar-empty-hint { + font-size: 12px; + color: var(--muted); + opacity: 0.65; + line-height: 1.45; + max-width: 220px; } .outline-simple { list-style: none; margin: 0; - padding: 6px 4px 10px; + padding: 6px 6px 10px; } .outline-simple-item { @@ -719,17 +1010,19 @@ html[data-bg='22'] .app-root { background: transparent; color: var(--text); font: inherit; - padding: 6px 8px; + padding: 7px 10px; border-radius: 6px; cursor: pointer; - line-height: 1.35; + line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + transition: background 0.12s, color 0.12s; } .outline-simple-item:hover { background: var(--hover); + color: var(--accent); } .outline-simple-item--h1 { @@ -757,35 +1050,95 @@ html[data-bg='22'] .app-root { } .history-pane { - padding-top: 4px; + display: flex; + flex-direction: column; + min-height: 0; +} + +.history-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px 8px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.history-toolbar-title { + flex: 1; + font-size: 13px; + font-weight: 600; + color: var(--text); +} + +.history-toolbar-meta { + color: var(--muted); + font-weight: 400; + font-size: 11px; +} + +.history-clear-btn { + padding: 4px 10px; + font-size: 11px; + border-radius: 5px; + border: 1px solid var(--border); + background: var(--btn-bg); + color: var(--muted); + cursor: pointer; + font: inherit; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} + +.history-clear-btn:hover { + border-color: #e53e3e; + color: #e53e3e; + background: color-mix(in srgb, #e53e3e 6%, var(--btn-bg)); } .history-list { list-style: none; margin: 0; - padding: 0 4px 8px; + padding: 6px 6px 8px; + flex: 1; + overflow: auto; } .history-item { width: 100%; display: flex; - flex-direction: column; + flex-direction: row; align-items: flex-start; - gap: 2px; + gap: 8px; text-align: left; border: none; background: transparent; color: inherit; font: inherit; - padding: 8px 10px; + padding: 10px 12px; border-radius: 8px; cursor: pointer; + transition: background 0.12s; } .history-item:hover { background: var(--hover); } +.history-item-icon { + flex: 0 0 auto; + font-size: 14px; + line-height: 1.4; + margin-top: 1px; +} + +.history-item-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + .history-item-name { font-weight: 600; font-size: 13px; @@ -805,14 +1158,15 @@ html[data-bg='22'] .app-root { } .entry-kind { - flex: 0 0 24px; + flex: 0 0 26px; font-size: 10px; font-weight: 700; text-align: center; - border-radius: 4px; + border-radius: 5px; padding: 3px 0; line-height: 1.1; color: #fff; + letter-spacing: 0.02em; } .entry-kind--dir { @@ -843,29 +1197,58 @@ html[data-bg='22'] .app-root { .md-format-bar { display: flex; flex-wrap: wrap; - gap: 4px; + gap: 2px; padding: 6px 8px; - max-height: 120px; + max-height: 140px; overflow-y: auto; } +.md-format-group { + display: flex; + align-items: center; + gap: 2px; + padding: 2px 6px; + border-right: 1px solid var(--border); +} + +.md-format-group:last-child { + border-right: none; +} + +.md-format-group-label { + font-size: 10px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0 2px; + user-select: none; +} + +.md-format-group-btns { + display: flex; + gap: 2px; +} + .md-format-bar button { padding: 4px 8px; - font-size: 12px; - border-radius: 4px; + font-size: 11px; + border-radius: 5px; border: 1px solid var(--border); background: var(--btn-bg); color: var(--text); cursor: pointer; white-space: nowrap; + transition: border-color 0.15s, background 0.15s; } .md-format-bar button:hover { border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, var(--btn-bg)); } .dir-item[data-kind='dir'] { font-weight: 600; + color: var(--accent); } .content { @@ -879,25 +1262,342 @@ html[data-bg='22'] .app-root { overflow: hidden; } +.search-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--border); + background: var(--panel-bg); + z-index: 26; +} + +.search-input { + flex: 1; + padding: 8px 14px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--btn-bg); + color: var(--text); + font: inherit; + font-size: 13px; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.search-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); +} + +.search-close-btn { + flex: 0 0 32px; + height: 32px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--btn-bg); + color: var(--muted); + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} + +.search-close-btn:hover { + color: var(--text); + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 6%, var(--btn-bg)); +} + +.search-next-btn { + flex: 0 0 32px; + height: 32px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--btn-bg); + color: var(--accent); + cursor: pointer; + font-size: 14px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.15s, background 0.15s; +} + +.search-next-btn:hover { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, var(--btn-bg)); +} + +.status-bar { + flex: 0 0 auto; + padding: 6px 16px; + border-top: 1px solid var(--border); + background: var(--panel-bg); + font-size: 12px; + color: var(--muted); + display: flex; + align-items: center; + gap: 14px; + min-height: 30px; + z-index: 20; + letter-spacing: 0.01em; +} + .content-empty { flex: 1; min-height: 0; display: flex; + flex-direction: column; align-items: center; justify-content: center; - padding: 32px; + padding: 40px; color: var(--muted); font-size: 15px; text-align: center; + gap: 14px; +} + +.content-empty-icon { + font-size: 52px; + opacity: 0.3; + margin-bottom: 6px; +} + +.content-empty-hint { + font-size: 13px; + color: var(--muted); + opacity: 0.7; + max-width: 380px; + line-height: 1.55; +} + +.content-drop-zone { + margin-top: 20px; + padding: 28px 48px; + border: 2px dashed color-mix(in srgb, var(--accent) 35%, var(--border)); + border-radius: 14px; + background: color-mix(in srgb, var(--accent) 4%, var(--btn-bg)); + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + font-size: 14px; + color: var(--muted); + transition: border-color 0.2s, background 0.2s, transform 0.2s; +} + +.content-drop-zone:hover { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 8%, var(--btn-bg)); + transform: scale(1.02); +} + +.content-drop-zone-icon { + font-size: 36px; + opacity: 0.55; +} + +.content-drag-over { + outline: 3px dashed var(--accent); + outline-offset: -6px; + background: color-mix(in srgb, var(--accent) 8%, var(--app-bg)); +} + +.content-drag-over .content-drop-zone { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, var(--btn-bg)); + transform: scale(1.03); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 15%, transparent); +} + +.stats-panel { + flex: 0 0 auto; + min-width: 0; + border-left: 1px solid var(--border); + background: var(--panel-bg); + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.stats-resize { + width: 5px; + cursor: col-resize; + flex: 0 0 5px; + background: transparent; + transition: background 0.15s; + position: relative; + z-index: 1; +} + +.stats-resize:hover, +.stats-resize:active { + background: var(--accent); +} + +.stats-panel-header { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 14px; + font-size: 12px; + font-weight: 600; + color: var(--muted); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.stats-panel-header-icon { + font-size: 14px; +} + +.stats-panel-body { + flex: 1; + overflow: auto; + padding: 10px 12px 14px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.stats-panel-body::-webkit-scrollbar { + width: 4px; +} + +.stats-panel-body::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 2px; +} + +.stats-panel-body::-webkit-scrollbar-track { + background: transparent; +} + +.stats-panel-section-title { + font-size: 10px; + font-weight: 600; + color: var(--muted); + margin-bottom: 6px; + padding-left: 2px; +} + +.stats-panel-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; +} + +.stats-panel-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 6px; + border-radius: 6px; + background: var(--btn-bg); + border: 1px solid var(--border); + transition: border-color 0.15s; +} + +.stats-panel-item:hover { + border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); +} + +.stats-panel-value { + font-size: 15px; + font-weight: 700; + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.stats-panel-label { + font-size: 10px; + color: var(--muted); + margin-top: 2px; +} + +.global-drop-overlay { + position: fixed; + inset: 0; + z-index: 9999; + background: color-mix(in srgb, var(--accent) 12%, rgba(0, 0, 0, 0.4)); + backdrop-filter: blur(3px); + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + animation: drop-overlay-in 0.18s ease; +} + +@keyframes drop-overlay-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.global-drop-overlay-box { + padding: 36px 64px; + border: 3px dashed var(--accent); + border-radius: 20px; + background: color-mix(in srgb, var(--accent) 15%, var(--btn-bg)); + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.3); +} + +.global-drop-overlay-icon { + font-size: 52px; +} + +.global-drop-overlay-text { + font-size: 18px; + font-weight: 600; + color: var(--text); } .editor-host { flex: 1; min-height: 0; + border-radius: 0 0 8px 8px; + overflow: hidden; } .editor-host .cm-editor { height: 100%; + outline: none; +} + +.editor-host .cm-editor .cm-scroller { + font-family: var(--font-editor, 'Consolas', 'Courier New', monospace); + line-height: 1.6; +} + +.editor-host .cm-editor .cm-gutters { + border-right: 1px solid var(--border); + background: var(--panel-bg); + color: var(--muted); +} + +.editor-host .cm-editor .cm-activeLineGutter { + background: color-mix(in srgb, var(--accent) 6%, var(--panel-bg)); +} + +.editor-host .cm-editor .cm-activeLine { + background: color-mix(in srgb, var(--accent) 4%, transparent); +} + +.editor-host .cm-editor .cm-selectionBackground, +.editor-host .cm-editor.cm-focused .cm-selectionBackground { + background: color-mix(in srgb, var(--accent) 25%, transparent) !important; +} + +.editor-host .cm-editor .cm-cursor { + border-left-color: var(--accent); } .preview-outer { @@ -908,7 +1608,7 @@ html[data-bg='22'] .app-root { display: flex; justify-content: center; align-items: flex-start; - padding: 12px 16px 16px; + padding: 20px 24px 32px; } .preview-shell { @@ -930,15 +1630,16 @@ html[data-bg='22'] .app-root { height: 100%; cursor: ew-resize; z-index: 5; - border-radius: 4px; + border-radius: 6px; border: 2px solid var(--accent); background: var(--btn-bg); - opacity: 0.95; + opacity: 0.9; + transition: opacity 0.15s, filter 0.15s; } .preview-resizer:hover { opacity: 1; - filter: brightness(1.05); + filter: brightness(1.08); } .preview-resizer::after { @@ -956,30 +1657,30 @@ html[data-bg='22'] .app-root { .preview { flex: 0 0 auto; overflow: visible; - padding: 24px 32px 48px; + padding: 28px 36px 52px; width: 100%; font-size: 16px; - line-height: var(--preview-line-height, 1.65); + line-height: var(--preview-line-height, 1.7); background: var(--preview-bg); color: var(--preview-text); - border-radius: 8px; + border-radius: 10px; box-shadow: var(--preview-shadow); font-family: var(--font-preview, var(--font-ui, Georgia, serif)); } .preview code { font-family: 'Cascadia Code', 'Consolas', 'Monaco', 'Courier New', monospace; - font-size: 0.9em; + font-size: 0.88em; background: color-mix(in srgb, var(--code-bg) 85%, var(--border)); - padding: 0.12em 0.35em; + padding: 0.15em 0.4em; border-radius: 4px; } .preview .md-code-wrap { position: relative; - margin: 1.1em 0; + margin: 1.2em 0; border: 1px solid color-mix(in srgb, var(--border) 90%, #000); - border-radius: 6px; + border-radius: 8px; background: var(--code-bg); overflow: hidden; } @@ -988,7 +1689,7 @@ html[data-bg='22'] .app-root { position: absolute; top: 0; right: 0; - padding: 3px 10px; + padding: 4px 12px; font-size: 11px; color: var(--muted); text-transform: lowercase; @@ -999,7 +1700,7 @@ html[data-bg='22'] .app-root { .preview .md-code-wrap pre.hljs { margin: 0; - padding: 16px 18px; + padding: 18px 20px; background: transparent; border-radius: 0; overflow-x: auto; @@ -1015,10 +1716,10 @@ html[data-bg='22'] .app-root { .preview .md-diagram-wrap { margin: 1.5em 0; - padding: 20px 16px 24px; + padding: 24px 20px 28px; background: var(--preview-bg); border: 1px solid color-mix(in srgb, var(--border) 85%, #000); - border-radius: 6px; + border-radius: 8px; overflow-x: auto; overflow-y: hidden; text-align: center; @@ -1075,8 +1776,8 @@ html[data-bg='22'] .app-root { max-width: 100%; height: auto; display: block; - margin: 1em auto; - border-radius: 6px; + margin: 1.2em auto; + border-radius: 8px; box-shadow: 0 2px 12px rgba(15, 23, 42, 0.1); } @@ -1088,36 +1789,36 @@ html[data-bg='22'] .app-root { .preview h6 { font-family: inherit; color: inherit; - margin-top: 1.25em; - margin-bottom: 0.55em; - line-height: 1.25; + margin-top: 1.3em; + margin-bottom: 0.6em; + line-height: 1.3; } .preview h1 { font-size: 2rem; font-weight: 700; - margin-top: 0.35em; - padding-bottom: 0.2em; + margin-top: 0.4em; + padding-bottom: 0.25em; border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent); } .preview h2 { - font-size: 1.55rem; + font-size: 1.6rem; font-weight: 650; } .preview h3 { - font-size: 1.28rem; + font-size: 1.3rem; font-weight: 600; } .preview h4 { - font-size: 1.12rem; + font-size: 1.15rem; font-weight: 600; } .preview h5 { - font-size: 1.02rem; + font-size: 1.05rem; font-weight: 600; } @@ -1129,8 +1830,8 @@ html[data-bg='22'] .app-root { .preview pre { overflow: auto; - padding: 12px; - border-radius: 6px; + padding: 14px; + border-radius: 8px; background: var(--code-bg); } @@ -1140,18 +1841,20 @@ html[data-bg='22'] .app-root { } .preview a { - color: #4183c4; + color: var(--accent); text-decoration: none; cursor: pointer; + transition: color 0.12s; } .preview a:hover { text-decoration: underline; + color: color-mix(in srgb, var(--accent) 80%, #fff); } .preview a.md-internal-link { - color: #4183c4; - border-bottom: 1px dashed color-mix(in srgb, #4183c4 55%, transparent); + color: var(--accent); + border-bottom: 1px dashed color-mix(in srgb, var(--accent) 55%, transparent); } .preview a.md-internal-link:hover { @@ -1161,7 +1864,7 @@ html[data-bg='22'] .app-root { .preview hr { border: none; border-top: 1px solid var(--border); - margin: 1.25em 0; + margin: 1.5em 0; height: 0; background: none; } @@ -1180,17 +1883,19 @@ html[data-bg='22'] .app-root { display: flex; flex-direction: column; border-right: 1px solid var(--border); + background: var(--panel-bg); } .hybrid-sash { flex: 0 0 6px; cursor: col-resize; background: transparent; + transition: background 0.15s; } .hybrid-sash:hover { background: var(--accent); - opacity: 0.25; + opacity: 0.3; } .hybrid-preview { @@ -1200,10 +1905,13 @@ html[data-bg='22'] .app-root { overflow: auto; display: flex; justify-content: center; + background: var(--app-bg); } .hybrid-preview-shell { position: relative; + width: 100%; + max-width: 860px; } .hybrid-line-pulse { @@ -1245,60 +1953,91 @@ html[data-bg='22'] .app-root { .modal-overlay { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.35); + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(2px); display: flex; align-items: center; justify-content: center; z-index: 1000; + animation: modal-overlay-in 0.18s ease; +} + +@keyframes modal-overlay-in { + from { opacity: 0; } + to { opacity: 1; } } .modal { - width: min(420px, calc(100% - 32px)); + width: min(440px, calc(100% - 32px)); background: var(--panel-bg); color: var(--text); border: 1px solid var(--border); - border-radius: 10px; - padding: 16px 18px; - box-shadow: var(--preview-shadow); + border-radius: 14px; + padding: 24px 26px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + animation: modal-in 0.22s ease; +} + +@keyframes modal-in { + from { opacity: 0; transform: scale(0.94) translateY(-10px); } + to { opacity: 1; transform: scale(1) translateY(0); } } .modal-title { - margin: 0 0 8px; - font-size: 16px; + margin: 0 0 12px; + font-size: 18px; + font-weight: 700; } .modal-body { - margin: 0 0 16px; + margin: 0 0 18px; font-size: 14px; color: var(--muted); + line-height: 1.55; } .modal-text-input { width: 100%; box-sizing: border-box; - margin: 0 0 16px; - padding: 8px 10px; - border-radius: 6px; + margin: 0 0 18px; + padding: 10px 14px; + border-radius: 8px; border: 1px solid var(--border); background: var(--btn-bg); color: var(--text); font: inherit; + font-size: 14px; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.modal-text-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); } .modal-actions { display: flex; flex-wrap: wrap; - gap: 8px; + gap: 10px; justify-content: flex-end; } .modal-actions button { - padding: 6px 12px; - border-radius: 6px; + padding: 8px 18px; + border-radius: 8px; border: 1px solid var(--border); background: var(--btn-bg); color: var(--text); cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; +} + +.modal-actions button:hover { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 6%, var(--btn-bg)); } .modal-actions button.primary { @@ -1307,20 +2046,25 @@ html[data-bg='22'] .app-root { color: #fff; } +.modal-actions button.primary:hover { + background: color-mix(in srgb, var(--accent) 85%, #000); + border-color: color-mix(in srgb, var(--accent) 85%, #000); +} + .modal--wide { - width: min(480px, calc(100% - 32px)); + width: min(520px, calc(100% - 32px)); } .modal-hint { - margin: 0 0 12px; + margin: 0 0 14px; font-size: 13px; color: var(--muted); - line-height: 1.45; + line-height: 1.5; } .modal-field { display: block; - margin-bottom: 12px; + margin-bottom: 14px; } .modal-field .modal-text-input { @@ -1331,21 +2075,24 @@ html[data-bg='22'] .app-root { display: block; font-size: 12px; color: var(--muted); - margin-bottom: 4px; + margin-bottom: 5px; + font-weight: 500; } .modal-row-btns { - margin: -4px 0 12px; + margin: -4px 0 14px; } .modal-row-btns button { - padding: 6px 12px; - border-radius: 6px; + padding: 7px 14px; + border-radius: 8px; border: 1px solid var(--border); background: var(--btn-bg); color: var(--text); cursor: pointer; font: inherit; + font-size: 13px; + transition: border-color 0.15s, background 0.15s; } .modal-row-btns button:hover { @@ -1403,12 +2150,12 @@ html[data-bg='22'] .app-root { .ctx-menu { position: fixed; - z-index: 900; + z-index: 9999; min-width: 120px; background: var(--panel-bg); border: 1px solid var(--border); border-radius: 6px; - box-shadow: var(--preview-shadow); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); padding: 4px; } @@ -1434,9 +2181,9 @@ html[data-bg='22'] .app-root { .preview .md-table-wrap { overflow-x: auto; - margin: 1.25em 0; + margin: 1.3em 0; border: 1px solid var(--border); - border-radius: 8px; + border-radius: 10px; box-shadow: 0 1px 4px rgba(15, 23, 42, 0.06); } @@ -1444,20 +2191,20 @@ html[data-bg='22'] .app-root { width: 100%; border-collapse: collapse; font-size: 0.94em; - line-height: 1.55; + line-height: 1.6; } .preview thead th { background: color-mix(in srgb, var(--accent) 10%, var(--preview-bg)); font-weight: 650; text-align: left; - padding: 10px 14px; + padding: 12px 16px; border-bottom: 2px solid var(--border); white-space: nowrap; } .preview tbody td { - padding: 9px 14px; + padding: 10px 16px; border-bottom: 1px solid color-mix(in srgb, var(--border) 80%, transparent); vertical-align: top; } @@ -1486,7 +2233,7 @@ html[data-bg='22'] .app-root { } .preview-anchor-flash { - animation: preview-anchor-flash 1.4s ease-out; + animation: preview-anchor-flash 1.6s ease-out; } @keyframes preview-anchor-flash { @@ -1498,7 +2245,7 @@ html[data-bg='22'] .app-root { 15%, 40% { background-color: rgba(250, 204, 21, 0.35); - box-shadow: 0 0 0 2px rgba(234, 179, 8, 0.25); + box-shadow: 0 0 0 3px rgba(234, 179, 8, 0.25); border-radius: 4px; } 55% { @@ -1507,7 +2254,7 @@ html[data-bg='22'] .app-root { } 70% { background-color: rgba(250, 204, 21, 0.28); - box-shadow: 0 0 0 2px rgba(234, 179, 8, 0.2); + box-shadow: 0 0 0 3px rgba(234, 179, 8, 0.2); border-radius: 4px; } } diff --git a/src/renderer/src/markdown.ts b/src/renderer/src/markdown.ts index 4e99f99..cc5f02a 100644 --- a/src/renderer/src/markdown.ts +++ b/src/renderer/src/markdown.ts @@ -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, '"') +} + 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({ diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts new file mode 100644 index 0000000..6408863 --- /dev/null +++ b/src/renderer/src/types.ts @@ -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 } \ No newline at end of file diff --git a/src/renderer/src/utils.ts b/src/renderer/src/utils.ts new file mode 100644 index 0000000..fdcf426 --- /dev/null +++ b/src/renderer/src/utils.ts @@ -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))) +} \ No newline at end of file