121 lines
5.4 KiB
TypeScript
121 lines
5.4 KiB
TypeScript
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>
|
||
);
|
||
} |