界面优化,提高颜值方案
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+121
-13
@@ -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,6 +1395,7 @@ ${previewHtml}
|
|||||||
onCtxDelete={(path) => void deleteFileAt(path)}
|
onCtxDelete={(path) => void deleteFileAt(path)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{!isFullscreen && (
|
||||||
<header className="tab-bar">
|
<header className="tab-bar">
|
||||||
<TabBar
|
<TabBar
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
@@ -1376,8 +1407,21 @@ ${previewHtml}
|
|||||||
onReorder={onReorderTabs}
|
onReorder={onReorderTabs}
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="toolbar">
|
{!isFullscreen && (
|
||||||
|
<div className={`toolbar${toolbarCollapsed ? ' toolbar-collapsed' : ''}`}>
|
||||||
|
{toolbarCollapsed ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setToolbarCollapsed(false)}
|
||||||
|
title="展开功能栏"
|
||||||
|
className="toolbar-collapse-btn"
|
||||||
|
>
|
||||||
|
⋀
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<button type="button" onClick={() => void openFileDialog()}>
|
<button type="button" onClick={() => void openFileDialog()}>
|
||||||
打开
|
打开
|
||||||
</button>
|
</button>
|
||||||
@@ -1418,6 +1462,13 @@ ${previewHtml}
|
|||||||
>
|
>
|
||||||
📷 导出图片
|
📷 导出图片
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsFullscreen((v) => !v)}
|
||||||
|
title={isFullscreen ? '退出全屏阅读' : '全屏阅读'}
|
||||||
|
>
|
||||||
|
{isFullscreen ? '⛶ 退出全屏' : '⛶ 全屏阅读'}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAutoSave((v) => !v)}
|
onClick={() => setAutoSave((v) => !v)}
|
||||||
@@ -1463,16 +1514,27 @@ ${previewHtml}
|
|||||||
<select value={bgId} onChange={(e) => setBgId(e.target.value)}>
|
<select value={bgId} onChange={(e) => setBgId(e.target.value)}>
|
||||||
{BACKGROUNDS.map((t) => (
|
{BACKGROUNDS.map((t) => (
|
||||||
<option key={t.id} value={t.id}>
|
<option key={t.id} value={t.id}>
|
||||||
{t.name}
|
{t.name}{t.eyeCare ? ' 🛡️' : ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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,6 +1665,29 @@ ${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>
|
||||||
|
<div className="image-preview-header-actions">
|
||||||
|
<button
|
||||||
|
className="image-preview-zoom-out"
|
||||||
|
onClick={() => setImageScale((s) => Math.max(0.25, s - 0.25))}
|
||||||
|
title="缩小"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</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
|
<button
|
||||||
className="image-preview-close"
|
className="image-preview-close"
|
||||||
onClick={handleCloseImagePreview}
|
onClick={handleCloseImagePreview}
|
||||||
@@ -1600,11 +1696,23 @@ ${previewHtml}
|
|||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="image-preview-content">
|
</div>
|
||||||
|
<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">
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,38 +1,16 @@
|
|||||||
/** 背景 id 0–29,对应 `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 0–19,对应 `: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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user