代码优化
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>
|
||||
+484
-350
File diff suppressed because it is too large
Load Diff
+20
-2
@@ -13,6 +13,8 @@ import {
|
||||
|
||||
export type MarkdownEditorHandle = {
|
||||
applyAction: (id: string) => void | Promise<void>
|
||||
searchText: (query: string) => boolean
|
||||
getView: () => EditorView | null
|
||||
}
|
||||
|
||||
type Props = {
|
||||
@@ -286,7 +288,22 @@ export const EditorPane = React.forwardRef<MarkdownEditorHandle, Props>(
|
||||
requestImageDetails: requestImageDetailsRef.current,
|
||||
requestColor: requestColorRef.current
|
||||
})
|
||||
}
|
||||
},
|
||||
searchText: (query: string): boolean => {
|
||||
const view = viewRef.current
|
||||
if (!view || !query.trim()) return false
|
||||
const doc = view.state.doc.toString()
|
||||
const idx = doc.toLowerCase().indexOf(query.toLowerCase())
|
||||
if (idx >= 0) {
|
||||
view.dispatch({
|
||||
selection: { anchor: idx, head: idx + query.length },
|
||||
scrollIntoView: true
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
getView: (): EditorView | null => viewRef.current
|
||||
}),
|
||||
[]
|
||||
)
|
||||
@@ -347,9 +364,10 @@ export const EditorPane = React.forwardRef<MarkdownEditorHandle, Props>(
|
||||
if (!view) return
|
||||
const cur = view.state.doc.toString()
|
||||
if (cur !== value) {
|
||||
const main = view.state.selection.main
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: cur.length, insert: value },
|
||||
selection: { anchor: 0 },
|
||||
selection: main.empty ? { anchor: main.from } : { anchor: main.from, head: main.to },
|
||||
scrollIntoView: true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import * as React from 'react'
|
||||
|
||||
type Props = {
|
||||
doc: string
|
||||
width: number
|
||||
onResizeStart: (e: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
function wordCount(s: string): number {
|
||||
return s.replace(/[\u4e00-\u9fff]/g, ' $& ').split(/[\s]+/).filter(Boolean).length
|
||||
}
|
||||
|
||||
function codeBlockCount(s: string): number {
|
||||
return Math.floor(((s.match(/```/g) || []).length) / 2)
|
||||
}
|
||||
|
||||
function imageCount(s: string): number {
|
||||
return (s.match(/!\[.*?\]\(.*?\)/g) || []).length
|
||||
}
|
||||
|
||||
function linkCount(s: string): number {
|
||||
return (s.match(/\[.*?\]\(.*?\)/g) || []).length
|
||||
}
|
||||
|
||||
function tableCount(s: string): number {
|
||||
return (s.match(/^\|.+\|.+\|/gm) || []).length
|
||||
}
|
||||
|
||||
function blockquoteCount(s: string): number {
|
||||
return (s.match(/^>\s/gm) || []).length
|
||||
}
|
||||
|
||||
function listItemCount(s: string): number {
|
||||
return (s.match(/^(?:[-*+]|\d+\.)\s/gm) || []).length
|
||||
}
|
||||
|
||||
function mermaidCount(s: string): number {
|
||||
return (s.match(/```mermaid\n?/gi) || []).length
|
||||
}
|
||||
|
||||
function headingCount(s: string): number {
|
||||
return (s.match(/^#{1,6}\s/gm) || []).length
|
||||
}
|
||||
|
||||
function boldCount(s: string): number {
|
||||
return (s.match(/\*\*[^*]+\*\*/g) || []).length
|
||||
}
|
||||
|
||||
function italicCount(s: string): number {
|
||||
return (s.match(/(?<!\*)\*[^*\n]+\*(?!\*)/g) || []).length
|
||||
}
|
||||
|
||||
function inlineCodeCount(s: string): number {
|
||||
return (s.match(/`[^`\n]+`/g) || []).length
|
||||
}
|
||||
|
||||
function taskCount(s: string): { done: number; total: number } {
|
||||
const tasks = s.match(/^\s*[-*+]\s+\[([ x])\]/gm) || []
|
||||
const done = tasks.filter((t) => t.includes('[x]')).length
|
||||
return { done, total: tasks.length }
|
||||
}
|
||||
|
||||
function docSize(s: string): string {
|
||||
const sizeKB = new Blob([s]).size / 1024
|
||||
return sizeKB < 1 ? '<1 KB' : `${Math.round(sizeKB)} KB`
|
||||
}
|
||||
|
||||
export function StatsPanel({ doc, width, onResizeStart }: Props): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
<div className="stats-resize" onMouseDown={onResizeStart} title="拖动调整统计栏宽度" />
|
||||
<aside className="stats-panel" style={{ width, minWidth: width }}>
|
||||
<div className="stats-panel-header">
|
||||
<span className="stats-panel-header-icon">📊</span>
|
||||
文档统计
|
||||
</div>
|
||||
<div className="stats-panel-body">
|
||||
<div className="stats-panel-section">
|
||||
<div className="stats-panel-section-title">字数</div>
|
||||
<div className="stats-panel-grid">
|
||||
<div className="stats-panel-item">
|
||||
<span className="stats-panel-value">{doc.length.toLocaleString()}</span>
|
||||
<span className="stats-panel-label">字符</span>
|
||||
</div>
|
||||
<div className="stats-panel-item">
|
||||
<span className="stats-panel-value">{doc.replace(/\s/g, '').length.toLocaleString()}</span>
|
||||
<span className="stats-panel-label">字(无空格)</span>
|
||||
</div>
|
||||
<div className="stats-panel-item">
|
||||
<span className="stats-panel-value">{wordCount(doc).toLocaleString()}</span>
|
||||
<span className="stats-panel-label">词</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stats-panel-section">
|
||||
<div className="stats-panel-section-title">结构</div>
|
||||
<div className="stats-panel-grid">
|
||||
<div className="stats-panel-item">
|
||||
<span className="stats-panel-value">{doc === '' ? 0 : doc.split('\n').length}</span>
|
||||
<span className="stats-panel-label">行</span>
|
||||
</div>
|
||||
<div className="stats-panel-item">
|
||||
<span className="stats-panel-value">{doc.split(/\n\s*\n/).filter((p) => p.trim().length > 0).length}</span>
|
||||
<span className="stats-panel-label">段落</span>
|
||||
</div>
|
||||
<div className="stats-panel-item">
|
||||
<span className="stats-panel-value">{Math.max(1, Math.round(wordCount(doc) / 300))}</span>
|
||||
<span className="stats-panel-label">分钟阅读</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stats-panel-section">
|
||||
<div className="stats-panel-section-title">元素</div>
|
||||
<div className="stats-panel-grid">
|
||||
<div className="stats-panel-item">
|
||||
<span className="stats-panel-value">{codeBlockCount(doc)}</span>
|
||||
<span className="stats-panel-label">代码块</span>
|
||||
</div>
|
||||
<div className="stats-panel-item">
|
||||
<span className="stats-panel-value">{imageCount(doc)}</span>
|
||||
<span className="stats-panel-label">图片</span>
|
||||
</div>
|
||||
<div className="stats-panel-item">
|
||||
<span className="stats-panel-value">{linkCount(doc)}</span>
|
||||
<span className="stats-panel-label">链接</span>
|
||||
</div>
|
||||
<div className="stats-panel-item">
|
||||
<span className="stats-panel-value">{tableCount(doc)}</span>
|
||||
<span className="stats-panel-label">表格</span>
|
||||
</div>
|
||||
<div className="stats-panel-item">
|
||||
<span className="stats-panel-value">{blockquoteCount(doc)}</span>
|
||||
<span className="stats-panel-label">引用</span>
|
||||
</div>
|
||||
<div className="stats-panel-item">
|
||||
<span className="stats-panel-value">{listItemCount(doc)}</span>
|
||||
<span className="stats-panel-label">列表项</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stats-panel-section">
|
||||
<div className="stats-panel-section-title">图表</div>
|
||||
<div className="stats-panel-grid">
|
||||
<div className="stats-panel-item">
|
||||
<span className="stats-panel-value">{mermaidCount(doc)}</span>
|
||||
<span className="stats-panel-label">Mermaid</span>
|
||||
</div>
|
||||
<div className="stats-panel-item">
|
||||
<span className="stats-panel-value">{docSize(doc)}</span>
|
||||
<span className="stats-panel-label">文档大小</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stats-panel-section">
|
||||
<div className="stats-panel-section-title">格式</div>
|
||||
<div className="stats-panel-grid">
|
||||
<div className="stats-panel-item">
|
||||
<span className="stats-panel-value">{headingCount(doc)}</span>
|
||||
<span className="stats-panel-label">标题</span>
|
||||
</div>
|
||||
<div className="stats-panel-item">
|
||||
<span className="stats-panel-value">{boldCount(doc)}</span>
|
||||
<span className="stats-panel-label">加粗</span>
|
||||
</div>
|
||||
<div className="stats-panel-item">
|
||||
<span className="stats-panel-value">{italicCount(doc)}</span>
|
||||
<span className="stats-panel-label">斜体</span>
|
||||
</div>
|
||||
<div className="stats-panel-item">
|
||||
<span className="stats-panel-value">{inlineCodeCount(doc)}</span>
|
||||
<span className="stats-panel-label">行内代码</span>
|
||||
</div>
|
||||
<div className="stats-panel-item">
|
||||
<span className="stats-panel-value">{taskCount(doc).done}/{taskCount(doc).total}</span>
|
||||
<span className="stats-panel-label">任务</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import * as React from 'react'
|
||||
import type { Tab } from './types'
|
||||
import { tabLabel, isDirty } from './utils'
|
||||
|
||||
type Props = {
|
||||
tabs: Tab[]
|
||||
activeId: string
|
||||
onSelect: (id: string) => void
|
||||
onClose: (id: string) => void
|
||||
onCloseOthers: (id: string) => void
|
||||
onCloseAll: () => void
|
||||
}
|
||||
|
||||
export function TabBar({ tabs, activeId, onSelect, onClose, onCloseOthers, onCloseAll }: Props): React.ReactElement {
|
||||
const barRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = barRef.current
|
||||
if (!el) return
|
||||
const active = el.querySelector('.tab.active') as HTMLElement | null
|
||||
if (active) {
|
||||
active.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
|
||||
}
|
||||
}, [activeId])
|
||||
|
||||
return (
|
||||
<div className="tab-bar" ref={barRef}>
|
||||
{tabs.map((t) => (
|
||||
<TabItem
|
||||
key={t.id}
|
||||
tab={t}
|
||||
active={t.id === activeId}
|
||||
onSelect={() => onSelect(t.id)}
|
||||
onClose={() => onClose(t.id)}
|
||||
onCloseOthers={() => onCloseOthers(t.id)}
|
||||
onCloseAll={onCloseAll}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TabItemProps = {
|
||||
tab: Tab
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
onClose: () => void
|
||||
onCloseOthers: () => void
|
||||
onCloseAll: () => void
|
||||
}
|
||||
|
||||
function TabItem({ tab, active, onSelect, onClose, onCloseOthers, onCloseAll }: TabItemProps): React.ReactElement {
|
||||
const [menuOpen, setMenuOpen] = React.useState(false)
|
||||
const [menuPos, setMenuPos] = React.useState({ x: 0, y: 0 })
|
||||
const menuRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!menuOpen) return
|
||||
const close = (e: MouseEvent): void => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMenuOpen(false)
|
||||
}
|
||||
}
|
||||
const closeOnScroll = (): void => setMenuOpen(false)
|
||||
document.addEventListener('mousedown', close)
|
||||
window.addEventListener('scroll', closeOnScroll, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', close)
|
||||
window.removeEventListener('scroll', closeOnScroll, true)
|
||||
}
|
||||
}, [menuOpen])
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent): void => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setMenuPos({
|
||||
x: Math.min(e.clientX, window.innerWidth - 140),
|
||||
y: Math.min(e.clientY, window.innerHeight - 140)
|
||||
})
|
||||
setMenuOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`tab${active ? ' active' : ''}`} onContextMenu={handleContextMenu}>
|
||||
<button type="button" className="tab-main" onClick={onSelect} title={tab.filePath ?? undefined}>
|
||||
{isDirty(tab) ? <span className="dot">·</span> : null}
|
||||
<span className="tab-title">{tabLabel(tab)}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tab-close"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}}
|
||||
title="关闭"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{menuOpen ? (
|
||||
<div className="tab-context-menu" ref={menuRef} style={{ left: menuPos.x, top: menuPos.y }}>
|
||||
<button type="button" onClick={() => { onClose(); setMenuOpen(false) }}>
|
||||
关闭
|
||||
</button>
|
||||
<button type="button" onClick={() => { onCloseOthers(); setMenuOpen(false) }}>
|
||||
关闭其他
|
||||
</button>
|
||||
<button type="button" onClick={() => { onCloseAll(); setMenuOpen(false) }}>
|
||||
关闭全部
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const BG_KEY = 'myreader-bg-id'
|
||||
export const FONT_KEY = 'myreader-font-id'
|
||||
export const VIEW_KEY = 'myreader-view'
|
||||
export const LEGACY_READ_KEY = 'myreader-read'
|
||||
export const SIDEBAR_W_KEY = 'myreader-sidebar-w'
|
||||
export const SIDEBAR_HIDDEN_KEY = 'myreader-sidebar-hidden'
|
||||
export const SPLIT_KEY = 'myreader-split'
|
||||
export const PREVIEW_W_KEY = 'myreader-preview-max'
|
||||
export const SESSION_KEY = 'myreader-session-v1'
|
||||
export const PREVIEW_LINE_KEY = 'myreader-preview-line-height'
|
||||
export const AUTO_SAVE_KEY = 'myreader-auto-save'
|
||||
@@ -0,0 +1,35 @@
|
||||
const HISTORY_KEY = 'myreader-file-history-v1'
|
||||
const MAX_ITEMS = 80
|
||||
|
||||
export function loadFileHistory(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(HISTORY_KEY)
|
||||
if (!raw) return []
|
||||
const data = JSON.parse(raw) as { paths?: unknown }
|
||||
if (!Array.isArray(data.paths)) return []
|
||||
return data.paths.filter((p): p is string => typeof p === 'string' && p.length > 0)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function saveFileHistory(paths: string[]): void {
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify({ v: 1, paths }))
|
||||
}
|
||||
|
||||
export function addFileHistory(paths: string[], filePath: string): string[] {
|
||||
const next = [filePath, ...paths.filter((p) => p !== filePath)].slice(0, MAX_ITEMS)
|
||||
saveFileHistory(next)
|
||||
return next
|
||||
}
|
||||
|
||||
export function removeFileHistory(paths: string[], filePath: string): string[] {
|
||||
const next = paths.filter((p) => p !== filePath)
|
||||
saveFileHistory(next)
|
||||
return next
|
||||
}
|
||||
|
||||
export function historyFileName(path: string): string {
|
||||
const parts = path.split(/[/\\]/)
|
||||
return parts[parts.length - 1] || path
|
||||
}
|
||||
+256
-24
@@ -153,18 +153,6 @@ html[data-bg='22'] .app-root {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.save-toast {
|
||||
flex: 1 1 100%;
|
||||
margin: 0 0 4px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--panel-bg));
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.toolbar button,
|
||||
.sidebar-tabs button,
|
||||
.path-row button,
|
||||
@@ -891,18 +879,6 @@ html[data-bg='22'] .app-root {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-empty {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.editor-host {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -1254,6 +1230,262 @@ html[data-bg='22'] .app-root {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Search Bar ── */
|
||||
.search-bar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel-bg);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--btn-bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.search-close-btn {
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.search-close-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Status Bar ── */
|
||||
.status-bar {
|
||||
flex: 0 0 auto;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--panel-bg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Save Toast ── */
|
||||
.save-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 2000;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--panel-bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
box-shadow: var(--preview-shadow);
|
||||
animation: save-toast-in 0.25s ease-out;
|
||||
max-width: min(90vw, 480px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes save-toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Content Empty State ── */
|
||||
.content-empty {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 32px;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.content-empty-icon {
|
||||
font-size: 48px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.content-empty-hint {
|
||||
font-size: 13px;
|
||||
color: color-mix(in srgb, var(--muted) 80%, transparent);
|
||||
}
|
||||
|
||||
.content-drop-zone {
|
||||
margin-top: 16px;
|
||||
padding: 24px 32px;
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.content-drop-zone-icon {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Drag Over State ── */
|
||||
.content-drag-over {
|
||||
outline: 3px dashed var(--accent);
|
||||
outline-offset: -3px;
|
||||
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||
}
|
||||
|
||||
/* ── Global Drop Overlay ── */
|
||||
.global-drop-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.global-drop-overlay-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 40px 48px;
|
||||
border: 3px dashed var(--accent);
|
||||
border-radius: 16px;
|
||||
background: var(--panel-bg);
|
||||
box-shadow: var(--preview-shadow);
|
||||
}
|
||||
|
||||
.global-drop-overlay-icon {
|
||||
font-size: 56px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.global-drop-overlay-text {
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Stats Panel ── */
|
||||
.stats-resize {
|
||||
width: 5px;
|
||||
cursor: col-resize;
|
||||
flex: 0 0 5px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.stats-resize:hover {
|
||||
background: var(--accent);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.stats-panel {
|
||||
flex: 0 0 auto;
|
||||
align-self: stretch;
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--panel-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.stats-panel-header {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stats-panel-header-icon {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stats-panel-body {
|
||||
padding: 8px 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stats-panel-section {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.stats-panel-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
|
||||
}
|
||||
|
||||
.stats-panel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stats-panel-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--btn-bg);
|
||||
}
|
||||
|
||||
.stats-panel-value {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stats-panel-label {
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export type Tab = {
|
||||
id: string
|
||||
filePath: string | null
|
||||
content: string
|
||||
savedContent: string
|
||||
}
|
||||
|
||||
export type SessionTabRow = { path: string | null; content: string; saved: string }
|
||||
export type SessionV1 = { v: 1; activeIndex: number; tabs: SessionTabRow[]; bgId?: string; fontId?: string }
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Tab } from './types'
|
||||
import { PREVIEW_LINE_KEY, LEGACY_READ_KEY, VIEW_KEY } from './constants'
|
||||
import type { ViewMode } from './appearance'
|
||||
import { randomId } from './randomId'
|
||||
|
||||
export function clamp(n: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, n))
|
||||
}
|
||||
|
||||
export function normalizePathKey(p: string): string {
|
||||
return p.replace(/\\/g, '/').toLowerCase()
|
||||
}
|
||||
|
||||
export function newTab(): Tab {
|
||||
const id = randomId()
|
||||
return { id, filePath: null, content: '', savedContent: '' }
|
||||
}
|
||||
|
||||
export function tabLabel(t: Tab): string {
|
||||
if (t.filePath) {
|
||||
const parts = t.filePath.split(/[/\\]/)
|
||||
return parts[parts.length - 1] || t.filePath
|
||||
}
|
||||
return '未命名'
|
||||
}
|
||||
|
||||
export function isDirty(t: Tab): boolean {
|
||||
return t.content !== t.savedContent
|
||||
}
|
||||
|
||||
export function loadViewMode(): ViewMode {
|
||||
const v = localStorage.getItem(VIEW_KEY) as ViewMode | null
|
||||
if (v === 'edit' || v === 'read' || v === 'hybrid') return v
|
||||
if (localStorage.getItem(LEGACY_READ_KEY) === '1') return 'read'
|
||||
if (localStorage.getItem(LEGACY_READ_KEY) === '0') return 'edit'
|
||||
return 'read'
|
||||
}
|
||||
|
||||
export function viewModeLabel(m: ViewMode): string {
|
||||
if (m === 'edit') return '编辑'
|
||||
if (m === 'read') return '阅读'
|
||||
return '分栏'
|
||||
}
|
||||
|
||||
export function loadPreviewLineHeight(): string {
|
||||
const raw = localStorage.getItem(PREVIEW_LINE_KEY)
|
||||
if (!raw) return '1.5'
|
||||
const n = Number.parseFloat(raw)
|
||||
if (!Number.isFinite(n)) return '1.5'
|
||||
return String(Math.min(3, Math.max(1, n)))
|
||||
}
|
||||
Reference in New Issue
Block a user