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([]); const [activeIdx, setActiveIdx] = useState(-1); const listRef = useRef(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
未打开文档
; } if (headings.length === 0) { return
无标题。添加一至六级标题以生成大纲。
; } return (
大纲({headings.length})
{headings.map((h, i) => (
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 || '(空标题)'}
))}
); }