diff --git a/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx b/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx index 7f1fc53..73d89f0 100644 --- a/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx +++ b/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx @@ -98,6 +98,11 @@ export function TiptapEditor() { useEffect(() => { if (!editor) return; editorRef.current = editor; + if (!currentPath) { + // 无文档打开——编辑器 view 未挂载,注销共享引用,跳过 setContent 防止 tiptap error + useEditorShared.getState().setEditor(null); + return; + } useEditorShared.getState().setEditor(editor); skipUpdate.current = true; editor.commands.setContent(body || '', { emitUpdate: false }); diff --git a/apps/desktop/src/renderer/features/outline/OutlinePanel.tsx b/apps/desktop/src/renderer/features/outline/OutlinePanel.tsx index d40d7db..efc23ff 100644 --- a/apps/desktop/src/renderer/features/outline/OutlinePanel.tsx +++ b/apps/desktop/src/renderer/features/outline/OutlinePanel.tsx @@ -12,10 +12,10 @@ export function OutlinePanel() { const [activeIdx, setActiveIdx] = useState(-1); const listRef = useRef(null); - // 收集标题 + // 收集标题——currentPath 变化时重新收集(setContent 用 emitUpdate:false 不触发 update 事件) useEffect(() => { if (!editor) return; - const collect = () => { + const doCollect = () => { const items: HeadingItem[] = []; editor.state.doc.descendants((node, pos) => { if (node.type.name === 'heading') { @@ -24,10 +24,12 @@ export function OutlinePanel() { }); setHeadings(items); }; - collect(); - editor.on('update', collect); - return () => { editor.off('update', collect); }; - }, [editor]); + // rAF 延迟到下一帧,确保 TiptapEditor 的 setContent 已完成 + const raf = requestAnimationFrame(doCollect); + // 编辑时实时跟随 + editor.on('update', doCollect); + return () => { editor.off('update', doCollect); cancelAnimationFrame(raf); }; + }, [editor, currentPath]); // FR-O3:编辑器滚动联动——计算当前可见标题,高亮大纲并自动滚动大纲 const activeRef = useRef(-1); @@ -62,18 +64,28 @@ export function OutlinePanel() { return () => { scrollEl!.removeEventListener('scroll', onScroll); }; }, [editor, headings]); - // FR-O2:平滑滚动 + 闪烁高亮 + // FR-O2:平滑滚动到标题——手动计算 scrollTop,确保标题显示在编辑区最上方 const scrollTo = useCallback((h: HeadingItem) => { if (!editor) return; const el = editor.view.nodeDOM(h.pos) as HTMLElement | null; - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'start' }); - // 闪烁高亮 - el.classList.remove('outline-flash'); - void el.offsetWidth; // 触发重排以重启动画 - el.classList.add('outline-flash'); - setTimeout(() => el.classList.remove('outline-flash'), 1600); + if (!el) return; + // 找到编辑器的可滚动父容器(与滚动联动逻辑一致) + let scrollEl: HTMLElement | null = el.parentElement; + while (scrollEl && getComputedStyle(scrollEl).overflowY !== 'auto' && getComputedStyle(scrollEl).overflowY !== 'scroll') { + scrollEl = scrollEl.parentElement; } + if (scrollEl) { + // 计算让标题位于容器最上方的滚动位置 + const elRect = el.getBoundingClientRect(); + const containerRect = scrollEl.getBoundingClientRect(); + const targetTop = elRect.top - containerRect.top + scrollEl.scrollTop; + scrollEl.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' }); + } + // 闪烁高亮 + el.classList.remove('outline-flash'); + void el.offsetWidth; // 触发重排以重启动画 + el.classList.add('outline-flash'); + setTimeout(() => el.classList.remove('outline-flash'), 1600); editor.commands.setTextSelection(h.pos + 1); editor.commands.focus(); }, [editor]); diff --git a/apps/desktop/tests/e2e/button-all.spec.ts b/apps/desktop/tests/e2e/button-all.spec.ts index 1682087..0c19676 100644 --- a/apps/desktop/tests/e2e/button-all.spec.ts +++ b/apps/desktop/tests/e2e/button-all.spec.ts @@ -339,3 +339,19 @@ test('TabBar: 关闭全部标签后编辑器应显示空状态', async () => { await expect(a.page.getByText('从左侧选择一篇笔记')).toBeVisible({ timeout: 5000 }); await a.app.close(); }); +test('OutlinePanel: 切换文档后大纲自动更新', async () => { + const a = await launch(nodePath.join(os.tmpdir(), 'hn-outline-' + Date.now())); + await setupVault(a, [ + { name: 'DocA', body: '# Title A\n## Sub A' }, + { name: 'DocB', body: '# Title B\n## Sub B\n### Deep B' }, + ]); + await a.page.getByTestId('filetree').getByText('DocA', { exact: true }).click(); + await a.page.waitForTimeout(500); + await expect(a.page.locator('.outline-item', { hasText: 'Title A' })).toBeVisible({ timeout: 5000 }); + await expect(a.page.locator('.outline-item', { hasText: 'Sub A' })).toBeVisible({ timeout: 5000 }); + await a.page.getByTestId('filetree').getByText('DocB', { exact: true }).click(); + await a.page.waitForTimeout(800); + await expect(a.page.locator('.outline-item', { hasText: 'Title B' })).toBeVisible({ timeout: 5000 }); + await expect(a.page.locator('.outline-item', { hasText: 'Deep B' })).toBeVisible({ timeout: 5000 }); + await a.app.close(); +});