feat(editor): 标签编辑器保活,切换立即呈现(docs/18)
最近用过的 6 个标签保持挂载、隐藏而非销毁,切回零解析零序列化、各窗格 保留滚动位置;超限窗格卸载走最终回写。引用改为按标签注册表 + 活动变化 effect 统一指向;外部重载 epoch 按标签粒度、抑制闸门对所有标签生效。 EditorPane 新增就绪状态:首次打开大文件显示「正在打开文档…」加载态。 打印守卫:只出活动窗格。vitest 87 通过 + 2 跳过(新增 pane-loading 1 例)。
This commit is contained in:
45
docs/18-tab-keepalive.md
Normal file
45
docs/18-tab-keepalive.md
Normal 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);构建通过。
|
||||||
|
- 待真机验:两个大文档来回切换应立即呈现;首次打开大文档有加载态;
|
||||||
|
外部修改重载、源码模式进出、打印只出当前文档。
|
||||||
149
src/App.tsx
149
src/App.tsx
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
50
tests/pane-loading.test.tsx
Normal file
50
tests/pane-loading.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user