fix(audit): 审计问题全量修复(docs/12)

高危逻辑 A1-A10:saved 写盘快照、重载卸载回写闸门与期望值校验、
?file 无条件打开、偏好读后才写+防抖、depth=0 守卫、查找命中随编辑
作废、自动保存避让冲突标签、搜索防链接环、重 IO 命令异步化+上限。
样式:--panel 更正为 --bg-panel、新增 .input 基础样式、文档主题
纸面/字色成对、打印隐藏清单补全、paperTone 驱动源码明暗。
性能:状态栏/大纲按正文 memo、大纲延后提取、Sidebar memo、专注
装饰映射复用、查找防抖、图片 base64 过 IPC。
中危一批:关窗落盘后放行、回收站去向明示、GBK 搜索、create_new
落盘、Mermaid 渲染代次守卫、多行粘贴分段等。
回归:新增 tabkey/audit-fix 集成测试(E1 Tab 抢先定性排除),
vitest 71 通过 + 2 跳过。
This commit is contained in:
Muse
2026-11-13 23:34:19 +08:00
parent 78fbf6a91b
commit db81e60d71
26 changed files with 1064 additions and 228 deletions

View File

@@ -1,4 +1,4 @@
import { Suspense, lazy, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { Suspense, lazy, useCallback, useDeferredValue, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import type { Editor } from '@milkdown/kit/core'
import { editorViewCtx } from '@milkdown/kit/core'
import TitleBar from './components/TitleBar'
@@ -25,7 +25,7 @@ import { editorCommands, insertMermaidBlock, insertMathBlock } from './editor/co
import {
isTauri, pickOpenFile, pickSaveFile, pickFolder, readTextFile, writeTextFile,
pickAndReadWebFile, downloadTextFile,
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets,
pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64,
statFile, createTextFile, renamePath, deleteToTrash, type DirEntry, type RecoveryEntry,
pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport,
} from './services/platform'
@@ -33,7 +33,7 @@ import * as store from './services/store'
import { recoveryKey, planRecovery } from './utils/recovery'
import { RecoveryModal, ConflictModal, QuitModal, PromptModal, ConfirmModal } from './components/Stage4Modals'
import { AppearanceModal, ExportModal } from './components/Stage5Modals'
import { DOC_THEMES } from './themes'
import { DOC_THEMES, docThemeById } from './themes'
import { markdownToExportHtml, htmlToMarkdown, docxToMarkdown, type ImageMode } from './services/exportHtml'
const WELCOME_MD = `# 欢迎使用 new-markdown
@@ -100,6 +100,12 @@ export default function App() {
const [exportOpen, setExportOpen] = useState(false)
/** 文件基线(外部修改检测):tabId -> 上次确认的磁盘状态 */
const baselinesRef = useRef(new Map<string, { mtimeMs: number; size: number }>())
/** 外部重载时抑制旧编辑器卸载回写的标签集合(见 reloadTabFromDisk) */
const suppressFinalWriteRef = useRef(new Set<string>())
/** UI 偏好是否已完成启动读库(之前不许把默认值写回库,防启动竞态覆盖) */
const uiLoadedRef = useRef(false)
/** 任一弹窗打开时为真:全局快捷键整体让行 */
const anyModalRef = useRef(false)
const conflictSeenRef = useRef(new Set<string>())
const recoveryWrittenRef = useRef(new Map<string, string>())
const lastTabsRef = useRef(new Map<string, { path: string | null; title: string }>())
@@ -125,6 +131,8 @@ export default function App() {
const findUiRef = useRef(findUi)
findUiRef.current = findUi
const pendingFindRef = useRef<string | null>(null)
const findTimerRef = useRef<number | undefined>(undefined)
const findPendingRef = useRef(false)
const activeFindApi = useCallback((): Pick<EditorPaneApi, 'findAll' | 'gotoMatch' | 'replaceCurrent' | 'replaceAll' | 'clearFind'> | SourceEditorApi | null => {
return sourceModeRef.current ? sourceApiRef.current : paneFindRef.current
@@ -154,6 +162,25 @@ export default function App() {
window.setTimeout(() => runFind(query, findUiRef.current.opts), 30)
}, [runFind])
const scheduleFind = useCallback((query: string, opts: FindOptions) => {
// 审计修复(性能):逐字输入查询词时每键都全量重建字符索引,大文档明显卡顿;防抖 150ms。
// Enter/上下翻页会先把挂起的查找立即执行(见 FindBar 的 onNext/onPrev)。
window.clearTimeout(findTimerRef.current)
findPendingRef.current = true
findTimerRef.current = window.setTimeout(() => {
findPendingRef.current = false
runFind(query, opts)
}, 150)
}, [runFind])
const flushPendingFind = useCallback((): boolean => {
if (!findPendingRef.current) return false
window.clearTimeout(findTimerRef.current)
findPendingRef.current = false
runFind(findUiRef.current.query, findUiRef.current.opts)
return true
}, [runFind])
const closeFind = useCallback(() => {
activeFindApi()?.clearFind()
setFindUi((f) => ({ ...f, open: false, count: 0, current: -1, error: null }))
@@ -161,9 +188,14 @@ export default function App() {
const tauriMode = isTauri()
const uiSaveTimerRef = useRef<number | undefined>(undefined)
useEffect(() => {
document.documentElement.dataset.theme = ui.theme
store.saveUiPref(JSON.stringify(ui))
if (!uiLoadedRef.current) return
window.clearTimeout(uiSaveTimerRef.current)
uiSaveTimerRef.current = window.setTimeout(() => {
store.saveUiPref(JSON.stringify(ui))
}, 400)
}, [ui])
// 工作区搜索点击结果:文件打开后自动打开查找条并定位
@@ -240,6 +272,8 @@ export default function App() {
}
}, [tauriMode, openPath])
const handleSidebarOpenFile = useCallback((path: string) => { void openPath(path) }, [openPath])
const openSearchResult = useCallback((path: string, query: string) => {
pendingFindRef.current = query
void openPath(path)
@@ -271,11 +305,17 @@ export default function App() {
}
}, [])
const handleSidebarOpenFolder = useCallback(() => { void openFolder() }, [openFolder])
const saveTab = useCallback(async (target: DocTab, saveAs: boolean): Promise<boolean> => {
// 审计修复:保存用调用时刻的快照;saved 只把这份快照标为已保存,
// 写盘期间新输入的内容保持「脏」,不会被误标、不会丢恢复记录。
const snapshot = target.content
const oldKey = recoveryKey(target)
try {
if (!tauriMode) {
downloadTextFile(`${target.title}.md`, target.content)
dispatch({ type: 'saved', id: target.id })
downloadTextFile(`${target.title}.md`, snapshot)
dispatch({ type: 'saved', id: target.id, savedContent: snapshot })
return true
}
let path = target.path
@@ -283,13 +323,16 @@ export default function App() {
path = await pickSaveFile(path ?? `${target.title}.md`)
if (!path) return false
}
await writeTextFile(path, toDiskContent(target.content, target.lineEnding), target.hadBom)
dispatch({ type: 'saved', id: target.id, path, title: baseName(path) })
await writeTextFile(path, toDiskContent(snapshot, target.lineEnding), target.hadBom)
dispatch({ type: 'saved', id: target.id, savedContent: snapshot, path, title: baseName(path) })
const stat = await statFile(path)
if (stat.exists) baselinesRef.current.set(target.id, { mtimeMs: stat.mtimeMs, size: stat.size })
const key = recoveryKey({ id: target.id, path })
recoveryWrittenRef.current.delete(key)
void store.deleteRecovery(key)
// 恢复记录可能按旧键(未命名/旧路径)写入,新旧键都清,避免下次启动误报草稿
const newKey = recoveryKey({ id: target.id, path })
for (const key of new Set([oldKey, newKey])) {
recoveryWrittenRef.current.delete(key)
void store.deleteRecovery(key)
}
pushRecent(path)
return true
} catch (e) {
@@ -309,17 +352,30 @@ export default function App() {
}, [requestClose])
// ——— 阶段 4:存储初始化与启动恢复 ———
const reloadTabFromDisk = useCallback(async (target: DocTab) => {
const reloadTabFromDisk = useCallback(async (target: DocTab, expectedContent?: string) => {
if (!target.path) return
try {
const file = await readTextFile(target.path)
// 审计修复(检查—执行竞态):读取期间用户已输入或状态已变,则放弃这次重载,
// 交给下一轮轮询按冲突流程处理,绝不用磁盘内容整表覆盖新输入。
const current = stateRef.current.tabs.find((t) => t.id === target.id)
if (!current) return
if (expectedContent !== undefined && current.content !== expectedContent) return
if (isDirty(current)) return
const isActive = stateRef.current.activeId === target.id
// 审计修复(卸载回写覆盖):活动标签重挂时,旧 EditorPane 的卸载回写会把旧正文
// 写回状态、盖掉刚载入的内容——重载前先给该标签的这次最终回写上闸门。
if (isActive) {
suppressFinalWriteRef.current.add(target.id)
window.setTimeout(() => suppressFinalWriteRef.current.delete(target.id), 1500)
}
dispatch({
type: 'loadContent', id: target.id, content: file.content,
encoding: file.encoding, lineEnding: file.lineEnding, hadBom: file.hadBom,
})
const stat = await statFile(target.path)
if (stat.exists) baselinesRef.current.set(target.id, { mtimeMs: stat.mtimeMs, size: stat.size })
if (stateRef.current.activeId === target.id) setEditorEpoch((n) => n + 1)
if (isActive) setEditorEpoch((n) => n + 1)
} catch (e) { setError(String(e)) }
}, [])
@@ -354,26 +410,31 @@ export default function App() {
useEffect(() => {
let cancelled = false
void (async () => {
const recentFromDb = await store.loadRecent()
if (!cancelled && recentFromDb) setRecent(recentFromDb.map((r) => r.path))
// 偏好先于最近列表读回并置位 uiLoadedRef:[ui] 保存 effect 在此之前不得写库,
// 否则挂载时的初始默认值会先落库、把用户设置覆盖掉(启动竞态)。
const uiJson = await store.loadUiPref()
if (!cancelled && uiJson) {
try { setUi((prev) => ({ ...prev, ...JSON.parse(uiJson) })) } catch { /* 偏好损坏时用默认值 */ }
}
uiLoadedRef.current = true
const recentFromDb = await store.loadRecent()
if (!cancelled && recentFromDb) setRecent(recentFromDb.map((r) => r.path))
// 启动恢复:先读磁盘现状再划分(与磁盘一致的记录静默清理)
const records = await store.listRecovery()
if (cancelled || records.length === 0) return
const disk = new Map<string, string | null>()
for (const record of records) {
if (!record.path || disk.has(record.path)) continue
try {
disk.set(record.path, (await readTextFile(record.path)).content)
} catch { disk.set(record.path, null) }
if (!cancelled && records.length > 0) {
const disk = new Map<string, string | null>()
for (const record of records) {
if (!record.path || disk.has(record.path)) continue
try {
disk.set(record.path, (await readTextFile(record.path)).content)
} catch { disk.set(record.path, null) }
}
const plan = planRecovery(records, (path) => disk.get(path) ?? null)
for (const key of plan.purgeKeys) void store.deleteRecovery(key)
if (plan.offer.length > 0) setRecoveryOffer(plan.offer)
}
const plan = planRecovery(records, (path) => disk.get(path) ?? null)
for (const key of plan.purgeKeys) void store.deleteRecovery(key)
if (!cancelled && plan.offer.length > 0) setRecoveryOffer(plan.offer)
// 多窗口:新窗口通过 ?file= 参数打开指定文档(阶段 6)
// 多窗口:新窗口通过 ?file= 参数打开指定文档(阶段 6)。
// 审计修复:这段原先写在恢复记录的提前 return 之后,无恢复记录时永远执行不到。
const startupFile = new URLSearchParams(window.location.search).get('file')
if (!cancelled && startupFile) void openPath(startupFile)
})()
@@ -391,6 +452,15 @@ export default function App() {
for (const [id, prev] of lastTabsRef.current) {
if (!current.has(id)) {
baselinesRef.current.delete(id)
for (const key of [...conflictSeenRef.current]) {
if (key.startsWith(`${id}:`)) conflictSeenRef.current.delete(key)
}
setDocOverrides((prevMap) => {
if (!(id in prevMap)) return prevMap
const next = { ...prevMap }
delete next[id]
return next
})
const key = recoveryKey({ id, path: prev.path })
if (recoveryWrittenRef.current.delete(key)) void store.deleteRecovery(key)
}
@@ -425,7 +495,8 @@ export default function App() {
if (!ui.autosave) return
const timer = window.setInterval(() => {
for (const t of stateRef.current.tabs) {
if (t.path && isDirty(t)) void saveTabRef.current(t, false)
// 冲突弹窗正等用户裁决的标签不自动保存,否则会抢先覆盖外部修改
if (t.path && isDirty(t) && conflictRef.current !== t.id) void saveTabRef.current(t, false)
}
}, Math.max(10, ui.autosaveSeconds) * 1000)
return () => window.clearInterval(timer)
@@ -449,7 +520,7 @@ export default function App() {
}
if (stat.mtimeMs === base.mtimeMs && stat.size === base.size) continue
if (!isDirty(t)) {
await reloadTabFromDisk(t)
await reloadTabFromDisk(t, t.content)
flash(`《${t.title}》已被外部修改,已自动重新加载`)
} else if (conflictRef.current !== t.id && !conflictSeenRef.current.has(`${t.id}:${stat.mtimeMs}`)) {
conflictSeenRef.current.add(`${t.id}:${stat.mtimeMs}`)
@@ -467,17 +538,19 @@ export default function App() {
let unlisten: (() => void) | undefined
void (async () => {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
unlisten = await getCurrentWindow().onCloseRequested((event) => {
const win = getCurrentWindow()
unlisten = await win.onCloseRequested(async (event) => {
// 审计修复:原实现恢复写入是 fire-and-forget,只有未命名草稿时直接放行关窗,
// IPC 未落盘进程已退出、草稿照样丢。现先拦下关闭,等恢复记录全部写完再决定。
event.preventDefault()
const dirty = stateRef.current.tabs.filter(isDirty)
// 先把所有草稿刷进恢复记录,保证任何退出路径都不丢内容
for (const t of dirty) {
void store.saveRecovery({
docKey: recoveryKey(t), path: t.path, title: t.title, content: t.content, updatedAt: Date.now(),
})
}
await Promise.all(dirty.map((t) => store.saveRecovery({
docKey: recoveryKey(t), path: t.path, title: t.title, content: t.content, updatedAt: Date.now(),
})))
if (dirty.some((t) => t.path)) {
event.preventDefault()
setQuitOpen(true)
} else {
await win.destroy()
}
})
})()
@@ -547,7 +620,7 @@ export default function App() {
setConfirmSpec(null)
void (async () => {
try {
await deleteToTrash(entry.path)
const dest = await deleteToTrash(entry.path)
for (const t of stateRef.current.tabs) {
if (t.path && (t.path === entry.path || t.path.startsWith(`${entry.path}/`) || t.path.startsWith(`${entry.path}\\`))) {
dispatch({ type: 'close', id: t.id })
@@ -557,7 +630,9 @@ export default function App() {
}
store.removeRecent(entry.path)
setTreeRefresh((n) => n + 1)
flash(`已删除 ${entry.name}`)
flash(dest === 'permanent'
? `已永久删除 ${entry.name}(回收站不可用,未进回收站)`
: `已删除 ${entry.name}`)
} catch (e) { setError(String(e)) }
})()
},
@@ -593,6 +668,7 @@ export default function App() {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (isImeComposing(e)) return // IME 组合期间全局快捷键一律不触发(阶段 2 守卫)
if (anyModalRef.current) return // 任一弹窗打开时让行,避免在弹窗背后触发编辑器动作
if (e.key === 'Escape' && findUiRef.current.open) { e.preventDefault(); closeFind(); return }
if (!e.ctrlKey && !e.metaKey && !e.altKey) return
const combo = comboFromEvent(e)
@@ -654,8 +730,7 @@ export default function App() {
if (isTauri() && current?.path) {
const ext = (file.type.split('/')[1] || 'png').replace('jpeg', 'jpg')
const named = file.name && file.name !== 'image.png' ? file.name : `image-${Date.now()}.${ext}`
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()))
return saveBytesToAssets(current.path, named, bytes)
return saveBytesToAssets(current.path, named, await fileToBase64(file))
}
return new Promise((resolve) => {
const reader = new FileReader()
@@ -697,8 +772,10 @@ export default function App() {
const dir = docPath.replace(/[\\/][^\\/]*$/, '')
return async (src: string): Promise<string | null> => {
try {
const isAbsolute = /^[a-zA-Z]:[\\/]/.test(src) || src.startsWith('/') || src.startsWith('\\')
const raw = isAbsolute ? src : `${dir}/${src}`
let decoded = src
try { decoded = decodeURIComponent(src) } catch { /* 非法转义时用原文 */ }
const isAbsolute = /^[a-zA-Z]:[\\/]/.test(decoded) || decoded.startsWith('/') || decoded.startsWith('\\')
const raw = isAbsolute ? decoded : `${dir}/${decoded}`
const drive = raw.match(/^[a-zA-Z]:/)?.[0] ?? ''
const segments: string[] = []
for (const part of raw.slice(drive.length).replace(/\\/g, '/').split('/')) {
@@ -706,7 +783,8 @@ export default function App() {
else if (part !== '.' && part !== '') segments.push(part)
}
const joined = drive + '/' + segments.join('/')
const ext = (src.split('.').pop() ?? '').toLowerCase()
const cleanSrc = src.split(/[?#]/)[0]
const ext = (cleanSrc.split('.').pop() ?? '').toLowerCase()
const mime = ext === 'png' ? 'image/png' : ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg'
: ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : ext === 'svg' ? 'image/svg+xml' : 'application/octet-stream'
const base64 = await readFileBase64(joined)
@@ -764,9 +842,21 @@ export default function App() {
const printDoc = useCallback(() => {
if (!tab) return
if (sourceMode) toggleSourceMode()
flash('在打印对话框中可选择「另存为 PDF」导出 PDF')
window.setTimeout(() => window.print(), 60)
if (!sourceMode) {
window.setTimeout(() => window.print(), 60)
return
}
// 审计修复:原先固定等 60ms 就打印,源码切渲染是异步重建,大文档必然赛跑。
// 改为等渲染编辑器就绪(最多 3 秒兜底)后再打印。
toggleSourceMode()
const started = Date.now()
const timer = window.setInterval(() => {
if (editorRef.current || Date.now() - started > 3000) {
window.clearInterval(timer)
window.requestAnimationFrame(() => window.print())
}
}, 50)
}, [tab, sourceMode, toggleSourceMode, flash])
const printRef = useRef(printDoc)
printRef.current = printDoc
@@ -792,7 +882,23 @@ export default function App() {
} catch (e) { setError(String(e)) }
}, [tauriMode, flash])
const outline = useMemo(() => (tab ? extractOutline(tab.content) : []), [tab])
anyModalRef.current = Boolean(
modal || appearanceOpen || exportOpen || pendingClose || conflict
|| promptSpec || confirmSpec || quitOpen || recoveryOffer.length > 0,
)
// 大纲与状态栏计数只随正文变化重算(原先每次光标移动/击键都对全文重算);
// 大纲再经 useDeferredValue 让高频输入时延后提取,不堵击键路径。
const deferredContent = useDeferredValue(tab?.content ?? '')
const outline = useMemo(
() => (tab ? extractOutline(deferredContent) : []),
// eslint-disable-next-line react-hooks/exhaustive-deps
[tab?.id, deferredContent],
)
const statusCounts = useMemo(() => {
if (!tab) return { words: 0, chars: 0, lines: 0 }
return { words: countWords(tab.content), chars: countChars(tab.content), lines: tab.content.split('\n').length }
}, [tab?.id, tab?.content])
const menus: MenuDef[] = useMemo(() => [
{
@@ -927,8 +1033,8 @@ export default function App() {
onModeChange={setSidebarMode}
workspaceRoot={workspaceRoot}
activePath={tab?.path ?? null}
onOpenFile={(path) => void openPath(path)}
onOpenFolder={() => void openFolder()}
onOpenFile={handleSidebarOpenFile}
onOpenFolder={handleSidebarOpenFolder}
onCreateFile={tauriMode && workspaceRoot ? createFileInWorkspace : undefined}
onRenameEntry={tauriMode ? renameEntry : undefined}
onDeleteEntry={tauriMode ? deleteEntry : undefined}
@@ -953,11 +1059,13 @@ export default function App() {
) : sourceMode ? (
<Suspense fallback={<div className="empty-state">正在加载源码编辑器…</div>}>
<SourceEditor
key={`source:${tab.id}:${ui.theme}`}
key={`source:${tab.id}`}
tabId={tab.id}
initialText={tab.content}
initialCursorOffset={sourceCursorOffset}
dark={ui.theme === 'dark'}
dark={docThemeById(effectiveDocTheme).paperTone
? docThemeById(effectiveDocTheme).paperTone === 'dark'
: ui.theme === 'dark'}
onChange={(text) => dispatch({ type: 'updateContent', id: tab.id, content: text })}
onReady={(api) => { sourceApiRef.current = api }}
/>
@@ -968,10 +1076,15 @@ export default function App() {
tabId={tab.id}
initialMarkdown={tab.content}
onMarkdown={(id, md) => dispatch({ type: 'updateContent', id, content: md })}
onFinalMarkdown={(id, md) => {
if (suppressFinalWriteRef.current.delete(id)) return // 外部重载的卸载回写,丢弃
dispatch({ type: 'updateContent', id, content: md })
}}
onCursor={setCursor}
focusMode={ui.focusMode}
typewriter={ui.typewriter}
onImageFile={handleImageFile}
onImageError={(message) => setError(`图片插入失败:${message}`)}
onReady={(api) => {
paneFindRef.current = api.editor ? api : null
editorRef.current = api.editor
@@ -993,12 +1106,12 @@ export default function App() {
count={findUi.count}
current={findUi.current}
error={findUi.error}
onQueryChange={(value) => { setFindUi((f) => ({ ...f, query: value })); runFind(value, findUiRef.current.opts) }}
onQueryChange={(value) => { setFindUi((f) => ({ ...f, query: value })); scheduleFind(value, findUiRef.current.opts) }}
onReplacementChange={(value) => setFindUi((f) => ({ ...f, replacement: value }))}
onToggleReplace={() => setFindUi((f) => ({ ...f, replace: !f.replace }))}
onOptsChange={(opts) => { setFindUi((f) => ({ ...f, opts })); runFind(findUiRef.current.query, opts) }}
onNext={() => { const api = activeFindApi(); if (api) { const cur = api.gotoMatch(findUiRef.current.current + 1); setFindUi((f) => ({ ...f, current: cur })) } }}
onPrev={() => { const api = activeFindApi(); if (api) { const cur = api.gotoMatch(findUiRef.current.current - 1); setFindUi((f) => ({ ...f, current: cur })) } }}
onOptsChange={(opts) => { setFindUi((f) => ({ ...f, opts })); scheduleFind(findUiRef.current.query, opts) }}
onNext={() => { if (flushPendingFind()) return; const api = activeFindApi(); if (api) { const cur = api.gotoMatch(findUiRef.current.current + 1); setFindUi((f) => ({ ...f, current: cur })) } }}
onPrev={() => { if (flushPendingFind()) return; const api = activeFindApi(); if (api) { const cur = api.gotoMatch(findUiRef.current.current - 1); setFindUi((f) => ({ ...f, current: cur })) } }}
onReplace={() => {
const api = activeFindApi()
if (!api || !api.replaceCurrent(findUiRef.current.replacement)) return
@@ -1019,9 +1132,9 @@ export default function App() {
</section>
</div>
<StatusBar
words={tab ? countWords(tab.content) : 0}
chars={tab ? countChars(tab.content) : 0}
lines={tab ? tab.content.split('\n').length : 0}
words={statusCounts.words}
chars={statusCounts.chars}
lines={statusCounts.lines}
cursor={cursor}
encoding={tab?.encoding ?? 'UTF-8'}
lineEnding={tab?.lineEnding ?? 'LF'}

View File

@@ -24,9 +24,12 @@ interface Props {
tabId: string
initialMarkdown: string
onMarkdown: (tabId: string, markdown: string) => void
/** 卸载前的最终回写(与实时 onMarkdown 分开,供外部重载时拦截旧内容回写) */
onFinalMarkdown?: (tabId: string, markdown: string) => void
onCursor: (cursor: CursorInfo) => void
onReady: (api: EditorPaneApi) => void
onImageFile?: (file: File) => Promise<string>
onImageError?: (message: string) => void
focusMode: boolean
typewriter: boolean
}
@@ -44,7 +47,7 @@ const EMPTY_API: EditorPaneApi = {
}
/** 单标签编辑器宿主:挂载时以标签内容建模,卸载前回写最终序列化文本,保证切标签不丢内容。 */
export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCursor, onReady, onImageFile, focusMode, typewriter }: Props) {
export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinalMarkdown, onCursor, onReady, onImageFile, onImageError, focusMode, typewriter }: Props) {
const wrapRef = useRef<HTMLDivElement>(null)
const findRef = useRef<{ matches: MatchRange[]; current: number; query: string; opts: FindOptions | null }>({
matches: [], current: -1, query: '', opts: null,
@@ -65,8 +68,8 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso
}, [])
const hostRef = useRef<HTMLDivElement>(null)
const initialRef = useRef(initialMarkdown)
const callbacksRef = useRef({ onMarkdown, onCursor, onReady, onImageFile })
callbacksRef.current = { onMarkdown, onCursor, onReady, onImageFile }
const callbacksRef = useRef({ onMarkdown, onFinalMarkdown, onCursor, onReady, onImageFile, onImageError })
callbacksRef.current = { onMarkdown, onFinalMarkdown, onCursor, onReady, onImageFile, onImageError }
useEffect(() => {
if (!hostRef.current) return
@@ -90,13 +93,23 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso
})
}
const editor = createMarkdownEditor(hostRef.current, initialRef.current, {
onMarkdown: (md) => callbacksRef.current.onMarkdown(tabId, md),
onMarkdown: (md) => {
// 文档变化后已算出的查找命中立即过期:下一次查找操作会先按新内容重算,
// 杜绝替换打在偏移后的错误位置上(审计高危)。
if (findRef.current.matches.length > 0) {
findRef.current = { matches: [], current: -1, query: findRef.current.query, opts: findRef.current.opts }
}
callbacksRef.current.onMarkdown(tabId, md)
},
onCursor: (c) => {
callbacksRef.current.onCursor(c)
if (modesRef.current.typewriter) centerCursorLine()
},
onTableContext: (inTable, el) => { tableElRef.current = el; if (inTable) updateTableBar(); else setTableBar(null) },
}, { onImageFile: (file) => callbacksRef.current.onImageFile ? callbacksRef.current.onImageFile(file) : Promise.resolve(URL.createObjectURL(file)) })
}, {
onImageFile: (file) => callbacksRef.current.onImageFile ? callbacksRef.current.onImageFile(file) : Promise.resolve(URL.createObjectURL(file)),
onImageError: (message) => callbacksRef.current.onImageError?.(message),
})
editorObjRef.current = editor
const jumpToHeading = (text: string) => {
const els = hostRef.current?.querySelectorAll('h1,h2,h3,h4,h5,h6') ?? []
@@ -148,7 +161,11 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso
}
}
const gotoMatch = (index: number): number => {
const found = findRef.current
let found = findRef.current
if (found.matches.length === 0 && found.query && found.opts) {
findAll(found.query, found.opts)
found = findRef.current
}
if (found.matches.length === 0) return -1
const wrapped = ((index % found.matches.length) + found.matches.length) % found.matches.length
found.current = wrapped
@@ -161,7 +178,11 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso
return wrapped
}
const replaceCurrent = (replacement: string): boolean => {
const found = findRef.current
let found = findRef.current
if (found.matches.length === 0 && found.query && found.opts) {
findAll(found.query, found.opts)
found = findRef.current
}
if (found.current < 0 || !found.opts) return false
const m = found.matches[found.current]
if (!m) return false
@@ -170,13 +191,16 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso
view.dispatch(view.state.tr.insertText(replacement, m.from, m.to))
view.focus()
})
const previous = found.current
const count = findAll(found.query, found.opts)
if (count > 0) gotoMatch(Math.min(previous, count - 1))
// 命中刷新与定位统一由 App 层在替换后做(原先两层各算一遍、序号还对不齐)
findRef.current = { matches: [], current: -1, query: found.query, opts: found.opts }
return true
}
const replaceAll = (replacement: string): number => {
const found = findRef.current
let found = findRef.current
if (found.matches.length === 0 && found.query && found.opts) {
findAll(found.query, found.opts)
found = findRef.current
}
if (found.matches.length === 0) return 0
const total = found.matches.length
const reversed = [...found.matches].reverse()
@@ -210,7 +234,9 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso
editorObjRef.current = null
setTableBar(null)
try {
callbacksRef.current.onMarkdown(tabId, getMarkdown(editor))
const finalMarkdown = getMarkdown(editor)
if (callbacksRef.current.onFinalMarkdown) callbacksRef.current.onFinalMarkdown(tabId, finalMarkdown)
else callbacksRef.current.onMarkdown(tabId, finalMarkdown)
} catch { /* 编辑器未就绪时跳过回写 */ }
callbacksRef.current.onReady(EMPTY_API)
editor.destroy().catch(() => undefined)

View File

@@ -46,6 +46,7 @@ export function ShortcutsModal({ overrides, onChange, onClose }: {
onClose: () => void
}) {
const [recordingId, setRecordingId] = useState<string | null>(null)
const [recordHint, setRecordHint] = useState<string | null>(null)
const [conflict, setConflict] = useState<{ id: string; combo: string; ownerLabel: string; ownerId: string } | null>(null)
const combos = effectiveCombos(overrides)
@@ -57,6 +58,12 @@ export function ShortcutsModal({ overrides, onChange, onClose }: {
if (e.key === 'Escape') { setRecordingId(null); return }
const combo = comboFromEvent(e)
if (!combo) return
if (!combo.includes('ctrl') && !combo.includes('alt') && !combo.includes('meta')) {
// 全局分发只处理带修饰键的组合,纯按键绑定永不触发,录入时直接拦下说明
setRecordHint('快捷键需要带 Ctrl 或 Alt 修饰键,纯按键不会被应用触发。')
return
}
setRecordHint(null)
const owner = comboOwner(combo, overrides, recordingId)
setRecordingId(null)
if (owner) {
@@ -93,6 +100,7 @@ export function ShortcutsModal({ overrides, onChange, onClose }: {
)
})}
</div>
{recordHint && <p className="modal-note">{recordHint}</p>}
{conflict && (
<p className="modal-note conflict-note">
<b>{prettyCombo(conflict.combo)}</b> 已被「{conflict.ownerLabel}」占用。

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { memo, useCallback, useEffect, useState } from 'react'
import { listDir, workspaceSearch, type DirEntry, type WorkspaceSearchFile } from '../services/platform'
import type { OutlineItem } from '../utils/text'
import { baseName } from '../utils/text'
@@ -20,7 +20,7 @@ interface Props {
tauriMode: boolean
}
export default function Sidebar(p: Props) {
function Sidebar(p: Props) {
return (
<aside className="sidebar">
<div className="sidebar-tabs">
@@ -174,6 +174,7 @@ function WorkspaceSearch({ root, tauriMode, onOpen }: {
onOpen?: (path: string, query: string) => void
}) {
const [query, setQuery] = useState('')
const [searchedQuery, setSearchedQuery] = useState('')
const [caseSensitive, setCaseSensitive] = useState(false)
const [results, setResults] = useState<WorkspaceSearchFile[] | null>(null)
const [searching, setSearching] = useState(false)
@@ -184,7 +185,9 @@ function WorkspaceSearch({ root, tauriMode, onOpen }: {
setSearching(true)
setError(null)
try {
setResults(await workspaceSearch(root, query.trim(), caseSensitive))
const word = query.trim()
setResults(await workspaceSearch(root, word, caseSensitive))
setSearchedQuery(word)
} catch (e) {
setError(String(e))
} finally {
@@ -192,6 +195,14 @@ function WorkspaceSearch({ root, tauriMode, onOpen }: {
}
}
// 换工作区后旧结果作废,避免点到上一个工作区的文件
const [lastRoot, setLastRoot] = useState(root)
if (lastRoot !== root) {
setLastRoot(root)
setResults(null)
setSearchedQuery('')
}
if (!tauriMode) return <div className="sidebar-empty">Web 降级模式不支持工作区搜索。</div>
if (!root) return <div className="sidebar-empty">先打开一个文件夹,再搜索其中的文档。</div>
const total = results?.reduce((acc, f) => acc + f.matches.length, 0) ?? 0
@@ -225,7 +236,7 @@ function WorkspaceSearch({ root, tauriMode, onOpen }: {
<button
key={i}
className="ws-search-hit"
onClick={() => onOpen?.(file.path, query.trim())}
onClick={() => onOpen?.(file.path, searchedQuery)}
title={`${file.path}:${m.line}`}
>
<span className="ws-search-line">{m.line}</span>
@@ -238,3 +249,7 @@ function WorkspaceSearch({ root, tauriMode, onOpen }: {
</div>
)
}
// 审计修复(性能):组件 memo 化——正文击键时 App 整树重渲染,侧边栏(文件树/大纲)不必跟随
// (调用方需传稳定回调与已 memo 的 outline,见 App)。
export default memo(Sidebar)

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react'
import { EditorState } from '@codemirror/state'
import { EditorState, Compartment } from '@codemirror/state'
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view'
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown'
@@ -27,7 +27,8 @@ function findInText(text: string, query: string, opts: FindOptions): { from: num
if (opts.regex) {
let re: RegExp
try {
re = new RegExp(query, opts.caseSensitive ? 'g' : 'gi')
const pattern = opts.wholeWord ? `\\b(?:${query})\\b` : query
re = new RegExp(pattern, opts.caseSensitive ? 'g' : 'gi')
} catch (e) {
throw new Error(`正则表达式无效:${e instanceof Error ? e.message : String(e)}`)
}
@@ -39,8 +40,16 @@ function findInText(text: string, query: string, opts: FindOptions): { from: num
}
return hits
}
const haystack = opts.caseSensitive ? text : text.toLowerCase()
const needle = opts.caseSensitive ? query : query.toLowerCase()
const lowerKeepLen = (value: string) => {
let out = ''
for (const ch of value) {
const lower = ch.toLowerCase()
out += lower.length === ch.length ? lower : ch
}
return out
}
const haystack = opts.caseSensitive ? text : lowerKeepLen(text)
const needle = opts.caseSensitive ? query : lowerKeepLen(query)
let from = 0
while (from <= haystack.length - needle.length) {
const hit = haystack.indexOf(needle, from)
@@ -67,6 +76,9 @@ interface Props {
/** 源码模式编辑器(CodeMirror 6):行号、Markdown 高亮、历史栈与渲染模式相互独立,进入/退出靠锚点同步光标。 */
export default function SourceEditor({ tabId, initialText, initialCursorOffset, dark, onChange, onReady }: Props) {
const hostRef = useRef<HTMLDivElement>(null)
const themeCompartmentRef = useRef(new Compartment())
const darkRef = useRef(dark)
darkRef.current = dark
const viewRef = useRef<EditorView | null>(null)
const initialRef = useRef({ initialText, initialCursorOffset })
const callbacksRef = useRef({ onChange, onReady })
@@ -74,6 +86,10 @@ export default function SourceEditor({ tabId, initialText, initialCursorOffset,
useEffect(() => {
if (!hostRef.current) return
let findMatches: { from: number; to: number }[] = []
let findCurrent = -1
let findQuery = ''
let findOpts: FindOptions | null = null
const { initialText: doc, initialCursorOffset: cursor } = initialRef.current
const view = new EditorView({
parent: hostRef.current,
@@ -92,35 +108,36 @@ export default function SourceEditor({ tabId, initialText, initialCursorOffset,
markdown({ codeLanguages: languages }),
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
if (update.docChanged) callbacksRef.current.onChange(update.state.doc.toString())
if (update.docChanged) {
findMatches = []
findCurrent = -1
callbacksRef.current.onChange(update.state.doc.toString())
}
}),
themeCompartmentRef.current.of(darkRef.current ? [oneDark] : []),
EditorView.theme({
'&': { height: '100%', fontSize: '14px', backgroundColor: 'transparent' },
'&': { height: '100%', fontSize: 'calc(14px * var(--doc-zoom, 1))', backgroundColor: 'transparent' },
'.cm-content': { fontFamily: '"Cascadia Code", Consolas, monospace', padding: '24px 0', lineHeight: '1.7' },
'.cm-gutters': { backgroundColor: 'transparent', border: 'none' },
}),
...(dark ? [oneDark] : []),
],
}),
})
viewRef.current = view
view.focus()
let matches: { from: number; to: number }[] = []
let current = -1
let lastQuery = ''
let lastOpts: FindOptions | null = null
const findAll = (query: string, opts: FindOptions): number => {
matches = findInText(view.state.doc.toString(), query, opts)
current = matches.length ? 0 : -1
lastQuery = query
lastOpts = opts
return matches.length
findMatches = findInText(view.state.doc.toString(), query, opts)
findCurrent = findMatches.length ? 0 : -1
findQuery = query
findOpts = opts
return findMatches.length
}
const gotoMatch = (index: number): number => {
if (matches.length === 0) return -1
const wrapped = ((index % matches.length) + matches.length) % matches.length
current = wrapped
const m = matches[wrapped]
if (findMatches.length === 0 && findQuery && findOpts) findAll(findQuery, findOpts)
if (findMatches.length === 0) return -1
const wrapped = ((index % findMatches.length) + findMatches.length) % findMatches.length
findCurrent = wrapped
const m = findMatches[wrapped]
view.dispatch({
selection: { anchor: m.from, head: m.to },
effects: EditorView.scrollIntoView(m.from, { y: 'center' }),
@@ -140,27 +157,29 @@ export default function SourceEditor({ tabId, initialText, initialCursorOffset,
findAll,
gotoMatch,
replaceCurrent: (replacement) => {
if (current < 0 || !lastOpts) return false
const m = matches[current]
if (findMatches.length === 0 && findQuery && findOpts) findAll(findQuery, findOpts)
if (findCurrent < 0 || !findOpts) return false
const m = findMatches[findCurrent]
if (!m) return false
view.dispatch({ changes: { from: m.from, to: m.to, insert: replacement } })
view.focus()
const previous = current
const count = findAll(lastQuery, lastOpts)
if (count > 0) gotoMatch(Math.min(previous, count - 1))
// 命中刷新与定位统一由 App 层在替换后做(与渲染模式契约一致)
findMatches = []
findCurrent = -1
return true
},
replaceAll: (replacement) => {
if (matches.length === 0) return 0
const total = matches.length
const changes = [...matches].reverse().map((m) => ({ from: m.from, to: m.to, insert: replacement }))
if (findMatches.length === 0 && findQuery && findOpts) findAll(findQuery, findOpts)
if (findMatches.length === 0) return 0
const total = findMatches.length
const changes = [...findMatches].reverse().map((m) => ({ from: m.from, to: m.to, insert: replacement }))
view.dispatch({ changes })
view.focus()
matches = []
current = -1
findMatches = []
findCurrent = -1
return total
},
clearFind: () => { matches = []; current = -1 },
clearFind: () => { findMatches = []; findCurrent = -1 },
})
return () => {
// 卸载前回写最终文本,避免切换瞬间丢失最后一次输入
@@ -172,5 +191,12 @@ export default function SourceEditor({ tabId, initialText, initialCursorOffset,
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabId])
// UI/文档主题变化时只重配主题扩展,不重挂组件(原先靠 key 重挂,光标与撤销史全丢)
useEffect(() => {
const view = viewRef.current
if (!view) return
view.dispatch({ effects: themeCompartmentRef.current.reconfigure(dark ? [oneDark] : []) })
}, [dark])
return <div className="source-cm" ref={hostRef} />
}

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react'
import { DOC_THEMES } from '../themes'
/** 外观与设置(阶段 5):文档主题、当前文档临时主题、自定义 CSS、自动保存间隔。 */
@@ -14,9 +15,15 @@ export function AppearanceModal({ docTheme, override, customCss, autosave, autos
onAutosave: (patch: { autosave?: boolean; autosaveSeconds?: number }) => void
onClose: () => void
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div className="modal-backdrop">
<div className="modal" style={{ width: 560 }}>
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 560 }} onClick={(e) => e.stopPropagation()}>
<h3>外观与设置</h3>
<div className="setting-label">文档主题(作用于编辑区排版)</div>
<div className="theme-grid">
@@ -92,9 +99,15 @@ export function ExportModal({ title, onExportHtml, onCopyHtml, onPrint, onClose
onPrint: () => void
onClose: () => void
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div className="modal-backdrop">
<div className="modal">
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>导出《{title}》</h3>
<div className="modal-actions column left">
<button onClick={() => onExportHtml('relative')}>导出 HTML(图片保持相对路径)</button>

View File

@@ -94,7 +94,15 @@ export function setAlignColumn(editor: Editor, align: 'left' | 'center' | 'right
view.dispatch(state.tr.setSelection(TextSelection.create(state.doc, headerCellPos + 2)))
ctx.get(commandsCtx).call(setAlignCommand.key, align)
const now = view.state
view.dispatch(now.tr.setSelection(TextSelection.create(now.doc, Math.min(original.from, now.doc.content.size))))
// 审计修复:恢复完整选区(原先只恢复起点,多格/文本选区被塌陷成一个光标)
const size = now.doc.content.size
const from = Math.min(original.from, size)
const to = Math.min(original.to, size)
try {
view.dispatch(now.tr.setSelection(TextSelection.create(now.doc, from, Math.max(from, to))))
} catch {
view.dispatch(now.tr.setSelection(TextSelection.create(now.doc, from)))
}
})
}

View File

@@ -45,5 +45,7 @@ export function findBlockIndex(blockTexts: string[], anchor: CursorAnchor): numb
if (!anchor.blockText) return -1
const exact = blockTexts.findIndex((t) => t === anchor.blockText)
if (exact >= 0) return exact
return blockTexts.findIndex((t) => t.includes(anchor.blockText) || anchor.blockText.includes(t))
// 兜底包含匹配必须跳过空块:空字符串是任何字符串的子串,
// 文档前部只要有空段落就会把光标劫持过去(审计修复)。
return blockTexts.findIndex((t) => t !== '' && (t.includes(anchor.blockText) || anchor.blockText.includes(t)))
}

View File

@@ -27,6 +27,7 @@ export interface EditorCallbacks {
export interface EditorOptions {
/** 粘贴/拖入图片文件的落盘处理:返回应写入文档的图片地址(相对路径或 data URL)。缺省用会话级对象 URL。 */
onImageFile?: (file: File) => Promise<string>
onImageError?: (message: string) => void
}
export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string, callbacks: EditorCallbacks = {}, options: EditorOptions = {}) {
@@ -64,9 +65,14 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
const nodes: unknown[] = []
for (const file of Array.from(files)) {
if (!file.type.startsWith('image/')) continue
const src = options.onImageFile ? await options.onImageFile(file) : URL.createObjectURL(file)
const imageNode = schema.nodes.image?.createAndFill({ src, alt: file.name })
if (imageNode) nodes.push(imageNode)
try {
const src = options.onImageFile ? await options.onImageFile(file) : URL.createObjectURL(file)
const imageNode = schema.nodes.image?.createAndFill({ src, alt: file.name })
if (imageNode) nodes.push(imageNode)
} catch (e) {
// 落盘失败时跳过这张图并上报,而不是让整个粘贴/拖入无声失败或残留占位
options.onImageError?.(e instanceof Error ? e.message : String(e))
}
}
return nodes
}) as UploadOptions['uploader'],
@@ -90,6 +96,7 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
}
interface PmSelection {
from: number
$from: {
parentOffset: number
depth: number
@@ -104,13 +111,28 @@ interface PmSelection {
export function cursorFromSelection(selection: unknown): CursorInfo {
const sel = selection as PmSelection
const $from = sel.$from
// 审计修复:depth=0 时(全选 Ctrl+A、点中公式块/图片等顶层原子节点的 NodeSelection)
// $from.before(0) 会抛 RangeError。此时以选区起点为锚:行 = 起点之前的文本块数 + 1,列 = 1。
if ($from.depth === 0) {
let before = 0
let passed = false
$from.doc.descendants((node, pos) => {
if (passed) return false
if (pos >= sel.from) { passed = true; return false }
if (node.isTextblock) before += 1
return true
})
return { line: before + 1, col: 1 }
}
const parentPos = $from.before($from.depth)
let line = 0
let current = 1
let found = false
$from.doc.descendants((node, pos) => {
if (found) return false // 找到目标块后不再往后数(原先每次选区变化都数完整篇)
if (node.isTextblock) {
line += 1
if (pos === parentPos) current = line
if (pos === parentPos) { current = line; found = true; return false }
}
return true
})

View File

@@ -12,7 +12,7 @@ interface ListItemNode {
}
const listItemView: NodeViewConstructor = (node, view, getPos) => {
const item = node as unknown as ListItemNode
let item = node as unknown as ListItemNode
const dom = document.createElement('li')
dom.dataset.itemType = item.attrs.checked != null ? 'task' : 'list'
if (item.attrs.label) dom.dataset.label = item.attrs.label
@@ -54,6 +54,11 @@ const listItemView: NodeViewConstructor = (node, view, getPos) => {
const wasTask = item.attrs.checked != null
const isTask = next.attrs.checked != null
if (wasTask !== isTask) return false // 任务性变化时交回 PM 重建视图
// 审计修复:闭包 item 此前从不更新,wasTask 永远按创建时判断、label 永不刷新
item = next
if (next.attrs.label) dom.dataset.label = next.attrs.label
else delete dom.dataset.label
if (next.attrs.listType) dom.dataset.listType = next.attrs.listType
if (checkbox && next.attrs.checked != null) {
checkbox.checked = Boolean(next.attrs.checked)
dom.dataset.checked = String(next.attrs.checked)
@@ -104,6 +109,7 @@ const codeBlockView: NodeViewConstructor = (node, _view, _getPos) => {
if (next.type.name !== 'code_block') return false
if ((next.attrs.language === 'mermaid') !== isMermaid()) return false
current = next
code.className = current.attrs.language ? `language-${current.attrs.language}` : ''
return true
},
}
@@ -111,6 +117,8 @@ const codeBlockView: NodeViewConstructor = (node, _view, _getPos) => {
let mode: 'preview' | 'source' = 'preview'
let renderTimer: ReturnType<typeof setTimeout> | null = null
let renderSeq = 0
let destroyed = false
mermaidSeq += 1
const renderId = `new-markdown-mermaid-${mermaidSeq}`
@@ -144,7 +152,9 @@ const codeBlockView: NodeViewConstructor = (node, _view, _getPos) => {
}
const render = async () => {
if (mode !== 'preview') return
if (mode !== 'preview' || destroyed) return
// 代次守卫:快速连续编辑时先启动的旧渲染后完成,不得用旧 SVG 覆盖新预览
const mySeq = ++renderSeq
const source = current.textContent
try {
const mermaid = (await import('mermaid')).default
@@ -153,10 +163,12 @@ const codeBlockView: NodeViewConstructor = (node, _view, _getPos) => {
theme: document.documentElement.dataset.theme === 'dark' ? 'dark' : 'default',
securityLevel: 'strict',
})
const { svg } = await mermaid.render(renderId, source)
const { svg } = await mermaid.render(`${renderId}-${mySeq}`, source)
if (destroyed || mySeq !== renderSeq) return
preview.innerHTML = svg
preview.classList.remove('mermaid-error')
} catch (e) {
if (destroyed || mySeq !== renderSeq) return
preview.textContent = `图表渲染失败:${e instanceof Error ? e.message : String(e)}(可切换到源码修改)`
preview.classList.add('mermaid-error')
}
@@ -190,7 +202,7 @@ const codeBlockView: NodeViewConstructor = (node, _view, _getPos) => {
},
stopEvent: (event) => event.target === toggle,
ignoreMutation: (mutation) => preview.contains(mutation.target as Node) || head.contains(mutation.target as Node),
destroy: () => { if (renderTimer) clearTimeout(renderTimer) },
destroy: () => { destroyed = true; if (renderTimer) clearTimeout(renderTimer) },
}
}

View File

@@ -8,6 +8,25 @@ export function insertPlainText(editor: Editor, text: string): boolean {
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const { from, to } = view.state.selection
const lines = text.replace(/\r\n?/g, '\n').split('\n')
const paragraph = view.state.schema.nodes.paragraph
// 审计修复:多行文本原先被 insertText 塞进单个文本节点,渲染折叠成空格、
// 序列化却保留字面换行,往返不保真。选区在普通段落/标题里时按段落节点插入。
const parentType = view.state.selection.$from.parent.type.name
if (lines.length > 1 && paragraph && (parentType === 'paragraph' || parentType === 'heading')) {
const nodes = lines.map((line) => paragraph.create(null, line ? view.state.schema.text(line) : undefined))
view.dispatch(view.state.tr.replaceSelectionWith(nodes[0]).scrollIntoView())
// replaceSelectionWith 只放第一个段落,其余段落用 insert 续在后面
let pos = view.state.selection.to
const tr = view.state.tr
for (const node of nodes.slice(1)) {
tr.insert(pos, node)
pos += node.nodeSize
}
view.dispatch(tr.scrollIntoView())
view.focus()
return
}
view.dispatch(view.state.tr.insertText(text, from, to).scrollIntoView())
view.focus()
})

View File

@@ -147,22 +147,54 @@ interface MinimalView {
dispatch: (tr: unknown) => void
}
/** 代码块内缩进/反缩进。返回 true 表示已处理。导出供测试。 */
interface MinimalSelection {
$from: MinimalView['state']['selection']['$from']
$to?: { parentOffset: number }
}
/** 代码块内缩进/反缩进。返回 true 表示已处理。导出供测试。
* 审计修复:原先只处理选区起点一行;现对选区覆盖的每一行整体处理(单事务)。 */
export function applyCodeBlockIndent(view: MinimalView, outdent: boolean): boolean {
const { $from } = view.state.selection
const selection = view.state.selection as MinimalView['state']['selection'] & MinimalSelection
const { $from } = selection
if ($from.parent.type.name !== 'code_block') return false
const text = $from.parent.textContent
const offset = $from.parentOffset
const blockStart = $from.start($from.depth)
if (!outdent) {
view.dispatch(view.state.tr.insertText(' ', blockStart + offset))
return true
const fromOffset = $from.parentOffset
const toOffset = selection.$to?.parentOffset ?? fromOffset
// 计算每行在块文本中的起始偏移
const lineStarts: number[] = [0]
for (let i = 0; i < text.length; i++) {
if (text[i] === '\n') lineStarts.push(i + 1)
}
const lineStart = text.lastIndexOf('\n', Math.max(0, offset - 1)) + 1
const leading = text.slice(lineStart, lineStart + 2)
const remove = leading.startsWith(' ') ? 2 : leading.startsWith(' ') ? 1 : 0
if (remove === 0) return true // 已在行首无缩进:吞掉 Tab,避免焦点跳走
view.dispatch(view.state.tr.delete(blockStart + lineStart, blockStart + lineStart + remove))
const lineOf = (offset: number) => {
let line = 0
for (let i = 0; i < lineStarts.length; i++) {
if (lineStarts[i] <= offset) line = i
else break
}
return line
}
const firstLine = lineOf(fromOffset)
let lastLine = lineOf(toOffset)
// 选区恰好止于某行行首时不处理那一行(与主流编辑器一致)
if (toOffset > fromOffset && lineStarts[lastLine] === toOffset) lastLine -= 1
const tr = view.state.tr as unknown as {
insertText: (text: string, from: number) => unknown
delete: (from: number, to: number) => unknown
}
// 倒序处理各行,靠前行的偏移不受后面修改影响
for (let line = lastLine; line >= firstLine; line--) {
const lineStart = lineStarts[line]
if (!outdent) {
tr.insertText(' ', blockStart + lineStart)
} else {
const leading = text.slice(lineStart, lineStart + 2)
const remove = leading.startsWith(' ') ? 2 : leading.startsWith(' ') ? 1 : 0
if (remove > 0) tr.delete(blockStart + lineStart, blockStart + lineStart + remove)
}
}
view.dispatch(tr)
return true
}
@@ -178,22 +210,49 @@ export function isFocusModeEnabled() {
const focusKey = new PluginKey('new-markdown-focus')
interface FocusState {
set: DecorationSet
activeFrom: number
activeTo: number
}
const EMPTY_FOCUS: FocusState = { set: DecorationSet.empty, activeFrom: -1, activeTo: -1 }
export function createFocusDimPlugin() {
return $prose(() => new Plugin({
key: focusKey,
state: {
init: () => DecorationSet.empty,
apply: (tr, old, _oldState, newState) => {
if (!focusModeEnabled) return DecorationSet.empty
if (!tr.selectionSet && !tr.docChanged && !tr.getMeta(focusKey)) return old
init: (): FocusState => EMPTY_FOCUS,
apply: (tr, val, _oldState, newState): FocusState => {
if (!focusModeEnabled) return EMPTY_FOCUS
const { doc, selection } = newState
const anchor = selection.$from
// 找选区所在的顶层块范围
let activeFrom = -1
let activeTo = -1
if (anchor.depth >= 1) {
activeFrom = anchor.before(1)
activeTo = anchor.after(1)
} else if (selection.from !== selection.to) {
// 顶层原子节点被选中(NodeSelection):以选区范围为活动块,
// 原先这种情况 activeFrom 恒 -1、整篇(含选中块)一起变淡。
activeFrom = selection.from
activeTo = selection.to
}
const force = Boolean(tr.getMeta(focusKey))
let set = val.set
let prevFrom = val.activeFrom
let prevTo = val.activeTo
if (tr.docChanged) {
set = set.map(tr.mapping, doc)
if (prevFrom >= 0) {
prevFrom = tr.mapping.map(prevFrom)
prevTo = tr.mapping.map(prevTo, -1)
}
}
// 审计修复(性能):活动块未变时复用映射后的旧装饰集——原先每次击键
// 都为全文顶层块全量重建 DecorationSet。跨块移动/强制刷新时才重建。
if (!force && prevFrom === activeFrom && prevTo === activeTo) {
return prevFrom === val.activeFrom && set === val.set ? val : { set, activeFrom, activeTo }
}
const decorations: Decoration[] = []
doc.forEach((node, offset) => {
@@ -203,11 +262,11 @@ export function createFocusDimPlugin() {
decorations.push(Decoration.node(from, to, { class: 'focus-dim' }))
}
})
return DecorationSet.create(doc, decorations)
return { set: DecorationSet.create(doc, decorations), activeFrom, activeTo }
},
},
props: {
decorations: (state) => focusKey.getState(state) as DecorationSet,
decorations: (state) => (focusKey.getState(state) as FocusState).set,
},
}))
}
@@ -275,14 +334,14 @@ export function createTableTabPlugin() {
event.preventDefault()
const commands = ctx.get(commandsCtx) as unknown as { call: (k: unknown, p?: unknown) => boolean }
if (event.shiftKey) {
commands.call(goToPrevTableCellCommand.key)
return true
return commands.call(goToPrevTableCellCommand.key)
}
if (isLastTableCell(view.state as unknown as { selection: { $from: AncestorLike }; doc: PmDocLike })) {
commands.call(addRowAfterCommand.key)
// 加行失败时不吞键(原先无条件 return true,Tab 被吃掉且光标不动、用户无从得知)
const added = commands.call(addRowAfterCommand.key)
if (!added) return false
}
commands.call(goToNextTableCellCommand.key)
return true
return commands.call(goToNextTableCellCommand.key)
},
},
}))

View File

@@ -59,13 +59,26 @@ function isWholeWordAt(text: string, start: number, end: number): boolean {
return !WORD_CHAR.test(before) && !WORD_CHAR.test(after)
}
function lowerPreservingLength(text: string): string {
// 逐字小写化且保持长度:个别字符(如 İ)小写后变 2 个码元,
// 整串 toLowerCase 会让其后所有下标与 positions 错位,这里把这类字符保持原样。
let out = ''
for (const ch of text) {
const lower = ch.toLowerCase()
out += lower.length === ch.length ? lower : ch
}
return out
}
export function findInIndex(index: TextIndex, query: string, opts: FindOptions): MatchRange[] {
if (!query) return []
const ranges: { start: number; end: number }[] = []
if (opts.regex) {
let re: RegExp
try {
re = new RegExp(query, opts.caseSensitive ? 'g' : 'gi')
// 整词对正则同样生效:把模式包进 \b 边界(原先静默忽略)
const pattern = opts.wholeWord ? `\\b(?:${query})\\b` : query
re = new RegExp(pattern, opts.caseSensitive ? 'g' : 'gi')
} catch (e) {
throw new Error(`正则表达式无效:${e instanceof Error ? e.message : String(e)}`)
}
@@ -79,8 +92,8 @@ export function findInIndex(index: TextIndex, query: string, opts: FindOptions):
if (ranges.length >= 5000) break
}
} else {
const haystack = opts.caseSensitive ? index.text : index.text.toLowerCase()
const needle = opts.caseSensitive ? query : query.toLowerCase()
const haystack = opts.caseSensitive ? index.text : lowerPreservingLength(index.text)
const needle = opts.caseSensitive ? query : lowerPreservingLength(query)
let from = 0
while (from <= haystack.length - needle.length) {
const hit = haystack.indexOf(needle, from)
@@ -96,6 +109,13 @@ export function findInIndex(index: TextIndex, query: string, opts: FindOptions):
const fromPos = index.positions[r.start]
const toPos = index.positions[r.end - 1]
if (fromPos < 0 || toPos < 0) continue // 跨块命中,跳过
// 端点都在真实文本上、但中段横跨块间分隔符的命中(如正则 foo\s+bar 跨段)同样跳过:
// 只查首尾端点会放行这类命中,替换时将把两段合并。
let spansSeparator = false
for (let i = r.start; i < r.end; i++) {
if (index.positions[i] < 0) { spansSeparator = true; break }
}
if (spansSeparator) continue
out.push({ ...r, from: fromPos, to: toPos + 1 })
}
return out

View File

@@ -93,7 +93,7 @@ async function postProcess(tree: Root, opts: ExportOptions): Promise<void> {
let rawHtml: string | null = null
if (job.lang === 'mermaid' && mermaidApi) {
try {
const { svg } = await mermaidApi.render(`export-mermaid-${Math.abs(hashCode(job.code))}`, job.code)
const { svg } = await mermaidApi.render(`export-mermaid-${Math.abs(hashCode(job.code))}-${jobs.indexOf(job)}`, job.code)
rawHtml = `<div class="mermaid-diagram">${svg}</div>`
} catch {
rawHtml = null
@@ -188,7 +188,9 @@ export async function markdownToExportHtml(markdown: string, opts: ExportOptions
.use(remarkGfm)
.use(remarkMath)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeKatex)
// throwOnError:false —— 单个坏公式降级显示原文而不是让整篇导出失败。
// 插件处 as never 是为绕开 unified 对该插件 Options 的重载推断误判(运行时为标准两参调用)。
.use(rehypeKatex as never, { throwOnError: false })
const tree = (await processor.run(processor.parse(markdown))) as unknown as Root
await postProcess(tree, opts)
const body = await unified()

View File

@@ -89,9 +89,23 @@ export async function copyToAssets(source: string, docPath: string): Promise<str
return invoke<string>('copy_to_assets', { source, docPath })
}
/** 把字节(粘贴/拖入的图片)写入文档资源目录,返回相对路径。 */
export async function saveBytesToAssets(docPath: string, fileName: string, bytes: number[]): Promise<string> {
return invoke<string>('save_bytes_to_assets', { docPath, fileName, bytes })
/** 把字节(粘贴/拖入的图片)以 base64 写入文档资源目录,返回相对路径。
* 审计修复:原先传 number[],IPC 按 JSON 数字数组序列化,大图成本放大一个量级。 */
export async function saveBytesToAssets(docPath: string, fileName: string, base64: string): Promise<string> {
return invoke<string>('save_bytes_to_assets', { docPath, fileName, base64 })
}
/** File → base64(不带 data: 前缀)。 */
export function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const url = String(reader.result ?? '')
resolve(url.slice(url.indexOf(',') + 1))
}
reader.onerror = () => reject(reader.error ?? new Error('读取文件失败'))
reader.readAsDataURL(file)
})
}
/** Web 降级:选图片并读为 data URL。 */
@@ -165,8 +179,9 @@ export async function renamePath(from: string, to: string): Promise<void> {
return invoke<void>('rename_path', { from, to })
}
export async function deleteToTrash(path: string): Promise<void> {
return invoke<void>('delete_to_trash', { path })
/** 删除到回收站;返回实际去向 'trash' | 'permanent'(回收站不可用时永久删除,调用方应明示用户)。 */
export async function deleteToTrash(path: string): Promise<string> {
return invoke<string>('delete_to_trash', { path })
}
export interface RecentEntry {

View File

@@ -46,7 +46,7 @@ export type TabsAction =
| { type: 'open'; tab: DocTab }
| { type: 'activate'; id: string }
| { type: 'updateContent'; id: string; content: string }
| { type: 'saved'; id: string; path?: string; title?: string }
| { type: 'saved'; id: string; savedContent: string; path?: string; title?: string }
| { type: 'loadContent'; id: string; content: string; encoding?: string; lineEnding?: string; hadBom?: boolean }
| { type: 'setPath'; id: string; path: string; title: string }
| { type: 'close'; id: string }
@@ -66,17 +66,21 @@ export function tabsReducer(state: TabsState, action: TabsAction): TabsState {
}
case 'activate':
return state.tabs.some((t) => t.id === action.id) ? { ...state, activeId: action.id } : state
case 'updateContent':
case 'updateContent': {
// 内容未变时返回原 state:序列化回调偶尔会回放相同文本,避免整树无谓重渲染
const existing = state.tabs.find((t) => t.id === action.id)
if (existing && existing.content === action.content) return state
return {
...state,
tabs: state.tabs.map((t) => (t.id === action.id ? { ...t, content: action.content } : t)),
}
}
case 'saved':
return {
...state,
tabs: state.tabs.map((t) =>
t.id === action.id
? { ...t, savedContent: t.content, path: action.path ?? t.path, title: action.title ?? t.title }
? { ...t, savedContent: action.savedContent, path: action.path ?? t.path, title: action.title ?? t.title }
: t,
),
}

View File

@@ -89,7 +89,7 @@ button { font: inherit; color: inherit; }
.tree-item.active { background: var(--accent-weak); }
.tree-icon { color: var(--text-dim); width: 12px; flex: none; }
.editor-scroll { flex: 1; overflow-y: auto; background: var(--bg-editor); min-width: 0; }
.editor-scroll { flex: 1; overflow-y: auto; background: var(--bg-editor); min-width: 0; position: relative; }
.editor-host { max-width: 880px; margin: 0 auto; padding: 34px 48px 140px; }
.source-cm { height: 100%; max-width: 980px; margin: 0 auto; }
.source-cm .cm-editor { height: 100%; outline: none; }
@@ -148,7 +148,6 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
.mermaid-preview.mermaid-error { color: #b3261e; font-size: 13px; text-align: left; white-space: pre-wrap; }
.mermaid-block pre { margin: 0 !important; border-radius: 0 !important; }
.app-notice { position: fixed; right: 14px; bottom: 40px; z-index: 60; background: var(--bg-editor); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 8px; padding: 8px 12px; font-size: 13px; box-shadow: 0 8px 22px rgba(0,0,0,0.14); max-width: 420px; }
.modal-note { color: var(--text-dim); font-size: 13px; }
.recovery-list { list-style: none; margin: 10px 0; padding: 0; display: flex; flex-direction: column; gap: 8px; max-height: 300px; overflow: auto; }
.recovery-list li { display: flex; align-items: center; justify-content: space-between; gap: 10px; border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; }
.recovery-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
@@ -178,6 +177,8 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
--doc-font: -apple-system, "Segoe UI", "Microsoft YaHei UI", sans-serif;
--doc-lh: 1.62;
--doc-link: #0969da;
--doc-paper: #ffffff;
--doc-ink: #1f2328;
--doc-code-bg: #f6f8fa;
--doc-h-border: #d8dee4;
}
@@ -185,6 +186,8 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
--doc-font: ui-sans-serif, "Segoe UI", "Microsoft YaHei UI", sans-serif;
--doc-lh: 1.66;
--doc-paper: #fbfbfa;
--doc-ink: #37352f;
--doc-quote-color: #6f6e69;
--doc-quote-border: #e3e2de;
--doc-code-bg: #f7f6f3;
--doc-h-border: transparent;
@@ -250,7 +253,7 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
/* ——— 阶段 5:打印(只打文档区,隐藏全部框架) ——— */
@media print {
.titlebar, .menubar, .tabbar, .sidebar, .statusbar, .modal-backdrop, .app-notice, .context-menu,
.table-toolbar, .tree-context-menu { display: none !important; }
.table-toolbar, .tree-context-menu, .findbar, .mermaid-head { display: none !important; }
.app, .main, .editor-scroll { display: block !important; height: auto !important; overflow: visible !important; }
.editor-scroll { background: #fff !important; }
.milkdown .ProseMirror { font-size: 11.5pt; }
@@ -277,7 +280,7 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
.ws-search-hit:hover { background: var(--accent-weak); }
.ws-search-line { color: var(--text-dim); flex: none; min-width: 26px; text-align: right; }
.ws-search-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.findbar { position: absolute; top: 8px; right: 14px; z-index: 40; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 8px 24px rgba(15, 23, 42, 0.14); padding: 6px 8px; display: flex; flex-direction: column; gap: 6px; min-width: 380px; }
.findbar { position: absolute; top: 8px; right: 14px; z-index: 40; background: var(--bg-panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 8px 24px rgba(15, 23, 42, 0.14); padding: 6px 8px; display: flex; flex-direction: column; gap: 6px; min-width: 340px; max-width: calc(100% - 24px); }
.findbar-row { display: flex; align-items: center; gap: 5px; }
.find-input { flex: 1; min-width: 130px; height: 28px; }
.find-toggle { border: 1px solid transparent; background: transparent; border-radius: 6px; padding: 3px 7px; font-size: 12px; cursor: pointer; color: var(--text-dim); }
@@ -285,7 +288,7 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
.find-icon { border: 0; background: transparent; border-radius: 6px; padding: 3px 7px; cursor: pointer; color: var(--text-dim); }
.find-icon:hover { background: var(--accent-weak); }
.find-count { font-size: 12px; color: var(--text-dim); min-width: 74px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.find-action { border: 1px solid var(--border); background: var(--panel); border-radius: 6px; padding: 4px 10px; font-size: 12.5px; cursor: pointer; }
.find-action { border: 1px solid var(--border); background: var(--bg-panel); border-radius: 6px; padding: 4px 10px; font-size: 12.5px; cursor: pointer; }
.find-action:hover { background: var(--accent-weak); }
.find-spacer { width: 26px; flex: none; }
.focus-dim { opacity: 0.32; transition: opacity 0.25s ease; }
@@ -293,9 +296,30 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
.shortcut-row { display: flex; align-items: center; gap: 7px; padding: 4px 2px; border-bottom: 1px solid var(--border); font-size: 13px; }
.shortcut-row .shortcut-name { flex: 1; }
.shortcut-row kbd { min-width: 118px; text-align: center; }
.shortcut-row button { border: 1px solid var(--border); background: var(--panel); border-radius: 6px; padding: 2px 8px; font-size: 12px; cursor: pointer; }
.shortcut-row button { border: 1px solid var(--border); background: var(--bg-panel); border-radius: 6px; padding: 2px 8px; font-size: 12px; cursor: pointer; }
.shortcut-row button:disabled { opacity: 0.4; cursor: default; }
.shortcut-subtitle { margin: 12px 0 4px; font-size: 13px; }
.conflict-note { display: flex; align-items: center; gap: 8px; }
.findbar { position: absolute; }
.editor-scroll { position: relative; }
/* ——— 审计修复:统一输入框基础样式(.input 此前只有布局规则没有外观,
查找框/搜索框/弹窗输入框全是原生外观,深色下白底黑字) ——— */
.input {
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
border-radius: 6px;
padding: 5px 9px;
font-size: 13px;
font-family: inherit;
outline: none;
}
.input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-weak); }
.input::placeholder { color: var(--text-dim); }
/* ——— 审计修复:全应用滚动条与选区(此前全是 WebView 默认样式,深色下割裂) ——— */
::selection { background: var(--accent-weak); }
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; border: 2px solid transparent; background-clip: content-box; }
*::-webkit-scrollbar-thumb:hover { background: var(--text-dim); border: 2px solid transparent; background-clip: content-box; }
*::-webkit-scrollbar-corner { background: transparent; }

View File

@@ -4,18 +4,19 @@ export interface DocTheme {
id: string
name: string
description: string
/** 该主题自带深色纸面(编辑区强制深色,不随 UI 明暗) */
darkPaper?: boolean
/** 主题强制的纸面明暗:源码模式据此选 CodeMirror 明暗,避免深纸面配深字。
* 未设时纸面跟随 UI 明暗。原 darkPaper 字段无人消费,已由 paperTone 取代。 */
paperTone?: 'light' | 'dark'
}
export const DOC_THEMES: DocTheme[] = [
{ id: 'default', name: '默认', description: '石板中性、适度行距,当前默认排版' },
{ id: 'github', name: 'GitHub', description: 'GitHub 文档风:标题下边线、浅灰代码底、蓝色链接' },
{ id: 'notion', name: 'Notion', description: 'Notion 风:紧凑标题、暖灰纸面、低对比分隔' },
{ id: 'github', name: 'GitHub', description: 'GitHub 文档风:标题下边线、浅灰代码底、蓝色链接', paperTone: 'light' },
{ id: 'notion', name: 'Notion', description: 'Notion 风:紧凑标题、暖灰纸面、低对比分隔', paperTone: 'light' },
{ id: 'simple-blue', name: '简约蓝', description: '蓝色标题与链接,清爽的轻量排版' },
{ id: 'dark-eye', name: '深色护眼', description: '墨绿深灰纸面,低亮度长时阅读', darkPaper: true },
{ id: 'dark-eye', name: '深色护眼', description: '墨绿深灰纸面,低亮度长时阅读', paperTone: 'dark' },
{ id: 'focus', name: '专注写作', description: '衬线正文、加宽行距、窄栏,适合长文写作' },
{ id: 'paper', name: '经典纸张', description: '米色纸面、衬线字体、棕褐文字,纸书感' },
{ id: 'paper', name: '经典纸张', description: '米色纸面、衬线字体、棕褐文字,纸书感', paperTone: 'light' },
]
export function docThemeById(id: string): DocTheme {

View File

@@ -47,11 +47,17 @@ export function comboFromEvent(e: { ctrlKey: boolean; metaKey: boolean; altKey:
if (['Control', 'Meta', 'Alt', 'Shift'].includes(key)) return null
let main = key.length === 1 ? key.toLowerCase() : key.toLowerCase()
if (main === ' ') main = 'space'
if (main === '+') main = '='
let shift = e.shiftKey
if (main === '+') {
// '+' 本就是 Shift+'=' 的产物:归一为不带 shift 的 '=',
// 否则按 Ctrl+Shift+= 录入的组合永远匹配不上默认的 ctrl+=(缩放)。
main = '='
shift = false
}
const parts: string[] = []
if (e.ctrlKey || e.metaKey) parts.push('ctrl')
if (e.altKey) parts.push('alt')
if (e.shiftKey) parts.push('shift')
if (shift) parts.push('shift')
parts.push(main)
return parts.join('+')
}