diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 1918506..cc39066 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -31,6 +31,8 @@ function createWindow() { // 窗口控制(FR-W2) ipcMain.on(IPC.winMinimize, () => win?.minimize()); ipcMain.on(IPC.winMaximize, () => { if (win?.isMaximized()) win.unmaximize(); else win?.maximize(); }); + // FR-W2:返回窗口最大化状态供 TitleBar 切换图标 + ipcMain.handle(IPC.winIsMaximized, () => win?.isMaximized() ?? false); ipcMain.on(IPC.winClose, () => win?.close()); if (process.env.ELECTRON_RENDERER_URL) win.loadURL(process.env.ELECTRON_RENDERER_URL); else win.loadFile(nodePath.join(__dirname, '../renderer/index.html')); diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index e04be68..ca6309b 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -37,7 +37,10 @@ export function App() { const layout = useLayout(); useEffect(() => { loadVault(); loadTree(); restore(); appearance.load(); useKeymap.getState().load(); useSettings.getState().loadGpu(); }, []); - useEffect(() => { if (activeId && tabs.length > 0) { const t = tabs.find(x => x.id === activeId); if (t) openDoc(t.path).catch(() => {}); } }, []); + useEffect(() => { if (activeId && tabs.length > 0) { const t = tabs.find(x => x.id === activeId); if (t) openDoc(t.path).then(() => { + // FR-T5:恢复标签页保存的滚动位置 + if (t.scrollTop > 0) { setTimeout(() => { const ed = document.querySelector('.ProseMirror'); const c = ed?.closest('[class*="editor"]') as HTMLElement | null; if (c) c.scrollTop = t.scrollTop; }, 100); } + }).catch(() => {}); } }, [activeId]); // FR-S4:关窗前冲刷未保存内容 useEffect(() => { const onBeforeUnload = () => { try { useStore.getState().saveNow(); } catch {} }; @@ -52,9 +55,9 @@ export function App() {
{appearance.bgImage &&
}
- setView(v => v === 'editor' ? 'graph' : 'editor')} onSettings={() => setShowSettings(true)} onAppearance={() => setShowAppearance(true)} onToggleSidebar={() => layout.toggleSidebar()} onToggleMinimal={() => layout.toggleMinimal()} minimal={layout.minimal} /> + setView(v => v === 'editor' ? 'graph' : 'editor')} onSettings={() => setShowSettings(true)} onAppearance={() => setShowAppearance(true)} onToggleSidebar={() => layout.toggleSidebar()} onToggleMinimal={() => layout.toggleMinimal()} onToggleTabbar={() => layout.toggleTabbar()} minimal={layout.minimal} tabbarVisible={layout.tabbarVisible} /> - {view === 'editor' && } + {view === 'editor' && layout.tabbarVisible && !layout.minimal && }
{layout.minimal ? () : (<> {layout.sidebarVisible && (<> diff --git a/apps/desktop/src/renderer/features/shell/TitleBar.tsx b/apps/desktop/src/renderer/features/shell/TitleBar.tsx index 0f55928..733ef4f 100644 --- a/apps/desktop/src/renderer/features/shell/TitleBar.tsx +++ b/apps/desktop/src/renderer/features/shell/TitleBar.tsx @@ -1,30 +1,34 @@ -import { send } from '../../ipc-client'; +import { send, invoke } from '../../ipc-client'; import { IPC } from '@hunter/shared'; import { useStore } from '../../store'; +import { useState, useEffect } from 'react'; const drag = { WebkitAppRegion: 'drag', flex: 1, height: '100%' } as React.CSSProperties; const noDrag = { WebkitAppRegion: 'no-drag' } as React.CSSProperties; -export function TitleBar({ onToggleView, view, onSettings, onAppearance, onToggleSidebar, onToggleMinimal, minimal }: { onToggleView: () => void; view: 'editor' | 'graph'; onSettings: () => void; onAppearance: () => void; onToggleSidebar: () => void; onToggleMinimal: () => void; minimal: boolean }) { +export function TitleBar({ onToggleView, view, onSettings, onAppearance, onToggleSidebar, onToggleMinimal, onToggleTabbar, minimal, tabbarVisible }: { onToggleView: () => void; view: 'editor' | 'graph'; onSettings: () => void; onAppearance: () => void; onToggleSidebar: () => void; onToggleMinimal: () => void; onToggleTabbar: () => void; minimal: boolean; tabbarVisible: boolean }) { const { loadTree } = useStore(); + const [maximized, setMaximized] = useState(false); + useEffect(() => { invoke(IPC.winIsMaximized, undefined as never).then(setMaximized).catch(() => {}); }, []); return (
狩夜人笔记
- - + +
- - - - + + + + + - - - + + +
); } -const btn: React.CSSProperties = { width: 36, height: 28, border: 'none', background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer', fontSize: 12 }; \ No newline at end of file +const btn: React.CSSProperties = { width: 36, height: 28, border: 'none', background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer', fontSize: 12 }; diff --git a/apps/desktop/src/renderer/features/tabs/TabBar.tsx b/apps/desktop/src/renderer/features/tabs/TabBar.tsx index 3e040a5..d369e3d 100644 --- a/apps/desktop/src/renderer/features/tabs/TabBar.tsx +++ b/apps/desktop/src/renderer/features/tabs/TabBar.tsx @@ -1,10 +1,11 @@ import { useTabs } from './tab-store'; import { useStore } from '../../store'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; export function TabBar() { const { tabs, activeId, setActive, closeTab, togglePin } = useTabs(); const { openDoc, currentPath } = useStore(); + const [hoveredTab, setHoveredTab] = useState(null); // 当 activeId 变化,加载对应标签的文档 useEffect(() => { @@ -17,19 +18,21 @@ export function TabBar() {
{tabs.map(t => (
setActive(t.id)} + onMouseEnter={() => setHoveredTab(t.id)} onMouseLeave={() => setHoveredTab(null)} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 10px', cursor: 'pointer', borderBottom: activeId === t.id ? '2px solid var(--accent)' : '2px solid transparent', background: activeId === t.id ? 'var(--bg-secondary)' : 'transparent', fontSize: 12, color: activeId === t.id ? 'var(--text-primary)' : 'var(--text-tertiary)', whiteSpace: 'nowrap' }}> {t.pinned && ★} {t.path.split('/').pop()?.replace(/\.md$/, '')} {t.pinned ? ( - 📌 + // FR-T4:固定标签悬停时浮现关闭按钮 + { e.stopPropagation(); closeTab(t.id); }} style={{ color: 'var(--text-tertiary)', cursor: 'pointer', fontSize: 12, opacity: hoveredTab === t.id ? 1 : 0 }} onMouseEnter={e => (e.currentTarget.style.color = '#f88')} onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-tertiary)')}>x ) : ( - { e.stopPropagation(); closeTab(t.id); }} style={{ color: 'var(--text-tertiary)', cursor: 'pointer', fontSize: 12 }} onMouseEnter={e => (e.currentTarget.style.color = '#f88')} onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-tertiary)')}>× + { e.stopPropagation(); closeTab(t.id); }} style={{ color: 'var(--text-tertiary)', cursor: 'pointer', fontSize: 12 }} onMouseEnter={e => (e.currentTarget.style.color = '#f88')} onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-tertiary)')}>x )} { e.stopPropagation(); togglePin(t.id); }} style={{ color: t.pinned ? 'var(--accent)' : 'var(--text-tertiary)', cursor: 'pointer', fontSize: 10 }} title={t.pinned ? '取消固定' : '固定'}>●
))}
); -} \ No newline at end of file +} diff --git a/apps/desktop/src/renderer/features/tabs/tab-store.ts b/apps/desktop/src/renderer/features/tabs/tab-store.ts index 9b5b533..e716dff 100644 --- a/apps/desktop/src/renderer/features/tabs/tab-store.ts +++ b/apps/desktop/src/renderer/features/tabs/tab-store.ts @@ -9,6 +9,7 @@ interface TabState { closeTab: (id: string) => void; setActive: (id: string) => void; togglePin: (id: string) => void; + updateScroll: (id: string, scrollTop: number) => void; // FR-T5:保存滚动位置 closeAll: () => void; // FR-V6: 切库时清空全部标签页 renamePath: (oldPath: string, newPath: string) => void; // FR-T7: 重命名/移动后同步标签页路径 activePath: () => string | null; @@ -46,6 +47,11 @@ export const useTabs = create((set, get) => ({ setActive: (id) => { set({ activeId: id }); get().persist(); }, + // FR-T5:更新标签页滚动位置(不触发 persist,避免频繁写入) + updateScroll: (id, scrollTop) => { + set(s => ({ tabs: s.tabs.map(t => t.id === id ? { ...t, scrollTop } : t) })); + }, + togglePin: (id) => { set(s => { const tabs = s.tabs.map(t => t.id === id ? { ...t, pinned: !t.pinned } : t); diff --git a/apps/desktop/tests/e2e/layout-tabs.spec.ts b/apps/desktop/tests/e2e/layout-tabs.spec.ts new file mode 100644 index 0000000..34be38f --- /dev/null +++ b/apps/desktop/tests/e2e/layout-tabs.spec.ts @@ -0,0 +1,89 @@ +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-T6/W6:标签栏可隐藏/显示 +test('FR-T6/W6:标签栏可隐藏/显示', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-t6-')); + const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v'); + await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true }); + const a = await launch(ud); + await a.invoke('vault:switch', { root: v }); + await newDoc(a, 'T6doc'); + // 标签栏应可见 + await expect(a.page.getByTestId('tabbar')).toBeVisible(); + // 点击切换按钮隐藏 + await a.page.getByTestId('tabbar-toggle').click(); + await a.page.waitForTimeout(300); + // 标签栏应隐藏 + await expect(a.page.getByTestId('tabbar')).toHaveCount(0, { timeout: 3000 }); + // 再点显示 + await a.page.getByTestId('tabbar-toggle').click(); + await a.page.waitForTimeout(300); + await expect(a.page.getByTestId('tabbar')).toBeVisible(); + await a.app.close(); +}); + +// FR-W7:极简模式隐藏标签栏 +test('FR-W7:极简模式隐藏标签栏', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-w7-')); + const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v'); + await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true }); + const a = await launch(ud); + await a.invoke('vault:switch', { root: v }); + await newDoc(a, 'W7doc'); + await expect(a.page.getByTestId('tabbar')).toBeVisible(); + // 点击极简模式 + await a.page.getByTitle('极简模式').click(); + await a.page.waitForTimeout(300); + // 标签栏应隐藏 + await expect(a.page.getByTestId('tabbar')).toHaveCount(0, { timeout: 3000 }); + // 退出极简 + await a.page.getByTitle('极简模式').click(); + await a.page.waitForTimeout(300); + await expect(a.page.getByTestId('tabbar')).toBeVisible(); + await a.app.close(); +}); + +// FR-W2:最大化按钮图标随状态切换 +test('FR-W2:最大化按钮图标切换', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-w2-')); + const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v'); + await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true }); + const a = await launch(ud); + await a.invoke('vault:switch', { root: v }); + const maxBtn = a.page.getByTestId('maximize-btn'); + // 初始可能未最大化 + const initialText = await maxBtn.textContent(); + // 点击最大化 + await maxBtn.click(); + await a.page.waitForTimeout(300); + const afterClickText = await maxBtn.textContent(); + // 图标应变化 + expect(initialText).not.toBe(afterClickText); + // 再点还原 + await maxBtn.click(); + await a.page.waitForTimeout(300); + const afterRestoreText = await maxBtn.textContent(); + expect(afterRestoreText).toBe(initialText); + await a.app.close(); +}); diff --git a/packages/shared/src/types/ipc.ts b/packages/shared/src/types/ipc.ts index ee53061..fa931f8 100644 --- a/packages/shared/src/types/ipc.ts +++ b/packages/shared/src/types/ipc.ts @@ -29,6 +29,7 @@ export const IPC = { graph: 'graph', winMinimize: 'win:minimize', winMaximize: 'win:maximize', + winIsMaximized: 'win:is-maximized', winClose: 'win:close', gpuInfo: 'gpu:info', copyMd: 'doc:copy-md', @@ -66,6 +67,7 @@ export interface IpcMap { [IPC.graph]: { args: void; result: GraphData }; [IPC.winMinimize]: { args: void; result: void }; [IPC.winMaximize]: { args: void; result: void }; + [IPC.winIsMaximized]: { args: void; result: boolean }; [IPC.winClose]: { args: void; result: void }; [IPC.gpuInfo]: { args: void; result: { softwareRendering: boolean; vendor: string; renderer: string } }; [IPC.copyMd]: { args: { path: string }; result: string };