feat(editor): 标签编辑器保活,切换立即呈现(docs/18)
最近用过的 6 个标签保持挂载、隐藏而非销毁,切回零解析零序列化、各窗格 保留滚动位置;超限窗格卸载走最终回写。引用改为按标签注册表 + 活动变化 effect 统一指向;外部重载 epoch 按标签粒度、抑制闸门对所有标签生效。 EditorPane 新增就绪状态:首次打开大文件显示「正在打开文档…」加载态。 打印守卫:只出活动窗格。vitest 87 通过 + 2 跳过(新增 pane-loading 1 例)。
This commit is contained in:
149
src/App.tsx
149
src/App.tsx
@@ -80,7 +80,13 @@ export default function App() {
|
||||
const [recent, setRecent] = useState<string[]>(loadRecent)
|
||||
const [cursor, setCursor] = useState<CursorInfo>({ line: 1, col: 1 })
|
||||
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 [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<string> => {
|
||||
const current = activeTab(stateRef.current)
|
||||
@@ -1159,8 +1185,8 @@ export default function App() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<section className="editor-scroll" style={{ ['--doc-zoom' as string]: zoom }}>
|
||||
{!tab ? (
|
||||
<div className="editor-area" style={{ ['--doc-zoom' as string]: zoom }}>
|
||||
{!tab && (
|
||||
<div className="empty-state">
|
||||
<h2>没有打开的文档</h2>
|
||||
<p>用 Ctrl+N 新建,或 Ctrl+O 打开一个 Markdown 文件。</p>
|
||||
@@ -1169,49 +1195,62 @@ export default function App() {
|
||||
<button onClick={() => void openFile()}>打开文件…</button>
|
||||
</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 && (
|
||||
<FindBar
|
||||
query={findUi.query}
|
||||
@@ -1244,7 +1283,7 @@ export default function App() {
|
||||
onClose={closeFind}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBar
|
||||
words={statusCounts.words}
|
||||
|
||||
@@ -59,6 +59,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
|
||||
modesRef.current = { focusMode, typewriter }
|
||||
const editorObjRef = useRef<Editor | null>(null)
|
||||
const [tableBar, setTableBar] = useState<{ top: number; left: number } | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
const tableElRef = useRef<HTMLElement | null>(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
|
||||
<TableToolbar editor={editorObjRef.current} top={tableBar.top} left={tableBar.left} />
|
||||
)}
|
||||
<div className="editor-host" ref={hostRef} />
|
||||
{!ready && (
|
||||
<div className="pane-loading">
|
||||
<div className="pane-spinner" />
|
||||
<span>正在打开文档…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user