bug修复
This commit is contained in:
+69
-23
@@ -182,7 +182,7 @@ function buildMenu(): Menu {
|
|||||||
return Menu.buildFromTemplate(template)
|
return Menu.buildFromTemplate(template)
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWindow(): BrowserWindow {
|
function createWindow(_pendingPaths?: string[]): BrowserWindow {
|
||||||
const preloadPath = getPreloadPath()
|
const preloadPath = getPreloadPath()
|
||||||
const iconPath = resolveAppIconPath()
|
const iconPath = resolveAppIconPath()
|
||||||
let icon: NativeImage | undefined
|
let icon: NativeImage | undefined
|
||||||
@@ -218,7 +218,8 @@ function createWindow(): BrowserWindow {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (process.env.ELECTRON_RENDERER_URL) {
|
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 {
|
} else {
|
||||||
void win.loadFile(getRendererIndexHtml())
|
void win.loadFile(getRendererIndexHtml())
|
||||||
}
|
}
|
||||||
@@ -353,34 +354,79 @@ ipcMain.on('myreader:allow-close', (event) => {
|
|||||||
|
|
||||||
let pendingOpenPaths: string[] = []
|
let pendingOpenPaths: string[] = []
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
const gotTheLock = app.requestSingleInstanceLock()
|
||||||
Menu.setApplicationMenu(buildMenu())
|
|
||||||
|
|
||||||
const args = process.argv.slice(1)
|
function collectFilePathsFromArgv(): void {
|
||||||
for (const arg of args) {
|
for (const arg of process.argv) {
|
||||||
|
if (arg === process.argv[0]) continue
|
||||||
|
if (arg.startsWith('--')) continue
|
||||||
if (existsSync(arg) && isMarkdownFile(arg)) {
|
if (existsSync(arg) && isMarkdownFile(arg)) {
|
||||||
pendingOpenPaths.push(arg)
|
if (!pendingOpenPaths.includes(arg)) {
|
||||||
|
pendingOpenPaths.push(arg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const win = createWindow()
|
ipcMain.handle('myreader:get-pending-files', (): string[] => {
|
||||||
|
const paths = pendingOpenPaths.slice()
|
||||||
win.webContents.on('dom-ready', () => {
|
pendingOpenPaths = []
|
||||||
if (pendingOpenPaths.length > 0) {
|
return paths
|
||||||
setTimeout(() => {
|
|
||||||
win.webContents.send('myreader:open-files', pendingOpenPaths)
|
|
||||||
pendingOpenPaths = []
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.on('activate', () => {
|
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
|
||||||
createWindow()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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', () => {
|
app.on('window-all-closed', () => {
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
app.quit()
|
app.quit()
|
||||||
|
|||||||
+28
-6
@@ -7,6 +7,17 @@ import type {
|
|||||||
SaveDialogResult
|
SaveDialogResult
|
||||||
} from './ipc-types.js'
|
} 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 {
|
export interface MyReaderApi {
|
||||||
openFileDialog: () => Promise<OpenFileResult>
|
openFileDialog: () => Promise<OpenFileResult>
|
||||||
openFolderDialog: () => Promise<OpenFolderResult>
|
openFolderDialog: () => Promise<OpenFolderResult>
|
||||||
@@ -16,7 +27,6 @@ export interface MyReaderApi {
|
|||||||
writeFile: (filePath: string, content: string) => Promise<void>
|
writeFile: (filePath: string, content: string) => Promise<void>
|
||||||
listDirectory: (dirPath: string) => Promise<ListDirResult>
|
listDirectory: (dirPath: string) => Promise<ListDirResult>
|
||||||
parentDirectory: (dirPath: string) => Promise<string>
|
parentDirectory: (dirPath: string) => Promise<string>
|
||||||
/** 相对当前 Markdown 文件解析内链 href → 绝对路径(外链原样返回) */
|
|
||||||
resolveRelativePath: (baseFilePath: string, href: string) => Promise<string>
|
resolveRelativePath: (baseFilePath: string, href: string) => Promise<string>
|
||||||
openExternal: (url: string) => Promise<void>
|
openExternal: (url: string) => Promise<void>
|
||||||
defaultDirectory: () => Promise<string>
|
defaultDirectory: () => Promise<string>
|
||||||
@@ -26,6 +36,7 @@ export interface MyReaderApi {
|
|||||||
onRequestClose: (handler: () => void) => () => void
|
onRequestClose: (handler: () => void) => () => void
|
||||||
onMenuAction: (handler: (action: string) => void) => () => void
|
onMenuAction: (handler: (action: string) => void) => () => void
|
||||||
onOpenFiles: (handler: (paths: string[]) => void) => () => void
|
onOpenFiles: (handler: (paths: string[]) => void) => () => void
|
||||||
|
getPendingFilesIPC: () => Promise<string[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
const api: MyReaderApi = {
|
const api: MyReaderApi = {
|
||||||
@@ -67,13 +78,24 @@ const api: MyReaderApi = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOpenFiles: (handler: (paths: string[]) => void) => {
|
onOpenFiles: (handler: (paths: string[]) => void) => {
|
||||||
const listener = (_e: unknown, paths: string[]): void => {
|
openFilesHandler = handler
|
||||||
handler(paths)
|
|
||||||
}
|
|
||||||
ipcRenderer.on('myreader:open-files', listener)
|
|
||||||
return () => {
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,18 @@
|
|||||||
"electronDownload": {
|
"electronDownload": {
|
||||||
"mirror": "https://npmmirror.com/mirrors/electron/"
|
"mirror": "https://npmmirror.com/mirrors/electron/"
|
||||||
},
|
},
|
||||||
|
"fileAssociations": [
|
||||||
|
{
|
||||||
|
"ext": "md",
|
||||||
|
"name": "Markdown",
|
||||||
|
"description": "Markdown 文件"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ext": "markdown",
|
||||||
|
"name": "Markdown",
|
||||||
|
"description": "Markdown 文件"
|
||||||
|
}
|
||||||
|
],
|
||||||
"win": {
|
"win": {
|
||||||
"icon": "resources/app-icon.png",
|
"icon": "resources/app-icon.png",
|
||||||
"target": [
|
"target": [
|
||||||
|
|||||||
+288
-634
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -1,5 +1,4 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { EditorSelection } from '@codemirror/state'
|
|
||||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
|
||||||
import { markdown } from '@codemirror/lang-markdown'
|
import { markdown } from '@codemirror/lang-markdown'
|
||||||
import { EditorState } from '@codemirror/state'
|
import { EditorState } from '@codemirror/state'
|
||||||
@@ -10,11 +9,14 @@ import {
|
|||||||
lineNumbers,
|
lineNumbers,
|
||||||
placeholder
|
placeholder
|
||||||
} from '@codemirror/view'
|
} from '@codemirror/view'
|
||||||
|
import { applyMarkdownAction } from './markdown-actions'
|
||||||
|
|
||||||
export type MarkdownEditorHandle = {
|
export type MarkdownEditorHandle = {
|
||||||
applyAction: (id: string) => void | Promise<void>
|
applyAction: (id: string) => void | Promise<void>
|
||||||
searchText: (query: string) => boolean
|
searchText: (query: string) => boolean
|
||||||
getView: () => EditorView | null
|
getView: () => EditorView | null
|
||||||
|
getScrollTop: () => number
|
||||||
|
setScrollTop: (top: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -32,211 +34,6 @@ type Props = {
|
|||||||
requestColor?: () => Promise<string | null>
|
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,
|
|
||||||
`)})`
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
const url = await requestText('图片地址', 'https://')
|
|
||||||
if (url === null) return
|
|
||||||
const rawAlt = await requestText('替代文字', '图片')
|
|
||||||
if (rawAlt === null) return
|
|
||||||
const alt = escapeMarkdownLinkFragment(rawAlt || '图片')
|
|
||||||
insertAtCursor(view, `})`)
|
|
||||||
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>(
|
export const EditorPane = React.forwardRef<MarkdownEditorHandle, Props>(
|
||||||
function EditorPane(
|
function EditorPane(
|
||||||
{
|
{
|
||||||
@@ -303,7 +100,15 @@ export const EditorPane = React.forwardRef<MarkdownEditorHandle, Props>(
|
|||||||
}
|
}
|
||||||
return false
|
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
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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"> </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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import type { OutlineItem } from './markdown'
|
import type { OutlineItem } from './markdown'
|
||||||
import { buildOutlineTree, collectBranchIds, type OutlineTreeNode } from './outlineTree'
|
import {
|
||||||
|
branchIdsCollapsedThroughLevel,
|
||||||
|
buildOutlineTree,
|
||||||
|
collectBranchIds,
|
||||||
|
type OutlineTreeNode
|
||||||
|
} from './outlineTree'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
items: OutlineItem[]
|
items: OutlineItem[]
|
||||||
@@ -13,8 +18,8 @@ export function OutlinePanel({ items, onSelect }: Props): React.ReactElement {
|
|||||||
const [collapsed, setCollapsed] = React.useState<Set<string>>(() => new Set())
|
const [collapsed, setCollapsed] = React.useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setCollapsed(new Set())
|
setCollapsed(new Set(branchIdsCollapsedThroughLevel(tree, 2)))
|
||||||
}, [items])
|
}, [items, tree])
|
||||||
|
|
||||||
const toggleBranch = React.useCallback((id: string): void => {
|
const toggleBranch = React.useCallback((id: string): void => {
|
||||||
setCollapsed((prev) => {
|
setCollapsed((prev) => {
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ function ensureMermaid(): void {
|
|||||||
theme: 'neutral',
|
theme: 'neutral',
|
||||||
securityLevel: 'loose',
|
securityLevel: 'loose',
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit',
|
||||||
flowchart: { useMaxWidth: true, htmlLabels: true },
|
flowchart: { useMaxWidth: true, htmlLabels: true, wrappingWidth: 180 },
|
||||||
sequence: { useMaxWidth: true, wrap: true },
|
sequence: { useMaxWidth: true, wrap: true, width: 150 },
|
||||||
er: { useMaxWidth: true },
|
er: { useMaxWidth: true },
|
||||||
gantt: { useMaxWidth: true }
|
gantt: { useMaxWidth: true },
|
||||||
|
mindmap: { useMaxWidth: true }
|
||||||
})
|
})
|
||||||
mermaidInited = true
|
mermaidInited = true
|
||||||
}
|
}
|
||||||
@@ -68,6 +69,7 @@ async function renderMermaidIn(root: HTMLElement, isStale: () => boolean): Promi
|
|||||||
if (!root.isConnected || !wrap.isConnected) return
|
if (!root.isConnected || !wrap.isConnected) return
|
||||||
host.innerHTML = svg
|
host.innerHTML = svg
|
||||||
bindFunctions?.(host)
|
bindFunctions?.(host)
|
||||||
|
tuneMermaidDiagram(host, wrap)
|
||||||
wrap.dataset.mermaidRendered = '1'
|
wrap.dataset.mermaidRendered = '1'
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isStale()) return
|
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 {
|
function escapeHtml(s: string): string {
|
||||||
return s
|
return s
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import type { Tab } from './types'
|
import type { Tab } from './types'
|
||||||
import { OutlinePanel } from './OutlinePanel'
|
import { OutlinePanel } from './OutlinePanel'
|
||||||
|
import { FileIcon, FolderIcon, MdFileIcon, HistoryFileIcon } from './Icons'
|
||||||
import type { OutlineItem } from './markdown'
|
import type { OutlineItem } from './markdown'
|
||||||
|
|
||||||
type DirEntry = { name: string; path: string; kind: 'file' | 'dir' }
|
type DirEntry = { name: string; path: string; kind: 'file' | 'dir' }
|
||||||
@@ -18,6 +19,7 @@ type Props = {
|
|||||||
fileHistory: string[]
|
fileHistory: string[]
|
||||||
onHistoryOpen: (path: string) => void
|
onHistoryOpen: (path: string) => void
|
||||||
onHistoryContextMenu: (e: React.MouseEvent, path: string) => void
|
onHistoryContextMenu: (e: React.MouseEvent, path: string) => void
|
||||||
|
onHistoryClear: () => void
|
||||||
outlineItems: OutlineItem[]
|
outlineItems: OutlineItem[]
|
||||||
onOutlineSelect: (item: OutlineItem) => void
|
onOutlineSelect: (item: OutlineItem) => void
|
||||||
tabs: Tab[]
|
tabs: Tab[]
|
||||||
@@ -37,6 +39,7 @@ export function Sidebar({
|
|||||||
fileHistory,
|
fileHistory,
|
||||||
onHistoryOpen,
|
onHistoryOpen,
|
||||||
onHistoryContextMenu,
|
onHistoryContextMenu,
|
||||||
|
onHistoryClear,
|
||||||
outlineItems,
|
outlineItems,
|
||||||
onOutlineSelect,
|
onOutlineSelect,
|
||||||
tabs,
|
tabs,
|
||||||
@@ -99,6 +102,7 @@ export function Sidebar({
|
|||||||
fileHistory={fileHistory}
|
fileHistory={fileHistory}
|
||||||
onOpen={onHistoryOpen}
|
onOpen={onHistoryOpen}
|
||||||
onContextMenu={onHistoryContextMenu}
|
onContextMenu={onHistoryContextMenu}
|
||||||
|
onClear={onHistoryClear}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<OutlinePanel items={outlineItems} onSelect={onOutlineSelect} />
|
<OutlinePanel items={outlineItems} onSelect={onOutlineSelect} />
|
||||||
@@ -180,7 +184,9 @@ function FilePanel({
|
|||||||
}}
|
}}
|
||||||
onContextMenu={(ev) => onContextMenu(ev, e.path, e.kind)}
|
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>
|
<span className="dir-item-name">{e.name}</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -193,14 +199,24 @@ function FilePanel({
|
|||||||
function HistoryPanel({
|
function HistoryPanel({
|
||||||
fileHistory,
|
fileHistory,
|
||||||
onOpen,
|
onOpen,
|
||||||
onContextMenu
|
onContextMenu,
|
||||||
|
onClear
|
||||||
}: {
|
}: {
|
||||||
fileHistory: string[]
|
fileHistory: string[]
|
||||||
onOpen: (path: string) => void
|
onOpen: (path: string) => void
|
||||||
onContextMenu: (e: React.MouseEvent, path: string) => void
|
onContextMenu: (e: React.MouseEvent, path: string) => void
|
||||||
|
onClear: () => void
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="history-panel">
|
<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 ? (
|
{fileHistory.length === 0 ? (
|
||||||
<div className="sidebar-empty">
|
<div className="sidebar-empty">
|
||||||
<div className="sidebar-empty-icon">🕐</div>
|
<div className="sidebar-empty-icon">🕐</div>
|
||||||
@@ -215,7 +231,7 @@ function HistoryPanel({
|
|||||||
onClick={() => onOpen(p)}
|
onClick={() => onOpen(p)}
|
||||||
onContextMenu={(ev) => onContextMenu(ev, p)}
|
onContextMenu={(ev) => onContextMenu(ev, p)}
|
||||||
>
|
>
|
||||||
<span className="history-item-icon">📄</span>
|
<span className="history-item-icon"><HistoryFileIcon /></span>
|
||||||
<div className="history-item-body">
|
<div className="history-item-body">
|
||||||
<span className="history-item-name">{p.split(/[/\\]/).pop()}</span>
|
<span className="history-item-name">{p.split(/[/\\]/).pop()}</span>
|
||||||
<span className="history-item-path">{p}</span>
|
<span className="history-item-path">{p}</span>
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ export function StatsPanel({ doc, width, onResizeStart }: Props): React.ReactEle
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="stats-panel-sign">made by huangzhijun 2026</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+117
-60
@@ -1,4 +1,5 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import type { Tab } from './types'
|
import type { Tab } from './types'
|
||||||
import { tabLabel, isDirty } from './utils'
|
import { tabLabel, isDirty } from './utils'
|
||||||
|
|
||||||
@@ -9,33 +10,114 @@ type Props = {
|
|||||||
onClose: (id: string) => void
|
onClose: (id: string) => void
|
||||||
onCloseOthers: (id: string) => void
|
onCloseOthers: (id: string) => void
|
||||||
onCloseAll: () => void
|
onCloseAll: () => void
|
||||||
|
onReorder: (fromIndex: number, toIndex: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TabBar({ tabs, activeId, onSelect, onClose, onCloseOthers, onCloseAll }: Props): React.ReactElement {
|
type ContextMenuState = {
|
||||||
const barRef = React.useRef<HTMLDivElement>(null)
|
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(() => {
|
React.useEffect(() => {
|
||||||
const el = barRef.current
|
if (!ctxMenu) return
|
||||||
if (!el) return
|
const close = (): void => {
|
||||||
const active = el.querySelector('.tab.active') as HTMLElement | null
|
setCtxMenu(null)
|
||||||
if (active) {
|
|
||||||
active.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
|
|
||||||
}
|
}
|
||||||
}, [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 (
|
return (
|
||||||
<div className="tab-bar" ref={barRef}>
|
<div className="tab-bar">
|
||||||
{tabs.map((t) => (
|
{tabs.map((t, i) => (
|
||||||
<TabItem
|
<TabItem
|
||||||
key={t.id}
|
key={t.id}
|
||||||
tab={t}
|
tab={t}
|
||||||
active={t.id === activeId}
|
active={t.id === activeId}
|
||||||
|
index={i}
|
||||||
|
dragOver={dragOverIndex === i}
|
||||||
onSelect={() => onSelect(t.id)}
|
onSelect={() => onSelect(t.id)}
|
||||||
onClose={() => onClose(t.id)}
|
onClose={() => onClose(t.id)}
|
||||||
onCloseOthers={() => onCloseOthers(t.id)}
|
onContextMenu={(e) => handleContextMenu(e, t.id)}
|
||||||
onCloseAll={onCloseAll}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -43,48 +125,36 @@ export function TabBar({ tabs, activeId, onSelect, onClose, onCloseOthers, onClo
|
|||||||
type TabItemProps = {
|
type TabItemProps = {
|
||||||
tab: Tab
|
tab: Tab
|
||||||
active: boolean
|
active: boolean
|
||||||
|
index: number
|
||||||
|
dragOver: boolean
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onCloseOthers: () => void
|
onContextMenu: (e: React.MouseEvent) => void
|
||||||
onCloseAll: () => 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 {
|
function TabItem({ tab, active, index, dragOver, onSelect, onClose, onContextMenu, onDragStart, onDragOver: onDragOverCb, onDragLeave, onDrop, onDragEnd }: 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 (
|
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}>
|
<button type="button" className="tab-main" onClick={onSelect} title={tab.filePath ?? undefined}>
|
||||||
{isDirty(tab) ? <span className="dot">·</span> : null}
|
{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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -97,19 +167,6 @@ function TabItem({ tab, active, onSelect, onClose, onCloseOthers, onCloseAll }:
|
|||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -324,7 +324,7 @@
|
|||||||
--code-bg: #ede8df;
|
--code-bg: #ede8df;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 字体:仅字体栈 */
|
/* 字体:仅字体栈 — 20 种效果差异明显的字体 */
|
||||||
:root[data-font='0'] {
|
:root[data-font='0'] {
|
||||||
--font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
--font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
--font-editor: ui-monospace, Menlo, Consolas, monospace;
|
--font-editor: ui-monospace, Menlo, Consolas, monospace;
|
||||||
@@ -336,154 +336,203 @@
|
|||||||
--font-preview: 'Palatino Linotype', Palatino, 'Noto Serif SC', 'Songti SC', serif;
|
--font-preview: 'Palatino Linotype', Palatino, 'Noto Serif SC', 'Songti SC', serif;
|
||||||
}
|
}
|
||||||
:root[data-font='2'] {
|
:root[data-font='2'] {
|
||||||
--font-ui: system-ui, sans-serif;
|
--font-ui: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
||||||
--font-editor: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace;
|
--font-editor: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
||||||
--font-preview: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
--font-preview: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
||||||
}
|
}
|
||||||
:root[data-font='3'] {
|
: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-editor: 'Sarasa Mono SC', Consolas, monospace;
|
||||||
--font-preview: 'Songti SC', 'SimSun', 'Noto Serif SC', 'STSong', serif;
|
--font-preview: 'Songti SC', 'SimSun', 'Noto Serif SC', 'STSong', serif;
|
||||||
}
|
}
|
||||||
:root[data-font='4'] {
|
:root[data-font='4'] {
|
||||||
--font-ui: 'IBM Plex Sans', system-ui, sans-serif;
|
--font-ui: 'KaiTi', 'STKaiti', 'Noto Serif SC', serif;
|
||||||
--font-editor: 'IBM Plex Mono', Consolas, monospace;
|
--font-editor: 'Sarasa Mono SC', Consolas, monospace;
|
||||||
--font-preview: 'IBM Plex Serif', Georgia, 'Noto Serif SC', serif;
|
--font-preview: 'KaiTi', 'STKaiti', 'Noto Serif SC', 'STSong', serif;
|
||||||
}
|
}
|
||||||
:root[data-font='5'] {
|
: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-ui: 'LXGW WenKai', 'KaiTi', 'Microsoft YaHei', sans-serif;
|
||||||
--font-editor: 'JetBrains Mono', Consolas, monospace;
|
--font-editor: 'JetBrains Mono', Consolas, monospace;
|
||||||
--font-preview: 'LXGW WenKai', 'KaiTi', 'Noto Serif SC', serif;
|
--font-preview: 'LXGW WenKai', 'KaiTi', 'Noto Serif SC', serif;
|
||||||
}
|
}
|
||||||
:root[data-font='15'] {
|
:root[data-font='6'] {
|
||||||
--font-ui: 'HarmonyOS Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
--font-ui: 'Noto Sans SC', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
||||||
--font-editor: 'HarmonyOS Sans Mono', 'JetBrains Mono', Consolas, monospace;
|
--font-editor: 'Noto Sans Mono CJK SC', 'Sarasa Mono SC', Consolas, monospace;
|
||||||
--font-preview: 'HarmonyOS Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
--font-preview: 'Noto Sans SC', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
||||||
}
|
}
|
||||||
:root[data-font='16'] {
|
:root[data-font='7'] {
|
||||||
--font-ui: 'OPPO Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
--font-ui: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
--font-editor: 'JetBrains Mono', Consolas, monospace;
|
--font-editor: Menlo, Monaco, Consolas, monospace;
|
||||||
--font-preview: 'OPPO Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
--font-preview: 'Helvetica Neue', Helvetica, Arial, 'PingFang SC', sans-serif;
|
||||||
}
|
}
|
||||||
:root[data-font='17'] {
|
:root[data-font='8'] {
|
||||||
--font-ui: 'MiSans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
--font-ui: Verdana, Geneva, Tahoma, sans-serif;
|
||||||
--font-editor: 'JetBrains Mono', Consolas, monospace;
|
--font-editor: 'Lucida Console', 'Courier New', monospace;
|
||||||
--font-preview: 'MiSans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
--font-preview: Verdana, Geneva, 'Microsoft YaHei', sans-serif;
|
||||||
}
|
}
|
||||||
:root[data-font='18'] {
|
:root[data-font='9'] {
|
||||||
--font-ui: 'Alibaba PuHuiTi', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
--font-ui: 'Courier New', Courier, 'Liberation Mono', monospace;
|
||||||
--font-editor: 'JetBrains Mono', Consolas, monospace;
|
--font-editor: 'Courier New', Courier, 'Liberation Mono', monospace;
|
||||||
--font-preview: 'Alibaba PuHuiTi', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
--font-preview: 'Courier New', Courier, 'Liberation Mono', 'Noto Serif SC', serif;
|
||||||
}
|
}
|
||||||
:root[data-font='19'] {
|
:root[data-font='10'] {
|
||||||
--font-ui: 'Smiley Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
--font-ui: 'IBM Plex Sans', 'Helvetica Neue', system-ui, sans-serif;
|
||||||
--font-editor: 'JetBrains Mono', Consolas, monospace;
|
--font-editor: 'IBM Plex Mono', Consolas, monospace;
|
||||||
--font-preview: 'Smiley Sans', 'Microsoft YaHei', 'PingFang SC', sans-serif;
|
--font-preview: 'IBM Plex Serif', Georgia, 'Noto Serif SC', serif;
|
||||||
}
|
}
|
||||||
:root[data-font='20'] {
|
:root[data-font='11'] {
|
||||||
--font-ui: 'Maple Mono', 'Cascadia Code', Consolas, monospace;
|
--font-ui: 'Playfair Display', Georgia, 'Times New Roman', serif;
|
||||||
--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;
|
|
||||||
--font-editor: 'Source Code Pro', Consolas, monospace;
|
--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;
|
--font-preview: 'Playfair Display', Georgia, 'Noto Serif SC', serif;
|
||||||
}
|
}
|
||||||
:root[data-font='26'] {
|
:root[data-font='12'] {
|
||||||
--font-ui: 'Merriweather Sans', system-ui, sans-serif;
|
--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-editor: 'Inconsolata', Consolas, monospace;
|
||||||
--font-preview: 'Merriweather', Georgia, 'Noto Serif SC', serif;
|
--font-preview: 'Merriweather', Georgia, 'Noto Serif SC', serif;
|
||||||
}
|
}
|
||||||
:root[data-font='27'] {
|
:root[data-font='15'] {
|
||||||
--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'] {
|
|
||||||
--font-ui: 'Anonymous Pro', 'Courier New', monospace;
|
--font-ui: 'Anonymous Pro', 'Courier New', monospace;
|
||||||
--font-editor: 'Anonymous Pro', 'Courier New', monospace;
|
--font-editor: 'Anonymous Pro', 'Courier New', monospace;
|
||||||
--font-preview: '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-ui: 'Fantasque Sans Mono', Consolas, monospace;
|
||||||
--font-editor: 'Fantasque Sans Mono', Consolas, monospace;
|
--font-editor: 'Fantasque Sans Mono', monospace;
|
||||||
--font-preview: 'Fantasque Sans Mono', Consolas, 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,
|
:root[data-bg='4'] .toolbar,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** 背景 id 0–22,对应 `appearance-styles.css` 中 `:root[data-bg]`。 */
|
/** 背景 id 0–29,对应 `appearance-styles.css` 中 `:root[data-bg]`。 */
|
||||||
export const BACKGROUNDS: { id: string; name: string }[] = [
|
export const BACKGROUNDS: { id: string; name: string }[] = [
|
||||||
{ id: '0', name: '云雾灰' },
|
{ id: '0', name: '暖雾灰' },
|
||||||
{ id: '1', name: '羊皮纸' },
|
{ id: '1', name: '羊皮纸' },
|
||||||
{ id: '2', name: '深海蓝' },
|
{ id: '2', name: '深海蓝' },
|
||||||
{ id: '3', name: '森绿' },
|
{ id: '3', name: '森绿' },
|
||||||
@@ -22,53 +22,48 @@ export const BACKGROUNDS: { id: string; name: string }[] = [
|
|||||||
{ id: '19', name: '樱粉雾' },
|
{ id: '19', name: '樱粉雾' },
|
||||||
{ id: '20', name: '钢蓝灰' },
|
{ id: '20', name: '钢蓝灰' },
|
||||||
{ id: '21', 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 0–31,对应 `:root[data-font]`。 */
|
/** 字体 id 0–19,对应 `:root[data-font]`。 */
|
||||||
export const FONTS: { id: string; name: string }[] = [
|
export const FONTS: { id: string; name: string }[] = [
|
||||||
{ id: '0', name: '系统默认' },
|
{ id: '0', name: '系统默认' },
|
||||||
{ id: '1', name: '经典衬线' },
|
{ id: '1', name: '经典衬线' },
|
||||||
{ id: '2', name: '现代等宽' },
|
{ id: '2', name: '现代等宽' },
|
||||||
{ id: '3', name: '宋体阅读' },
|
{ id: '3', name: '宋体阅读' },
|
||||||
{ id: '4', name: 'IBM Plex 家族' },
|
{ id: '4', name: '楷体古韵' },
|
||||||
{ id: '5', name: 'Helvetica 现代' },
|
{ id: '5', name: '霞鹜文楷' },
|
||||||
{ id: '6', name: 'Georgia 优雅' },
|
{ id: '6', name: '思源黑体' },
|
||||||
{ id: '7', name: 'Verdana 清晰' },
|
{ id: '7', name: 'Helvetica 现代' },
|
||||||
{ id: '8', name: 'Courier 复古' },
|
{ id: '8', name: '粗体圆润' },
|
||||||
{ id: '9', name: 'Segoe UI 流畅' },
|
{ id: '9', name: '打字机风' },
|
||||||
{ id: '10', name: '思源黑体' },
|
{ id: '10', name: 'IBM Plex' },
|
||||||
{ id: '11', name: '楷体古韵' },
|
{ id: '11', name: '典雅衬线' },
|
||||||
{ id: '12', name: '苹方雅致' },
|
{ id: '12', name: '圆体柔和' },
|
||||||
{ id: '13', name: '冬青黑体' },
|
{ id: '13', name: '几何现代' },
|
||||||
{ id: '14', name: '霞鹜文楷' },
|
{ id: '14', name: '厚重阅读' },
|
||||||
{ id: '15', name: 'HarmonyOS 鸿蒙' },
|
{ id: '15', name: '等宽复古' },
|
||||||
{ id: '16', name: 'OPPO Sans' },
|
{ id: '16', name: '趣味手写' },
|
||||||
{ id: '17', name: 'MiSans 小米' },
|
{ id: '17', name: '清晰开放' },
|
||||||
{ id: '18', name: '阿里巴巴普惠体' },
|
{ id: '18', name: '方块黑体' },
|
||||||
{ id: '19', 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 趣味码' }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export type ViewMode = 'edit' | 'read' | 'hybrid'
|
export type ViewMode = 'read' | 'hybrid' | 'live'
|
||||||
|
|
||||||
export function validBackgroundId(s: string | null): string | null {
|
export function validBackgroundId(s: string | null): string | null {
|
||||||
if (s === null) return null
|
if (s === null) return null
|
||||||
return /^([0-9]|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 {
|
export function validFontId(s: string | null): string | null {
|
||||||
if (s === null) return 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
|
||||||
}
|
}
|
||||||
Vendored
+3
-31
@@ -1,37 +1,9 @@
|
|||||||
/// <reference types="vite/client" />
|
import type { MyReaderApi } from '../../../electron/preload.js'
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
myreader?: MyReaderApi
|
myreader: MyReaderApi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {}
|
export type { MyReaderApi }
|
||||||
@@ -29,6 +29,11 @@ export function removeFileHistory(paths: string[], filePath: string): string[] {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearFileHistory(): string[] {
|
||||||
|
saveFileHistory([])
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
export function historyFileName(path: string): string {
|
export function historyFileName(path: string): string {
|
||||||
const parts = path.split(/[/\\]/)
|
const parts = path.split(/[/\\]/)
|
||||||
return parts[parts.length - 1] || path
|
return parts[parts.length - 1] || path
|
||||||
|
|||||||
+436
-68
@@ -36,9 +36,10 @@ body {
|
|||||||
transition: background 0.3s ease;
|
transition: background 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 滚动条默认透明,hover/滚动时显示 */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 6px;
|
||||||
height: 8px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
@@ -46,15 +47,17 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: color-mix(in srgb, var(--muted) 30%, transparent);
|
background: transparent;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
*:hover::-webkit-scrollbar-thumb,
|
||||||
background: color-mix(in srgb, var(--muted) 50%, transparent);
|
*:focus::-webkit-scrollbar-thumb,
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: color-mix(in srgb, var(--muted) 40%, transparent);
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
}
|
}
|
||||||
@@ -86,61 +89,61 @@ html[data-bg='22'] .app-root {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
padding: 8px 10px 0;
|
padding: 10px 12px 0;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background: var(--panel-bg);
|
background: var(--panel-bg);
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
overflow-x: auto;
|
overflow: hidden;
|
||||||
overflow-y: hidden;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
box-shadow: 0 1px 4px color-mix(in srgb, #000 3%, transparent);
|
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 {
|
.tab {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex: 0 0 160px;
|
flex: 1 1 auto;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
min-width: 120px;
|
min-width: 40px;
|
||||||
max-width: 200px;
|
max-width: 220px;
|
||||||
border: 1px solid transparent;
|
border: none;
|
||||||
border-bottom: none;
|
|
||||||
border-radius: 8px 8px 0 0;
|
border-radius: 8px 8px 0 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
color: var(--muted);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: background 0.15s;
|
transition: background 0.18s, color 0.18s;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab:not(.active):hover {
|
.tab:not(.active):hover {
|
||||||
background: var(--hover);
|
background: var(--hover);
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab.active {
|
.tab.active {
|
||||||
background: var(--app-bg);
|
background: var(--app-bg);
|
||||||
border-color: var(--border);
|
color: var(--text);
|
||||||
box-shadow: 0 -2px 0 var(--accent), 0 1px 0 var(--app-bg);
|
box-shadow: 0 -1.5px 0 var(--accent);
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab.active::after {
|
.tab.active::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -1px;
|
bottom: 0;
|
||||||
left: 0;
|
left: 8px;
|
||||||
right: 0;
|
right: 8px;
|
||||||
height: 1px;
|
height: 2.5px;
|
||||||
background: var(--app-bg);
|
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 {
|
.tab-main {
|
||||||
@@ -148,8 +151,8 @@ html[data-bg='22'] .app-root {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
padding: 7px 4px 7px 12px;
|
padding: 8px 6px 10px 14px;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
@@ -159,20 +162,32 @@ html[data-bg='22'] .app-root {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tab-close {
|
.tab-close {
|
||||||
flex: 0 0 28px;
|
flex: 0 0 26px;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
transition: color 0.12s, background 0.12s;
|
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 {
|
.tab-close:hover {
|
||||||
color: #e53e3e;
|
color: #fff;
|
||||||
background: color-mix(in srgb, #e53e3e 10%, transparent);
|
background: #e53e3e;
|
||||||
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-title {
|
.tab-title {
|
||||||
@@ -182,17 +197,37 @@ html[data-bg='22'] .app-root {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.01em;
|
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 {
|
.tab.active .tab-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab .dot {
|
.tab .dot {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-size: 20px;
|
font-size: 18px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
margin-right: 2px;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-context-menu {
|
.tab-context-menu {
|
||||||
@@ -1124,11 +1159,26 @@ html[data-bg='22'] .app-root {
|
|||||||
background: var(--hover);
|
background: var(--hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dir-item-icon,
|
||||||
.history-item-icon {
|
.history-item-icon {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
font-size: 14px;
|
display: inline-flex;
|
||||||
line-height: 1.4;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
margin-top: 1px;
|
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 {
|
.history-item-body {
|
||||||
@@ -1520,6 +1570,16 @@ html[data-bg='22'] .app-root {
|
|||||||
margin-top: 2px;
|
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 {
|
.global-drop-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1625,33 +1685,24 @@ html[data-bg='22'] .app-root {
|
|||||||
.preview-resizer {
|
.preview-resizer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: -10px;
|
right: -4px;
|
||||||
width: 16px;
|
width: 6px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
cursor: ew-resize;
|
cursor: ew-resize;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
border-radius: 6px;
|
border-radius: 3px;
|
||||||
border: 2px solid var(--accent);
|
background: var(--accent);
|
||||||
background: var(--btn-bg);
|
opacity: 0;
|
||||||
opacity: 0.9;
|
transition: opacity 0.18s ease;
|
||||||
transition: opacity 0.15s, filter 0.15s;
|
}
|
||||||
|
|
||||||
|
.preview-shell:hover .preview-resizer,
|
||||||
|
.preview-resizer:active {
|
||||||
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-resizer:hover {
|
.preview-resizer:hover {
|
||||||
opacity: 1;
|
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 {
|
.preview {
|
||||||
@@ -1737,10 +1788,13 @@ html[data-bg='22'] .app-root {
|
|||||||
.preview .md-diagram-wrap .mermaid-render-host {
|
.preview .md-diagram-wrap .mermaid-render-host {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
min-height: 4rem;
|
min-height: 4rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview .md-diagram-wrap .mermaid-placeholder {
|
.preview .md-diagram-wrap .mermaid-placeholder {
|
||||||
@@ -1754,6 +1808,25 @@ html[data-bg='22'] .app-root {
|
|||||||
display: block;
|
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 {
|
.preview .md-diagram-wrap .mermaid-error {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -2257,4 +2330,299 @@ html[data-bg='22'] .app-root {
|
|||||||
box-shadow: 0 0 0 3px rgba(234, 179, 8, 0.2);
|
box-shadow: 0 0 0 3px rgba(234, 179, 8, 0.2);
|
||||||
border-radius: 4px;
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, `)})`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const url = await requestText('图片地址', 'https://')
|
||||||
|
if (url === null) return
|
||||||
|
const rawAlt = await requestText('替代文字', '图片')
|
||||||
|
if (rawAlt === null) return
|
||||||
|
const alt = escapeMarkdownLinkFragment(rawAlt || '图片')
|
||||||
|
insertAtCursor(view, `})`)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,13 +9,17 @@ const MERMAID_LANG = new Set(['mermaid', 'flowchart', 'sequencediagram', 'sequen
|
|||||||
const MERMAID_START =
|
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
|
/^\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()
|
const l = lang.toLowerCase()
|
||||||
if (MERMAID_LANG.has(l)) return true
|
if (MERMAID_LANG.has(l)) return true
|
||||||
if (!lang.trim() && MERMAID_START.test(content)) return true
|
if (!lang.trim() && MERMAID_START.test(content)) return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMermaidFence(lang: string, content: string): boolean {
|
||||||
|
return isMermaidDiagramFence(lang, content)
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtmlSafe(s: string): string {
|
function escapeHtmlSafe(s: string): string {
|
||||||
return s
|
return s
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -377,6 +381,18 @@ export function previewLineCenterInShell(
|
|||||||
return blockRect.top - shellRect.top + blockRect.height / 2
|
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(
|
export function scrollPreviewToSourceLine(
|
||||||
scrollEl: HTMLElement,
|
scrollEl: HTMLElement,
|
||||||
article: HTMLElement,
|
article: HTMLElement,
|
||||||
@@ -386,23 +402,34 @@ export function scrollPreviewToSourceLine(
|
|||||||
): HTMLElement | null {
|
): HTMLElement | null {
|
||||||
const total = totalSourceLines ?? article.querySelectorAll('[data-source-line]').length
|
const total = totalSourceLines ?? article.querySelectorAll('[data-source-line]').length
|
||||||
const shell = article.closest('.preview-shell') ?? article.parentElement
|
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
|
if (!anchor && !shell) return null
|
||||||
|
|
||||||
const scrollRect = scrollEl.getBoundingClientRect()
|
|
||||||
let top: number
|
|
||||||
if (anchor) {
|
if (anchor) {
|
||||||
const anchorRect = anchor.getBoundingClientRect()
|
|
||||||
top = anchorRect.top - scrollRect.top + scrollEl.scrollTop - 20
|
|
||||||
anchor.classList.add('preview-anchor-flash')
|
anchor.classList.add('preview-anchor-flash')
|
||||||
window.setTimeout(() => anchor.classList.remove('preview-anchor-flash'), 1600)
|
window.setTimeout(() => anchor.classList.remove('preview-anchor-flash'), 1600)
|
||||||
} else if (shell instanceof HTMLElement) {
|
if (scrollEl.contains(anchor)) {
|
||||||
const center = previewLineCenterInShell(article, shell, line, total)
|
scrollElementInContainer(anchor, scrollEl, behavior)
|
||||||
top = (center ?? 0) - 40
|
return anchor
|
||||||
} else {
|
}
|
||||||
return null
|
}
|
||||||
|
|
||||||
|
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
|
return anchor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,24 @@ export function buildOutlineTree(items: OutlineItem[]): OutlineTreeNode[] {
|
|||||||
return root
|
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[] {
|
export function collectBranchIds(nodes: OutlineTreeNode[]): string[] {
|
||||||
const ids: string[] = []
|
const ids: string[] = []
|
||||||
const walk = (list: OutlineTreeNode[]): void => {
|
const walk = (list: OutlineTreeNode[]): void => {
|
||||||
|
|||||||
@@ -29,15 +29,14 @@ export function isDirty(t: Tab): boolean {
|
|||||||
|
|
||||||
export function loadViewMode(): ViewMode {
|
export function loadViewMode(): ViewMode {
|
||||||
const v = localStorage.getItem(VIEW_KEY) as ViewMode | null
|
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) === '1') return 'read'
|
||||||
if (localStorage.getItem(LEGACY_READ_KEY) === '0') return 'edit'
|
return 'live'
|
||||||
return 'read'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function viewModeLabel(m: ViewMode): string {
|
export function viewModeLabel(m: ViewMode): string {
|
||||||
if (m === 'edit') return '编辑'
|
|
||||||
if (m === 'read') return '阅读'
|
if (m === 'read') return '阅读'
|
||||||
|
if (m === 'live') return '创作'
|
||||||
return '分栏'
|
return '分栏'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user