diff --git a/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx b/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx index de65990..f018530 100644 --- a/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx +++ b/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx @@ -15,6 +15,7 @@ import { Markdown } from 'tiptap-markdown'; import { createLowlight, common } from 'lowlight'; import { IPC } from '@hunter/shared'; import { useStore } from '../../store'; +import { useTabs } from '../tabs/tab-store'; import { invoke } from '../../ipc-client'; import { EditorProvider } from './editor-context'; import { useEditorShared } from './editor-shared-store'; @@ -44,6 +45,8 @@ const Img = Image.extend({ export function TiptapEditor() { const currentPath = useStore(s => s.currentPath); const setBody = useStore(s => s.setBody); + // 当前文档对应的标签页 id(用于读写该标签记忆的滚动位置) + const activeTabId = useTabs(s => s.tabs.find(t => t.path === currentPath)?.id); const skipUpdate = useRef(false); const editorRef = useRef(null); const mdTimer = useRef | null>(null); @@ -179,14 +182,32 @@ export function TiptapEditor() { skipUpdate.current = true; editor.commands.setContent(body || '', { emitUpdate: false }); skipUpdate.current = false; - // 切到新文档时回到顶部(编辑器实例复用,滚动容器不随文档重建,需手动复位) + // 滚动位置恢复:编辑器实例复用,滚动容器不随文档重建。新文档默认 0(顶部); + // 切回已打开的标签页则恢复到该标签上次记忆的位置。 + const tabScroll = useTabs.getState().tabs.find(t => t.path === currentPath)?.scrollTop ?? 0; const scroller = editor.view.dom.parentElement as HTMLElement | null; if (scroller) { - // 内容换入后再复位,确保 scrollTop 真正落到顶部 - requestAnimationFrame(() => { scroller.scrollTop = 0; }); + requestAnimationFrame(() => { scroller.scrollTop = tabScroll; }); } }, [currentPath, editor]); + // FR-T5:把当前文档的滚动位置记录回对应标签页(切换/重开时可恢复;不触发持久化) + useEffect(() => { + if (!editor || !activeTabId) return; + const scroller = editor.view.dom.parentElement as HTMLElement | null; + if (!scroller) return; + let raf = 0; + const onScroll = () => { + if (raf) return; + raf = requestAnimationFrame(() => { + raf = 0; + useTabs.getState().updateScroll(activeTabId, scroller.scrollTop); + }); + }; + scroller.addEventListener('scroll', onScroll, { passive: true }); + return () => { scroller.removeEventListener('scroll', onScroll); if (raf) cancelAnimationFrame(raf); }; + }, [editor, activeTabId]); + // FR-E13:长文档底部书写——光标行距编辑区底部保持留白,避免输入内容贴着页面底缘 // B档-7:同时同步光标所在行高亮(.is-active-line) useEffect(() => { diff --git a/apps/desktop/tests/e2e/scroll-memory.spec.ts b/apps/desktop/tests/e2e/scroll-memory.spec.ts new file mode 100644 index 0000000..ec0bbba --- /dev/null +++ b/apps/desktop/tests/e2e/scroll-memory.spec.ts @@ -0,0 +1,67 @@ +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 }; +} + +// 生成长文档:足够滚动 +function longBody(title: string): string { + const paras: string[] = []; + for (let i = 1; i <= 800; i++) paras.push('段落 ' + i + ' 内容内容内容内容内容内容内容内容内容内容内容内容内容内容内容'); + return '# ' + title + '\n\n' + paras.join('\n\n'); +} + +async function scrollerTop(page: any): Promise { + return page.evaluate(() => { + const sc = document.querySelector('.ProseMirror')?.parentElement as HTMLElement | null; + return sc ? sc.scrollTop : -1; + }); +} + +async function setScroll(page: any, top: number): Promise { + await page.evaluate((t: number) => { + const sc = document.querySelector('.ProseMirror')?.parentElement as HTMLElement | null; + if (sc) { sc.scrollTop = t; sc.dispatchEvent(new Event('scroll')); } + }, top); +} + +test('FR-T5:切换文档恢复各自滚动位置;新文档从顶部', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-scroll-')); + const userData = nodePath.join(tmp, 'ud'); const vault = nodePath.join(tmp, 'v'); + 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 a.invoke('doc:write', { path: 'long.md', meta: {}, body: longBody('长文') }); + await a.invoke('doc:write', { path: 'short.md', meta: {}, body: '# 短文\n\n很短的内容。' }); + + // 打开长文档,滚到中部,等 rAF 记录 + await a.page.evaluate((p: string) => (globalThis as any).__STORE__?.getState?.().openDoc(p), 'long.md'); + await a.page.waitForTimeout(1200); + await setScroll(a.page, 1500); + await a.page.waitForTimeout(400); + + // 切到新文档(短文)——应从顶部开始 + await a.page.evaluate((p: string) => (globalThis as any).__STORE__?.getState?.().openDoc(p), 'short.md'); + await a.page.waitForTimeout(400); + const shortTop = await scrollerTop(a.page); + expect(shortTop).toBeLessThanOrEqual(1); + + // 切回长文档——应恢复到上次滚动位置 + await a.page.evaluate((p: string) => (globalThis as any).__STORE__?.getState?.().openDoc(p), 'long.md'); + await a.page.waitForTimeout(400); + const restored = await scrollerTop(a.page); + expect(restored).toBeGreaterThan(1000); + + await a.app.close(); +});