From 0c605cb8b60d228443a1d0b7662f444ee04aff9b Mon Sep 17 00:00:00 2001 From: hunter-dev Date: Wed, 26 Aug 2026 21:35:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(editor+outline):=202=E4=B8=AA=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=941.tiptap=20error:?= =?UTF-8?q?=20resetEditor=E6=B8=85=E7=A9=BAcurrentPath=E5=90=8ETiptapEdito?= =?UTF-8?q?r=E6=8F=90=E5=89=8Dreturn=E4=B8=8D=E6=B8=B2=E6=9F=93EditorConte?= =?UTF-8?q?nt,=20=E4=BD=86useEffect=E4=BB=8D=E8=B0=83editor.commands.setCo?= =?UTF-8?q?ntent=E8=A7=A6=E5=8F=91view.dom=E8=AE=BF=E9=97=AE=E5=B4=A9?= =?UTF-8?q?=E6=BA=83;=20=E4=BF=AE=E5=A4=8D:=20currentPath=E4=B8=BAnull?= =?UTF-8?q?=E6=97=B6=E8=B7=B3=E8=BF=87setContent=E5=B9=B6=E6=B3=A8?= =?UTF-8?q?=E9=94=80useEditorShared=E5=BC=95=E7=94=A8;=202a.=E5=A4=A7?= =?UTF-8?q?=E7=BA=B2=E4=B8=8D=E9=9A=8F=E6=96=87=E6=A1=A3=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E6=9B=B4=E6=96=B0:=20collect=20effect=E4=BB=85=E4=BE=9D?= =?UTF-8?q?=E8=B5=96editor,=20setContent=E7=94=A8emitUpdate:false=E4=B8=8D?= =?UTF-8?q?=E8=A7=A6=E5=8F=91update=E4=BA=8B=E4=BB=B6;=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D:=20=E5=8A=A0currentPath=E4=BE=9D=E8=B5=96+rAF?= =?UTF-8?q?=E5=BB=B6=E8=BF=9F=E6=94=B6=E9=9B=86;=202b.=E5=A4=A7=E7=BA=B2?= =?UTF-8?q?=E7=82=B9=E5=87=BB=E8=B7=B3=E8=BD=AC=E4=BD=8D=E7=BD=AE=E4=B8=8D?= =?UTF-8?q?=E5=87=86:=20scrollIntoView=E7=9A=84block:start=E5=8F=97?= =?UTF-8?q?=E7=88=B6=E5=AE=B9=E5=99=A8=E5=BD=B1=E5=93=8D;=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D:=20=E6=89=8B=E5=8A=A8=E8=AE=A1=E7=AE=97scrollTop=3Del?= =?UTF-8?q?Rect.top-containerRect.top+scrollEl.scrollTop,=20scrollTo?= =?UTF-8?q?=E7=B2=BE=E7=A1=AE=E5=88=B0=E5=AE=B9=E5=99=A8=E6=9C=80=E4=B8=8A?= =?UTF-8?q?=E6=96=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../renderer/features/editor/TiptapEditor.tsx | 5 +++ .../features/outline/OutlinePanel.tsx | 40 ++++++++++++------- apps/desktop/tests/e2e/button-all.spec.ts | 16 ++++++++ 3 files changed, 47 insertions(+), 14 deletions(-) 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(); +});