From 4a527da655edfc19f247c18535d7abb68547b36b Mon Sep 17 00:00:00 2001 From: Muse Date: Sat, 14 Nov 2026 13:06:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E6=A0=87=E7=AD=BE=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E4=BF=9D=E6=B4=BB=EF=BC=8C=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E7=AB=8B=E5=8D=B3=E5=91=88=E7=8E=B0=EF=BC=88docs/18=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 最近用过的 6 个标签保持挂载、隐藏而非销毁,切回零解析零序列化、各窗格 保留滚动位置;超限窗格卸载走最终回写。引用改为按标签注册表 + 活动变化 effect 统一指向;外部重载 epoch 按标签粒度、抑制闸门对所有标签生效。 EditorPane 新增就绪状态:首次打开大文件显示「正在打开文档…」加载态。 打印守卫:只出活动窗格。vitest 87 通过 + 2 跳过(新增 pane-loading 1 例)。 --- docs/18-tab-keepalive.md | 45 ++++++++++ src/App.tsx | 149 +++++++++++++++++++++------------- src/components/EditorPane.tsx | 8 ++ src/styles.css | 10 ++- tests/pane-loading.test.tsx | 50 ++++++++++++ 5 files changed, 206 insertions(+), 56 deletions(-) create mode 100644 docs/18-tab-keepalive.md create mode 100644 tests/pane-loading.test.tsx diff --git a/docs/18-tab-keepalive.md b/docs/18-tab-keepalive.md new file mode 100644 index 0000000..bf2c931 --- /dev/null +++ b/docs/18-tab-keepalive.md @@ -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);构建通过。 +- 待真机验:两个大文档来回切换应立即呈现;首次打开大文档有加载态; + 外部修改重载、源码模式进出、打印只出当前文档。 diff --git a/src/App.tsx b/src/App.tsx index ced895d..496eedf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -80,7 +80,13 @@ export default function App() { const [recent, setRecent] = useState(loadRecent) const [cursor, setCursor] = useState({ line: 1, col: 1 }) const [sourceMode, setSourceMode] = useState(false) - const [editorEpoch, setEditorEpoch] = useState(0) + const [paneEpochs, setPaneEpochs] = useState>({}) + const bumpPaneEpoch = useCallback((id: string) => { + setPaneEpochs((prev) => ({ ...prev, [id]: (prev[id] ?? 0) + 1 })) + }, []) + // 标签编辑器保活:最近用过的标签保持挂载(隐藏而非销毁),切回免去整篇重解析 + const [aliveIds, setAliveIds] = useState([]) + const paneApisRef = useRef(new Map()) const [zoom, setZoom] = useState(1) const [ui, setUi] = useState(loadUi) 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 (isDirty(current)) return } - const isActive = stateRef.current.activeId === target.id - // 审计修复(卸载回写覆盖):活动标签重挂时,旧 EditorPane 的卸载回写会把旧正文 + // 审计修复(卸载回写覆盖):标签重挂时,旧 EditorPane 的卸载回写会把旧正文 // 写回状态、盖掉刚载入的内容——重载前先给该标签的这次最终回写上闸门。 - if (isActive) { - suppressFinalWriteRef.current.add(target.id) - window.setTimeout(() => suppressFinalWriteRef.current.delete(target.id), 1500) - } + // 保活后非活动标签也可能正挂载着,闸门与重挂对所有标签一律生效。 + 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 (isActive) setEditorEpoch((n) => n + 1) + bumpPaneEpoch(target.id) } catch (e) { setError(String(e)) } - }, []) + }, [bumpPaneEpoch]) const restoreRecovery = useCallback(async (item: RecoveryEntry) => { const finish = () => { @@ -698,7 +702,7 @@ export default function App() { // 退出源码:记住源码光标锚点,编辑器重建后回定位 pendingAnchorRef.current = sourceApiRef.current?.getAnchor() ?? null setSourceMode(false) - setEditorEpoch((n) => n + 1) // 以源码文本重建编辑器模型 + bumpPaneEpoch(tab.id) // 以源码文本重建编辑器模型 } }, [tab, sourceMode]) @@ -767,6 +771,28 @@ export default function App() { const stateRef = useRef(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。 */ const handleImageFile = useCallback(async (file: File): Promise => { const current = activeTab(stateRef.current) @@ -1159,8 +1185,8 @@ export default function App() { /> )} -
- {!tab ? ( +
+ {!tab && (

没有打开的文档

用 Ctrl+N 新建,或 Ctrl+O 打开一个 Markdown 文件。

@@ -1169,49 +1195,62 @@ export default function App() {
- ) : sourceMode ? ( - 正在加载源码编辑器…}> - dispatch({ type: 'updateContent', id: tab.id, content: text })} - onReady={(api) => { sourceApiRef.current = api }} - /> - - ) : ( - 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 && ( +
+ 正在加载源码编辑器…}> + dispatch({ type: 'updateContent', id: tab.id, content: text })} + onReady={(api) => { sourceApiRef.current = api }} + /> + +
+ )} + {state.tabs.filter((t) => aliveIds.includes(t.id)).map((t) => ( +
+ 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 + } + }} + /> +
+ ))} {findUi.open && tab && ( )} -
+ (null) const [tableBar, setTableBar] = useState<{ top: number; left: number } | null>(null) + const [ready, setReady] = useState(false) const tableElRef = useRef(null) const updateTableBar = useCallback(() => { @@ -223,6 +224,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal } editor.create() .then(() => { + setReady(true) setFocusModeEnabled(modesRef.current.focusMode) try { editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never)) @@ -263,6 +265,12 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal )}
+ {!ready && ( +
+
+ 正在打开文档… +
+ )}
) } diff --git a/src/styles.css b/src/styles.css index b2103b9..5d8857d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -95,6 +95,11 @@ button { font: inherit; color: inherit; } .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-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; } .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; } @@ -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 .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); } -.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 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); } @@ -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; } .app, .main, .editor-scroll { display: block !important; height: auto !important; overflow: visible !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; } @page { margin: 18mm 16mm; } } diff --git a/tests/pane-loading.test.tsx b/tests/pane-loading.test.tsx new file mode 100644 index 0000000..983cf1a --- /dev/null +++ b/tests/pane-loading.test.tsx @@ -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((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( + 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() + }) +})