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(() => {
|
useEffect(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
editorRef.current = editor;
|
editorRef.current = editor;
|
||||||
|
if (!currentPath) {
|
||||||
|
// 无文档打开——编辑器 view 未挂载,注销共享引用,跳过 setContent 防止 tiptap error
|
||||||
|
useEditorShared.getState().setEditor(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
useEditorShared.getState().setEditor(editor);
|
useEditorShared.getState().setEditor(editor);
|
||||||
skipUpdate.current = true;
|
skipUpdate.current = true;
|
||||||
editor.commands.setContent(body || '', { emitUpdate: false });
|
editor.commands.setContent(body || '', { emitUpdate: false });
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ export function OutlinePanel() {
|
|||||||
const [activeIdx, setActiveIdx] = useState(-1);
|
const [activeIdx, setActiveIdx] = useState(-1);
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 收集标题
|
// 收集标题——currentPath 变化时重新收集(setContent 用 emitUpdate:false 不触发 update 事件)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const collect = () => {
|
const doCollect = () => {
|
||||||
const items: HeadingItem[] = [];
|
const items: HeadingItem[] = [];
|
||||||
editor.state.doc.descendants((node, pos) => {
|
editor.state.doc.descendants((node, pos) => {
|
||||||
if (node.type.name === 'heading') {
|
if (node.type.name === 'heading') {
|
||||||
@@ -24,10 +24,12 @@ export function OutlinePanel() {
|
|||||||
});
|
});
|
||||||
setHeadings(items);
|
setHeadings(items);
|
||||||
};
|
};
|
||||||
collect();
|
// rAF 延迟到下一帧,确保 TiptapEditor 的 setContent 已完成
|
||||||
editor.on('update', collect);
|
const raf = requestAnimationFrame(doCollect);
|
||||||
return () => { editor.off('update', collect); };
|
// 编辑时实时跟随
|
||||||
}, [editor]);
|
editor.on('update', doCollect);
|
||||||
|
return () => { editor.off('update', doCollect); cancelAnimationFrame(raf); };
|
||||||
|
}, [editor, currentPath]);
|
||||||
|
|
||||||
// FR-O3:编辑器滚动联动——计算当前可见标题,高亮大纲并自动滚动大纲
|
// FR-O3:编辑器滚动联动——计算当前可见标题,高亮大纲并自动滚动大纲
|
||||||
const activeRef = useRef(-1);
|
const activeRef = useRef(-1);
|
||||||
@@ -62,18 +64,28 @@ export function OutlinePanel() {
|
|||||||
return () => { scrollEl!.removeEventListener('scroll', onScroll); };
|
return () => { scrollEl!.removeEventListener('scroll', onScroll); };
|
||||||
}, [editor, headings]);
|
}, [editor, headings]);
|
||||||
|
|
||||||
// FR-O2:平滑滚动 + 闪烁高亮
|
// FR-O2:平滑滚动到标题——手动计算 scrollTop,确保标题显示在编辑区最上方
|
||||||
const scrollTo = useCallback((h: HeadingItem) => {
|
const scrollTo = useCallback((h: HeadingItem) => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const el = editor.view.nodeDOM(h.pos) as HTMLElement | null;
|
const el = editor.view.nodeDOM(h.pos) as HTMLElement | null;
|
||||||
if (el) {
|
if (!el) return;
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
// 找到编辑器的可滚动父容器(与滚动联动逻辑一致)
|
||||||
// 闪烁高亮
|
let scrollEl: HTMLElement | null = el.parentElement;
|
||||||
el.classList.remove('outline-flash');
|
while (scrollEl && getComputedStyle(scrollEl).overflowY !== 'auto' && getComputedStyle(scrollEl).overflowY !== 'scroll') {
|
||||||
void el.offsetWidth; // 触发重排以重启动画
|
scrollEl = scrollEl.parentElement;
|
||||||
el.classList.add('outline-flash');
|
|
||||||
setTimeout(() => el.classList.remove('outline-flash'), 1600);
|
|
||||||
}
|
}
|
||||||
|
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.setTextSelection(h.pos + 1);
|
||||||
editor.commands.focus();
|
editor.commands.focus();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|||||||
@@ -339,3 +339,19 @@ test('TabBar: 关闭全部标签后编辑器应显示空状态', async () => {
|
|||||||
await expect(a.page.getByText('从左侧选择一篇笔记')).toBeVisible({ timeout: 5000 });
|
await expect(a.page.getByText('从左侧选择一篇笔记')).toBeVisible({ timeout: 5000 });
|
||||||
await a.app.close();
|
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