commit 72d7a750a2053505861d0f681bb895c6265b77e8
Author: 黄志军 <851516902@qq.com>
Date: Sat May 16 23:24:55 2026 +0800
初始化web版本MyReader
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b96e3fa
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules
+dist
+.DS_Store
+*.log
+.env
+.env.*
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/.idea/.gitignore
@@ -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
diff --git a/.idea/MyReader-Web.iml b/.idea/MyReader-Web.iml
new file mode 100644
index 0000000..d6ebd48
--- /dev/null
+++ b/.idea/MyReader-Web.iml
@@ -0,0 +1,9 @@
+
+
浏览器文件接口未就绪,请刷新页面重试。
++ 「{tabLabel(closePromptTab)}」有未保存的更改,是否保存? +
+{textPrompt.hint}
: null} + setTextPromptInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + textPrompt.resolve(textPromptInput) + setTextPrompt(null) + } + if (e.key === 'Escape') { + e.preventDefault() + textPrompt.resolve(null) + setTextPrompt(null) + } + }} + autoFocus + /> +支持网络地址或本地图片文件(Markdown 中保存为 file:// 链接)。
+ ++ 在浏览器中运行,读写的是您本机通过「打开 / 保存」授权的文件,不会访问服务器磁盘。需要完整本地编辑体验请使用工具栏「桌面版下载地址」。 +
++ 打开与保存:工具栏「打开」选择本地 Markdown;「保存」写回原文件(需浏览器支持 + File System Access API);「另存为」可保存到新位置。推荐使用 Chrome / Edge 桌面版,并通过 + HTTPS 访问以获得完整能力。 +
++ 侧栏:仅显示当前文档大纲。Web 版不提供文件夹浏览与打开历史,请用工具栏「打开」或 + Ctrl+O 选择文件。 +
+
+ 图片与链接:本地 file:// 图片受浏览器安全策略限制,可能无法显示;可改用
+ 网络图片地址,或将图片与文档放在同一目录后重新打开。文内相对链接在已打开文件范围内解析。
+
+ 快捷键:Ctrl+O 打开 · Ctrl+S 保存 · Ctrl+Shift+S 另存为 · Ctrl+T 新标签 · + Ctrl+W 关闭标签 · Ctrl+E 切换视图 · Ctrl+Tab / Ctrl+Shift+Tab 切换标签。 +
++ 作者:黄志军 · 支持与协助(wx):liaofan199404 +
++ 为什么推荐桌面版? + Web 版在 HTTP 站点上无法直接写回您电脑上的原文件,保存时往往只能触发下载;文件夹浏览、稳定保存等能力也受浏览器限制。桌面版(Electron)在 + Windows 本地运行,可像普通编辑器一样打开、保存 Markdown,体验更接近完整版 MyReader。 +
++ 如何安装? +
+.exe);.md 文件进行阅读与编辑。
+ 下载地址:
+
+
+ {DESKTOP_DOWNLOAD_URL}
+
+
+ 若链接无法打开,请检查网络或联系站点管理员。桌面版仅支持 Windows;Mac / Linux 请继续使用 Web 版。 +
+当前文档没有标题
+ } + + const collapsedCount = collapsed.size + const branchCount = branchIds.length + + return ( +${escapeHtml(msg)}\n\n${escapeHtml(source.slice(0, 2000))}`
+ console.warn("[Huangzhijun's Reader] mermaid render failed", err)
+ }
+ }
+}
+
+function escapeHtml(s: string): string {
+ return s
+ .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${body}`
+ }
+})
+
+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)
+ : '${escaped}${highlighted}${md.utils.escapeHtml(raw.slice(0, 8000))}
` + } + } catch (e) { + console.warn('[MyReader] markdown render failed', e) + return `${md.utils.escapeHtml(input.slice(0, 8000))}`
+ }
+}
+
+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