Files
MyNote/apps/desktop/src/renderer/features/outline/OutlinePanel.tsx
T

121 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useCallback } from 'react';
import { useEditorShared } from '../editor/editor-shared-store';
import { useStore } from '../../store';
import type { Editor } from '@tiptap/react';
interface HeadingItem { id: string; level: number; text: string; pos: number; }
export function OutlinePanel() {
const editor = useEditorShared(s => s.editor);
const currentPath = useStore(s => s.currentPath);
const [headings, setHeadings] = useState<HeadingItem[]>([]);
const [activeIdx, setActiveIdx] = useState(-1);
const listRef = useRef<HTMLDivElement>(null);
// 收集标题——currentPath 变化时重新收集(setContent 用 emitUpdate:false 不触发 update 事件)
useEffect(() => {
if (!editor) return;
const doCollect = () => {
const items: HeadingItem[] = [];
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'heading') {
items.push({ id: `${pos}-${node.attrs.level}`, level: node.attrs.level, text: node.textContent, pos });
}
});
setHeadings(items);
};
// 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);
useEffect(() => {
if (!editor || headings.length === 0) return;
const dom = editor.view.dom as HTMLElement;
// 找到可滚动父容器
let scrollEl: HTMLElement | null = dom.parentElement;
while (scrollEl && getComputedStyle(scrollEl).overflowY !== 'auto' && getComputedStyle(scrollEl).overflowY !== 'scroll') {
scrollEl = scrollEl.parentElement;
}
if (!scrollEl) return;
const onScroll = () => {
const containerTop = scrollEl!.getBoundingClientRect().top;
let current = 0; // 默认第一个标题
for (let i = 0; i < headings.length; i++) {
const coords = editor!.view.coordsAtPos(headings[i].pos + 1);
if (coords.top - containerTop <= 100) current = i;
else break;
}
if (current !== activeRef.current) {
activeRef.current = current;
setActiveIdx(current);
// 大纲自身滚动到当前项
const el = listRef.current?.querySelector(`[data-idx="${current}"]`) as HTMLElement | null;
el?.scrollIntoView({ block: 'nearest' });
}
};
scrollEl.addEventListener('scroll', onScroll, { passive: true });
onScroll(); // 初始计算
return () => { scrollEl!.removeEventListener('scroll', onScroll); };
}, [editor, headings]);
// FR-O2:Typora 式大纲跳转——瞬时定位 + 光标设置 + 闪烁高亮
// 不用 smooth scroll(导航场景应瞬时跳转),不用 editor.commands.focus(会触发 scrollIntoView)
const scrollTo = useCallback((h: HeadingItem) => {
if (!editor) return;
const el = editor.view.nodeDOM(h.pos) as HTMLElement | null;
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 = Math.max(0, elRect.top - containerRect.top + scrollEl.scrollTop);
// 瞬时跳转(不用 smooth scroll——导航操作应该立即到位)
scrollEl.scrollTop = targetTop;
}
// 设置光标到标题位置——setTextSelection 不触发滚动
editor.commands.setTextSelection(h.pos + 1);
// focus 但不触发 ProseMirror scrollIntoView——直接调 view.focus()
editor.view.focus();
// 闪烁高亮(放在最后,确保滚动已完成)
el.classList.remove('outline-flash');
void el.offsetWidth;
el.classList.add('outline-flash');
setTimeout(() => el.classList.remove('outline-flash'), 1500);
}, [editor]);
// FR-O4:未打开文档时显示占位
if (!currentPath) {
return <div style={{ padding: 16, opacity: 0.5, fontSize: 13 }}>未打开文档</div>;
}
if (headings.length === 0) {
return <div style={{ padding: 16, opacity: 0.5, fontSize: 13 }}>无标题。添加一至六级标题以生成大纲。</div>;
}
return (
<div ref={listRef} style={{ padding: 16, overflow: 'auto' }}>
<div style={{ opacity: 0.6, fontSize: 12, marginBottom: 8 }}>大纲({headings.length})</div>
{headings.map((h, i) => (
<div key={h.id} data-idx={i} onClick={() => scrollTo(h)}
className={i === activeIdx ? 'outline-item outline-item-active' : 'outline-item'}
style={{ paddingLeft: 8 + (h.level - 1) * 12, cursor: 'pointer', padding: '4px 4px 4px ' + (8 + (h.level-1)*12) + 'px',
fontSize: 13, borderRadius: 4 }}
onMouseEnter={e => { if (i !== activeIdx) e.currentTarget.style.background = 'var(--control-border)'; }}
onMouseLeave={e => { if (i !== activeIdx) e.currentTarget.style.background = 'transparent'; }}>
{h.text || '(空标题)'}
</div>
))}
</div>
);
}