代码优化
This commit is contained in:
Generated
+6
@@ -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
@@ -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">
|
||||
若链接无法打开,请检查网络或联系站点管理员。桌面版仅支持 Windows;Mac / 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
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
@@ -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)))
|
||||
}
|
||||
Reference in New Issue
Block a user