代码优化
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'
|
} from './downloadTextFile'
|
||||||
import { randomId } from './randomId'
|
import { randomId } from './randomId'
|
||||||
import { webVfs } from './platform/webVfs'
|
import { webVfs } from './platform/webVfs'
|
||||||
|
import type { Tab } from './types'
|
||||||
type Tab = {
|
import {
|
||||||
id: string
|
clamp,
|
||||||
filePath: string | null
|
newTab,
|
||||||
content: string
|
tabLabel,
|
||||||
savedContent: string
|
isDirty,
|
||||||
}
|
loadViewMode,
|
||||||
|
viewModeLabel,
|
||||||
const BG_KEY = 'myreader-bg-id'
|
loadPreviewLineHeight,
|
||||||
const FONT_KEY = 'myreader-font-id'
|
normalizePathKey
|
||||||
const VIEW_KEY = 'myreader-view'
|
} from './utils'
|
||||||
const LEGACY_READ_KEY = 'myreader-read'
|
import {
|
||||||
const SESSION_KEY = 'myreader-session-v1'
|
BG_KEY,
|
||||||
const SIDEBAR_W_KEY = 'myreader-sidebar-w'
|
FONT_KEY,
|
||||||
const SIDEBAR_HIDDEN_KEY = 'myreader-sidebar-hidden'
|
VIEW_KEY,
|
||||||
const DESKTOP_DOWNLOAD_URL =
|
SIDEBAR_W_KEY,
|
||||||
'http://huangzjsecret.online/api/uploads/huangzhijun`sReader.zip'
|
SIDEBAR_HIDDEN_KEY,
|
||||||
const SPLIT_KEY = 'myreader-split'
|
SPLIT_KEY,
|
||||||
const PREVIEW_W_KEY = 'myreader-preview-max'
|
PREVIEW_W_KEY,
|
||||||
const PREVIEW_LINE_KEY = 'myreader-preview-line-height'
|
PREVIEW_LINE_KEY,
|
||||||
|
SESSION_KEY,
|
||||||
function loadPreviewLineHeight(): string {
|
AUTO_SAVE_KEY
|
||||||
const raw = localStorage.getItem(PREVIEW_LINE_KEY)
|
} from './constants'
|
||||||
if (!raw) return '1.5'
|
import { TabBar } from './TabBar'
|
||||||
const n = Number.parseFloat(raw)
|
import { StatsPanel } from './StatsPanel'
|
||||||
if (!Number.isFinite(n)) return '1.5'
|
import { addFileHistory, loadFileHistory, removeFileHistory } from './fileHistory'
|
||||||
return String(Math.min(3, Math.max(1, n)))
|
|
||||||
}
|
|
||||||
|
|
||||||
type SessionTabRow = { path: string | null; content: string; saved: string }
|
type SessionTabRow = { path: string | null; content: string; saved: string }
|
||||||
type SessionV1 = { v: 1; activeIndex: number; tabs: SessionTabRow[]; bgId?: string; fontId?: 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 {
|
export default function App(): React.ReactElement {
|
||||||
const api = window.myreader
|
const api = window.myreader
|
||||||
const editorRef = React.useRef<MarkdownEditorHandle | null>(null)
|
const editorRef = React.useRef<MarkdownEditorHandle | null>(null)
|
||||||
@@ -146,6 +105,19 @@ export default function App(): React.ReactElement {
|
|||||||
const [colorHexInput, setColorHexInput] = React.useState('#dc2626')
|
const [colorHexInput, setColorHexInput] = React.useState('#dc2626')
|
||||||
const [webNotesOpen, setWebNotesOpen] = React.useState(false)
|
const [webNotesOpen, setWebNotesOpen] = React.useState(false)
|
||||||
const [desktopDownloadOpen, setDesktopDownloadOpen] = 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 readPreviewOuterRef = React.useRef<HTMLDivElement | null>(null)
|
||||||
const readPreviewArticleRef = React.useRef<HTMLElement | null>(null)
|
const readPreviewArticleRef = React.useRef<HTMLElement | null>(null)
|
||||||
const hybridPreviewRef = React.useRef<HTMLDivElement | 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))
|
localStorage.setItem(PREVIEW_W_KEY, String(previewMaxWidth))
|
||||||
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (textPrompt) setTextPromptInput(textPrompt.defaultValue)
|
if (textPrompt) setTextPromptInput(textPrompt.defaultValue)
|
||||||
}, [textPrompt])
|
}, [textPrompt])
|
||||||
@@ -305,6 +301,12 @@ export default function App(): React.ReactElement {
|
|||||||
if (viewMode !== 'hybrid') setHybridLinePulse(null)
|
if (viewMode !== 'hybrid') setHybridLinePulse(null)
|
||||||
}, [viewMode])
|
}, [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) => {
|
const requestText = React.useCallback((title: string, defaultValue: string) => {
|
||||||
return new Promise<string | null>((resolve) => {
|
return new Promise<string | null>((resolve) => {
|
||||||
setTextPrompt({ title, defaultValue, resolve })
|
setTextPrompt({ title, defaultValue, resolve })
|
||||||
@@ -452,17 +454,10 @@ export default function App(): React.ReactElement {
|
|||||||
[activeDoc, activeTab?.filePath]
|
[activeDoc, activeTab?.filePath]
|
||||||
)
|
)
|
||||||
|
|
||||||
const pickLocalImage = React.useCallback(async (): Promise<void> => {
|
const recordFileOpen = React.useCallback((path: string): void => {
|
||||||
if (!api) return
|
if (!/\.(md|markdown)$/i.test(path)) return
|
||||||
const overlay = document.querySelector<HTMLElement>('.modal-overlay')
|
addFileHistory(loadFileHistory(), path)
|
||||||
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 updateActive = React.useCallback(
|
const updateActive = React.useCallback(
|
||||||
(fn: (t: Tab) => Tab): void => {
|
(fn: (t: Tab) => Tab): void => {
|
||||||
@@ -540,6 +535,7 @@ export default function App(): React.ReactElement {
|
|||||||
)
|
)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
setActiveId(existing.id)
|
setActiveId(existing.id)
|
||||||
|
recordFileOpen(r.path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setViewMode('read')
|
setViewMode('read')
|
||||||
@@ -551,15 +547,8 @@ export default function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
setTabs((prev) => [...prev, nt])
|
setTabs((prev) => [...prev, nt])
|
||||||
setActiveId(nt.id)
|
setActiveId(nt.id)
|
||||||
}, [api])
|
recordFileOpen(r.path)
|
||||||
|
}, [api, recordFileOpen])
|
||||||
const [saveToast, setSaveToast] = React.useState<string | null>(null)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!saveToast) return
|
|
||||||
const timer = window.setTimeout(() => setSaveToast(null), 2800)
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}, [saveToast])
|
|
||||||
|
|
||||||
const markTabSaved = React.useCallback((tabId: string, filePath?: string | null): void => {
|
const markTabSaved = React.useCallback((tabId: string, filePath?: string | null): void => {
|
||||||
setTabs((prev) =>
|
setTabs((prev) =>
|
||||||
@@ -641,6 +630,18 @@ export default function App(): React.ReactElement {
|
|||||||
await saveTabById(activeId)
|
await saveTabById(activeId)
|
||||||
}, [activeId, saveTabById])
|
}, [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> => {
|
const saveActiveAs = React.useCallback(async (): Promise<void> => {
|
||||||
if (!api) return
|
if (!api) return
|
||||||
const t = tabs.find((x) => x.id === activeId)
|
const t = tabs.find((x) => x.id === activeId)
|
||||||
@@ -701,6 +702,19 @@ export default function App(): React.ReactElement {
|
|||||||
[removeTabById]
|
[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 => {
|
const newTabAction = React.useCallback((): void => {
|
||||||
setViewMode('edit')
|
setViewMode('edit')
|
||||||
const nt = newTab()
|
const nt = newTab()
|
||||||
@@ -782,96 +796,129 @@ export default function App(): React.ReactElement {
|
|||||||
[previewMaxWidth]
|
[previewMaxWidth]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handlersRef = React.useRef({
|
const handleDrop = React.useCallback(
|
||||||
openFileDialog: async (): Promise<void> => {},
|
async (e: React.DragEvent): Promise<void> => {
|
||||||
saveActive: async (): Promise<void> => {},
|
e.preventDefault()
|
||||||
saveActiveAs: async (): Promise<void> => {},
|
e.stopPropagation()
|
||||||
newTabAction: (): void => {},
|
setDragOver(false)
|
||||||
closeTabAction: (): void => {},
|
const files = e.dataTransfer.files
|
||||||
tabNext: (): void => {},
|
if (!files || files.length === 0) return
|
||||||
tabPrev: (): void => {}
|
for (let i = 0; i < files.length; i++) {
|
||||||
})
|
const file = files[i]
|
||||||
handlersRef.current = {
|
if (!file) continue
|
||||||
openFileDialog,
|
if (/\.(md|markdown|mdown|mkd|txt)$/i.test(file.name)) {
|
||||||
saveActive,
|
try {
|
||||||
saveActiveAs,
|
const content = await file.text()
|
||||||
newTabAction,
|
const filePath: string | null = (file as { path?: string }).path ?? file.name
|
||||||
closeTabAction,
|
if (filePath) {
|
||||||
tabNext,
|
const key = normalizePathKey(filePath)
|
||||||
tabPrev
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!api) return
|
const onKeyDown = (e: KeyboardEvent): void => {
|
||||||
const unsubClose = api.onRequestClose(() => {
|
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
||||||
try {
|
e.preventDefault()
|
||||||
const anyDirty = tabsRef.current.some(isDirty)
|
void saveActive()
|
||||||
if (!anyDirty) {
|
|
||||||
api.allowClose()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const ok = window.confirm('有未保存的标签,确定退出并丢弃更改?')
|
if (e.key === 'f' && (e.ctrlKey || e.metaKey)) {
|
||||||
if (ok) api.allowClose()
|
e.preventDefault()
|
||||||
} catch (e) {
|
setSearchVisible((v) => !v)
|
||||||
console.error('[MyReader] request-close handler', e)
|
return
|
||||||
api.allowClose()
|
|
||||||
}
|
}
|
||||||
})
|
if (e.key === 'n' && (e.ctrlKey || e.metaKey)) {
|
||||||
const unsubMenu = api.onMenuAction((action) => {
|
e.preventDefault()
|
||||||
void (async () => {
|
newTabAction()
|
||||||
try {
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
if (e.key === 'w' && (e.ctrlKey || e.metaKey)) {
|
||||||
console.error('[MyReader] menu action', action, e)
|
e.preventDefault()
|
||||||
|
closeTabAction()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
})()
|
if (e.key === 'Tab' && (e.ctrlKey || e.metaKey)) {
|
||||||
})
|
e.preventDefault()
|
||||||
return () => {
|
if (e.shiftKey) tabPrev()
|
||||||
unsubClose()
|
else tabNext()
|
||||||
unsubMenu()
|
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 => (
|
const renderPreviewArticle = (articleRef?: React.Ref<HTMLElement>): React.ReactElement => (
|
||||||
<PreviewArticle
|
<PreviewArticle
|
||||||
@@ -879,9 +926,20 @@ export default function App(): React.ReactElement {
|
|||||||
html={previewHtml}
|
html={previewHtml}
|
||||||
articleRef={articleRef}
|
articleRef={articleRef}
|
||||||
baseFilePath={activeTab?.filePath ?? null}
|
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)}
|
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()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h3 className="modal-title">{textPrompt.title}</h3>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="modal-text-input"
|
className="modal-text-input"
|
||||||
@@ -965,17 +1023,16 @@ export default function App(): React.ReactElement {
|
|||||||
onChange={(e) => setTextPromptInput(e.target.value)}
|
onChange={(e) => setTextPromptInput(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault()
|
|
||||||
textPrompt.resolve(textPromptInput)
|
textPrompt.resolve(textPromptInput)
|
||||||
setTextPrompt(null)
|
setTextPrompt(null)
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault()
|
|
||||||
textPrompt.resolve(null)
|
textPrompt.resolve(null)
|
||||||
setTextPrompt(null)
|
setTextPrompt(null)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button
|
<button
|
||||||
@@ -1014,28 +1071,23 @@ export default function App(): React.ReactElement {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="modal modal--wide"
|
className="modal"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h3 className="modal-title">插入图片</h3>
|
<h3 className="modal-title">插入图片</h3>
|
||||||
<p className="modal-hint">支持网络地址或本地图片文件(Markdown 中保存为 file:// 链接)。</p>
|
|
||||||
<label className="modal-field">
|
<label className="modal-field">
|
||||||
<span className="modal-label">图片地址</span>
|
<span className="modal-label">图片地址</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="modal-text-input"
|
className="modal-text-input"
|
||||||
placeholder="https://… 或选择本地文件"
|
|
||||||
value={imageUrlInput}
|
value={imageUrlInput}
|
||||||
onChange={(e) => setImageUrlInput(e.target.value)}
|
onChange={(e) => setImageUrlInput(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="modal-row-btns">
|
|
||||||
<button type="button" onClick={() => void pickLocalImage()}>
|
|
||||||
浏览本地图片…
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<label className="modal-field">
|
<label className="modal-field">
|
||||||
<span className="modal-label">替代文字</span>
|
<span className="modal-label">替代文字</span>
|
||||||
<input
|
<input
|
||||||
@@ -1043,6 +1095,7 @@ export default function App(): React.ReactElement {
|
|||||||
className="modal-text-input"
|
className="modal-text-input"
|
||||||
value={imageAltInput}
|
value={imageAltInput}
|
||||||
onChange={(e) => setImageAltInput(e.target.value)}
|
onChange={(e) => setImageAltInput(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
@@ -1050,9 +1103,7 @@ export default function App(): React.ReactElement {
|
|||||||
type="button"
|
type="button"
|
||||||
className="primary"
|
className="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const url = imageUrlInput.trim()
|
imageInsert.resolve({ url: imageUrlInput, alt: imageAltInput })
|
||||||
if (!url) return
|
|
||||||
imageInsert.resolve({ url, alt: imageAltInput.trim() || '图片' })
|
|
||||||
setImageInsert(null)
|
setImageInsert(null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1152,149 +1203,40 @@ export default function App(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{webNotesOpen ? (
|
{histCtxMenu ? (
|
||||||
<div
|
<div
|
||||||
className="modal-overlay"
|
className="ctx-menu"
|
||||||
role="presentation"
|
style={{
|
||||||
onMouseDown={() => setWebNotesOpen(false)}
|
left: Math.min(histCtxMenu.x, window.innerWidth - 140),
|
||||||
>
|
top: Math.min(histCtxMenu.y, window.innerHeight - 100)
|
||||||
<div
|
}}
|
||||||
className="modal modal--wide"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="web-notes-title"
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h3 className="modal-title" id="web-notes-title">
|
<button
|
||||||
Web 版额外说明
|
type="button"
|
||||||
</h3>
|
className="ctx-item danger"
|
||||||
<div className="modal-body web-notes-body">
|
onClick={() => {
|
||||||
<p>
|
removeFileHistory(loadFileHistory(), histCtxMenu.path)
|
||||||
在浏览器中运行,读写的是您本机通过「打开 / 保存」授权的文件,不会访问服务器磁盘。需要完整本地编辑体验请使用工具栏「桌面版下载地址」。
|
setHistCtxMenu(null)
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</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}
|
) : null}
|
||||||
|
|
||||||
<header className="tab-bar">
|
<header className="tab-bar">
|
||||||
{tabs.map((t) => (
|
<TabBar
|
||||||
<div key={t.id} className={`tab ${t.id === activeId ? 'active' : ''}`}>
|
tabs={tabs}
|
||||||
<button
|
activeId={activeId}
|
||||||
type="button"
|
onSelect={setActiveId}
|
||||||
className="tab-main"
|
onClose={requestCloseTab}
|
||||||
onClick={() => {
|
onCloseOthers={closeOtherTabs}
|
||||||
setActiveId(t.id)
|
onCloseAll={closeAllTabs}
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
{saveToast ? (
|
|
||||||
<div className="save-toast" role="status">
|
|
||||||
{saveToast}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<button type="button" onClick={() => void openFileDialog()}>
|
<button type="button" onClick={() => void openFileDialog()}>
|
||||||
打开
|
打开
|
||||||
</button>
|
</button>
|
||||||
@@ -1313,6 +1255,22 @@ export default function App(): React.ReactElement {
|
|||||||
<button type="button" onClick={() => setSidebarHidden((v) => !v)}>
|
<button type="button" onClick={() => setSidebarHidden((v) => !v)}>
|
||||||
{sidebarHidden ? '显示侧栏' : '隐藏侧栏'}
|
{sidebarHidden ? '显示侧栏' : '隐藏侧栏'}
|
||||||
</button>
|
</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 type="button" onClick={() => setWebNotesOpen(true)} title="Web 版使用说明">
|
||||||
额外说明
|
额外说明
|
||||||
</button>
|
</button>
|
||||||
@@ -1372,6 +1330,9 @@ export default function App(): React.ReactElement {
|
|||||||
{!sidebarHidden ? (
|
{!sidebarHidden ? (
|
||||||
<>
|
<>
|
||||||
<aside className="sidebar" style={{ width: sidebarWidth, minWidth: sidebarWidth }}>
|
<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">
|
<div className="file-pane outline-pane">
|
||||||
<OutlinePanel items={outline} onSelect={navigateToOutline} />
|
<OutlinePanel items={outline} onSelect={navigateToOutline} />
|
||||||
</div>
|
</div>
|
||||||
@@ -1393,7 +1354,76 @@ export default function App(): React.ReactElement {
|
|||||||
</button>
|
</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 ? (
|
{(viewMode === 'edit' || viewMode === 'hybrid') && activeTab ? (
|
||||||
<MarkdownFormatToolbar editorRef={editorRef} />
|
<MarkdownFormatToolbar editorRef={editorRef} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1411,7 +1441,13 @@ export default function App(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="content-empty">
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1431,7 +1467,13 @@ export default function App(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="content-empty">
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1479,12 +1521,104 @@ export default function App(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="content-empty">
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{activeTab ? (
|
||||||
|
<StatsPanel
|
||||||
|
doc={activeDoc}
|
||||||
|
width={220}
|
||||||
|
onResizeStart={() => {}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{statusText ? <div className="status-bar">{statusText}</div> : null}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DESKTOP_DOWNLOAD_URL =
|
||||||
|
'http://huangzjsecret.online/api/uploads/huangzhijun`sReader.zip'
|
||||||
+19
-1
@@ -13,6 +13,8 @@ import {
|
|||||||
|
|
||||||
export type MarkdownEditorHandle = {
|
export type MarkdownEditorHandle = {
|
||||||
applyAction: (id: string) => void | Promise<void>
|
applyAction: (id: string) => void | Promise<void>
|
||||||
|
searchText: (query: string) => boolean
|
||||||
|
getView: () => EditorView | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -286,7 +288,22 @@ export const EditorPane = React.forwardRef<MarkdownEditorHandle, Props>(
|
|||||||
requestImageDetails: requestImageDetailsRef.current,
|
requestImageDetails: requestImageDetailsRef.current,
|
||||||
requestColor: requestColorRef.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
|
if (!view) return
|
||||||
const cur = view.state.doc.toString()
|
const cur = view.state.doc.toString()
|
||||||
if (cur !== value) {
|
if (cur !== value) {
|
||||||
|
const main = view.state.selection.main
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
changes: { from: 0, to: cur.length, insert: value },
|
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
|
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;
|
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,
|
.toolbar button,
|
||||||
.sidebar-tabs button,
|
.sidebar-tabs button,
|
||||||
.path-row button,
|
.path-row button,
|
||||||
@@ -891,18 +879,6 @@ html[data-bg='22'] .app-root {
|
|||||||
overflow: hidden;
|
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 {
|
.editor-host {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
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 {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
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