界面优化,提高颜值方案

This commit is contained in:
2026-07-06 20:30:40 +08:00
parent 5f54a5ab6f
commit 9854f6ec9b
10 changed files with 1012 additions and 755 deletions
+5
View File
@@ -360,6 +360,11 @@ ipcMain.on('myreader:allow-close', (event) => {
win.close() win.close()
}) })
ipcMain.handle('myreader:set-menu-visible', (_evt, visible: boolean): void => {
const menu = visible ? buildMenu() : null
Menu.setApplicationMenu(menu)
})
let pendingOpenPaths: string[] = [] let pendingOpenPaths: string[] = []
const gotTheLock = app.requestSingleInstanceLock() const gotTheLock = app.requestSingleInstanceLock()
+2
View File
@@ -33,6 +33,7 @@ export interface MyReaderApi {
deleteFile: (filePath: string) => Promise<void> deleteFile: (filePath: string) => Promise<void>
renamePath: (oldPath: string, newName: string) => Promise<{ newPath: string }> renamePath: (oldPath: string, newName: string) => Promise<{ newPath: string }>
allowClose: () => void allowClose: () => void
setMenuVisible: (visible: boolean) => Promise<void>
onRequestClose: (handler: () => void) => () => void onRequestClose: (handler: () => void) => () => void
onMenuAction: (handler: (action: string) => void) => () => void onMenuAction: (handler: (action: string) => void) => () => void
onOpenFiles: (handler: (paths: string[]) => void) => () => void onOpenFiles: (handler: (paths: string[]) => void) => () => void
@@ -59,6 +60,7 @@ const api: MyReaderApi = {
allowClose: () => { allowClose: () => {
ipcRenderer.send('myreader:allow-close') ipcRenderer.send('myreader:allow-close')
}, },
setMenuVisible: (visible: boolean) => ipcRenderer.invoke('myreader:set-menu-visible', visible),
onRequestClose: (handler: () => void) => { onRequestClose: (handler: () => void) => {
const listener = (): void => { const listener = (): void => {
handler() handler()
+229 -121
View File
@@ -69,7 +69,7 @@ export default function App(): React.ReactElement {
return Number.isFinite(n) ? clamp(n, 160, 400) : 220 return Number.isFinite(n) ? clamp(n, 160, 400) : 220
}) })
const [statsHidden, setStatsHidden] = React.useState<boolean>( const [statsHidden, setStatsHidden] = React.useState<boolean>(
() => localStorage.getItem(STATS_HIDDEN_KEY) === '1' () => localStorage.getItem(STATS_HIDDEN_KEY) !== '0'
) )
const [splitPct, setSplitPct] = React.useState<number>(() => { const [splitPct, setSplitPct] = React.useState<number>(() => {
const n = Number(localStorage.getItem(SPLIT_KEY) ?? '48') const n = Number(localStorage.getItem(SPLIT_KEY) ?? '48')
@@ -129,11 +129,13 @@ export default function App(): React.ReactElement {
const [dragOver, setDragOver] = React.useState(false) const [dragOver, setDragOver] = React.useState(false)
const [imagePreviewData, setImagePreviewData] = React.useState<string | null>(null) const [imagePreviewData, setImagePreviewData] = React.useState<string | null>(null)
const [imagePreviewFileName, setImagePreviewFileName] = React.useState('') const [imagePreviewFileName, setImagePreviewFileName] = React.useState('')
const [imageScale, setImageScale] = React.useState(1) const [imageScale, setImageScale] = React.useState(5)
const [imagePosition, setImagePosition] = React.useState({ x: 0, y: 0 }) const [imagePosition, setImagePosition] = React.useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = React.useState(false) const [isDragging, setIsDragging] = React.useState(false)
const [dragStart, setDragStart] = React.useState({ x: 0, y: 0 }) const [dragStart, setDragStart] = React.useState({ x: 0, y: 0 })
const [isExportingImage, setIsExportingImage] = React.useState(false) const [isExportingImage, setIsExportingImage] = React.useState(false)
const [isFullscreen, setIsFullscreen] = React.useState(false)
const [toolbarCollapsed, setToolbarCollapsed] = React.useState(false)
const activeTab = tabs.find((t) => t.id === activeId) ?? null const activeTab = tabs.find((t) => t.id === activeId) ?? null
const activeDoc = activeTab?.content ?? '' const activeDoc = activeTab?.content ?? ''
@@ -147,6 +149,7 @@ export default function App(): React.ReactElement {
const openingPathsRef = React.useRef<Set<string>>(new Set()) const openingPathsRef = React.useRef<Set<string>>(new Set())
const startupFilesHandledRef = React.useRef(false) const startupFilesHandledRef = React.useRef(false)
const scrollPositionsRef = React.useRef<Map<string, number>>(new Map()) const scrollPositionsRef = React.useRef<Map<string, number>>(new Map())
const previewScrollPositionsRef = React.useRef<Map<string, number>>(new Map())
const prevActiveIdRef = React.useRef<string | null>(null) const prevActiveIdRef = React.useRef<string | null>(null)
const refreshDir = React.useCallback(async (): Promise<void> => { const refreshDir = React.useCallback(async (): Promise<void> => {
@@ -327,6 +330,13 @@ export default function App(): React.ReactElement {
localStorage.setItem(AUTO_SAVE_KEY, autoSave ? '1' : '0') localStorage.setItem(AUTO_SAVE_KEY, autoSave ? '1' : '0')
}, [autoSave]) }, [autoSave])
React.useEffect(() => {
api.setMenuVisible(!isFullscreen)
if (isFullscreen) {
setPreviewMaxWidth(window.innerWidth)
}
}, [isFullscreen])
React.useEffect(() => { React.useEffect(() => {
if (!activeDoc) { if (!activeDoc) {
setStatusText('') setStatusText('')
@@ -559,6 +569,14 @@ export default function App(): React.ReactElement {
if (editor) { if (editor) {
scrollPositionsRef.current.set(prevId, editor.getScrollTop()) scrollPositionsRef.current.set(prevId, editor.getScrollTop())
} }
const readPreviewOuter = readPreviewOuterRef.current
if (readPreviewOuter) {
previewScrollPositionsRef.current.set(prevId, readPreviewOuter.scrollTop)
}
const livePreviewOuter = livePreviewOuterRef.current
if (livePreviewOuter) {
previewScrollPositionsRef.current.set(prevId, livePreviewOuter.scrollTop)
}
} }
prevActiveIdRef.current = activeId prevActiveIdRef.current = activeId
}, [activeId]) }, [activeId])
@@ -572,6 +590,15 @@ export default function App(): React.ReactElement {
if (editor) editor.setScrollTop(saved) if (editor) editor.setScrollTop(saved)
}) })
} }
const previewSaved = previewScrollPositionsRef.current.get(activeId)
if (previewSaved !== undefined && previewSaved > 0) {
requestAnimationFrame(() => {
const readPreviewOuter = readPreviewOuterRef.current
if (readPreviewOuter) readPreviewOuter.scrollTop = previewSaved
const livePreviewOuter = livePreviewOuterRef.current
if (livePreviewOuter) livePreviewOuter.scrollTop = previewSaved
})
}
}, [editorMountKey]) }, [editorMountKey])
const scrollPreviewToAnchorId = React.useCallback( const scrollPreviewToAnchorId = React.useCallback(
@@ -893,7 +920,7 @@ export default function App(): React.ReactElement {
const startX = e.clientX const startX = e.clientX
const startW = previewMaxWidth const startW = previewMaxWidth
const onMove = (ev: MouseEvent): void => { const onMove = (ev: MouseEvent): void => {
setPreviewMaxWidth(clamp(startW + (ev.clientX - startX), 420, 1400)) setPreviewMaxWidth(clamp(startW + (ev.clientX - startX), 420, 3000))
} }
const onUp = (): void => { const onUp = (): void => {
document.removeEventListener('mousemove', onMove) document.removeEventListener('mousemove', onMove)
@@ -1282,10 +1309,13 @@ ${previewHtml}
setSearchVisible(false) setSearchVisible(false)
setSearchQuery('') setSearchQuery('')
} }
if (e.key === 'Escape' && isFullscreen) {
setIsFullscreen(false)
}
} }
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown)
}, [searchVisible, saveActive, newTabAction, activeId, requestCloseTab, cycleViewMode, openFileDialog, saveActiveAs, exportHtml, openFolderDialog, refreshDir, viewMode]) }, [searchVisible, saveActive, newTabAction, activeId, requestCloseTab, cycleViewMode, openFileDialog, saveActiveAs, exportHtml, openFolderDialog, refreshDir, viewMode, isFullscreen])
if (!api) { if (!api) {
return ( return (
@@ -1365,114 +1395,146 @@ ${previewHtml}
onCtxDelete={(path) => void deleteFileAt(path)} onCtxDelete={(path) => void deleteFileAt(path)}
/> />
<header className="tab-bar"> {!isFullscreen && (
<TabBar <header className="tab-bar">
tabs={tabs} <TabBar
activeId={activeId} tabs={tabs}
onSelect={setActiveId} activeId={activeId}
onClose={requestCloseTab} onSelect={setActiveId}
onCloseOthers={closeOtherTabs} onClose={requestCloseTab}
onCloseAll={closeAllTabs} onCloseOthers={closeOtherTabs}
onReorder={onReorderTabs} onCloseAll={closeAllTabs}
/> onReorder={onReorderTabs}
</header> />
</header>
)}
<div className="toolbar"> {!isFullscreen && (
<button type="button" onClick={() => void openFileDialog()}> <div className={`toolbar${toolbarCollapsed ? ' toolbar-collapsed' : ''}`}>
{toolbarCollapsed ? (
</button> <button
<button type="button" onClick={() => void openFolderDialog()}> type="button"
onClick={() => setToolbarCollapsed(false)}
</button> title="展开功能栏"
<button type="button" onClick={() => void saveActive()}> className="toolbar-collapse-btn"
>
</button>
<button type="button" onClick={() => void saveActiveAs()}> </button>
) : (
</button> <>
<button type="button" onClick={newTabAction}> <button type="button" onClick={() => void openFileDialog()}>
</button> </button>
<button type="button" onClick={cycleViewMode} title="在 编辑 / 阅读 / 创作 / 分栏 间切换"> <button type="button" onClick={() => void openFolderDialog()}>
{viewModeLabel(viewMode)}
</button> </button>
<button type="button" onClick={() => setSidebarHidden((v) => !v)}> <button type="button" onClick={() => void saveActive()}>
{sidebarHidden ? '显示侧栏' : '隐藏侧栏'}
</button> </button>
<button type="button" onClick={() => setStatsHidden((v) => !v)}> <button type="button" onClick={() => void saveActiveAs()}>
{statsHidden ? '显示统计' : '隐藏统计'}
</button> </button>
<button <button type="button" onClick={newTabAction}>
type="button"
onClick={exportHtml} </button>
disabled={!activeDoc.trim()} <button type="button" onClick={cycleViewMode} title="在 编辑 / 阅读 / 创作 / 分栏 间切换">
title="导出当前文档为 HTML 文件" {viewModeLabel(viewMode)}
> </button>
HTML <button type="button" onClick={() => setSidebarHidden((v) => !v)}>
</button> {sidebarHidden ? '显示侧栏' : '隐藏侧栏'}
<button </button>
type="button" <button type="button" onClick={() => setStatsHidden((v) => !v)}>
onClick={() => void handleExportImage()} {statsHidden ? '显示统计' : '隐藏统计'}
disabled={!activeDoc.trim()} </button>
title="导出当前文档为图片" <button
> type="button"
📷 onClick={exportHtml}
</button> disabled={!activeDoc.trim()}
<button title="导出当前文档为 HTML 文件"
type="button" >
onClick={() => setAutoSave((v) => !v)} HTML
title={autoSave ? '自动保存已开启(3秒无操作后保存)' : '自动保存已关闭'} </button>
style={autoSave ? { borderColor: 'var(--accent)', color: 'var(--accent)' } : undefined} <button
> type="button"
{autoSave ? '自动保存:开' : '自动保存:关'} onClick={() => void handleExportImage()}
</button> disabled={!activeDoc.trim()}
<div className="toolbar-tail"> title="导出当前文档为图片"
<label className="bg-select"> >
📷
<input </button>
type="number" <button
className="toolbar-mini-input" type="button"
min={1} onClick={() => setIsFullscreen((v) => !v)}
max={3} title={isFullscreen ? '退出全屏阅读' : '全屏阅读'}
step={0.05} >
value={previewLineHeight} {isFullscreen ? '⛶ 退出全屏' : '⛶ 全屏阅读'}
title="阅读视图行距(13" </button>
onChange={(e) => setPreviewLineHeight(e.target.value)} <button
onBlur={() => { type="button"
const n = Number.parseFloat(previewLineHeight) onClick={() => setAutoSave((v) => !v)}
if (!Number.isFinite(n)) { title={autoSave ? '自动保存已开启(3秒无操作后保存)' : '自动保存已关闭'}
setPreviewLineHeight('1.5') style={autoSave ? { borderColor: 'var(--accent)', color: 'var(--accent)' } : undefined}
return >
} {autoSave ? '自动保存:开' : '自动保存:关'}
setPreviewLineHeight(String(clamp(n, 1, 3))) </button>
}} <div className="toolbar-tail">
/> <label className="bg-select">
</label>
<label className="bg-select"> <input
type="number"
<select value={fontId} onChange={(e) => setFontId(e.target.value)}> className="toolbar-mini-input"
{FONTS.map((t) => ( min={1}
<option key={t.id} value={t.id}> max={3}
{t.name} step={0.05}
</option> value={previewLineHeight}
))} title="阅读视图行距(13"
</select> onChange={(e) => setPreviewLineHeight(e.target.value)}
</label> onBlur={() => {
<label className="bg-select"> const n = Number.parseFloat(previewLineHeight)
if (!Number.isFinite(n)) {
<select value={bgId} onChange={(e) => setBgId(e.target.value)}> setPreviewLineHeight('1.5')
{BACKGROUNDS.map((t) => ( return
<option key={t.id} value={t.id}> }
{t.name} setPreviewLineHeight(String(clamp(n, 1, 3)))
</option> }}
))} />
</select> </label>
</label> <label className="bg-select">
<select value={fontId} onChange={(e) => setFontId(e.target.value)}>
{FONTS.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</label>
<label className="bg-select">
<select value={bgId} onChange={(e) => setBgId(e.target.value)}>
{BACKGROUNDS.map((t) => (
<option key={t.id} value={t.id}>
{t.name}{t.eyeCare ? ' 🛡️' : ''}
</option>
))}
</select>
</label>
<button
type="button"
onClick={() => setToolbarCollapsed(true)}
title="收缩功能栏"
className="toolbar-collapse-btn"
>
</button>
</div>
</>
)}
</div> </div>
</div> )}
<div className="main"> <div className={`main${isFullscreen ? ' fullscreen-mode' : ''}`}>
{!sidebarHidden ? ( {!isFullscreen && !sidebarHidden ? (
<> <>
<aside className="sidebar" style={{ width: sidebarWidth, minWidth: sidebarWidth }}> <aside className="sidebar" style={{ width: sidebarWidth, minWidth: sidebarWidth }}>
<Sidebar <Sidebar
@@ -1509,7 +1571,7 @@ ${previewHtml}
title="拖动调整侧栏宽度" title="拖动调整侧栏宽度"
/> />
</> </>
) : ( ) : !isFullscreen ? (
<button <button
type="button" type="button"
className="sidebar-reopen" className="sidebar-reopen"
@@ -1518,7 +1580,7 @@ ${previewHtml}
> >
</button> </button>
)} ) : null}
<ContentArea <ContentArea
viewMode={viewMode} viewMode={viewMode}
@@ -1560,7 +1622,7 @@ ${previewHtml}
onSetSearchQuery={setSearchQuery} onSetSearchQuery={setSearchQuery}
/> />
{activeTab && !statsHidden ? ( {!isFullscreen && activeTab && !statsHidden ? (
<StatsPanel <StatsPanel
doc={activeDoc} doc={activeDoc}
width={statsWidth} width={statsWidth}
@@ -1568,7 +1630,18 @@ ${previewHtml}
/> />
) : null} ) : null}
{statusText ? <div className="status-bar">{statusText}</div> : null} {!isFullscreen && statusText ? <div className="status-bar">{statusText}</div> : null}
{isFullscreen && (
<button
type="button"
className="fullscreen-exit-btn"
onClick={() => setIsFullscreen(false)}
title="退出全屏阅读(Esc 键)"
>
</button>
)}
</div> </div>
{dragOver ? ( {dragOver ? (
<div className="global-drop-overlay"> <div className="global-drop-overlay">
@@ -1592,19 +1665,54 @@ ${previewHtml}
<div className="image-preview-modal" onClick={(e) => e.stopPropagation()}> <div className="image-preview-modal" onClick={(e) => e.stopPropagation()}>
<div className="image-preview-header"> <div className="image-preview-header">
<h3 className="image-preview-title">📷 </h3> <h3 className="image-preview-title">📷 </h3>
<button <div className="image-preview-header-actions">
className="image-preview-close" <button
onClick={handleCloseImagePreview} className="image-preview-zoom-out"
title="关闭" onClick={() => setImageScale((s) => Math.max(0.25, s - 0.25))}
> title="缩小"
>
</button>
</button>
<span className="image-preview-scale">{Math.round(imageScale * 100)}%</span>
<button
className="image-preview-zoom-in"
onClick={() => setImageScale((s) => Math.min(20, s + 0.25))}
title="放大"
>
+
</button>
<button
className="image-preview-reset"
onClick={handleResetZoom}
title="重置"
>
</button>
<button
className="image-preview-close"
onClick={handleCloseImagePreview}
title="关闭"
>
</button>
</div>
</div> </div>
<div className="image-preview-content"> <div
className="image-preview-content"
onWheel={(e) => {
e.preventDefault()
const delta = e.deltaY > 0 ? -0.25 : 0.25
setImageScale((s) => Math.min(20, Math.max(0.25, s + delta)))
}}
>
<img <img
src={imagePreviewData} src={imagePreviewData}
alt="预览" alt="预览"
className="image-preview-img" className="image-preview-img"
style={{
transform: `scale(${imageScale})`,
transformOrigin: 'center center'
}}
/> />
</div> </div>
<div className="image-preview-footer"> <div className="image-preview-footer">
+2 -10
View File
@@ -112,9 +112,9 @@ export function ContentArea(props: ContentAreaProps): React.ReactElement {
} }
return ( return (
<> <section className={`content${dragOver ? ' content-drag-over' : ''}`} onDrop={onDrop} onDragOver={onDragOver} onDragLeave={onDragLeave}>
{searchVisible ? ( {searchVisible ? (
<div className="search-bar-toolbar"> <div className="search-bar">
<input <input
type="text" type="text"
className="search-input" className="search-input"
@@ -148,8 +148,6 @@ export function ContentArea(props: ContentAreaProps): React.ReactElement {
</button> </button>
</div> </div>
) : null} ) : null}
<section className={`content${dragOver ? ' content-drag-over' : ''}`} onDrop={onDrop} onDragOver={onDragOver} onDragLeave={onDragLeave}>
{(viewMode === 'hybrid' || viewMode === 'live') && activeTab ? ( {(viewMode === 'hybrid' || viewMode === 'live') && activeTab ? (
<MarkdownFormatToolbar editorRef={editorRef} /> <MarkdownFormatToolbar editorRef={editorRef} />
) : null} ) : null}
@@ -158,11 +156,6 @@ export function ContentArea(props: ContentAreaProps): React.ReactElement {
<div className="preview-outer" ref={readPreviewOuterRef}> <div className="preview-outer" ref={readPreviewOuterRef}>
<div className="preview-shell" style={{ maxWidth: previewMaxWidth }}> <div className="preview-shell" style={{ maxWidth: previewMaxWidth }}>
{renderPreviewArticle(readPreviewArticleRef)} {renderPreviewArticle(readPreviewArticleRef)}
<div
className="preview-resizer"
title="拖动调整阅读区宽度"
onMouseDown={onStartPreviewWidthResize}
/>
</div> </div>
</div> </div>
) : ( ) : (
@@ -264,6 +257,5 @@ export function ContentArea(props: ContentAreaProps): React.ReactElement {
) )
) : null} ) : null}
</section> </section>
</>
) )
} }
+46 -4
View File
@@ -45,7 +45,7 @@ function mermaidSourceFromWrap(wrap: HTMLElement): string {
return pre?.textContent ?? '' return pre?.textContent ?? ''
} }
async function renderMermaidIn(root: HTMLElement, isStale: () => boolean): Promise<void> { async function renderMermaidIn(root: HTMLElement, isStale: () => boolean, onZoom?: (source: string, svgHtml: string) => void): Promise<void> {
const wraps = root.querySelectorAll<HTMLElement>('.md-diagram-wrap') const wraps = root.querySelectorAll<HTMLElement>('.md-diagram-wrap')
if (wraps.length === 0) return if (wraps.length === 0) return
@@ -88,6 +88,19 @@ async function renderMermaidIn(root: HTMLElement, isStale: () => boolean): Promi
}) })
}) })
wrap.dataset.mermaidRendered = '1' wrap.dataset.mermaidRendered = '1'
if (onZoom) {
const zoomBtn = document.createElement('button')
zoomBtn.type = 'button'
zoomBtn.className = 'diagram-zoom-btn'
zoomBtn.innerHTML = '🔍'
zoomBtn.title = '放大查看'
zoomBtn.onclick = (e) => {
e.stopPropagation()
onZoom(source, svg)
}
wrap.appendChild(zoomBtn)
}
} catch (err) { } catch (err) {
if (isStale()) return if (isStale()) return
const msg = err instanceof Error ? err.message : String(err) const msg = err instanceof Error ? err.message : String(err)
@@ -255,6 +268,11 @@ type Props = {
onOpenExternal?: (url: string) => void | Promise<void> onOpenExternal?: (url: string) => void | Promise<void>
} }
type DiagramZoomState = {
source: string
svgHtml: string
} | null
export function PreviewArticle({ export function PreviewArticle({
html, html,
articleRef, articleRef,
@@ -274,6 +292,16 @@ export function PreviewArticle({
const openExtRef = React.useRef(onOpenExternal) const openExtRef = React.useRef(onOpenExternal)
openExtRef.current = onOpenExternal openExtRef.current = onOpenExternal
const [diagramZoom, setDiagramZoom] = React.useState<DiagramZoomState>(null)
const handleDiagramZoom = React.useCallback((source: string, svgHtml: string) => {
setDiagramZoom({ source, svgHtml })
}, [])
const handleDiagramCloseZoom = React.useCallback(() => {
setDiagramZoom(null)
}, [])
const setRef = React.useCallback( const setRef = React.useCallback(
(el: HTMLElement | null) => { (el: HTMLElement | null) => {
innerRef.current = el innerRef.current = el
@@ -366,7 +394,7 @@ export function PreviewArticle({
const gen = ++renderGenRef.current const gen = ++renderGenRef.current
root.innerHTML = html root.innerHTML = html
void renderMermaidIn(root, () => gen !== renderGenRef.current).catch((err) => { void renderMermaidIn(root, () => gen !== renderGenRef.current, handleDiagramZoom).catch((err) => {
console.warn("[Huangzhijun's Reader] mermaid batch failed", err) console.warn("[Huangzhijun's Reader] mermaid batch failed", err)
}) })
@@ -374,9 +402,23 @@ export function PreviewArticle({
renderGenRef.current += 1 renderGenRef.current += 1
disconnectMermaidObservers(root) disconnectMermaidObservers(root)
} }
}, [html]) }, [html, handleDiagramZoom])
return ( return (
<article ref={setRef} className="preview" onClickCapture={handleClickCapture} /> <>
<article ref={setRef} className="preview" onClickCapture={handleClickCapture} />
{diagramZoom ? (
<div className="diagram-zoom-overlay" role="presentation" onClick={handleDiagramCloseZoom}>
<div className="diagram-zoom-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<button type="button" className="diagram-zoom-close" onClick={handleDiagramCloseZoom} aria-label="关闭">
</button>
<div className="diagram-zoom-content">
<div className="diagram-zoom-svg" dangerouslySetInnerHTML={{ __html: diagramZoom.svgHtml }} />
</div>
</div>
</div>
) : null}
</>
) )
} }
+1 -1
View File
@@ -176,7 +176,7 @@ export function StatsPanel({ doc, width, onResizeStart }: Props): React.ReactEle
</div> </div>
</div> </div>
</div> </div>
<div className="stats-panel-sign">made by huangzhijun 2026</div>
</div> </div>
</aside> </aside>
</> </>
+162 -400
View File
@@ -1,330 +1,201 @@
/* 背景(12):颜色变量,挂到 document.documentElement data-bg */ :root {
:root[data-bg='0'] { --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04);
--app-bg: #f3f4f6; --shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.06);
--panel-bg: #e5e7eb; --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--text: #111827; --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1);
--muted: #6b7280; --shadow-xl: 0 16px 40px rgba(0, 0, 0, 0.12);
--border: #d1d5db;
--accent: #2563eb; --radius-sm: 6px;
--btn-bg: #ffffff; --radius-md: 10px;
--hover: rgba(0, 0, 0, 0.06); --radius-lg: 16px;
--preview-bg: #ffffff; --radius-xl: 24px;
--preview-text: #111827; --radius-full: 9999px;
--preview-shadow: 0 8px 30px rgba(15, 23, 42, 0.08);
--code-bg: #f9fafb; --glass-border: rgba(255, 255, 255, 0.12);
--glass-bg: rgba(255, 255, 255, 0.06);
} }
:root[data-bg='0'] {
--app-bg: #fafafa;
--bg-start: #fafafa;
--bg-mid: #f5f5f5;
--bg-end: #f0f0f0;
--panel-bg: #fafafa;
--text: #1c1917;
--muted: #78716c;
--border: #e7e5e4;
--accent: #6366f1;
--btn-bg: #f5f5f5;
--hover: rgba(99, 102, 241, 0.08);
--preview-bg: #fafafa;
--preview-text: #1c1917;
--preview-shadow: none;
--code-bg: #f5f5f4;
--glass-border: rgba(255, 255, 255, 0.6);
--glass-bg: rgba(255, 255, 255, 0.8);
}
:root[data-bg='1'] { :root[data-bg='1'] {
--app-bg: #f4ecd8; --app-bg: #f4ecd8;
--panel-bg: #e8dfc8; --bg-start: #f4ecd8;
--bg-mid: #f0e6c8;
--bg-end: #ebe0c8;
--panel-bg: #f2e8d0;
--text: #3e3428; --text: #3e3428;
--muted: #7a6a58; --muted: #7a6a58;
--border: #d2c7b2; --border: #d2c7b2;
--accent: #8b4513; --accent: #8b4513;
--btn-bg: #fffaf0; --btn-bg: #f0e6d6;
--hover: rgba(62, 52, 40, 0.08); --hover: rgba(62, 52, 40, 0.08);
--preview-bg: #fffaf0; --preview-bg: #f4ecd8;
--preview-text: #3e3428; --preview-text: #3e3428;
--preview-shadow: 0 8px 24px rgba(62, 52, 40, 0.12); --preview-shadow: none;
--code-bg: #f0e6d6; --code-bg: #f0e6d6;
} }
:root[data-bg='2'] { :root[data-bg='2'] {
--app-bg: #0f172a; --app-bg: #1a1a1a;
--panel-bg: #111c33; --bg-start: #1a1a1a;
--text: #e5e7eb; --bg-mid: #202020;
--muted: #94a3b8; --bg-end: #1e1e1e;
--border: #1e293b; --panel-bg: #1e1e1e;
--accent: #38bdf8; --text: #fafafa;
--btn-bg: #1e293b; --muted: #a3a3a3;
--hover: rgba(148, 163, 184, 0.12); --border: #3f3f3f;
--preview-bg: #111827; --accent: #60a5fa;
--preview-text: #e5e7eb; --btn-bg: #202020;
--preview-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); --hover: rgba(96, 165, 250, 0.12);
--code-bg: #0b1224; --preview-bg: #1a1a1a;
--preview-text: #fafafa;
--preview-shadow: none;
--code-bg: #141414;
--glass-border: rgba(255, 255, 255, 0.1);
--glass-bg: rgba(255, 255, 255, 0.06);
} }
:root[data-bg='3'] { :root[data-bg='3'] {
--app-bg: #102216; --app-bg: #f0fdf4;
--panel-bg: #143220; --bg-start: #f0fdf4;
--text: #e7f7ec; --bg-mid: #dcfce7;
--muted: #8fb39a; --bg-end: #dcfce7;
--border: #1f3b2a; --panel-bg: #e8f5ec;
--accent: #4ade80; --text: #166534;
--btn-bg: #163527; --muted: #22c55e;
--hover: rgba(74, 222, 128, 0.12); --border: #bbf7d0;
--preview-bg: #132a1f; --accent: #15803d;
--preview-text: #e7f7ec; --btn-bg: #dcfce7;
--preview-shadow: 0 10px 36px rgba(0, 0, 0, 0.45); --hover: rgba(21, 128, 61, 0.08);
--code-bg: #0d1f16; --preview-bg: #f0fdf4;
--preview-text: #166534;
--preview-shadow: none;
--code-bg: #dcfce7;
} }
:root[data-bg='4'] { :root[data-bg='4'] {
--app-bg: linear-gradient(160deg, #e0f2fe 0%, #eef2ff 45%, #fdf4ff 100%); --app-bg: linear-gradient(160deg, #e0f2fe 0%, #eef2ff 45%, #fdf4ff 100%);
--panel-bg: rgba(255, 255, 255, 0.72); --panel-bg: rgba(255, 255, 255, 0.88);
--text: #0f172a; --text: #0f172a;
--muted: #475569; --muted: #475569;
--border: #cbd5f5; --border: #cbd5f5;
--accent: #4f46e5; --accent: #4f46e5;
--btn-bg: rgba(255, 255, 255, 0.9); --btn-bg: rgba(255, 255, 255, 0.95);
--hover: rgba(79, 70, 229, 0.1); --hover: rgba(79, 70, 229, 0.1);
--preview-bg: rgba(255, 255, 255, 0.92); --preview-bg: rgba(255, 255, 255, 0.98);
--preview-text: #0f172a; --preview-text: #0f172a;
--preview-shadow: 0 12px 40px rgba(15, 23, 42, 0.12); --preview-shadow: none;
--code-bg: #f8fafc; --code-bg: #f8fafc;
} }
:root[data-bg='5'] {
--app-bg: #fafafa;
--panel-bg: #ffffff;
--text: #111111;
--muted: #737373;
--border: #e5e5e5;
--accent: #db2777;
--btn-bg: #ffffff;
--hover: rgba(219, 39, 119, 0.08);
--preview-bg: #ffffff;
--preview-text: #111111;
--preview-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
--code-bg: #f5f5f5;
}
:root[data-bg='6'] {
--app-bg: #121212;
--panel-bg: #1a1a1a;
--text: #fafafa;
--muted: #a3a3a3;
--border: #262626;
--accent: #f97316;
--btn-bg: #171717;
--hover: rgba(249, 115, 22, 0.15);
--preview-bg: #0a0a0a;
--preview-text: #fafafa;
--preview-shadow: 0 0 0 1px #262626;
--code-bg: #000000;
}
:root[data-bg='7'] {
--app-bg: #f5f3ff;
--panel-bg: #ede9fe;
--text: #312e81;
--muted: #6d28d9;
--border: #ddd6fe;
--accent: #7c3aed;
--btn-bg: #ffffff;
--hover: rgba(124, 58, 237, 0.1);
--preview-bg: #ffffff;
--preview-text: #312e81;
--preview-shadow: 0 10px 32px rgba(49, 46, 129, 0.12);
--code-bg: #f5f3ff;
}
:root[data-bg='8'] {
--app-bg: #1c1917;
--panel-bg: #292524;
--text: #fafaf9;
--muted: #a8a29e;
--border: #44403c;
--accent: #fbbf24;
--btn-bg: #292524;
--hover: rgba(251, 191, 36, 0.12);
--preview-bg: #1c1917;
--preview-text: #fafaf9;
--preview-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
--code-bg: #0c0a09;
}
:root[data-bg='9'] {
--app-bg: #1a1033;
--panel-bg: #221547;
--text: #f3e8ff;
--muted: #c4b5fd;
--border: #3b2a6b;
--accent: #a78bfa;
--btn-bg: #2a185f;
--hover: rgba(167, 139, 250, 0.15);
--preview-bg: #120c24;
--preview-text: #f3e8ff;
--preview-shadow: 0 14px 48px rgba(0, 0, 0, 0.55);
--code-bg: #0f0820;
}
:root[data-bg='10'] {
--app-bg: #e8f4fc;
--panel-bg: #d7eaf8;
--text: #0c4a6e;
--muted: #0369a1;
--border: #bae6fd;
--accent: #0284c7;
--btn-bg: #f0f9ff;
--hover: rgba(2, 132, 199, 0.12);
--preview-bg: #ffffff;
--preview-text: #0c4a6e;
--preview-shadow: 0 10px 28px rgba(12, 74, 110, 0.1);
--code-bg: #e0f2fe;
}
:root[data-bg='11'] {
--app-bg: #fdf2f4;
--panel-bg: #fce7eb;
--text: #881337;
--muted: #9f1239;
--border: #fbcfe8;
--accent: #db2777;
--btn-bg: #fff1f2;
--hover: rgba(219, 39, 119, 0.1);
--preview-bg: #ffffff;
--preview-text: #881337;
--preview-shadow: 0 10px 28px rgba(136, 19, 55, 0.08);
--code-bg: #ffe4e6;
}
:root[data-bg='12'] {
--app-bg: #18181b;
--panel-bg: #27272a;
--text: #fafafa;
--muted: #a1a1aa;
--border: #3f3f46;
--accent: #22d3ee;
--btn-bg: #27272a;
--hover: rgba(34, 211, 238, 0.12);
--preview-bg: #18181b;
--preview-text: #fafafa;
--preview-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
--code-bg: #09090b;
}
:root[data-bg='13'] {
--app-bg: #ecfeff;
--panel-bg: #cffafe;
--text: #0e7490;
--muted: #0891b2;
--border: #a5f3fc;
--accent: #0d9488;
--btn-bg: #f0fdfa;
--hover: rgba(13, 148, 136, 0.12);
--preview-bg: #ffffff;
--preview-text: #134e4a;
--preview-shadow: 0 10px 28px rgba(8, 145, 178, 0.1);
--code-bg: #ccfbf1;
}
:root[data-bg='14'] {
--app-bg: #faf5f0;
--panel-bg: #ede4d8;
--text: #422006;
--muted: #92400e;
--border: #d6c4b0;
--accent: #b45309;
--btn-bg: #fffbeb;
--hover: rgba(180, 83, 9, 0.1);
--preview-bg: #fffbeb;
--preview-text: #422006;
--preview-shadow: 0 10px 28px rgba(66, 32, 6, 0.08);
--code-bg: #fef3c7;
}
:root[data-bg='15'] {
--app-bg: #f0fdfa;
--panel-bg: #ccfbf1;
--text: #115e59;
--muted: #0f766e;
--border: #99f6e4;
--accent: #0d9488;
--btn-bg: #ecfdf5;
--hover: rgba(13, 148, 136, 0.12);
--preview-bg: #ffffff;
--preview-text: #134e4a;
--preview-shadow: 0 10px 28px rgba(17, 94, 89, 0.08);
--code-bg: #d1fae5;
}
:root[data-bg='16'] {
--app-bg: #f5f3ff;
--panel-bg: #ede9fe;
--text: #4c1d95;
--muted: #6d28d9;
--border: #ddd6fe;
--accent: #7c3aed;
--btn-bg: #faf5ff;
--hover: rgba(124, 58, 237, 0.1);
--preview-bg: #ffffff;
--preview-text: #4c1d95;
--preview-shadow: 0 10px 28px rgba(76, 29, 149, 0.08);
--code-bg: #ede9fe;
}
:root[data-bg='17'] {
--app-bg: #faf8f5;
--panel-bg: #f3efe8;
--text: #44403c;
--muted: #78716c;
--border: #e7e5e4;
--accent: #d97706;
--btn-bg: #ffffff;
--hover: rgba(217, 119, 6, 0.1);
--preview-bg: #ffffff;
--preview-text: #44403c;
--preview-shadow: 0 10px 28px rgba(68, 64, 60, 0.08);
--code-bg: #f5f5f4;
}
:root[data-bg='18'] {
--app-bg: #1c1917;
--panel-bg: #292524;
--text: #fafaf9;
--muted: #a8a29e;
--border: #44403c;
--accent: #eab308;
--btn-bg: #292524;
--hover: rgba(234, 179, 8, 0.12);
--preview-bg: #1c1917;
--preview-text: #fafaf9;
--preview-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
--code-bg: #0c0a09;
}
:root[data-bg='19'] {
--app-bg: #fdf4f8;
--panel-bg: #fce7f3;
--text: #831843;
--muted: #be185d;
--border: #fbcfe8;
--accent: #db2777;
--btn-bg: #fff1f2;
--hover: rgba(219, 39, 119, 0.1);
--preview-bg: #ffffff;
--preview-text: #831843;
--preview-shadow: 0 10px 28px rgba(131, 24, 67, 0.08);
--code-bg: #ffe4e6;
}
:root[data-bg='20'] {
--app-bg: #f1f5f9;
--panel-bg: #e2e8f0;
--text: #1e293b;
--muted: #64748b;
--border: #cbd5e1;
--accent: #475569;
--btn-bg: #ffffff;
--hover: rgba(71, 85, 105, 0.12);
--preview-bg: #ffffff;
--preview-text: #1e293b;
--preview-shadow: 0 10px 28px rgba(30, 41, 59, 0.1);
--code-bg: #f8fafc;
}
:root[data-bg='21'] {
--app-bg: #ecfdf5;
--panel-bg: #d1fae5;
--text: #14532d;
--muted: #166534;
--border: #bbf7d0;
--accent: #15803d;
--btn-bg: #f0fdf4;
--hover: rgba(21, 128, 61, 0.12);
--preview-bg: #ffffff;
--preview-text: #14532d;
--preview-shadow: 0 10px 28px rgba(20, 83, 45, 0.08);
--code-bg: #dcfce7;
}
/* Kindle 风格:暖灰纸面 + 轻微颗粒感(纯 CSS) */ :root[data-bg='5'] {
:root[data-bg='22'] {
--app-bg: #e8e2d8; --app-bg: #e8e2d8;
--panel-bg: rgba(245, 241, 234, 0.94); --bg-start: #e8e2d8;
--bg-mid: #e0d8cc;
--bg-end: #dcccb8;
--panel-bg: #e6ddd2;
--text: #2d2a26; --text: #2d2a26;
--muted: #6b6560; --muted: #6b6560;
--border: #c9c2b8; --border: #c9c2b8;
--accent: #c2410c; --accent: #c2410c;
--btn-bg: #faf6ef; --btn-bg: #e4dcc8;
--hover: rgba(194, 65, 12, 0.08); --hover: rgba(194, 65, 12, 0.08);
--preview-bg: #f7f3eb; --preview-bg: #e8e2d8;
--preview-text: #1f1c18; --preview-text: #1f1c18;
--preview-shadow: 0 10px 28px rgba(45, 42, 38, 0.1); --preview-shadow: none;
--code-bg: #ede8df; --code-bg: #ede8df;
} }
/* 字体:仅字体栈 — 20 种效果差异明显的字体 */ :root[data-bg='6'] {
--app-bg: #f5efe6;
--bg-start: #f5efe6;
--bg-mid: #efe6d8;
--bg-end: #e8dfd0;
--panel-bg: #f1e9dd;
--text: #4a3f35;
--muted: #7a6b5e;
--border: #d9cfc0;
--accent: #a16207;
--btn-bg: #efe7db;
--hover: rgba(161, 98, 7, 0.08);
--preview-bg: #f5efe6;
--preview-text: #4a3f35;
--preview-shadow: none;
--code-bg: #efe7db;
}
:root[data-bg='7'] {
--app-bg: #0a0a0f;
--bg-start: #0a0a0f;
--bg-mid: #10101a;
--bg-end: #0f0f1a;
--panel-bg: #0f0f1a;
--text: #e2e8f0;
--muted: #718096;
--border: #2d2d44;
--accent: #6366f1;
--btn-bg: #12121e;
--hover: rgba(99, 102, 241, 0.15);
--preview-bg: #0a0a0f;
--preview-text: #e2e8f0;
--preview-shadow: none;
--code-bg: #070710;
}
:root[data-bg='8'] {
--app-bg: #e8e6e1;
--bg-start: #e8e6e1;
--bg-mid: #e0ddd5;
--bg-end: #d9d5cd;
--panel-bg: #e4e1d9;
--text: #404040;
--muted: #707070;
--border: #c5c1b8;
--accent: #6b7280;
--btn-bg: #e0ddd5;
--hover: rgba(107, 114, 128, 0.08);
--preview-bg: #e8e6e1;
--preview-text: #404040;
--preview-shadow: none;
--code-bg: #dfddd8;
}
:root[data-bg='9'] {
--app-bg: #f8f5f0;
--bg-start: #f8f5f0;
--bg-mid: #f0ebe1;
--bg-end: #e8dfd0;
--panel-bg: #f2ede3;
--text: #4a3e30;
--muted: #8a7d6e;
--border: #d8cec0;
--accent: #b45309;
--btn-bg: #f0ebe1;
--hover: rgba(180, 83, 9, 0.08);
--preview-bg: #f8f5f0;
--preview-text: #4a3e30;
--preview-shadow: none;
--code-bg: #f0ebe1;
}
:root[data-font='0'] { :root[data-font='0'] {
--font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; --font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-editor: ui-monospace, Menlo, Consolas, monospace; --font-editor: ui-monospace, Menlo, Consolas, monospace;
@@ -426,115 +297,6 @@
--font-preview: 'Space Mono', 'Courier New', monospace; --font-preview: 'Space Mono', 'Courier New', monospace;
} }
/* 新增 7 种背景(23-29),凑满 30 种 */
:root[data-bg='23'] {
--app-bg: #fffdf5;
--panel-bg: #fef9e7;
--text: #5c4033;
--muted: #8b7355;
--border: #f0e6d3;
--accent: #d4a373;
--btn-bg: #ffffff;
--hover: rgba(92, 64, 51, 0.06);
--preview-bg: #ffffff;
--preview-text: #5c4033;
--preview-shadow: 0 10px 28px rgba(92, 64, 51, 0.08);
--code-bg: #faf5eb;
}
[data-bg='23'] ::selection,
[data-bg='24'] ::selection,
[data-bg='25'] ::selection,
[data-bg='26'] ::selection,
[data-bg='27'] ::selection,
[data-bg='28'] ::selection,
[data-bg='29'] ::selection {
background: var(--border);
}
:root[data-bg='24'] {
--app-bg: #fef4e8;
--panel-bg: #fce8d0;
--text: #7b341e;
--muted: #c05621;
--border: #fbd38d;
--accent: #dd6b20;
--btn-bg: #fffaf0;
--hover: rgba(221, 107, 32, 0.1);
--preview-bg: #fff7ed;
--preview-text: #7b341e;
--preview-shadow: 0 10px 28px rgba(123, 52, 30, 0.1);
--code-bg: #feebc8;
}
:root[data-bg='25'] {
--app-bg: #eef6ff;
--panel-bg: #dbeafe;
--text: #1e3a5f;
--muted: #3b82f6;
--border: #bfdbfe;
--accent: #2563eb;
--btn-bg: #f8faff;
--hover: rgba(37, 99, 235, 0.1);
--preview-bg: #ffffff;
--preview-text: #1e3a5f;
--preview-shadow: 0 10px 28px rgba(30, 58, 95, 0.08);
--code-bg: #e0edff;
}
:root[data-bg='26'] {
--app-bg: #0a0a0f;
--panel-bg: #12121e;
--text: #e2e8f0;
--muted: #718096;
--border: #2d2d44;
--accent: #6366f1;
--btn-bg: #1a1a2e;
--hover: rgba(99, 102, 241, 0.15);
--preview-bg: #0f0f1a;
--preview-text: #e2e8f0;
--preview-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
--code-bg: #070710;
}
:root[data-bg='27'] {
--app-bg: #fdf2e9;
--panel-bg: #fae2d0;
--text: #7a2e1a;
--muted: #c05621;
--border: #f6c8a8;
--accent: #c2410c;
--btn-bg: #fff8f3;
--hover: rgba(194, 65, 12, 0.1);
--preview-bg: #fff5ed;
--preview-text: #7a2e1a;
--preview-shadow: 0 10px 28px rgba(122, 46, 26, 0.08);
--code-bg: #fde8d0;
}
:root[data-bg='28'] {
--app-bg: #eef2f7;
--panel-bg: #e0e6ef;
--text: #2d3748;
--muted: #718096;
--border: #d1d9e6;
--accent: #4a5568;
--btn-bg: #f8faff;
--hover: rgba(74, 85, 104, 0.1);
--preview-bg: #ffffff;
--preview-text: #2d3748;
--preview-shadow: 0 10px 28px rgba(45, 55, 72, 0.08);
--code-bg: #e8edf5;
}
:root[data-bg='29'] {
--app-bg: #1a1423;
--panel-bg: #2a1f3d;
--text: #e8e0f0;
--muted: #9b8ab0;
--border: #3d2f57;
--accent: #b39ddb;
--btn-bg: #2a1f3d;
--hover: rgba(179, 157, 219, 0.15);
--preview-bg: #151020;
--preview-text: #e8e0f0;
--preview-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
--code-bg: #100c1a;
}
:root[data-bg='4'] .toolbar, :root[data-bg='4'] .toolbar,
:root[data-bg='4'] .tab-bar, :root[data-bg='4'] .tab-bar,
:root[data-bg='4'] .sidebar { :root[data-bg='4'] .sidebar {
+11 -33
View File
@@ -1,38 +1,16 @@
/** 背景 id 029,对应 `appearance-styles.css` 中 `:root[data-bg]`。 */ export const BACKGROUNDS: { id: string; name: string; eyeCare?: boolean }[] = [
export const BACKGROUNDS: { id: string; name: string }[] = [ { id: '0', name: '极简白' },
{ id: '0', name: '暖雾灰' }, { id: '1', name: '羊皮纸', eyeCare: true },
{ id: '1', name: '羊皮纸' }, { id: '2', name: '深空灰' },
{ id: '2', name: '深海蓝' }, { id: '3', name: '护眼绿', eyeCare: true },
{ id: '3', name: '森绿' },
{ id: '4', name: '极光渐变' }, { id: '4', name: '极光渐变' },
{ id: '5', name: '极简粉' }, { id: '5', name: 'Kindle 纸感', eyeCare: true },
{ id: '6', name: '暗夜橙' }, { id: '6', name: '宣纸黄', eyeCare: true },
{ id: '7', name: '薰衣草' }, { id: '7', name: '暗夜紫' },
{ id: '8', name: '暖石褐' }, { id: '8', name: '磨砂灰', eyeCare: true },
{ id: '9', name: '星夜紫' }, { id: '9', name: '茶纸棕', eyeCare: true }
{ id: '10', name: '霜青' },
{ id: '11', name: '玫瑰灰' },
{ id: '12', name: '石墨黑' },
{ id: '13', name: '薄荷冰' },
{ id: '14', name: '焦糖拿铁' },
{ id: '15', name: '青瓷' },
{ id: '16', name: '暮光紫灰' },
{ id: '17', name: '沙岸米' },
{ id: '18', name: '松烟墨' },
{ id: '19', name: '樱粉雾' },
{ id: '20', name: '钢蓝灰' },
{ id: '21', name: '苔痕绿' },
{ id: '22', name: 'Kindle 纸感' },
{ id: '23', name: '暖雾白' },
{ id: '24', name: '落日橘' },
{ id: '25', name: '冰川蓝' },
{ id: '26', name: '暗夜墨' },
{ id: '27', name: '陶土红' },
{ id: '28', name: '灰蓝雾' },
{ id: '29', name: '夜雾紫' }
] ]
/** 字体 id 019,对应 `:root[data-font]`。 */
export const FONTS: { id: string; name: string }[] = [ export const FONTS: { id: string; name: string }[] = [
{ id: '0', name: '系统默认' }, { id: '0', name: '系统默认' },
{ id: '1', name: '经典衬线' }, { id: '1', name: '经典衬线' },
@@ -60,7 +38,7 @@ export type ViewMode = 'read' | 'hybrid' | 'live'
export function validBackgroundId(s: string | null): string | null { export function validBackgroundId(s: string | null): string | null {
if (s === null) return null if (s === null) return null
return /^([0-9]|[12][0-9])$/.test(s) ? s : null return /^[0-9]$/.test(s) ? s : null
} }
export function validFontId(s: string | null): string | null { export function validFontId(s: string | null): string | null {
+526 -181
View File
File diff suppressed because it is too large Load Diff
+27 -4
View File
@@ -170,9 +170,8 @@ md.renderer.rules.fence = (tokens, idx, options, env, self) => {
if (isMermaidFence(lang, raw)) { if (isMermaidFence(lang, raw)) {
const trimmed = raw.trim() const trimmed = raw.trim()
const escaped = md.utils.escapeHtml(trimmed)
const dataSrc = md.utils.escapeHtml(encodeURIComponent(trimmed)) const dataSrc = md.utils.escapeHtml(encodeURIComponent(trimmed))
return `<div class="md-diagram-wrap" data-source-line="${token.map[0]}" data-source-line-end="${codeEndLine}" data-mermaid-src="${dataSrc}"><pre class="mermaid">${escaped}</pre></div>` return `<div class="md-diagram-wrap" data-source-line="${token.map[0]}" data-source-line-end="${codeEndLine}" data-mermaid-src="${dataSrc}"><pre class="mermaid">${trimmed}</pre></div>`
} }
const highlighted = highlightCodeBlock(raw, lang) const highlighted = highlightCodeBlock(raw, lang)
@@ -184,7 +183,7 @@ md.renderer.rules.fence = (tokens, idx, options, env, self) => {
const purifyOpts: Parameters<typeof DOMPurify.sanitize>[1] = { const purifyOpts: Parameters<typeof DOMPurify.sanitize>[1] = {
USE_PROFILES: { html: true }, USE_PROFILES: { html: true },
ADD_TAGS: ['span', 'mark', 'kbd', 'sup', 'sub', 'details', 'summary', 'font', 'div'], ADD_TAGS: ['span', 'mark', 'kbd', 'sup', 'sub', 'details', 'summary', 'font', 'div', 'b', 'strong', 'em', 'i', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
ADD_ATTR: [ ADD_ATTR: [
'style', 'style',
'color', 'color',
@@ -228,7 +227,8 @@ export type RenderMarkdownEnv = {
} }
export function renderMarkdown(src: string, env?: RenderMarkdownEnv): string { export function renderMarkdown(src: string, env?: RenderMarkdownEnv): string {
const input = typeof src === 'string' ? src : '' let input = typeof src === 'string' ? src : ''
input = input.replace(/([^\n])(#{1,6}\s)/g, (_match, p1, p2) => `${p1}\n${p2}`)
const renderEnv = env ?? {} const renderEnv = env ?? {}
try { try {
const raw = md.render(input, renderEnv) const raw = md.render(input, renderEnv)
@@ -274,13 +274,36 @@ export function isValidOutlineHeading(displayText: string, rawLine: string): boo
return true return true
} }
const FENCE_OPEN_RE = /^(\s*)(`{3,}|~{3,})(.*)$/
function isFenceClose(line: string, fenceChar: string, fenceLen: number): boolean {
return new RegExp(`^\\s*\\${fenceChar}{${fenceLen},}\\s*$`).test(line)
}
/** 仅从 ATX `#` 标题提取;`line` 为 0-based。 */ /** 仅从 ATX `#` 标题提取;`line` 为 0-based。 */
export function extractOutline(src: string): OutlineItem[] { export function extractOutline(src: string): OutlineItem[] {
const input = typeof src === 'string' ? src : '' const input = typeof src === 'string' ? src : ''
const lines = input.split(/\r?\n/) const lines = input.split(/\r?\n/)
const out: OutlineItem[] = [] const out: OutlineItem[] = []
let inFence = false
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const raw = lines[i] ?? '' const raw = lines[i] ?? ''
const fenceOpen = FENCE_OPEN_RE.exec(raw)
if (fenceOpen) {
inFence = true
const fenceChar = fenceOpen[2]![0]!
const fenceLen = fenceOpen[2]!.length
while (i < lines.length && !isFenceClose(lines[i] ?? '', fenceChar, fenceLen)) {
i++
}
inFence = false
continue
}
if (inFence) continue
const m = /^(#{1,6})\s+(.+)$/.exec(raw) const m = /^(#{1,6})\s+(.+)$/.exec(raw)
if (!m) continue if (!m) continue
const level = m[1]!.length const level = m[1]!.length