feat(editor): 标签编辑器保活,切换立即呈现(docs/18)

最近用过的 6 个标签保持挂载、隐藏而非销毁,切回零解析零序列化、各窗格
保留滚动位置;超限窗格卸载走最终回写。引用改为按标签注册表 + 活动变化
effect 统一指向;外部重载 epoch 按标签粒度、抑制闸门对所有标签生效。
EditorPane 新增就绪状态:首次打开大文件显示「正在打开文档…」加载态。
打印守卫:只出活动窗格。vitest 87 通过 + 2 跳过(新增 pane-loading 1 例)。
This commit is contained in:
Muse
2026-11-14 13:06:17 +08:00
parent f378c60363
commit 4a527da655
5 changed files with 206 additions and 56 deletions

45
docs/18-tab-keepalive.md Normal file
View File

@@ -0,0 +1,45 @@
# 标签切换保活与加载状态(2026-11-14)
## 用户反馈
切换大文件时要等很久才能切回去,希望立即切回;至少要有加载进度/状态。
## 根因
原架构下每次切标签都销毁旧编辑器(全量序列化回写)并为新标签从 Markdown
全量解析重建编辑器。阶段 6 实测(jsdom 悲观口径):626K 字符建模 29.8s、
序列化 2.6s——大文档来回切换就是把这个成本反复付两遍。
## 改造:编辑器保活
- App 改为按标签渲染多个编辑器窗格,只有活动窗格可见,其余 `display:none`
保持挂载:切回时零解析、零序列化,立即显示;每个窗格自带滚动容器,
连滚动位置都各自保留。
- 保活上限:最近用过的 6 个标签(LRU)。超出的窗格卸载,卸载前走既有的
最终回写(syncEditorContent),内容不丢,之后再切到时按状态内容重建。
- 编辑器引用改为注册表(paneApisRef: tabId → api);onReady 只登记,
共享引用(查找/大纲跳转/源码锚点)由「活动标签变化」effect 统一指向
活动窗格,后台窗格就绪/卸载不会再冲掉活动窗格的引用。
- 外部重载(reloadTabFromDisk)的 epoch 重挂改为按标签粒度(paneEpochs),
且闸门(抑制卸载回写)对所有标签生效——保活后非活动标签也可能正挂载,
旧实现只处理活动标签会漏。退出源码模式的重建同理改为按标签 bump。
- 状态栏光标只接受活动窗格的回调,后台窗格不再干扰。
## 加载状态
- EditorPane 新增就绪状态:编辑器 create() 完成前在窗格内显示
「正在打开文档…」+ 旋转指示(.pane-loading),就绪即撤下。
首次打开大文件时标签立刻出现、窗格明确处于加载中,不再是无反馈的卡住。
解析是单线程同步计算,做不了真实百分比进度,用明确的加载态代替。
## 打印
@media print 增加守卫:.tab-pane 打印时隐藏、仅 .active 出纸,
避免保活窗格被通用 display:block 规则一起打出来。
## 验证
- tsc 0 错误;vitest 87 通过 + 2 跳过(新增 tests/pane-loading.test.tsx:
创建未完成时显示加载提示、完成后撤下并回传就绪 API);构建通过。
- 待真机验:两个大文档来回切换应立即呈现;首次打开大文档有加载态;
外部修改重载、源码模式进出、打印只出当前文档。

View File

@@ -80,7 +80,13 @@ export default function App() {
const [recent, setRecent] = useState<string[]>(loadRecent) const [recent, setRecent] = useState<string[]>(loadRecent)
const [cursor, setCursor] = useState<CursorInfo>({ line: 1, col: 1 }) const [cursor, setCursor] = useState<CursorInfo>({ line: 1, col: 1 })
const [sourceMode, setSourceMode] = useState(false) const [sourceMode, setSourceMode] = useState(false)
const [editorEpoch, setEditorEpoch] = useState(0) const [paneEpochs, setPaneEpochs] = useState<Record<string, number>>({})
const bumpPaneEpoch = useCallback((id: string) => {
setPaneEpochs((prev) => ({ ...prev, [id]: (prev[id] ?? 0) + 1 }))
}, [])
// 标签编辑器保活:最近用过的标签保持挂载(隐藏而非销毁),切回免去整篇重解析
const [aliveIds, setAliveIds] = useState<string[]>([])
const paneApisRef = useRef(new Map<string, EditorPaneApi>())
const [zoom, setZoom] = useState(1) const [zoom, setZoom] = useState(1)
const [ui, setUi] = useState(loadUi) const [ui, setUi] = useState(loadUi)
const [modal, setModal] = useState<'about' | 'shortcuts' | 'link' | null>(null) const [modal, setModal] = useState<'about' | 'shortcuts' | 'link' | null>(null)
@@ -366,22 +372,20 @@ export default function App() {
if (expectedContent !== undefined && current.content !== expectedContent) return if (expectedContent !== undefined && current.content !== expectedContent) return
if (isDirty(current)) return if (isDirty(current)) return
} }
const isActive = stateRef.current.activeId === target.id // 审计修复(卸载回写覆盖):标签重挂时,旧 EditorPane 的卸载回写会把旧正文
// 审计修复(卸载回写覆盖):活动标签重挂时,旧 EditorPane 的卸载回写会把旧正文
// 写回状态、盖掉刚载入的内容——重载前先给该标签的这次最终回写上闸门。 // 写回状态、盖掉刚载入的内容——重载前先给该标签的这次最终回写上闸门。
if (isActive) { // 保活后非活动标签也可能正挂载着,闸门与重挂对所有标签一律生效。
suppressFinalWriteRef.current.add(target.id) suppressFinalWriteRef.current.add(target.id)
window.setTimeout(() => suppressFinalWriteRef.current.delete(target.id), 1500) window.setTimeout(() => suppressFinalWriteRef.current.delete(target.id), 1500)
}
dispatch({ dispatch({
type: 'loadContent', id: target.id, content: file.content, type: 'loadContent', id: target.id, content: file.content,
encoding: file.encoding, lineEnding: file.lineEnding, hadBom: file.hadBom, encoding: file.encoding, lineEnding: file.lineEnding, hadBom: file.hadBom,
}) })
const stat = await statFile(target.path) const stat = await statFile(target.path)
if (stat.exists) baselinesRef.current.set(target.id, { mtimeMs: stat.mtimeMs, size: stat.size }) if (stat.exists) baselinesRef.current.set(target.id, { mtimeMs: stat.mtimeMs, size: stat.size })
if (isActive) setEditorEpoch((n) => n + 1) bumpPaneEpoch(target.id)
} catch (e) { setError(String(e)) } } catch (e) { setError(String(e)) }
}, []) }, [bumpPaneEpoch])
const restoreRecovery = useCallback(async (item: RecoveryEntry) => { const restoreRecovery = useCallback(async (item: RecoveryEntry) => {
const finish = () => { const finish = () => {
@@ -698,7 +702,7 @@ export default function App() {
// 退出源码:记住源码光标锚点,编辑器重建后回定位 // 退出源码:记住源码光标锚点,编辑器重建后回定位
pendingAnchorRef.current = sourceApiRef.current?.getAnchor() ?? null pendingAnchorRef.current = sourceApiRef.current?.getAnchor() ?? null
setSourceMode(false) setSourceMode(false)
setEditorEpoch((n) => n + 1) // 以源码文本重建编辑器模型 bumpPaneEpoch(tab.id) // 以源码文本重建编辑器模型
} }
}, [tab, sourceMode]) }, [tab, sourceMode])
@@ -767,6 +771,28 @@ export default function App() {
const stateRef = useRef(state) const stateRef = useRef(state)
stateRef.current = state stateRef.current = state
// 活动标签变化时登记保活顺序(最近用过的排前面,最多 6 个;超出的卸载走最终回写同步)
const activeIdForAlive = tab?.id
useEffect(() => {
if (!activeIdForAlive) return
setAliveIds((prev) => (prev[0] === activeIdForAlive
? prev
: [activeIdForAlive, ...prev.filter((id) => id !== activeIdForAlive)].slice(0, 6)))
}, [activeIdForAlive])
// 共享编辑器引用(查找/大纲跳转/源码模式锚点)始终指向活动标签的编辑器
useEffect(() => {
const api = activeIdForAlive ? paneApisRef.current.get(activeIdForAlive) : undefined
paneFindRef.current = api ?? null
editorRef.current = api?.editor ?? null
jumpRef.current = api?.jumpToHeading ?? (() => undefined)
anchorApiRef.current = api ? { getAnchor: api.getAnchor, setCursorByAnchor: api.setCursorByAnchor } : null
if (api && pendingAnchorRef.current) {
api.setCursorByAnchor(pendingAnchorRef.current)
pendingAnchorRef.current = null
}
}, [activeIdForAlive])
/** 粘贴/拖入图片的落盘:桌面模式写入文档 .assets 并回相对路径;未命名文档或 Web 模式退为 data URL。 */ /** 粘贴/拖入图片的落盘:桌面模式写入文档 .assets 并回相对路径;未命名文档或 Web 模式退为 data URL。 */
const handleImageFile = useCallback(async (file: File): Promise<string> => { const handleImageFile = useCallback(async (file: File): Promise<string> => {
const current = activeTab(stateRef.current) const current = activeTab(stateRef.current)
@@ -1159,8 +1185,8 @@ export default function App() {
/> />
</div> </div>
)} )}
<section className="editor-scroll" style={{ ['--doc-zoom' as string]: zoom }}> <div className="editor-area" style={{ ['--doc-zoom' as string]: zoom }}>
{!tab ? ( {!tab && (
<div className="empty-state"> <div className="empty-state">
<h2>没有打开的文档</h2> <h2>没有打开的文档</h2>
<p>用 Ctrl+N 新建,或 Ctrl+O 打开一个 Markdown 文件。</p> <p>用 Ctrl+N 新建,或 Ctrl+O 打开一个 Markdown 文件。</p>
@@ -1169,49 +1195,62 @@ export default function App() {
<button onClick={() => void openFile()}>打开文件…</button> <button onClick={() => void openFile()}>打开文件…</button>
</div> </div>
</div> </div>
) : sourceMode ? (
<Suspense fallback={<div className="empty-state">正在加载源码编辑器…</div>}>
<SourceEditor
key={`source:${tab.id}`}
tabId={tab.id}
initialText={tab.content}
initialCursorOffset={sourceCursorOffset}
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 }}
/>
</Suspense>
) : (
<EditorPane
key={`${tab.id}:${editorEpoch}`}
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: 'syncEditorContent', id, content: md })
}}
onSyncInitial={(id, md) => dispatch({ type: 'syncEditorContent', id, content: md })}
onCursor={setCursor}
focusMode={ui.focusMode}
typewriter={ui.typewriter}
onImageFile={handleImageFile}
onImageError={(message) => setError(`图片插入失败:${message}`)}
resolveImageDisplay={tauriMode && tab.path ? (src) => resolveDisplayImage(tab.path as string, src) : undefined}
onReady={(api) => {
paneFindRef.current = api.editor ? api : null
editorRef.current = api.editor
jumpRef.current = api.jumpToHeading
anchorApiRef.current = api.editor ? { getAnchor: api.getAnchor, setCursorByAnchor: api.setCursorByAnchor } : null
if (api.editor && pendingAnchorRef.current) {
api.setCursorByAnchor(pendingAnchorRef.current)
pendingAnchorRef.current = null
}
}}
/>
)} )}
{tab && sourceMode && (
<section className="editor-scroll active">
<Suspense fallback={<div className="empty-state">正在加载源码编辑器…</div>}>
<SourceEditor
key={`source:${tab.id}`}
tabId={tab.id}
initialText={tab.content}
initialCursorOffset={sourceCursorOffset}
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 }}
/>
</Suspense>
</section>
)}
{state.tabs.filter((t) => aliveIds.includes(t.id)).map((t) => (
<section
key={t.id}
className={`editor-scroll tab-pane${t.id === tab?.id && !sourceMode ? ' active' : ''}`}
style={t.id === tab?.id && !sourceMode ? undefined : { display: 'none' }}
>
<EditorPane
key={`${t.id}:${paneEpochs[t.id] ?? 0}`}
tabId={t.id}
initialMarkdown={t.content}
onMarkdown={(id, md) => dispatch({ type: 'updateContent', id, content: md })}
onFinalMarkdown={(id, md) => {
if (suppressFinalWriteRef.current.delete(id)) return // 外部重载的卸载回写,丢弃
dispatch({ type: 'syncEditorContent', id, content: md })
}}
onSyncInitial={(id, md) => dispatch({ type: 'syncEditorContent', id, content: md })}
onCursor={(c) => { if (stateRef.current.activeId === t.id) setCursor(c) }}
focusMode={ui.focusMode}
typewriter={ui.typewriter}
onImageFile={handleImageFile}
onImageError={(message) => setError(`图片插入失败:${message}`)}
resolveImageDisplay={tauriMode && t.path ? (src) => resolveDisplayImage(t.path as string, src) : undefined}
onReady={(api) => {
if (api.editor) paneApisRef.current.set(t.id, api)
else paneApisRef.current.delete(t.id)
if (stateRef.current.activeId !== t.id) return
paneFindRef.current = api.editor ? api : null
editorRef.current = api.editor
jumpRef.current = api.jumpToHeading
anchorApiRef.current = api.editor ? { getAnchor: api.getAnchor, setCursorByAnchor: api.setCursorByAnchor } : null
if (api.editor && pendingAnchorRef.current) {
api.setCursorByAnchor(pendingAnchorRef.current)
pendingAnchorRef.current = null
}
}}
/>
</section>
))}
{findUi.open && tab && ( {findUi.open && tab && (
<FindBar <FindBar
query={findUi.query} query={findUi.query}
@@ -1244,7 +1283,7 @@ export default function App() {
onClose={closeFind} onClose={closeFind}
/> />
)} )}
</section> </div>
</div> </div>
<StatusBar <StatusBar
words={statusCounts.words} words={statusCounts.words}

View File

@@ -59,6 +59,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
modesRef.current = { focusMode, typewriter } modesRef.current = { focusMode, typewriter }
const editorObjRef = useRef<Editor | null>(null) const editorObjRef = useRef<Editor | null>(null)
const [tableBar, setTableBar] = useState<{ top: number; left: number } | null>(null) const [tableBar, setTableBar] = useState<{ top: number; left: number } | null>(null)
const [ready, setReady] = useState(false)
const tableElRef = useRef<HTMLElement | null>(null) const tableElRef = useRef<HTMLElement | null>(null)
const updateTableBar = useCallback(() => { const updateTableBar = useCallback(() => {
@@ -223,6 +224,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
} }
editor.create() editor.create()
.then(() => { .then(() => {
setReady(true)
setFocusModeEnabled(modesRef.current.focusMode) setFocusModeEnabled(modesRef.current.focusMode)
try { try {
editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never)) editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never))
@@ -263,6 +265,12 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
<TableToolbar editor={editorObjRef.current} top={tableBar.top} left={tableBar.left} /> <TableToolbar editor={editorObjRef.current} top={tableBar.top} left={tableBar.left} />
)} )}
<div className="editor-host" ref={hostRef} /> <div className="editor-host" ref={hostRef} />
{!ready && (
<div className="pane-loading">
<div className="pane-spinner" />
<span>正在打开文档…</span>
</div>
)}
</div> </div>
) )
} }

View File

@@ -95,6 +95,11 @@ button { font: inherit; color: inherit; }
.tree-icon { color: var(--text-dim); width: 12px; flex: none; } .tree-icon { color: var(--text-dim); width: 12px; flex: none; }
.editor-scroll { flex: 1; overflow-y: auto; background: var(--bg-editor); min-width: 0; position: relative; } .editor-scroll { flex: 1; overflow-y: auto; background: var(--bg-editor); min-width: 0; position: relative; }
.editor-area { flex: 1; display: flex; min-width: 0; position: relative; }
/* 标签保活后每个窗格自带滚动容器,各自保留滚动位置;窗格加载状态覆盖层 */
.pane-loading { position: absolute; inset: 0; display: flex; flex-direction: column; gap: 12px; align-items: center; justify-content: center; color: var(--text-dim); font-size: 13px; background: var(--doc-paper); z-index: 5; }
.pane-spinner { width: 26px; height: 26px; border-radius: 50%; border: 3px solid var(--border); border-top-color: var(--accent); animation: pane-spin 0.8s linear infinite; }
@keyframes pane-spin { to { transform: rotate(360deg); } }
.editor-host { max-width: 880px; margin: 0 auto; padding: 34px 48px 140px; } .editor-host { max-width: 880px; margin: 0 auto; padding: 34px 48px 140px; }
.source-cm { height: 100%; max-width: 980px; margin: 0 auto; color: var(--doc-ink); caret-color: var(--doc-ink); } .source-cm { height: 100%; max-width: 980px; margin: 0 auto; color: var(--doc-ink); caret-color: var(--doc-ink); }
.source-cm .cm-editor { height: 100%; outline: none; } .source-cm .cm-editor { height: 100%; outline: none; }
@@ -140,7 +145,7 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
.milkdown .ProseMirror li.task-item { list-style: none; display: flex; align-items: flex-start; gap: 2px; margin-left: -1.1em; } .milkdown .ProseMirror li.task-item { list-style: none; display: flex; align-items: flex-start; gap: 2px; margin-left: -1.1em; }
.milkdown .ProseMirror .task-checkbox { margin-top: 0.42em; width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; flex: none; } .milkdown .ProseMirror .task-checkbox { margin-top: 0.42em; width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; flex: none; }
.milkdown .ProseMirror li[data-checked='true'] .task-content { color: var(--text-dim); } .milkdown .ProseMirror li[data-checked='true'] .task-content { color: var(--text-dim); }
.editor-pane-wrap { position: relative; } .editor-pane-wrap { position: relative; min-height: 100%; }
.table-toolbar { position: absolute; z-index: 30; display: flex; gap: 2px; align-items: center; background: var(--bg-editor); border: 1px solid var(--border); border-radius: 8px; padding: 3px; box-shadow: 0 6px 18px rgba(0,0,0,0.12); } .table-toolbar { position: absolute; z-index: 30; display: flex; gap: 2px; align-items: center; background: var(--bg-editor); border: 1px solid var(--border); border-radius: 8px; padding: 3px; box-shadow: 0 6px 18px rgba(0,0,0,0.12); }
.table-toolbar button { border: 0; background: transparent; padding: 4px 8px; border-radius: 6px; cursor: pointer; font-size: 12.5px; white-space: nowrap; } .table-toolbar button { border: 0; background: transparent; padding: 4px 8px; border-radius: 6px; cursor: pointer; font-size: 12.5px; white-space: nowrap; }
.table-toolbar button:hover { background: var(--accent-weak); } .table-toolbar button:hover { background: var(--accent-weak); }
@@ -266,6 +271,9 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
.table-toolbar, .tree-context-menu, .findbar, .mermaid-head { 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; } .app, .main, .editor-scroll { display: block !important; height: auto !important; overflow: visible !important; }
.editor-scroll { background: #fff !important; } .editor-scroll { background: #fff !important; }
/* 打印只出活动窗格:保活的隐藏窗格不能被上面的 display:block 通杀带出来 */
.editor-scroll.tab-pane { display: none !important; }
.editor-scroll.tab-pane.active { display: block !important; }
.milkdown .ProseMirror { font-size: 11.5pt; } .milkdown .ProseMirror { font-size: 11.5pt; }
@page { margin: 18mm 16mm; } @page { margin: 18mm 16mm; }
} }

View File

@@ -0,0 +1,50 @@
// 编辑器窗格加载状态:编辑器创建完成前显示「正在打开文档…」,就绪后撤下并回传 API。
import { describe, it, expect, vi } from 'vitest'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
let resolveCreate: (() => void) | null = null
const fakeEditor = {
create: () => new Promise<void>((resolve) => { resolveCreate = resolve }),
destroy: () => Promise.resolve(),
action: (fn: (ctx: unknown) => unknown) => fn({ get: () => { throw new Error('no view in test') } }),
}
vi.mock('../src/editor/editor', () => ({
createMarkdownEditor: () => fakeEditor,
getMarkdown: () => '',
}))
import EditorPane from '../src/components/EditorPane'
describe('EditorPane 加载状态', () => {
it('创建未完成时显示加载提示,完成后撤下并回传就绪 API', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const readyApis: unknown[] = []
await act(async () => {
root.render(
<EditorPane
tabId="t1"
initialMarkdown="# 大文档"
onMarkdown={() => undefined}
onCursor={() => undefined}
onReady={(api) => readyApis.push(api)}
focusMode={false}
typewriter={false}
/>,
)
})
expect(container.textContent).toContain('正在打开文档')
expect(readyApis).toHaveLength(0)
await act(async () => { resolveCreate?.() })
expect(container.textContent).not.toContain('正在打开文档')
expect(readyApis).toHaveLength(1)
expect((readyApis[0] as { editor: unknown }).editor).toBe(fakeEditor)
await act(async () => { root.unmount() })
container.remove()
})
})