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