fix(editor+outline): 2个问题修复——1.tiptap error: resetEditor清空currentPath后TiptapEditor提前return不渲染EditorContent, 但useEffect仍调editor.commands.setContent触发view.dom访问崩溃; 修复: currentPath为null时跳过setContent并注销useEditorShared引用; 2a.大纲不随文档切换更新: collect effect仅依赖editor, setContent用emitUpdate:false不触发update事件; 修复: 加currentPath依赖+rAF延迟收集; 2b.大纲点击跳转位置不准: scrollIntoView的block:start受父容器影响; 修复: 手动计算scrollTop=elRect.top-containerRect.top+scrollEl.scrollTop, scrollTo精确到容器最上方

This commit is contained in:
hunter-dev
2026-08-26 21:35:11 +08:00
parent b01bdd380a
commit 0c605cb8b6
3 changed files with 47 additions and 14 deletions
@@ -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 });
@@ -12,10 +12,10 @@ export function OutlinePanel() {
const [activeIdx, setActiveIdx] = useState(-1);
const listRef = useRef<HTMLDivElement>(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]);
+16
View File
@@ -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();
});