代码初始化
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
out
|
||||||
|
release
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
Generated
+8
@@ -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
|
||||||
Generated
+9
@@ -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>
|
||||||
Generated
+6
@@ -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>
|
||||||
Generated
+8
@@ -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>
|
||||||
@@ -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**。
|
||||||
@@ -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()]
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Generated
+7231
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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
@@ -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,
|
||||||
|
`)})`
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const url = await requestText('图片地址', 'https://')
|
||||||
|
if (url === null) return
|
||||||
|
const rawAlt = await requestText('替代文字', '图片')
|
||||||
|
if (rawAlt === null) return
|
||||||
|
const alt = escapeMarkdownLinkFragment(rawAlt || '图片')
|
||||||
|
insertAtCursor(view, `})`)
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
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} />
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/** 背景 id 0–22,对应 `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 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: '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
|
||||||
|
}
|
||||||
@@ -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']
|
||||||
|
]
|
||||||
Vendored
+36
@@ -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 {}
|
||||||
@@ -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
@@ -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>
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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:// URL(Windows / 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)}`
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user