初始化web版本MyReader

This commit is contained in:
2026-05-16 23:24:55 +08:00
commit 72d7a750a2
34 changed files with 9564 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.DS_Store
*.log
.env
.env.*
+8
View File
@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="openjdk-24" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/MyReader-Web.iml" filepath="$PROJECT_DIR$/.idea/MyReader-Web.iml" />
</modules>
</component>
</project>
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProtoPluginMemos">
<option name="items">
<list>
<MemoItem>
<option name="id" value="0efca706-a594-4ed4-b7a4-50a6e801c598" />
<option name="title" value="网页版本的下载" />
<option name="content" value="1、额外说明中加入下载链接&#10;2、怎么把压缩包放到机器上下载&#10;3、压缩包名改下&#10;4、访问地址文件丢失问题" />
<option name="updated" value="1778859283194" />
</MemoItem>
</list>
</option>
</component>
</project>
+114
View File
@@ -0,0 +1,114 @@
# Huangzhijun's Reader — Web 版(MyReader-Web
本工程是桌面版 **[MyReader](../MyReader)** 的 **1:1 功能 Web 移植**,同一套 React 界面与 Markdown 能力,在浏览器中运行。桌面版基于 Electron,本工程基于 **Vite + React**,通过浏览器 **File System Access API**(及降级方案)替代 Electron 的本地文件 IPC。
## 与 MyReader 的对应关系
| 能力 | MyReader (Electron) | MyReader-Web |
|------|---------------------|--------------|
| 多标签、脏标记、关闭前保存提示 | ✅ | ✅ |
| 编辑 / 阅读 / 分栏三视图 | ✅ | ✅ |
| 侧栏:文件树、历史、大纲 | ✅ | ✅ |
| 工具栏:打开/保存/另存为/新标签/视图切换 | ✅ | ✅ |
| 背景 23 种、字体 32 种、阅读行距 | ✅ | ✅ |
| CodeMirror 编辑 + 格式工具栏 | ✅ | ✅ |
| Markdown 预览、GFM 表格、代码高亮 | ✅ | ✅ |
| Mermaid / 流程图 | ✅ | ✅ |
| 本地图片插入与预览 | `file://` | `blob:`(浏览器) |
| 会话恢复(localStorage) | ✅ | ✅(路径需仍可访问) |
| 原生菜单与快捷键 | 系统菜单 | **Ctrl 组合键**(见下) |
| 安装包 / 系统托盘 | ✅ | ❌ |
## 设计思路
1. **UI 与逻辑复用**
`MyReader/src/renderer/src` 复制全部前端模块(`App.tsx``EditorPane``markdown.ts``PreviewArticle` 等),保证交互与桌面版一致。
2. **平台适配层**
`src/platform/browserApi.ts` 实现与 Electron `preload` 相同的 `window.myreader` 接口;`src/platform/webVfs.ts` 用虚拟路径(如 `/项目名/笔记.md`)映射 `FileSystemDirectoryHandle` / `FileSystemFileHandle`
3. **启动注入**
`main.tsx` 在渲染前调用 `installBrowserApi()`,无需 Electron。
4. **安全与能力边界**
浏览器无法任意访问磁盘;须用户通过「打开」「打开文件夹」授权。未授权路径无法读写。推荐使用 **Chrome / Edge 桌面版** 以获得完整文件夹 API。
## 环境要求
- **Node.js** ≥ 20.10
- 现代浏览器(推荐 Chrome / Edge 最新版)
## 安装与运行
```bash
cd F:\coding\MyReader-Web
npm install
npm run dev
```
开发服务器默认端口 **1234**。若被占用,Vite 会自动尝试下一可用端口,终端会打印实际地址(例如 `http://localhost:1235`)。
生产构建与本地预览:
```bash
npm run build
npm run preview
```
`npm run preview` 同样优先使用 1234 端口(占用时自动递增)。
## 使用步骤
1. 执行 `npm run dev`,浏览器打开提示的本地地址。
2. 点击 **「打开文件夹」**(侧栏)或工具栏 **「打开」** 选择 Markdown 文件。
3. 使用 **「视图」** 或 `Ctrl+E` 在编辑 / 阅读 / 分栏间切换。
4. 调整工具栏右侧 **行距、字体、背景**
5. **保存** / **另存为** 在已授权文件上写回磁盘;新文件通过保存对话框创建。
6. 插入图片时使用工具栏;Web 版以 **blob URL** 嵌入,请与 `.md` 同目录保存图片或改用相对路径 + 已打开文件夹。
## 快捷键(Web
| 快捷键 | 功能 |
|--------|------|
| Ctrl+O | 打开文件 |
| Ctrl+S | 保存 |
| Ctrl+Shift+S | 另存为 |
| Ctrl+T | 新标签 |
| Ctrl+W | 关闭标签 |
| Ctrl+E | 切换视图模式 |
| Ctrl+Tab | 下一标签 |
| Ctrl+Shift+Tab | 上一标签 |
## 项目结构
```
MyReader-Web/
├── index.html
├── vite.config.ts # 端口 1234(可自动换端口)
├── package.json
├── public/
└── src/
├── App.tsx # 主界面(与 MyReader 同源)
├── platform/
│ ├── browserApi.ts # window.myreader 浏览器实现
│ └── webVfs.ts # 虚拟路径 + 文件句柄
├── types/ipc-types.ts
└── … # 其余与 MyReader 渲染层一致
```
## 已知差异(相对桌面版)
- 无系统菜单栏;依赖页面内按钮与上表快捷键。
- 关闭浏览器标签时,若有未保存内容,使用 `beforeunload` 提示(行为因浏览器而异)。
- 会话恢复中的路径若文件夹未再次授权,可能无法自动重新打开。
- Safari / Firefox 对 `showDirectoryPicker` 支持有限,文件夹浏览可能不可用。
- 内链打开本地非 Markdown 文件、任意 `file://` 图片等仍受浏览器安全策略限制。
## 维护说明
桌面版功能更新后,可将 `MyReader/src/renderer/src` 下同名文件同步复制到本工程 `src/`,并检查是否新增 `window.myreader` 接口——若有,须在 `platform/browserApi.ts` 中一并实现。
---
**显示名称**Huangzhijun's Reader (Web)
**同源项目**`F:\coding\MyReader`
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/app-icon.png" />
<title>Huangzhijun's Reader (Web)</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3289
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "myreader-web",
"version": "0.1.0",
"private": true,
"description": "Huangzhijun's Reader — Web edition (browser port of MyReader)",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"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/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"typescript": "^5.8.3",
"vite": "^6.3.4"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+1490
View File
File diff suppressed because it is too large Load Diff
+370
View File
@@ -0,0 +1,370 @@
import * as React from 'react'
import { EditorSelection } from '@codemirror/state'
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown'
import { EditorState } from '@codemirror/state'
import {
EditorView,
highlightActiveLine,
keymap,
lineNumbers,
placeholder
} from '@codemirror/view'
export type MarkdownEditorHandle = {
applyAction: (id: string) => void | Promise<void>
}
type Props = {
mountKey: string
value: string
onChange: (next: string) => void
gotoLine?: { line: number; seq: number } | null
showLineNumbers?: boolean
/** In Electron, `window.prompt` is unreliable; use an in-app prompt from the host. */
requestText?: (title: string, defaultValue: string) => Promise<string | null>
onViewReady?: (view: EditorView | null) => void
/** 分栏:根据光标所在行在编辑器可滚动内容中的垂直比例,对齐阅读区提示条。 */
onHybridCursorLine?: (line: number) => void
requestImageDetails?: () => Promise<{ url: string; alt: string } | null>
requestColor?: () => Promise<string | null>
}
function getMainSelection(view: EditorView): { from: number; to: number } {
const r = view.state.selection.main
return { from: r.from, to: r.to }
}
function wrap(view: EditorView, left: string, right: string): void {
const { from, to } = getMainSelection(view)
const sel = view.state.sliceDoc(from, to)
view.dispatch({
changes: { from, to, insert: left + sel + right },
selection: EditorSelection.range(from + left.length, from + left.length + sel.length)
})
view.focus()
}
function insertAtCursor(view: EditorView, text: string): void {
const { from, to } = getMainSelection(view)
view.dispatch({
changes: { from, to, insert: text },
selection: EditorSelection.cursor(from + text.length)
})
view.focus()
}
function insertLines(view: EditorView, text: string): void {
insertAtCursor(view, text)
}
function linePrefixOnMainLine(view: EditorView, prefix: string): void {
const pos = view.state.selection.main.head
const line = view.state.doc.lineAt(pos)
const lineText = line.text
const hashes = /^#+\s/.exec(lineText)?.[0] ?? ''
let rest = lineText
if (hashes) rest = lineText.slice(hashes.length)
const newLine = prefix + rest
view.dispatch({
changes: { from: line.from, to: line.to, insert: newLine },
selection: EditorSelection.cursor(line.from + newLine.length)
})
view.focus()
}
function escapeMarkdownLinkFragment(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/\[/g, '\\[').replace(/\]/g, '\\]')
}
function sanitizeInlineColor(c: string): string {
const t = c.trim()
if (/^#[0-9a-fA-F]{3}$/.test(t)) {
const r = t[1]!
const g = t[2]!
const b = t[3]!
return `#${r}${r}${g}${g}${b}${b}`
}
if (/^#[0-9a-fA-F]{6}$/.test(t) || /^#[0-9a-fA-F]{8}$/.test(t)) return t.toLowerCase()
if (/^rgba?\(\s*[\d.\s%,]+\s*\)$/.test(t)) return t
if (/^hsla?\(\s*[\d.\s%,deg]+\s*\)$/.test(t)) return t
if (/^[\w-]{1,40}$/.test(t)) return t
return '#333333'
}
function sanitizeLinkUrl(url: string): string {
const t = url.trim() || 'https://'
if (/^file:/i.test(t)) return `<${t.replace(/[<>]/g, '')}>`
return t.replace(/\)/g, '%29')
}
type ActionDeps = {
requestText: (title: string, def: string) => Promise<string | null>
requestImageDetails?: () => Promise<{ url: string; alt: string } | null>
requestColor?: () => Promise<string | null>
}
async function applyMarkdownAction(view: EditorView, id: string, deps: ActionDeps): Promise<void> {
const { requestText, requestImageDetails, requestColor } = deps
try {
switch (id) {
case 'bold':
wrap(view, '**', '**')
break
case 'italic':
wrap(view, '*', '*')
break
case 'strike':
wrap(view, '~~', '~~')
break
case 'code':
wrap(view, '`', '`')
break
case 'codeBlock':
insertLines(view, '\n```\n\n```\n')
break
case 'link': {
const url = await requestText('链接地址', 'https://')
if (url === null) return
const { from, to } = getMainSelection(view)
const rawLabel = view.state.sliceDoc(from, to) || '链接文字'
const label = escapeMarkdownLinkFragment(rawLabel)
const insert = `[${label}](${sanitizeLinkUrl(url)})`
view.dispatch({
changes: { from, to, insert },
selection: EditorSelection.cursor(from + insert.length)
})
view.focus()
break
}
case 'image': {
if (requestImageDetails) {
const det = await requestImageDetails()
if (!det?.url?.trim()) return
const alt = (det.alt ?? '图片').trim() || '图片'
insertAtCursor(
view,
`![${escapeMarkdownLinkFragment(alt)}](${sanitizeLinkUrl(det.url.trim())})`
)
break
}
const url = await requestText('图片地址', 'https://')
if (url === null) return
const rawAlt = await requestText('替代文字', '图片')
if (rawAlt === null) return
const alt = escapeMarkdownLinkFragment(rawAlt || '图片')
insertAtCursor(view, `![${alt}](${sanitizeLinkUrl(url)})`)
break
}
case 'h1':
linePrefixOnMainLine(view, '# ')
break
case 'h2':
linePrefixOnMainLine(view, '## ')
break
case 'h3':
linePrefixOnMainLine(view, '### ')
break
case 'h4':
linePrefixOnMainLine(view, '#### ')
break
case 'h5':
linePrefixOnMainLine(view, '##### ')
break
case 'h6':
linePrefixOnMainLine(view, '###### ')
break
case 'ul':
linePrefixOnMainLine(view, '- ')
break
case 'ol':
linePrefixOnMainLine(view, '1. ')
break
case 'task':
linePrefixOnMainLine(view, '- [ ] ')
break
case 'quote':
linePrefixOnMainLine(view, '> ')
break
case 'hr':
insertLines(view, '\n---\n')
break
case 'table':
insertLines(
view,
'\n| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| | | |\n'
)
break
case 'mark':
wrap(view, '<mark>', '</mark>')
break
case 'color': {
if (requestColor) {
const c = await requestColor()
if (c === null || !c.trim()) return
const color = sanitizeInlineColor(c)
wrap(view, `<span style="color:${color}">`, '</span>')
break
}
const c = await requestText('颜色(如 #e11 或 red', '#dc2626')
if (c === null || !c.trim()) return
const color = sanitizeInlineColor(c)
wrap(view, `<span style="color:${color}">`, '</span>')
break
}
case 'sup':
wrap(view, '<sup>', '</sup>')
break
case 'sub':
wrap(view, '<sub>', '</sub>')
break
case 'kbd':
wrap(view, '<kbd>', '</kbd>')
break
case 'details':
insertLines(view, '\n<details>\n<summary>标题</summary>\n内容\n</details>\n')
break
case 'footnote':
insertLines(view, '[^1]\n\n[^1]: 脚注内容\n')
break
default:
break
}
} catch (e) {
console.error('[MyReader] toolbar action failed', id, e)
}
}
export const EditorPane = React.forwardRef<MarkdownEditorHandle, Props>(
function EditorPane(
{
mountKey,
value,
onChange,
gotoLine,
showLineNumbers = true,
requestText,
onViewReady,
onHybridCursorLine,
requestImageDetails,
requestColor
}: Props,
ref
): React.ReactElement {
const hostRef = React.useRef<HTMLDivElement | null>(null)
const viewRef = React.useRef<EditorView | null>(null)
const onChangeRef = React.useRef(onChange)
onChangeRef.current = onChange
const requestTextRef = React.useRef(requestText)
requestTextRef.current = requestText
const onViewReadyRef = React.useRef(onViewReady)
onViewReadyRef.current = onViewReady
const onHybridCursorLineRef = React.useRef(onHybridCursorLine)
onHybridCursorLineRef.current = onHybridCursorLine
const requestImageDetailsRef = React.useRef(requestImageDetails)
requestImageDetailsRef.current = requestImageDetails
const requestColorRef = React.useRef(requestColor)
requestColorRef.current = requestColor
React.useImperativeHandle(
ref,
() => ({
applyAction: async (id: string) => {
const view = viewRef.current
if (!view) return
const rt =
requestTextRef.current ??
((title: string, def: string) =>
Promise.resolve<string | null>(window.prompt(title, def)))
await applyMarkdownAction(view, id, {
requestText: rt,
requestImageDetails: requestImageDetailsRef.current,
requestColor: requestColorRef.current
})
}
}),
[]
)
React.useEffect(() => {
const host = hostRef.current
if (!host) return
host.replaceChildren()
const view = new EditorView({
parent: host,
state: EditorState.create({
doc: value,
extensions: [
history(),
markdown(),
...(showLineNumbers ? [lineNumbers()] : []),
highlightActiveLine(),
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
placeholder('在此编辑 Markdown…'),
EditorView.lineWrapping,
EditorView.updateListener.of((u) => {
if (u.docChanged) {
onChangeRef.current(u.state.doc.toString())
}
}),
EditorView.updateListener.of((u) => {
if (!onHybridCursorLineRef.current) return
if (!u.selectionSet && !u.focusChanged && !u.docChanged) return
const pos = u.state.selection.main.head
const line = u.state.doc.lineAt(pos)
onHybridCursorLineRef.current(line.number - 1)
}),
EditorView.theme({
'&': {
height: '100%',
fontSize: '14px',
fontFamily: 'var(--font-ui, system-ui, sans-serif)'
},
'.cm-scroller': { fontFamily: 'inherit' },
'.cm-content': { paddingBlock: '12px' }
})
]
})
})
viewRef.current = view
onViewReadyRef.current?.(view)
return () => {
onViewReadyRef.current?.(null)
view.destroy()
viewRef.current = null
host.replaceChildren()
}
}, [mountKey, showLineNumbers])
React.useEffect(() => {
const view = viewRef.current
if (!view) return
const cur = view.state.doc.toString()
if (cur !== value) {
view.dispatch({
changes: { from: 0, to: cur.length, insert: value },
selection: { anchor: 0 },
scrollIntoView: true
})
}
}, [value, mountKey])
React.useEffect(() => {
if (!gotoLine) return
const view = viewRef.current
if (!view) return
const lineNo = gotoLine.line + 1
if (lineNo < 1 || lineNo > view.state.doc.lines) return
const line = view.state.doc.line(lineNo)
view.dispatch({ selection: { anchor: line.from }, scrollIntoView: true })
}, [gotoLine])
return <div className="editor-host" ref={hostRef} />
}
)
+54
View File
@@ -0,0 +1,54 @@
import * as React from 'react'
import type { MarkdownEditorHandle } from './EditorPane'
const BUTTONS: { id: string; label: string }[] = [
{ id: 'h1', label: 'H1' },
{ id: 'h2', label: 'H2' },
{ id: 'h3', label: 'H3' },
{ id: 'h4', label: 'H4' },
{ id: 'h5', label: 'H5' },
{ id: 'h6', label: 'H6' },
{ id: 'bold', label: '加粗' },
{ id: 'italic', label: '斜体' },
{ id: 'strike', label: '删除线' },
{ id: 'code', label: '行内代码' },
{ id: 'codeBlock', label: '代码块' },
{ id: 'link', label: '链接' },
{ id: 'image', label: '图片' },
{ id: 'ul', label: '无序列表' },
{ id: 'ol', label: '有序列表' },
{ id: 'task', label: '任务' },
{ id: 'quote', label: '引用' },
{ id: 'hr', label: '分隔线' },
{ id: 'table', label: '表格' },
{ id: 'mark', label: '高亮' },
{ id: 'color', label: '颜色' },
{ id: 'sup', label: '上标' },
{ id: 'sub', label: '下标' },
{ id: 'kbd', label: '按键' },
{ id: 'details', label: '折叠' },
{ id: 'footnote', label: '脚注' }
]
type Props = {
editorRef: React.RefObject<MarkdownEditorHandle | null>
}
export function MarkdownFormatToolbar({ editorRef }: Props): React.ReactElement {
return (
<div className="md-format-wrap">
<div className="md-format-bar">
{BUTTONS.map((b) => (
<button
key={b.id}
type="button"
title={b.label}
onClick={() => void editorRef.current?.applyAction(b.id)}
>
{b.label}
</button>
))}
</div>
</div>
)
}
+110
View File
@@ -0,0 +1,110 @@
import * as React from 'react'
import type { OutlineItem } from './markdown'
import { buildOutlineTree, collectBranchIds, type OutlineTreeNode } from './outlineTree'
type Props = {
items: OutlineItem[]
onSelect: (item: OutlineItem) => void
}
export function OutlinePanel({ items, onSelect }: Props): React.ReactElement {
const tree = React.useMemo(() => buildOutlineTree(items), [items])
const branchIds = React.useMemo(() => collectBranchIds(tree), [tree])
const [collapsed, setCollapsed] = React.useState<Set<string>>(() => new Set())
React.useEffect(() => {
setCollapsed(new Set())
}, [items])
const toggleBranch = React.useCallback((id: string): void => {
setCollapsed((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const expandAll = React.useCallback((): void => {
setCollapsed(new Set())
}, [])
const collapseAll = React.useCallback((): void => {
setCollapsed(new Set(branchIds))
}, [branchIds])
const renderNode = (node: OutlineTreeNode): React.ReactElement => {
const hasChildren = node.children.length > 0
const isCollapsed = collapsed.has(node.id)
return (
<li key={node.id} className="outline-tree-node">
<div className="outline-tree-row">
{hasChildren ? (
<button
type="button"
className={`outline-fold-btn${isCollapsed ? ' is-collapsed' : ''}`}
aria-expanded={!isCollapsed}
aria-label={isCollapsed ? '展开子标题' : '收起子标题'}
title={isCollapsed ? '展开' : '收起'}
onClick={(e) => {
e.stopPropagation()
toggleBranch(node.id)
}}
>
{isCollapsed ? '▸' : '▾'}
</button>
) : (
<span className="outline-fold-spacer" aria-hidden />
)}
<button
type="button"
className={`outline-tree-title outline-tree-title--h${node.level}`}
title={node.text || node.displayText}
onClick={() => onSelect(node)}
>
{node.displayText}
</button>
</div>
{hasChildren && !isCollapsed ? (
<ul className="outline-tree">{node.children.map(renderNode)}</ul>
) : null}
</li>
)
}
if (items.length === 0) {
return <p className="outline-empty"></p>
}
const collapsedCount = collapsed.size
const branchCount = branchIds.length
return (
<div className="outline-panel-wrap">
<div className="outline-toolbar">
<span className="outline-toolbar-title">
{branchCount > 0 ? (
<span className="outline-toolbar-meta">
{' '}
· {branchCount - collapsedCount}/{branchCount}
</span>
) : null}
</span>
<button type="button" className="outline-toolbar-btn" onClick={expandAll} disabled={collapsedCount === 0}>
</button>
<button
type="button"
className="outline-toolbar-btn"
onClick={collapseAll}
disabled={branchCount === 0 || collapsedCount === branchCount}
>
</button>
</div>
<ul className="outline-tree outline-tree--root">{tree.map(renderNode)}</ul>
</div>
)
}
+258
View File
@@ -0,0 +1,258 @@
import * as React from 'react'
import mermaid from 'mermaid'
import { webVfs } from './platform/webVfs'
let mermaidInited = false
let mermaidSeq = 0
function ensureMermaid(): void {
if (mermaidInited) return
mermaid.initialize({
startOnLoad: false,
theme: 'neutral',
securityLevel: 'loose',
fontFamily: 'inherit',
flowchart: { useMaxWidth: true, htmlLabels: true },
sequence: { useMaxWidth: true, wrap: true },
er: { useMaxWidth: true },
gantt: { useMaxWidth: true }
})
mermaidInited = true
}
function mermaidSourceFromWrap(wrap: HTMLElement): string {
const enc = wrap.getAttribute('data-mermaid-src')
if (enc) {
try {
return decodeURIComponent(enc)
} catch {
/* fall through */
}
}
const pre = wrap.querySelector<HTMLElement>('pre.mermaid')
return pre?.textContent ?? ''
}
async function renderMermaidIn(root: HTMLElement, isStale: () => boolean): Promise<void> {
const wraps = root.querySelectorAll<HTMLElement>('.md-diagram-wrap')
if (wraps.length === 0) return
ensureMermaid()
for (const wrap of wraps) {
if (isStale()) return
const source = mermaidSourceFromWrap(wrap).trim()
if (!source) continue
delete wrap.dataset.mermaidRendered
let host = wrap.querySelector<HTMLElement>('.mermaid-render-host')
const pre = wrap.querySelector<HTMLElement>('pre.mermaid')
if (pre) {
host = document.createElement('div')
host.className = 'mermaid-render-host'
pre.replaceWith(host)
} else if (!host) {
host = document.createElement('div')
host.className = 'mermaid-render-host'
wrap.appendChild(host)
}
host.innerHTML = '<div class="mermaid-placeholder" aria-hidden></div>'
host.removeAttribute('data-mermaid-error')
const id = `mr-mmd-${++mermaidSeq}`
try {
const { svg, bindFunctions } = await mermaid.render(id, source)
if (isStale()) return
if (!root.isConnected || !wrap.isConnected) return
host.innerHTML = svg
bindFunctions?.(host)
wrap.dataset.mermaidRendered = '1'
} catch (err) {
if (isStale()) return
const msg = err instanceof Error ? err.message : String(err)
host.setAttribute('data-mermaid-error', msg)
host.innerHTML = `<pre class="mermaid-error">${escapeHtml(msg)}\n\n${escapeHtml(source.slice(0, 2000))}</pre>`
console.warn("[Huangzhijun's Reader] mermaid render failed", err)
}
}
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function isMarkdownPath(p: string): boolean {
return /\.(md|markdown|mdown|mkd)$/i.test(p)
}
function linkHref(anchor: HTMLAnchorElement): string | null {
return anchor.getAttribute('href') ?? anchor.getAttribute('data-md-href')
}
type Props = {
html: string
articleRef?: React.Ref<HTMLElement>
baseFilePath?: string | null
resolveRelativePath?: (baseFilePath: string, href: string) => Promise<string>
onOpenMarkdown?: (absolutePath: string, anchorId?: string) => void | Promise<void>
onOpenExternal?: (url: string) => void | Promise<void>
}
export function PreviewArticle({
html,
articleRef,
baseFilePath,
resolveRelativePath,
onOpenMarkdown,
onOpenExternal
}: Props): React.ReactElement {
const innerRef = React.useRef<HTMLElement | null>(null)
const renderGenRef = React.useRef(0)
const baseFilePathRef = React.useRef(baseFilePath)
baseFilePathRef.current = baseFilePath
const resolveRef = React.useRef(resolveRelativePath)
resolveRef.current = resolveRelativePath
const openMdRef = React.useRef(onOpenMarkdown)
openMdRef.current = onOpenMarkdown
const openExtRef = React.useRef(onOpenExternal)
openExtRef.current = onOpenExternal
const setRef = React.useCallback(
(el: HTMLElement | null) => {
innerRef.current = el
if (typeof articleRef === 'function') articleRef(el)
else if (articleRef && typeof articleRef === 'object' && 'current' in articleRef) {
;(articleRef as React.MutableRefObject<HTMLElement | null>).current = el
}
},
[articleRef]
)
const scrollToAnchor = React.useCallback((root: HTMLElement, anchorId: string): void => {
const id = decodeURIComponent(anchorId)
if (!id) return
let el: Element | null = null
try {
el = root.querySelector(`#${CSS.escape(id)}`)
} catch {
el = root.querySelector(`#${id}`)
}
if (!el) el = document.getElementById(id)
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}, [])
const handleClickCapture = React.useCallback((e: React.MouseEvent<HTMLElement>) => {
if (e.defaultPrevented || e.button !== 0) return
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
const root = innerRef.current
if (!root) return
const anchor = (e.target as Element | null)?.closest('a')
if (!anchor || !root.contains(anchor)) return
const href = linkHref(anchor)
if (!href) return
e.preventDefault()
e.stopPropagation()
void (async () => {
if (href.startsWith('#')) {
scrollToAnchor(root, href.slice(1))
return
}
const hashIdx = href.indexOf('#')
const pathPart = (hashIdx >= 0 ? href.slice(0, hashIdx) : href).trim()
const anchorId = hashIdx >= 0 ? href.slice(hashIdx + 1) : ''
if (!pathPart) {
if (anchorId) scrollToAnchor(root, anchorId)
return
}
if (/^https?:\/\//i.test(pathPart) || /^mailto:/i.test(pathPart) || /^tel:/i.test(pathPart)) {
await openExtRef.current?.(pathPart)
return
}
const base = baseFilePathRef.current
if (!base) {
window.alert('请先保存当前文件,再打开文内相对链接。')
return
}
const resolve = resolveRef.current
if (!resolve) return
const resolved = await resolve(base, pathPart)
if (!resolved) return
if (/^https?:\/\//i.test(resolved) || /^mailto:/i.test(resolved) || /^tel:/i.test(resolved)) {
await openExtRef.current?.(resolved)
return
}
if (isMarkdownPath(resolved)) {
await openMdRef.current?.(resolved, anchorId || undefined)
return
}
window.alert(`暂不支持打开该类型链接:${resolved}`)
})()
}, [scrollToAnchor])
React.useLayoutEffect(() => {
const root = innerRef.current
if (!root) return
const gen = ++renderGenRef.current
root.innerHTML = html
const isStale = (): boolean => gen !== renderGenRef.current
void (async () => {
for (const img of root.querySelectorAll<HTMLImageElement>('img')) {
if (isStale()) return
const disk = img.getAttribute('data-disk-path')
const src = img.getAttribute('src')
if (!disk && (!src || /^(blob:|data:|https?:)/i.test(src))) continue
const blob = await webVfs.resolveImageToBlobUrl(
disk || src || '',
baseFilePath ?? null
)
if (isStale()) return
if (blob) {
img.setAttribute('src', blob)
img.removeAttribute('data-img-error')
} else if (disk) {
img.setAttribute('data-img-error', '1')
img.setAttribute(
'title',
`无法加载图片:${disk}\n请将图片与 Markdown 放在同一目录后通过「打开」重新选择,或使用网络图片地址。`
)
img.alt = img.alt || '图片未加载'
}
}
})()
void renderMermaidIn(root, isStale).catch((err) => {
console.warn("[Huangzhijun's Reader] mermaid batch failed", err)
})
return () => {
renderGenRef.current += 1
}
}, [html, baseFilePath])
return (
<article ref={setRef} className="preview" onClickCapture={handleClickCapture} />
)
}
+493
View File
@@ -0,0 +1,493 @@
/* 背景(12):颜色变量,挂到 document.documentElement data-bg */
:root[data-bg='0'] {
--app-bg: #f3f4f6;
--panel-bg: #e5e7eb;
--text: #111827;
--muted: #6b7280;
--border: #d1d5db;
--accent: #2563eb;
--btn-bg: #ffffff;
--hover: rgba(0, 0, 0, 0.06);
--preview-bg: #ffffff;
--preview-text: #111827;
--preview-shadow: 0 8px 30px rgba(15, 23, 42, 0.08);
--code-bg: #f9fafb;
}
:root[data-bg='1'] {
--app-bg: #f4ecd8;
--panel-bg: #e8dfc8;
--text: #3e3428;
--muted: #7a6a58;
--border: #d2c7b2;
--accent: #8b4513;
--btn-bg: #fffaf0;
--hover: rgba(62, 52, 40, 0.08);
--preview-bg: #fffaf0;
--preview-text: #3e3428;
--preview-shadow: 0 8px 24px rgba(62, 52, 40, 0.12);
--code-bg: #f0e6d6;
}
:root[data-bg='2'] {
--app-bg: #0f172a;
--panel-bg: #111c33;
--text: #e5e7eb;
--muted: #94a3b8;
--border: #1e293b;
--accent: #38bdf8;
--btn-bg: #1e293b;
--hover: rgba(148, 163, 184, 0.12);
--preview-bg: #111827;
--preview-text: #e5e7eb;
--preview-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
--code-bg: #0b1224;
}
:root[data-bg='3'] {
--app-bg: #102216;
--panel-bg: #143220;
--text: #e7f7ec;
--muted: #8fb39a;
--border: #1f3b2a;
--accent: #4ade80;
--btn-bg: #163527;
--hover: rgba(74, 222, 128, 0.12);
--preview-bg: #132a1f;
--preview-text: #e7f7ec;
--preview-shadow: 0 10px 36px rgba(0, 0, 0, 0.45);
--code-bg: #0d1f16;
}
:root[data-bg='4'] {
--app-bg: linear-gradient(160deg, #e0f2fe 0%, #eef2ff 45%, #fdf4ff 100%);
--panel-bg: rgba(255, 255, 255, 0.72);
--text: #0f172a;
--muted: #475569;
--border: #cbd5f5;
--accent: #4f46e5;
--btn-bg: rgba(255, 255, 255, 0.9);
--hover: rgba(79, 70, 229, 0.1);
--preview-bg: rgba(255, 255, 255, 0.92);
--preview-text: #0f172a;
--preview-shadow: 0 12px 40px rgba(15, 23, 42, 0.12);
--code-bg: #f8fafc;
}
:root[data-bg='5'] {
--app-bg: #fafafa;
--panel-bg: #ffffff;
--text: #111111;
--muted: #737373;
--border: #e5e5e5;
--accent: #db2777;
--btn-bg: #ffffff;
--hover: rgba(219, 39, 119, 0.08);
--preview-bg: #ffffff;
--preview-text: #111111;
--preview-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
--code-bg: #f5f5f5;
}
:root[data-bg='6'] {
--app-bg: #121212;
--panel-bg: #1a1a1a;
--text: #fafafa;
--muted: #a3a3a3;
--border: #262626;
--accent: #f97316;
--btn-bg: #171717;
--hover: rgba(249, 115, 22, 0.15);
--preview-bg: #0a0a0a;
--preview-text: #fafafa;
--preview-shadow: 0 0 0 1px #262626;
--code-bg: #000000;
}
:root[data-bg='7'] {
--app-bg: #f5f3ff;
--panel-bg: #ede9fe;
--text: #312e81;
--muted: #6d28d9;
--border: #ddd6fe;
--accent: #7c3aed;
--btn-bg: #ffffff;
--hover: rgba(124, 58, 237, 0.1);
--preview-bg: #ffffff;
--preview-text: #312e81;
--preview-shadow: 0 10px 32px rgba(49, 46, 129, 0.12);
--code-bg: #f5f3ff;
}
:root[data-bg='8'] {
--app-bg: #1c1917;
--panel-bg: #292524;
--text: #fafaf9;
--muted: #a8a29e;
--border: #44403c;
--accent: #fbbf24;
--btn-bg: #292524;
--hover: rgba(251, 191, 36, 0.12);
--preview-bg: #1c1917;
--preview-text: #fafaf9;
--preview-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
--code-bg: #0c0a09;
}
:root[data-bg='9'] {
--app-bg: #1a1033;
--panel-bg: #221547;
--text: #f3e8ff;
--muted: #c4b5fd;
--border: #3b2a6b;
--accent: #a78bfa;
--btn-bg: #2a185f;
--hover: rgba(167, 139, 250, 0.15);
--preview-bg: #120c24;
--preview-text: #f3e8ff;
--preview-shadow: 0 14px 48px rgba(0, 0, 0, 0.55);
--code-bg: #0f0820;
}
:root[data-bg='10'] {
--app-bg: #e8f4fc;
--panel-bg: #d7eaf8;
--text: #0c4a6e;
--muted: #0369a1;
--border: #bae6fd;
--accent: #0284c7;
--btn-bg: #f0f9ff;
--hover: rgba(2, 132, 199, 0.12);
--preview-bg: #ffffff;
--preview-text: #0c4a6e;
--preview-shadow: 0 10px 28px rgba(12, 74, 110, 0.1);
--code-bg: #e0f2fe;
}
:root[data-bg='11'] {
--app-bg: #fdf2f4;
--panel-bg: #fce7eb;
--text: #881337;
--muted: #9f1239;
--border: #fbcfe8;
--accent: #db2777;
--btn-bg: #fff1f2;
--hover: rgba(219, 39, 119, 0.1);
--preview-bg: #ffffff;
--preview-text: #881337;
--preview-shadow: 0 10px 28px rgba(136, 19, 55, 0.08);
--code-bg: #ffe4e6;
}
:root[data-bg='12'] {
--app-bg: #18181b;
--panel-bg: #27272a;
--text: #fafafa;
--muted: #a1a1aa;
--border: #3f3f46;
--accent: #22d3ee;
--btn-bg: #27272a;
--hover: rgba(34, 211, 238, 0.12);
--preview-bg: #18181b;
--preview-text: #fafafa;
--preview-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
--code-bg: #09090b;
}
:root[data-bg='13'] {
--app-bg: #ecfeff;
--panel-bg: #cffafe;
--text: #0e7490;
--muted: #0891b2;
--border: #a5f3fc;
--accent: #0d9488;
--btn-bg: #f0fdfa;
--hover: rgba(13, 148, 136, 0.12);
--preview-bg: #ffffff;
--preview-text: #134e4a;
--preview-shadow: 0 10px 28px rgba(8, 145, 178, 0.1);
--code-bg: #ccfbf1;
}
:root[data-bg='14'] {
--app-bg: #faf5f0;
--panel-bg: #ede4d8;
--text: #422006;
--muted: #92400e;
--border: #d6c4b0;
--accent: #b45309;
--btn-bg: #fffbeb;
--hover: rgba(180, 83, 9, 0.1);
--preview-bg: #fffbeb;
--preview-text: #422006;
--preview-shadow: 0 10px 28px rgba(66, 32, 6, 0.08);
--code-bg: #fef3c7;
}
:root[data-bg='15'] {
--app-bg: #f0fdfa;
--panel-bg: #ccfbf1;
--text: #115e59;
--muted: #0f766e;
--border: #99f6e4;
--accent: #0d9488;
--btn-bg: #ecfdf5;
--hover: rgba(13, 148, 136, 0.12);
--preview-bg: #ffffff;
--preview-text: #134e4a;
--preview-shadow: 0 10px 28px rgba(17, 94, 89, 0.08);
--code-bg: #d1fae5;
}
:root[data-bg='16'] {
--app-bg: #f5f3ff;
--panel-bg: #ede9fe;
--text: #4c1d95;
--muted: #6d28d9;
--border: #ddd6fe;
--accent: #7c3aed;
--btn-bg: #faf5ff;
--hover: rgba(124, 58, 237, 0.1);
--preview-bg: #ffffff;
--preview-text: #4c1d95;
--preview-shadow: 0 10px 28px rgba(76, 29, 149, 0.08);
--code-bg: #ede9fe;
}
:root[data-bg='17'] {
--app-bg: #faf8f5;
--panel-bg: #f3efe8;
--text: #44403c;
--muted: #78716c;
--border: #e7e5e4;
--accent: #d97706;
--btn-bg: #ffffff;
--hover: rgba(217, 119, 6, 0.1);
--preview-bg: #ffffff;
--preview-text: #44403c;
--preview-shadow: 0 10px 28px rgba(68, 64, 60, 0.08);
--code-bg: #f5f5f4;
}
:root[data-bg='18'] {
--app-bg: #1c1917;
--panel-bg: #292524;
--text: #fafaf9;
--muted: #a8a29e;
--border: #44403c;
--accent: #eab308;
--btn-bg: #292524;
--hover: rgba(234, 179, 8, 0.12);
--preview-bg: #1c1917;
--preview-text: #fafaf9;
--preview-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
--code-bg: #0c0a09;
}
:root[data-bg='19'] {
--app-bg: #fdf4f8;
--panel-bg: #fce7f3;
--text: #831843;
--muted: #be185d;
--border: #fbcfe8;
--accent: #db2777;
--btn-bg: #fff1f2;
--hover: rgba(219, 39, 119, 0.1);
--preview-bg: #ffffff;
--preview-text: #831843;
--preview-shadow: 0 10px 28px rgba(131, 24, 67, 0.08);
--code-bg: #ffe4e6;
}
:root[data-bg='20'] {
--app-bg: #f1f5f9;
--panel-bg: #e2e8f0;
--text: #1e293b;
--muted: #64748b;
--border: #cbd5e1;
--accent: #475569;
--btn-bg: #ffffff;
--hover: rgba(71, 85, 105, 0.12);
--preview-bg: #ffffff;
--preview-text: #1e293b;
--preview-shadow: 0 10px 28px rgba(30, 41, 59, 0.1);
--code-bg: #f8fafc;
}
:root[data-bg='21'] {
--app-bg: #ecfdf5;
--panel-bg: #d1fae5;
--text: #14532d;
--muted: #166534;
--border: #bbf7d0;
--accent: #15803d;
--btn-bg: #f0fdf4;
--hover: rgba(21, 128, 61, 0.12);
--preview-bg: #ffffff;
--preview-text: #14532d;
--preview-shadow: 0 10px 28px rgba(20, 83, 45, 0.08);
--code-bg: #dcfce7;
}
/* Kindle 风格:暖灰纸面 + 轻微颗粒感(纯 CSS) */
:root[data-bg='22'] {
--app-bg: #e8e2d8;
--panel-bg: rgba(245, 241, 234, 0.94);
--text: #2d2a26;
--muted: #6b6560;
--border: #c9c2b8;
--accent: #c2410c;
--btn-bg: #faf6ef;
--hover: rgba(194, 65, 12, 0.08);
--preview-bg: #f7f3eb;
--preview-text: #1f1c18;
--preview-shadow: 0 10px 28px rgba(45, 42, 38, 0.1);
--code-bg: #ede8df;
}
/* 字体:仅字体栈 */
:root[data-font='0'] {
--font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-editor: ui-monospace, Menlo, Consolas, monospace;
--font-preview: Georgia, 'Times New Roman', 'Noto Serif SC', serif;
}
:root[data-font='1'] {
--font-ui: Georgia, 'Times New Roman', serif;
--font-editor: Consolas, 'Courier New', monospace;
--font-preview: 'Palatino Linotype', Palatino, 'Noto Serif SC', serif;
}
:root[data-font='2'] {
--font-ui: system-ui, sans-serif;
--font-editor: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
--font-preview: 'Source Serif 4', Cambria, Georgia, serif;
}
:root[data-font='3'] {
--font-ui: 'Microsoft YaHei', 'PingFang SC', sans-serif;
--font-editor: 'Sarasa Mono SC', Consolas, monospace;
--font-preview: 'Songti SC', SimSun, 'Noto Serif SC', serif;
}
:root[data-font='4'] {
--font-ui: 'IBM Plex Sans', system-ui, sans-serif;
--font-editor: 'IBM Plex Mono', Consolas, monospace;
--font-preview: 'IBM Plex Serif', Georgia, serif;
}
:root[data-font='5'] {
--font-ui: 'Helvetica Neue', Helvetica, Arial, sans-serif;
--font-editor: Menlo, Monaco, Consolas, monospace;
--font-preview: 'Helvetica Neue', Arial, sans-serif;
}
:root[data-font='6'] {
--font-ui: Verdana, Geneva, sans-serif;
--font-editor: 'Lucida Console', monospace;
--font-preview: Georgia, Cambria, serif;
}
:root[data-font='7'] {
--font-ui: 'Trebuchet MS', sans-serif;
--font-editor: 'Courier New', Courier, monospace;
--font-preview: 'Book Antiqua', Palatino, serif;
}
:root[data-font='8'] {
--font-ui: 'Segoe UI', system-ui, sans-serif;
--font-editor: 'Cascadia Code', 'Cascadia Mono', Consolas, monospace;
--font-preview: Constantia, 'Times New Roman', serif;
}
:root[data-font='9'] {
--font-ui: 'Segoe UI Variable', 'Segoe UI', sans-serif;
--font-editor: 'Cascadia Mono', Consolas, monospace;
--font-preview: 'Sitka Text', Georgia, serif;
}
:root[data-font='10'] {
--font-ui: 'Noto Sans SC', 'Microsoft YaHei', 'PingFang SC', sans-serif;
--font-editor: 'Noto Sans Mono CJK SC', 'Sarasa Mono SC', Consolas, monospace;
--font-preview: 'Noto Serif SC', 'Source Han Serif SC', SimSun, serif;
}
:root[data-font='11'] {
--font-ui: 'Hiragino Sans GB', 'Microsoft YaHei', system-ui, sans-serif;
--font-editor: ui-monospace, monospace;
--font-preview: 'STKaiti', KaiTi, 'Noto Serif SC', serif;
}
:root[data-font='12'] {
--font-ui: 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
--font-editor: 'Menlo', 'Monaco', Consolas, monospace;
--font-preview: 'Songti SC', SimSun, 'Noto Serif SC', serif;
}
:root[data-font='13'] {
--font-ui: 'Hiragino Sans GB', 'PingFang SC', sans-serif;
--font-editor: 'SF Mono', Menlo, Consolas, monospace;
--font-preview: 'Hiragino Mincho ProN', 'Songti SC', serif;
}
:root[data-font='14'] {
--font-ui: 'LXGW WenKai', 'KaiTi', 'Microsoft YaHei', sans-serif;
--font-editor: 'JetBrains Mono', Consolas, monospace;
--font-preview: 'LXGW WenKai', 'KaiTi', 'Noto Serif SC', serif;
}
:root[data-font='15'] {
--font-ui: 'HarmonyOS Sans', 'Microsoft YaHei', system-ui, sans-serif;
--font-editor: 'HarmonyOS Sans Mono', Consolas, monospace;
--font-preview: 'HarmonyOS Serif', 'Noto Serif SC', serif;
}
:root[data-font='16'] {
--font-ui: 'OPPO Sans', 'Microsoft YaHei', system-ui, sans-serif;
--font-editor: 'OPPO Sans Mono', Consolas, monospace;
--font-preview: 'Source Han Serif SC', SimSun, serif;
}
:root[data-font='17'] {
--font-ui: 'MiSans', 'Microsoft YaHei', system-ui, sans-serif;
--font-editor: 'MiSans Mono', Consolas, monospace;
--font-preview: 'Noto Serif SC', 'Source Han Serif SC', serif;
}
:root[data-font='18'] {
--font-ui: 'Alibaba PuHuiTi', 'Microsoft YaHei', sans-serif;
--font-editor: 'JetBrains Mono', Consolas, monospace;
--font-preview: 'Alibaba PuHuiTi', 'Noto Serif SC', serif;
}
:root[data-font='19'] {
--font-ui: 'Smiley Sans', 'Microsoft YaHei', sans-serif;
--font-editor: 'JetBrains Mono', Consolas, monospace;
--font-preview: 'Smiley Sans', 'Noto Serif SC', serif;
}
:root[data-font='20'] {
--font-ui: system-ui, sans-serif;
--font-editor: 'Maple Mono', 'Cascadia Code', Consolas, monospace;
--font-preview: 'Source Serif 4', Georgia, serif;
}
:root[data-font='21'] {
--font-ui: 'Fira Sans', system-ui, sans-serif;
--font-editor: 'Fira Code', 'Fira Mono', Consolas, monospace;
--font-preview: 'Fira Sans', Georgia, serif;
}
:root[data-font='22'] {
--font-ui: 'Roboto', system-ui, sans-serif;
--font-editor: 'Roboto Mono', Consolas, monospace;
--font-preview: 'Roboto Slab', Georgia, serif;
}
:root[data-font='23'] {
--font-ui: 'Open Sans', system-ui, sans-serif;
--font-editor: 'Source Code Pro', Consolas, monospace;
--font-preview: 'Merriweather', Georgia, serif;
}
:root[data-font='24'] {
--font-ui: 'Lato', system-ui, sans-serif;
--font-editor: 'Ubuntu Mono', Consolas, monospace;
--font-preview: 'Lora', Georgia, serif;
}
:root[data-font='25'] {
--font-ui: 'Montserrat', system-ui, sans-serif;
--font-editor: 'Space Mono', Consolas, monospace;
--font-preview: 'Playfair Display', Georgia, serif;
}
:root[data-font='26'] {
--font-ui: 'Merriweather Sans', system-ui, sans-serif;
--font-editor: 'Inconsolata', Consolas, monospace;
--font-preview: 'Merriweather', Georgia, serif;
}
:root[data-font='27'] {
--font-ui: system-ui, sans-serif;
--font-editor: 'Source Code Pro', monospace;
--font-preview: 'Crimson Text', 'Times New Roman', serif;
}
:root[data-font='28'] {
--font-ui: 'Literata', Georgia, serif;
--font-editor: 'IBM Plex Mono', Consolas, monospace;
--font-preview: 'Literata', Georgia, serif;
}
:root[data-font='29'] {
--font-ui: system-ui, sans-serif;
--font-editor: 'Inconsolata', 'Consolas', monospace;
--font-preview: Georgia, 'Noto Serif SC', serif;
}
:root[data-font='30'] {
--font-ui: system-ui, sans-serif;
--font-editor: 'Anonymous Pro', 'Courier New', monospace;
--font-preview: 'Palatino Linotype', Palatino, serif;
}
:root[data-font='31'] {
--font-ui: system-ui, sans-serif;
--font-editor: 'Fantasque Sans Mono', Consolas, monospace;
--font-preview: 'Charter', 'Noto Serif SC', serif;
}
:root[data-bg='4'] .toolbar,
:root[data-bg='4'] .tab-bar,
:root[data-bg='4'] .sidebar {
backdrop-filter: blur(8px);
}
+74
View File
@@ -0,0 +1,74 @@
/** 背景 id 022,对应 `appearance-styles.css` 中 `:root[data-bg]`。 */
export const BACKGROUNDS: { id: string; name: string }[] = [
{ id: '0', name: '云雾灰' },
{ id: '1', name: '羊皮纸' },
{ id: '2', name: '深海蓝' },
{ id: '3', name: '森绿' },
{ id: '4', name: '极光渐变' },
{ id: '5', name: '极简粉' },
{ id: '6', name: '暗夜橙' },
{ id: '7', name: '薰衣草' },
{ id: '8', name: '暖石褐' },
{ id: '9', name: '星夜紫' },
{ id: '10', name: '霜青' },
{ id: '11', name: '玫瑰灰' },
{ id: '12', name: '石墨黑' },
{ id: '13', name: '薄荷冰' },
{ id: '14', name: '焦糖拿铁' },
{ id: '15', name: '青瓷' },
{ id: '16', name: '暮光紫灰' },
{ id: '17', name: '沙岸米' },
{ id: '18', name: '松烟墨' },
{ id: '19', name: '樱粉雾' },
{ id: '20', name: '钢蓝灰' },
{ id: '21', name: '苔痕绿' },
{ id: '22', name: 'Kindle 纸感' }
]
/** 字体 id 031,对应 `:root[data-font]`。 */
export const FONTS: { id: string; name: string }[] = [
{ id: '0', name: '系统默认' },
{ id: '1', name: '衬线阅读' },
{ id: '2', name: '等宽代码' },
{ id: '3', name: '人文宋黑' },
{ id: '4', name: 'IBM Plex' },
{ id: '5', name: 'Helvetica 系' },
{ id: '6', name: 'Georgia 系' },
{ id: '7', name: 'Verdana 系' },
{ id: '8', name: 'Courier 打字机' },
{ id: '9', name: 'Segoe UI 系' },
{ id: '10', name: '思源黑体优先' },
{ id: '11', name: '圆体休闲' },
{ id: '12', name: '苹方优先' },
{ id: '13', name: '冬青黑体' },
{ id: '14', name: '霞鹜文楷' },
{ id: '15', name: 'HarmonyOS Sans' },
{ id: '16', name: 'OPPO Sans' },
{ id: '17', name: '小米兰亭' },
{ id: '18', name: '阿里巴巴普惠' },
{ id: '19', name: '得意黑' },
{ id: '20', name: 'Maple Mono' },
{ id: '21', name: 'Fira Sans + Mono' },
{ id: '22', name: 'Roboto 系' },
{ id: '23', name: 'Open Sans 系' },
{ id: '24', name: 'Lato 系' },
{ id: '25', name: 'Montserrat 系' },
{ id: '26', name: 'Merriweather 阅读' },
{ id: '27', name: 'Crimson Text' },
{ id: '28', name: 'Literata 阅读' },
{ id: '29', name: 'Inconsolata 等宽' },
{ id: '30', name: 'Anonymous Pro' },
{ id: '31', name: 'Fantasque Sans Mono' }
]
export type ViewMode = 'edit' | 'read' | 'hybrid'
export function validBackgroundId(s: string | null): string | null {
if (s === null) return null
return /^([0-9]|1[0-9]|2[0-2])$/.test(s) ? s : null
}
export function validFontId(s: string | null): string | null {
if (s === null) return null
return /^([0-9]|[12][0-9]|3[01])$/.test(s) ? s : null
}
+11
View File
@@ -0,0 +1,11 @@
/** 颜色选择器预设(按行分组展示) */
export const COLOR_SWATCH_ROWS: string[][] = [
['#000000', '#1c1917', '#292524', '#44403c', '#57534e', '#78716c', '#a8a29e', '#d6d3d1'],
['#450a0a', '#7f1d1d', '#b91c1c', '#dc2626', '#f87171', '#fecaca', '#fee2e2', '#fef2f2'],
['#431407', '#9a3412', '#c2410c', '#ea580c', '#fb923c', '#fed7aa', '#ffedd5', '#fff7ed'],
['#422006', '#a16207', '#ca8a04', '#eab308', '#fde047', '#fef9c3', '#fefce8', '#fefce8'],
['#14532d', '#166534', '#15803d', '#22c55e', '#86efac', '#bbf7d0', '#dcfce7', '#f0fdf4'],
['#134e4a', '#0f766e', '#0d9488', '#14b8a6', '#5eead4', '#ccfbf1', '#ecfdf5', '#f0fdfa'],
['#1e3a8a', '#1d4ed8', '#2563eb', '#3b82f6', '#93c5fd', '#bfdbfe', '#dbeafe', '#eff6ff'],
['#4c1d95', '#6d28d9', '#7c3aed', '#8b5cf6', '#c4b5fd', '#ddd6fe', '#ede9fe', '#f5f3ff']
]
+28
View File
@@ -0,0 +1,28 @@
/**
* 当 File System Access「另存为」不可用时(典型:HTTP 公网域名),
* 用浏览器下载代替,避免保存流程完全失败。
*/
export function downloadTextFile(fileName: string, content: string): void {
const safe = fileName.replace(/[/\\<>|:*?"\u0000-\u001f]/g, '_').trim() || '未命名.md'
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = safe
a.style.display = 'none'
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
/** 当前页面不在「安全上下文」时(如 http://域名,非 localhost),FSA 不可用。 */
export function lacksFileSystemAccessContext(): boolean {
return typeof globalThis.isSecureContext === 'boolean' && !globalThis.isSecureContext
}
export function fileNameFromPath(filePath: string | null): string {
if (!filePath) return '未命名.md'
const base = filePath.replace(/\\/g, '/').split('/').pop()?.trim() || '未命名.md'
return base || '未命名.md'
}
+39
View File
@@ -0,0 +1,39 @@
/// <reference types="vite/client" />
import type {
ListDirResult,
OpenFileResult,
OpenFolderResult,
OpenImageResult,
SaveDialogResult
} from './types/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
showOpenFilePicker?: (options?: OpenFilePickerOptions) => Promise<FileSystemFileHandle[]>
showSaveFilePicker?: (options?: SaveFilePickerOptions) => Promise<FileSystemFileHandle>
showDirectoryPicker?: (options?: DirectoryPickerOptions) => Promise<FileSystemDirectoryHandle>
}
}
export {}
+1583
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import { validBackgroundId, validFontId } from './appearance'
import { installBrowserApi } from './platform/browserApi'
import './appearance-styles.css'
import './index.css'
installBrowserApi()
{
const bg =
localStorage.getItem('myreader-bg-id') ??
localStorage.getItem('myreader-theme') ??
localStorage.getItem('myreader-bg') ??
'1'
const font = localStorage.getItem('myreader-font-id') ?? '0'
const line = localStorage.getItem('myreader-preview-line-height')
const lineN = line ? Number.parseFloat(line) : NaN
const lineH = Number.isFinite(lineN) ? String(Math.min(3, Math.max(1, lineN))) : '1.5'
document.documentElement.dataset.bg = validBackgroundId(bg) ?? '1'
document.documentElement.dataset.font = validFontId(font) ?? '0'
document.documentElement.style.setProperty('--preview-line-height', lineH)
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)
+406
View File
@@ -0,0 +1,406 @@
import MarkdownIt from 'markdown-it'
import multimdTable from 'markdown-it-multimd-table'
import hljs from 'highlight.js'
import DOMPurify from 'dompurify'
import { getCachedImageBlobUrl, webVfs } from './platform/webVfs'
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
if (/^blob:/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>`
}
/** 1×1 透明占位图:浏览器禁止在 <img> 中直接加载 file://。 */
const IMG_PLACEHOLDER =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
function resolveImageDiskPath(src: string, baseFilePath: string | null | undefined): string | null {
return webVfs.resolveImageAbsPath(src, baseFilePath?.trim() || null)
}
function normalizeImageSrc(src: string, baseFilePath: string | null | undefined): string {
const s = src.trim()
if (!s) return s
if (/^(blob:|data:|https?:)/i.test(s)) return s
const absPath = resolveImageDiskPath(s, baseFilePath)
if (!absPath) return s
const hit = getCachedImageBlobUrl(absPath)
if (hit) return hit
return IMG_PLACEHOLDER
}
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) {
const disk = resolveImageDiskPath(src, base)
if (disk) token.attrSet('data-disk-path', disk)
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|blob):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i
}
const defaultLinkOpen = md.renderer.rules.link_open
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
const token = tokens[idx]!
const href = token.attrGet('href') ?? ''
if (href && !/^https?:\/\//i.test(href) && !href.startsWith('#') && !href.startsWith('mailto:')) {
token.attrSet('class', 'md-internal-link')
token.attrSet('data-md-href', href)
}
if (defaultLinkOpen) return defaultLinkOpen(tokens, idx, options, env, self)
return self.renderToken(tokens, idx, options)
}
export function slugForLine(line: number): string {
return `mr-line-${line}`
}
export type RenderMarkdownEnv = {
baseFilePath?: string | null
}
export function renderMarkdown(src: string, env?: RenderMarkdownEnv): string {
const input = typeof src === 'string' ? src : ''
const renderEnv = env ?? {}
try {
const raw = md.render(input, renderEnv)
try {
return DOMPurify.sanitize(raw, purifyOpts)
} catch (e) {
console.warn('[MyReader] DOMPurify failed', e)
return `<p>${md.utils.escapeHtml(raw.slice(0, 8000))}</p>`
}
} catch (e) {
console.warn('[MyReader] markdown render failed', e)
return `<pre>${md.utils.escapeHtml(input.slice(0, 8000))}</pre>`
}
}
export type OutlineItem = {
level: number
text: string
displayText: string
/** 0-based source line */
line: number
slug: string
}
function outlineDisplayText(raw: string): string {
let s = raw
s = s.replace(/\*\*([^*]+)\*\*/g, '$1')
s = s.replace(/\*([^*]+)\*/g, '$1')
s = s.replace(/`([^`]+)`/g, '$1')
s = s.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
s = s.replace(/<[^>]+>/g, '')
s = s.replace(/\s+/g, ' ').trim()
return s
}
/** 分隔线、纯符号等不应出现在大纲。 */
export function isValidOutlineHeading(displayText: string, rawLine: string): boolean {
const t = displayText.trim()
if (!t || t === '(空标题)') return false
if (/^[-—–_=~*`#>\s]+$/.test(t)) return false
const body = rawLine.replace(/^#{1,6}\s*/, '').trim()
if (/^[-—–_=~*\s]+$/.test(body)) return false
return true
}
/** 仅从 ATX `#` 标题提取;`line` 为 0-based。 */
export function extractOutline(src: string): OutlineItem[] {
const input = typeof src === 'string' ? src : ''
const lines = input.split(/\r?\n/)
const out: OutlineItem[] = []
for (let i = 0; i < lines.length; i++) {
const raw = lines[i] ?? ''
const m = /^(#{1,6})\s+(.+)$/.exec(raw)
if (!m) continue
const level = m[1]!.length
const text = m[2]!.trim()
const displayText = outlineDisplayText(text) || '(空标题)'
if (!isValidOutlineHeading(displayText, raw)) continue
out.push({
level,
text,
displayText,
line: i,
slug: slugForLine(i)
})
}
return out
}
export type SourceLineRegion = {
line: number
lineEnd: number
el: HTMLElement
}
export function collectSourceLineRegions(article: HTMLElement): SourceLineRegion[] {
const nodes = article.querySelectorAll<HTMLElement>('[data-source-line]')
const regions: SourceLineRegion[] = []
for (const el of nodes) {
const start = Number(el.getAttribute('data-source-line'))
if (!Number.isFinite(start)) continue
const endRaw = el.getAttribute('data-source-line-end')
const end = endRaw !== null && Number.isFinite(Number(endRaw)) ? Number(endRaw) : start
regions.push({ line: start, lineEnd: Math.max(start, end), el })
}
regions.sort((a, b) => a.line - b.line)
return regions
}
/** 阅读区中与源行号对应的元素(用于高亮闪烁)。 */
export function findPreviewAnchorForLine(
article: HTMLElement,
line: number
): HTMLElement | null {
return finestElementForLine(article, line) ?? article.querySelector<HTMLElement>(`#${slugForLine(line)}`)
}
function finestElementForLine(article: HTMLElement, line: number): HTMLElement | null {
const nodes = article.querySelectorAll<HTMLElement>(`[data-source-line="${line}"]`)
for (const el of nodes) {
const start = Number(el.getAttribute('data-source-line'))
if (start !== line) continue
const endRaw = el.getAttribute('data-source-line-end')
const end = endRaw !== null && Number.isFinite(Number(endRaw)) ? Number(endRaw) : start
if (end > start) continue
return el
}
return null
}
/**
* shell px
* `line` 0-based
*/
export function previewLineCenterInShell(
article: HTMLElement,
shell: HTMLElement,
line: number,
totalSourceLines: number
): number | null {
const shellRect = shell.getBoundingClientRect()
const exact = finestElementForLine(article, line)
if (exact) {
const r = exact.getBoundingClientRect()
return r.top - shellRect.top + r.height / 2
}
const regions = collectSourceLineRegions(article)
if (regions.length === 0) {
const h = article.offsetHeight || 1
return ((line + 0.5) / Math.max(1, totalSourceLines)) * h
}
let region = regions[0]!
for (const r of regions) {
if (r.line <= line) region = r
else break
}
if (line >= region.line && line <= region.lineEnd) {
const span = Math.max(1, region.lineEnd - region.line + 1)
const offset = Math.max(0, Math.min(line - region.line, span - 1))
const frac = (offset + 0.5) / span
const blockRect = region.el.getBoundingClientRect()
return blockRect.top - shellRect.top + frac * blockRect.height
}
const blockRect = region.el.getBoundingClientRect()
return blockRect.top - shellRect.top + blockRect.height / 2
}
export function scrollPreviewToSourceLine(
scrollEl: HTMLElement,
article: HTMLElement,
line: number,
behavior: ScrollBehavior = 'smooth',
totalSourceLines?: number
): HTMLElement | null {
const total = totalSourceLines ?? article.querySelectorAll('[data-source-line]').length
const shell = article.closest('.preview-shell') ?? article.parentElement
const anchor = findPreviewAnchorForLine(article, line)
if (!anchor && !shell) return null
const scrollRect = scrollEl.getBoundingClientRect()
let top: number
if (anchor) {
const anchorRect = anchor.getBoundingClientRect()
top = anchorRect.top - scrollRect.top + scrollEl.scrollTop - 20
anchor.classList.add('preview-anchor-flash')
window.setTimeout(() => anchor.classList.remove('preview-anchor-flash'), 1600)
} else if (shell instanceof HTMLElement) {
const center = previewLineCenterInShell(article, shell, line, total)
top = (center ?? 0) - 40
} else {
return null
}
scrollEl.scrollTo({ top: Math.max(0, top), behavior })
return anchor
}
/** @deprecated 使用 previewLineCenterInShell */
export function anchorCenterInShell(anchor: HTMLElement, shell: HTMLElement): number {
const shellRect = shell.getBoundingClientRect()
const r = anchor.getBoundingClientRect()
return r.top - shellRect.top + r.height / 2
}
+39
View File
@@ -0,0 +1,39 @@
import type { OutlineItem } from './markdown'
export type OutlineTreeNode = OutlineItem & {
id: string
children: OutlineTreeNode[]
}
export function buildOutlineTree(items: OutlineItem[]): OutlineTreeNode[] {
const root: OutlineTreeNode[] = []
const stack: OutlineTreeNode[] = []
for (const item of items) {
const node: OutlineTreeNode = {
...item,
id: `o-${item.line}`,
children: []
}
while (stack.length > 0 && stack[stack.length - 1]!.level >= node.level) {
stack.pop()
}
if (stack.length === 0) root.push(node)
else stack[stack.length - 1]!.children.push(node)
stack.push(node)
}
return root
}
export function collectBranchIds(nodes: OutlineTreeNode[]): string[] {
const ids: string[] = []
const walk = (list: OutlineTreeNode[]): void => {
for (const n of list) {
if (n.children.length > 0) {
ids.push(n.id)
walk(n.children)
}
}
}
walk(nodes)
return ids
}
+177
View File
@@ -0,0 +1,177 @@
/** 轻量路径工具,供渲染进程 Markdown 使用(避免依赖 node:path 打包问题)。 */
/** 是否为 Windows 磁盘绝对路径(如 `C:/Users/...`)。 */
export function isAbsDiskPath(p: string): boolean {
return /^[A-Za-z]:\//.test(p.replace(/\\/g, '/'))
}
/** 归一化为正斜杠绝对路径(保留盘符)。 */
export function normalizeAbsPath(p: string): string {
let n = p.trim().replace(/\\/g, '/')
if (!n) return n
const win = n.match(/^([A-Za-z]:)\/(.*)$/)
if (win) {
const drive = win[1]!.toUpperCase()
const tail = normalizeDotSegments(win[2] ?? '')
return tail ? `${drive}/${tail}` : `${drive}/`
}
if (n.startsWith('/')) {
const tail = normalizeDotSegments(n.slice(1))
return tail ? `/${tail}` : '/'
}
return normalizeDotSegments(n)
}
export function joinAbsPath(dir: string, rel: string): string {
const base = normalizeAbsPath(dir).replace(/\/+$/, '')
const tail = rel.replace(/^[/\\]+/, '')
return tail ? normalizeAbsPath(`${base}/${tail}`) : base
}
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)
}
/** VFS 根路径,如 `/项目名`。 */
export function vfsRootFromPath(filePath: string): string {
const parts = filePath.replace(/\\/g, '/').split('/').filter(Boolean)
return parts.length > 0 ? `/${parts[0]}` : '/'
}
export function joinVfsPath(dir: string, rel: string): string {
const base = dir.replace(/\\/g, '/').replace(/\/+$/, '')
const tail = rel.replace(/^\/+/, '')
return tail ? `${base}/${tail}` : base
}
/** 归一化 VFS 路径(`/a/../b` → `/b`)。 */
export function normalizeVfsPath(p: string): string {
const parts = p.replace(/\\/g, '/').split('/').filter(Boolean)
const st: string[] = []
for (const part of parts) {
if (part === '..') {
if (st.length) st.pop()
} else if (part !== '.') {
st.push(part)
}
}
return st.length ? `/${st.join('/')}` : '/'
}
/**
* Markdown VFS Web
* `/plans/foo.md`
*/
export function resolveVfsPath(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 base = baseFilePath.replace(/\\/g, '/')
if (/^https?:\/\//i.test(h) || /^mailto:/i.test(h) || /^tel:/i.test(h)) return h
if (/^(blob:|data:|file:)/i.test(h)) return h
if (/^[A-Za-z]:\//i.test(h)) return normalizeVfsPath(h)
if (h.startsWith('/')) {
const root = vfsRootFromPath(base)
return normalizeVfsPath(joinVfsPath(root, h.slice(1)))
}
const dir = dirnamePath(base)
return normalizeVfsPath(joinVfsPath(dir, h))
}
/** `file:///C:/path/to.png` → `C:/path/to.png` */
export function fileUrlToPath(url: string): string {
let u = url.trim()
if (!/^file:/i.test(u)) return u
u = u.replace(/^file:\/+/, '')
if (/^\/[A-Za-z]:/.test(u)) u = u.slice(1)
try {
return decodeURIComponent(u)
} catch {
return u
}
}
export function pathBasename(p: string): string {
const n = p.replace(/\\/g, '/').replace(/\/+$/, '')
const i = n.lastIndexOf('/')
return i >= 0 ? n.slice(i + 1) : n
}
/** Windows 下路径比较(忽略大小写)。 */
export function pathsEqual(a: string, b: string): boolean {
const na = normalizeAbsPath(a)
const nb = normalizeAbsPath(b)
if (na === nb) return true
if (isAbsDiskPath(na) && isAbsDiskPath(nb)) {
return na.toLowerCase() === nb.toLowerCase()
}
return false
}
/** 绝对路径 → file:// URLWindows / Unix)。 */
export function absolutePathToFileUrl(absPath: string): string {
const norm = absPath.replace(/\\/g, '/').trim()
if (!norm) return ''
if (/^[A-Za-z]:\//.test(norm)) {
return `file:///${encodeURI(norm)}`
}
const withLead = norm.startsWith('/') ? norm : `/${norm}`
return `file://${encodeURI(withLead)}`
}
+294
View File
@@ -0,0 +1,294 @@
import type { MyReaderApi } from '../env'
import type {
ListDirResult,
OpenFileResult,
OpenFolderResult,
OpenImageResult,
SaveDialogResult
} from '../types/ipc-types'
import {
dirnamePath,
isAbsDiskPath,
joinAbsPath,
normalizeAbsPath,
resolvePathFromBaseFile
} from '../pathLite'
import { askDiskPath, rememberedAbsRoot } from './pathPrompt'
import { supportsFileSystemAccess, webVfs } from './webVfs'
const MD_PICKER = {
description: 'Markdown',
accept: { 'text/markdown': ['.md', '.markdown', '.mdown', '.mkd'] }
}
const IMAGE_PICKER = {
description: '图片',
accept: {
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif']
}
}
function pickFilesViaInput(accept: string, multiple: boolean): Promise<FileList | null> {
return new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
input.accept = accept
input.multiple = multiple
input.style.display = 'none'
input.onchange = () => {
resolve(input.files)
input.remove()
}
input.oncancel = () => {
resolve(null)
input.remove()
}
document.body.appendChild(input)
input.click()
})
}
const menuHandlers = new Set<(action: string) => void>()
function emitMenu(action: string): void {
for (const h of menuHandlers) h(action)
}
function installKeyboardShortcuts(): void {
window.addEventListener('keydown', (e) => {
if (!e.ctrlKey && !e.metaKey) return
const k = e.key.toLowerCase()
if (k === 'o' && !e.shiftKey) {
e.preventDefault()
emitMenu('file-open')
} else if (k === 's' && e.shiftKey) {
e.preventDefault()
emitMenu('file-save-as')
} else if (k === 's') {
e.preventDefault()
emitMenu('file-save')
} else if (k === 't') {
e.preventDefault()
emitMenu('tab-new')
} else if (k === 'w') {
e.preventDefault()
emitMenu('tab-close')
} else if (k === 'e') {
e.preventDefault()
emitMenu('toggle-read')
} else if (k === 'tab' && e.shiftKey) {
e.preventDefault()
emitMenu('tab-prev')
} else if (k === 'tab') {
e.preventDefault()
emitMenu('tab-next')
}
})
}
let closeHandler: (() => void) | null = null
let closeAllowed = false
function installBeforeUnload(): void {
window.addEventListener('beforeunload', (e) => {
if (closeAllowed) return
if (!closeHandler) return
closeHandler()
e.preventDefault()
e.returnValue = ''
})
}
async function promptFileAbsPath(fileName: string, suggested?: string): Promise<string | null> {
const def = webVfs.getDefaultDirectory()
const guess =
suggested ??
(isAbsDiskPath(def) ? joinAbsPath(def, fileName) : rememberedAbsRoot(fileName) ?? `F:\\${fileName}`)
const raw = await askDiskPath({
title: '请输入文件的本机完整路径',
hint: `浏览器无法自动读取磁盘路径。请填写「${fileName}」的绝对路径(例如 F:\\coding\\notes\\${fileName})。`,
defaultValue: guess,
folderName: fileName
})
return raw ? normalizeAbsPath(raw) : null
}
export function createBrowserApi(): MyReaderApi {
installKeyboardShortcuts()
installBeforeUnload()
return {
openFileDialog: async (): Promise<OpenFileResult> => {
try {
if (supportsFileSystemAccess() && window.showOpenFilePicker) {
const handles = await window.showOpenFilePicker({
multiple: false,
types: [MD_PICKER]
})
const h = handles[0]
if (!h) return { canceled: true }
const path = await webVfs.registerOpenFile(h, webVfs.virtualPathForPickedFile(h.name))
const content = await h.getFile().then((f) => f.text())
return { canceled: false, path, content }
}
const files = await pickFilesViaInput('.md,.markdown,.mdown,.mkd', false)
if (!files?.[0]) return { canceled: true }
const file = files[0]
const path = webVfs.registerMemoryFile(webVfs.virtualPathForPickedFile(file.name), file)
webVfs.setDefaultDirectory(dirnamePath(path))
const content = await file.text()
return { canceled: false, path, content }
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return { canceled: true }
console.error('[MyReader] openFileDialog', e)
const msg = e instanceof Error ? e.message : String(e)
window.alert(`打开文件失败:${msg}`)
return { canceled: true }
}
},
/** Web 版已关闭侧栏文件夹浏览;保留接口以兼容类型。 */
openFolderDialog: async (): Promise<OpenFolderResult> => {
return { canceled: true }
},
openImageDialog: async (): Promise<OpenImageResult> => {
try {
if (supportsFileSystemAccess() && window.showOpenFilePicker) {
const handles = await window.showOpenFilePicker({
multiple: false,
types: [IMAGE_PICKER]
})
const h = handles[0]
if (!h) return { canceled: true }
const vPath = webVfs.virtualPathForPickedFile(h.name)
await webVfs.registerOpenFile(h, vPath)
const file = await h.getFile()
const url = URL.createObjectURL(file)
return { canceled: false, url, path: vPath }
}
const files = await pickFilesViaInput('image/*', false)
if (!files?.[0]) return { canceled: true }
const file = files[0]
const vPath = webVfs.registerMemoryFile(webVfs.virtualPathForPickedFile(file.name), file)
const url = URL.createObjectURL(file)
return { canceled: false, url, path: vPath }
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return { canceled: true }
console.error('[MyReader] openImageDialog', e)
const msg = e instanceof Error ? e.message : String(e)
window.alert(`选择图片失败:${msg}`)
return { canceled: true }
}
},
saveFileDialog: async (defaultPath?: string): Promise<SaveDialogResult> => {
const name = defaultPath ? defaultPath.split(/[/\\]/).pop() : '未命名.md'
try {
const h = await webVfs.pickSaveHandle(name || '未命名.md')
if (!h) return { canceled: true }
let absPath = defaultPath ? normalizeAbsPath(defaultPath) : null
if (isAbsDiskPath(absPath || '')) {
/* keep disk path */
} else if (absPath?.startsWith('/__web__/')) {
absPath = webVfs.virtualPathForPickedFile(h.name)
} else if (!absPath) {
absPath = webVfs.virtualPathForPickedFile(h.name)
} else {
absPath = await promptFileAbsPath(h.name, defaultPath)
if (!absPath) return { canceled: true }
}
const path = await webVfs.registerOpenFile(h, absPath!)
return { canceled: false, path }
} catch (e) {
console.error('[MyReader] saveFileDialog', e)
return { canceled: true }
}
},
readFile: (filePath: string) => webVfs.readFile(filePath),
writeFile: async (filePath: string, content: string) => {
if (webVfs.hasPath(filePath)) {
await webVfs.writeFile(filePath, content)
return
}
const h = await webVfs.pickSaveHandle(baseNameFromPath(filePath))
if (!h) throw new Error('保存已取消')
const norm = normalizeAbsPath(filePath)
let absPath: string | null = null
if (isAbsDiskPath(norm)) {
absPath = await promptFileAbsPath(h.name, filePath)
} else if (norm.startsWith('/__web__/')) {
absPath = webVfs.virtualPathForPickedFile(h.name)
} else {
absPath = await promptFileAbsPath(h.name, filePath)
}
if (!absPath) throw new Error('保存已取消')
const path = await webVfs.registerOpenFile(h, absPath)
await webVfs.writeFile(path, content)
},
listDirectory: (dirPath: string): Promise<ListDirResult> => webVfs.listDirectory(dirPath),
parentDirectory: (dirPath: string): Promise<string> =>
Promise.resolve(webVfs.parentDirectory(dirPath)),
resolveRelativePath: async (baseFilePath: string, href: string): Promise<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 (/^(blob:|data:)/i.test(raw)) return raw
try {
raw = decodeURIComponent(raw)
} catch {
/* keep */
}
return resolvePathFromBaseFile(baseFilePath, raw)
},
openExternal: async (url: string) => {
const u = url.trim()
if (/^https?:\/\//i.test(u) || /^mailto:/i.test(u) || /^tel:/i.test(u)) {
window.open(u, '_blank', 'noopener,noreferrer')
}
},
defaultDirectory: async () => webVfs.getDefaultDirectory(),
deleteFile: (filePath: string) => webVfs.deleteFile(filePath),
renamePath: (oldPath: string, newName: string) => webVfs.renamePath(oldPath, newName),
allowClose: () => {
closeAllowed = true
},
onRequestClose: (handler: () => void) => {
closeHandler = handler
return () => {
if (closeHandler === handler) closeHandler = null
}
},
onMenuAction: (handler: (action: string) => void) => {
menuHandlers.add(handler)
return () => menuHandlers.delete(handler)
}
}
}
function baseNameFromPath(p: string): string {
const n = p.replace(/\\/g, '/')
const i = n.lastIndexOf('/')
return i >= 0 ? n.slice(i + 1) : n
}
export function installBrowserApi(): void {
window.myreader = createBrowserApi()
}
+41
View File
@@ -0,0 +1,41 @@
export type PathPromptRequest = {
title: string
hint: string
defaultValue: string
folderName?: string
}
type PathPromptFn = (req: PathPromptRequest) => Promise<string | null>
let promptFn: PathPromptFn | null = null
export function registerPathPrompt(fn: PathPromptFn | null): void {
promptFn = fn
}
export async function askDiskPath(req: PathPromptRequest): Promise<string | null> {
if (promptFn) return promptFn(req)
const v = window.prompt(`${req.title}\n\n${req.hint}`, req.defaultValue)
return v?.trim() ? v.trim() : null
}
const ABS_ROOT_KEY = 'myreader-abs-root-map'
export function rememberedAbsRoot(folderName: string): string | undefined {
try {
const map = JSON.parse(localStorage.getItem(ABS_ROOT_KEY) || '{}') as Record<string, string>
return map[folderName]
} catch {
return undefined
}
}
export function rememberAbsRoot(folderName: string, absPath: string): void {
try {
const map = JSON.parse(localStorage.getItem(ABS_ROOT_KEY) || '{}') as Record<string, string>
map[folderName] = absPath
localStorage.setItem(ABS_ROOT_KEY, JSON.stringify(map))
} catch {
/* ignore */
}
}
+458
View File
@@ -0,0 +1,458 @@
import { randomId } from '../randomId'
import type { DirEntry, ListDirResult } from '../types/ipc-types'
import {
dirnamePath,
fileUrlToPath,
isAbsDiskPath,
joinAbsPath,
normalizeAbsPath,
pathBasename,
pathsEqual,
resolvePathFromBaseFile
} from '../pathLite'
const MD_EXT = new Set(['.md', '.markdown', '.mdown', '.mkd'])
const IMAGE_EXT = new Set([
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.svg',
'.bmp',
'.ico',
'.avif'
])
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))
}
function isImageFile(name: string): boolean {
const lower = name.toLowerCase()
const dot = lower.lastIndexOf('.')
if (dot < 0) return false
return IMAGE_EXT.has(lower.slice(dot))
}
function parentPath(p: string): string {
const n = normalizeAbsPath(p).replace(/\/+$/, '')
const i = n.lastIndexOf('/')
if (i <= 0) return '/'
const d = n.slice(0, i)
if (/^[A-Za-z]:$/.test(d)) return `${d}/`
return d || '/'
}
function baseName(p: string): string {
const n = p.replace(/\\/g, '/').replace(/\/+$/, '')
const i = n.lastIndexOf('/')
return i >= 0 ? n.slice(i + 1) : n
}
type DirEntries = FileSystemDirectoryHandle & {
entries: () => AsyncIterableIterator<[string, FileSystemHandle]>
}
async function* iterateDir(
dir: FileSystemDirectoryHandle
): AsyncGenerator<[string, FileSystemHandle]> {
const d = dir as DirEntries
for await (const entry of d.entries()) {
yield entry
}
}
/** 浏览器端:File System Access 句柄 + 本机绝对路径(用户确认)映射。 */
class WebVfs {
private dirHandles = new Map<string, FileSystemDirectoryHandle>()
private fileHandles = new Map<string, FileSystemFileHandle>()
private memoryFiles = new Map<string, File>()
private blobUrlByPath = new Map<string, string>()
private defaultDir = ''
getDefaultDirectory(): string {
return this.defaultDir
}
setDefaultDirectory(p: string): void {
this.defaultDir = normalizeAbsPath(p)
}
hasPath(path: string): boolean {
const n = normalizeAbsPath(path)
return this.fileHandles.has(n) || this.memoryFiles.has(n) || this.dirHandles.has(n)
}
/** 仅通过 <input type="file"> 打开、无 FileHandle,无法写回用户磁盘原文件。 */
isMemoryOnlyPath(path: string): boolean {
const n = normalizeAbsPath(path)
if (this.fileHandles.has(n)) return false
return this.memoryFiles.has(n)
}
getCachedBlobUrlPublic(absPath: string): string | undefined {
return this.getCachedBlobUrl(absPath)
}
listKnownImagePaths(): string[] {
return Array.from(this.blobUrlByPath.keys())
}
async registerDirectory(root: FileSystemDirectoryHandle, absRoot: string): Promise<string> {
const rootPath = normalizeAbsPath(absRoot)
this.dirHandles.set(rootPath, root)
this.defaultDir = rootPath
await this.indexDirectory(root, rootPath, true)
return rootPath
}
private async indexDirectory(
dir: FileSystemDirectoryHandle,
absPath: string,
deep: boolean
): Promise<void> {
for await (const [name, handle] of iterateDir(dir)) {
if (name.startsWith('.')) continue
const full = joinAbsPath(absPath, name)
if (handle.kind === 'directory') {
const sub = handle as FileSystemDirectoryHandle
this.dirHandles.set(full, sub)
if (deep) await this.indexDirectory(sub, full, true)
} else if (handle.kind === 'file') {
const fh = handle as FileSystemFileHandle
if (isMarkdownFile(name)) {
this.fileHandles.set(full, fh)
}
if (isImageFile(name)) {
void this.cacheBlobUrl(full, fh)
}
}
}
}
private async cacheBlobUrl(absPath: string, handle: FileSystemFileHandle): Promise<string> {
const key = normalizeAbsPath(absPath)
const prev = this.blobUrlByPath.get(key)
if (prev) return prev
const file = await handle.getFile()
const url = URL.createObjectURL(file)
this.blobUrlByPath.set(key, url)
return url
}
/** 将图片 src(含 file://)解析为可显示的 blob URL。 */
async resolveImageToBlobUrl(src: string, baseFilePath: string | null): Promise<string | null> {
const s = src.trim()
if (!s) return null
if (/^(blob:|data:|https?:)/i.test(s)) return s
const base = baseFilePath?.trim() ? normalizeAbsPath(baseFilePath) : null
const absPath = this.resolveImageAbsPath(s, base)
if (!absPath) return null
const hit = await this.ensureBlobUrl(absPath)
if (hit) return hit
if (base) {
const baseDir = dirnamePath(base)
const imgDir = dirnamePath(absPath)
if (pathsEqual(baseDir, imgDir)) {
const sibling = joinAbsPath(baseDir, pathBasename(absPath))
if (!pathsEqual(sibling, absPath)) {
const alt = await this.ensureBlobUrl(sibling)
if (alt) return alt
}
}
}
return this.ensureBlobUrlByBasename(pathBasename(absPath))
}
resolveImageAbsPath(src: string, baseFilePath: string | null): string | null {
const s = src.trim()
if (/^file:/i.test(s)) return normalizeAbsPath(fileUrlToPath(s))
if (isAbsDiskPath(s)) return normalizeAbsPath(s)
if (baseFilePath) return normalizeAbsPath(resolvePathFromBaseFile(baseFilePath, s))
return null
}
private lookupFileHandle(
absPath: string
): { key: string; handle: FileSystemFileHandle } | null {
const key = normalizeAbsPath(absPath)
const direct = this.fileHandles.get(key)
if (direct) return { key, handle: direct }
for (const [k, h] of this.fileHandles) {
if (pathsEqual(k, key)) return { key: k, handle: h }
}
return null
}
private lookupImageByBasename(fileName: string): { key: string; handle: FileSystemFileHandle } | null {
const want = fileName.toLowerCase()
let found: { key: string; handle: FileSystemFileHandle } | null = null
for (const [k, h] of this.fileHandles) {
if (!isImageFile(pathBasename(k))) continue
if (pathBasename(k).toLowerCase() !== want) continue
if (!found) found = { key: k, handle: h }
}
return found
}
private async ensureBlobUrl(absPath: string): Promise<string | null> {
const key = normalizeAbsPath(absPath)
const cached = this.getCachedBlobUrl(key)
if (cached) return cached
const located = this.lookupFileHandle(key)
let handle = located?.handle
let storeKey = located?.key ?? key
if (!handle) {
handle = (await this.ensureFileHandle(key)) ?? undefined
storeKey = key
}
if (!handle) return null
return this.cacheBlobUrl(storeKey, handle)
}
private async ensureBlobUrlByBasename(fileName: string): Promise<string | null> {
const located = this.lookupImageByBasename(fileName)
if (!located) return null
return this.ensureBlobUrl(located.key)
}
private getCachedBlobUrl(absPath: string): string | undefined {
const key = normalizeAbsPath(absPath)
const direct = this.blobUrlByPath.get(key)
if (direct) return direct
for (const [k, url] of this.blobUrlByPath) {
if (pathsEqual(k, key)) return url
}
return undefined
}
async registerOpenFile(handle: FileSystemFileHandle, absPath: string): Promise<string> {
const normalized = normalizeAbsPath(absPath)
this.fileHandles.set(normalized, handle)
this.memoryFiles.delete(normalized)
this.defaultDir = dirnamePath(normalized)
return normalized
}
/** 为「仅打开文件」生成稳定虚拟路径(无需用户填写磁盘绝对路径)。 */
virtualPathForPickedFile(fileName: string): string {
const id = randomId()
const safe =
fileName.replace(/[/\\<>|:*?"\u0000-\u001f]/g, '_').trim() || 'note.md'
return normalizeAbsPath(`/__web__/open/${id}/${safe}`)
}
registerMemoryFile(absPath: string, file: File): string {
const normalized = normalizeAbsPath(absPath)
this.memoryFiles.set(normalized, file)
return normalized
}
async readFile(path: string): Promise<string> {
const normalized = normalizeAbsPath(path)
const mem = this.memoryFiles.get(normalized)
if (mem) return mem.text()
let h = this.fileHandles.get(normalized)
if (!h) {
h = (await this.ensureFileHandle(normalized)) ?? undefined
}
if (!h) throw new Error(`文件不存在或未授权:${normalized}`)
const file = await h.getFile()
return file.text()
}
async writeFile(path: string, content: string): Promise<void> {
const normalized = normalizeAbsPath(path)
const mem = this.memoryFiles.get(normalized)
if (mem) {
this.memoryFiles.set(normalized, new File([content], mem.name, { type: 'text/markdown' }))
return
}
let h = this.fileHandles.get(normalized)
if (!h) {
h = await this.createFileAtParent(normalized)
}
const w = await h.createWritable()
await w.write(content)
await w.close()
}
private async createFileAtParent(path: string): Promise<FileSystemFileHandle> {
const parent = parentPath(path)
const name = baseName(path)
const dir = this.dirHandles.get(parent)
if (!dir) throw new Error(`无法写入:父目录未打开 (${parent})`)
const h = await dir.getFileHandle(name, { create: true })
this.fileHandles.set(path, h)
return h
}
async listDirectory(dirPath: string): Promise<ListDirResult> {
const normalized = normalizeAbsPath(dirPath)
const dir = this.dirHandles.get(normalized)
if (!dir) {
return { entries: [], error: '未授权访问该目录(Web 版请使用工具栏「打开」选择文件)。' }
}
try {
const entries: DirEntry[] = []
for await (const [name, handle] of iterateDir(dir)) {
if (name.startsWith('.')) continue
const full = joinAbsPath(normalized, name)
if (handle.kind === 'directory') {
this.dirHandles.set(full, handle as FileSystemDirectoryHandle)
entries.push({ name, path: full, kind: 'dir' })
} else if (handle.kind === 'file' && isMarkdownFile(name)) {
this.fileHandles.set(full, handle as FileSystemFileHandle)
entries.push({ 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 }
}
}
parentDirectory(dirPath: string): string {
const n = normalizeAbsPath(dirPath)
if (!n) return n
if (isAbsDiskPath(n)) {
const p = parentPath(n)
return p
}
return parentPath(n)
}
private findWorkspaceRoot(absPath: string): { rootPath: string; dir: FileSystemDirectoryHandle } | null {
const normalized = normalizeAbsPath(absPath)
let best = ''
let bestDir: FileSystemDirectoryHandle | null = null
for (const [rp, dir] of this.dirHandles) {
const root = normalizeAbsPath(rp)
if (normalized === root || normalized.startsWith(`${root}/`)) {
if (root.length > best.length) {
best = root
bestDir = dir
}
}
}
return bestDir ? { rootPath: best, dir: bestDir } : null
}
async ensureFileHandle(absPath: string): Promise<FileSystemFileHandle | null> {
const normalized = normalizeAbsPath(absPath)
const direct = this.fileHandles.get(normalized)
if (direct) return direct
const ws = this.findWorkspaceRoot(normalized)
if (!ws) return null
const relParts = normalized.slice(ws.rootPath.length).split('/').filter(Boolean)
if (relParts.length === 0) return null
try {
let cur = ws.dir
let curPath = ws.rootPath
for (let i = 0; i < relParts.length - 1; i++) {
const part = relParts[i]!
cur = await cur.getDirectoryHandle(part)
curPath = joinAbsPath(curPath, part)
this.dirHandles.set(curPath, cur)
}
const fileName = relParts[relParts.length - 1]!
const fh = await cur.getFileHandle(fileName)
this.fileHandles.set(normalized, fh)
if (isImageFile(fileName)) void this.cacheBlobUrl(normalized, fh)
return fh
} catch {
return null
}
}
async deleteFile(path: string): Promise<void> {
const normalized = normalizeAbsPath(path)
const parent = parentPath(normalized)
const name = baseName(normalized)
const dir = this.dirHandles.get(parent)
if (!dir) throw new Error('无法删除:父目录未找到')
await dir.removeEntry(name)
this.fileHandles.delete(normalized)
}
async renamePath(oldPath: string, newName: string): Promise<{ newPath: string }> {
const oldNorm = normalizeAbsPath(oldPath)
const parent = parentPath(oldNorm)
const dir = this.dirHandles.get(parent)
if (!dir) throw new Error('无法重命名:父目录未找到')
const newPath = joinAbsPath(parent, newName.trim())
const handle = this.fileHandles.get(oldNorm) ?? this.dirHandles.get(oldNorm)
if (!handle) throw new Error('无法重命名:条目未找到')
const mover = dir as FileSystemDirectoryHandle & {
move?: (item: FileSystemHandle, name: string) => Promise<void>
}
if (typeof mover.move !== 'function') {
throw new Error('当前浏览器不支持重命名,请在资源管理器中手动操作')
}
await mover.move(handle, newName.trim())
if (this.fileHandles.has(oldNorm)) {
this.fileHandles.delete(oldNorm)
if (handle.kind === 'file') this.fileHandles.set(newPath, handle as FileSystemFileHandle)
} else if (this.dirHandles.has(oldNorm)) {
this.dirHandles.delete(oldNorm)
if (handle.kind === 'directory') this.dirHandles.set(newPath, handle as FileSystemDirectoryHandle)
}
return { newPath }
}
async pickSaveHandle(
suggestedName?: string
): Promise<FileSystemFileHandle | null> {
if (typeof window.isSecureContext === 'boolean' && !window.isSecureContext) {
return null
}
if ('showSaveFilePicker' in window) {
try {
return await window.showSaveFilePicker!({
suggestedName: suggestedName || '未命名.md',
types: [
{
description: 'Markdown',
accept: { 'text/markdown': ['.md', '.markdown'] }
}
]
})
} catch {
return null
}
}
return null
}
}
export const webVfs = new WebVfs()
export function supportsFileSystemAccess(): boolean {
if (typeof window.showOpenFilePicker !== 'function') return false
// Chromium 上 API 可能「存在」但在非安全上下文调用会抛 SecurityError,必须走 <input type="file"> 降级。
if (typeof window.isSecureContext === 'boolean' && !window.isSecureContext) return false
return true
}
export function getCachedImageBlobUrl(absPath: string): string | undefined {
return webVfs.getCachedBlobUrlPublic(normalizeAbsPath(absPath))
}
+18
View File
@@ -0,0 +1,18 @@
/**
* HTTP `crypto.randomUUID` http://域名)。
*
*/
export function randomId(): string {
const c = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined
if (c && typeof c.randomUUID === 'function') {
try {
return c.randomUUID()
} catch {
/* fall through */
}
}
const t = Date.now().toString(36)
const r = Math.random().toString(36).slice(2, 12)
const r2 = Math.random().toString(36).slice(2, 8)
return `${t}-${r}${r2}`
}
+32
View File
@@ -0,0 +1,32 @@
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
}
export type OpenImageResult = {
canceled: boolean
url?: string
path?: string
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
const PREFERRED_PORT = 1234
export default defineConfig({
/** 部署在子路径 `https://域名/reader/` 时必须设置,否则 JS/CSS 会去请求 `/assets/` 根路径而 404 */
base: '/reader/',
plugins: [react()],
server: {
port: PREFERRED_PORT,
strictPort: false,
open: true,
// 经域名 / 反代访问开发服务器时放行 Host(与 preview 一致)
allowedHosts: ['huangzjsecret.online', 'www.huangzjsecret.online', 'localhost', '127.0.0.1']
},
preview: {
port: PREFERRED_PORT,
strictPort: false,
// Nginx 会把 Host 原样转发,需与浏览器地址栏域名一致
allowedHosts: ['huangzjsecret.online', 'www.huangzjsecret.online', 'localhost', '127.0.0.1']
}
})