请在 Electron 中运行以使用文件系统功能。
++ 「{tabLabel(closePromptTab)}」有未保存的更改,是否保存? +
+支持网络地址或本地图片文件(Markdown 中保存为 file:// 链接)。
+ +当前文档没有标题
+ } + + 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
+ 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