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:
@@ -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]);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user