bug修复

This commit is contained in:
2026-05-19 13:33:48 +08:00
parent 1e53162f0d
commit 025f226205
24 changed files with 2590 additions and 1214 deletions
+71 -25
View File
@@ -182,7 +182,7 @@ function buildMenu(): Menu {
return Menu.buildFromTemplate(template)
}
function createWindow(): BrowserWindow {
function createWindow(_pendingPaths?: string[]): BrowserWindow {
const preloadPath = getPreloadPath()
const iconPath = resolveAppIconPath()
let icon: NativeImage | undefined
@@ -218,7 +218,8 @@ function createWindow(): BrowserWindow {
})
if (process.env.ELECTRON_RENDERER_URL) {
void win.loadURL(process.env.ELECTRON_RENDERER_URL)
const url = new URL(process.env.ELECTRON_RENDERER_URL)
void win.loadURL(url.toString())
} else {
void win.loadFile(getRendererIndexHtml())
}
@@ -353,34 +354,79 @@ ipcMain.on('myreader:allow-close', (event) => {
let pendingOpenPaths: string[] = []
app.whenReady().then(() => {
Menu.setApplicationMenu(buildMenu())
const args = process.argv.slice(1)
for (const arg of args) {
const gotTheLock = app.requestSingleInstanceLock()
function collectFilePathsFromArgv(): void {
for (const arg of process.argv) {
if (arg === process.argv[0]) continue
if (arg.startsWith('--')) continue
if (existsSync(arg) && isMarkdownFile(arg)) {
pendingOpenPaths.push(arg)
if (!pendingOpenPaths.includes(arg)) {
pendingOpenPaths.push(arg)
}
}
}
const win = createWindow()
win.webContents.on('dom-ready', () => {
if (pendingOpenPaths.length > 0) {
setTimeout(() => {
win.webContents.send('myreader:open-files', pendingOpenPaths)
pendingOpenPaths = []
}, 500)
}
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
}
ipcMain.handle('myreader:get-pending-files', (): string[] => {
const paths = pendingOpenPaths.slice()
pendingOpenPaths = []
return paths
})
ipcMain.on('myreader:get-pending-files-sync', (event) => {
event.returnValue = pendingOpenPaths.slice()
pendingOpenPaths = []
})
if (!gotTheLock) {
app.quit()
} else {
app.on('second-instance', (_evt, argv) => {
const win = BrowserWindow.getAllWindows()[0]
if (win) {
if (win.isMinimized()) win.restore()
win.focus()
for (const arg of argv) {
if (arg === argv[0]) continue
if (arg.startsWith('--')) continue
if (existsSync(arg) && isMarkdownFile(arg)) {
if (!win.webContents.isDestroyed()) {
win.webContents.send('myreader:open-files', [arg])
}
}
}
}
})
app.on('open-file', (_evt, path) => {
if (!isMarkdownFile(path)) return
const win = BrowserWindow.getAllWindows()[0]
if (win && !win.webContents.isDestroyed() && !win.webContents.isLoading()) {
win.webContents.send('myreader:open-files', [path])
return
}
const key = path.replace(/\\/g, '/').toLowerCase()
if (!pendingOpenPaths.some((p) => p.replace(/\\/g, '/').toLowerCase() === key)) {
pendingOpenPaths.push(path)
}
})
app.whenReady().then(() => {
Menu.setApplicationMenu(buildMenu())
collectFilePathsFromArgv()
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
}
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
+29 -7
View File
@@ -7,6 +7,17 @@ import type {
SaveDialogResult
} from './ipc-types.js'
const bufferedOpenFiles: string[][] = []
let openFilesHandler: ((paths: string[]) => void) | null = null
ipcRenderer.on('myreader:open-files', (_e, paths: string[]) => {
if (openFilesHandler) {
openFilesHandler(paths)
} else {
bufferedOpenFiles.push(paths)
}
})
export interface MyReaderApi {
openFileDialog: () => Promise<OpenFileResult>
openFolderDialog: () => Promise<OpenFolderResult>
@@ -16,7 +27,6 @@ export interface MyReaderApi {
writeFile: (filePath: string, content: string) => Promise<void>
listDirectory: (dirPath: string) => Promise<ListDirResult>
parentDirectory: (dirPath: string) => Promise<string>
/** 相对当前 Markdown 文件解析内链 href → 绝对路径(外链原样返回) */
resolveRelativePath: (baseFilePath: string, href: string) => Promise<string>
openExternal: (url: string) => Promise<void>
defaultDirectory: () => Promise<string>
@@ -26,6 +36,7 @@ export interface MyReaderApi {
onRequestClose: (handler: () => void) => () => void
onMenuAction: (handler: (action: string) => void) => () => void
onOpenFiles: (handler: (paths: string[]) => void) => () => void
getPendingFilesIPC: () => Promise<string[]>
}
const api: MyReaderApi = {
@@ -67,13 +78,24 @@ const api: MyReaderApi = {
}
},
onOpenFiles: (handler: (paths: string[]) => void) => {
const listener = (_e: unknown, paths: string[]): void => {
handler(paths)
}
ipcRenderer.on('myreader:open-files', listener)
openFilesHandler = handler
return () => {
ipcRenderer.removeListener('myreader:open-files', listener)
openFilesHandler = null
}
},
getPendingFilesIPC: async (): Promise<string[]> => {
const fromMain = await ipcRenderer.invoke('myreader:get-pending-files')
const fromBuffer = bufferedOpenFiles.flat()
bufferedOpenFiles.length = 0
const seen = new Set<string>()
const merged: string[] = []
for (const p of [...fromMain, ...fromBuffer]) {
const key = p.replace(/\\/g, '/').toLowerCase()
if (seen.has(key)) continue
seen.add(key)
merged.push(p)
}
return merged
}
}
@@ -81,4 +103,4 @@ try {
contextBridge.exposeInMainWorld('myreader', api)
} catch (e) {
console.error('[MyReader] preload contextBridge failed', e)
}
}
+12
View File
@@ -59,6 +59,18 @@
"electronDownload": {
"mirror": "https://npmmirror.com/mirrors/electron/"
},
"fileAssociations": [
{
"ext": "md",
"name": "Markdown",
"description": "Markdown 文件"
},
{
"ext": "markdown",
"name": "Markdown",
"description": "Markdown 文件"
}
],
"win": {
"icon": "resources/app-icon.png",
"target": [
+289 -635
View File
File diff suppressed because it is too large Load Diff
+292
View File
@@ -0,0 +1,292 @@
import * as React from 'react'
import { COLOR_SWATCH_ROWS } from './colorPresets'
import type { Tab } from './types'
import { tabLabel } from './utils'
type CtxMenu = { x: number; y: number; path: string; kind: 'file' | 'dir' }
type HistCtxMenu = { x: number; y: number; path: string }
type Props = {
closePromptTab: Tab | undefined
closePromptTabId: string | null
onClosePromptDismiss: () => void
onClosePromptSave: () => void
onClosePromptDiscard: () => void
textPrompt: { title: string; defaultValue: string; resolve: (v: string | null) => void } | null
textPromptInput: string
onTextPromptInput: (v: string) => void
onTextPromptDismiss: () => void
onTextPromptConfirm: () => void
imageInsert: { resolve: (v: { url: string; alt: string } | null) => void } | null
imageUrlInput: string
imageAltInput: string
onImageUrlInput: (v: string) => void
onImageAltInput: (v: string) => void
onImageInsertDismiss: () => void
onImageInsertConfirm: () => void
onPickLocalImage: () => void
colorPicker: { resolve: (v: string | null) => void } | null
colorHexInput: string
onColorHexInput: (v: string) => void
onColorPickerDismiss: () => void
onColorPickerConfirm: () => void
histCtxMenu: HistCtxMenu | null
onHistCtxRemove: (path: string) => void
onHistCtxDismiss: () => void
ctxMenu: CtxMenu | null
onCtxRename: (path: string) => void
onCtxDelete: (path: string) => void
}
export function AppModals({
closePromptTab,
closePromptTabId,
onClosePromptDismiss,
onClosePromptSave,
onClosePromptDiscard,
textPrompt,
textPromptInput,
onTextPromptInput,
onTextPromptDismiss,
onTextPromptConfirm,
imageInsert,
imageUrlInput,
imageAltInput,
onImageUrlInput,
onImageAltInput,
onImageInsertDismiss,
onImageInsertConfirm,
onPickLocalImage,
colorPicker,
colorHexInput,
onColorHexInput,
onColorPickerDismiss,
onColorPickerConfirm,
histCtxMenu,
onHistCtxRemove,
onHistCtxDismiss,
ctxMenu,
onCtxRename,
onCtxDelete
}: Props): React.ReactElement {
return (
<>
{closePromptTabId && closePromptTab ? (
<div className="modal-overlay" role="presentation" onMouseDown={onClosePromptDismiss}>
<div className="modal" role="dialog" aria-modal="true" onMouseDown={(e) => e.stopPropagation()}>
<h3 className="modal-title"></h3>
<p className="modal-body">{tabLabel(closePromptTab)}</p>
<div className="modal-actions">
<button type="button" className="primary" onClick={onClosePromptSave}>
</button>
<button type="button" onClick={onClosePromptDiscard}>
</button>
<button type="button" onClick={onClosePromptDismiss}>
</button>
</div>
</div>
</div>
) : null}
{textPrompt ? (
<div
className="modal-overlay"
role="presentation"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onTextPromptDismiss()
}}
>
<div className="modal" role="dialog" aria-modal="true" onMouseDown={(e) => e.stopPropagation()}>
<h3 className="modal-title">{textPrompt.title}</h3>
<input
type="text"
className="modal-text-input"
value={textPromptInput}
onChange={(e) => onTextPromptInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
onTextPromptConfirm()
}
if (e.key === 'Escape') {
e.preventDefault()
onTextPromptDismiss()
}
}}
autoFocus
/>
<div className="modal-actions">
<button type="button" className="primary" onClick={onTextPromptConfirm}>
</button>
<button type="button" onClick={onTextPromptDismiss}>
</button>
</div>
</div>
</div>
) : null}
{imageInsert ? (
<div
className="modal-overlay"
role="presentation"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onImageInsertDismiss()
}}
>
<div
className="modal modal--wide"
role="dialog"
aria-modal="true"
onMouseDown={(e) => e.stopPropagation()}
>
<h3 className="modal-title"></h3>
<p className="modal-hint">Markdown file:// 链接)。</p>
<label className="modal-field">
<span className="modal-label"></span>
<input
type="text"
className="modal-text-input"
placeholder="https://… 或选择本地文件"
value={imageUrlInput}
onChange={(e) => onImageUrlInput(e.target.value)}
/>
</label>
<div className="modal-row-btns">
<button type="button" onClick={() => void onPickLocalImage()}>
</button>
</div>
<label className="modal-field">
<span className="modal-label"></span>
<input
type="text"
className="modal-text-input"
value={imageAltInput}
onChange={(e) => onImageAltInput(e.target.value)}
/>
</label>
<div className="modal-actions">
<button type="button" className="primary" onClick={onImageInsertConfirm}>
</button>
<button type="button" onClick={onImageInsertDismiss}>
</button>
</div>
</div>
</div>
) : null}
{colorPicker ? (
<div
className="modal-overlay"
role="presentation"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onColorPickerDismiss()
}}
>
<div
className="modal modal--wide"
role="dialog"
aria-modal="true"
onMouseDown={(e) => e.stopPropagation()}
>
<h3 className="modal-title"></h3>
<div className="color-picker-native">
<span className="modal-label"></span>
<input
type="color"
className="color-picker-spectrum"
value={/^#[0-9a-fA-F]{6}$/.test(colorHexInput) ? colorHexInput : '#dc2626'}
onChange={(e) => onColorHexInput(e.target.value.toLowerCase())}
aria-label="色谱"
/>
</div>
<label className="modal-field">
<span className="modal-label"></span>
<input
type="text"
className="modal-text-input"
value={colorHexInput}
onChange={(e) => onColorHexInput(e.target.value)}
spellCheck={false}
/>
</label>
<div className="color-swatch-grid" role="list">
{COLOR_SWATCH_ROWS.map((row, ri) => (
<div key={ri} className="color-swatch-row" role="presentation">
{row.map((hex) => (
<button
key={hex + ri}
type="button"
className={`color-swatch ${colorHexInput.toLowerCase() === hex ? 'selected' : ''}`}
style={{ backgroundColor: hex }}
title={hex}
aria-label={hex}
onClick={() => onColorHexInput(hex)}
/>
))}
</div>
))}
</div>
<div className="modal-actions">
<button type="button" className="primary" onClick={onColorPickerConfirm}>
使
</button>
<button type="button" onClick={onColorPickerDismiss}>
</button>
</div>
</div>
</div>
) : null}
{histCtxMenu ? (
<div
className="ctx-menu"
style={{
left: Math.min(histCtxMenu.x, window.innerWidth - 140),
top: Math.min(histCtxMenu.y, window.innerHeight - 100)
}}
onMouseDown={(e) => e.stopPropagation()}
>
<button
type="button"
className="ctx-item danger"
onClick={() => {
onHistCtxRemove(histCtxMenu.path)
onHistCtxDismiss()
}}
>
</button>
</div>
) : null}
{ctxMenu ? (
<div
className="ctx-menu"
style={{
left: Math.min(ctxMenu.x, window.innerWidth - 140),
top: Math.min(ctxMenu.y, window.innerHeight - 100)
}}
onMouseDown={(e) => e.stopPropagation()}
>
<button type="button" className="ctx-item" onClick={() => onCtxRename(ctxMenu.path)}>
</button>
{ctxMenu.kind === 'file' ? (
<button type="button" className="ctx-item danger" onClick={() => onCtxDelete(ctxMenu.path)}>
</button>
) : null}
</div>
) : null}
</>
)
}
+266
View File
@@ -0,0 +1,266 @@
import * as React from 'react'
import type { EditorView } from '@codemirror/view'
import type { MarkdownEditorHandle } from './EditorPane'
import { EditorPane } from './EditorPane'
import { LiveLineView } from './LiveLineView'
import { MarkdownFormatToolbar } from './MarkdownFormatToolbar'
import { PreviewArticle } from './PreviewArticle'
import type { Tab } from './types'
import type { ViewMode } from './appearance'
type ContentAreaProps = {
viewMode: ViewMode
activeTab: Tab | null
dragOver: boolean
previewMaxWidth: number
splitPct: number
searchQuery: string
searchVisible: boolean
editorMountKey: string
editorRef: React.RefObject<MarkdownEditorHandle | null>
readPreviewArticleRef: React.RefObject<HTMLElement | null>
readPreviewOuterRef: React.RefObject<HTMLDivElement | null>
hybridPreviewArticleRef: React.RefObject<HTMLElement | null>
hybridPreviewRef: React.RefObject<HTMLDivElement | null>
hybridPreviewShellRef: React.RefObject<HTMLDivElement | null>
hybridEditorViewRef: React.RefObject<EditorView | null>
hybridLinePulse: { seq: number; topPx: number } | null
previewHtml: string
baseFilePath: string | null
activeId: string
resolveRelativePath: (base: string, href: string) => Promise<string>
onOpenMarkdown: (path: string, anchorId?: string) => void
onOpenExternal: (url: string) => void
onDrop: (e: React.DragEvent) => void
onDragOver: (e: React.DragEvent) => void
onDragLeave: (e: React.DragEvent) => void
onEditorChange: (val: string) => void
onGotoLine: { line: number; seq: number } | null
onRequestText: (title: string, defaultValue: string) => Promise<string | null>
onRequestImageDetails: () => Promise<{ url: string; alt: string } | null>
onRequestColor: () => Promise<string | null>
livePreviewOuterRef?: React.RefObject<HTMLDivElement | null>
livePreviewArticleRef?: React.RefObject<HTMLElement | null>
onStartPreviewWidthResize: (e: React.MouseEvent) => void
onStartHybridSplitResize: (e: React.MouseEvent) => void
onHybridViewReady: (view: EditorView | null) => void
onHybridCursorLine: (line: number) => void
onSetSearchVisible: (v: boolean) => void
onSetSearchQuery: (q: string) => void
}
function scrollArticleToQuery(article: HTMLElement, query: string): void {
const text = article.textContent || ''
const idx = text.toLowerCase().indexOf(query.toLowerCase())
if (idx < 0) return
const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT)
let currentIdx = 0
let node: Text | null = null
while ((node = walker.nextNode() as Text | null)) {
const len = node.textContent?.length || 0
if (currentIdx + len > idx) {
node.parentElement?.scrollIntoView({ behavior: 'smooth', block: 'center' })
break
}
currentIdx += len
}
}
export function ContentArea(props: ContentAreaProps): React.ReactElement {
const {
viewMode, activeTab, dragOver, previewMaxWidth,
splitPct, searchQuery, searchVisible,
editorMountKey, editorRef,
readPreviewArticleRef, readPreviewOuterRef,
hybridPreviewArticleRef, hybridPreviewRef, hybridPreviewShellRef,
hybridEditorViewRef, hybridLinePulse,
previewHtml, baseFilePath, activeId, resolveRelativePath,
onOpenMarkdown, onOpenExternal,
onDrop, onDragOver, onDragLeave,
onEditorChange, onGotoLine,
onRequestText, onRequestImageDetails, onRequestColor,
livePreviewOuterRef, livePreviewArticleRef,
onStartPreviewWidthResize, onStartHybridSplitResize,
onHybridViewReady, onHybridCursorLine,
onSetSearchVisible, onSetSearchQuery
} = props
const renderPreviewArticle = (articleRef?: React.Ref<HTMLElement>): React.ReactElement => (
<PreviewArticle
key={activeId || 'no-tab'}
html={previewHtml}
articleRef={articleRef}
baseFilePath={baseFilePath}
resolveRelativePath={resolveRelativePath}
onOpenMarkdown={onOpenMarkdown}
onOpenExternal={onOpenExternal}
/>
)
const runSearch = (): void => {
if (!searchQuery.trim()) return
if (viewMode === 'read') {
const article = readPreviewArticleRef.current
if (article) scrollArticleToQuery(article, searchQuery)
return
}
if (viewMode === 'live') {
const article = livePreviewArticleRef?.current
if (article) scrollArticleToQuery(article, searchQuery)
return
}
hybridEditorViewRef.current?.searchText?.(searchQuery)
}
return (
<>
{searchVisible ? (
<div className="search-bar-toolbar">
<input
type="text"
className="search-input"
placeholder="搜索文档内容…(Enter 查找下一个,Esc 关闭)"
value={searchQuery}
onChange={(e) => onSetSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
onSetSearchVisible(false)
onSetSearchQuery('')
}
if (e.key === 'Enter') {
e.preventDefault()
runSearch()
}
}}
autoFocus
/>
<button type="button" className="search-next-btn" title="查找下一个" onClick={runSearch}>
</button>
<button
type="button"
className="search-close-btn"
onClick={() => {
onSetSearchVisible(false)
onSetSearchQuery('')
}}
>
</button>
</div>
) : null}
<section className={`content${dragOver ? ' content-drag-over' : ''}`} onDrop={onDrop} onDragOver={onDragOver} onDragLeave={onDragLeave}>
{viewMode === 'hybrid' && activeTab ? (
<MarkdownFormatToolbar editorRef={editorRef} />
) : null}
{viewMode === 'read' ? (
activeTab ? (
<div className="preview-outer" ref={readPreviewOuterRef}>
<div className="preview-shell" style={{ maxWidth: previewMaxWidth }}>
{renderPreviewArticle(readPreviewArticleRef)}
<div
className="preview-resizer"
title="拖动调整阅读区宽度"
onMouseDown={onStartPreviewWidthResize}
/>
</div>
</div>
) : (
<div className="content-empty">
<div className="content-empty-icon">📖</div>
<div>使</div>
<div className="content-empty-hint"> .md · Ctrl+F · Ctrl+S </div>
<div className="content-drop-zone">
<span className="content-drop-zone-icon">📂</span>
<span> Markdown </span>
</div>
</div>
)
) : null}
{viewMode === 'live' ? (
activeTab ? (
<LiveLineView
content={activeTab.content}
baseFilePath={baseFilePath}
activeId={activeId}
gotoLine={onGotoLine}
previewMaxWidth={previewMaxWidth}
scrollRef={livePreviewOuterRef}
articleRef={livePreviewArticleRef}
resolveRelativePath={resolveRelativePath}
onOpenMarkdown={onOpenMarkdown}
onOpenExternal={onOpenExternal}
onChange={onEditorChange}
/>
) : (
<div className="content-empty">
<div className="content-empty-icon">🎨</div>
<div>使</div>
<div className="content-empty-hint"> .md · Ctrl+F · Ctrl+S </div>
<div className="content-drop-zone">
<span className="content-drop-zone-icon">📂</span>
<span> Markdown </span>
</div>
</div>
)
) : null}
{viewMode === 'hybrid' ? (
activeTab ? (
<div className="hybrid-row">
<div className="hybrid-editor" style={{ flex: `0 0 ${splitPct}%` }}>
<EditorPane
ref={editorRef}
mountKey={`${editorMountKey}-hybrid`}
value={activeTab.content}
onChange={onEditorChange}
gotoLine={onGotoLine}
showLineNumbers={false}
requestText={onRequestText}
requestImageDetails={onRequestImageDetails}
requestColor={onRequestColor}
onViewReady={onHybridViewReady}
onHybridCursorLine={onHybridCursorLine}
/>
</div>
<div
className="hybrid-sash"
onMouseDown={onStartHybridSplitResize}
title="拖动调整分栏比例"
/>
<div ref={hybridPreviewRef} className="hybrid-preview">
<div
ref={hybridPreviewShellRef}
className="preview-shell preview-shell--tight hybrid-preview-shell"
style={{ maxWidth: '100%' }}
>
{hybridLinePulse ? (
<div
key={hybridLinePulse.seq}
className="hybrid-line-pulse"
style={{ top: `${hybridLinePulse.topPx}px` }}
aria-hidden
/>
) : null}
{renderPreviewArticle(hybridPreviewArticleRef)}
</div>
</div>
</div>
) : (
<div className="content-empty">
<div className="content-empty-icon">📝</div>
<div>使</div>
<div className="content-empty-hint"> .md · Ctrl+F · Ctrl+S </div>
<div className="content-drop-zone">
<span className="content-drop-zone-icon">📂</span>
<span> Markdown </span>
</div>
</div>
)
) : null}
</section>
</>
)
}
+12 -207
View File
@@ -1,5 +1,4 @@
import * as React from 'react'
import { EditorSelection } from '@codemirror/state'
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown'
import { EditorState } from '@codemirror/state'
@@ -10,11 +9,14 @@ import {
lineNumbers,
placeholder
} from '@codemirror/view'
import { applyMarkdownAction } from './markdown-actions'
export type MarkdownEditorHandle = {
applyAction: (id: string) => void | Promise<void>
searchText: (query: string) => boolean
getView: () => EditorView | null
getScrollTop: () => number
setScrollTop: (top: number) => void
}
type Props = {
@@ -32,211 +34,6 @@ type Props = {
requestColor?: () => Promise<string | null>
}
function getMainSelection(view: EditorView): { from: number; to: number } {
const r = view.state.selection.main
return { from: r.from, to: r.to }
}
function wrap(view: EditorView, left: string, right: string): void {
const { from, to } = getMainSelection(view)
const sel = view.state.sliceDoc(from, to)
view.dispatch({
changes: { from, to, insert: left + sel + right },
selection: EditorSelection.range(from + left.length, from + left.length + sel.length)
})
view.focus()
}
function insertAtCursor(view: EditorView, text: string): void {
const { from, to } = getMainSelection(view)
view.dispatch({
changes: { from, to, insert: text },
selection: EditorSelection.cursor(from + text.length)
})
view.focus()
}
function insertLines(view: EditorView, text: string): void {
insertAtCursor(view, text)
}
function linePrefixOnMainLine(view: EditorView, prefix: string): void {
const pos = view.state.selection.main.head
const line = view.state.doc.lineAt(pos)
const lineText = line.text
const hashes = /^#+\s/.exec(lineText)?.[0] ?? ''
let rest = lineText
if (hashes) rest = lineText.slice(hashes.length)
const newLine = prefix + rest
view.dispatch({
changes: { from: line.from, to: line.to, insert: newLine },
selection: EditorSelection.cursor(line.from + newLine.length)
})
view.focus()
}
function escapeMarkdownLinkFragment(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/\[/g, '\\[').replace(/\]/g, '\\]')
}
function sanitizeInlineColor(c: string): string {
const t = c.trim()
if (/^#[0-9a-fA-F]{3}$/.test(t)) {
const r = t[1]!
const g = t[2]!
const b = t[3]!
return `#${r}${r}${g}${g}${b}${b}`
}
if (/^#[0-9a-fA-F]{6}$/.test(t) || /^#[0-9a-fA-F]{8}$/.test(t)) return t.toLowerCase()
if (/^rgba?\(\s*[\d.\s%,]+\s*\)$/.test(t)) return t
if (/^hsla?\(\s*[\d.\s%,deg]+\s*\)$/.test(t)) return t
if (/^[\w-]{1,40}$/.test(t)) return t
return '#333333'
}
function sanitizeLinkUrl(url: string): string {
const t = url.trim() || 'https://'
if (/^file:/i.test(t)) return `<${t.replace(/[<>]/g, '')}>`
return t.replace(/\)/g, '%29')
}
type ActionDeps = {
requestText: (title: string, def: string) => Promise<string | null>
requestImageDetails?: () => Promise<{ url: string; alt: string } | null>
requestColor?: () => Promise<string | null>
}
async function applyMarkdownAction(view: EditorView, id: string, deps: ActionDeps): Promise<void> {
const { requestText, requestImageDetails, requestColor } = deps
try {
switch (id) {
case 'bold':
wrap(view, '**', '**')
break
case 'italic':
wrap(view, '*', '*')
break
case 'strike':
wrap(view, '~~', '~~')
break
case 'code':
wrap(view, '`', '`')
break
case 'codeBlock':
insertLines(view, '\n```\n\n```\n')
break
case 'link': {
const url = await requestText('链接地址', 'https://')
if (url === null) return
const { from, to } = getMainSelection(view)
const rawLabel = view.state.sliceDoc(from, to) || '链接文字'
const label = escapeMarkdownLinkFragment(rawLabel)
const insert = `[${label}](${sanitizeLinkUrl(url)})`
view.dispatch({
changes: { from, to, insert },
selection: EditorSelection.cursor(from + insert.length)
})
view.focus()
break
}
case 'image': {
if (requestImageDetails) {
const det = await requestImageDetails()
if (!det?.url?.trim()) return
const alt = (det.alt ?? '图片').trim() || '图片'
insertAtCursor(
view,
`![${escapeMarkdownLinkFragment(alt)}](${sanitizeLinkUrl(det.url.trim())})`
)
break
}
const url = await requestText('图片地址', 'https://')
if (url === null) return
const rawAlt = await requestText('替代文字', '图片')
if (rawAlt === null) return
const alt = escapeMarkdownLinkFragment(rawAlt || '图片')
insertAtCursor(view, `![${alt}](${sanitizeLinkUrl(url)})`)
break
}
case 'h1':
linePrefixOnMainLine(view, '# ')
break
case 'h2':
linePrefixOnMainLine(view, '## ')
break
case 'h3':
linePrefixOnMainLine(view, '### ')
break
case 'h4':
linePrefixOnMainLine(view, '#### ')
break
case 'h5':
linePrefixOnMainLine(view, '##### ')
break
case 'h6':
linePrefixOnMainLine(view, '###### ')
break
case 'ul':
linePrefixOnMainLine(view, '- ')
break
case 'ol':
linePrefixOnMainLine(view, '1. ')
break
case 'task':
linePrefixOnMainLine(view, '- [ ] ')
break
case 'quote':
linePrefixOnMainLine(view, '> ')
break
case 'hr':
insertLines(view, '\n---\n')
break
case 'table':
insertLines(
view,
'\n| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| | | |\n'
)
break
case 'mark':
wrap(view, '<mark>', '</mark>')
break
case 'color': {
if (requestColor) {
const c = await requestColor()
if (c === null || !c.trim()) return
const color = sanitizeInlineColor(c)
wrap(view, `<span style="color:${color}">`, '</span>')
break
}
const c = await requestText('颜色(如 #e11 或 red', '#dc2626')
if (c === null || !c.trim()) return
const color = sanitizeInlineColor(c)
wrap(view, `<span style="color:${color}">`, '</span>')
break
}
case 'sup':
wrap(view, '<sup>', '</sup>')
break
case 'sub':
wrap(view, '<sub>', '</sub>')
break
case 'kbd':
wrap(view, '<kbd>', '</kbd>')
break
case 'details':
insertLines(view, '\n<details>\n<summary>标题</summary>\n内容\n</details>\n')
break
case 'footnote':
insertLines(view, '[^1]\n\n[^1]: 脚注内容\n')
break
default:
break
}
} catch (e) {
console.error('[MyReader] toolbar action failed', id, e)
}
}
export const EditorPane = React.forwardRef<MarkdownEditorHandle, Props>(
function EditorPane(
{
@@ -303,7 +100,15 @@ export const EditorPane = React.forwardRef<MarkdownEditorHandle, Props>(
}
return false
},
getView: (): EditorView | null => viewRef.current
getView: (): EditorView | null => viewRef.current,
getScrollTop: (): number => {
const view = viewRef.current
return view ? view.scrollDOM.scrollTop : 0
},
setScrollTop: (top: number): void => {
const view = viewRef.current
if (view) view.scrollDOM.scrollTop = top
}
}),
[]
)
+43
View File
@@ -0,0 +1,43 @@
import * as React from 'react'
type IconProps = {
className?: string
size?: number
}
export function FolderIcon({ className, size = 18 }: IconProps): React.ReactElement {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
)
}
export function FileIcon({ className, size = 18 }: IconProps): React.ReactElement {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
<polyline points="13 2 13 9 20 9" />
</svg>
)
}
export function MdFileIcon({ className, size = 18 }: IconProps): React.ReactElement {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
<polyline points="13 2 13 9 20 9" />
<path d="M8 15l2 2 2-2" />
<path d="M12 13v4" />
</svg>
)
}
export function HistoryFileIcon({ className, size = 18 }: IconProps): React.ReactElement {
return (
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
)
}
+364
View File
@@ -0,0 +1,364 @@
import * as React from 'react'
import { renderMarkdown } from './markdown'
import { PreviewArticle } from './PreviewArticle'
import {
applyBlockEdit,
applyLineEdit,
parseLiveDocumentSegments,
type LiveBlockSegment,
type LiveSegment
} from './liveDocumentSegments'
type Props = {
content: string
baseFilePath: string | null
activeId: string
gotoLine: { line: number; seq: number } | null
previewMaxWidth: number
scrollRef?: React.RefObject<HTMLDivElement | null>
articleRef?: React.RefObject<HTMLElement | null>
resolveRelativePath?: (base: string, href: string) => Promise<string>
onOpenMarkdown?: (path: string, anchorId?: string) => void
onOpenExternal?: (url: string) => void
onChange: (next: string) => void
}
function splitLines(doc: string): string[] {
return doc.split(/\r?\n/)
}
function joinLines(lines: string[]): string {
return lines.join('\n')
}
function linePreviewHtml(line: string, baseFilePath: string | null): string {
if (!line.trim()) return '<p class="live-line-empty" aria-hidden="true">&nbsp;</p>'
return renderMarkdown(line, { baseFilePath })
}
type EditTarget =
| { type: 'line'; line: number }
| { type: 'block'; startLine: number; endLine: number }
function editKey(target: EditTarget): string {
return target.type === 'line' ? `line:${target.line}` : `block:${target.startLine}`
}
function resizeTextarea(ta: HTMLTextAreaElement): void {
ta.style.height = 'auto'
ta.style.height = `${ta.scrollHeight}px`
}
type BlockPreviewProps = {
html: string
baseFilePath: string | null
blockKind: 'code' | 'diagram'
resolveRelativePath?: (base: string, href: string) => Promise<string>
onOpenMarkdown?: (path: string, anchorId?: string) => void
onOpenExternal?: (url: string) => void
}
function LiveBlockPreview({
html,
baseFilePath,
blockKind,
resolveRelativePath,
onOpenMarkdown,
onOpenExternal
}: BlockPreviewProps): React.ReactElement {
return (
<div className={`live-block-preview-inner live-block-preview-inner--${blockKind}`}>
<PreviewArticle
html={html}
baseFilePath={baseFilePath}
resolveRelativePath={resolveRelativePath}
onOpenMarkdown={onOpenMarkdown}
onOpenExternal={onOpenExternal}
/>
</div>
)
}
export function LiveLineView({
content,
baseFilePath,
activeId,
gotoLine,
previewMaxWidth,
scrollRef,
articleRef,
resolveRelativePath,
onOpenMarkdown,
onOpenExternal,
onChange
}: Props): React.ReactElement {
const lines = React.useMemo(() => splitLines(content), [content])
const segments = React.useMemo(() => parseLiveDocumentSegments(content), [content])
const [editingKey, setEditingKey] = React.useState<string | null>(null)
const [draft, setDraft] = React.useState('')
const inputRef = React.useRef<HTMLTextAreaElement | null>(null)
const innerScrollRef = React.useRef<HTMLDivElement | null>(null)
const skipBlurCommitRef = React.useRef(false)
const setScrollRef = React.useCallback(
(el: HTMLDivElement | null) => {
innerScrollRef.current = el
if (scrollRef && 'current' in scrollRef) {
;(scrollRef as React.MutableRefObject<HTMLDivElement | null>).current = el
}
},
[scrollRef]
)
React.useEffect(() => {
setEditingKey(null)
}, [activeId])
React.useEffect(() => {
if (!editingKey) return
const ta = inputRef.current
if (!ta) return
ta.focus()
const len = ta.value.length
ta.setSelectionRange(len, len)
resizeTextarea(ta)
}, [editingKey])
React.useEffect(() => {
if (!gotoLine) return
const line = gotoLine.line
const root = innerScrollRef.current
let row = root?.querySelector<HTMLElement>(`[data-source-line="${line}"]`)
if (!row && root) {
for (const el of root.querySelectorAll<HTMLElement>('[data-source-line]')) {
const start = Number(el.getAttribute('data-source-line'))
const endRaw = el.getAttribute('data-source-line-end')
const end = endRaw !== null && Number.isFinite(Number(endRaw)) ? Number(endRaw) : start
if (line >= start && line <= end) {
row = el
break
}
}
}
row?.scrollIntoView({ behavior: 'smooth', block: 'center' })
row?.classList.add('live-block--flash', 'live-line--flash')
const t = window.setTimeout(() => {
row?.classList.remove('live-block--flash', 'live-line--flash')
}, 1600)
return () => window.clearTimeout(t)
}, [gotoLine])
const cancelEdit = React.useCallback((): void => {
skipBlurCommitRef.current = true
setEditingKey(null)
}, [])
const commitEdit = React.useCallback(
(target: EditTarget, text: string): void => {
let nextLines: string[]
if (target.type === 'line') {
nextLines = applyLineEdit(lines, target.line, text)
} else {
nextLines = applyBlockEdit(lines, target.startLine, target.endLine, text)
}
onChange(joinLines(nextLines))
setEditingKey(null)
},
[lines, onChange]
)
const startLineEdit = React.useCallback((line: number): void => {
setDraft(lines[line] ?? '')
setEditingKey(editKey({ type: 'line', line }))
}, [lines])
const startBlockEdit = React.useCallback((seg: LiveBlockSegment): void => {
setDraft(seg.text)
setEditingKey(editKey({ type: 'block', startLine: seg.startLine, endLine: seg.endLine }))
}, [])
const lineHtmlCache = React.useMemo(() => {
const map = new Map<number, string>()
for (const seg of segments) {
if (seg.kind === 'line') {
map.set(seg.line, linePreviewHtml(seg.text, baseFilePath))
}
}
return map
}, [segments, baseFilePath])
const blockHtmlCache = React.useMemo(() => {
const map = new Map<number, string>()
for (const seg of segments) {
if (seg.kind === 'block') {
map.set(seg.startLine, renderMarkdown(seg.text, { baseFilePath }))
}
}
return map
}, [segments, baseFilePath])
const renderLineEditor = (line: number): React.ReactElement => {
const target: EditTarget = { type: 'line', line }
return (
<div
key={editKey(target)}
className="live-line live-line--editing"
data-source-line={line}
>
<textarea
ref={inputRef}
className="live-line-input"
value={draft}
rows={1}
spellCheck={false}
onChange={(e) => {
setDraft(e.target.value)
resizeTextarea(e.target)
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault()
cancelEdit()
}
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
commitEdit(target, draft)
}
}}
onBlur={() => {
if (skipBlurCommitRef.current) {
skipBlurCommitRef.current = false
return
}
commitEdit(target, draft)
}}
/>
<span className="live-line-edit-hint">Esc · Ctrl+Enter </span>
</div>
)
}
const renderBlockEditor = (seg: LiveBlockSegment): React.ReactElement => {
const target: EditTarget = {
type: 'block',
startLine: seg.startLine,
endLine: seg.endLine
}
return (
<div
key={editKey(target)}
className={`live-block live-block--editing live-block--${seg.blockKind}`}
data-source-line={seg.startLine}
data-source-line-end={seg.endLine}
>
<textarea
ref={inputRef}
className="live-block-input"
value={draft}
spellCheck={false}
onChange={(e) => {
setDraft(e.target.value)
resizeTextarea(e.target)
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault()
cancelEdit()
}
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
commitEdit(target, draft)
}
}}
onBlur={() => {
if (skipBlurCommitRef.current) {
skipBlurCommitRef.current = false
return
}
commitEdit(target, draft)
}}
/>
<span className="live-line-edit-hint">
{seg.blockKind === 'diagram' ? '图表源码' : '代码块'} · Esc · Ctrl+Enter
</span>
</div>
)
}
const renderSegment = (seg: LiveSegment, index: number): React.ReactElement => {
if (seg.kind === 'block') {
const key = editKey({ type: 'block', startLine: seg.startLine, endLine: seg.endLine })
if (editingKey === key) return renderBlockEditor(seg)
const title =
seg.blockKind === 'diagram' ? '点击编辑图表(整块)' : '点击编辑代码块(整块)'
return (
<div
key={`block-${seg.startLine}-${index}`}
className={`live-block live-block--preview live-block--${seg.blockKind}`}
data-source-line={seg.startLine}
data-source-line-end={seg.endLine}
role="button"
tabIndex={0}
title={title}
onClick={(e) => {
if ((e.target as Element).closest('a')) return
startBlockEdit(seg)
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
startBlockEdit(seg)
}
}}
>
<LiveBlockPreview
html={blockHtmlCache.get(seg.startLine) ?? ''}
baseFilePath={baseFilePath}
blockKind={seg.blockKind}
resolveRelativePath={resolveRelativePath}
onOpenMarkdown={onOpenMarkdown}
onOpenExternal={onOpenExternal}
/>
<span className="live-block-edit-badge" aria-hidden>
{seg.blockKind === 'diagram' ? '点击编辑图表' : '点击编辑代码'}
</span>
</div>
)
}
const line = seg.line
const lineKey = editKey({ type: 'line', line })
if (editingKey === lineKey) return renderLineEditor(line)
return (
<div
key={`line-${line}-${index}`}
className="live-line live-line--preview"
data-source-line={line}
role="button"
tabIndex={0}
title="点击编辑此行"
onClick={() => startLineEdit(line)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
startLineEdit(line)
}
}}
dangerouslySetInnerHTML={{ __html: lineHtmlCache.get(line) ?? '' }}
/>
)
}
return (
<div className="live-editor-outer" ref={setScrollRef}>
<div className="live-editor-shell live-line-shell" style={{ maxWidth: previewMaxWidth }}>
<article className="preview live-line-doc" ref={articleRef}>
{segments.map(renderSegment)}
</article>
</div>
</div>
)
}
+8 -3
View File
@@ -1,6 +1,11 @@
import * as React from 'react'
import type { OutlineItem } from './markdown'
import { buildOutlineTree, collectBranchIds, type OutlineTreeNode } from './outlineTree'
import {
branchIdsCollapsedThroughLevel,
buildOutlineTree,
collectBranchIds,
type OutlineTreeNode
} from './outlineTree'
type Props = {
items: OutlineItem[]
@@ -13,8 +18,8 @@ export function OutlinePanel({ items, onSelect }: Props): React.ReactElement {
const [collapsed, setCollapsed] = React.useState<Set<string>>(() => new Set())
React.useEffect(() => {
setCollapsed(new Set())
}, [items])
setCollapsed(new Set(branchIdsCollapsedThroughLevel(tree, 2)))
}, [items, tree])
const toggleBranch = React.useCallback((id: string): void => {
setCollapsed((prev) => {
+44 -3
View File
@@ -11,10 +11,11 @@ function ensureMermaid(): void {
theme: 'neutral',
securityLevel: 'loose',
fontFamily: 'inherit',
flowchart: { useMaxWidth: true, htmlLabels: true },
sequence: { useMaxWidth: true, wrap: true },
flowchart: { useMaxWidth: true, htmlLabels: true, wrappingWidth: 180 },
sequence: { useMaxWidth: true, wrap: true, width: 150 },
er: { useMaxWidth: true },
gantt: { useMaxWidth: true }
gantt: { useMaxWidth: true },
mindmap: { useMaxWidth: true }
})
mermaidInited = true
}
@@ -68,6 +69,7 @@ async function renderMermaidIn(root: HTMLElement, isStale: () => boolean): Promi
if (!root.isConnected || !wrap.isConnected) return
host.innerHTML = svg
bindFunctions?.(host)
tuneMermaidDiagram(host, wrap)
wrap.dataset.mermaidRendered = '1'
} catch (err) {
if (isStale()) return
@@ -79,6 +81,45 @@ async function renderMermaidIn(root: HTMLElement, isStale: () => boolean): Promi
}
}
function tuneMermaidDiagram(host: HTMLElement, wrap: HTMLElement): void {
const svg = host.querySelector('svg')
if (!svg) return
svg.style.maxWidth = '100%'
svg.style.height = 'auto'
svg.removeAttribute('width')
svg.removeAttribute('height')
host.querySelectorAll('foreignObject').forEach((fo) => {
fo.setAttribute('overflow', 'visible')
const inner = fo.querySelector<HTMLElement>('div, span, p')
if (inner) {
inner.style.wordBreak = 'break-word'
inner.style.overflowWrap = 'anywhere'
inner.style.whiteSpace = 'normal'
inner.style.maxWidth = '100%'
inner.style.lineHeight = '1.35'
inner.style.fontSize = '13px'
}
})
const vb = svg.viewBox?.baseVal
const svgW = vb?.width || svg.getBoundingClientRect().width
const maxW = Math.max(120, wrap.clientWidth - 24)
if (svgW > maxW && maxW > 0) {
const scale = maxW / svgW
const svgH = vb?.height || svg.getBoundingClientRect().height
host.style.width = '100%'
host.style.overflowX = 'auto'
host.style.overflowY = 'hidden'
svg.style.display = 'block'
svg.style.margin = '0 auto'
svg.style.transform = `scale(${scale})`
svg.style.transformOrigin = 'top center'
host.style.minHeight = `${Math.ceil(svgH * scale) + 8}px`
}
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
+19 -3
View File
@@ -1,6 +1,7 @@
import * as React from 'react'
import type { Tab } from './types'
import { OutlinePanel } from './OutlinePanel'
import { FileIcon, FolderIcon, MdFileIcon, HistoryFileIcon } from './Icons'
import type { OutlineItem } from './markdown'
type DirEntry = { name: string; path: string; kind: 'file' | 'dir' }
@@ -18,6 +19,7 @@ type Props = {
fileHistory: string[]
onHistoryOpen: (path: string) => void
onHistoryContextMenu: (e: React.MouseEvent, path: string) => void
onHistoryClear: () => void
outlineItems: OutlineItem[]
onOutlineSelect: (item: OutlineItem) => void
tabs: Tab[]
@@ -37,6 +39,7 @@ export function Sidebar({
fileHistory,
onHistoryOpen,
onHistoryContextMenu,
onHistoryClear,
outlineItems,
onOutlineSelect,
tabs,
@@ -99,6 +102,7 @@ export function Sidebar({
fileHistory={fileHistory}
onOpen={onHistoryOpen}
onContextMenu={onHistoryContextMenu}
onClear={onHistoryClear}
/>
) : (
<OutlinePanel items={outlineItems} onSelect={onOutlineSelect} />
@@ -180,7 +184,9 @@ function FilePanel({
}}
onContextMenu={(ev) => onContextMenu(ev, e.path, e.kind)}
>
<span className="dir-item-icon">{e.kind === 'dir' ? '📁' : '📄'}</span>
<span className="dir-item-icon">
{e.kind === 'dir' ? <FolderIcon /> : e.name.endsWith('.md') ? <MdFileIcon /> : <FileIcon />}
</span>
<span className="dir-item-name">{e.name}</span>
</div>
))
@@ -193,14 +199,24 @@ function FilePanel({
function HistoryPanel({
fileHistory,
onOpen,
onContextMenu
onContextMenu,
onClear
}: {
fileHistory: string[]
onOpen: (path: string) => void
onContextMenu: (e: React.MouseEvent, path: string) => void
onClear: () => void
}): React.ReactElement {
return (
<div className="history-panel">
{fileHistory.length > 0 ? (
<div className="history-toolbar">
<span className="history-toolbar-meta">{fileHistory.length} </span>
<button type="button" className="history-clear-btn" onClick={onClear}>
</button>
</div>
) : null}
{fileHistory.length === 0 ? (
<div className="sidebar-empty">
<div className="sidebar-empty-icon">🕐</div>
@@ -215,7 +231,7 @@ function HistoryPanel({
onClick={() => onOpen(p)}
onContextMenu={(ev) => onContextMenu(ev, p)}
>
<span className="history-item-icon">📄</span>
<span className="history-item-icon"><HistoryFileIcon /></span>
<div className="history-item-body">
<span className="history-item-name">{p.split(/[/\\]/).pop()}</span>
<span className="history-item-path">{p}</span>
+1
View File
@@ -176,6 +176,7 @@ export function StatsPanel({ doc, width, onResizeStart }: Props): React.ReactEle
</div>
</div>
</div>
<div className="stats-panel-sign">made by huangzhijun 2026</div>
</div>
</aside>
</>
+117 -60
View File
@@ -1,4 +1,5 @@
import * as React from 'react'
import { createPortal } from 'react-dom'
import type { Tab } from './types'
import { tabLabel, isDirty } from './utils'
@@ -9,33 +10,114 @@ type Props = {
onClose: (id: string) => void
onCloseOthers: (id: string) => void
onCloseAll: () => void
onReorder: (fromIndex: number, toIndex: number) => void
}
export function TabBar({ tabs, activeId, onSelect, onClose, onCloseOthers, onCloseAll }: Props): React.ReactElement {
const barRef = React.useRef<HTMLDivElement>(null)
type ContextMenuState = {
x: number
y: number
onClose: () => void
onCloseOthers: () => void
onCloseAll: () => void
}
export function TabBar({ tabs, activeId, onSelect, onClose, onCloseOthers, onCloseAll, onReorder }: Props): React.ReactElement {
const [ctxMenu, setCtxMenu] = React.useState<ContextMenuState | null>(null)
const [dragOverIndex, setDragOverIndex] = React.useState<number | null>(null)
const dragIndexRef = React.useRef<number>(-1)
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' })
if (!ctxMenu) return
const close = (): void => {
setCtxMenu(null)
}
}, [activeId])
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [ctxMenu])
const handleDragStart = (e: React.DragEvent, index: number): void => {
dragIndexRef.current = index
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(index))
}
const handleDragOver = (e: React.DragEvent, index: number): void => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDragOverIndex(index)
}
const handleDragLeave = (): void => {
setDragOverIndex(null)
}
const handleDrop = (e: React.DragEvent, toIndex: number): void => {
e.preventDefault()
setDragOverIndex(null)
const fromIndex = dragIndexRef.current
if (fromIndex >= 0 && fromIndex !== toIndex) {
onReorder(fromIndex, toIndex)
}
}
const handleDragEnd = (): void => {
setDragOverIndex(null)
dragIndexRef.current = -1
}
const handleContextMenu = (e: React.MouseEvent, tabId: string): void => {
e.preventDefault()
e.stopPropagation()
const menuWidth = 140
const menuHeight = 140
let x = e.clientX
let y = e.clientY
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight
if (x < 0) x = 0
if (y < 0) y = 0
setCtxMenu({
x,
y,
onClose: () => onClose(tabId),
onCloseOthers: () => onCloseOthers(tabId),
onCloseAll
})
}
return (
<div className="tab-bar" ref={barRef}>
{tabs.map((t) => (
<div className="tab-bar">
{tabs.map((t, i) => (
<TabItem
key={t.id}
tab={t}
active={t.id === activeId}
index={i}
dragOver={dragOverIndex === i}
onSelect={() => onSelect(t.id)}
onClose={() => onClose(t.id)}
onCloseOthers={() => onCloseOthers(t.id)}
onCloseAll={onCloseAll}
onContextMenu={(e) => handleContextMenu(e, t.id)}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
/>
))}
{ctxMenu && createPortal(
<div className="tab-context-menu" style={{ left: ctxMenu.x, top: ctxMenu.y }}>
<button type="button" onClick={() => { ctxMenu.onClose(); setCtxMenu(null) }}>
</button>
<button type="button" onClick={() => { ctxMenu.onCloseOthers(); setCtxMenu(null) }}>
</button>
<button type="button" onClick={() => { ctxMenu.onCloseAll(); setCtxMenu(null) }}>
</button>
</div>,
document.body
)}
</div>
)
}
@@ -43,48 +125,36 @@ export function TabBar({ tabs, activeId, onSelect, onClose, onCloseOthers, onClo
type TabItemProps = {
tab: Tab
active: boolean
index: number
dragOver: boolean
onSelect: () => void
onClose: () => void
onCloseOthers: () => void
onCloseAll: () => void
onContextMenu: (e: React.MouseEvent) => void
onDragStart: (e: React.DragEvent, index: number) => void
onDragOver: (e: React.DragEvent, index: number) => void
onDragLeave: () => void
onDrop: (e: React.DragEvent, index: number) => void
onDragEnd: () => 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)
}
function TabItem({ tab, active, index, dragOver, onSelect, onClose, onContextMenu, onDragStart, onDragOver: onDragOverCb, onDragLeave, onDrop, onDragEnd }: TabItemProps): React.ReactElement {
return (
<div className={`tab${active ? ' active' : ''}`} onContextMenu={handleContextMenu}>
<div
className={`tab${active ? ' active' : ''}${dragOver ? ' drag-over' : ''}`}
draggable
onContextMenu={onContextMenu}
onDragStart={(e) => onDragStart(e, index)}
onDragOver={(e) => onDragOverCb(e, index)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, index)}
onDragEnd={onDragEnd}
>
<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>
<div className="tab-text">
<span className="tab-title">{tabLabel(tab)}</span>
{tab.filePath && <span className="tab-path">{tab.filePath}</span>}
</div>
</button>
<button
type="button"
@@ -97,19 +167,6 @@ function TabItem({ tab, active, onSelect, onClose, onCloseOthers, onCloseAll }:
>
</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>
)
}
+169 -120
View File
@@ -324,7 +324,7 @@
--code-bg: #ede8df;
}
/* 字体:仅字体栈 */
/* 字体:仅字体栈 — 20 种效果差异明显的字体 */
:root[data-font='0'] {
--font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-editor: ui-monospace, Menlo, Consolas, monospace;
@@ -336,154 +336,203 @@
--font-preview: 'Palatino Linotype', Palatino, 'Noto Serif SC', 'Songti SC', serif;
}
:root[data-font='2'] {
--font-ui: system-ui, sans-serif;
--font-editor: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace;
--font-ui: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
--font-editor: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
--font-preview: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
}
:root[data-font='3'] {
--font-ui: 'Microsoft YaHei', 'PingFang SC', sans-serif;
--font-ui: 'Songti SC', 'SimSun', 'Noto Serif SC', 'STSong', serif;
--font-editor: 'Sarasa Mono SC', Consolas, monospace;
--font-preview: 'Songti SC', 'SimSun', 'Noto Serif SC', 'STSong', serif;
}
:root[data-font='4'] {
--font-ui: 'IBM Plex Sans', system-ui, sans-serif;
--font-editor: 'IBM Plex Mono', Consolas, monospace;
--font-preview: 'IBM Plex Serif', Georgia, 'Noto Serif SC', serif;
--font-ui: 'KaiTi', 'STKaiti', 'Noto Serif SC', serif;
--font-editor: 'Sarasa Mono SC', Consolas, monospace;
--font-preview: 'KaiTi', 'STKaiti', 'Noto Serif SC', 'STSong', serif;
}
:root[data-font='5'] {
--font-ui: 'Helvetica Neue', Helvetica, Arial, sans-serif;
--font-editor: Menlo, Monaco, Consolas, monospace;
--font-preview: 'Helvetica Neue', Helvetica, Arial, 'PingFang SC', sans-serif;
}
:root[data-font='6'] {
--font-ui: Georgia, 'Times New Roman', serif;
--font-editor: 'Lucida Console', 'Courier New', monospace;
--font-preview: Georgia, Cambria, 'Times New Roman', 'Noto Serif SC', serif;
}
:root[data-font='7'] {
--font-ui: Verdana, Geneva, Tahoma, sans-serif;
--font-editor: 'Lucida Console', 'Courier New', monospace;
--font-preview: Verdana, Geneva, 'Microsoft YaHei', sans-serif;
}
:root[data-font='8'] {
--font-ui: 'Trebuchet MS', 'Segoe UI', sans-serif;
--font-editor: 'Courier New', Courier, monospace;
--font-preview: 'Book Antiqua', Palatino, 'Times New Roman', 'Noto Serif SC', serif;
}
:root[data-font='9'] {
--font-ui: 'Segoe UI Variable', 'Segoe UI', system-ui, sans-serif;
--font-editor: 'Cascadia Mono', 'Cascadia Code', Consolas, monospace;
--font-preview: 'Sitka Text', 'Sitka', Georgia, 'Noto Serif SC', serif;
}
:root[data-font='10'] {
--font-ui: 'Noto Sans SC', 'Microsoft YaHei', 'PingFang SC', sans-serif;
--font-editor: 'Noto Sans Mono CJK SC', 'Sarasa Mono SC', Consolas, monospace;
--font-preview: 'Noto Sans SC', 'Microsoft YaHei', 'PingFang SC', sans-serif;
}
:root[data-font='11'] {
--font-ui: 'STKaiti', 'KaiTi', 'Noto Serif SC', serif;
--font-editor: 'Sarasa Mono SC', Consolas, monospace;
--font-preview: 'STKaiti', 'KaiTi', 'Noto Serif SC', 'STSong', serif;
}
:root[data-font='12'] {
--font-ui: 'PingFang SC', 'Microsoft YaHei', 'Hiragino Sans GB', sans-serif;
--font-editor: 'SF Mono', Menlo, Consolas, monospace;
--font-preview: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
}
:root[data-font='13'] {
--font-ui: 'Hiragino Sans GB', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-editor: 'SF Mono', Menlo, Consolas, monospace;
--font-preview: 'Hiragino Mincho ProN', 'Songti SC', 'Noto Serif SC', serif;
}
:root[data-font='14'] {
--font-ui: 'LXGW WenKai', 'KaiTi', 'Microsoft YaHei', sans-serif;
--font-editor: 'JetBrains Mono', Consolas, monospace;
--font-preview: 'LXGW WenKai', 'KaiTi', 'Noto Serif SC', serif;
}
:root[data-font='15'] {
--font-ui: 'HarmonyOS Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
--font-editor: 'HarmonyOS Sans Mono', 'JetBrains Mono', Consolas, monospace;
--font-preview: 'HarmonyOS Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
:root[data-font='6'] {
--font-ui: 'Noto Sans SC', 'Microsoft YaHei', 'PingFang SC', sans-serif;
--font-editor: 'Noto Sans Mono CJK SC', 'Sarasa Mono SC', Consolas, monospace;
--font-preview: 'Noto Sans SC', 'Microsoft YaHei', 'PingFang SC', sans-serif;
}
:root[data-font='16'] {
--font-ui: 'OPPO Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
--font-editor: 'JetBrains Mono', Consolas, monospace;
--font-preview: 'OPPO Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
:root[data-font='7'] {
--font-ui: 'Helvetica Neue', Helvetica, Arial, sans-serif;
--font-editor: Menlo, Monaco, Consolas, monospace;
--font-preview: 'Helvetica Neue', Helvetica, Arial, 'PingFang SC', sans-serif;
}
:root[data-font='17'] {
--font-ui: 'MiSans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
--font-editor: 'JetBrains Mono', Consolas, monospace;
--font-preview: 'MiSans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
:root[data-font='8'] {
--font-ui: Verdana, Geneva, Tahoma, sans-serif;
--font-editor: 'Lucida Console', 'Courier New', monospace;
--font-preview: Verdana, Geneva, 'Microsoft YaHei', sans-serif;
}
:root[data-font='18'] {
--font-ui: 'Alibaba PuHuiTi', 'Microsoft YaHei', 'PingFang SC', sans-serif;
--font-editor: 'JetBrains Mono', Consolas, monospace;
--font-preview: 'Alibaba PuHuiTi', 'Microsoft YaHei', 'PingFang SC', sans-serif;
:root[data-font='9'] {
--font-ui: 'Courier New', Courier, 'Liberation Mono', monospace;
--font-editor: 'Courier New', Courier, 'Liberation Mono', monospace;
--font-preview: 'Courier New', Courier, 'Liberation Mono', 'Noto Serif SC', serif;
}
:root[data-font='19'] {
--font-ui: 'Smiley Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
--font-editor: 'JetBrains Mono', Consolas, monospace;
--font-preview: 'Smiley Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
:root[data-font='10'] {
--font-ui: 'IBM Plex Sans', 'Helvetica Neue', system-ui, sans-serif;
--font-editor: 'IBM Plex Mono', Consolas, monospace;
--font-preview: 'IBM Plex Serif', Georgia, 'Noto Serif SC', serif;
}
:root[data-font='20'] {
--font-ui: 'Maple Mono', 'Cascadia Code', Consolas, monospace;
--font-editor: 'Maple Mono', 'Cascadia Code', Consolas, monospace;
--font-preview: 'Maple Mono', 'Cascadia Code', Consolas, monospace;
}
:root[data-font='21'] {
--font-ui: 'Fira Sans', system-ui, sans-serif;
--font-editor: 'Fira Code', 'Fira Mono', Consolas, monospace;
--font-preview: 'Fira Sans', system-ui, 'PingFang SC', sans-serif;
}
:root[data-font='22'] {
--font-ui: 'Roboto', system-ui, sans-serif;
--font-editor: 'Roboto Mono', Consolas, monospace;
--font-preview: 'Roboto Slab', Georgia, 'Noto Serif SC', serif;
}
:root[data-font='23'] {
--font-ui: 'Open Sans', system-ui, sans-serif;
:root[data-font='11'] {
--font-ui: 'Playfair Display', Georgia, 'Times New Roman', serif;
--font-editor: 'Source Code Pro', Consolas, monospace;
--font-preview: 'Merriweather', Georgia, 'Noto Serif SC', serif;
}
:root[data-font='24'] {
--font-ui: 'Lato', system-ui, sans-serif;
--font-editor: 'Ubuntu Mono', Consolas, monospace;
--font-preview: 'Lora', Georgia, 'Noto Serif SC', serif;
}
:root[data-font='25'] {
--font-ui: 'Montserrat', system-ui, sans-serif;
--font-editor: 'Space Mono', Consolas, monospace;
--font-preview: 'Playfair Display', Georgia, 'Noto Serif SC', serif;
}
:root[data-font='26'] {
--font-ui: 'Merriweather Sans', system-ui, sans-serif;
:root[data-font='12'] {
--font-ui: 'Lato', 'Arial', 'Microsoft YaHei', sans-serif;
--font-editor: 'Ubuntu Mono', Consolas, monospace;
--font-preview: 'Lato', 'Arial', 'Microsoft YaHei', sans-serif;
}
:root[data-font='13'] {
--font-ui: 'Montserrat', 'Trebuchet MS', sans-serif;
--font-editor: 'Space Mono', Consolas, monospace;
--font-preview: 'Montserrat', 'Segoe UI', 'PingFang SC', sans-serif;
}
:root[data-font='14'] {
--font-ui: 'Merriweather Sans', 'Segoe UI', sans-serif;
--font-editor: 'Inconsolata', Consolas, monospace;
--font-preview: 'Merriweather', Georgia, 'Noto Serif SC', serif;
}
:root[data-font='27'] {
--font-ui: 'Crimson Text', Georgia, 'Times New Roman', serif;
--font-editor: 'Source Code Pro', Consolas, monospace;
--font-preview: 'Crimson Text', 'Times New Roman', 'Noto Serif SC', serif;
}
:root[data-font='28'] {
--font-ui: 'Literata', Georgia, 'Noto Serif SC', serif;
--font-editor: 'IBM Plex Mono', Consolas, monospace;
--font-preview: 'Literata', Georgia, 'Noto Serif SC', serif;
}
:root[data-font='29'] {
--font-ui: 'Inconsolata', Consolas, monospace;
--font-editor: 'Inconsolata', Consolas, monospace;
--font-preview: 'Inconsolata', Consolas, monospace;
}
:root[data-font='30'] {
:root[data-font='15'] {
--font-ui: 'Anonymous Pro', 'Courier New', monospace;
--font-editor: 'Anonymous Pro', 'Courier New', monospace;
--font-preview: 'Anonymous Pro', 'Courier New', monospace;
}
:root[data-font='31'] {
:root[data-font='16'] {
--font-ui: 'Fantasque Sans Mono', Consolas, monospace;
--font-editor: 'Fantasque Sans Mono', Consolas, monospace;
--font-preview: 'Fantasque Sans Mono', Consolas, monospace;
--font-editor: 'Fantasque Sans Mono', monospace;
--font-preview: 'Fantasque Sans Mono', monospace;
}
:root[data-font='17'] {
--font-ui: 'Open Sans', 'Segoe UI', 'Microsoft YaHei', sans-serif;
--font-editor: 'Source Code Pro', Consolas, monospace;
--font-preview: 'Open Sans', 'Segoe UI', 'Microsoft YaHei', sans-serif;
}
:root[data-font='18'] {
--font-ui: 'Roboto Slab', 'Source Serif 4', Georgia, serif;
--font-editor: 'Roboto Mono', Consolas, monospace;
--font-preview: 'Roboto Slab', Georgia, 'Noto Serif SC', serif;
}
:root[data-font='19'] {
--font-ui: 'Space Mono', 'Courier New', monospace;
--font-editor: '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,
+31 -36
View File
@@ -1,6 +1,6 @@
/** 背景 id 022,对应 `appearance-styles.css` 中 `:root[data-bg]`。 */
/** 背景 id 029,对应 `appearance-styles.css` 中 `:root[data-bg]`。 */
export const BACKGROUNDS: { id: string; name: string }[] = [
{ id: '0', name: '雾灰' },
{ id: '0', name: '雾灰' },
{ id: '1', name: '羊皮纸' },
{ id: '2', name: '深海蓝' },
{ id: '3', name: '森绿' },
@@ -22,53 +22,48 @@ export const BACKGROUNDS: { id: string; name: string }[] = [
{ id: '19', name: '樱粉雾' },
{ id: '20', name: '钢蓝灰' },
{ id: '21', name: '苔痕绿' },
{ id: '22', name: 'Kindle 纸感' }
{ 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 031,对应 `:root[data-font]`。 */
/** 字体 id 019,对应 `:root[data-font]`。 */
export const FONTS: { id: string; name: string }[] = [
{ id: '0', name: '系统默认' },
{ id: '1', name: '经典衬线' },
{ id: '2', name: '现代等宽' },
{ id: '3', name: '宋体阅读' },
{ id: '4', name: 'IBM Plex 家族' },
{ id: '5', name: 'Helvetica 现代' },
{ id: '6', name: 'Georgia 优雅' },
{ id: '7', name: 'Verdana 清晰' },
{ id: '8', name: 'Courier 复古' },
{ id: '9', name: 'Segoe UI 流畅' },
{ id: '10', name: '思源黑体' },
{ id: '11', name: '楷体古韵' },
{ id: '12', name: '苹方雅致' },
{ id: '13', name: '冬青黑体' },
{ id: '14', name: '霞鹜文楷' },
{ id: '15', name: 'HarmonyOS 鸿蒙' },
{ id: '16', name: 'OPPO Sans' },
{ id: '17', name: 'MiSans 小米' },
{ id: '18', name: '阿里巴巴普惠体' },
{ id: '19', name: '得意黑' },
{ id: '20', name: 'Maple Mono 枫叶' },
{ id: '21', name: 'Fira 家族' },
{ id: '22', name: 'Roboto 家族' },
{ id: '23', name: 'Open Sans 开放' },
{ id: '24', name: 'Lato 优雅' },
{ id: '25', name: 'Montserrat 现代' },
{ id: '26', name: 'Merriweather 阅读' },
{ id: '27', name: 'Crimson 古典' },
{ id: '28', name: 'Literata 电子书' },
{ id: '29', name: 'Inconsolata 代码' },
{ id: '30', name: 'Anonymous Pro 复古码' },
{ id: '31', name: 'Fantasque 趣味码' }
{ id: '4', name: '楷体古韵' },
{ id: '5', name: '霞鹜文楷' },
{ id: '6', name: '思源黑体' },
{ id: '7', name: 'Helvetica 现代' },
{ id: '8', name: '粗体圆润' },
{ id: '9', name: '打字机风' },
{ id: '10', name: 'IBM Plex' },
{ 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: '等宽现代' }
]
export type ViewMode = 'edit' | 'read' | 'hybrid'
export type ViewMode = 'read' | 'hybrid' | 'live'
export function validBackgroundId(s: string | null): string | null {
if (s === null) return null
return /^([0-9]|1[0-9]|2[0-2])$/.test(s) ? s : null
return /^([0-9]|[12][0-9])$/.test(s) ? s : null
}
export function validFontId(s: string | null): string | null {
if (s === null) return null
return /^([0-9]|[12][0-9]|3[01])$/.test(s) ? s : null
}
return /^([0-9]|1[0-9])$/.test(s) ? s : null
}
+3 -31
View File
@@ -1,37 +1,9 @@
/// <reference types="vite/client" />
import type {
ListDirResult,
OpenFileResult,
OpenFolderResult,
OpenImageResult,
SaveDialogResult
} from '../../../electron/ipc-types'
export interface MyReaderApi {
openFileDialog: () => Promise<OpenFileResult>
openFolderDialog: () => Promise<OpenFolderResult>
openImageDialog: () => Promise<OpenImageResult>
saveFileDialog: (defaultPath?: string) => Promise<SaveDialogResult>
readFile: (filePath: string) => Promise<string>
writeFile: (filePath: string, content: string) => Promise<void>
listDirectory: (dirPath: string) => Promise<ListDirResult>
parentDirectory: (dirPath: string) => Promise<string>
resolveRelativePath: (baseFilePath: string, href: string) => Promise<string>
openExternal: (url: string) => Promise<void>
defaultDirectory: () => Promise<string>
deleteFile: (filePath: string) => Promise<void>
renamePath: (oldPath: string, newName: string) => Promise<{ newPath: string }>
allowClose: () => void
onRequestClose: (handler: () => void) => () => void
onMenuAction: (handler: (action: string) => void) => () => void
onOpenFiles: (handler: (paths: string[]) => void) => () => void
}
import type { MyReaderApi } from '../../../electron/preload.js'
declare global {
interface Window {
myreader?: MyReaderApi
myreader: MyReaderApi
}
}
export {}
export type { MyReaderApi }
+5
View File
@@ -29,6 +29,11 @@ export function removeFileHistory(paths: string[], filePath: string): string[] {
return next
}
export function clearFileHistory(): string[] {
saveFileHistory([])
return []
}
export function historyFileName(path: string): string {
const parts = path.split(/[/\\]/)
return parts[parts.length - 1] || path
+436 -68
View File
@@ -36,9 +36,10 @@ body {
transition: background 0.3s ease;
}
/* 滚动条默认透明,hover/滚动时显示 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@@ -46,15 +47,17 @@ body {
}
::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--muted) 30%, transparent);
background: transparent;
border-radius: 4px;
border: 2px solid transparent;
background-clip: content-box;
transition: background 0.2s;
}
::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--muted) 50%, transparent);
*:hover::-webkit-scrollbar-thumb,
*:focus::-webkit-scrollbar-thumb,
*::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--muted) 40%, transparent);
border: 2px solid transparent;
background-clip: content-box;
}
@@ -86,61 +89,61 @@ html[data-bg='22'] .app-root {
flex: 0 0 auto;
flex-wrap: nowrap;
align-items: stretch;
gap: 4px;
padding: 8px 10px 0;
gap: 2px;
padding: 10px 12px 0;
border-bottom: 1px solid var(--border);
background: var(--panel-bg);
position: relative;
z-index: 30;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
overflow: hidden;
box-shadow: 0 1px 4px color-mix(in srgb, #000 3%, transparent);
}
.tab-bar::-webkit-scrollbar {
height: 5px;
}
.tab-bar::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--muted) 40%, transparent);
border-radius: 3px;
}
.tab {
display: inline-flex;
flex: 0 0 160px;
flex: 1 1 auto;
align-items: stretch;
min-width: 120px;
max-width: 200px;
border: 1px solid transparent;
border-bottom: none;
min-width: 40px;
max-width: 220px;
border: none;
border-radius: 8px 8px 0 0;
background: transparent;
color: inherit;
color: var(--muted);
overflow: hidden;
transition: background 0.15s;
transition: background 0.18s, color 0.18s;
position: relative;
}
.tab:not(.active):hover {
background: var(--hover);
color: var(--text);
}
.tab.active {
background: var(--app-bg);
border-color: var(--border);
box-shadow: 0 -2px 0 var(--accent), 0 1px 0 var(--app-bg);
position: relative;
color: var(--text);
box-shadow: 0 -1.5px 0 var(--accent);
}
.tab.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 1px;
background: var(--app-bg);
bottom: 0;
left: 8px;
right: 8px;
height: 2.5px;
background: var(--accent);
border-radius: 2px 2px 0 0;
}
.tab.drag-over {
border-left: 2px solid var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
opacity: 0.85;
}
.tab.dragging {
opacity: 0.4;
}
.tab-main {
@@ -148,8 +151,8 @@ html[data-bg='22'] .app-root {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 4px 7px 12px;
gap: 8px;
padding: 8px 6px 10px 14px;
border: none;
background: transparent;
color: inherit;
@@ -159,20 +162,32 @@ html[data-bg='22'] .app-root {
}
.tab-close {
flex: 0 0 28px;
flex: 0 0 26px;
border: none;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 15px;
font-size: 14px;
line-height: 1;
border-radius: 4px;
transition: color 0.12s, background 0.12s;
border-radius: 6px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
margin: 6px 6px 6px 0;
transition: color 0.15s, background 0.15s, transform 0.15s;
opacity: 0;
}
.tab.active .tab-close,
.tab:hover .tab-close {
opacity: 1;
}
.tab-close:hover {
color: #e53e3e;
background: color-mix(in srgb, #e53e3e 10%, transparent);
color: #fff;
background: #e53e3e;
transform: scale(1.1);
}
.tab-title {
@@ -182,17 +197,37 @@ html[data-bg='22'] .app-root {
font-size: 13px;
font-weight: 500;
letter-spacing: 0.01em;
color: inherit;
}
.tab-text {
display: flex;
flex-direction: column;
min-width: 0;
gap: 1px;
}
.tab-path {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 10px;
color: var(--muted);
line-height: 1.2;
opacity: 0.6;
max-width: 100%;
}
.tab.active .tab-title {
font-weight: 600;
color: var(--text);
}
.tab .dot {
color: var(--accent);
font-size: 20px;
font-size: 18px;
line-height: 1;
margin-right: 2px;
flex-shrink: 0;
}
.tab-context-menu {
@@ -1124,11 +1159,26 @@ html[data-bg='22'] .app-root {
background: var(--hover);
}
.dir-item-icon,
.history-item-icon {
flex: 0 0 auto;
font-size: 14px;
line-height: 1.4;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-top: 1px;
color: var(--muted);
}
.dir-item-icon svg,
.history-item-icon svg {
width: 16px;
height: 16px;
}
.dir-item[data-kind='dir'] .dir-item-icon {
color: var(--accent);
}
.history-item-body {
@@ -1520,6 +1570,16 @@ html[data-bg='22'] .app-root {
margin-top: 2px;
}
.stats-panel-sign {
text-align: center;
font-size: 10px;
color: var(--muted);
padding-top: 8px;
margin-top: auto;
opacity: 0.6;
border-top: 1px solid var(--border);
}
.global-drop-overlay {
position: fixed;
inset: 0;
@@ -1625,33 +1685,24 @@ html[data-bg='22'] .app-root {
.preview-resizer {
position: absolute;
top: 0;
right: -10px;
width: 16px;
right: -4px;
width: 6px;
height: 100%;
cursor: ew-resize;
z-index: 5;
border-radius: 6px;
border: 2px solid var(--accent);
background: var(--btn-bg);
opacity: 0.9;
transition: opacity 0.15s, filter 0.15s;
border-radius: 3px;
background: var(--accent);
opacity: 0;
transition: opacity 0.18s ease;
}
.preview-shell:hover .preview-resizer,
.preview-resizer:active {
opacity: 0.6;
}
.preview-resizer:hover {
opacity: 1;
filter: brightness(1.08);
}
.preview-resizer::after {
content: '‖';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: var(--accent);
pointer-events: none;
}
.preview {
@@ -1737,10 +1788,13 @@ html[data-bg='22'] .app-root {
.preview .md-diagram-wrap .mermaid-render-host {
display: flex;
justify-content: center;
align-items: center;
align-items: flex-start;
min-height: 4rem;
width: 100%;
overflow: hidden;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
}
.preview .md-diagram-wrap .mermaid-placeholder {
@@ -1754,6 +1808,25 @@ html[data-bg='22'] .app-root {
display: block;
}
.preview .md-diagram-wrap .mermaid-render-host svg foreignObject {
overflow: visible;
}
.preview .md-diagram-wrap .mermaid-render-host svg foreignObject div,
.preview .md-diagram-wrap .mermaid-render-host svg foreignObject span,
.preview .md-diagram-wrap .mermaid-render-host svg foreignObject p {
word-break: break-word;
overflow-wrap: anywhere;
white-space: normal;
max-width: 100%;
line-height: 1.35;
}
.preview .md-diagram-wrap .mermaid-render-host svg .nodeLabel,
.preview .md-diagram-wrap .mermaid-render-host svg .label {
overflow: visible;
}
.preview .md-diagram-wrap .mermaid-error {
margin: 0;
padding: 12px;
@@ -2257,4 +2330,299 @@ html[data-bg='22'] .app-root {
box-shadow: 0 0 0 3px rgba(234, 179, 8, 0.2);
border-radius: 4px;
}
100% {
background-color: transparent;
box-shadow: none;
}
}
.live-editor-outer {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 20px 24px 32px;
}
.live-editor-shell {
position: relative;
flex: 0 1 auto;
min-width: 0;
width: 100%;
}
.live-editor-shell .editor-host {
border-radius: 10px;
overflow: hidden;
background: var(--preview-bg);
box-shadow: var(--preview-shadow);
}
.live-editor-shell .editor-host .cm-editor {
height: 100%;
outline: none;
background: var(--preview-bg);
color: var(--preview-text);
}
.live-editor-shell .editor-host .cm-editor .cm-scroller {
font-family: var(--font-preview, var(--font-ui, Georgia, serif));
font-size: 16px;
line-height: var(--preview-line-height, 1.7);
color: var(--preview-text);
}
.live-editor-shell .editor-host .cm-editor .cm-content {
padding: 28px 36px 52px;
caret-color: var(--accent);
}
.live-editor-shell .editor-host .cm-editor .cm-gutters {
display: none;
}
.live-editor-shell .editor-host .cm-editor .cm-activeLine {
background: color-mix(in srgb, var(--accent) 4%, transparent);
}
.live-editor-shell .editor-host .cm-editor .cm-selectionBackground,
.live-editor-shell .editor-host .cm-editor.cm-focused .cm-selectionBackground {
background: color-mix(in srgb, var(--accent) 25%, transparent) !important;
}
.live-editor-shell .editor-host .cm-editor .cm-cursor {
border-left-color: var(--accent);
border-left-width: 2px;
}
.live-preview-wrap {
position: relative;
cursor: pointer;
border-radius: 10px;
overflow: hidden;
background: var(--preview-bg);
box-shadow: var(--preview-shadow);
transition: box-shadow 0.2s;
}
.live-preview-wrap:hover {
box-shadow: var(--preview-shadow), 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent);
}
.live-preview-wrap .preview {
padding: 28px 36px 52px;
}
.live-edit-hint {
position: absolute;
inset: auto 0 0 0;
padding: 8px 36px 12px;
font-size: 12px;
color: var(--muted);
background: linear-gradient(transparent, color-mix(in srgb, var(--preview-bg) 95%, transparent));
text-align: center;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.live-preview-wrap:hover .live-edit-hint {
opacity: 1;
}
.live-mode-toggle {
position: absolute;
top: 12px;
right: 12px;
z-index: 10;
padding: 6px 14px;
font-size: 12px;
font-weight: 500;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--btn-bg);
color: var(--text);
cursor: pointer;
font: inherit;
transition: border-color 0.15s, background 0.15s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.live-mode-toggle:hover {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 6%, var(--btn-bg));
}
.live-line-shell {
background: var(--preview-bg);
border-radius: 10px;
box-shadow: var(--preview-shadow);
}
.live-line-doc {
padding: 28px 36px 52px;
}
.live-line {
position: relative;
margin: 0;
padding: 2px 6px;
border-radius: 4px;
transition: background 0.12s;
}
.live-line--preview {
cursor: text;
min-height: 1.4em;
}
.live-line--preview:hover {
background: color-mix(in srgb, var(--accent) 5%, transparent);
}
.live-line--preview > :first-child {
margin-top: 0;
}
.live-line--preview > :last-child {
margin-bottom: 0;
}
.live-line--preview p,
.live-line--preview h1,
.live-line--preview h2,
.live-line--preview h3,
.live-line--preview h4,
.live-line--preview h5,
.live-line--preview h6,
.live-line--preview li {
margin: 0;
}
.live-line-empty {
min-height: 0.6em;
opacity: 0.35;
}
.live-line--editing {
background: color-mix(in srgb, var(--accent) 8%, var(--preview-bg));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
padding: 4px 8px 6px;
margin: 2px 0;
}
.live-line-input {
display: block;
width: 100%;
border: none;
background: transparent;
resize: none;
overflow: hidden;
font-family: var(--font-preview, var(--font-ui, Georgia, serif));
font-size: inherit;
line-height: var(--preview-line-height, 1.7);
color: var(--preview-text);
padding: 4px 2px;
margin: 0;
}
.live-line-input:focus {
outline: none;
}
.live-line-edit-hint {
display: block;
font-size: 11px;
color: var(--muted);
margin-top: 4px;
}
.live-line--flash,
.live-block--flash {
animation: preview-anchor-flash 1.2s ease-out;
}
.live-block {
position: relative;
margin: 12px 0;
border-radius: 8px;
transition: background 0.12s, box-shadow 0.12s;
}
.live-block--preview {
cursor: pointer;
padding: 4px 0;
}
.live-block--preview:hover {
background: color-mix(in srgb, var(--accent) 4%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 18%, transparent);
}
.live-block-preview-inner {
pointer-events: none;
}
.live-block-preview-inner .preview {
padding: 0;
}
.live-block-preview-inner .md-code-wrap,
.live-block-preview-inner .md-diagram-wrap {
margin: 0;
}
.live-block--preview .live-block-preview-inner a {
pointer-events: auto;
}
.live-block-edit-badge {
display: block;
text-align: center;
font-size: 11px;
color: var(--muted);
padding: 4px 0 2px;
opacity: 0;
transition: opacity 0.15s;
}
.live-block--preview:hover .live-block-edit-badge {
opacity: 1;
}
.live-block--editing {
background: color-mix(in srgb, var(--accent) 8%, var(--preview-bg));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
padding: 10px 12px 8px;
}
.live-block-input {
display: block;
width: 100%;
min-height: 6em;
border: none;
background: color-mix(in srgb, var(--surface) 60%, transparent);
border-radius: 6px;
resize: vertical;
overflow: hidden;
font-family: var(--font-mono, 'Consolas', 'Source Code Pro', monospace);
font-size: 13px;
line-height: 1.5;
color: var(--preview-text);
padding: 12px 14px;
margin: 0;
tab-size: 2;
}
.live-block--diagram .live-block-input {
min-height: 8em;
}
.live-block-input:focus {
outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent);
}
+78
View File
@@ -0,0 +1,78 @@
import { isMermaidDiagramFence } from './markdown'
export type LiveLineSegment = {
kind: 'line'
line: number
text: string
}
export type LiveBlockSegment = {
kind: 'block'
blockKind: 'code' | 'diagram'
startLine: number
endLine: number
text: string
}
export type LiveSegment = LiveLineSegment | LiveBlockSegment
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)
}
/** 将 Markdown 源文拆成普通行段与围栏代码块(含 Mermaid)段。 */
export function parseLiveDocumentSegments(src: string): LiveSegment[] {
const lines = src.split(/\r?\n/)
const segments: LiveSegment[] = []
let i = 0
while (i < lines.length) {
const line = lines[i] ?? ''
const open = FENCE_OPEN_RE.exec(line)
if (open) {
const fenceSeq = open[2]!
const fenceChar = fenceSeq[0]!
const fenceLen = fenceSeq.length
const info = (open[3] ?? '').trim()
const lang = info.split(/\s+/)[0] ?? ''
const start = i
i++
const bodyLines: string[] = []
while (i < lines.length && !isFenceClose(lines[i] ?? '', fenceChar, fenceLen)) {
bodyLines.push(lines[i] ?? '')
i++
}
const end = i < lines.length ? i : lines.length - 1
if (i < lines.length) i++
const text = lines.slice(start, end + 1).join('\n')
const blockKind = isMermaidDiagramFence(lang, bodyLines.join('\n')) ? 'diagram' : 'code'
segments.push({ kind: 'block', blockKind, startLine: start, endLine: end, text })
continue
}
segments.push({ kind: 'line', line: i, text: line })
i++
}
return segments
}
export function applyLineEdit(lines: string[], line: number, text: string): string[] {
const next = [...lines]
next[line] = text
return next
}
export function applyBlockEdit(
lines: string[],
startLine: number,
endLine: number,
blockText: string
): string[] {
const next = [...lines]
const replacement = blockText.split(/\r?\n/)
next.splice(startLine, endLine - startLine + 1, ...replacement)
return next
}
+241
View File
@@ -0,0 +1,241 @@
import { EditorSelection } from '@codemirror/state'
import type { EditorView } from '@codemirror/view'
/* ------------------------------------------------------------------ */
/* 选区 / 光标工具 */
/* ------------------------------------------------------------------ */
export function getMainSelection(view: EditorView): { from: number; to: number } {
const r = view.state.selection.main
return { from: r.from, to: r.to }
}
/** 用 left/right 包裹选区文字,无选区时插入 left+right 并让光标居中 */
export function wrap(view: EditorView, left: string, right: string): void {
const { from, to } = getMainSelection(view)
const sel = view.state.sliceDoc(from, to)
view.dispatch({
changes: { from, to, insert: left + sel + right },
selection: EditorSelection.range(from + left.length, from + left.length + sel.length)
})
view.focus()
}
/** 在光标处插入文本 */
export function insertAtCursor(view: EditorView, text: string): void {
const { from, to } = getMainSelection(view)
view.dispatch({
changes: { from, to, insert: text },
selection: EditorSelection.cursor(from + text.length)
})
view.focus()
}
/** 在光标处插入多行文本(别名) */
export function insertLines(view: EditorView, text: string): void {
insertAtCursor(view, text)
}
/** 给光标所在行添加行前缀(如 # 、- 等) */
export function linePrefixOnMainLine(view: EditorView, prefix: string): void {
const pos = view.state.selection.main.head
const line = view.state.doc.lineAt(pos)
const lineText = line.text
const hashes = /^#+\s/.exec(lineText)?.[0] ?? ''
let rest = lineText
if (hashes) rest = lineText.slice(hashes.length)
const newLine = prefix + rest
view.dispatch({
changes: { from: line.from, to: line.to, insert: newLine },
selection: EditorSelection.cursor(line.from + newLine.length)
})
view.focus()
}
/* ------------------------------------------------------------------ */
/* 文本清理工具 */
/* ------------------------------------------------------------------ */
export function escapeMarkdownLinkFragment(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/\[/g, '\\[').replace(/\]/g, '\\]')
}
export function sanitizeInlineColor(c: string): string {
const t = c.trim()
if (/^#[0-9a-fA-F]{3}$/.test(t)) {
const r = t[1]!
const g = t[2]!
const b = t[3]!
return `#${r}${r}${g}${g}${b}${b}`
}
if (/^#[0-9a-fA-F]{6}$/.test(t) || /^#[0-9a-fA-F]{8}$/.test(t)) return t.toLowerCase()
if (/^rgba?\(\s*[\d.\s%,]+\s*\)$/.test(t)) return t
if (/^hsla?\(\s*[\d.\s%,deg]+\s*\)$/.test(t)) return t
if (/^[\w-]{1,40}$/.test(t)) return t
return '#333333'
}
export function sanitizeLinkUrl(url: string): string {
const t = url.trim() || 'https://'
if (/^file:/i.test(t)) return `<${t.replace(/[<>]/g, '')}>`
return t.replace(/\)/g, '%29')
}
/* ------------------------------------------------------------------ */
/* Action 依赖类型 & 主调度函数 */
/* ------------------------------------------------------------------ */
export type ActionDeps = {
requestText: (title: string, def: string) => Promise<string | null>
requestImageDetails?: () => Promise<{ url: string; alt: string } | null>
requestColor?: () => Promise<string | null>
}
/**
* action
*
* h1 h6
* bold / italic / strike / code / mark / color
* link / image / codeBlock / table / hr / quote
* ul / ol / task
* sup / sub / kbd / details / footnote
*/
export async function applyMarkdownAction(view: EditorView, id: string, deps: ActionDeps): Promise<void> {
const { requestText, requestImageDetails, requestColor } = deps
try {
switch (id) {
/* — 格式 — */
case 'bold':
wrap(view, '**', '**')
break
case 'italic':
wrap(view, '*', '*')
break
case 'strike':
wrap(view, '~~', '~~')
break
case 'code':
wrap(view, '`', '`')
break
case 'codeBlock':
insertLines(view, '\n```\n\n```\n')
break
/* — 链接 / 图片 — */
case 'link': {
const url = await requestText('链接地址', 'https://')
if (url === null) return
const { from, to } = getMainSelection(view)
const rawLabel = view.state.sliceDoc(from, to) || '链接文字'
const label = escapeMarkdownLinkFragment(rawLabel)
const insert = `[${label}](${sanitizeLinkUrl(url)})`
view.dispatch({
changes: { from, to, insert },
selection: EditorSelection.cursor(from + insert.length)
})
view.focus()
break
}
case 'image': {
if (requestImageDetails) {
const det = await requestImageDetails()
if (!det?.url?.trim()) return
const alt = (det.alt ?? '图片').trim() || '图片'
insertAtCursor(view, `![${escapeMarkdownLinkFragment(alt)}](${sanitizeLinkUrl(det.url.trim())})`)
break
}
const url = await requestText('图片地址', 'https://')
if (url === null) return
const rawAlt = await requestText('替代文字', '图片')
if (rawAlt === null) return
const alt = escapeMarkdownLinkFragment(rawAlt || '图片')
insertAtCursor(view, `![${alt}](${sanitizeLinkUrl(url)})`)
break
}
/* — 标题 — */
case 'h1':
linePrefixOnMainLine(view, '# ')
break
case 'h2':
linePrefixOnMainLine(view, '## ')
break
case 'h3':
linePrefixOnMainLine(view, '### ')
break
case 'h4':
linePrefixOnMainLine(view, '#### ')
break
case 'h5':
linePrefixOnMainLine(view, '##### ')
break
case 'h6':
linePrefixOnMainLine(view, '###### ')
break
/* — 列表 — */
case 'ul':
linePrefixOnMainLine(view, '- ')
break
case 'ol':
linePrefixOnMainLine(view, '1. ')
break
case 'task':
linePrefixOnMainLine(view, '- [ ] ')
break
/* — 元素 — */
case 'quote':
linePrefixOnMainLine(view, '> ')
break
case 'hr':
insertLines(view, '\n---\n')
break
case 'table':
insertLines(view, '\n| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| | | |\n')
break
/* — 高亮 / 颜色 — */
case 'mark':
wrap(view, '<mark>', '</mark>')
break
case 'color': {
if (requestColor) {
const c = await requestColor()
if (c === null || !c.trim()) return
const color = sanitizeInlineColor(c)
wrap(view, `<span style="color:${color}">`, '</span>')
break
}
const c = await requestText('颜色(如 #e11 或 red', '#dc2626')
if (c === null || !c.trim()) return
const color = sanitizeInlineColor(c)
wrap(view, `<span style="color:${color}">`, '</span>')
break
}
/* — 其他行内格式 — */
case 'sup':
wrap(view, '<sup>', '</sup>')
break
case 'sub':
wrap(view, '<sub>', '</sub>')
break
case 'kbd':
wrap(view, '<kbd>', '</kbd>')
break
/* — 块级元素 — */
case 'details':
insertLines(view, '\n<details>\n<summary>标题</summary>\n内容\n</details>\n')
break
case 'footnote':
insertLines(view, '[^1]\n\n[^1]: 脚注内容\n')
break
default:
break
}
} catch (e) {
console.error('[MyReader] toolbar action failed', id, e)
}
}
+39 -12
View File
@@ -9,13 +9,17 @@ const MERMAID_LANG = new Set(['mermaid', 'flowchart', 'sequencediagram', 'sequen
const MERMAID_START =
/^\s*(sequenceDiagram|classDiagram|stateDiagram(?:-v2)?|erDiagram|gantt|pie|gitGraph|journey|flowchart\s|graph\s|mindmap|timeline|quadrantChart|xychart-beta|block-beta)/i
function isMermaidFence(lang: string, content: string): boolean {
export function isMermaidDiagramFence(lang: string, content: string): boolean {
const l = lang.toLowerCase()
if (MERMAID_LANG.has(l)) return true
if (!lang.trim() && MERMAID_START.test(content)) return true
return false
}
function isMermaidFence(lang: string, content: string): boolean {
return isMermaidDiagramFence(lang, content)
}
function escapeHtmlSafe(s: string): string {
return s
.replace(/&/g, '&amp;')
@@ -377,6 +381,18 @@ export function previewLineCenterInShell(
return blockRect.top - shellRect.top + blockRect.height / 2
}
function scrollElementInContainer(
el: HTMLElement,
scrollEl: HTMLElement,
behavior: ScrollBehavior,
offsetPx = 24
): void {
const elRect = el.getBoundingClientRect()
const scrollRect = scrollEl.getBoundingClientRect()
const top = elRect.top - scrollRect.top + scrollEl.scrollTop - offsetPx
scrollEl.scrollTo({ top: Math.max(0, top), behavior })
}
export function scrollPreviewToSourceLine(
scrollEl: HTMLElement,
article: HTMLElement,
@@ -386,23 +402,34 @@ export function scrollPreviewToSourceLine(
): HTMLElement | null {
const total = totalSourceLines ?? article.querySelectorAll('[data-source-line]').length
const shell = article.closest('.preview-shell') ?? article.parentElement
const anchor = findPreviewAnchorForLine(article, line)
let anchor = findPreviewAnchorForLine(article, line)
if (!anchor) {
try {
anchor = article.querySelector<HTMLElement>(`#${CSS.escape(slugForLine(line))}`)
} catch {
anchor = article.querySelector<HTMLElement>(`#${slugForLine(line)}`)
}
}
if (!anchor && !shell) return null
const scrollRect = scrollEl.getBoundingClientRect()
let top: number
if (anchor) {
const anchorRect = anchor.getBoundingClientRect()
top = anchorRect.top - scrollRect.top + scrollEl.scrollTop - 20
anchor.classList.add('preview-anchor-flash')
window.setTimeout(() => anchor.classList.remove('preview-anchor-flash'), 1600)
} else if (shell instanceof HTMLElement) {
const center = previewLineCenterInShell(article, shell, line, total)
top = (center ?? 0) - 40
} else {
return null
if (scrollEl.contains(anchor)) {
scrollElementInContainer(anchor, scrollEl, behavior)
return anchor
}
}
if (shell instanceof HTMLElement) {
const center = previewLineCenterInShell(article, shell, line, total)
if (center !== null) {
const shellRect = shell.getBoundingClientRect()
const scrollRect = scrollEl.getBoundingClientRect()
const top = shellRect.top - scrollRect.top + scrollEl.scrollTop + center - 48
scrollEl.scrollTo({ top: Math.max(0, top), behavior })
}
}
scrollEl.scrollTo({ top: Math.max(0, top), behavior })
return anchor
}
+18
View File
@@ -24,6 +24,24 @@ export function buildOutlineTree(items: OutlineItem[]): OutlineTreeNode[] {
return root
}
/** 默认折叠到 h2:展开 h1/h2,收起 h3 及以下子树。 */
export function branchIdsCollapsedThroughLevel(
nodes: OutlineTreeNode[],
maxExpandedLevel: number
): string[] {
const ids: string[] = []
const walk = (list: OutlineTreeNode[]): void => {
for (const n of list) {
if (n.children.length > 0) {
if (n.level >= maxExpandedLevel) ids.push(n.id)
walk(n.children)
}
}
}
walk(nodes)
return ids
}
export function collectBranchIds(nodes: OutlineTreeNode[]): string[] {
const ids: string[] = []
const walk = (list: OutlineTreeNode[]): void => {
+3 -4
View File
@@ -29,15 +29,14 @@ export function isDirty(t: Tab): boolean {
export function loadViewMode(): ViewMode {
const v = localStorage.getItem(VIEW_KEY) as ViewMode | null
if (v === 'edit' || v === 'read' || v === 'hybrid') return v
if (v === 'read' || v === 'hybrid' || v === 'live') return v
if (localStorage.getItem(LEGACY_READ_KEY) === '1') return 'read'
if (localStorage.getItem(LEGACY_READ_KEY) === '0') return 'edit'
return 'read'
return 'live'
}
export function viewModeLabel(m: ViewMode): string {
if (m === 'edit') return '编辑'
if (m === 'read') return '阅读'
if (m === 'live') return '创作'
return '分栏'
}