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