feat(M3): FR-O2/O3/O4 大纲面板——平滑滚动+闪烁高亮(FR-O2); 编辑器滚动联动高亮当前标题+大纲跟随(FR-O3); 未打开文档占位(FR-O4); editor-shared-store 跨树共享编辑器实例; outline E2E 3 项
This commit is contained in:
@@ -17,6 +17,7 @@ import { IPC } from '@hunter/shared';
|
||||
import { useStore } from '../../store';
|
||||
import { invoke } from '../../ipc-client';
|
||||
import { EditorProvider } from './editor-context';
|
||||
import { useEditorShared } from './editor-shared-store';
|
||||
import { EditorToolbar } from './EditorToolbar';
|
||||
import { FindReplace, FindReplaceHighlight } from './FindReplace';
|
||||
import { SlashMenu } from './SlashMenu';
|
||||
@@ -97,6 +98,7 @@ export function TiptapEditor() {
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
editorRef.current = editor;
|
||||
useEditorShared.getState().setEditor(editor);
|
||||
skipUpdate.current = true;
|
||||
editor.commands.setContent(body || '', { emitUpdate: false });
|
||||
skipUpdate.current = false;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Editor } from '@tiptap/react';
|
||||
|
||||
// 跨组件树共享编辑器实例(大纲面板在 EditorProvider 上下文之外)
|
||||
interface EditorSharedState {
|
||||
editor: Editor | null;
|
||||
setEditor: (e: Editor | null) => void;
|
||||
}
|
||||
|
||||
export const useEditorShared = create<EditorSharedState>((set) => ({
|
||||
editor: null,
|
||||
setEditor: (e) => set({ editor: e }),
|
||||
}));
|
||||
@@ -1,13 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEditorRef } from '../editor/editor-context';
|
||||
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 = useEditorRef();
|
||||
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);
|
||||
|
||||
// 收集标题
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const collect = () => {
|
||||
@@ -24,26 +29,74 @@ export function OutlinePanel() {
|
||||
return () => { editor.off('update', collect); };
|
||||
}, [editor]);
|
||||
|
||||
const scrollTo = (h: HeadingItem) => {
|
||||
// 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:平滑滚动 + 闪烁高亮
|
||||
const scrollTo = useCallback((h: HeadingItem) => {
|
||||
if (!editor) return;
|
||||
editor.commands.setTextSelection(h.pos);
|
||||
editor.commands.scrollIntoView();
|
||||
// 闪烁高亮(简化版:focus 即可)
|
||||
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);
|
||||
}
|
||||
editor.commands.setTextSelection(h.pos + 1);
|
||||
editor.commands.focus();
|
||||
};
|
||||
}, [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 style={{ padding: 16, overflow: 'auto' }}>
|
||||
<div ref={listRef} style={{ padding: 16, overflow: 'auto' }}>
|
||||
<div style={{ opacity: 0.6, fontSize: 12, marginBottom: 8 }}>大纲({headings.length})</div>
|
||||
{headings.map(h => (
|
||||
<div key={h.id} onClick={() => scrollTo(h)}
|
||||
{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 => (e.currentTarget.style.background = '#2a2a2a')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>
|
||||
onMouseEnter={e => { if (i !== activeIdx) e.currentTarget.style.background = '#2a2a2a'; }}
|
||||
onMouseLeave={e => { if (i !== activeIdx) e.currentTarget.style.background = 'transparent'; }}>
|
||||
{h.text || '(空标题)'}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -41,6 +41,27 @@ html, body, #root { margin: 0; height: 100%; }
|
||||
/* FR-E11 查找高亮 */
|
||||
.tiptap-wrap .ProseMirror .find-match { background: rgba(255, 213, 79, 0.35); border-radius: 2px; }
|
||||
.tiptap-wrap .ProseMirror .find-match-active { background: rgba(255, 145, 0, 0.65); }
|
||||
|
||||
/* FR-O2:标题闪烁高亮 */
|
||||
@keyframes outline-flash-anim {
|
||||
0% { background: rgba(99, 179, 237, 0.4); }
|
||||
100% { background: transparent; }
|
||||
}
|
||||
.tiptap-wrap .ProseMirror h1.outline-flash,
|
||||
.tiptap-wrap .ProseMirror h2.outline-flash,
|
||||
.tiptap-wrap .ProseMirror h3.outline-flash,
|
||||
.tiptap-wrap .ProseMirror h4.outline-flash,
|
||||
.tiptap-wrap .ProseMirror h5.outline-flash,
|
||||
.tiptap-wrap .ProseMirror h6.outline-flash {
|
||||
animation: outline-flash-anim 1.5s ease-out;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* FR-O3:大纲当前项高亮 */
|
||||
.outline-item-active {
|
||||
background: rgba(99, 179, 237, 0.15) !important;
|
||||
border-left: 2px solid #63b3ed !important;
|
||||
}
|
||||
.tiptap-wrap .ProseMirror blockquote {
|
||||
border-left: 3px solid #444; padding-left: 12px; color: #aaa; margin: 0.6em 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user