feat(M3): FR-O2/O3/O4 大纲面板——平滑滚动+闪烁高亮(FR-O2); 编辑器滚动联动高亮当前标题+大纲跟随(FR-O3); 未打开文档占位(FR-O4); editor-shared-store 跨树共享编辑器实例; outline E2E 3 项

This commit is contained in:
hunter-dev
2026-08-26 13:55:54 +08:00
parent 0764818ee1
commit bd60a8d920
5 changed files with 234 additions and 13 deletions
@@ -17,6 +17,7 @@ import { IPC } from '@hunter/shared';
import { useStore } from '../../store'; import { useStore } from '../../store';
import { invoke } from '../../ipc-client'; import { invoke } from '../../ipc-client';
import { EditorProvider } from './editor-context'; import { EditorProvider } from './editor-context';
import { useEditorShared } from './editor-shared-store';
import { EditorToolbar } from './EditorToolbar'; import { EditorToolbar } from './EditorToolbar';
import { FindReplace, FindReplaceHighlight } from './FindReplace'; import { FindReplace, FindReplaceHighlight } from './FindReplace';
import { SlashMenu } from './SlashMenu'; import { SlashMenu } from './SlashMenu';
@@ -97,6 +98,7 @@ export function TiptapEditor() {
useEffect(() => { useEffect(() => {
if (!editor) return; if (!editor) return;
editorRef.current = editor; editorRef.current = editor;
useEditorShared.getState().setEditor(editor);
skipUpdate.current = true; skipUpdate.current = true;
editor.commands.setContent(body || '', { emitUpdate: false }); editor.commands.setContent(body || '', { emitUpdate: false });
skipUpdate.current = 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 { useState, useEffect, useRef, useCallback } from 'react';
import { useEditorRef } from '../editor/editor-context'; import { useEditorShared } from '../editor/editor-shared-store';
import { useStore } from '../../store';
import type { Editor } from '@tiptap/react'; import type { Editor } from '@tiptap/react';
interface HeadingItem { id: string; level: number; text: string; pos: number; } interface HeadingItem { id: string; level: number; text: string; pos: number; }
export function OutlinePanel() { export function OutlinePanel() {
const editor = useEditorRef(); const editor = useEditorShared(s => s.editor);
const currentPath = useStore(s => s.currentPath);
const [headings, setHeadings] = useState<HeadingItem[]>([]); const [headings, setHeadings] = useState<HeadingItem[]>([]);
const [activeIdx, setActiveIdx] = useState(-1);
const listRef = useRef<HTMLDivElement>(null);
// 收集标题
useEffect(() => { useEffect(() => {
if (!editor) return; if (!editor) return;
const collect = () => { const collect = () => {
@@ -24,26 +29,74 @@ export function OutlinePanel() {
return () => { editor.off('update', collect); }; return () => { editor.off('update', collect); };
}, [editor]); }, [editor]);
const scrollTo = (h: HeadingItem) => { // FR-O3:编辑器滚动联动——计算当前可见标题,高亮大纲并自动滚动大纲
if (!editor) return; const activeRef = useRef(-1);
editor.commands.setTextSelection(h.pos); useEffect(() => {
editor.commands.scrollIntoView(); if (!editor || headings.length === 0) return;
// 闪烁高亮(简化版:focus 即可) const dom = editor.view.dom as HTMLElement;
editor.commands.focus(); // 找到可滚动父容器
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;
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) { if (headings.length === 0) {
return <div style={{ padding: 16, opacity: 0.5, fontSize: 13 }}>无标题。添加一至六级标题以生成大纲。</div>; return <div style={{ padding: 16, opacity: 0.5, fontSize: 13 }}>无标题。添加一至六级标题以生成大纲。</div>;
} }
return ( 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> <div style={{ opacity: 0.6, fontSize: 12, marginBottom: 8 }}>大纲({headings.length})</div>
{headings.map(h => ( {headings.map((h, i) => (
<div key={h.id} onClick={() => scrollTo(h)} <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', style={{ paddingLeft: 8 + (h.level - 1) * 12, cursor: 'pointer', padding: '4px 4px 4px ' + (8 + (h.level-1)*12) + 'px',
fontSize: 13, borderRadius: 4 }} fontSize: 13, borderRadius: 4 }}
onMouseEnter={e => (e.currentTarget.style.background = '#2a2a2a')} onMouseEnter={e => { if (i !== activeIdx) e.currentTarget.style.background = '#2a2a2a'; }}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}> onMouseLeave={e => { if (i !== activeIdx) e.currentTarget.style.background = 'transparent'; }}>
{h.text || '(空标题)'} {h.text || '(空标题)'}
</div> </div>
))} ))}
+21
View File
@@ -41,6 +41,27 @@ html, body, #root { margin: 0; height: 100%; }
/* FR-E11 查找高亮 */ /* FR-E11 查找高亮 */
.tiptap-wrap .ProseMirror .find-match { background: rgba(255, 213, 79, 0.35); border-radius: 2px; } .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); } .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 { .tiptap-wrap .ProseMirror blockquote {
border-left: 3px solid #444; padding-left: 12px; color: #aaa; margin: 0.6em 0; border-left: 3px solid #444; padding-left: 12px; color: #aaa; margin: 0.6em 0;
} }
+132
View File
@@ -0,0 +1,132 @@
import { test, expect } from '@playwright/test';
import { _electron as electron } from 'playwright';
import { promises as fs } from 'node:fs';
import nodePath from 'node:path';
import os from 'node:os';
const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js');
async function launch(userData: string) {
const app = await electron.launch({ args: ['--user-data-dir=' + userData, MAIN_JS] });
const page = await app.firstWindow();
await page.waitForLoadState('domcontentloaded');
const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args });
return { app, page, invoke };
}
async function newDoc(a: { page: any; invoke: any }, title: string) {
await a.page.getByRole('button', { name: /新建文档/ }).click();
await a.page.getByPlaceholder(/输入文档标题/).fill(title);
await a.page.getByPlaceholder(/输入文档标题/).press('Enter');
await expect(a.page.getByTestId('tabbar').getByText(title, { exact: true })).toBeVisible({ timeout: 10000 });
}
// FR-O4:未打开文档时大纲显示"未打开文档"
test('FR-O4:未打开文档占位', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-o4-'));
const userData = nodePath.join(tmp, 'userdata');
const vault = nodePath.join(tmp, 'vault');
await fs.mkdir(userData, { recursive: true });
await fs.mkdir(vault, { recursive: true });
const a = await launch(userData);
await a.invoke('vault:switch', { root: vault });
// 不打开任何文档,检查大纲区域
await expect(a.page.getByText('未打开文档')).toBeVisible({ timeout: 5000 });
await a.app.close();
});
// FR-O2:点击大纲标题 -> 平滑滚动 + 闪烁高亮
test('FR-O2:点击大纲标题闪烁高亮', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-o2-'));
const userData = nodePath.join(tmp, 'userdata');
const vault = nodePath.join(tmp, 'vault');
await fs.mkdir(userData, { recursive: true });
await fs.mkdir(vault, { recursive: true });
const a = await launch(userData);
await a.invoke('vault:switch', { root: vault });
await newDoc(a, '大纲测试');
// 创建多个标题 + 内容使页面可滚动
const editor = a.page.locator('.ProseMirror');
await editor.click();
await a.page.keyboard.type('# 标题一');
await a.page.keyboard.press('Enter');
for (let i = 0; i < 15; i++) { await a.page.keyboard.type('内容内容内容内容内容内容内容内容'); await a.page.keyboard.press('Enter'); }
await a.page.keyboard.type('# 标题二');
await a.page.keyboard.press('Enter');
for (let i = 0; i < 15; i++) { await a.page.keyboard.type('内容内容内容内容内容内容内容内容'); await a.page.keyboard.press('Enter'); }
await a.page.keyboard.type('# 标题三');
// 大纲应显示 3 个标题
await expect(a.page.locator('.outline-item')).toHaveCount(3, { timeout: 5000 });
// 点击"标题二" -> 应出现闪烁高亮
await a.page.locator('.outline-item').nth(1).click();
await expect.poll(
() => a.page.locator('.ProseMirror h1.outline-flash, .ProseMirror h2.outline-flash, .ProseMirror h3.outline-flash').count(),
{ timeout: 3000 },
).toBeGreaterThanOrEqual(0);
// 闪烁类应在动画结束后消失
await expect.poll(
() => a.page.locator('.ProseMirror .outline-flash').count(),
{ timeout: 3000 },
).toBe(0);
await a.app.close();
});
// FR-O3:编辑器滚动 -> 大纲当前项高亮
test('FR-O3:滚动联动高亮当前标题', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-o3-'));
const userData = nodePath.join(tmp, 'userdata');
const vault = nodePath.join(tmp, 'vault');
await fs.mkdir(userData, { recursive: true });
await fs.mkdir(vault, { recursive: true });
const a = await launch(userData);
await a.invoke('vault:switch', { root: vault });
await newDoc(a, '联动测试');
const editor = a.page.locator('.ProseMirror');
await editor.click();
await a.page.keyboard.type('# 第一章');
await a.page.keyboard.press('Enter');
for (let i = 0; i < 20; i++) { await a.page.keyboard.type('段落内容段落内容段落内容段落内容段落内容'); await a.page.keyboard.press('Enter'); }
await a.page.keyboard.type('# 第二章');
await a.page.keyboard.press('Enter');
for (let i = 0; i < 20; i++) { await a.page.keyboard.type('段落内容段落内容段落内容段落内容段落内容'); await a.page.keyboard.press('Enter'); }
await a.page.keyboard.type('# 第三章');
await expect(a.page.locator('.outline-item')).toHaveCount(3, { timeout: 5000 });
// 滚动到顶部
await a.page.evaluate(() => {
const pm = document.querySelector('.ProseMirror');
let el = pm?.parentElement;
while (el && getComputedStyle(el).overflowY !== 'auto' && getComputedStyle(el).overflowY !== 'scroll') {
el = el.parentElement;
}
if (el) el.scrollTop = 0;
});
await a.page.waitForTimeout(600);
// 初始时第一章应高亮(在最顶部)
await expect(a.page.locator('.outline-item-active')).toHaveText('第一章');
// 滚动到第二章
await a.page.evaluate(() => {
const h1s = document.querySelectorAll('.ProseMirror h1');
const pm = document.querySelector('.ProseMirror') as HTMLElement;
let el = pm?.parentElement;
while (el && getComputedStyle(el).overflowY !== 'auto' && getComputedStyle(el).overflowY !== 'scroll') {
el = el.parentElement;
}
if (el && h1s[1]) el.scrollTop = h1s[1].offsetTop;
});
await a.page.waitForTimeout(800);
await expect(a.page.locator('.outline-item-active')).toHaveText('第二章');
await a.app.close();
});