代码优化

This commit is contained in:
2026-05-18 11:07:53 +08:00
parent 72d7a750a2
commit b1645056e5
10 changed files with 1170 additions and 376 deletions
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+453 -319
View File
@@ -21,77 +21,36 @@ import {
} from './downloadTextFile'
import { randomId } from './randomId'
import { webVfs } from './platform/webVfs'
type Tab = {
id: string
filePath: string | null
content: string
savedContent: string
}
const BG_KEY = 'myreader-bg-id'
const FONT_KEY = 'myreader-font-id'
const VIEW_KEY = 'myreader-view'
const LEGACY_READ_KEY = 'myreader-read'
const SESSION_KEY = 'myreader-session-v1'
const SIDEBAR_W_KEY = 'myreader-sidebar-w'
const SIDEBAR_HIDDEN_KEY = 'myreader-sidebar-hidden'
const DESKTOP_DOWNLOAD_URL =
'http://huangzjsecret.online/api/uploads/huangzhijun`sReader.zip'
const SPLIT_KEY = 'myreader-split'
const PREVIEW_W_KEY = 'myreader-preview-max'
const PREVIEW_LINE_KEY = 'myreader-preview-line-height'
function loadPreviewLineHeight(): string {
const raw = localStorage.getItem(PREVIEW_LINE_KEY)
if (!raw) return '1.5'
const n = Number.parseFloat(raw)
if (!Number.isFinite(n)) return '1.5'
return String(Math.min(3, Math.max(1, n)))
}
import type { Tab } from './types'
import {
clamp,
newTab,
tabLabel,
isDirty,
loadViewMode,
viewModeLabel,
loadPreviewLineHeight,
normalizePathKey
} from './utils'
import {
BG_KEY,
FONT_KEY,
VIEW_KEY,
SIDEBAR_W_KEY,
SIDEBAR_HIDDEN_KEY,
SPLIT_KEY,
PREVIEW_W_KEY,
PREVIEW_LINE_KEY,
SESSION_KEY,
AUTO_SAVE_KEY
} from './constants'
import { TabBar } from './TabBar'
import { StatsPanel } from './StatsPanel'
import { addFileHistory, loadFileHistory, removeFileHistory } from './fileHistory'
type SessionTabRow = { path: string | null; content: string; saved: string }
type SessionV1 = { v: 1; activeIndex: number; tabs: SessionTabRow[]; bgId?: string; fontId?: string }
function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n))
}
function normalizePathKey(p: string): string {
return p.replace(/\\/g, '/').toLowerCase()
}
function newTab(): Tab {
const id = randomId()
return { id, filePath: null, content: '', savedContent: '' }
}
function tabLabel(t: Tab): string {
if (t.filePath) {
const parts = t.filePath.split(/[/\\]/)
return parts[parts.length - 1] || t.filePath
}
return '未命名'
}
function isDirty(t: Tab): boolean {
return t.content !== t.savedContent
}
function loadViewMode(): ViewMode {
const v = localStorage.getItem(VIEW_KEY) as ViewMode | null
if (v === 'edit' || v === 'read' || v === 'hybrid') return v
if (localStorage.getItem(LEGACY_READ_KEY) === '1') return 'read'
if (localStorage.getItem(LEGACY_READ_KEY) === '0') return 'edit'
return 'read'
}
function viewModeLabel(m: ViewMode): string {
if (m === 'edit') return '编辑'
if (m === 'read') return '阅读'
return '分栏'
}
export default function App(): React.ReactElement {
const api = window.myreader
const editorRef = React.useRef<MarkdownEditorHandle | null>(null)
@@ -146,6 +105,19 @@ export default function App(): React.ReactElement {
const [colorHexInput, setColorHexInput] = React.useState('#dc2626')
const [webNotesOpen, setWebNotesOpen] = React.useState(false)
const [desktopDownloadOpen, setDesktopDownloadOpen] = React.useState(false)
const [histCtxMenu, setHistCtxMenu] = React.useState<{
x: number
y: number
path: string
} | null>(null)
const [saveToast, setSaveToast] = React.useState<string | null>(null)
const [autoSave, setAutoSave] = React.useState<boolean>(
() => localStorage.getItem(AUTO_SAVE_KEY) === '1'
)
const [statusText, setStatusText] = React.useState('')
const [searchVisible, setSearchVisible] = React.useState(false)
const [searchQuery, setSearchQuery] = React.useState('')
const [dragOver, setDragOver] = React.useState(false)
const readPreviewOuterRef = React.useRef<HTMLDivElement | null>(null)
const readPreviewArticleRef = React.useRef<HTMLElement | null>(null)
const hybridPreviewRef = React.useRef<HTMLDivElement | null>(null)
@@ -286,6 +258,30 @@ export default function App(): React.ReactElement {
localStorage.setItem(PREVIEW_W_KEY, String(previewMaxWidth))
}, [previewMaxWidth])
React.useEffect(() => {
localStorage.setItem(AUTO_SAVE_KEY, autoSave ? '1' : '0')
}, [autoSave])
React.useEffect(() => {
if (!activeDoc) {
setStatusText('')
return
}
setStatusText('')
}, [activeDoc, activeLineCount])
React.useEffect(() => {
const onDoc = (): void => {
setHistCtxMenu(null)
}
window.addEventListener('mousedown', onDoc)
window.addEventListener('scroll', onDoc, true)
return () => {
window.removeEventListener('mousedown', onDoc)
window.removeEventListener('scroll', onDoc, true)
}
}, [])
React.useEffect(() => {
if (textPrompt) setTextPromptInput(textPrompt.defaultValue)
}, [textPrompt])
@@ -305,6 +301,12 @@ export default function App(): React.ReactElement {
if (viewMode !== 'hybrid') setHybridLinePulse(null)
}, [viewMode])
React.useEffect(() => {
if (!saveToast) return
const timer = window.setTimeout(() => setSaveToast(null), 2800)
return () => clearTimeout(timer)
}, [saveToast])
const requestText = React.useCallback((title: string, defaultValue: string) => {
return new Promise<string | null>((resolve) => {
setTextPrompt({ title, defaultValue, resolve })
@@ -452,17 +454,10 @@ export default function App(): React.ReactElement {
[activeDoc, activeTab?.filePath]
)
const pickLocalImage = React.useCallback(async (): Promise<void> => {
if (!api) return
const overlay = document.querySelector<HTMLElement>('.modal-overlay')
if (overlay) overlay.style.visibility = 'hidden'
try {
const r = await api.openImageDialog()
if (!r.canceled && r.url) setImageUrlInput(r.url)
} finally {
if (overlay) overlay.style.visibility = ''
}
}, [api])
const recordFileOpen = React.useCallback((path: string): void => {
if (!/\.(md|markdown)$/i.test(path)) return
addFileHistory(loadFileHistory(), path)
}, [])
const updateActive = React.useCallback(
(fn: (t: Tab) => Tab): void => {
@@ -540,6 +535,7 @@ export default function App(): React.ReactElement {
)
if (existing) {
setActiveId(existing.id)
recordFileOpen(r.path)
return
}
setViewMode('read')
@@ -551,15 +547,8 @@ export default function App(): React.ReactElement {
}
setTabs((prev) => [...prev, nt])
setActiveId(nt.id)
}, [api])
const [saveToast, setSaveToast] = React.useState<string | null>(null)
React.useEffect(() => {
if (!saveToast) return
const timer = window.setTimeout(() => setSaveToast(null), 2800)
return () => clearTimeout(timer)
}, [saveToast])
recordFileOpen(r.path)
}, [api, recordFileOpen])
const markTabSaved = React.useCallback((tabId: string, filePath?: string | null): void => {
setTabs((prev) =>
@@ -641,6 +630,18 @@ export default function App(): React.ReactElement {
await saveTabById(activeId)
}, [activeId, saveTabById])
React.useEffect(() => {
if (!autoSave || !activeId) return
const t = tabsRef.current.find((x) => x.id === activeId)
if (!t || !t.filePath || !isDirty(t)) return
const timer = window.setTimeout(() => {
void saveTabById(activeId)
setStatusText('已自动保存')
window.setTimeout(() => setStatusText(''), 2000)
}, 3000)
return () => window.clearTimeout(timer)
}, [activeDoc, autoSave, activeId, saveTabById])
const saveActiveAs = React.useCallback(async (): Promise<void> => {
if (!api) return
const t = tabs.find((x) => x.id === activeId)
@@ -701,6 +702,19 @@ export default function App(): React.ReactElement {
[removeTabById]
)
const closeOtherTabs = React.useCallback(
(tabId: string): void => {
setTabs((prev) => prev.filter((t) => t.id === tabId))
setActiveId(tabId)
},
[]
)
const closeAllTabs = React.useCallback((): void => {
setTabs([])
setActiveId('')
}, [])
const newTabAction = React.useCallback((): void => {
setViewMode('edit')
const nt = newTab()
@@ -782,96 +796,129 @@ export default function App(): React.ReactElement {
[previewMaxWidth]
)
const handlersRef = React.useRef({
openFileDialog: async (): Promise<void> => {},
saveActive: async (): Promise<void> => {},
saveActiveAs: async (): Promise<void> => {},
newTabAction: (): void => {},
closeTabAction: (): void => {},
tabNext: (): void => {},
tabPrev: (): void => {}
})
handlersRef.current = {
openFileDialog,
saveActive,
saveActiveAs,
newTabAction,
closeTabAction,
tabNext,
tabPrev
const handleDrop = React.useCallback(
async (e: React.DragEvent): Promise<void> => {
e.preventDefault()
e.stopPropagation()
setDragOver(false)
const files = e.dataTransfer.files
if (!files || files.length === 0) return
for (let i = 0; i < files.length; i++) {
const file = files[i]
if (!file) continue
if (/\.(md|markdown|mdown|mkd|txt)$/i.test(file.name)) {
try {
const content = await file.text()
const filePath: string | null = (file as { path?: string }).path ?? file.name
if (filePath) {
const key = normalizePathKey(filePath)
const existing = tabsRef.current.find(
(t) => t.filePath && normalizePathKey(t.filePath) === key
)
if (existing) {
setActiveId(existing.id)
continue
}
}
const nt: Tab = {
id: randomId(),
filePath,
content,
savedContent: content
}
setTabs((prev) => [...prev, nt])
setActiveId(nt.id)
setViewMode('read')
} catch {
/* ignore */
}
}
}
},
[]
)
const handleDragOver = React.useCallback((e: React.DragEvent): void => {
e.preventDefault()
e.stopPropagation()
setDragOver(true)
}, [])
const handleDragLeave = React.useCallback((e: React.DragEvent): void => {
e.preventDefault()
const target = e.currentTarget as HTMLElement | null
const related = e.relatedTarget as HTMLElement | null
if (target && related && target.contains(related)) return
setDragOver(false)
}, [])
const exportHtml = React.useCallback((): void => {
if (!activeDoc.trim()) return
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${activeTab?.filePath ? tabLabel(activeTab) : '导出文档'}</title>
<style>
body { max-width: 860px; margin: 40px auto; padding: 0 20px; font-family: Georgia, 'Noto Serif SC', serif; font-size: 16px; line-height: 1.7; color: #333; }
img { max-width: 100%; }
pre { background: #f5f5f5; padding: 16px; border-radius: 6px; overflow-x: auto; }
code { font-family: 'Consolas', monospace; font-size: 0.9em; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
blockquote { border-left: 4px solid #ddd; margin: 0; padding: 0 16px; color: #666; }
</style>
</head>
<body>
${previewHtml}
</body>
</html>`
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${activeTab?.filePath ? tabLabel(activeTab).replace(/\.\w+$/, '') : 'document'}.html`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
setStatusText('已导出 HTML')
window.setTimeout(() => setStatusText(''), 2000)
}, [activeDoc, activeTab, previewHtml])
React.useEffect(() => {
if (!api) return
const unsubClose = api.onRequestClose(() => {
try {
const anyDirty = tabsRef.current.some(isDirty)
if (!anyDirty) {
api.allowClose()
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
void saveActive()
return
}
const ok = window.confirm('有未保存的标签,确定退出并丢弃更改?')
if (ok) api.allowClose()
} catch (e) {
console.error('[MyReader] request-close handler', e)
api.allowClose()
if (e.key === 'f' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
setSearchVisible((v) => !v)
return
}
})
const unsubMenu = api.onMenuAction((action) => {
void (async () => {
try {
const h = handlersRef.current
switch (action) {
case 'file-open':
await h.openFileDialog()
break
case 'file-save':
await h.saveActive()
break
case 'file-save-as':
await h.saveActiveAs()
break
case 'tab-new':
h.newTabAction()
break
case 'tab-close':
h.closeTabAction()
break
case 'tab-next':
h.tabNext()
break
case 'tab-prev':
h.tabPrev()
break
case 'toggle-read':
cycleViewMode()
break
case 'help-about':
window.alert(
" Huangzhijun's Reader 0.1 — 本地 Markdown 阅读与编辑 \n \n 作者:黄志军 \n 支持与协助(wx)liaofan199404"
)
break
default:
break
if (e.key === 'n' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
newTabAction()
return
}
} catch (e) {
console.error('[MyReader] menu action', action, e)
if (e.key === 'w' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
closeTabAction()
return
}
})()
})
return () => {
unsubClose()
unsubMenu()
if (e.key === 'Tab' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
if (e.shiftKey) tabPrev()
else tabNext()
return
}
}, [api, cycleViewMode])
if (!api) {
return (
<div className="app-root">
<p className="no-api"></p>
</div>
)
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [saveActive, newTabAction, closeTabAction, tabNext, tabPrev])
const renderPreviewArticle = (articleRef?: React.Ref<HTMLElement>): React.ReactElement => (
<PreviewArticle
@@ -879,9 +926,20 @@ export default function App(): React.ReactElement {
html={previewHtml}
articleRef={articleRef}
baseFilePath={activeTab?.filePath ?? null}
resolveRelativePath={(base, href) => api.resolveRelativePath(base, href)}
resolveRelativePath={async (base, href) => {
if (api) return api.resolveRelativePath(base, href)
try {
const url = new URL(href, base)
return url.href
} catch {
return href
}
}}
onOpenMarkdown={(path, anchorId) => openPathInTab(path, anchorId)}
onOpenExternal={(url) => api.openExternal(url)}
onOpenExternal={(url) => {
if (api) api.openExternal(url)
else window.open(url, '_blank', 'noopener')
}}
/>
)
@@ -957,7 +1015,7 @@ export default function App(): React.ReactElement {
onMouseDown={(e) => e.stopPropagation()}
>
<h3 className="modal-title">{textPrompt.title}</h3>
{textPrompt.hint ? <p className="modal-body">{textPrompt.hint}</p> : null}
{textPrompt.hint ? <p className="modal-hint">{textPrompt.hint}</p> : null}
<input
type="text"
className="modal-text-input"
@@ -965,17 +1023,16 @@ export default function App(): React.ReactElement {
onChange={(e) => 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
spellCheck={false}
/>
<div className="modal-actions">
<button
@@ -1014,28 +1071,23 @@ export default function App(): React.ReactElement {
}}
>
<div
className="modal modal--wide"
className="modal"
role="dialog"
aria-modal="true"
onMouseDown={(e) => e.stopPropagation()}
>
<h3 className="modal-title"></h3>
<p className="modal-hint">Markdown file:// 链接)。</p>
<label className="modal-field">
<span className="modal-label"></span>
<input
type="text"
className="modal-text-input"
placeholder="https://… 或选择本地文件"
value={imageUrlInput}
onChange={(e) => setImageUrlInput(e.target.value)}
autoFocus
spellCheck={false}
/>
</label>
<div className="modal-row-btns">
<button type="button" onClick={() => void pickLocalImage()}>
</button>
</div>
<label className="modal-field">
<span className="modal-label"></span>
<input
@@ -1043,6 +1095,7 @@ export default function App(): React.ReactElement {
className="modal-text-input"
value={imageAltInput}
onChange={(e) => setImageAltInput(e.target.value)}
spellCheck={false}
/>
</label>
<div className="modal-actions">
@@ -1050,9 +1103,7 @@ export default function App(): React.ReactElement {
type="button"
className="primary"
onClick={() => {
const url = imageUrlInput.trim()
if (!url) return
imageInsert.resolve({ url, alt: imageAltInput.trim() || '图片' })
imageInsert.resolve({ url: imageUrlInput, alt: imageAltInput })
setImageInsert(null)
}}
>
@@ -1152,149 +1203,40 @@ export default function App(): React.ReactElement {
</div>
) : null}
{webNotesOpen ? (
{histCtxMenu ? (
<div
className="modal-overlay"
role="presentation"
onMouseDown={() => setWebNotesOpen(false)}
>
<div
className="modal modal--wide"
role="dialog"
aria-modal="true"
aria-labelledby="web-notes-title"
className="ctx-menu"
style={{
left: Math.min(histCtxMenu.x, window.innerWidth - 140),
top: Math.min(histCtxMenu.y, window.innerHeight - 100)
}}
onMouseDown={(e) => e.stopPropagation()}
>
<h3 className="modal-title" id="web-notes-title">
Web
</h3>
<div className="modal-body web-notes-body">
<p>
/ 访使
</p>
<p>
<strong></strong> Markdown
File System Access API使 Chrome / Edge
HTTPS 访
</p>
<p>
<strong></strong>Web
Ctrl+O
</p>
<p>
<strong></strong> <code>file://</code> 图片受浏览器安全策略限制,可能无法显示;可改用
</p>
<p>
<strong></strong>Ctrl+O · Ctrl+S · Ctrl+Shift+S · Ctrl+T ·
Ctrl+W · Ctrl+E · Ctrl+Tab / Ctrl+Shift+Tab
</p>
<p className="web-notes-footer">
· (wx)liaofan199404
</p>
</div>
<div className="modal-actions">
<button type="button" className="primary" onClick={() => setWebNotesOpen(false)}>
<button
type="button"
className="ctx-item danger"
onClick={() => {
removeFileHistory(loadFileHistory(), histCtxMenu.path)
setHistCtxMenu(null)
}}
>
</button>
</div>
</div>
</div>
) : null}
{desktopDownloadOpen ? (
<div
className="modal-overlay"
role="presentation"
onMouseDown={() => setDesktopDownloadOpen(false)}
>
<div
className="modal modal--wide"
role="dialog"
aria-modal="true"
aria-labelledby="desktop-download-title"
onMouseDown={(e) => e.stopPropagation()}
>
<h3 className="modal-title" id="desktop-download-title">
</h3>
<div className="modal-body web-notes-body">
<p>
<strong></strong>
Web HTTP Electron
Windows Markdown MyReader
</p>
<p>
<strong></strong>
</p>
<ol className="web-notes-list">
<li> ZIP </li>
<li></li>
<li> <code>.exe</code></li>
<li>使 <code>.md</code> </li>
</ol>
<p>
<strong></strong>
<br />
<a href={DESKTOP_DOWNLOAD_URL} target="_blank" rel="noopener noreferrer">
{DESKTOP_DOWNLOAD_URL}
</a>
</p>
<p className="web-notes-muted">
WindowsMac / Linux 使 Web
</p>
</div>
<div className="modal-actions">
<a
className="primary modal-link-btn"
href={DESKTOP_DOWNLOAD_URL}
target="_blank"
rel="noopener noreferrer"
>
</a>
<button type="button" onClick={() => setDesktopDownloadOpen(false)}>
</button>
</div>
</div>
</div>
) : null}
<header className="tab-bar">
{tabs.map((t) => (
<div key={t.id} className={`tab ${t.id === activeId ? 'active' : ''}`}>
<button
type="button"
className="tab-main"
onClick={() => {
setActiveId(t.id)
}}
>
<span className="tab-title">{tabLabel(t)}</span>
{isDirty(t) ? <span className="dot"></span> : null}
</button>
<button
type="button"
className="tab-close"
aria-label="关闭标签"
onClick={(e) => {
e.stopPropagation()
requestCloseTab(t.id)
}}
>
×
</button>
</div>
))}
<TabBar
tabs={tabs}
activeId={activeId}
onSelect={setActiveId}
onClose={requestCloseTab}
onCloseOthers={closeOtherTabs}
onCloseAll={closeAllTabs}
/>
</header>
<div className="toolbar">
{saveToast ? (
<div className="save-toast" role="status">
{saveToast}
</div>
) : null}
<button type="button" onClick={() => void openFileDialog()}>
</button>
@@ -1313,6 +1255,22 @@ export default function App(): React.ReactElement {
<button type="button" onClick={() => setSidebarHidden((v) => !v)}>
{sidebarHidden ? '显示侧栏' : '隐藏侧栏'}
</button>
<button
type="button"
onClick={exportHtml}
disabled={!activeDoc.trim()}
title="导出当前文档为 HTML 文件"
>
HTML
</button>
<button
type="button"
onClick={() => setAutoSave((v) => !v)}
title={autoSave ? '自动保存已开启(3秒无操作后保存)' : '自动保存已关闭'}
style={autoSave ? { borderColor: 'var(--accent)', color: 'var(--accent)' } : undefined}
>
{autoSave ? '自动保存:开' : '自动保存:关'}
</button>
<button type="button" onClick={() => setWebNotesOpen(true)} title="Web 版使用说明">
</button>
@@ -1372,6 +1330,9 @@ export default function App(): React.ReactElement {
{!sidebarHidden ? (
<>
<aside className="sidebar" style={{ width: sidebarWidth, minWidth: sidebarWidth }}>
<div className="sidebar-tabs">
<button type="button" className="on"></button>
</div>
<div className="file-pane outline-pane">
<OutlinePanel items={outline} onSelect={navigateToOutline} />
</div>
@@ -1393,7 +1354,76 @@ export default function App(): React.ReactElement {
</button>
)}
<section className="content">
{searchVisible ? (
<div className="search-bar">
<input
type="text"
className="search-input"
placeholder="搜索文档内容…(Enter 查找下一个,Esc 关闭)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setSearchVisible(false)
setSearchQuery('')
}
if (e.key === 'Enter') {
e.preventDefault()
if (!searchQuery.trim()) return
if (viewMode === 'read') {
const article = readPreviewArticleRef.current
if (article) {
const text = article.textContent || ''
const idx = text.toLowerCase().indexOf(searchQuery.toLowerCase())
if (idx >= 0) {
const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT)
let currentIdx = 0
let node: Text | null = null
while ((node = walker.nextNode() as Text | null)) {
const len = node.textContent?.length || 0
if (currentIdx + len > idx) {
node.parentElement?.scrollIntoView({ behavior: 'smooth', block: 'center' })
break
}
currentIdx += len
}
}
}
return
}
if (viewMode === 'edit') {
editorRef.current?.searchText(searchQuery)
return
}
const view = hybridEditorViewRef.current
if (view) {
const doc = view.state.doc.toString()
const idx = doc.toLowerCase().indexOf(searchQuery.toLowerCase())
if (idx >= 0) {
view.dispatch({
selection: { anchor: idx, head: idx + searchQuery.length },
scrollIntoView: true
})
}
}
}
}}
autoFocus
/>
<button
type="button"
className="search-close-btn"
onClick={() => {
setSearchVisible(false)
setSearchQuery('')
}}
>
</button>
</div>
) : null}
<section className={`content${dragOver ? ' content-drag-over' : ''}`} onDrop={handleDrop} onDragOver={handleDragOver} onDragLeave={handleDragLeave}>
{(viewMode === 'edit' || viewMode === 'hybrid') && activeTab ? (
<MarkdownFormatToolbar editorRef={editorRef} />
) : null}
@@ -1411,7 +1441,13 @@ export default function App(): React.ReactElement {
</div>
) : (
<div className="content-empty">
使
<div className="content-empty-icon">📖</div>
<div>使</div>
<div className="content-empty-hint"> .md · Ctrl+F · Ctrl+S </div>
<div className="content-drop-zone">
<span className="content-drop-zone-icon">📂</span>
<span> Markdown </span>
</div>
</div>
)
) : null}
@@ -1431,7 +1467,13 @@ export default function App(): React.ReactElement {
/>
) : (
<div className="content-empty">
使
<div className="content-empty-icon"></div>
<div>使</div>
<div className="content-empty-hint"> .md · Ctrl+F · Ctrl+S </div>
<div className="content-drop-zone">
<span className="content-drop-zone-icon">📂</span>
<span> Markdown </span>
</div>
</div>
)
) : null}
@@ -1479,12 +1521,104 @@ export default function App(): React.ReactElement {
</div>
) : (
<div className="content-empty">
使
<div className="content-empty-icon">📝</div>
<div>使</div>
<div className="content-empty-hint"> .md · Ctrl+F · Ctrl+S </div>
<div className="content-drop-zone">
<span className="content-drop-zone-icon">📂</span>
<span> Markdown </span>
</div>
</div>
)
) : null}
</section>
{activeTab ? (
<StatsPanel
doc={activeDoc}
width={220}
onResizeStart={() => {}}
/>
) : null}
{statusText ? <div className="status-bar">{statusText}</div> : null}
</div>
{saveToast ? (
<div className="save-toast">{saveToast}</div>
) : null}
{dragOver ? (
<div className="global-drop-overlay">
<div className="global-drop-overlay-box">
<span className="global-drop-overlay-icon">📂</span>
<span className="global-drop-overlay-text"> Markdown </span>
</div>
</div>
) : null}
{webNotesOpen ? (
<div className="modal-overlay" role="presentation" onMouseDown={() => setWebNotesOpen(false)}>
<div className="modal modal--wide" role="dialog" aria-modal="true" onMouseDown={(e) => e.stopPropagation()}>
<h3 className="modal-title">Web 使</h3>
<div className="modal-body" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.7 }}>
{`MyReader Web 版说明
由于浏览器环境的限制,Web 版与桌面版有以下差异:
1. 文件操作
- 需要通过「打开」按钮或拖放 .md 文件来导入文档
- 保存时会触发文件下载(浏览器无法直接写回原文件)
- 支持 File System Access API 的浏览器可获得更好的保存体验
2. 功能限制
- 不支持文件系统浏览(侧栏仅显示大纲)
- 不支持文件重命名/删除
- 不支持文件夹操作
3. 快捷键
- Ctrl+S / Cmd+S:保存
- Ctrl+F / Cmd+F:搜索
- Ctrl+N / Cmd+N:新建标签
- Ctrl+W / Cmd+W:关闭标签
- Ctrl+Tab / Cmd+Tab:切换标签
4. 数据存储
- 所有配置保存在浏览器 localStorage 中
- 清除浏览器数据会导致配置丢失`}
</div>
<div className="modal-actions">
<button type="button" onClick={() => setWebNotesOpen(false)}></button>
</div>
</div>
</div>
) : null}
{desktopDownloadOpen ? (
<div className="modal-overlay" role="presentation" onMouseDown={() => setDesktopDownloadOpen(false)}>
<div className="modal" role="dialog" aria-modal="true" onMouseDown={(e) => e.stopPropagation()}>
<h3 className="modal-title"></h3>
<div className="modal-body" style={{ lineHeight: 1.7 }}>
<p>访使</p>
<p></p>
<a
href={DESKTOP_DOWNLOAD_URL}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--accent)', wordBreak: 'break-all' }}
>
{DESKTOP_DOWNLOAD_URL}
</a>
</div>
<div className="modal-actions">
<button type="button" onClick={() => setDesktopDownloadOpen(false)}></button>
</div>
</div>
</div>
) : null}
</div>
)
}
const DESKTOP_DOWNLOAD_URL =
'http://huangzjsecret.online/api/uploads/huangzhijun`sReader.zip'
+19 -1
View File
@@ -13,6 +13,8 @@ import {
export type MarkdownEditorHandle = {
applyAction: (id: string) => void | Promise<void>
searchText: (query: string) => boolean
getView: () => EditorView | null
}
type Props = {
@@ -286,7 +288,22 @@ export const EditorPane = React.forwardRef<MarkdownEditorHandle, Props>(
requestImageDetails: requestImageDetailsRef.current,
requestColor: requestColorRef.current
})
},
searchText: (query: string): boolean => {
const view = viewRef.current
if (!view || !query.trim()) return false
const doc = view.state.doc.toString()
const idx = doc.toLowerCase().indexOf(query.toLowerCase())
if (idx >= 0) {
view.dispatch({
selection: { anchor: idx, head: idx + query.length },
scrollIntoView: true
})
return true
}
return false
},
getView: (): EditorView | null => viewRef.current
}),
[]
)
@@ -347,9 +364,10 @@ export const EditorPane = React.forwardRef<MarkdownEditorHandle, Props>(
if (!view) return
const cur = view.state.doc.toString()
if (cur !== value) {
const main = view.state.selection.main
view.dispatch({
changes: { from: 0, to: cur.length, insert: value },
selection: { anchor: 0 },
selection: main.empty ? { anchor: main.from } : { anchor: main.from, head: main.to },
scrollIntoView: true
})
}
+183
View File
@@ -0,0 +1,183 @@
import * as React from 'react'
type Props = {
doc: string
width: number
onResizeStart: (e: React.MouseEvent) => void
}
function wordCount(s: string): number {
return s.replace(/[\u4e00-\u9fff]/g, ' $& ').split(/[\s]+/).filter(Boolean).length
}
function codeBlockCount(s: string): number {
return Math.floor(((s.match(/```/g) || []).length) / 2)
}
function imageCount(s: string): number {
return (s.match(/!\[.*?\]\(.*?\)/g) || []).length
}
function linkCount(s: string): number {
return (s.match(/\[.*?\]\(.*?\)/g) || []).length
}
function tableCount(s: string): number {
return (s.match(/^\|.+\|.+\|/gm) || []).length
}
function blockquoteCount(s: string): number {
return (s.match(/^>\s/gm) || []).length
}
function listItemCount(s: string): number {
return (s.match(/^(?:[-*+]|\d+\.)\s/gm) || []).length
}
function mermaidCount(s: string): number {
return (s.match(/```mermaid\n?/gi) || []).length
}
function headingCount(s: string): number {
return (s.match(/^#{1,6}\s/gm) || []).length
}
function boldCount(s: string): number {
return (s.match(/\*\*[^*]+\*\*/g) || []).length
}
function italicCount(s: string): number {
return (s.match(/(?<!\*)\*[^*\n]+\*(?!\*)/g) || []).length
}
function inlineCodeCount(s: string): number {
return (s.match(/`[^`\n]+`/g) || []).length
}
function taskCount(s: string): { done: number; total: number } {
const tasks = s.match(/^\s*[-*+]\s+\[([ x])\]/gm) || []
const done = tasks.filter((t) => t.includes('[x]')).length
return { done, total: tasks.length }
}
function docSize(s: string): string {
const sizeKB = new Blob([s]).size / 1024
return sizeKB < 1 ? '<1 KB' : `${Math.round(sizeKB)} KB`
}
export function StatsPanel({ doc, width, onResizeStart }: Props): React.ReactElement {
return (
<>
<div className="stats-resize" onMouseDown={onResizeStart} title="拖动调整统计栏宽度" />
<aside className="stats-panel" style={{ width, minWidth: width }}>
<div className="stats-panel-header">
<span className="stats-panel-header-icon">📊</span>
</div>
<div className="stats-panel-body">
<div className="stats-panel-section">
<div className="stats-panel-section-title"></div>
<div className="stats-panel-grid">
<div className="stats-panel-item">
<span className="stats-panel-value">{doc.length.toLocaleString()}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{doc.replace(/\s/g, '').length.toLocaleString()}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{wordCount(doc).toLocaleString()}</span>
<span className="stats-panel-label"></span>
</div>
</div>
</div>
<div className="stats-panel-section">
<div className="stats-panel-section-title"></div>
<div className="stats-panel-grid">
<div className="stats-panel-item">
<span className="stats-panel-value">{doc === '' ? 0 : doc.split('\n').length}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{doc.split(/\n\s*\n/).filter((p) => p.trim().length > 0).length}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{Math.max(1, Math.round(wordCount(doc) / 300))}</span>
<span className="stats-panel-label"></span>
</div>
</div>
</div>
<div className="stats-panel-section">
<div className="stats-panel-section-title"></div>
<div className="stats-panel-grid">
<div className="stats-panel-item">
<span className="stats-panel-value">{codeBlockCount(doc)}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{imageCount(doc)}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{linkCount(doc)}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{tableCount(doc)}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{blockquoteCount(doc)}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{listItemCount(doc)}</span>
<span className="stats-panel-label"></span>
</div>
</div>
</div>
<div className="stats-panel-section">
<div className="stats-panel-section-title"></div>
<div className="stats-panel-grid">
<div className="stats-panel-item">
<span className="stats-panel-value">{mermaidCount(doc)}</span>
<span className="stats-panel-label">Mermaid</span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{docSize(doc)}</span>
<span className="stats-panel-label"></span>
</div>
</div>
</div>
<div className="stats-panel-section">
<div className="stats-panel-section-title"></div>
<div className="stats-panel-grid">
<div className="stats-panel-item">
<span className="stats-panel-value">{headingCount(doc)}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{boldCount(doc)}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{italicCount(doc)}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{inlineCodeCount(doc)}</span>
<span className="stats-panel-label"></span>
</div>
<div className="stats-panel-item">
<span className="stats-panel-value">{taskCount(doc).done}/{taskCount(doc).total}</span>
<span className="stats-panel-label"></span>
</div>
</div>
</div>
</div>
</aside>
</>
)
}
+115
View File
@@ -0,0 +1,115 @@
import * as React from 'react'
import type { Tab } from './types'
import { tabLabel, isDirty } from './utils'
type Props = {
tabs: Tab[]
activeId: string
onSelect: (id: string) => void
onClose: (id: string) => void
onCloseOthers: (id: string) => void
onCloseAll: () => void
}
export function TabBar({ tabs, activeId, onSelect, onClose, onCloseOthers, onCloseAll }: Props): React.ReactElement {
const barRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
const el = barRef.current
if (!el) return
const active = el.querySelector('.tab.active') as HTMLElement | null
if (active) {
active.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
}
}, [activeId])
return (
<div className="tab-bar" ref={barRef}>
{tabs.map((t) => (
<TabItem
key={t.id}
tab={t}
active={t.id === activeId}
onSelect={() => onSelect(t.id)}
onClose={() => onClose(t.id)}
onCloseOthers={() => onCloseOthers(t.id)}
onCloseAll={onCloseAll}
/>
))}
</div>
)
}
type TabItemProps = {
tab: Tab
active: boolean
onSelect: () => void
onClose: () => void
onCloseOthers: () => void
onCloseAll: () => void
}
function TabItem({ tab, active, onSelect, onClose, onCloseOthers, onCloseAll }: TabItemProps): React.ReactElement {
const [menuOpen, setMenuOpen] = React.useState(false)
const [menuPos, setMenuPos] = React.useState({ x: 0, y: 0 })
const menuRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (!menuOpen) return
const close = (e: MouseEvent): void => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false)
}
}
const closeOnScroll = (): void => setMenuOpen(false)
document.addEventListener('mousedown', close)
window.addEventListener('scroll', closeOnScroll, true)
return () => {
document.removeEventListener('mousedown', close)
window.removeEventListener('scroll', closeOnScroll, true)
}
}, [menuOpen])
const handleContextMenu = (e: React.MouseEvent): void => {
e.preventDefault()
e.stopPropagation()
setMenuPos({
x: Math.min(e.clientX, window.innerWidth - 140),
y: Math.min(e.clientY, window.innerHeight - 140)
})
setMenuOpen(true)
}
return (
<div className={`tab${active ? ' active' : ''}`} onContextMenu={handleContextMenu}>
<button type="button" className="tab-main" onClick={onSelect} title={tab.filePath ?? undefined}>
{isDirty(tab) ? <span className="dot">·</span> : null}
<span className="tab-title">{tabLabel(tab)}</span>
</button>
<button
type="button"
className="tab-close"
onClick={(e) => {
e.stopPropagation()
onClose()
}}
title="关闭"
>
</button>
{menuOpen ? (
<div className="tab-context-menu" ref={menuRef} style={{ left: menuPos.x, top: menuPos.y }}>
<button type="button" onClick={() => { onClose(); setMenuOpen(false) }}>
</button>
<button type="button" onClick={() => { onCloseOthers(); setMenuOpen(false) }}>
</button>
<button type="button" onClick={() => { onCloseAll(); setMenuOpen(false) }}>
</button>
</div>
) : null}
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
export const BG_KEY = 'myreader-bg-id'
export const FONT_KEY = 'myreader-font-id'
export const VIEW_KEY = 'myreader-view'
export const LEGACY_READ_KEY = 'myreader-read'
export const SIDEBAR_W_KEY = 'myreader-sidebar-w'
export const SIDEBAR_HIDDEN_KEY = 'myreader-sidebar-hidden'
export const SPLIT_KEY = 'myreader-split'
export const PREVIEW_W_KEY = 'myreader-preview-max'
export const SESSION_KEY = 'myreader-session-v1'
export const PREVIEW_LINE_KEY = 'myreader-preview-line-height'
export const AUTO_SAVE_KEY = 'myreader-auto-save'
+35
View File
@@ -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
}
+256 -24
View File
@@ -153,18 +153,6 @@ html[data-bg='22'] .app-root {
isolation: isolate;
}
.save-toast {
flex: 1 1 100%;
margin: 0 0 4px;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--panel-bg));
color: var(--text);
font-size: 13px;
line-height: 1.45;
}
.toolbar button,
.sidebar-tabs button,
.path-row button,
@@ -891,18 +879,6 @@ html[data-bg='22'] .app-root {
overflow: hidden;
}
.content-empty {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
color: var(--muted);
font-size: 15px;
text-align: center;
}
.editor-host {
flex: 1;
min-height: 0;
@@ -1254,6 +1230,262 @@ html[data-bg='22'] .app-root {
}
}
/* ── Search Bar ── */
.search-bar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-bottom: 1px solid var(--border);
background: var(--panel-bg);
z-index: 20;
}
.search-input {
flex: 1;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--btn-bg);
color: var(--text);
font: inherit;
font-size: 13px;
}
.search-input:focus {
outline: none;
border-color: var(--accent);
}
.search-close-btn {
flex: 0 0 auto;
padding: 4px 8px;
border: none;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 16px;
line-height: 1;
}
.search-close-btn:hover {
color: var(--text);
}
/* ── Status Bar ── */
.status-bar {
flex: 0 0 auto;
padding: 4px 12px;
font-size: 12px;
color: var(--muted);
border-top: 1px solid var(--border);
background: var(--panel-bg);
text-align: center;
}
/* ── Save Toast ── */
.save-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 2000;
padding: 10px 20px;
border-radius: 8px;
border: 1px solid var(--accent);
background: var(--panel-bg);
color: var(--text);
font-size: 14px;
box-shadow: var(--preview-shadow);
animation: save-toast-in 0.25s ease-out;
max-width: min(90vw, 480px);
text-align: center;
}
@keyframes save-toast-in {
from {
opacity: 0;
transform: translateX(-50%) translateY(12px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
/* ── Content Empty State ── */
.content-empty {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 32px;
color: var(--muted);
font-size: 15px;
text-align: center;
}
.content-empty-icon {
font-size: 48px;
line-height: 1;
}
.content-empty-hint {
font-size: 13px;
color: color-mix(in srgb, var(--muted) 80%, transparent);
}
.content-drop-zone {
margin-top: 16px;
padding: 24px 32px;
border: 2px dashed var(--border);
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 14px;
}
.content-drop-zone-icon {
font-size: 32px;
line-height: 1;
}
/* ── Drag Over State ── */
.content-drag-over {
outline: 3px dashed var(--accent);
outline-offset: -3px;
background: color-mix(in srgb, var(--accent) 6%, transparent);
}
/* ── Global Drop Overlay ── */
.global-drop-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.global-drop-overlay-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 40px 48px;
border: 3px dashed var(--accent);
border-radius: 16px;
background: var(--panel-bg);
box-shadow: var(--preview-shadow);
}
.global-drop-overlay-icon {
font-size: 56px;
line-height: 1;
}
.global-drop-overlay-text {
font-size: 18px;
color: var(--text);
font-weight: 600;
}
/* ── Stats Panel ── */
.stats-resize {
width: 5px;
cursor: col-resize;
flex: 0 0 5px;
background: transparent;
}
.stats-resize:hover {
background: var(--accent);
opacity: 0.35;
}
.stats-panel {
flex: 0 0 auto;
align-self: stretch;
border-left: 1px solid var(--border);
background: var(--panel-bg);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.stats-panel-header {
padding: 10px 12px;
font-size: 13px;
font-weight: 600;
color: var(--text);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 6px;
}
.stats-panel-header-icon {
font-size: 16px;
line-height: 1;
}
.stats-panel-body {
padding: 8px 10px;
flex: 1;
}
.stats-panel-section {
margin-bottom: 14px;
}
.stats-panel-section-title {
font-size: 11px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 6px;
padding-bottom: 4px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
}
.stats-panel-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.stats-panel-item {
display: flex;
flex-direction: column;
gap: 1px;
padding: 6px 8px;
border-radius: 6px;
background: var(--btn-bg);
}
.stats-panel-value {
font-size: 15px;
font-weight: 700;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.stats-panel-label {
font-size: 10px;
color: var(--muted);
font-weight: 500;
}
.modal-overlay {
position: fixed;
inset: 0;
+9
View File
@@ -0,0 +1,9 @@
export type Tab = {
id: string
filePath: string | null
content: string
savedContent: string
}
export type SessionTabRow = { path: string | null; content: string; saved: string }
export type SessionV1 = { v: 1; activeIndex: number; tabs: SessionTabRow[]; bgId?: string; fontId?: string }
+51
View File
@@ -0,0 +1,51 @@
import type { Tab } from './types'
import { PREVIEW_LINE_KEY, LEGACY_READ_KEY, VIEW_KEY } from './constants'
import type { ViewMode } from './appearance'
import { randomId } from './randomId'
export function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n))
}
export function normalizePathKey(p: string): string {
return p.replace(/\\/g, '/').toLowerCase()
}
export function newTab(): Tab {
const id = randomId()
return { id, filePath: null, content: '', savedContent: '' }
}
export function tabLabel(t: Tab): string {
if (t.filePath) {
const parts = t.filePath.split(/[/\\]/)
return parts[parts.length - 1] || t.filePath
}
return '未命名'
}
export function isDirty(t: Tab): boolean {
return t.content !== t.savedContent
}
export function loadViewMode(): ViewMode {
const v = localStorage.getItem(VIEW_KEY) as ViewMode | null
if (v === 'edit' || v === 'read' || v === 'hybrid') return v
if (localStorage.getItem(LEGACY_READ_KEY) === '1') return 'read'
if (localStorage.getItem(LEGACY_READ_KEY) === '0') return 'edit'
return 'read'
}
export function viewModeLabel(m: ViewMode): string {
if (m === 'edit') return '编辑'
if (m === 'read') return '阅读'
return '分栏'
}
export function loadPreviewLineHeight(): string {
const raw = localStorage.getItem(PREVIEW_LINE_KEY)
if (!raw) return '1.5'
const n = Number.parseFloat(raw)
if (!Number.isFinite(n)) return '1.5'
return String(Math.min(3, Math.max(1, n)))
}