diff --git a/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx b/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx index 9d34a8a..7f1fc53 100644 --- a/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx +++ b/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx @@ -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; diff --git a/apps/desktop/src/renderer/features/editor/editor-shared-store.ts b/apps/desktop/src/renderer/features/editor/editor-shared-store.ts new file mode 100644 index 0000000..0b01932 --- /dev/null +++ b/apps/desktop/src/renderer/features/editor/editor-shared-store.ts @@ -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((set) => ({ + editor: null, + setEditor: (e) => set({ editor: e }), +})); diff --git a/apps/desktop/src/renderer/features/outline/OutlinePanel.tsx b/apps/desktop/src/renderer/features/outline/OutlinePanel.tsx index 138ef1c..690816b 100644 --- a/apps/desktop/src/renderer/features/outline/OutlinePanel.tsx +++ b/apps/desktop/src/renderer/features/outline/OutlinePanel.tsx @@ -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([]); + const [activeIdx, setActiveIdx] = useState(-1); + const listRef = useRef(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
未打开文档
; + } if (headings.length === 0) { return
无标题。添加一至六级标题以生成大纲。
; } + return ( -
+
大纲({headings.length})
- {headings.map(h => ( -
scrollTo(h)} + {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 => (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 || '(空标题)'}
))} diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index 2b65e78..1e61a18 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -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; } diff --git a/apps/desktop/tests/e2e/outline.spec.ts b/apps/desktop/tests/e2e/outline.spec.ts new file mode 100644 index 0000000..acce9ce --- /dev/null +++ b/apps/desktop/tests/e2e/outline.spec.ts @@ -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(); +});