代码优化

This commit is contained in:
2026-05-18 11:07:53 +08:00
parent 72d7a750a2
commit b1645056e5
10 changed files with 1170 additions and 376 deletions
Generated
+6
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+20 -2
View File
@@ -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
})
}
+183
View File
@@ -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
View File
@@ -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>
)
}
+11
View File
@@ -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'
+35
View File
@@ -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
View File
@@ -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;
+9
View File
@@ -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 }
+51
View File
@@ -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)))
}