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:
@@ -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'));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user