代码初始化

This commit is contained in:
2026-05-15 16:35:31 +08:00
commit b56c63e166
31 changed files with 12969 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
out
release
dist
.DS_Store
*.log
+8
View File
@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="openjdk-24" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/MyReader.iml" filepath="$PROJECT_DIR$/.idea/MyReader.iml" />
</modules>
</component>
</project>
+57
View File
@@ -0,0 +1,57 @@
# Huangzhijun's Reader
本地 Markdown 阅读与编辑(Electron + Vite + React + CodeMirror 6)。侧重「长文阅读体验」与「轻量编辑」:多标签、会话恢复、侧栏文件树与大纲、阅读/编辑/分栏三模式、外观与行距可调。
## 设计思路
1. **进程与数据**
主进程负责文件系统对话框、读写目录、路径解析与窗口生命周期;预加载脚本通过 `contextBridge` 暴露受限 API,渲染进程不开启 `nodeIntegration`,在隔离前提下使用本地文件能力。
2. **渲染与 Markdown**
使用 `markdown-it` + 插件(表格、Mermaid 围栏等)生成 HTML,再经 DOMPurify 白名单消毒后注入预览。相对路径图片在渲染阶段结合当前文件路径解析为 `file://` URL;开发环境下页面为 `http(s)` 源时,通过主窗口 **`webSecurity: false`** 允许从页面加载本地 `file://` 图片(典型 Electron 本地阅读器权衡)。
3. **阅读体验**
背景与字体通过 `html` 上的 `data-bg` / `data-font` 切换 CSS 变量,预览区单独使用 `--preview-bg``--font-preview` 等,与侧栏/工具栏解耦。阅读行距使用 `--preview-line-height`,与字体选择独立。Mermaid 图在源码中写入 `data-mermaid-src`,便于分栏宽度变化后恢复文本并重绘。
4. **分栏同步**
编辑器与预览共享文档字符串;预览 DOM 为块级元素打上源行号,用于大纲跳转与分栏滚动对齐。
5. **持久化**
标签内容与未保存状态、当前背景/字体等写入 `localStorage`(会话键 `myreader-session-v1`);打开过的 Markdown 路径记入「历史」列表,与真实文件删除无关。
## 使用步骤(简述)
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/`
## 脚本
| 命令 | 说明 |
|------|------|
| `npm run dev` | 开发调试 |
| `npm run build` | 构建到 `out/` |
| `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`:在 **编辑 / 阅读 / 分栏** 三种视图间循环切换
侧栏可隐藏、可拖动右边缘调整宽度;阅读模式下可拖动预览区右缘调整正文最大宽度。编辑、分栏模式下顶部有 **Markdown 格式工具栏**。侧栏文件列表 **右键**:重命名(文件/文件夹)、删除(仅文件)。
## 技术栈摘要
Electron 35、electron-vite、React 19、CodeMirror 6、markdown-it、highlight.js、DOMPurify、mermaid。
---
仓库 npm 包名仍为 `myreader`;安装后显示名与窗口标题为 **Huangzhijun's Reader**
+29
View File
@@ -0,0 +1,29 @@
import { resolve } from 'node:path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'electron/main.ts')
}
}
}
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'electron/preload.ts')
}
}
}
},
renderer: {
plugins: [react()]
}
})
+33
View File
@@ -0,0 +1,33 @@
export type DirEntry = {
name: string
path: string
kind: 'file' | 'dir'
}
export type ListDirResult = {
entries: DirEntry[]
error?: string
}
export type OpenFileResult = {
canceled: boolean
path?: string
content?: string
}
export type SaveDialogResult = {
canceled: boolean
path?: string
}
export type OpenFolderResult = {
canceled: boolean
path?: string
}
/** `url` 为可写入 Markdown 的 `file://` 绝对地址(已编码)。 */
export type OpenImageResult = {
canceled: boolean
url?: string
path?: string
}
+368
View File
@@ -0,0 +1,368 @@
import { existsSync } from 'node:fs'
import { readdir, rename, readFile, writeFile, unlink } from 'node:fs/promises'
import { dirname, join, resolve, normalize, isAbsolute } from 'node:path'
import { pathToFileURL, fileURLToPath } from 'node:url'
import {
app,
BrowserWindow,
dialog,
ipcMain,
Menu,
nativeImage,
shell,
type MenuItemConstructorOptions,
type NativeImage
} from 'electron'
import type {
DirEntry,
ListDirResult,
OpenFileResult,
OpenFolderResult,
OpenImageResult,
SaveDialogResult
} from './ipc-types.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const MD_EXT = new Set(['.md', '.markdown', '.mdown', '.mkd'])
function getPreloadPath(): string {
const fromMainDir = join(__dirname, '..', 'preload')
const fromAppRoot = join(app.getAppPath(), 'out', 'preload')
const candidates = ['index.mjs', 'index.cjs', 'index.js']
for (const base of [fromMainDir, fromAppRoot]) {
for (const name of candidates) {
const p = resolve(base, name)
if (existsSync(p)) return p
}
}
return resolve(fromAppRoot, 'index.mjs')
}
function getRendererIndexHtml(): string {
const fromMain = join(__dirname, '..', 'renderer', 'index.html')
if (existsSync(fromMain)) return fromMain
return join(app.getAppPath(), 'out', 'renderer', 'index.html')
}
function resolveAppIconPath(): string | undefined {
const candidates = [
join(__dirname, '../../resources/app-icon.png'),
join(app.getAppPath(), 'resources', 'app-icon.png'),
join(process.cwd(), 'resources', 'app-icon.png')
]
for (const p of candidates) {
if (existsSync(p)) return p
}
return undefined
}
function isMarkdownFile(name: string): boolean {
const lower = name.toLowerCase()
const dot = lower.lastIndexOf('.')
if (dot < 0) return false
return MD_EXT.has(lower.slice(dot))
}
/** 将 Markdown 内链(相对/绝对/file://)解析为本地绝对路径;外链原样返回。 */
function resolveMarkdownLinkHref(baseFilePath: string, href: string): string {
let raw = href.trim()
const hashIdx = raw.indexOf('#')
if (hashIdx >= 0) raw = raw.slice(0, hashIdx)
const queryIdx = raw.indexOf('?')
if (queryIdx >= 0) raw = raw.slice(0, queryIdx)
raw = raw.trim()
if (!raw) return ''
if (/^https?:\/\//i.test(raw) || /^mailto:/i.test(raw) || /^tel:/i.test(raw)) return raw
if (raw.startsWith('file://')) {
try {
return normalize(fileURLToPath(raw))
} catch {
return ''
}
}
let decoded = raw
try {
decoded = decodeURIComponent(raw)
} catch {
decoded = raw
}
if (isAbsolute(decoded)) return normalize(decoded)
return normalize(resolve(dirname(baseFilePath), decoded))
}
ipcMain.handle(
'myreader:resolve-relative-path',
(_evt, baseFilePath: string, href: string): string => {
if (typeof baseFilePath !== 'string' || typeof href !== 'string') return ''
if (!baseFilePath.trim()) return ''
return resolveMarkdownLinkHref(baseFilePath, href)
}
)
ipcMain.handle('myreader:open-external', async (_evt, url: string): Promise<void> => {
if (typeof url !== 'string') return
const u = url.trim()
if (/^https?:\/\//i.test(u) || /^mailto:/i.test(u) || /^tel:/i.test(u)) {
await shell.openExternal(u)
}
})
async function listDirectoryImpl(dirPath: string): Promise<ListDirResult> {
const normalized = normalize(dirPath)
try {
const dirents = await readdir(normalized, { withFileTypes: true })
const entries: DirEntry[] = []
for (const d of dirents) {
if (d.name.startsWith('.')) continue
const full = join(normalized, d.name)
if (d.isDirectory()) {
entries.push({ name: d.name, path: full, kind: 'dir' })
} else if (d.isFile() && isMarkdownFile(d.name)) {
entries.push({ name: d.name, path: full, kind: 'file' })
}
}
entries.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
})
return { entries }
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return { entries: [], error: msg }
}
}
function sendMenu(win: BrowserWindow | null, action: string): void {
const w = win ?? BrowserWindow.getFocusedWindow()
w?.webContents.send('myreader:menu', action)
}
function buildMenu(): Menu {
const template: MenuItemConstructorOptions[] = [
{
label: '文件',
submenu: [
{ label: '新建标签', accelerator: 'CmdOrCtrl+T', click: () => sendMenu(null, 'tab-new') },
{ label: '打开…', accelerator: 'CmdOrCtrl+O', click: () => sendMenu(null, 'file-open') },
{ label: '保存', accelerator: 'CmdOrCtrl+S', click: () => sendMenu(null, 'file-save') },
{ label: '另存为…', accelerator: 'CmdOrCtrl+Shift+S', click: () => sendMenu(null, 'file-save-as') },
{ type: 'separator' },
{ label: '关闭标签', accelerator: 'CmdOrCtrl+W', click: () => sendMenu(null, 'tab-close') },
{ type: 'separator' },
{ label: '退出', accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Alt+F4', role: 'quit' }
]
},
{
label: '编辑',
submenu: [
{ label: '撤销', accelerator: 'CmdOrCtrl+Z', role: 'undo' },
{ label: '重做', accelerator: process.platform === 'darwin' ? 'Cmd+Shift+Z' : 'Ctrl+Y', role: 'redo' },
{ type: 'separator' },
{ label: '剪切', accelerator: 'CmdOrCtrl+X', role: 'cut' },
{ label: '复制', accelerator: 'CmdOrCtrl+C', role: 'copy' },
{ label: '粘贴', accelerator: 'CmdOrCtrl+V', role: 'paste' },
{ label: '全选', accelerator: 'CmdOrCtrl+A', role: 'selectAll' }
]
},
{
label: '视图',
submenu: [
{ label: '切换视图模式', accelerator: 'CmdOrCtrl+E', click: () => sendMenu(null, 'toggle-read') },
{ label: '下一个标签', accelerator: 'Ctrl+Tab', click: () => sendMenu(null, 'tab-next') },
{ label: '上一个标签', accelerator: 'Ctrl+Shift+Tab', click: () => sendMenu(null, 'tab-prev') }
]
},
{
label: '帮助',
submenu: [{ label: "关于 Huangzhijun's Reader", click: () => sendMenu(null, 'help-about') }]
}
]
return Menu.buildFromTemplate(template)
}
function createWindow(): BrowserWindow {
const preloadPath = getPreloadPath()
const iconPath = resolveAppIconPath()
let icon: NativeImage | undefined
if (iconPath) {
try {
icon = nativeImage.createFromPath(iconPath)
if (icon.isEmpty()) icon = undefined
} catch {
icon = undefined
}
}
const win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 560,
title: "Huangzhijun's Reader",
...(icon ? { icon } : {}),
webPreferences: {
preload: preloadPath,
contextIsolation: true,
sandbox: false,
nodeIntegration: false,
webSecurity: false
}
})
win.on('close', (e) => {
const w = win as BrowserWindow & { __myreaderForceClose?: boolean }
if (w.__myreaderForceClose) return
e.preventDefault()
win.webContents.send('myreader:request-close')
})
if (process.env.ELECTRON_RENDERER_URL) {
void win.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
void win.loadFile(getRendererIndexHtml())
}
return win
}
ipcMain.handle('myreader:open-file-dialog', async (): Promise<OpenFileResult> => {
const win = BrowserWindow.getFocusedWindow()
const { canceled, filePaths } = win
? await dialog.showOpenDialog(win, {
properties: ['openFile'],
filters: [{ name: 'Markdown', extensions: ['md', 'markdown', 'mdown', 'mkd', 'txt'] }]
})
: await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Markdown', extensions: ['md', 'markdown', 'mdown', 'mkd', 'txt'] }]
})
if (canceled || !filePaths?.[0]) return { canceled: true }
const path = filePaths[0]
const content = await readFile(path, 'utf8')
return { canceled: false, path, content }
})
ipcMain.handle('myreader:open-folder-dialog', async (): Promise<OpenFolderResult> => {
const win = BrowserWindow.getFocusedWindow()
const opts = { properties: ['openDirectory' as const] }
const { canceled, filePaths } = win
? await dialog.showOpenDialog(win, opts)
: await dialog.showOpenDialog(opts)
if (canceled || !filePaths?.[0]) return { canceled: true }
return { canceled: false, path: filePaths[0] }
})
ipcMain.handle('myreader:open-image-dialog', async (event): Promise<OpenImageResult> => {
const win =
BrowserWindow.fromWebContents(event.sender) ?? BrowserWindow.getFocusedWindow()
const opts = {
properties: ['openFile' as const],
filters: [
{
name: '图片',
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif']
}
]
}
if (win) {
win.focus()
win.show()
}
const { canceled, filePaths } = win
? await dialog.showOpenDialog(win, opts)
: await dialog.showOpenDialog(opts)
if (canceled || !filePaths?.[0]) return { canceled: true }
const path = filePaths[0]
return { canceled: false, path, url: pathToFileURL(path).href }
})
ipcMain.handle(
'myreader:save-dialog',
async (_evt, defaultPath?: string): Promise<SaveDialogResult> => {
const win = BrowserWindow.getFocusedWindow()
const { canceled, filePath } = win
? await dialog.showSaveDialog(win, {
defaultPath,
filters: [{ name: 'Markdown', extensions: ['md'] }]
})
: await dialog.showSaveDialog({
defaultPath,
filters: [{ name: 'Markdown', extensions: ['md'] }]
})
if (canceled || !filePath) return { canceled: true }
return { canceled: false, path: filePath }
}
)
ipcMain.handle('myreader:read-file', async (_evt, filePath: string): Promise<string> => {
return readFile(filePath, 'utf8')
})
ipcMain.handle(
'myreader:write-file',
async (_evt, filePath: string, content: string): Promise<void> => {
await writeFile(filePath, content, 'utf8')
}
)
ipcMain.handle('myreader:list-directory', async (_evt, dirPath: string): Promise<ListDirResult> => {
return listDirectoryImpl(dirPath)
})
ipcMain.handle('myreader:parent-directory', (_evt, dirPath: string): string => {
return dirname(dirPath)
})
ipcMain.handle('myreader:default-directory', async (): Promise<string> => {
return app.getPath('documents')
})
ipcMain.handle('myreader:delete-file', async (_evt, filePath: string): Promise<void> => {
if (typeof filePath !== 'string' || filePath.length === 0) {
throw new Error('Invalid path')
}
await unlink(filePath)
})
ipcMain.handle(
'myreader:rename-path',
async (_evt, oldPath: string, newName: string): Promise<{ newPath: string }> => {
if (typeof oldPath !== 'string' || typeof newName !== 'string') {
throw new Error('Invalid arguments')
}
const clean = newName.replace(/[/\\]/g, '').trim()
if (!clean) {
throw new Error('Empty name')
}
const parent = dirname(oldPath)
const newPath = join(parent, clean)
if (normalize(newPath) === normalize(oldPath)) {
return { newPath: oldPath }
}
await rename(oldPath, newPath)
return { newPath }
}
)
ipcMain.on('myreader:allow-close', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
;(win as BrowserWindow & { __myreaderForceClose?: boolean }).__myreaderForceClose = true
win.close()
})
app.whenReady().then(() => {
Menu.setApplicationMenu(buildMenu())
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
+74
View File
@@ -0,0 +1,74 @@
import { contextBridge, ipcRenderer } from 'electron'
import type {
ListDirResult,
OpenFileResult,
OpenFolderResult,
OpenImageResult,
SaveDialogResult
} from './ipc-types.js'
export interface MyReaderApi {
openFileDialog: () => Promise<OpenFileResult>
openFolderDialog: () => Promise<OpenFolderResult>
openImageDialog: () => Promise<OpenImageResult>
saveFileDialog: (defaultPath?: string) => Promise<SaveDialogResult>
readFile: (filePath: string) => Promise<string>
writeFile: (filePath: string, content: string) => Promise<void>
listDirectory: (dirPath: string) => Promise<ListDirResult>
parentDirectory: (dirPath: string) => Promise<string>
/** 相对当前 Markdown 文件解析内链 href → 绝对路径(外链原样返回) */
resolveRelativePath: (baseFilePath: string, href: string) => Promise<string>
openExternal: (url: string) => Promise<void>
defaultDirectory: () => Promise<string>
deleteFile: (filePath: string) => Promise<void>
renamePath: (oldPath: string, newName: string) => Promise<{ newPath: string }>
allowClose: () => void
onRequestClose: (handler: () => void) => () => void
onMenuAction: (handler: (action: string) => void) => () => void
}
const api: MyReaderApi = {
openFileDialog: () => ipcRenderer.invoke('myreader:open-file-dialog'),
openFolderDialog: () => ipcRenderer.invoke('myreader:open-folder-dialog'),
openImageDialog: () => ipcRenderer.invoke('myreader:open-image-dialog'),
saveFileDialog: (defaultPath?: string) => ipcRenderer.invoke('myreader:save-dialog', defaultPath),
readFile: (filePath: string) => ipcRenderer.invoke('myreader:read-file', filePath),
writeFile: (filePath: string, content: string) =>
ipcRenderer.invoke('myreader:write-file', filePath, content),
listDirectory: (dirPath: string) => ipcRenderer.invoke('myreader:list-directory', dirPath),
parentDirectory: (dirPath: string) => ipcRenderer.invoke('myreader:parent-directory', dirPath),
resolveRelativePath: (baseFilePath: string, href: string) =>
ipcRenderer.invoke('myreader:resolve-relative-path', baseFilePath, href),
openExternal: (url: string) => ipcRenderer.invoke('myreader:open-external', url),
defaultDirectory: () => ipcRenderer.invoke('myreader:default-directory'),
deleteFile: (filePath: string) => ipcRenderer.invoke('myreader:delete-file', filePath),
renamePath: (oldPath: string, newName: string) =>
ipcRenderer.invoke('myreader:rename-path', oldPath, newName),
allowClose: () => {
ipcRenderer.send('myreader:allow-close')
},
onRequestClose: (handler: () => void) => {
const listener = (): void => {
handler()
}
ipcRenderer.on('myreader:request-close', listener)
return () => {
ipcRenderer.removeListener('myreader:request-close', listener)
}
},
onMenuAction: (handler: (action: string) => void) => {
const listener = (_e: unknown, action: string): void => {
handler(action)
}
ipcRenderer.on('myreader:menu', listener)
return () => {
ipcRenderer.removeListener('myreader:menu', listener)
}
}
}
try {
contextBridge.exposeInMainWorld('myreader', api)
} catch (e) {
console.error('[MyReader] preload contextBridge failed', e)
}
+7231
View File
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
{
"name": "myreader",
"productName": "Huangzhijun's Reader",
"version": "0.1.0",
"description": "Huangzhijun's Reader — local Markdown reader and editor (Electron + React)",
"main": "./out/main/index.js",
"type": "module",
"config": {
"electron_mirror": "https://npmmirror.com/mirrors/electron/"
},
"engines": {
"node": ">=20.10.0"
},
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit",
"dist": "npm run build && electron-builder --win"
},
"dependencies": {
"@codemirror/commands": "^6.8.0",
"@codemirror/lang-markdown": "^6.3.2",
"@codemirror/language": "^6.11.0",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.4",
"dompurify": "^3.2.5",
"highlight.js": "^11.11.1",
"markdown-it": "^14.1.0",
"markdown-it-multimd-table": "^4.2.3",
"mermaid": "^11.15.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@types/node": "^22.15.3",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"electron": "^35.1.5",
"electron-builder": "^26.0.12",
"electron-vite": "^3.1.0",
"typescript": "^5.8.3",
"vite": "^6.3.4"
},
"build": {
"appId": "com.myreader.app",
"productName": "Huangzhijun's Reader",
"asar": true,
"directories": {
"output": "release"
},
"files": [
"out/**/*",
"package.json",
"resources/**/*"
],
"electronDownload": {
"mirror": "https://npmmirror.com/mirrors/electron/"
},
"win": {
"icon": "resources/app-icon.png",
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
],
"artifactName": "${productName}-${version}-${arch}.${ext}"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"shortcutName": "Huangzhijun's Reader",
"perMachine": false
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Huangzhijun's Reader</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+370
View File
@@ -0,0 +1,370 @@
import * as React from 'react'
import { EditorSelection } from '@codemirror/state'
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown'
import { EditorState } from '@codemirror/state'
import {
EditorView,
highlightActiveLine,
keymap,
lineNumbers,
placeholder
} from '@codemirror/view'
export type MarkdownEditorHandle = {
applyAction: (id: string) => void | Promise<void>
}
type Props = {
mountKey: string
value: string
onChange: (next: string) => void
gotoLine?: { line: number; seq: number } | null
showLineNumbers?: boolean
/** In Electron, `window.prompt` is unreliable; use an in-app prompt from the host. */
requestText?: (title: string, defaultValue: string) => Promise<string | null>
onViewReady?: (view: EditorView | null) => void
/** 分栏:根据光标所在行在编辑器可滚动内容中的垂直比例,对齐阅读区提示条。 */
onHybridCursorLine?: (line: number) => void
requestImageDetails?: () => Promise<{ url: string; alt: string } | null>
requestColor?: () => Promise<string | null>
}
function getMainSelection(view: EditorView): { from: number; to: number } {
const r = view.state.selection.main
return { from: r.from, to: r.to }
}
function wrap(view: EditorView, left: string, right: string): void {
const { from, to } = getMainSelection(view)
const sel = view.state.sliceDoc(from, to)
view.dispatch({
changes: { from, to, insert: left + sel + right },
selection: EditorSelection.range(from + left.length, from + left.length + sel.length)
})
view.focus()
}
function insertAtCursor(view: EditorView, text: string): void {
const { from, to } = getMainSelection(view)
view.dispatch({
changes: { from, to, insert: text },
selection: EditorSelection.cursor(from + text.length)
})
view.focus()
}
function insertLines(view: EditorView, text: string): void {
insertAtCursor(view, text)
}
function linePrefixOnMainLine(view: EditorView, prefix: string): void {
const pos = view.state.selection.main.head
const line = view.state.doc.lineAt(pos)
const lineText = line.text
const hashes = /^#+\s/.exec(lineText)?.[0] ?? ''
let rest = lineText
if (hashes) rest = lineText.slice(hashes.length)
const newLine = prefix + rest
view.dispatch({
changes: { from: line.from, to: line.to, insert: newLine },
selection: EditorSelection.cursor(line.from + newLine.length)
})
view.focus()
}
function escapeMarkdownLinkFragment(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/\[/g, '\\[').replace(/\]/g, '\\]')
}
function sanitizeInlineColor(c: string): string {
const t = c.trim()
if (/^#[0-9a-fA-F]{3}$/.test(t)) {
const r = t[1]!
const g = t[2]!
const b = t[3]!
return `#${r}${r}${g}${g}${b}${b}`
}
if (/^#[0-9a-fA-F]{6}$/.test(t) || /^#[0-9a-fA-F]{8}$/.test(t)) return t.toLowerCase()
if (/^rgba?\(\s*[\d.\s%,]+\s*\)$/.test(t)) return t
if (/^hsla?\(\s*[\d.\s%,deg]+\s*\)$/.test(t)) return t
if (/^[\w-]{1,40}$/.test(t)) return t
return '#333333'
}
function sanitizeLinkUrl(url: string): string {
const t = url.trim() || 'https://'
if (/^file:/i.test(t)) return `<${t.replace(/[<>]/g, '')}>`
return t.replace(/\)/g, '%29')
}
type ActionDeps = {
requestText: (title: string, def: string) => Promise<string | null>
requestImageDetails?: () => Promise<{ url: string; alt: string } | null>
requestColor?: () => Promise<string | null>
}
async function applyMarkdownAction(view: EditorView, id: string, deps: ActionDeps): Promise<void> {
const { requestText, requestImageDetails, requestColor } = deps
try {
switch (id) {
case 'bold':
wrap(view, '**', '**')
break
case 'italic':
wrap(view, '*', '*')
break
case 'strike':
wrap(view, '~~', '~~')
break
case 'code':
wrap(view, '`', '`')
break
case 'codeBlock':
insertLines(view, '\n```\n\n```\n')
break
case 'link': {
const url = await requestText('链接地址', 'https://')
if (url === null) return
const { from, to } = getMainSelection(view)
const rawLabel = view.state.sliceDoc(from, to) || '链接文字'
const label = escapeMarkdownLinkFragment(rawLabel)
const insert = `[${label}](${sanitizeLinkUrl(url)})`
view.dispatch({
changes: { from, to, insert },
selection: EditorSelection.cursor(from + insert.length)
})
view.focus()
break
}
case 'image': {
if (requestImageDetails) {
const det = await requestImageDetails()
if (!det?.url?.trim()) return
const alt = (det.alt ?? '图片').trim() || '图片'
insertAtCursor(
view,
`![${escapeMarkdownLinkFragment(alt)}](${sanitizeLinkUrl(det.url.trim())})`
)
break
}
const url = await requestText('图片地址', 'https://')
if (url === null) return
const rawAlt = await requestText('替代文字', '图片')
if (rawAlt === null) return
const alt = escapeMarkdownLinkFragment(rawAlt || '图片')
insertAtCursor(view, `![${alt}](${sanitizeLinkUrl(url)})`)
break
}
case 'h1':
linePrefixOnMainLine(view, '# ')
break
case 'h2':
linePrefixOnMainLine(view, '## ')
break
case 'h3':
linePrefixOnMainLine(view, '### ')
break
case 'h4':
linePrefixOnMainLine(view, '#### ')
break
case 'h5':
linePrefixOnMainLine(view, '##### ')
break
case 'h6':
linePrefixOnMainLine(view, '###### ')
break
case 'ul':
linePrefixOnMainLine(view, '- ')
break
case 'ol':
linePrefixOnMainLine(view, '1. ')
break
case 'task':
linePrefixOnMainLine(view, '- [ ] ')
break
case 'quote':
linePrefixOnMainLine(view, '> ')
break
case 'hr':
insertLines(view, '\n---\n')
break
case 'table':
insertLines(
view,
'\n| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| | | |\n'
)
break
case 'mark':
wrap(view, '<mark>', '</mark>')
break
case 'color': {
if (requestColor) {
const c = await requestColor()
if (c === null || !c.trim()) return
const color = sanitizeInlineColor(c)
wrap(view, `<span style="color:${color}">`, '</span>')
break
}
const c = await requestText('颜色(如 #e11 或 red', '#dc2626')
if (c === null || !c.trim()) return
const color = sanitizeInlineColor(c)
wrap(view, `<span style="color:${color}">`, '</span>')
break
}
case 'sup':
wrap(view, '<sup>', '</sup>')
break
case 'sub':
wrap(view, '<sub>', '</sub>')
break
case 'kbd':
wrap(view, '<kbd>', '</kbd>')
break
case 'details':
insertLines(view, '\n<details>\n<summary>标题</summary>\n内容\n</details>\n')
break
case 'footnote':
insertLines(view, '[^1]\n\n[^1]: 脚注内容\n')
break
default:
break
}
} catch (e) {
console.error('[MyReader] toolbar action failed', id, e)
}
}
export const EditorPane = React.forwardRef<MarkdownEditorHandle, Props>(
function EditorPane(
{
mountKey,
value,
onChange,
gotoLine,
showLineNumbers = true,
requestText,
onViewReady,
onHybridCursorLine,
requestImageDetails,
requestColor
}: Props,
ref
): React.ReactElement {
const hostRef = React.useRef<HTMLDivElement | null>(null)
const viewRef = React.useRef<EditorView | null>(null)
const onChangeRef = React.useRef(onChange)
onChangeRef.current = onChange
const requestTextRef = React.useRef(requestText)
requestTextRef.current = requestText
const onViewReadyRef = React.useRef(onViewReady)
onViewReadyRef.current = onViewReady
const onHybridCursorLineRef = React.useRef(onHybridCursorLine)
onHybridCursorLineRef.current = onHybridCursorLine
const requestImageDetailsRef = React.useRef(requestImageDetails)
requestImageDetailsRef.current = requestImageDetails
const requestColorRef = React.useRef(requestColor)
requestColorRef.current = requestColor
React.useImperativeHandle(
ref,
() => ({
applyAction: async (id: string) => {
const view = viewRef.current
if (!view) return
const rt =
requestTextRef.current ??
((title: string, def: string) =>
Promise.resolve<string | null>(window.prompt(title, def)))
await applyMarkdownAction(view, id, {
requestText: rt,
requestImageDetails: requestImageDetailsRef.current,
requestColor: requestColorRef.current
})
}
}),
[]
)
React.useEffect(() => {
const host = hostRef.current
if (!host) return
host.replaceChildren()
const view = new EditorView({
parent: host,
state: EditorState.create({
doc: value,
extensions: [
history(),
markdown(),
...(showLineNumbers ? [lineNumbers()] : []),
highlightActiveLine(),
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
placeholder('在此编辑 Markdown…'),
EditorView.lineWrapping,
EditorView.updateListener.of((u) => {
if (u.docChanged) {
onChangeRef.current(u.state.doc.toString())
}
}),
EditorView.updateListener.of((u) => {
if (!onHybridCursorLineRef.current) return
if (!u.selectionSet && !u.focusChanged && !u.docChanged) return
const pos = u.state.selection.main.head
const line = u.state.doc.lineAt(pos)
onHybridCursorLineRef.current(line.number - 1)
}),
EditorView.theme({
'&': {
height: '100%',
fontSize: '14px',
fontFamily: 'var(--font-ui, system-ui, sans-serif)'
},
'.cm-scroller': { fontFamily: 'inherit' },
'.cm-content': { paddingBlock: '12px' }
})
]
})
})
viewRef.current = view
onViewReadyRef.current?.(view)
return () => {
onViewReadyRef.current?.(null)
view.destroy()
viewRef.current = null
host.replaceChildren()
}
}, [mountKey, showLineNumbers])
React.useEffect(() => {
const view = viewRef.current
if (!view) return
const cur = view.state.doc.toString()
if (cur !== value) {
view.dispatch({
changes: { from: 0, to: cur.length, insert: value },
selection: { anchor: 0 },
scrollIntoView: true
})
}
}, [value, mountKey])
React.useEffect(() => {
if (!gotoLine) return
const view = viewRef.current
if (!view) return
const lineNo = gotoLine.line + 1
if (lineNo < 1 || lineNo > view.state.doc.lines) return
const line = view.state.doc.line(lineNo)
view.dispatch({ selection: { anchor: line.from }, scrollIntoView: true })
}, [gotoLine])
return <div className="editor-host" ref={hostRef} />
}
)
@@ -0,0 +1,54 @@
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: '脚注' }
]
type Props = {
editorRef: React.RefObject<MarkdownEditorHandle | null>
}
export function MarkdownFormatToolbar({ editorRef }: Props): React.ReactElement {
return (
<div className="md-format-wrap">
<div className="md-format-bar">
{BUTTONS.map((b) => (
<button
key={b.id}
type="button"
title={b.label}
onClick={() => void editorRef.current?.applyAction(b.id)}
>
{b.label}
</button>
))}
</div>
</div>
)
}
+110
View File
@@ -0,0 +1,110 @@
import * as React from 'react'
import type { OutlineItem } from './markdown'
import { buildOutlineTree, collectBranchIds, type OutlineTreeNode } from './outlineTree'
type Props = {
items: OutlineItem[]
onSelect: (item: OutlineItem) => void
}
export function OutlinePanel({ items, onSelect }: Props): React.ReactElement {
const tree = React.useMemo(() => buildOutlineTree(items), [items])
const branchIds = React.useMemo(() => collectBranchIds(tree), [tree])
const [collapsed, setCollapsed] = React.useState<Set<string>>(() => new Set())
React.useEffect(() => {
setCollapsed(new Set())
}, [items])
const toggleBranch = React.useCallback((id: string): void => {
setCollapsed((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const expandAll = React.useCallback((): void => {
setCollapsed(new Set())
}, [])
const collapseAll = React.useCallback((): void => {
setCollapsed(new Set(branchIds))
}, [branchIds])
const renderNode = (node: OutlineTreeNode): React.ReactElement => {
const hasChildren = node.children.length > 0
const isCollapsed = collapsed.has(node.id)
return (
<li key={node.id} className="outline-tree-node">
<div className="outline-tree-row">
{hasChildren ? (
<button
type="button"
className={`outline-fold-btn${isCollapsed ? ' is-collapsed' : ''}`}
aria-expanded={!isCollapsed}
aria-label={isCollapsed ? '展开子标题' : '收起子标题'}
title={isCollapsed ? '展开' : '收起'}
onClick={(e) => {
e.stopPropagation()
toggleBranch(node.id)
}}
>
{isCollapsed ? '▸' : '▾'}
</button>
) : (
<span className="outline-fold-spacer" aria-hidden />
)}
<button
type="button"
className={`outline-tree-title outline-tree-title--h${node.level}`}
title={node.text || node.displayText}
onClick={() => onSelect(node)}
>
{node.displayText}
</button>
</div>
{hasChildren && !isCollapsed ? (
<ul className="outline-tree">{node.children.map(renderNode)}</ul>
) : null}
</li>
)
}
if (items.length === 0) {
return <p className="outline-empty"></p>
}
const collapsedCount = collapsed.size
const branchCount = branchIds.length
return (
<div className="outline-panel-wrap">
<div className="outline-toolbar">
<span className="outline-toolbar-title">
{branchCount > 0 ? (
<span className="outline-toolbar-meta">
{' '}
· {branchCount - collapsedCount}/{branchCount}
</span>
) : null}
</span>
<button type="button" className="outline-toolbar-btn" onClick={expandAll} disabled={collapsedCount === 0}>
</button>
<button
type="button"
className="outline-toolbar-btn"
onClick={collapseAll}
disabled={branchCount === 0 || collapsedCount === branchCount}
>
</button>
</div>
<ul className="outline-tree outline-tree--root">{tree.map(renderNode)}</ul>
</div>
)
}
+230
View File
@@ -0,0 +1,230 @@
import * as React from 'react'
import mermaid from 'mermaid'
let mermaidInited = false
let mermaidSeq = 0
function ensureMermaid(): void {
if (mermaidInited) return
mermaid.initialize({
startOnLoad: false,
theme: 'neutral',
securityLevel: 'loose',
fontFamily: 'inherit',
flowchart: { useMaxWidth: true, htmlLabels: true },
sequence: { useMaxWidth: true, wrap: true },
er: { useMaxWidth: true },
gantt: { useMaxWidth: true }
})
mermaidInited = true
}
function mermaidSourceFromWrap(wrap: HTMLElement): string {
const enc = wrap.getAttribute('data-mermaid-src')
if (enc) {
try {
return decodeURIComponent(enc)
} catch {
/* fall through */
}
}
const pre = wrap.querySelector<HTMLElement>('pre.mermaid')
return pre?.textContent ?? ''
}
async function renderMermaidIn(root: HTMLElement, isStale: () => boolean): Promise<void> {
const wraps = root.querySelectorAll<HTMLElement>('.md-diagram-wrap')
if (wraps.length === 0) return
ensureMermaid()
for (const wrap of wraps) {
if (isStale()) return
const source = mermaidSourceFromWrap(wrap).trim()
if (!source) continue
delete wrap.dataset.mermaidRendered
let host = wrap.querySelector<HTMLElement>('.mermaid-render-host')
const pre = wrap.querySelector<HTMLElement>('pre.mermaid')
if (pre) {
host = document.createElement('div')
host.className = 'mermaid-render-host'
pre.replaceWith(host)
} else if (!host) {
host = document.createElement('div')
host.className = 'mermaid-render-host'
wrap.appendChild(host)
}
host.innerHTML = '<div class="mermaid-placeholder" aria-hidden></div>'
host.removeAttribute('data-mermaid-error')
const id = `mr-mmd-${++mermaidSeq}`
try {
const { svg, bindFunctions } = await mermaid.render(id, source)
if (isStale()) return
if (!root.isConnected || !wrap.isConnected) return
host.innerHTML = svg
bindFunctions?.(host)
wrap.dataset.mermaidRendered = '1'
} catch (err) {
if (isStale()) return
const msg = err instanceof Error ? err.message : String(err)
host.setAttribute('data-mermaid-error', msg)
host.innerHTML = `<pre class="mermaid-error">${escapeHtml(msg)}\n\n${escapeHtml(source.slice(0, 2000))}</pre>`
console.warn("[Huangzhijun's Reader] mermaid render failed", err)
}
}
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function isMarkdownPath(p: string): boolean {
return /\.(md|markdown|mdown|mkd)$/i.test(p)
}
function linkHref(anchor: HTMLAnchorElement): string | null {
return anchor.getAttribute('href') ?? anchor.getAttribute('data-md-href')
}
type Props = {
html: string
articleRef?: React.Ref<HTMLElement>
baseFilePath?: string | null
resolveRelativePath?: (baseFilePath: string, href: string) => Promise<string>
onOpenMarkdown?: (absolutePath: string, anchorId?: string) => void | Promise<void>
onOpenExternal?: (url: string) => void | Promise<void>
}
export function PreviewArticle({
html,
articleRef,
baseFilePath,
resolveRelativePath,
onOpenMarkdown,
onOpenExternal
}: Props): React.ReactElement {
const innerRef = React.useRef<HTMLElement | null>(null)
const renderGenRef = React.useRef(0)
const baseFilePathRef = React.useRef(baseFilePath)
baseFilePathRef.current = baseFilePath
const resolveRef = React.useRef(resolveRelativePath)
resolveRef.current = resolveRelativePath
const openMdRef = React.useRef(onOpenMarkdown)
openMdRef.current = onOpenMarkdown
const openExtRef = React.useRef(onOpenExternal)
openExtRef.current = onOpenExternal
const setRef = React.useCallback(
(el: HTMLElement | null) => {
innerRef.current = el
if (typeof articleRef === 'function') articleRef(el)
else if (articleRef && typeof articleRef === 'object' && 'current' in articleRef) {
;(articleRef as React.MutableRefObject<HTMLElement | null>).current = el
}
},
[articleRef]
)
const scrollToAnchor = React.useCallback((root: HTMLElement, anchorId: string): void => {
const id = decodeURIComponent(anchorId)
if (!id) return
let el: Element | null = null
try {
el = root.querySelector(`#${CSS.escape(id)}`)
} catch {
el = root.querySelector(`#${id}`)
}
if (!el) el = document.getElementById(id)
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, [])
const handleClickCapture = React.useCallback((e: React.MouseEvent<HTMLElement>) => {
if (e.defaultPrevented || e.button !== 0) return
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
const root = innerRef.current
if (!root) return
const anchor = (e.target as Element | null)?.closest('a')
if (!anchor || !root.contains(anchor)) return
const href = linkHref(anchor)
if (!href) return
e.preventDefault()
e.stopPropagation()
void (async () => {
if (href.startsWith('#')) {
scrollToAnchor(root, href.slice(1))
return
}
const hashIdx = href.indexOf('#')
const pathPart = (hashIdx >= 0 ? href.slice(0, hashIdx) : href).trim()
const anchorId = hashIdx >= 0 ? href.slice(hashIdx + 1) : ''
if (!pathPart) {
if (anchorId) scrollToAnchor(root, anchorId)
return
}
if (/^https?:\/\//i.test(pathPart) || /^mailto:/i.test(pathPart) || /^tel:/i.test(pathPart)) {
await openExtRef.current?.(pathPart)
return
}
const base = baseFilePathRef.current
if (!base) {
window.alert('请先保存当前文件,再打开文内相对链接。')
return
}
const resolve = resolveRef.current
if (!resolve) return
const resolved = await resolve(base, pathPart)
if (!resolved) return
if (/^https?:\/\//i.test(resolved) || /^mailto:/i.test(resolved) || /^tel:/i.test(resolved)) {
await openExtRef.current?.(resolved)
return
}
if (isMarkdownPath(resolved)) {
await openMdRef.current?.(resolved, anchorId || undefined)
return
}
window.alert(`暂不支持打开该类型链接:${resolved}`)
})()
}, [scrollToAnchor])
React.useLayoutEffect(() => {
const root = innerRef.current
if (!root) return
const gen = ++renderGenRef.current
root.innerHTML = html
void renderMermaidIn(root, () => gen !== renderGenRef.current).catch((err) => {
console.warn("[Huangzhijun's Reader] mermaid batch failed", err)
})
return () => {
renderGenRef.current += 1
}
}, [html])
return (
<article ref={setRef} className="preview" onClickCapture={handleClickCapture} />
)
}
+493
View File
@@ -0,0 +1,493 @@
/* 背景(12):颜色变量,挂到 document.documentElement data-bg */
:root[data-bg='0'] {
--app-bg: #f3f4f6;
--panel-bg: #e5e7eb;
--text: #111827;
--muted: #6b7280;
--border: #d1d5db;
--accent: #2563eb;
--btn-bg: #ffffff;
--hover: rgba(0, 0, 0, 0.06);
--preview-bg: #ffffff;
--preview-text: #111827;
--preview-shadow: 0 8px 30px rgba(15, 23, 42, 0.08);
--code-bg: #f9fafb;
}
:root[data-bg='1'] {
--app-bg: #f4ecd8;
--panel-bg: #e8dfc8;
--text: #3e3428;
--muted: #7a6a58;
--border: #d2c7b2;
--accent: #8b4513;
--btn-bg: #fffaf0;
--hover: rgba(62, 52, 40, 0.08);
--preview-bg: #fffaf0;
--preview-text: #3e3428;
--preview-shadow: 0 8px 24px rgba(62, 52, 40, 0.12);
--code-bg: #f0e6d6;
}
:root[data-bg='2'] {
--app-bg: #0f172a;
--panel-bg: #111c33;
--text: #e5e7eb;
--muted: #94a3b8;
--border: #1e293b;
--accent: #38bdf8;
--btn-bg: #1e293b;
--hover: rgba(148, 163, 184, 0.12);
--preview-bg: #111827;
--preview-text: #e5e7eb;
--preview-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
--code-bg: #0b1224;
}
:root[data-bg='3'] {
--app-bg: #102216;
--panel-bg: #143220;
--text: #e7f7ec;
--muted: #8fb39a;
--border: #1f3b2a;
--accent: #4ade80;
--btn-bg: #163527;
--hover: rgba(74, 222, 128, 0.12);
--preview-bg: #132a1f;
--preview-text: #e7f7ec;
--preview-shadow: 0 10px 36px rgba(0, 0, 0, 0.45);
--code-bg: #0d1f16;
}
:root[data-bg='4'] {
--app-bg: linear-gradient(160deg, #e0f2fe 0%, #eef2ff 45%, #fdf4ff 100%);
--panel-bg: rgba(255, 255, 255, 0.72);
--text: #0f172a;
--muted: #475569;
--border: #cbd5f5;
--accent: #4f46e5;
--btn-bg: rgba(255, 255, 255, 0.9);
--hover: rgba(79, 70, 229, 0.1);
--preview-bg: rgba(255, 255, 255, 0.92);
--preview-text: #0f172a;
--preview-shadow: 0 12px 40px rgba(15, 23, 42, 0.12);
--code-bg: #f8fafc;
}
:root[data-bg='5'] {
--app-bg: #fafafa;
--panel-bg: #ffffff;
--text: #111111;
--muted: #737373;
--border: #e5e5e5;
--accent: #db2777;
--btn-bg: #ffffff;
--hover: rgba(219, 39, 119, 0.08);
--preview-bg: #ffffff;
--preview-text: #111111;
--preview-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
--code-bg: #f5f5f5;
}
:root[data-bg='6'] {
--app-bg: #121212;
--panel-bg: #1a1a1a;
--text: #fafafa;
--muted: #a3a3a3;
--border: #262626;
--accent: #f97316;
--btn-bg: #171717;
--hover: rgba(249, 115, 22, 0.15);
--preview-bg: #0a0a0a;
--preview-text: #fafafa;
--preview-shadow: 0 0 0 1px #262626;
--code-bg: #000000;
}
:root[data-bg='7'] {
--app-bg: #f5f3ff;
--panel-bg: #ede9fe;
--text: #312e81;
--muted: #6d28d9;
--border: #ddd6fe;
--accent: #7c3aed;
--btn-bg: #ffffff;
--hover: rgba(124, 58, 237, 0.1);
--preview-bg: #ffffff;
--preview-text: #312e81;
--preview-shadow: 0 10px 32px rgba(49, 46, 129, 0.12);
--code-bg: #f5f3ff;
}
:root[data-bg='8'] {
--app-bg: #1c1917;
--panel-bg: #292524;
--text: #fafaf9;
--muted: #a8a29e;
--border: #44403c;
--accent: #fbbf24;
--btn-bg: #292524;
--hover: rgba(251, 191, 36, 0.12);
--preview-bg: #1c1917;
--preview-text: #fafaf9;
--preview-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
--code-bg: #0c0a09;
}
:root[data-bg='9'] {
--app-bg: #1a1033;
--panel-bg: #221547;
--text: #f3e8ff;
--muted: #c4b5fd;
--border: #3b2a6b;
--accent: #a78bfa;
--btn-bg: #2a185f;
--hover: rgba(167, 139, 250, 0.15);
--preview-bg: #120c24;
--preview-text: #f3e8ff;
--preview-shadow: 0 14px 48px rgba(0, 0, 0, 0.55);
--code-bg: #0f0820;
}
:root[data-bg='10'] {
--app-bg: #e8f4fc;
--panel-bg: #d7eaf8;
--text: #0c4a6e;
--muted: #0369a1;
--border: #bae6fd;
--accent: #0284c7;
--btn-bg: #f0f9ff;
--hover: rgba(2, 132, 199, 0.12);
--preview-bg: #ffffff;
--preview-text: #0c4a6e;
--preview-shadow: 0 10px 28px rgba(12, 74, 110, 0.1);
--code-bg: #e0f2fe;
}
:root[data-bg='11'] {
--app-bg: #fdf2f4;
--panel-bg: #fce7eb;
--text: #881337;
--muted: #9f1239;
--border: #fbcfe8;
--accent: #db2777;
--btn-bg: #fff1f2;
--hover: rgba(219, 39, 119, 0.1);
--preview-bg: #ffffff;
--preview-text: #881337;
--preview-shadow: 0 10px 28px rgba(136, 19, 55, 0.08);
--code-bg: #ffe4e6;
}
:root[data-bg='12'] {
--app-bg: #18181b;
--panel-bg: #27272a;
--text: #fafafa;
--muted: #a1a1aa;
--border: #3f3f46;
--accent: #22d3ee;
--btn-bg: #27272a;
--hover: rgba(34, 211, 238, 0.12);
--preview-bg: #18181b;
--preview-text: #fafafa;
--preview-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
--code-bg: #09090b;
}
:root[data-bg='13'] {
--app-bg: #ecfeff;
--panel-bg: #cffafe;
--text: #0e7490;
--muted: #0891b2;
--border: #a5f3fc;
--accent: #0d9488;
--btn-bg: #f0fdfa;
--hover: rgba(13, 148, 136, 0.12);
--preview-bg: #ffffff;
--preview-text: #134e4a;
--preview-shadow: 0 10px 28px rgba(8, 145, 178, 0.1);
--code-bg: #ccfbf1;
}
:root[data-bg='14'] {
--app-bg: #faf5f0;
--panel-bg: #ede4d8;
--text: #422006;
--muted: #92400e;
--border: #d6c4b0;
--accent: #b45309;
--btn-bg: #fffbeb;
--hover: rgba(180, 83, 9, 0.1);
--preview-bg: #fffbeb;
--preview-text: #422006;
--preview-shadow: 0 10px 28px rgba(66, 32, 6, 0.08);
--code-bg: #fef3c7;
}
:root[data-bg='15'] {
--app-bg: #f0fdfa;
--panel-bg: #ccfbf1;
--text: #115e59;
--muted: #0f766e;
--border: #99f6e4;
--accent: #0d9488;
--btn-bg: #ecfdf5;
--hover: rgba(13, 148, 136, 0.12);
--preview-bg: #ffffff;
--preview-text: #134e4a;
--preview-shadow: 0 10px 28px rgba(17, 94, 89, 0.08);
--code-bg: #d1fae5;
}
:root[data-bg='16'] {
--app-bg: #f5f3ff;
--panel-bg: #ede9fe;
--text: #4c1d95;
--muted: #6d28d9;
--border: #ddd6fe;
--accent: #7c3aed;
--btn-bg: #faf5ff;
--hover: rgba(124, 58, 237, 0.1);
--preview-bg: #ffffff;
--preview-text: #4c1d95;
--preview-shadow: 0 10px 28px rgba(76, 29, 149, 0.08);
--code-bg: #ede9fe;
}
:root[data-bg='17'] {
--app-bg: #faf8f5;
--panel-bg: #f3efe8;
--text: #44403c;
--muted: #78716c;
--border: #e7e5e4;
--accent: #d97706;
--btn-bg: #ffffff;
--hover: rgba(217, 119, 6, 0.1);
--preview-bg: #ffffff;
--preview-text: #44403c;
--preview-shadow: 0 10px 28px rgba(68, 64, 60, 0.08);
--code-bg: #f5f5f4;
}
:root[data-bg='18'] {
--app-bg: #1c1917;
--panel-bg: #292524;
--text: #fafaf9;
--muted: #a8a29e;
--border: #44403c;
--accent: #eab308;
--btn-bg: #292524;
--hover: rgba(234, 179, 8, 0.12);
--preview-bg: #1c1917;
--preview-text: #fafaf9;
--preview-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
--code-bg: #0c0a09;
}
:root[data-bg='19'] {
--app-bg: #fdf4f8;
--panel-bg: #fce7f3;
--text: #831843;
--muted: #be185d;
--border: #fbcfe8;
--accent: #db2777;
--btn-bg: #fff1f2;
--hover: rgba(219, 39, 119, 0.1);
--preview-bg: #ffffff;
--preview-text: #831843;
--preview-shadow: 0 10px 28px rgba(131, 24, 67, 0.08);
--code-bg: #ffe4e6;
}
:root[data-bg='20'] {
--app-bg: #f1f5f9;
--panel-bg: #e2e8f0;
--text: #1e293b;
--muted: #64748b;
--border: #cbd5e1;
--accent: #475569;
--btn-bg: #ffffff;
--hover: rgba(71, 85, 105, 0.12);
--preview-bg: #ffffff;
--preview-text: #1e293b;
--preview-shadow: 0 10px 28px rgba(30, 41, 59, 0.1);
--code-bg: #f8fafc;
}
:root[data-bg='21'] {
--app-bg: #ecfdf5;
--panel-bg: #d1fae5;
--text: #14532d;
--muted: #166534;
--border: #bbf7d0;
--accent: #15803d;
--btn-bg: #f0fdf4;
--hover: rgba(21, 128, 61, 0.12);
--preview-bg: #ffffff;
--preview-text: #14532d;
--preview-shadow: 0 10px 28px rgba(20, 83, 45, 0.08);
--code-bg: #dcfce7;
}
/* Kindle 风格:暖灰纸面 + 轻微颗粒感(纯 CSS) */
:root[data-bg='22'] {
--app-bg: #e8e2d8;
--panel-bg: rgba(245, 241, 234, 0.94);
--text: #2d2a26;
--muted: #6b6560;
--border: #c9c2b8;
--accent: #c2410c;
--btn-bg: #faf6ef;
--hover: rgba(194, 65, 12, 0.08);
--preview-bg: #f7f3eb;
--preview-text: #1f1c18;
--preview-shadow: 0 10px 28px rgba(45, 42, 38, 0.1);
--code-bg: #ede8df;
}
/* 字体:仅字体栈 */
: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;
}
:root[data-font='1'] {
--font-ui: Georgia, 'Times New Roman', serif;
--font-editor: Consolas, 'Courier New', monospace;
--font-preview: 'Palatino Linotype', Palatino, 'Noto Serif 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;
}
: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;
}
: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;
}
: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;
}
:root[data-font='6'] {
--font-ui: Verdana, Geneva, sans-serif;
--font-editor: 'Lucida Console', monospace;
--font-preview: Georgia, Cambria, serif;
}
:root[data-font='7'] {
--font-ui: 'Trebuchet MS', sans-serif;
--font-editor: 'Courier New', Courier, monospace;
--font-preview: 'Book Antiqua', Palatino, 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;
}
: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;
}
: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;
}
: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;
}
: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;
}
:root[data-font='13'] {
--font-ui: 'Hiragino Sans GB', 'PingFang SC', sans-serif;
--font-editor: 'SF Mono', Menlo, Consolas, monospace;
--font-preview: 'Hiragino Mincho ProN', 'Songti SC', serif;
}
:root[data-font='14'] {
--font-ui: 'LXGW WenKai', 'KaiTi', 'Microsoft YaHei', sans-serif;
--font-editor: 'JetBrains Mono', Consolas, monospace;
--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;
}
: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;
}
: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;
}
:root[data-font='18'] {
--font-ui: 'Alibaba PuHuiTi', 'Microsoft YaHei', sans-serif;
--font-editor: 'JetBrains Mono', Consolas, monospace;
--font-preview: 'Alibaba PuHuiTi', 'Noto Serif SC', serif;
}
:root[data-font='19'] {
--font-ui: 'Smiley Sans', 'Microsoft YaHei', sans-serif;
--font-editor: 'JetBrains Mono', Consolas, monospace;
--font-preview: 'Smiley Sans', 'Noto Serif SC', serif;
}
:root[data-font='20'] {
--font-ui: system-ui, sans-serif;
--font-editor: 'Maple Mono', 'Cascadia Code', Consolas, monospace;
--font-preview: 'Source Serif 4', Georgia, serif;
}
: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;
}
:root[data-font='22'] {
--font-ui: 'Roboto', system-ui, sans-serif;
--font-editor: 'Roboto Mono', Consolas, monospace;
--font-preview: 'Roboto Slab', Georgia, 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;
}
:root[data-font='24'] {
--font-ui: 'Lato', system-ui, sans-serif;
--font-editor: 'Ubuntu Mono', Consolas, monospace;
--font-preview: 'Lora', Georgia, serif;
}
:root[data-font='25'] {
--font-ui: 'Montserrat', system-ui, sans-serif;
--font-editor: 'Space Mono', Consolas, monospace;
--font-preview: 'Playfair Display', Georgia, serif;
}
:root[data-font='26'] {
--font-ui: 'Merriweather Sans', system-ui, sans-serif;
--font-editor: 'Inconsolata', Consolas, monospace;
--font-preview: 'Merriweather', Georgia, 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;
}
:root[data-font='28'] {
--font-ui: 'Literata', Georgia, serif;
--font-editor: 'IBM Plex Mono', Consolas, monospace;
--font-preview: 'Literata', Georgia, serif;
}
:root[data-font='29'] {
--font-ui: system-ui, sans-serif;
--font-editor: 'Inconsolata', 'Consolas', monospace;
--font-preview: Georgia, 'Noto Serif SC', serif;
}
:root[data-font='30'] {
--font-ui: system-ui, sans-serif;
--font-editor: 'Anonymous Pro', 'Courier New', monospace;
--font-preview: 'Palatino Linotype', Palatino, serif;
}
:root[data-font='31'] {
--font-ui: system-ui, sans-serif;
--font-editor: 'Fantasque Sans Mono', Consolas, monospace;
--font-preview: 'Charter', 'Noto Serif SC', serif;
}
:root[data-bg='4'] .toolbar,
:root[data-bg='4'] .tab-bar,
:root[data-bg='4'] .sidebar {
backdrop-filter: blur(8px);
}
+74
View File
@@ -0,0 +1,74 @@
/** 背景 id 022,对应 `appearance-styles.css` 中 `:root[data-bg]`。 */
export const BACKGROUNDS: { id: string; name: string }[] = [
{ id: '0', name: '云雾灰' },
{ id: '1', name: '羊皮纸' },
{ id: '2', name: '深海蓝' },
{ id: '3', name: '森绿' },
{ id: '4', name: '极光渐变' },
{ id: '5', name: '极简粉' },
{ id: '6', name: '暗夜橙' },
{ id: '7', name: '薰衣草' },
{ id: '8', name: '暖石褐' },
{ id: '9', name: '星夜紫' },
{ id: '10', name: '霜青' },
{ id: '11', name: '玫瑰灰' },
{ id: '12', name: '石墨黑' },
{ id: '13', name: '薄荷冰' },
{ id: '14', name: '焦糖拿铁' },
{ id: '15', name: '青瓷' },
{ id: '16', name: '暮光紫灰' },
{ id: '17', name: '沙岸米' },
{ id: '18', name: '松烟墨' },
{ id: '19', name: '樱粉雾' },
{ id: '20', name: '钢蓝灰' },
{ id: '21', name: '苔痕绿' },
{ id: '22', name: 'Kindle 纸感' }
]
/** 字体 id 031,对应 `: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: '13', name: '冬青黑体' },
{ id: '14', name: '霞鹜文楷' },
{ id: '15', name: 'HarmonyOS Sans' },
{ id: '16', name: 'OPPO Sans' },
{ id: '17', name: '小米兰亭' },
{ 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: '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' }
]
export type ViewMode = 'edit' | 'read' | 'hybrid'
export function validBackgroundId(s: string | null): string | null {
if (s === null) return null
return /^([0-9]|1[0-9]|2[0-2])$/.test(s) ? s : null
}
export function validFontId(s: string | null): string | null {
if (s === null) return null
return /^([0-9]|[12][0-9]|3[01])$/.test(s) ? s : null
}
+11
View File
@@ -0,0 +1,11 @@
/** 颜色选择器预设(按行分组展示) */
export const COLOR_SWATCH_ROWS: string[][] = [
['#000000', '#1c1917', '#292524', '#44403c', '#57534e', '#78716c', '#a8a29e', '#d6d3d1'],
['#450a0a', '#7f1d1d', '#b91c1c', '#dc2626', '#f87171', '#fecaca', '#fee2e2', '#fef2f2'],
['#431407', '#9a3412', '#c2410c', '#ea580c', '#fb923c', '#fed7aa', '#ffedd5', '#fff7ed'],
['#422006', '#a16207', '#ca8a04', '#eab308', '#fde047', '#fef9c3', '#fefce8', '#fefce8'],
['#14532d', '#166534', '#15803d', '#22c55e', '#86efac', '#bbf7d0', '#dcfce7', '#f0fdf4'],
['#134e4a', '#0f766e', '#0d9488', '#14b8a6', '#5eead4', '#ccfbf1', '#ecfdf5', '#f0fdfa'],
['#1e3a8a', '#1d4ed8', '#2563eb', '#3b82f6', '#93c5fd', '#bfdbfe', '#dbeafe', '#eff6ff'],
['#4c1d95', '#6d28d9', '#7c3aed', '#8b5cf6', '#c4b5fd', '#ddd6fe', '#ede9fe', '#f5f3ff']
]
+36
View File
@@ -0,0 +1,36 @@
/// <reference types="vite/client" />
import type {
ListDirResult,
OpenFileResult,
OpenFolderResult,
OpenImageResult,
SaveDialogResult
} from '../../../electron/ipc-types'
export interface MyReaderApi {
openFileDialog: () => Promise<OpenFileResult>
openFolderDialog: () => Promise<OpenFolderResult>
openImageDialog: () => Promise<OpenImageResult>
saveFileDialog: (defaultPath?: string) => Promise<SaveDialogResult>
readFile: (filePath: string) => Promise<string>
writeFile: (filePath: string, content: string) => Promise<void>
listDirectory: (dirPath: string) => Promise<ListDirResult>
parentDirectory: (dirPath: string) => Promise<string>
resolveRelativePath: (baseFilePath: string, href: string) => Promise<string>
openExternal: (url: string) => Promise<void>
defaultDirectory: () => Promise<string>
deleteFile: (filePath: string) => Promise<void>
renamePath: (oldPath: string, newName: string) => Promise<{ newPath: string }>
allowClose: () => void
onRequestClose: (handler: () => void) => () => void
onMenuAction: (handler: (action: string) => void) => () => void
}
declare global {
interface Window {
myreader?: MyReaderApi
}
}
export {}
+35
View File
@@ -0,0 +1,35 @@
const HISTORY_KEY = 'myreader-file-history-v1'
const MAX_ITEMS = 80
export function loadFileHistory(): string[] {
try {
const raw = localStorage.getItem(HISTORY_KEY)
if (!raw) return []
const data = JSON.parse(raw) as { paths?: unknown }
if (!Array.isArray(data.paths)) return []
return data.paths.filter((p): p is string => typeof p === 'string' && p.length > 0)
} catch {
return []
}
}
export function saveFileHistory(paths: string[]): void {
localStorage.setItem(HISTORY_KEY, JSON.stringify({ v: 1, paths }))
}
export function addFileHistory(paths: string[], filePath: string): string[] {
const next = [filePath, ...paths.filter((p) => p !== filePath)].slice(0, MAX_ITEMS)
saveFileHistory(next)
return next
}
export function removeFileHistory(paths: string[], filePath: string): string[] {
const next = paths.filter((p) => p !== filePath)
saveFileHistory(next)
return next
}
export function historyFileName(path: string): string {
const parts = path.split(/[/\\]/)
return parts[parts.length - 1] || path
}
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import { validBackgroundId, validFontId } from './appearance'
import './appearance-styles.css'
import './index.css'
{
const bg =
localStorage.getItem('myreader-bg-id') ??
localStorage.getItem('myreader-theme') ??
localStorage.getItem('myreader-bg') ??
'1'
const font = localStorage.getItem('myreader-font-id') ?? '0'
const line = localStorage.getItem('myreader-preview-line-height')
const lineN = line ? Number.parseFloat(line) : NaN
const lineH = Number.isFinite(lineN) ? String(Math.min(3, Math.max(1, lineN))) : '1.5'
document.documentElement.dataset.bg = validBackgroundId(bg) ?? '1'
document.documentElement.dataset.font = validFontId(font) ?? '0'
document.documentElement.style.setProperty('--preview-line-height', lineH)
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)
+406
View File
@@ -0,0 +1,406 @@
import MarkdownIt from 'markdown-it'
import multimdTable from 'markdown-it-multimd-table'
import hljs from 'highlight.js'
import DOMPurify from 'dompurify'
import { absolutePathToFileUrl, resolvePathFromBaseFile } from './pathLite'
import 'highlight.js/styles/github.min.css'
const MERMAID_LANG = new Set(['mermaid', 'flowchart', 'sequencediagram', 'sequence'])
const MERMAID_START =
/^\s*(sequenceDiagram|classDiagram|stateDiagram(?:-v2)?|erDiagram|gantt|pie|gitGraph|journey|flowchart\s|graph\s|mindmap|timeline|quadrantChart|xychart-beta|block-beta)/i
function isMermaidFence(lang: string, content: string): boolean {
const l = lang.toLowerCase()
if (MERMAID_LANG.has(l)) return true
if (!lang.trim() && MERMAID_START.test(content)) return true
return false
}
function highlightCodeBlock(content: string, lang: string): string {
const trimmed = (lang || '').trim()
if (trimmed && hljs.getLanguage(trimmed)) {
try {
return hljs.highlight(content, { language: trimmed, ignoreIllegals: true }).value
} catch {
/* fall through */
}
}
if (trimmed) {
try {
return hljs.highlightAuto(content).value
} catch {
/* fall through */
}
}
return md.utils.escapeHtml(content)
}
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
highlight: function (this: MarkdownIt, str: string, lang: string) {
const body = highlightCodeBlock(str.replace(/\n$/, ''), lang)
return `<pre class="hljs"><code>${body}</code></pre>`
}
})
md.use(multimdTable, { multiline: true, rowspan: true, headerless: false })
/** 允许本地 file:// 图片与链接(markdown-it 默认禁止 file: 协议)。 */
const defaultValidateLink = md.validateLink.bind(md)
md.validateLink = (url: string): boolean => {
if (/^file:/i.test(url)) return true
return defaultValidateLink(url)
}
/** 禁用 Setext 标题,避免 `---` 等被当成 h2。 */
md.disable(['lheading'])
function setBlockSourceLines(token: { map?: [number, number] | null; attrSet: (n: string, v: string) => void }): void {
if (!token.map) return
const start = token.map[0]
const end = Math.max(start, token.map[1] - 1)
token.attrSet('data-source-line', String(start))
token.attrSet('data-source-line-end', String(end))
}
const defaultHeadingOpen =
md.renderer.rules.heading_open ??
function (tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.heading_open = (tokens, idx, options, env, self) => {
const token = tokens[idx]
setBlockSourceLines(token)
token.attrSet('id', slugForLine(token.map ? token.map[0] : 0))
return defaultHeadingOpen(tokens, idx, options, env, self)
}
function wrapBlockOpenRule(ruleName: 'paragraph_open' | 'list_item_open' | 'blockquote_open'): void {
const prev = md.renderer.rules[ruleName]
md.renderer.rules[ruleName] = (tokens, idx, options, env, self) => {
setBlockSourceLines(tokens[idx]!)
if (prev) return prev(tokens, idx, options, env, self)
return self.renderToken(tokens, idx, options)
}
}
wrapBlockOpenRule('paragraph_open')
wrapBlockOpenRule('list_item_open')
wrapBlockOpenRule('blockquote_open')
const defaultTableOpen = md.renderer.rules.table_open
const defaultTableClose = md.renderer.rules.table_close
md.renderer.rules.table_open = (tokens, idx, options, env, self) => {
setBlockSourceLines(tokens[idx]!)
const inner = defaultTableOpen
? defaultTableOpen(tokens, idx, options, env, self)
: '<table>'
return `<div class="md-table-wrap">${inner}`
}
md.renderer.rules.table_close = (tokens, idx, options, env, self) => {
const inner = defaultTableClose
? defaultTableClose(tokens, idx, options, env, self)
: '</table>'
return `${inner}</div>`
}
function normalizeImageSrc(src: string, baseFilePath: string | null | undefined): string {
const s = src.trim()
if (!s) return s
if (/^file:\/\//i.test(s)) return s
if (/^https?:\/\//i.test(s) || /^data:/i.test(s)) return s
if (baseFilePath?.trim()) {
try {
let pathPart = s
if (pathPart.startsWith('file:')) {
pathPart = pathPart.replace(/^file:\/*/i, '')
try {
pathPart = decodeURIComponent(pathPart)
} catch {
/* keep */
}
}
const abs = resolvePathFromBaseFile(baseFilePath, pathPart)
return absolutePathToFileUrl(abs)
} catch {
/* keep */
}
}
return s
}
const defaultImage = md.renderer.rules.image!
md.renderer.rules.image = (tokens, idx, options, env, self) => {
const token = tokens[idx]!
const src = token.attrGet('src')
const base = (env as { baseFilePath?: string | null }).baseFilePath
if (src) {
token.attrSet('src', normalizeImageSrc(src, base))
}
const html = defaultImage(tokens, idx, options, env, self)
return html.replace(/<img /i, '<img loading="lazy" decoding="async" ')
}
const defaultFence = md.renderer.rules.fence
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
const token = tokens[idx]
if (!token.map) {
return defaultFence ? defaultFence(tokens, idx, options, env, self) : ''
}
const lang = (token.info || '').trim()
const raw = token.content.replace(/\n$/, '')
const codeEndLine = Math.max(token.map[0], token.map[1] - 1)
if (isMermaidFence(lang, raw)) {
const trimmed = raw.trim()
const escaped = md.utils.escapeHtml(trimmed)
const dataSrc = md.utils.escapeHtml(encodeURIComponent(trimmed))
return `<div class="md-diagram-wrap" data-source-line="${token.map[0]}" data-source-line-end="${codeEndLine}" data-mermaid-src="${dataSrc}"><pre class="mermaid">${escaped}</pre></div>`
}
const highlighted = highlightCodeBlock(raw, lang)
const langLabel = lang
? `<span class="md-code-lang">${md.utils.escapeHtml(lang.split(/\s+/)[0] ?? lang)}</span>`
: ''
return `<div class="md-code-wrap" data-source-line="${token.map[0]}" data-source-line-end="${codeEndLine}">${langLabel}<pre class="hljs"><code>${highlighted}</code></pre></div>`
}
const purifyOpts: Parameters<typeof DOMPurify.sanitize>[1] = {
USE_PROFILES: { html: true },
ADD_TAGS: ['span', 'mark', 'kbd', 'sup', 'sub', 'details', 'summary', 'font', 'div'],
ADD_ATTR: [
'style',
'color',
'open',
'data-source-line',
'data-source-line-end',
'id',
'class',
'href',
'data-md-href',
'src',
'alt',
'width',
'height',
'loading',
'decoding',
'data-mermaid-src'
],
ALLOWED_URI_REGEXP:
/^(?:(?:https?|mailto|tel|data|file):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i
}
const defaultLinkOpen = md.renderer.rules.link_open
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
const token = tokens[idx]!
const href = token.attrGet('href') ?? ''
if (href && !/^https?:\/\//i.test(href) && !href.startsWith('#') && !href.startsWith('mailto:')) {
token.attrSet('class', 'md-internal-link')
token.attrSet('data-md-href', href)
}
if (defaultLinkOpen) return defaultLinkOpen(tokens, idx, options, env, self)
return self.renderToken(tokens, idx, options)
}
export function slugForLine(line: number): string {
return `mr-line-${line}`
}
export type RenderMarkdownEnv = {
baseFilePath?: string | null
}
export function renderMarkdown(src: string, env?: RenderMarkdownEnv): string {
const input = typeof src === 'string' ? src : ''
const renderEnv = env ?? {}
try {
const raw = md.render(input, renderEnv)
try {
return DOMPurify.sanitize(raw, purifyOpts)
} catch (e) {
console.warn('[MyReader] DOMPurify failed', e)
return `<p>${md.utils.escapeHtml(raw.slice(0, 8000))}</p>`
}
} catch (e) {
console.warn('[MyReader] markdown render failed', e)
return `<pre>${md.utils.escapeHtml(input.slice(0, 8000))}</pre>`
}
}
export type OutlineItem = {
level: number
text: string
displayText: string
/** 0-based source line */
line: number
slug: string
}
function outlineDisplayText(raw: string): string {
let s = raw
s = s.replace(/\*\*([^*]+)\*\*/g, '$1')
s = s.replace(/\*([^*]+)\*/g, '$1')
s = s.replace(/`([^`]+)`/g, '$1')
s = s.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
s = s.replace(/<[^>]+>/g, '')
s = s.replace(/\s+/g, ' ').trim()
return s
}
/** 分隔线、纯符号等不应出现在大纲。 */
export function isValidOutlineHeading(displayText: string, rawLine: string): boolean {
const t = displayText.trim()
if (!t || t === '(空标题)') return false
if (/^[-—–_=~*`#>\s]+$/.test(t)) return false
const body = rawLine.replace(/^#{1,6}\s*/, '').trim()
if (/^[-—–_=~*\s]+$/.test(body)) return false
return true
}
/** 仅从 ATX `#` 标题提取;`line` 为 0-based。 */
export function extractOutline(src: string): OutlineItem[] {
const input = typeof src === 'string' ? src : ''
const lines = input.split(/\r?\n/)
const out: OutlineItem[] = []
for (let i = 0; i < lines.length; i++) {
const raw = lines[i] ?? ''
const m = /^(#{1,6})\s+(.+)$/.exec(raw)
if (!m) continue
const level = m[1]!.length
const text = m[2]!.trim()
const displayText = outlineDisplayText(text) || '(空标题)'
if (!isValidOutlineHeading(displayText, raw)) continue
out.push({
level,
text,
displayText,
line: i,
slug: slugForLine(i)
})
}
return out
}
export type SourceLineRegion = {
line: number
lineEnd: number
el: HTMLElement
}
export function collectSourceLineRegions(article: HTMLElement): SourceLineRegion[] {
const nodes = article.querySelectorAll<HTMLElement>('[data-source-line]')
const regions: SourceLineRegion[] = []
for (const el of nodes) {
const start = Number(el.getAttribute('data-source-line'))
if (!Number.isFinite(start)) continue
const endRaw = el.getAttribute('data-source-line-end')
const end = endRaw !== null && Number.isFinite(Number(endRaw)) ? Number(endRaw) : start
regions.push({ line: start, lineEnd: Math.max(start, end), el })
}
regions.sort((a, b) => a.line - b.line)
return regions
}
/** 阅读区中与源行号对应的元素(用于高亮闪烁)。 */
export function findPreviewAnchorForLine(
article: HTMLElement,
line: number
): HTMLElement | null {
return finestElementForLine(article, line) ?? article.querySelector<HTMLElement>(`#${slugForLine(line)}`)
}
function finestElementForLine(article: HTMLElement, line: number): HTMLElement | null {
const nodes = article.querySelectorAll<HTMLElement>(`[data-source-line="${line}"]`)
for (const el of nodes) {
const start = Number(el.getAttribute('data-source-line'))
if (start !== line) continue
const endRaw = el.getAttribute('data-source-line-end')
const end = endRaw !== null && Number.isFinite(Number(endRaw)) ? Number(endRaw) : start
if (end > start) continue
return el
}
return null
}
/**
* 分栏阅读区:计算源行在 shell 内的垂直中心(px)。
* `line` 为 0-based 源行号。
*/
export function previewLineCenterInShell(
article: HTMLElement,
shell: HTMLElement,
line: number,
totalSourceLines: number
): number | null {
const shellRect = shell.getBoundingClientRect()
const exact = finestElementForLine(article, line)
if (exact) {
const r = exact.getBoundingClientRect()
return r.top - shellRect.top + r.height / 2
}
const regions = collectSourceLineRegions(article)
if (regions.length === 0) {
const h = article.offsetHeight || 1
return ((line + 0.5) / Math.max(1, totalSourceLines)) * h
}
let region = regions[0]!
for (const r of regions) {
if (r.line <= line) region = r
else break
}
if (line >= region.line && line <= region.lineEnd) {
const span = Math.max(1, region.lineEnd - region.line + 1)
const offset = Math.max(0, Math.min(line - region.line, span - 1))
const frac = (offset + 0.5) / span
const blockRect = region.el.getBoundingClientRect()
return blockRect.top - shellRect.top + frac * blockRect.height
}
const blockRect = region.el.getBoundingClientRect()
return blockRect.top - shellRect.top + blockRect.height / 2
}
export function scrollPreviewToSourceLine(
scrollEl: HTMLElement,
article: HTMLElement,
line: number,
behavior: ScrollBehavior = 'smooth',
totalSourceLines?: number
): HTMLElement | null {
const total = totalSourceLines ?? article.querySelectorAll('[data-source-line]').length
const shell = article.closest('.preview-shell') ?? article.parentElement
const anchor = findPreviewAnchorForLine(article, line)
if (!anchor && !shell) return null
const scrollRect = scrollEl.getBoundingClientRect()
let top: number
if (anchor) {
const anchorRect = anchor.getBoundingClientRect()
top = anchorRect.top - scrollRect.top + scrollEl.scrollTop - 20
anchor.classList.add('preview-anchor-flash')
window.setTimeout(() => anchor.classList.remove('preview-anchor-flash'), 1600)
} else if (shell instanceof HTMLElement) {
const center = previewLineCenterInShell(article, shell, line, total)
top = (center ?? 0) - 40
} else {
return null
}
scrollEl.scrollTo({ top: Math.max(0, top), behavior })
return anchor
}
/** @deprecated 使用 previewLineCenterInShell */
export function anchorCenterInShell(anchor: HTMLElement, shell: HTMLElement): number {
const shellRect = shell.getBoundingClientRect()
const r = anchor.getBoundingClientRect()
return r.top - shellRect.top + r.height / 2
}
+39
View File
@@ -0,0 +1,39 @@
import type { OutlineItem } from './markdown'
export type OutlineTreeNode = OutlineItem & {
id: string
children: OutlineTreeNode[]
}
export function buildOutlineTree(items: OutlineItem[]): OutlineTreeNode[] {
const root: OutlineTreeNode[] = []
const stack: OutlineTreeNode[] = []
for (const item of items) {
const node: OutlineTreeNode = {
...item,
id: `o-${item.line}`,
children: []
}
while (stack.length > 0 && stack[stack.length - 1]!.level >= node.level) {
stack.pop()
}
if (stack.length === 0) root.push(node)
else stack[stack.length - 1]!.children.push(node)
stack.push(node)
}
return root
}
export function collectBranchIds(nodes: OutlineTreeNode[]): string[] {
const ids: string[] = []
const walk = (list: OutlineTreeNode[]): void => {
for (const n of list) {
if (n.children.length > 0) {
ids.push(n.id)
walk(n.children)
}
}
}
walk(nodes)
return ids
}
+66
View File
@@ -0,0 +1,66 @@
/** 轻量路径工具,供渲染进程 Markdown 使用(避免依赖 node:path 打包问题)。 */
export function dirnamePath(filePath: string): string {
const n = filePath.replace(/\\/g, '/')
const i = n.lastIndexOf('/')
if (i < 0) return '.'
const d = n.slice(0, i)
if (/^[A-Za-z]:$/.test(d)) return `${d}/`
return d || '/'
}
/** 去掉 . /.. 的段落归一化(不含盘符)。 */
export function normalizeDotSegments(pathWithoutDrive: string): string {
const segs = pathWithoutDrive.split('/').filter((s) => s && s !== '.')
const st: string[] = []
for (const s of segs) {
if (s === '..') st.pop()
else st.push(s)
}
return st.join('/')
}
/** 将相对 href 解析为绝对本地路径(正斜杠)。 */
export function resolvePathFromBaseFile(baseFilePath: string, href: string): string {
let h = href.trim().replace(/\\/g, '/')
const q = h.indexOf('?')
if (q >= 0) h = h.slice(0, q)
const hash = h.indexOf('#')
if (hash >= 0) h = h.slice(0, hash)
const baseNorm = baseFilePath.replace(/\\/g, '/')
const winBase = baseNorm.match(/^([A-Za-z]:)\/(.*)$/)
if (/^[A-Za-z]:\//i.test(h)) {
const m = h.match(/^([A-Za-z]:\/)(.*)$/)!
return `${m[1]}${normalizeDotSegments(m[2] ?? '')}`
}
if (h.startsWith('/')) {
if (winBase) {
return `${winBase[1]}/${normalizeDotSegments(h.slice(1))}`
}
return `/${normalizeDotSegments(h.slice(1))}`
}
const dir = dirnamePath(baseNorm)
const joined = dir.endsWith('/') ? `${dir}${h}` : `${dir}/${h}`
if (winBase) {
const m = joined.match(/^([A-Za-z]:\/)(.*)$/)
if (m) return `${m[1]}${normalizeDotSegments(m[2] ?? '')}`
}
if (joined.startsWith('/')) return `/${normalizeDotSegments(joined.slice(1))}`
return normalizeDotSegments(joined)
}
/** 绝对路径 → file:// URLWindows / Unix)。 */
export function absolutePathToFileUrl(absPath: string): string {
const norm = absPath.replace(/\\/g, '/').trim()
if (!norm) return ''
if (/^[A-Za-z]:\//.test(norm)) {
return `file:///${encodeURI(norm)}`
}
const withLead = norm.startsWith('/') ? norm : `/${norm}`
return `file://${encodeURI(withLead)}`
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"useDefineForClassFields": true,
"types": ["vite/client"]
},
"include": [
"electron/**/*.ts",
"src/renderer/**/*.ts",
"src/renderer/**/*.tsx",
"src/renderer/index.html"
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"allowSyntheticDefaultImports": true
},
"include": ["electron.vite.config.ts"]
}