perf(renderer): 整体优化响应速度 — rAF 合并编辑器刷新/自动保存缓存/粒子引擎空闲自停

同时: 不透明窗口 + 精简主题(8到2)、TabBar 简化、图谱宽松布局、MoreActions 层级修复
This commit is contained in:
hunter-dev
2026-09-10 00:41:24 +08:00
parent d0dbdfd00a
commit c0ba8df9b6
27 changed files with 417 additions and 178 deletions
+4
View File
@@ -18,3 +18,7 @@ test-results/
playwright-report/
apps/desktop/tests/e2e/.report/
release/
# stray temp dir from os.tmpdir() env quirk
apps/desktop/undefined/
+2 -2
View File
@@ -22,8 +22,8 @@ function createWindow() {
const w = new BrowserWindow({
width: 1200, height: 760, minWidth: 900, minHeight: 580,
center: true, frame: false, show: false,
transparent: true, backgroundColor: '#00000000', backgroundMaterial: 'none',
// 真透明:透明主题下窗口直接透出下层软件(不用 acrylic 模糊,保证可清晰看到背后内容)
transparent: false, backgroundColor: '#f6f7f9', backgroundMaterial: 'none',
// UI 重新设计:窗口不透明,backgroundColor 用 L0 空间底色,稳定呈现玻璃材质(玻璃透过的是窗口内的空间层)
webPreferences: {
preload: nodePath.join(__dirname, '../preload/index.mjs'),
contextIsolation: true, nodeIntegration: false, sandbox: false
+5 -4
View File
@@ -138,7 +138,8 @@ export function App() {
// 编辑器样式随设置生效(FR-编辑器设置真实生效)
useEffect(() => { document.documentElement.style.setProperty('--editor-font-size', settings.fontSize + 'px'); document.documentElement.style.setProperty('--editor-font-family', settings.fontFamily); document.documentElement.style.setProperty('--editor-white-space', settings.wordWrap ? 'pre-wrap' : 'pre'); }, [settings.fontSize, settings.fontFamily, settings.wordWrap]);
return (
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', height: '100vh', background: 'var(--bg-primary, #1a1a1a)', color: 'var(--text-primary)', border: focused ? '1px solid color-mix(in srgb, var(--control-border) 95%, transparent)' : '1px solid color-mix(in srgb, var(--control-border) 45%, transparent)' }}>
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', height: '100vh', background: 'var(--space)', color: 'var(--text-primary)', border: focused ? '1px solid color-mix(in srgb, var(--control-border) 95%, transparent)' : '1px solid color-mix(in srgb, var(--control-border) 45%, transparent)' }}>
<div className="ambient-bg" />
{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()} onToggleTabbar={() => layout.toggleTabbar()} minimal={layout.minimal} tabbarVisible={layout.tabbarVisible} />
@@ -147,7 +148,7 @@ export function App() {
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{layout.minimal ? (<Editor />) : (<>
{layout.sidebarVisible && (<>
<div style={{ width: layout.sidebarWidth, borderRight: '1px solid var(--control-border)', display: 'flex', flexDirection: 'column', overflow: 'hidden', flexShrink: 0 }}>
<div className="glass-side" style={{ width: layout.sidebarWidth, borderRight: '1px solid var(--line)', display: 'flex', flexDirection: 'column', overflow: 'hidden', flexShrink: 0 }}>
<SearchBox />
<div style={{ flex: 1, overflow: 'auto' }}><FileTree /></div>
<TagFilter />
@@ -157,8 +158,8 @@ export function App() {
{view === 'editor' ? <Editor /> : <GraphView onSwitchToEditor={switchToEditor} />}
{view === 'editor' && layout.outlineVisible && (<>
<Resizer side="right" onDrag={d => layout.setOutlineWidth(layout.outlineWidth + d)} />
<div style={{ width: layout.outlineWidth, borderLeft: '1px solid var(--control-border)', display: 'flex', flexDirection: 'column', overflow: 'hidden', flexShrink: 0 }}>
<div style={{ display: 'flex', borderBottom: '1px solid var(--control-border)' }}>
<div className="glass-side" style={{ width: layout.outlineWidth, borderLeft: '1px solid var(--line)', display: 'flex', flexDirection: 'column', overflow: 'hidden', flexShrink: 0 }}>
<div style={{ display: 'flex', borderBottom: '1px solid var(--line)' }}>
<button style={tabBtn(rightTab==='outline')} onClick={() => setRightTab('outline')}>大纲</button>
<button style={tabBtn(rightTab==='info')} onClick={() => setRightTab('info')}>信息</button>
</div>
@@ -18,7 +18,7 @@ export function AppearancePanel({ onClose }: { onClose: () => void }) {
const colors: [keyof typeof draft, string][] = [['accent','强调色'],['glow','辉光色'],['accentText','强调色文字'],['textPrimary','主要文字'],['textSecondary','辅助文字'],['textTertiary','弱化文字'],['controlBorder','控件边框'],['bgPrimary','工作区背景'],['bgSecondary','浮层背景']];
return (
<div onClick={e => { if (e.target === e.currentTarget) { revert(); onClose(); } }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1500 }}>
<div style={{ width: 620, maxHeight: '85vh', overflow: 'auto', background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', borderRadius: 10, padding: 20, boxShadow: '0 8px 32px rgba(0,0,0,0.5)' }}>
<div className="float panel-enter" style={{ width: 620, maxHeight: '85vh', overflow: 'auto', borderRadius: 'var(--radius-lg)', padding: 20, border: '1px solid var(--line)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<strong style={{ fontSize: 15 }}>外观定制</strong>
<div style={{ display: 'flex', gap: 8 }}>
@@ -23,7 +23,7 @@ interface AppearanceState {
isDirty: () => boolean;
}
const defaultTheme = THEMES[1]; // 午夜蓝为默认
const defaultTheme = THEMES[0]; // 亮色档为默认(UI 重新设计:默认亮色 + 暗色档)
// 降采样取像素(渲染层 canvas 可用)
function sampleImage(dataUrl: string, size = 48): Promise<ImageSample> {
@@ -1,4 +1,7 @@
// 8 套主题定义(FR-A1/A2)——CSS 变量驱动
// 两档主题(亮/暗)——克制的液态玻璃质感 · 黑白灰基调(UI 重新设计方案)
// 设计 Token 见 styles.css 与设计文档:
// L0 space / L1 glass / L2 paper / L3 float
// ink 墨色阶 / line 分隔线 / radius / shadow / ease
export interface ThemeParams {
accent: string; glow: string; accentText: string;
textPrimary: string; textSecondary: string; textTertiary: string;
@@ -8,21 +11,33 @@ export interface ThemeParams {
export interface ThemeDef { id: string; name: string; group: 'dark' | 'light'; desc: string; params: ThemeParams; }
export const THEMES: ThemeDef[] = [
{ id: 'transparent', name: '全透明', group: 'dark', desc: '窗口直接透出桌面', params: { accent:'#4a9eff', glow:'#6a8cff', accentText:'#7ab8ff', textPrimary:'#e8e8e8', textSecondary:'#b8b8c8', textTertiary:'#8a8a9a', controlBorder:'#333', bgPrimary:'transparent', bgSecondary:'rgba(20,20,28,0.5)', blurPx:20, surfaceOpacity:0.5, radius:8 } },
{ id: 'midnight', name: '午夜蓝', group: 'dark', desc: '深邃午夜蓝调', params: { accent:'#4a9eff', glow:'#6a8cff', accentText:'#7ab8ff', textPrimary:'#e8e8e8', textSecondary:'#b8b8c8', textTertiary:'#8a8a9a', controlBorder:'#2a3548', bgPrimary:'#0d1525', bgSecondary:'#141d30', blurPx:16, surfaceOpacity:0.6, radius:8 } },
{ id: 'purple-night', name: '深紫夜', group: 'dark', desc: '神秘紫夜', params: { accent:'#a06aff', glow:'#b88aff', accentText:'#c4a3ff', textPrimary:'#e8e8f0', textSecondary:'#b8b8c8', textTertiary:'#8a8a9a', controlBorder:'#2a2438', bgPrimary:'#1a1228', bgSecondary:'#241a35', blurPx:16, surfaceOpacity:0.6, radius:8 } },
{ id: 'forest', name: '森林绿', group: 'dark', desc: '静谧森林', params: { accent:'#5abf7a', glow:'#7ad99a', accentText:'#7ad99a', textPrimary:'#e8f0e8', textSecondary:'#b8c8b8', textTertiary:'#8a9a8a', controlBorder:'#243028', bgPrimary:'#102018', bgSecondary:'#1a2a22', blurPx:16, surfaceOpacity:0.6, radius:8 } },
{ id: 'amber', name: '暖琥珀', group: 'dark', desc: '温暖琥珀光', params: { accent:'#e0a040', glow:'#f0c060', accentText:'#f0c060', textPrimary:'#f0e8d8', textSecondary:'#c8c0b0', textTertiary:'#9a8a7a', controlBorder:'#3a3028', bgPrimary:'#1f1810', bgSecondary:'#2a2018', blurPx:16, surfaceOpacity:0.6, radius:8 } },
{ id: 'latte', name: '奶霜拿铁', group: 'light', desc: '柔润奶白', params: { accent:'#a06840', glow:'#c08860', accentText:'#8a5830', textPrimary:'#3a3028', textSecondary:'#6a5a4a', textTertiary:'#6a5a4a', controlBorder:'#d8c8b8', bgPrimary:'#f5efe6', bgSecondary:'#ede5d8', blurPx:10, surfaceOpacity:0.8, radius:8 } },
{ id: 'daylight', name: '晴空白昼', group: 'light', desc: '清爽天蓝', params: { accent:'#2070c0', glow:'#4090e0', accentText:'#1860a8', textPrimary:'#2a3a4a', textSecondary:'#4a5a6a', textTertiary:'#5a6a7a', controlBorder:'#c8d8e8', bgPrimary:'#f0f5fa', bgSecondary:'#e0eaf2', blurPx:10, surfaceOpacity:0.85, radius:8 } },
{ id: 'plain', name: '素纸简白', group: 'light', desc: '极简素纸', params: { accent:'#555', glow:'#888', accentText:'#333', textPrimary:'#222', textSecondary:'#555', textTertiary:'#666', controlBorder:'#ddd', bgPrimary:'#fff', bgSecondary:'#f5f5f5', blurPx:0, surfaceOpacity:0.9, radius:6 } },
{
id: 'light', name: '亮色', group: 'light', desc: '通透玻璃 · 亮色档(默认)',
params: {
accent: '#262a33', glow: '#3c414b', accentText: '#ffffff',
textPrimary: '#16181d', textSecondary: '#3c414b', textTertiary: '#6d7480',
controlBorder: 'rgba(20,22,28,0.10)', bgPrimary: '#eef0f4', bgSecondary: 'rgba(255,255,255,0.62)',
blurPx: 18, surfaceOpacity: 0.62, radius: 16,
},
},
{
id: 'dark', name: '暗色', group: 'dark', desc: '通透玻璃 · 暗色档',
params: {
accent: '#e6e9ef', glow: '#c8ccd4', accentText: '#16181d',
textPrimary: '#eef0f4', textSecondary: '#c8ccd4', textTertiary: '#8a909c',
controlBorder: 'rgba(255,255,255,0.10)', bgPrimary: '#131519', bgSecondary: 'rgba(255,255,255,0.06)',
blurPx: 18, surfaceOpacity: 0.5, radius: 16,
},
},
];
export function applyTheme(params: ThemeParams, group?: 'dark' | 'light') {
const root = document.documentElement;
const g = group ?? 'dark';
// 设置主题组(dark/light)供 CSS 浅色语法高亮等区分
root.setAttribute('data-theme-group', group ?? 'dark');
root.setAttribute('data-theme-group', g);
// 语义 token(组件沿用,收敛为黑白灰墨色阶)
root.style.setProperty('--accent', params.accent);
root.style.setProperty('--glow', params.glow);
root.style.setProperty('--accent-text', params.accentText);
@@ -35,12 +50,34 @@ export function applyTheme(params: ThemeParams, group?: 'dark' | 'light') {
root.style.setProperty('--blur-px', params.blurPx + 'px');
root.style.setProperty('--surface-opacity', String(params.surfaceOpacity));
root.style.setProperty('--radius', params.radius + 'px');
// ---- 设计 token(A档-4):圆角阶梯 + 间距,组件统一引用 ----
root.style.setProperty('--radius-sm', '4px'); // 输入框/小元素
root.style.setProperty('--radius-md', '6px'); // 按钮/菜单项
root.style.setProperty('--radius-lg', params.radius + 'px'); // 面板/卡片
// 圆角阶梯 + 间距(组件统一引用)
root.style.setProperty('--radius-sm', '10px');
root.style.setProperty('--radius-md', '12px');
root.style.setProperty('--radius-lg', params.radius + 'px');
root.style.setProperty('--space-1', '4px');
root.style.setProperty('--space-2', '8px');
root.style.setProperty('--space-3', '12px');
root.style.setProperty('--space-4', '16px');
// ---- 设计系统 Token(分层材质)----
const L = g === 'light';
const set = (k: string, v: string) => root.style.setProperty(k, v);
set('--space', L ? '#f6f7f9' : '#141519'); // L0 窗口底色(更白更高级)
// L0 环境氛围(Apple 一束顶光渐变),亮/暗两档
set('--ambient', L
? 'radial-gradient(120% 95% at 12% -10%, #ffffff 0%, rgba(255,255,255,0) 55%), radial-gradient(70% 60% at 100% 5%, #f7f8fa 0%, rgba(247,248,250,0) 55%), linear-gradient(168deg, #fafbfc 0%, #f2f4f6 100%)'
: 'radial-gradient(120% 95% at 12% -10%, #2b2e34 0%, rgba(43,46,52,0) 55%), radial-gradient(70% 60% at 100% 5%, #22252a 0%, rgba(34,37,42,0) 55%), linear-gradient(168deg, #191a1e 0%, #101114 100%)');
set('--glass-side', L ? 'rgba(255,255,255,0.55)' : 'rgba(255,255,255,0.06)'); // L1 侧栏
set('--glass-bar', L ? 'rgba(255,255,255,0.62)' : 'rgba(255,255,255,0.08)'); // L1 栏
set('--paper', L ? '#ffffff' : '#1b1d22'); // L2 纸(编辑正文)
set('--float', L ? 'rgba(255,255,255,0.98)' : '#23262c'); // L3 悬浮
set('--ink', L ? '#16181d' : '#eef0f4');
set('--ink-2', L ? '#3c414b' : '#c8ccd4');
set('--ink-3', L ? '#6d7480' : '#8a909c');
set('--ink-4', L ? '#9aa1ad' : '#5c616b');
set('--line', L ? 'rgba(20,22,28,0.10)' : 'rgba(255,255,255,0.10)');
set('--line-strong', L ? 'rgba(20,22,28,0.16)' : 'rgba(255,255,255,0.16)');
set('--ease', 'cubic-bezier(.22,.61,.36,1)');
set('--shadow-soft', L ? '0 1px 2px rgba(20,22,28,0.04), 0 4px 16px rgba(20,22,28,0.06)' : '0 1px 2px rgba(0,0,0,0.3), 0 4px 16px rgba(0,0,0,0.35)');
set('--shadow-lift', L ? '0 8px 32px rgba(20,22,28,0.14), 0 2px 8px rgba(20,22,28,0.08)' : '0 8px 32px rgba(0,0,0,0.5), 0 2px 8px rgba(0,0,0,0.35)');
}
@@ -17,7 +17,7 @@ export function InputDialog() {
return (
<div onClick={e => { if (e.target === e.currentTarget) close(null); }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div onKeyDown={e => { if (e.key === 'Escape') close(null); }} style={{ background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', borderRadius: 8, padding: 20, minWidth: 360, boxShadow: '0 8px 32px rgba(0,0,0,0.4)' }}>
<div onKeyDown={e => { if (e.key === 'Escape') close(null); }} className="float panel-enter" style={{ borderRadius: 'var(--radius-lg)', padding: 20, minWidth: 360, border: '1px solid var(--line)' }}>
<div style={{ marginBottom: 12, fontSize: 14, color: 'var(--text-primary)' }}>{title}</div>
{mode === 'confirm' || mode === 'alert' ? (
<div style={{ color: mode === 'confirm' ? '#e88' : 'var(--text-secondary)', fontSize: 13, marginBottom: 16, whiteSpace: 'pre-wrap' }}>{message}</div>
@@ -1,6 +1,6 @@
import { useNotify } from './notify-store';
export function NotifyLayer() { const { items, remove } = useNotify();
return <div style={{ position: 'fixed', top: 48, right: 20, zIndex: 3000, display: 'flex', flexDirection: 'column', gap: 8 }}>{items.map(n => (
<div key={n.id} onClick={() => remove(n.id)} style={{ padding: '10px 16px', borderRadius: 6, fontSize: 13, cursor: 'pointer', boxShadow: '0 4px 12px rgba(0,0,0,0.3)', background: n.type === 'success' ? '#2a4a3a' : n.type === 'error' ? '#4a2a2a' : 'var(--control-border)', color: n.type === 'success' ? '#9fc' : n.type === 'error' ? '#f88' : 'var(--accent-text)' }}>{n.msg}</div>
<div key={n.id} onClick={() => remove(n.id)} className="float panel-enter" style={{ padding: '10px 16px', borderRadius: 'var(--radius-md)', fontSize: 13, cursor: 'pointer', background: n.type === 'success' ? '#2a4a3a' : n.type === 'error' ? '#4a2a2a' : 'var(--float)', color: n.type === 'success' ? '#9fc' : n.type === 'error' ? '#f88' : 'var(--text-primary)' }}>{n.msg}</div>
))}</div>;
}
@@ -86,7 +86,7 @@ export function DocInfoPanel() {
{/* 快速双向关联 */}
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 10 }}>
<ArrowLeftRight size={14} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} title="快速双向关联" />
<ArrowLeftRight size={14} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} aria-label="快速双向关联" />
<select value={quickSel} onChange={e => setQuickSel(e.target.value)} style={{ ...inp, flex: 1 }}>
<option value="">选择文档,双向关联…</option>
{quickCandidates.map(d => (<option key={d.path} value={d.path}>{d.name}</option>))}
@@ -28,7 +28,7 @@ export function Editor() {
}
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ padding: '6px 16px', borderBottom: '1px solid var(--control-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="glass" style={{ position: 'relative', zIndex: 5, padding: '6px 16px', borderBottom: '1px solid var(--line)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{title} <span style={{ opacity: 0.4, fontSize: 12 }}>{currentPath}</span></span>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 12, opacity: saveState === 'error' ? 1 : 0.5, color: saveState === 'error' ? '#f88' : 'inherit' }}>{saveLabel(saveState)}</span>
@@ -212,11 +212,13 @@ export function TiptapEditor() {
prevLine = el;
} catch { /* 编辑器销毁等异常:忽略 */ }
};
const onChange = () => { adjust(); syncActiveLine(); };
editor.on('update', onChange);
editor.on('selectionUpdate', onChange);
const raf = requestAnimationFrame(syncActiveLine);
return () => { editor.off('update', onChange); editor.off('selectionUpdate', onChange); cancelAnimationFrame(raf); };
// 性能优化:adjust/syncActiveLine 都是滚动/视觉类更新,合并进单个 rAF,避免每次按键同步阻塞输入
let raf = 0;
const schedule = () => { if (!raf) raf = requestAnimationFrame(() => { raf = 0; adjust(); syncActiveLine(); }); };
editor.on('update', schedule);
editor.on('selectionUpdate', schedule);
const initRaf = requestAnimationFrame(syncActiveLine);
return () => { editor.off('update', schedule); editor.off('selectionUpdate', schedule); if (raf) cancelAnimationFrame(raf); cancelAnimationFrame(initRaf); };
}, [editor]);
// 监听保存前冲刷——Ctrl+S / 关窗前同步编辑器内容到 store
@@ -249,4 +251,4 @@ export function TiptapEditor() {
</div>
</EditorProvider>
);
}
}
@@ -7,6 +7,7 @@ export class EffectEngine {
private canvas: HTMLCanvasElement | null = null;
private ctx: CanvasRenderingContext2D | null = null;
private raf = 0;
private running = false;
private particles: P[] = [];
private lastMove = 0;
private lastX = 0; lastY = 0;
@@ -40,9 +41,15 @@ export class EffectEngine {
window.removeEventListener('resize', this.resizeBound);
cancelAnimationFrame(this.raf);
this.active = false;
this.running = false;
}
setType(t: EffectType) { this.type = t; }
setType(t: EffectType) {
const wasOff = this.type === 'off';
this.type = t;
if (t === 'off') { this.particles = []; this.active = false; }
else if (wasOff) this.ensureRunning();
}
setColor(c: string) { this.color = c; }
setIdleMs(ms: number) { this.idleMs = ms; }
// 降级:关闭特效
@@ -63,6 +70,7 @@ export class EffectEngine {
else if (t === 'meteor') { const dx = this.lastX - (this.lastX - 5); this.particles.push(this.mkP(dx*0.1, 0.1, 40)); }
else if (t === 'aurora') this.particles.push(this.mkP(0, 0, 80));
if (this.particles.length > 200) this.particles.splice(0, this.particles.length - 200);
this.ensureRunning();
}
private mkP(vx: number, vy: number, life: number): P {
@@ -74,14 +82,20 @@ export class EffectEngine {
const t = this.type === 'auto' ? 'ripple' : this.type;
if (t !== 'ripple') return;
for (let i = 0; i < 2; i++) this.particles.push({ x, y, vx: 0, vy: 0, life: 40, maxLife: 40, size: 2 + i*8 });
this.ensureRunning();
}
private ensureRunning() {
if (this.type !== 'off' && !this.running && this.canvas) this.loop();
}
private loop = () => {
this.raf = requestAnimationFrame(this.loop);
const ctx = this.ctx; if (!ctx) return;
this.running = true;
const ctx = this.ctx; if (!ctx) { this.running = false; return; }
const idle = performance.now() - this.lastMove > this.idleMs;
// 空闲暂停:无新粒子且空闲则停转
if (idle && this.particles.length === 0) { this.active = false; return; }
// 空闲暂停:关闭/空闲且无新粒子时完全停转(不再每帧唤醒),交互时由 ensureRunning 重启
if ((this.type === 'off' || idle) && this.particles.length === 0) { this.active = false; this.running = false; return; }
this.raf = requestAnimationFrame(this.loop);
this.active = true;
ctx.clearRect(0, 0, this.canvas!.width, this.canvas!.height);
ctx.globalAlpha = 1;
@@ -99,4 +113,4 @@ export class EffectEngine {
}
}
};
}
}
@@ -105,11 +105,14 @@ export function FileTree() {
const isRenaming = renaming === n.path;
const isHover = hover === n.path;
const isDragOver = dragOver === n.path;
const active = currentPath === n.path;
const itemIcon = active ? 'var(--space)' : 'var(--text-secondary)';
return (
<div key={n.path}>
<div style={{ padding: '3px 4px', ...pad, display: 'flex', alignItems: 'center', cursor: 'pointer',
background: currentPath === n.path ? 'var(--control-border)' : (isDragOver ? 'var(--control-border)' : 'transparent'),
borderRadius: 4, position: 'relative' }}
<div style={{ padding: '4px 4px', ...pad, display: 'flex', alignItems: 'center', cursor: 'pointer',
background: active ? 'var(--ink)' : (isDragOver ? 'color-mix(in srgb, var(--ink) 10%, transparent)' : (isHover ? 'color-mix(in srgb, var(--ink) 5%, transparent)' : 'transparent')),
color: active ? 'var(--space)' : 'inherit',
borderRadius: 8, position: 'relative', transition: 'background .14s var(--ease), color .14s var(--ease)' }}
onMouseEnter={() => setHover(n.path)} onMouseLeave={() => setHover(null)}
onClick={() => isRenaming ? null : (n.type === 'folder' ? toggle(n.path) : openDoc(n.path))}
onContextMenu={e => { e.preventDefault(); setMenu({ path: n.path, x: e.clientX, y: e.clientY }); }}
@@ -118,10 +121,10 @@ export function FileTree() {
onDragOver={e => { if (n.type === 'folder') { e.preventDefault(); setDragOver(n.path); } }}
onDragLeave={() => setDragOver(null)}
onDrop={e => n.type === 'folder' && onDrop(e, n.path)}>
<span style={{ width: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, color: 'var(--text-tertiary)' }}>
<span style={{ width: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, color: active ? 'var(--space)' : 'var(--text-tertiary)' }}>
{n.type === 'folder' ? (isCollapsed ? <ChevronRight size={13} /> : <ChevronDown size={13} />) : <span style={{ width: 16 }} />}
</span>
<span style={{ width: 18, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, color: 'var(--text-secondary)' }}>
<span style={{ width: 18, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, color: itemIcon }}>
{n.type === 'folder' ? (isCollapsed ? <Folder size={15} /> : <FolderOpen size={15} />) : <FileText size={15} />}
</span>
{isRenaming ? (
@@ -129,7 +132,7 @@ export function FileTree() {
onKeyDown={e => { if (e.key === 'Enter') { setRenaming(null); invoke(IPC.fileRename, { from: n.path, toName: renameVal }).then(r => { useTabs.getState().renamePath(n.path, r.path); syncCurrentPath(n.path, r.path); loadTree(); }); } if (e.key === 'Escape') setRenaming(null); }}
onBlur={() => { if (renameVal.trim() && renameVal !== n.name) { setRenaming(null); invoke(IPC.fileRename, { from: n.path, toName: renameVal }).then(r2 => { useTabs.getState().renamePath(n.path, r2.path); syncCurrentPath(n.path, r2.path); loadTree(); }).catch(() => {}); } else setRenaming(null); }} style={{ flex: 1, background: 'var(--bg-secondary)', border: '1px solid var(--accent)', color: 'var(--text-primary)', borderRadius: 3, padding: '1px 4px', fontSize: 13 }} />
) : (
<span style={{ marginLeft: 4, flex: 1 }}>{n.name}</span>
<span style={{ marginLeft: 4, flex: 1, color: active ? 'var(--space)' : 'var(--text-primary)' }}>{n.name}</span>
)}
{isHover && !isRenaming && (
<span style={{ display: 'flex', gap: 2 }} onClick={e => e.stopPropagation()}>
@@ -161,7 +164,7 @@ export function FileTree() {
</div>
)}
{menu && menuNode && (
<div style={{ position: 'fixed', left: menu.x, top: menu.y, background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', borderRadius: 6, padding: 4, zIndex: 2000, minWidth: 120 }} onClick={e => e.stopPropagation()}>
<div className="float panel-enter" style={{ position: 'fixed', left: menu.x, top: menu.y, borderRadius: 'var(--radius-md)', padding: 4, zIndex: 2000, minWidth: 120, border: '1px solid var(--line)' }} onClick={e => e.stopPropagation()}>
{menuNode.type === 'doc' && <MenuItem onClick={() => { openDoc(menuNode.path); setMenu(null); }}>打开</MenuItem>}
{menuNode.type === 'folder' && <>
<MenuItem onClick={() => { doCreate(menuNode.path, false); setMenu(null); }}>新建文档</MenuItem>
@@ -16,8 +16,12 @@ const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
type NodeType = 'root' | 'folder' | 'doc';
// 节点图标:根=库 / 文件夹=文件夹 / 文档=文件
const ICONS: Record<NodeType, string> = { root: '🗂', folder: '📁', doc: '📄' };
// 节点图标(统一线条图标,stroke-width 1.6,无表情符号)
const ICONS: Record<NodeType, string> = {
root: '<svg class="mm-ico" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5z"/><path d="M2 12l10 5 10-5"/><path d="M2 17l10 5 10-5"/></svg>',
folder: '<svg class="mm-ico" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/></svg>',
doc: '<svg class="mm-ico" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>',
};
function toMarkmapTree(nodes: TreeNode[]): PureNode {
const byParent = new Map<string, TreeNode[]>();
@@ -47,20 +51,37 @@ const MARKMAP_OPTIONS = {
paddingX: 12,
spacingX: 90,
spacingY: 14,
color: (node: { payload?: Record<string, unknown> }) => {
const t = node.payload?.type as NodeType | undefined;
if (t === 'doc') return '#9aa0aa';
if (t === 'folder') return '#5db0ff';
return 'var(--accent)';
},
// 黑白灰:统一中性色连线条/节点点
color: () => '#9aa1ad',
};
function MindmapTree({ onSwitchToEditor }: { onSwitchToEditor?: () => void }) {
const svgRef = useRef<SVGSVGElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const mmRef = useRef<Markmap | null>(null);
const tree = useStore(s => s.tree);
const openDoc = useStore(s => s.openDoc);
// 渲染时机修复:容器隐藏时 clientWidth=0,尺寸就绪后再 fit 一次
useEffect(() => {
const wrap = wrapRef.current;
if (!wrap) return;
const prev = { w: 0, h: 0 };
const ro = new ResizeObserver(entries => {
for (const entry of entries) {
const w = Math.round(entry.contentRect.width);
const h = Math.round(entry.contentRect.height);
if (w > 0 && h > 0 && (w !== prev.w || h !== prev.h)) {
prev.w = w; prev.h = h;
try { mmRef.current?.fit(); } catch {}
}
}
});
ro.observe(wrap);
requestAnimationFrame(() => { try { mmRef.current?.fit(); } catch {} });
return () => ro.disconnect();
}, []);
useEffect(() => {
const svg = svgRef.current;
if (!svg) return;
@@ -103,37 +124,35 @@ function MindmapTree({ onSwitchToEditor }: { onSwitchToEditor?: () => void }) {
}, [tree]);
return (
<div className="markmap-dark" style={{ flex: 1, background: 'var(--bg-secondary)', position: 'relative', overflow: 'hidden' }}>
<div ref={wrapRef} className="markmap-dark" style={{ flex: 1, background: 'transparent', position: 'relative', overflow: 'hidden' }}>
<svg ref={svgRef} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
<style>{String.raw`
.markmap-foreign { color: var(--text-primary); font-size: 13px; line-height: 1.4; }
.mm-pill { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; padding: 3px 11px; border-radius: 999px; border: 1px solid transparent; font-size: 13px; }
.mm-ico { font-size: 13px; opacity: 0.9; line-height: 1; }
.mm-pill { display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; padding: 7px 15px; border-radius: 999px; border: 1px solid var(--line-strong); font-size: 13px; background: var(--float); box-shadow: var(--shadow-soft); transition: box-shadow .16s var(--ease), border-color .16s var(--ease), transform .16s var(--ease); }
.mm-ico { opacity: 0.85; flex-shrink: 0; }
.mm-name { font-weight: 500; }
.mm-root { background: color-mix(in srgb, var(--accent) 18%, transparent); border-color: color-mix(in srgb, var(--accent) 45%, transparent); }
.mm-root .mm-name { font-weight: 700; color: var(--accent-text); }
.mm-folder { background: color-mix(in srgb, #5db0ff 14%, transparent); border-color: color-mix(in srgb, #5db0ff 38%, transparent); }
.mm-folder .mm-name { font-weight: 600; color: #7fc0ff; }
.mm-doc { background: color-mix(in srgb, var(--text-secondary) 8%, transparent); border-color: color-mix(in srgb, var(--control-border) 70%, transparent); }
.mm-doc .mm-name { color: var(--text-secondary); font-weight: 400; }
.mm-badge { min-width: 15px; height: 15px; padding: 0 4px; border-radius: 999px; background: color-mix(in srgb, var(--accent) 60%, transparent); color: #fff; font-size: 10px; line-height: 15px; text-align: center; font-weight: 700; }
.mm-doc, .mm-folder, .mm-root { cursor: pointer; transition: background .12s ease, border-color .12s ease, transform .12s ease; }
.mm-pill:hover { filter: brightness(1.15); }
.mm-doc:hover .mm-name { color: var(--text-primary); }
.mm-root { background: var(--ink); border-color: var(--ink); box-shadow: var(--shadow-lift); }
.mm-root .mm-name { font-weight: 650; font-size: 14px; color: var(--space); }
.mm-folder .mm-name { font-weight: 550; color: var(--ink); }
.mm-doc .mm-name { font-weight: 400; color: var(--ink-2); }
.mm-badge { min-width: 16px; height: 16px; padding: 0 5px; border-radius: 999px; background: var(--ink); color: var(--space); font-size: 10px; line-height: 16px; text-align: center; font-weight: 600; }
.mm-doc, .mm-folder, .mm-root { cursor: pointer; }
.mm-pill:hover { transform: translateY(-1px); box-shadow: var(--shadow-lift); border-color: var(--line-strong); }
.mm-doc:hover .mm-name { color: var(--ink); }
`}</style>
</div>
);
}
function tabBtn(active: boolean): React.CSSProperties {
return { padding: '3px 10px', fontSize: 12, background: active ? 'var(--control-border)' : 'transparent', color: active ? 'var(--text-primary)' : 'var(--text-tertiary)', border: '1px solid var(--control-border)', borderRadius: 4, cursor: 'pointer' };
return { padding: '8px 18px', fontSize: 13, background: active ? 'var(--ink)' : 'transparent', color: active ? 'var(--space)' : 'var(--ink-2)', border: 'none', borderRadius: 9, cursor: 'pointer', boxShadow: active ? '0 1px 4px rgba(20,22,28,0.22)' : 'none', transition: 'background .16s var(--ease), color .16s var(--ease)' };
}
export function GraphView({ onSwitchToEditor }: { onSwitchToEditor?: () => void }) {
const [mode, setMode] = useState<'mindmap' | 'graph'>('mindmap');
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', position: 'relative', overflow: 'hidden', background: 'var(--bg-secondary)' }}>
<div style={{ position: 'absolute', top: 8, left: 8, zIndex: 10, display: 'flex', gap: 4 }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', position: 'relative', overflow: 'hidden', background: 'var(--space)' }}>
<div style={{ position: 'absolute', top: 14, left: 14, zIndex: 10, display: 'flex', gap: 6 }}>
<button style={tabBtn(mode === 'mindmap')} onClick={() => setMode('mindmap')}>思维导图</button>
<button style={tabBtn(mode === 'graph')} onClick={() => setMode('graph')}>知识图谱</button>
</div>
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { select } from 'd3-selection';
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, type SimulationNodeDatum } from 'd3-force';
import { zoom } from 'd3-zoom';
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY, type SimulationNodeDatum } from 'd3-force';
import { zoom, zoomIdentity } from 'd3-zoom';
import { useStore } from '../../store';
import { IPC, type GraphData, type GraphNode } from '@hunter/shared';
@@ -17,6 +17,30 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void
const [data, setData] = useState<GraphData | null>(null);
const [err, setErr] = useState<string | null>(null);
const zoomRef = useRef<{ zoomBeh: any; svg: any; g: any } | null>(null);
// 渲染时机修复:容器隐藏时 clientWidth=0,用 ResizeObserver 等到拿到真实尺寸后再重渲染
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
useEffect(() => {
const el = svgRef.current;
if (!el) return;
const prev = { w: 0, h: 0 };
const ro = new ResizeObserver(entries => {
for (const entry of entries) {
const w = Math.round(entry.contentRect.width);
const h = Math.round(entry.contentRect.height);
if (w > 0 && h > 0 && (w !== prev.w || h !== prev.h)) {
prev.w = w; prev.h = h;
setDims({ w, h });
}
}
});
ro.observe(el);
// 首帧补偿:布局在首帧后才稳定
requestAnimationFrame(() => {
const w = Math.round(el.clientWidth); const h = Math.round(el.clientHeight);
if (w > 0 && h > 0) setDims({ w, h });
});
return () => ro.disconnect();
}, []);
useEffect(() => {
let alive = true;
@@ -40,63 +64,88 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void
const defs = svg.append('defs');
// 卡片投影(提升层次感)
const filter = defs.append('filter').attr('id', 'graph-card-shadow').attr('x', '-30%').attr('y', '-30%').attr('width', '160%').attr('height', '160%');
filter.append('feDropShadow').attr('dx', 0).attr('dy', 2).attr('stdDeviation', 4).attr('flood-color', '#000').attr('flood-opacity', 0.28);
filter.append('feDropShadow').attr('dx', 0).attr('dy', 2).attr('stdDeviation', 4).attr('flood-color', '#000').attr('flood-opacity', 0.22);
// 深墨焦点节点的投影(更强,突出层次)
const ffilter = defs.append('filter').attr('id', 'graph-card-focus').attr('x', '-40%').attr('y', '-40%').attr('width', '180%').attr('height', '180%');
ffilter.append('feDropShadow').attr('dx', 0).attr('dy', 4).attr('stdDeviation', 8).attr('flood-color', '#000').attr('flood-opacity', 0.35);
// 背景 rect 必须先 append:若在卡片 g 之后 append,bgRect 的 pointer-events:all 会盖住卡片拦截点击
const bgRect = svg.append('rect').attr('width', W).attr('height', H).attr('fill', 'transparent').attr('pointer-events', 'all');
const g = svg.append('g');
// FR-G4:scaleExtent [0.1, 2];zoom 绑定在背景 rect 上,不拦截卡片点击
// FR-G4:scaleExtent [0.1, 2];zoom 绑定在背景 rect 上,不拦截卡片点击(卡片 g 在 bgRect 之上)
const zoomBeh = zoom<SVGSVGElement, unknown>().scaleExtent([0.1, 2]).on('zoom', (ev: any) => g.attr('transform', ev.transform));
const bgRect = svg.append('rect').attr('width', W).attr('height', H).attr('fill', 'transparent').attr('pointer-events', 'all');
(bgRect as any).call(zoomBeh);
zoomRef.current = { zoomBeh, svg, g };
const n = Math.max(1, data.nodes.length);
const nodes: SimNode[] = data.nodes.map((node, i) => ({
...node,
x: W / 2 + Math.cos((2 * Math.PI * i) / n) * 230,
y: H / 2 + Math.sin((2 * Math.PI * i) / n) * 230,
}));
// 初始位置:按节点数撑开成螺旋,避免全部挤在圆心
const R = 150 + n * 26; // 节点越多,初始散布半径越大
const nodes: SimNode[] = data.nodes.map((node, i) => {
const ang = (2 * Math.PI * i) / Math.max(1, n) + (n > 1 ? (Math.random() - 0.5) * 0.35 : 0);
const rad = R * (0.55 + 0.45 * ((i % 3) / 3)); // 内外圈交错,更自然
return { ...node, x: W / 2 + Math.cos(ang) * rad, y: H / 2 + Math.sin(ang) * rad };
});
const idx = new Map(nodes.map((nd) => [nd.id, nd] as const));
const links = data.edges
.filter((e) => idx.has(e.source) && idx.has(e.target))
.map((e) => ({ source: idx.get(e.source)!, target: idx.get(e.target)! }));
const linkSel = g.append('g').attr('stroke', 'var(--control-border)').attr('stroke-opacity', 0.55)
.selectAll('line').data(links).join('line').attr('stroke-width', 1.2);
// 曲线连线(贝塞尔弧):比直线更优雅、更符合"图谱"观感
const linkSel = g.append('g').attr('stroke', 'var(--line)').attr('stroke-opacity', 0.6)
.selectAll('path').data(links).join('path').attr('fill', 'none').attr('stroke-width', 1.1);
// 布局参数:刻意拉大间距,卡片(180 宽)之间留足空白,避免拥挤
const sim = forceSimulation<SimNode>(nodes)
.force('link', (forceLink as any)(links).id((d: any) => d.id).distance(140).strength(0.2))
.force('charge', forceManyBody().strength(-360))
.force('link', (forceLink as any)(links).id((d: any) => d.id).distance(175).strength(0.16))
.force('charge', forceManyBody().strength(-300).distanceMax(440))
.force('center', forceCenter(W / 2, H / 2))
.force('collide', forceCollide(105));
.force('collide', forceCollide(CARD_W / 2 + 16).iterations(3))
// 向心约束:纵向比横向稍强,让整图在横屏视口里铺得更宽、更扁平(更好适配,也更稳)
.force('x', forceX(W / 2).strength(0.07))
.force('y', forceY(H / 2).strength(0.14))
.alphaDecay(0.02).velocityDecay(0.38);
const nodeSel = g.append('g').selectAll('g').data(nodes).join('g').attr('cursor', 'pointer');
// 曲线连线:每根线用固定弯曲符号(按源+目标哈希),不随帧抖动、多线不重叠
const linkPath = (d: any) => {
const sx = d.source.x, sy = d.source.y, tx = d.target.x, ty = d.target.y;
const dx = tx - sx, dy = ty - sy;
const mx = sx + dx / 2, my = sy + dy / 2;
const key = String(d.source.id) + '>' + String(d.target.id);
let h = 7; for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0;
const dir = h % 2 === 0 ? 1 : -1;
const bend = 18 + (h % 14);
const nx = -dy, ny = dx;
const len = Math.max(1, Math.hypot(dx, dy));
return 'M' + sx + ',' + sy + ' Q' + (mx + (nx / len) * bend * dir) + ',' + (my + (ny / len) * bend * dir) + ' ' + tx + ',' + ty;
};
// 当前打开的文档作为深墨焦点节点
const curPath = useStore.getState().currentPath;
const nodeSel = g.append('g').selectAll('g').data(nodes).join('g').attr('cursor', 'pointer')
// 阴影 filter 加在 g 层:若加在卡片 rect 上会导致该 rect 的 pointer-events 命中失效(点击穿透)
.attr('filter', (d: any) => d.id === curPath ? 'url(#graph-card-focus)' : 'url(#graph-card-shadow)');
// 卡片:圆角底卡 + 左色条 + 文件图标 + 标题 + 路径 + 星标
nodeSel.append('rect').attr('class', 'gcard').attr('x', -CARD_W / 2).attr('y', -CARD_H / 2).attr('width', CARD_W).attr('height', CARD_H).attr('rx', 9)
.attr('fill', 'var(--bg-secondary)').attr('stroke', (d) => d.color || 'var(--control-border)').attr('stroke-width', 1.4)
.attr('filter', 'url(#graph-card-shadow)');
// 左色条(圆角左侧)
nodeSel.append('rect').attr('x', -CARD_W / 2).attr('y', -CARD_H / 2 + 4).attr('width', 4).attr('height', CARD_H - 8).attr('rx', 2).attr('fill', (d) => d.color || '#5db0ff');
// 文件图标(lucide file 路径,不占 rect)
nodeSel.append('path')
.attr('d', 'M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z')
.attr('transform', 'translate(' + (-CARD_W / 2 + 18) + ',' + (-CARD_H / 2 + 8) + ') scale(1)')
.attr('fill', 'none').attr('stroke', (d) => d.color || '#5db0ff').attr('stroke-width', 1.8).attr('stroke-linejoin', 'round');
nodeSel.append('path')
.attr('d', 'M13 2v6h6')
.attr('transform', 'translate(' + (-CARD_W / 2 + 18) + ',' + (-CARD_H / 2 + 8) + ')')
.attr('fill', 'none').attr('stroke', (d) => d.color || '#5db0ff').attr('stroke-width', 1.8).attr('stroke-linejoin', 'round');
// 标题
nodeSel.append('text').attr('x', -CARD_W / 2 + 34).attr('y', -4).attr('fill', 'var(--text-primary)').attr('font-size', 13).attr('font-weight', 600).attr('font-family', 'sans-serif')
.text((d) => truncate(d.title, 15));
// 路径副文本
nodeSel.append('text').attr('x', -CARD_W / 2 + 34).attr('y', 13).attr('fill', 'var(--text-tertiary)').attr('font-size', 9).attr('font-family', 'sans-serif')
.text((d) => truncate(d.basePath || d.id, 16));
// 星标
nodeSel.filter((d) => d.star).append('text').attr('x', CARD_W / 2 - 14).attr('y', 4).attr('fill', '#f5c518').attr('font-size', 14).text('★');
// 卡片:圆角底卡 + 标题 + 路径 + 星标(无图标,更简洁);当前文档 = 深墨焦点节点,文字居中
nodeSel.append('rect').attr('class', 'gcard').attr('x', -CARD_W / 2).attr('y', -CARD_H / 2).attr('width', CARD_W).attr('height', CARD_H).attr('rx', 13)
.attr('fill', (d: any) => d.id === curPath ? 'var(--ink)' : 'var(--float)')
.attr('stroke', (d: any) => d.id === curPath ? 'var(--ink)' : 'var(--line)')
.attr('stroke-width', 1.2)
.attr('pointer-events', 'all');
// 标题(水平居中)
nodeSel.append('text').attr('x', 0).attr('y', -2).attr('text-anchor', 'middle')
.attr('fill', (d: any) => d.id === curPath ? 'var(--space)' : 'var(--ink)')
.attr('font-size', 13).attr('font-weight', (d: any) => d.id === curPath ? 600 : 550).attr('font-family', 'sans-serif')
.text((d) => truncate(d.title, 16));
// 路径副文本(水平居中)
nodeSel.append('text').attr('x', 0).attr('y', 13).attr('text-anchor', 'middle')
.attr('fill', (d: any) => d.id === curPath ? 'rgba(255,255,255,0.7)' : 'var(--ink-4)')
.attr('font-size', 10).attr('font-family', 'sans-serif')
.text((d) => truncate(d.basePath || d.id, 18));
// 星标(右上角)
nodeSel.filter((d) => d.star).append('text').attr('x', CARD_W / 2 - 14).attr('y', 4)
.attr('fill', (d: any) => d.id === curPath ? 'var(--space)' : 'var(--ink-3)').attr('font-size', 14).text('★');
// 悬停高亮(CSS,不增加 rect)
svg.append('style').text('.gcard { transition: stroke-width .12s ease; } g[data-hover] .gcard { stroke-width: 2.2; }');
svg.append('style').text('.gcard { transition: stroke-width .16s var(--ease), stroke .16s var(--ease); } g[data-hover] .gcard { stroke: var(--line-strong); stroke-width: 1.7; }');
nodeSel.attr('data-doc-path', (d: any) => d.id || '')
.on('mouseenter', function (_ev: any, d: any) { (this as Element).setAttribute('data-hover', '1'); })
.on('mouseleave', function (_ev: any, d: any) { (this as Element).removeAttribute('data-hover'); })
@@ -111,12 +160,35 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void
});
sim.on('tick', () => {
linkSel.attr('x1', (d: any) => d.source.x).attr('y1', (d: any) => d.source.y).attr('x2', (d: any) => d.target.x).attr('y2', (d: any) => d.target.y);
linkSel.attr('d', linkPath);
nodeSel.attr('transform', (d: any) => 'translate(' + d.x + ',' + d.y + ')');
});
return () => { sim.stop(); (bgRect as any).on('.zoom', null); };
}, [data, openDoc, onSwitchToEditor]);
// 首帧自动适配视图:等布局稳定后整体框入可视区(带留白),避免节点被裁出画面
let autoFitTimer: any = null;
const doAutoFit = () => {
if (autoFitTimer) return;
autoFitTimer = setTimeout(() => {
autoFitTimer = null;
if (!zoomRef.current || !data) return;
const { svg, zoomBeh } = zoomRef.current;
const svgEl = svgRef.current;
if (!svgEl) return;
const VW = svgEl.clientWidth || 800, VH = svgEl.clientHeight || 600;
const xs = nodes.map(n => n.x || 0), ys = nodes.map(n => n.y || 0);
const minX = Math.min(...xs) - CARD_W / 2, maxX = Math.max(...xs) + CARD_W / 2;
const minY = Math.min(...ys) - CARD_H / 2, maxY = Math.max(...ys) + CARD_H / 2;
const dx = Math.max(1, maxX - minX), dy = Math.max(1, maxY - minY);
const pad = 60; // 四周留白
const scale = Math.max(0.5, Math.min(1.15, Math.min((VW - pad * 2) / dx, (VH - pad * 2) / dy)));
const tx = VW / 2 - scale * (minX + dx / 2), ty = VH / 2 - scale * (minY + dy / 2);
(svg as any).transition().duration(500).call(zoomBeh.transform, zoomIdentity.translate(tx, ty).scale(scale));
}, 650);
};
sim.on('end', doAutoFit);
// 兜底:即使 alpha 未完全归零也适配一次
const fallbackTimer = setTimeout(doAutoFit, 1600);
return () => { sim.stop(); (bgRect as any).on('.zoom', null); if (autoFitTimer) clearTimeout(autoFitTimer); clearTimeout(fallbackTimer); };
}, [data, openDoc, onSwitchToEditor, dims]);
// FR-G4:适配视图 + 缩放控制
const fitView = () => {
@@ -130,7 +202,7 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void
const dx = maxX - minX, dy = maxY - minY;
const scale = Math.min(2, Math.max(0.1, Math.min(W / Math.max(dx, 1), H / Math.max(dy, 1)) * 0.9));
const tx = W / 2 - scale * (minX + dx / 2), ty = H / 2 - scale * (minY + dy / 2);
(svg as any).transition().duration(400).call(zoomBeh.transform, (window as any).d3?.zoomIdentity?.translate(tx, ty).scale(scale) || { k: scale, x: tx, y: ty });
(svg as any).transition().duration(400).call(zoomBeh.transform, zoomIdentity.translate(tx, ty).scale(scale));
};
const zoomBy = (factor: number) => {
if (!zoomRef.current) return;
@@ -139,7 +211,7 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void
};
return (
<div style={{ flex: 1, background: 'var(--bg-secondary)', position: 'relative', overflow: 'hidden' }}>
<div style={{ flex: 1, background: 'transparent', position: 'relative', overflow: 'hidden' }}>
<svg data-testid="graph-canvas" ref={svgRef} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
<div data-testid="graph-controls" style={{ position: 'absolute', bottom: 12, right: 12, display: 'flex', flexDirection: 'column', gap: 4, zIndex: 10 }}>
<button title="放大" onClick={() => zoomBy(1.3)} style={ctrlBtn}>+</button>
@@ -22,10 +22,11 @@ export function SearchBox() {
}, [q]);
return (
<div style={{ padding: 8, borderBottom: '1px solid var(--control-border)' }}>
<div style={{ display: 'flex', gap: 4 }}>
<input value={q} onChange={e => setQ(e.target.value)} placeholder="搜索(Ctrl+Shift+F)" style={{ flex: 1, background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', color: 'var(--text-primary)', borderRadius: 4, padding: '4px 8px', fontSize: 13 }} />
{q && <button onClick={() => setQ('')} style={{ background: 'none', border: 'none', color: 'var(--text-tertiary)', cursor: 'pointer' }}>×</button>}
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--line)' }}>
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" style={{ position: 'absolute', left: 10, color: 'var(--ink-4)', pointerEvents: 'none' }}><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input value={q} onChange={e => setQ(e.target.value)} placeholder="搜索" style={{ width: '100%', background: 'color-mix(in srgb, var(--ink) 6%, transparent)', border: '1px solid var(--line)', color: 'var(--ink)', borderRadius: 9, padding: '7px 10px 7px 30px', fontSize: 13, boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.05)', outline: 'none' }} />
{q && <button onClick={() => setQ('')} style={{ position: 'absolute', right: 8, background: 'none', border: 'none', color: 'var(--ink-4)', cursor: 'pointer' }}>×</button>}
</div>
{searching && <div style={{ opacity: 0.5, fontSize: 12, marginTop: 4 }}>搜索中…</div>}
{q && !searching && hits.length === 0 && <div style={{ opacity: 0.5, fontSize: 12, marginTop: 4 }}>没有匹配</div>}
@@ -47,7 +47,7 @@ export function SettingsPanel({ onClose }: { onClose: () => void }) {
}, [recId, onClose]);
return (
<div onClick={e => { if (e.target === e.currentTarget) onClose(); }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1500 }}>
<div className="panel-slide-in" style={{ width: 520, maxHeight: '80vh', overflow: 'auto', background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', borderRadius: 10, padding: 20, boxShadow: '0 8px 32px rgba(0,0,0,0.5)' }}>
<div className="float panel-enter" style={{ width: 520, maxHeight: '80vh', overflow: 'auto', borderRadius: 'var(--radius-lg)', padding: 20, border: '1px solid var(--line)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<strong style={{ fontSize: 15 }}>设置</strong>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--text-tertiary)', cursor: 'pointer', fontSize: 16 }}>✕</button>
@@ -12,7 +12,7 @@ export function StatusBar({ view }: { view: 'editor' | 'graph' }) {
const saveText = saveState === 'saving' ? '保存中…' : saveState === 'error' ? '保存失败' : '已保存';
const saveDot = saveState === 'error' ? '#e06c75' : saveState === 'saving' ? 'var(--accent)' : 'var(--text-tertiary)';
return (
<div style={{ height: 24, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 12px', background: 'var(--bg-secondary)', borderTop: '1px solid var(--control-border)', fontSize: 11, color: 'var(--text-tertiary)', userSelect: 'none', flexShrink: 0 }}>
<div className="glass" style={{ height: 24, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 12px', borderTop: '1px solid var(--line)', fontSize: 11, color: 'var(--text-tertiary)', userSelect: 'none', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{currentPath ? <span>{chars} 字符 · {linesCount} 行</span> : <span style={{ opacity: 0.5 }}>—</span>}
</div>
@@ -16,7 +16,7 @@ export function TitleBar({ onToggleView, view, onSettings, onAppearance, onToggl
const docName = currentPath ? decodeURIComponent(currentPath.split('/').pop() || '') : '';
const vaultName = vault?.currentRoot ? vault.currentRoot.split(/[\\/]/).filter(Boolean).pop() || vault.currentRoot : '';
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 className="glass" style={{ height: 36, display: 'flex', alignItems: 'center', borderBottom: '1px solid var(--line)', WebkitAppRegion: 'drag', userSelect: 'none' } as React.CSSProperties}>
{/* 品牌区:双击最大化 */}
<div style={{ padding: '0 12px 0 14px', display: 'flex', alignItems: 'center', gap: 8, ...noDrag }} onDoubleClick={toggleMaximize} title="狩夜人笔记(双击最大化)">
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--accent)', boxShadow: '0 0 6px var(--glow)', display: 'inline-block' }} />
@@ -1,13 +1,12 @@
import { useTabs } from './tab-store';
import { useStore } from '../../store';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
export function TabBar() {
const { tabs, activeId, setActive, closeTab, togglePin } = useTabs();
const { tabs, activeId, setActive, closeTab } = useTabs();
const openDoc = useStore(s => s.openDoc);
const currentPath = useStore(s => s.currentPath);
const resetEditor = useStore(s => s.resetEditor);
const [hoveredTab, setHoveredTab] = useState<string | null>(null);
// 当 activeId 变化,加载对应标签的文档;标签全部关闭时清空编辑器
useEffect(() => {
@@ -18,24 +17,16 @@ export function TabBar() {
if (tabs.length === 0) return null;
return (
<div data-testid="tabbar" style={{ display: 'flex', borderBottom: '1px solid var(--control-border)', background: 'var(--bg-secondary)', overflowX: 'auto' }}>
<div data-testid="tabbar" className="glass" style={{ display: 'flex', borderBottom: '1px solid var(--line)', 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 ? (
// 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)')}>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>
<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>
</div>
))}
</div>
);
}
}
@@ -49,17 +49,17 @@ export function Toolbar() {
alert2(r.ok.length ? `导入成功 ${r.ok.length} 项` : '未选择文件');
};
return (
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--control-border)', display: 'flex', gap: 8, background: 'var(--bg-secondary)' }}>
<div className="glass" style={{ padding: '6px 12px', borderBottom: '1px solid var(--line)', display: 'flex', gap: 8, zIndex: 30 }}>
<button onClick={newDoc} style={btn}><FilePlus size={15} /><span>新建文档</span></button>
<button onClick={newFolder} style={btn}><FolderPlus size={15} /><span>新建文件夹</span></button>
<button onClick={importFiles} style={btn}><Import size={15} /><span>导入</span></button>
<button onClick={switchVault} style={btn}><FolderSync size={15} /><span>切换库</span></button>
{/* FR-V4:最近库下拉快速切换 */}
{vault?.recents && vault.recents.length > 1 && (
{vault?.recents && vault.recents.length >= 1 && (
<div style={{ position: 'relative' }}>
<button onClick={() => setRecentsOpen(o => !o)} style={btn} data-testid="recents-toggle"><FolderSync size={15} /><span>最近库</span><ChevronDown size={13} /></button>
{recentsOpen && (
<div style={{ position: 'absolute', left: 0, top: 30, background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', borderRadius: 6, padding: 4, zIndex: 500, minWidth: 200, boxShadow: '0 4px 12px rgba(0,0,0,0.3)' }} onMouseLeave={() => setRecentsOpen(false)}>
<div className="float panel-enter" style={{ position: 'absolute', left: 0, top: 30, borderRadius: 'var(--radius-md)', padding: 4, zIndex: 500, minWidth: 200, border: '1px solid var(--line)' }} onMouseLeave={() => setRecentsOpen(false)}>
{vault.recents.map(r => (
<div key={r} onClick={async () => { setRecentsOpen(false); await useStore.getState().saveNow(); await invoke(IPC.vaultSwitch, { root: r }); useTabs.getState().closeAll(); useStore.getState().resetEditor(); await loadVault(); await loadTree(); }}
style={{ padding: '6px 10px', cursor: 'pointer', borderRadius: 4, fontSize: 12, color: r === vault.currentRoot ? 'var(--accent)' : 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
+3 -2
View File
@@ -3,6 +3,7 @@ import { IPC } from '@hunter/shared';
import type { TreeNode, VaultMeta, DocMeta, ReadDocResult } from '@hunter/shared';
import { invoke } from './ipc-client';
import { useTabs } from './features/tabs/tab-store';
import { useSettings } from './features/settings/settings-store';
export type SaveState = 'saved' | 'saving' | 'error';
@@ -108,9 +109,9 @@ export const useStore = create<EditorState>((set, get) => ({
},
setBody: (body: string) => {
set({ body, saveState: 'saving' });
// FR-S1:防抖自动保存——间隔读 settings.autoSaveInterval(秒→毫秒)
// FR-S1:防抖自动保存——间隔读 settings.autoSaveInterval(秒→毫秒);直接取已解析的设置,避免每次按键重复解析 localStorage
if (saveTimer) clearTimeout(saveTimer);
const interval = (() => { try { const s = JSON.parse(localStorage.getItem('dsh-settings') ?? '{}'); return (s.autoSaveInterval ?? 3) * 1000; } catch { return 3000; } })();
const interval = (useSettings.getState().autoSaveInterval ?? 1) * 1000;
saveTimer = setTimeout(() => { get().saveNow(); }, interval);
},
+108 -9
View File
@@ -1,21 +1,111 @@
* { box-sizing: border-box; }
html, body, #root { margin: 0; height: 100%; }
html, body, #root { margin: 0; height: 100%; overflow: hidden; }
/* =========================================================
UI 重新设计 · 液态玻璃(分层材质)设计系统
L0 space / L1 glass / L2 paper / L3 float
黑白灰墨色阶 · 克制动效 · 统一线条图标
========================================================= */
/* 全局统一字体栈——消灭按钮/输入框回退到 Arial 的观感不一致(A档-1) */
body {
font-family: system-ui, -apple-system, 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', sans-serif;
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
background: var(--space, var(--bg-primary));
}
button, input, select, textarea { font-family: inherit; }
svg { display: block; }
/* 自定义滚动条——细+半透明+圆角 */
::-webkit-scrollbar { width: 6px; height: 6px; }
/* 统一线条图标——细描边,形成一致的线性语言(stroke-width 1.6) */
svg.lucide, .lucide { stroke-width: 1.6; }
/* 自定义滚动条——细+圆角,墨色阶常显(不再悬停才出现,避免“看不见滚动条”),hover 加深 */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--control-border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
* { scrollbar-width: thin; scrollbar-color: var(--control-border) transparent; }
::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--line-strong); }
::-webkit-scrollbar-corner { background: transparent; }
* { scrollbar-width: thin; scrollbar-color: var(--line) transparent; }
*:hover { scrollbar-color: var(--line-strong) transparent; }
/* ---- 高级滑动条(range):细轨道 + 白玻璃圆滑块,墨色系 ---- */
input[type="range"] {
-webkit-appearance: none; appearance: none;
width: 120px; height: 18px; background: transparent; cursor: pointer;
}
input[type="range"]::-webkit-slider-runnable-track {
height: 4px; border-radius: 999px;
background: color-mix(in srgb, var(--ink) 12%, transparent);
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 14px; height: 14px; border-radius: 50%;
background: var(--paper); border: 1px solid var(--line-strong);
margin-top: -5px;
box-shadow: 0 1px 3px rgba(20,22,28,0.16), 0 1px 1px rgba(20,22,28,0.08);
transition: transform .16s var(--ease), border-color .16s var(--ease), background .16s var(--ease);
}
input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.15); border-color: var(--ink-3); }
input[type="range"]:active::-webkit-slider-thumb { background: var(--ink); border-color: var(--ink); }
/* ---- 分层材质基类 ---- */
/* L1 玻璃:半透明 + 模糊 + 顶部高光线 */
.glass {
background: var(--glass-bar);
-webkit-backdrop-filter: blur(var(--blur-px, 18px)) saturate(1.2);
backdrop-filter: blur(var(--blur-px, 18px)) saturate(1.2);
position: relative;
}
.glass-side {
background: var(--glass-side);
-webkit-backdrop-filter: blur(var(--blur-px, 18px)) saturate(1.2);
backdrop-filter: blur(var(--blur-px, 18px)) saturate(1.2);
position: relative;
}
/* 顶部高光线(玻璃上缘一道亮边) */
.glass::before, .glass-side::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, color-mix(in srgb, #fff 70%, transparent), transparent);
pointer-events: none;
}
/* L2 纸:编辑正文,最清晰 */
.paper {
background: var(--paper);
}
/* L3 悬浮:弹窗/节点卡片,近实白 + 强投影 */
.float {
background: var(--float);
box-shadow: var(--shadow-lift);
}
/* L0 空间层:环境氛围——一束顶光 + 极淡噪点纹理(Apple 原生纵深感) */
.ambient-bg {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background-image: var(--ambient, linear-gradient(168deg, #f2f4f7 0%, #e9ecf1 100%));
}
.ambient-bg::after {
content: '';
position: absolute;
inset: 0;
opacity: .5;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E");
}
/* 克制动效:面板进入 fade+上移(380ms ease) */
@keyframes panel-enter {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.panel-enter { animation: panel-enter 380ms var(--ease) both; }
/* 柔和 hover 过渡 */
.hover-soft { transition: background 160ms var(--ease), color 160ms var(--ease), opacity 160ms var(--ease), box-shadow 160ms var(--ease), transform 160ms var(--ease); }
/* Tiptap 编辑器正文(B档-7 排版打磨:CJK 阅读优化、标题层级、选区、引用/链接/表格) */
.tiptap-wrap .ProseMirror {
font-size: var(--editor-font-size, 14px) !important;
@@ -44,8 +134,9 @@ svg { display: block; }
.tiptap-wrap .ProseMirror h3 { font-size: 1.25em; margin: 0.8em 0 0.4em; font-weight: 600; color: var(--text-primary); }
.tiptap-wrap .ProseMirror h4, .tiptap-wrap .ProseMirror h5, .tiptap-wrap .ProseMirror h6 { margin: 0.7em 0 0.35em; font-weight: 600; }
.tiptap-wrap .ProseMirror code {
background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent-text);
padding: 1px 5px; border-radius: 4px; font-family: 'Consolas', 'Source Code Pro', monospace;
background: color-mix(in srgb, var(--ink) 7%, transparent); color: var(--ink);
padding: 2px 6px; border-radius: 5px; border: 1px solid var(--line);
font-family: 'Consolas', 'Source Code Pro', monospace; font-weight: 500;
}
.tiptap-wrap .ProseMirror pre {
background: var(--bg-secondary); padding: 12px 16px; border-radius: var(--radius-md); overflow: auto;
@@ -144,4 +235,12 @@ button:hover { opacity: 0.85; }
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.panel-slide-in { animation: panel-slide-in 0.2s ease-out; }
.panel-slide-in { animation: panel-slide-in 0.2s ease-out; }
/* ---- 编辑区:L2 纸面,最清晰 ---- */
.tiptap-wrap { background: var(--paper); }
.tiptap-wrap .ProseMirror pre {
background: color-mix(in srgb, var(--ink) 5%, var(--paper));
border: 1px solid var(--line);
box-shadow: var(--shadow-soft);
}
+4 -11
View File
@@ -299,22 +299,15 @@ test('Graph: 控件+模式切换可用', async () => {
await a.app.close();
});
// === TabBar pin/close ===
test('TabBar: 固定+关闭可用', async () => {
// === TabBar close ===
test('TabBar: 关闭标签可用(pin 点已移除)', async () => {
const a = await launch(nodePath.join(os.tmpdir(), 'hn-tab-' + Date.now()));
const { v } = await setupVault(a, [{ name: 'Tab1', body: 't1' }, { name: 'Tab2', body: 't2' }]);
await a.page.getByTestId('filetree').getByText('Tab1').click();
await a.page.waitForTimeout(300);
await a.page.getByTestId('filetree').getByText('Tab2').click();
await a.page.waitForTimeout(300);
// 固定 Tab1(pin 按钮是 span[title="固定"]●)
const tab1 = a.page.getByTestId('tabbar').locator('div').filter({ hasText: 'Tab1' }).first();
await tab1.locator('span[title="固定"]').click();
await a.page.waitForTimeout(300);
// 取消固定
await tab1.locator('span[title="取消固定"]').click();
await a.page.waitForTimeout(300);
// 关闭 Tab2(close 按钮是 span 文本 x)
// 关闭 Tab2(close 按钮是 span 文本 x;pin 点按钮已按 UI 需求移除)
const tab2 = a.page.getByTestId('tabbar').locator('div').filter({ hasText: 'Tab2' }).first();
await tab2.locator('span').filter({ hasText: 'x' }).click();
await a.page.waitForTimeout(300);
@@ -354,4 +347,4 @@ test('OutlinePanel: 切换文档后大纲自动更新', async () => {
await expect(a.page.locator('.outline-item', { hasText: 'Title B' })).toBeVisible({ timeout: 5000 });
await expect(a.page.locator('.outline-item', { hasText: 'Deep B' })).toBeVisible({ timeout: 5000 });
await a.app.close();
});
});
+4 -3
View File
@@ -60,18 +60,19 @@ test('思维导图+知识图谱渲染验证(含可见性)', async () => {
const rects = svg.querySelectorAll('rect').length;
const texts = svg.querySelectorAll('text').length;
const lines = svg.querySelectorAll('line').length;
const linkPaths = svg.querySelectorAll('path').length;
// Check a node card is visible within the svg
const visibleCard = Array.from(svg.querySelectorAll('rect')).some((r) => {
const b = r.getBoundingClientRect();
return b.width > 0 && b.height > 0;
});
return { found: true, w: rect.width, h: rect.height, rects, texts, lines, visibleCard };
return { found: true, w: rect.width, h: rect.height, rects, texts, lines, linkPaths, visibleCard };
});
console.log('Knowledge graph:', JSON.stringify(graph));
expect(graph.found).toBe(true);
expect(graph.h).toBeGreaterThan(100);
expect(graph.rects).toBeGreaterThanOrEqual(5);
expect(graph.lines).toBeGreaterThanOrEqual(1);
expect(graph.rects).toBeGreaterThanOrEqual(3); // 2 卡 rect + 1 背景 rect(demo 卡片无左侧色条)
expect(graph.linkPaths).toBeGreaterThanOrEqual(1); // 曲线连线为 path
expect(graph.visibleCard).toBe(true);
await app.close();
+2 -1
View File
@@ -38,7 +38,8 @@ test('知识图谱:卡片+连线(消费 IPC.graph)', async () => {
await a.page.getByTitle('切换 编辑器/图谱').click();
await a.page.getByRole('button', { name: '知识图谱' }).click();
// 限定在知识图谱画布内统计卡片 rect(标题栏等 UI 图标也是 svg,不能全页统计)
await expect(a.page.locator('[data-testid="graph-canvas"] rect')).toHaveCount(5, { timeout: 10000 });
// 2 张卡片(2 卡 rect)+ 1 背景 rect = 3(demo 卡片无左侧色条)
await expect(a.page.locator('[data-testid="graph-canvas"] rect')).toHaveCount(3, { timeout: 10000 });
await a.app.close();
});
@@ -6,8 +6,8 @@ import os from 'node:os';
const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js');
// 透明主题真透明:bgPrimary=transparent 时根背景必须是 alpha=0(配合主进程 transparent:true + backgroundColor:#00000000 + backgroundMaterial:'none' 即可透出下层软件)
test('透明主题:根背景 alpha=0(真透明,可见下层软件)', async () => {
// UI 重新设计:窗口不透明(transparent:false + backgroundColor 空间底色),根背景应为不透明的 L0 空间色
test('窗口不透明:根背景为 L0 空间底色(alpha=1,非透明)', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-tr-'));
const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v');
await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true });
@@ -15,15 +15,7 @@ test('透明主题:根背景 alpha=0(真透明,可见下层软件)', asy
const page = await app.firstWindow();
await page.waitForLoadState('domcontentloaded');
await page.evaluate(({ root }) => (globalThis as any).dsh.invoke('vault:switch', { root }), { root: v });
// 应用透明主题的 CSS 变量(bgPrimary='transparent'),并移除背景图遮罩
await page.evaluate(() => {
const el = document.documentElement;
el.style.setProperty('--bg-primary', 'transparent');
el.style.setProperty('--bg-secondary', 'rgba(20,20,28,0.5)');
el.style.setProperty('--text-primary', '#ddd');
document.querySelectorAll('div[style*="background-image"]').forEach((n: any) => n.remove());
});
await page.waitForTimeout(400);
await page.waitForTimeout(500);
const rootBg = await page.evaluate(() => {
const root = document.querySelector('#root > div') as HTMLElement;
if (!root) return null;
@@ -31,7 +23,15 @@ test('透明主题:根背景 alpha=0(真透明,可见下层软件)', asy
});
console.log('ROOT_BG', rootBg);
expect(rootBg).not.toBeNull();
// 透明背景应为 alpha=0(rgba(0,0,0,0) 或 transparent)
expect(rootBg).toMatch(/rgba\(0, 0, 0, 0\)|transparent/i);
// 窗口不透明:根背景为不透明空间底色(alpha=1,非 rgba(...,0) / transparent)
const m = rootBg!.match(/rgba\(([\d.]+),\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)\)|^rgb\(([\d.]+),\s*([\d.]+),\s*([\d.]+)\)$/);
expect(m).not.toBeNull();
// 不透明:要么是 rgb(...)(隐式 alpha=1),要么 rgba alpha=1
if (rootBg!.startsWith('rgba')) {
const alpha = parseFloat(m![4]);
expect(alpha).toBe(1);
} else {
expect(rootBg!.startsWith('rgb(')).toBe(true);
}
await app.close();
});