feat(M8): FR-T4/T5/T6/W2/W6/W7 标签页/布局接线——固定标签悬停浮现关闭按钮(T4); tab-store updateScroll+restore恢复滚动位置(T5); tabbarVisible接线App+TitleBar切换按钮(T6/W6); winIsMaximized IPC+最大化/还原图标切换(W2); 极简模式隐藏TabBar(W7); layout-tabs E2E 3 项

This commit is contained in:
hunter-dev
2026-08-26 15:20:30 +08:00
parent 8f6fd7082b
commit 1f3ede9956
7 changed files with 128 additions and 19 deletions
+2
View File
@@ -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'));
+6 -3
View File
@@ -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() {
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', height: '100vh', background: 'var(--bg-primary, #1a1a1a)', color: '#ddd' }}>
{appearance.bgImage && <div style={{ position: 'absolute', inset: 0, backgroundImage: 'url(' + appearance.bgImage + ')', backgroundSize: 'cover', backgroundPosition: 'center', filter: settings.perfMode ? 'none' : 'blur(var(--blur-px,0px))', opacity: 0.45, zIndex: 0, pointerEvents: 'none' }} />}
<div style={{ position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', height: '100vh' }}>
<TitleBar view={view} onToggleView={() => setView(v => v === 'editor' ? 'graph' : 'editor')} onSettings={() => setShowSettings(true)} onAppearance={() => setShowAppearance(true)} onToggleSidebar={() => layout.toggleSidebar()} onToggleMinimal={() => layout.toggleMinimal()} minimal={layout.minimal} />
<TitleBar view={view} onToggleView={() => 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} />
<Toolbar />
{view === 'editor' && <TabBar />}
{view === 'editor' && layout.tabbarVisible && !layout.minimal && <TabBar />}
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{layout.minimal ? (<Editor />) : (<>
{layout.sidebarVisible && (<>
@@ -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 (
<div style={{ height: 36, display: 'flex', alignItems: 'center', background: 'var(--bg-secondary)', borderBottom: '1px solid var(--control-border)', WebkitAppRegion: 'drag', userSelect: 'none' } as React.CSSProperties}>
<div style={{ padding: '0 12px', color: 'var(--accent-text)', fontWeight: 600, fontSize: 13 }}>狩夜人笔记</div>
<div style={drag} />
<button title="侧边栏开关" onClick={onToggleSidebar} style={{ ...btn, fontSize: 14 }}>▤</button>
<button title="外观定制" onClick={onAppearance} style={{ ...btn, fontSize: 14 }}>🎨</button>
<button title={"侧边栏开关"} onClick={onToggleSidebar} style={{ ...btn, fontSize: 14 }}>▤</button>
<button title={"外观定制"} onClick={onAppearance} style={{ ...btn, fontSize: 14 }}>🎨</button>
<div style={{ display: 'flex', alignItems: 'center', gap: 2, padding: '0 6px', ...noDrag }}>
<button title="刷新" onClick={() => loadTree()} style={btn}>↻</button>
<button title="切换 编辑器/图谱" onClick={onToggleView} style={btn}>{view === 'editor' ? '🕸' : '✎'}</button>
<button title="极简模式" onClick={onToggleMinimal} style={btn}>{minimal ? '⛶' : '⊡'}</button>
<button title="设置" onClick={onSettings} style={btn}>⚙</button>
<button title={"刷新"} onClick={() => loadTree()} style={btn}>↻</button>
<button title={"切换 编辑器/图谱"} onClick={onToggleView} style={btn}>{view === 'editor' ? '🕸' : '✎'}</button>
<button title={tabbarVisible ? '隐藏标签栏' : '显示标签栏'} onClick={onToggleTabbar} style={btn} data-testid="tabbar-toggle">{tabbarVisible ? '▭' : '▯'}</button>
<button title={"极简模式"} onClick={onToggleMinimal} style={btn}>{minimal ? '⛶' : '⊡'}</button>
<button title={"设置"} onClick={onSettings} style={btn}>⚙</button>
<span style={{ width: 1, background: 'var(--control-border)', height: 16, margin: '0 4px' }} />
<button title="最小化" onClick={() => send(IPC.winMinimize, undefined as never)} style={btn}>—</button>
<button title="最大化/还原" onClick={() => send(IPC.winMaximize, undefined as never)} style={btn}>▢</button>
<button title="关闭" onMouseEnter={e => (e.currentTarget.style.background = '#e81123')} onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} onClick={() => send(IPC.winClose, undefined as never)} style={btn}>✕</button>
<button title={"最小化"} onClick={() => send(IPC.winMinimize, undefined as never)} style={btn}>—</button>
<button title={maximized ? '还原' : '最大化'} onClick={() => { send(IPC.winMaximize, undefined as never); setMaximized(m => !m); }} style={btn} data-testid="maximize-btn">{maximized ? '⧉' : '▢'}</button>
<button title={"关闭"} onMouseEnter={e => (e.currentTarget.style.background = '#e81123')} onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} onClick={() => send(IPC.winClose, undefined as never)} style={btn}>✕</button>
</div>
</div>
);
}
const btn: React.CSSProperties = { width: 36, height: 28, border: 'none', background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer', fontSize: 12 };
const btn: React.CSSProperties = { width: 36, height: 28, border: 'none', background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer', fontSize: 12 };
@@ -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<string | null>(null);
// 当 activeId 变化,加载对应标签的文档
useEffect(() => {
@@ -17,19 +18,21 @@ export function TabBar() {
<div data-testid="tabbar" style={{ display: 'flex', borderBottom: '1px solid var(--control-border)', background: 'var(--bg-secondary)', overflowX: 'auto' }}>
{tabs.map(t => (
<div key={t.id} onClick={() => 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 && <span style={{ color: 'var(--accent)' }}>★</span>}
<span>{t.path.split('/').pop()?.replace(/\.md$/, '')}</span>
{t.pinned ? (
<span style={{ color: 'var(--text-tertiary)', fontSize: 10 }}>📌</span>
// FR-T4:固定标签悬停时浮现关闭按钮
<span onClick={e => { 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</span>
) : (
<span onClick={e => { 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)')}>×</span>
<span onClick={e => { 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</span>
)}
<span onClick={e => { e.stopPropagation(); togglePin(t.id); }} style={{ color: t.pinned ? 'var(--accent)' : 'var(--text-tertiary)', cursor: 'pointer', fontSize: 10 }} title={t.pinned ? '取消固定' : '固定'}>●</span>
</div>
))}
</div>
);
}
}
@@ -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<TabState>((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);
@@ -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();
});