perf(renderer): 整体优化响应速度 — rAF 合并编辑器刷新/自动保存缓存/粒子引擎空闲自停
同时: 不透明窗口 + 精简主题(8到2)、TabBar 简化、图谱宽松布局、MoreActions 层级修复
This commit is contained in:
@@ -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/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,22 +17,14 @@ 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,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);
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -145,3 +236,11 @@ button:hover { opacity: 0.85; }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user