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:
227
src/App.tsx
227
src/App.tsx
@@ -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'}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}」占用。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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) },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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('+')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user