ui: 统一图标系统/字体/文案/token,标题栏与状态栏重做,编辑器排版打磨,窗口边框设计

This commit is contained in:
hunter-dev
2026-08-30 22:17:11 +08:00
parent bd6243950c
commit ab46ec3cce
17 changed files with 242 additions and 123 deletions
+6 -2
View File
@@ -18,7 +18,10 @@
],
"asar": true,
"win": {
"target": ["nsis", "dir"]
"target": [
"nsis",
"dir"
]
},
"nsis": {
"oneClick": false,
@@ -54,6 +57,7 @@
"hotkeys-js": "^4.0.5",
"katex": "^0.16.47",
"lowlight": "^3.3.0",
"lucide-react": "^1.37.0",
"markmap-view": "^0.18.12",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
@@ -66,10 +70,10 @@
"devDependencies": {
"@playwright/test": "^1.62.1",
"@types/d3-drag": "^3.0.7",
"@types/katex": "^0.16.8",
"@types/d3-force": "^3.0.10",
"@types/d3-selection": "^3.0.11",
"@types/d3-zoom": "^3.0.8",
"@types/katex": "^0.16.8",
"@types/node": "^20.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
+12 -1
View File
@@ -21,6 +21,7 @@ import { useGlobalHotkeys } from './features/keymap/use-hotkeys';
import { useKeymap } from './features/keymap/keymap-service';
import { useLayout } from './features/shell/layout-store';
import { Resizer } from './features/shell/Resizer';
import { StatusBar } from './features/shell/StatusBar';
import { EffectLayer } from './features/effects/EffectLayer';
import { useSettings } from './features/settings/settings-store';
import { SearchBox } from './features/search/SearchBox';
@@ -38,10 +39,19 @@ export function App() {
const [showSettings, setShowSettings] = useState(false);
const [showCheat, setShowCheat] = useState(false);
const [showAppearance, setShowAppearance] = useState(false);
const [focused, setFocused] = useState(true); // 窗口聚焦状态——用于最外层边框的明暗切换
const appearance = useAppearance();
const settings = useSettings();
const layout = useLayout();
const switchToEditor = useCallback(() => setView('editor'), []);
// 窗口聚焦/失焦 → 边框强化/淡化
useEffect(() => {
const onF = () => setFocused(true);
const onB = () => setFocused(false);
window.addEventListener('focus', onF);
window.addEventListener('blur', onB);
return () => { window.removeEventListener('focus', onF); window.removeEventListener('blur', onB); };
}, []);
useEffect(() => { loadVault(); loadTree(); restore(); appearance.load(); useKeymap.getState().load(); useSettings.getState().loadGpu(); }, []);
useEffect(() => { if (activeId && tabs.length > 0) { const t = tabs.find(x => x.id === activeId); if (t) openDoc(t.path).then(() => {
@@ -78,7 +88,7 @@ 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)' }}>
<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)' }}>
{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} />
@@ -107,6 +117,7 @@ export function App() {
</>)}
</>)}
</div>
<StatusBar view={view} />
<EffectLayer />
<InputDialog />
<NotifyLayer />
@@ -35,4 +35,12 @@ 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('--space-1', '4px');
root.style.setProperty('--space-2', '8px');
root.style.setProperty('--space-3', '12px');
root.style.setProperty('--space-4', '16px');
}
@@ -1,6 +1,7 @@
import { useStore } from '../../store';
import { TiptapEditor } from './TiptapEditor';
import { MoreActions } from './MoreActions';
import { BookOpen } from 'lucide-react';
import type { SaveState } from '../../store';
function saveLabel(s: SaveState): string {
@@ -14,9 +15,14 @@ export function Editor() {
const title = useStore(s => s.title);
const saveState = useStore(s => s.saveState);
if (!currentPath) {
// B档-8:空状态引导
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0.4 }}>
<div style={{ textAlign: 'center' }}>从左侧选择一篇笔记,或新建文档开始</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', color: 'var(--text-tertiary)' }}>
<div style={{ marginBottom: 14, opacity: 0.55 }}><BookOpen size={42} strokeWidth={1.4} /></div>
<div style={{ fontSize: 15, color: 'var(--text-secondary)', marginBottom: 6 }}>从左侧选择一篇笔记,或新建文档开始</div>
<div style={{ fontSize: 12, opacity: 0.7 }}>支持 Markdown · 代码高亮 · 公式 · 知识图谱</div>
</div>
</div>
);
}
@@ -4,14 +4,15 @@ import { useEditorRef } from './editor-context';
import { useDialog } from '../dialog/dialog-store';
import { invoke } from '../../ipc-client';
import { IPC } from '@hunter/shared';
import { Star, Undo2, Redo2, Bold, Italic, Strikethrough, Code, Eraser, Quote, Heading1, Heading2, Heading3, List, ListOrdered, SquareCode, Table, Link, ImagePlus, Globe } from 'lucide-react';
// FR-M4:编辑区星标入口
function StarButton() {
const { meta, toggleStar } = useStore();
return <button onClick={() => toggleStar()} title="星标" style={{ ...btn, color: meta.star ? 'var(--accent)' : 'var(--text-tertiary)', fontSize: 16 }}>{meta.star ? '★' : '☆'}</button>;
return <button onClick={() => toggleStar()} title="星标" style={{ ...btn, color: meta.star ? 'var(--accent)' : 'var(--text-tertiary)' }}><Star size={15} fill={meta.star ? 'currentColor' : 'none'} /></button>;
}
const btn: React.CSSProperties = { minWidth: 28, height: 26, border: 'none', background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer', borderRadius: 4, fontSize: 13 };
const btn: React.CSSProperties = { minWidth: 28, height: 26, border: 'none', background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer', borderRadius: 'var(--radius-md)', fontSize: 13, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' };
function T({ onClick, title, children, disabled }: { onClick: () => void; title: string; children: React.ReactNode; disabled?: boolean }) {
return <button style={{ ...btn, opacity: disabled ? 0.35 : 1, padding: '0 6px' }}
@@ -46,28 +47,28 @@ export function EditorToolbar() {
return (
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--control-border)', display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'center' }}>
<StarButton />
<T title="撤销 Ctrl+Z" onClick={cmd(e => e.chain().focus().undo().run())} disabled={d}>↶</T>
<T title="重做 Ctrl+Y" onClick={cmd(e => e.chain().focus().redo().run())} disabled={d}>↷</T>
<T title="撤销 Ctrl+Z" onClick={cmd(e => e.chain().focus().undo().run())} disabled={d}><Undo2 size={16} /></T>
<T title="重做 Ctrl+Y" onClick={cmd(e => e.chain().focus().redo().run())} disabled={d}><Redo2 size={16} /></T>
<span style={{ width: 1, background: 'var(--control-border)', height: 20, margin: '0 4px' }} />
<T title="加粗 Ctrl+B" onClick={cmd(e => e.chain().focus().toggleBold().run())} disabled={d}>B</T>
<T title="斜体 Ctrl+I" onClick={cmd(e => e.chain().focus().toggleItalic().run())} disabled={d}>I</T>
<T title="删除线 Alt+Shift+5" onClick={cmd(e => e.chain().focus().toggleStrike().run())} disabled={d}>S</T>
<T title="行内代码 Ctrl+E" onClick={cmd(e => e.chain().focus().toggleCode().run())} disabled={d}><span style={{ fontFamily: 'monospace' }}>code</span></T>
<T title="清除格式" onClick={cmd(e => e.chain().focus().unsetAllMarks().run())} disabled={d}>T×</T>
<T title="引用 Ctrl+Shift+Q" onClick={cmd(e => e.chain().focus().toggleBlockquote().run())} disabled={d}>❝</T>
<T title="加粗 Ctrl+B" onClick={cmd(e => e.chain().focus().toggleBold().run())} disabled={d}><Bold size={16} /></T>
<T title="斜体 Ctrl+I" onClick={cmd(e => e.chain().focus().toggleItalic().run())} disabled={d}><Italic size={16} /></T>
<T title="删除线 Alt+Shift+5" onClick={cmd(e => e.chain().focus().toggleStrike().run())} disabled={d}><Strikethrough size={16} /></T>
<T title="行内代码 Ctrl+E" onClick={cmd(e => e.chain().focus().toggleCode().run())} disabled={d}><Code size={16} /></T>
<T title="清除格式" onClick={cmd(e => e.chain().focus().unsetAllMarks().run())} disabled={d}><Eraser size={16} /></T>
<T title="引用 Ctrl+Shift+Q" onClick={cmd(e => e.chain().focus().toggleBlockquote().run())} disabled={d}><Quote size={16} /></T>
<span style={{ width: 1, background: 'var(--control-border)', height: 20, margin: '0 4px' }} />
<T title="一级标题 Ctrl+1" onClick={cmd(e => e.chain().focus().toggleHeading({ level: 1 }).run())} disabled={d}>H1</T>
<T title="二级标题 Ctrl+2" onClick={cmd(e => e.chain().focus().toggleHeading({ level: 2 }).run())} disabled={d}>H2</T>
<T title="三级标题 Ctrl+3" onClick={cmd(e => e.chain().focus().toggleHeading({ level: 3 }).run())} disabled={d}>H3</T>
<T title="一级标题 Ctrl+1" onClick={cmd(e => e.chain().focus().toggleHeading({ level: 1 }).run())} disabled={d}><Heading1 size={16} /></T>
<T title="二级标题 Ctrl+2" onClick={cmd(e => e.chain().focus().toggleHeading({ level: 2 }).run())} disabled={d}><Heading2 size={16} /></T>
<T title="三级标题 Ctrl+3" onClick={cmd(e => e.chain().focus().toggleHeading({ level: 3 }).run())} disabled={d}><Heading3 size={16} /></T>
<span style={{ width: 1, background: 'var(--control-border)', height: 20, margin: '0 4px' }} />
<T title="无序列表 Ctrl+L" onClick={cmd(e => e.chain().focus().toggleBulletList().run())} disabled={d}>•</T>
<T title="有序列表 Ctrl+Shift+O" onClick={cmd(e => e.chain().focus().toggleOrderedList().run())} disabled={d}>1.</T>
<T title="代码块 Ctrl+Shift+M" onClick={cmd(e => e.chain().focus().toggleCodeBlock().run())} disabled={d}>{'</>'}</T>
<T title="表格 Ctrl+Shift+T" onClick={cmd(e => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run())} disabled={d}>▦</T>
<T title="无序列表 Ctrl+L" onClick={cmd(e => e.chain().focus().toggleBulletList().run())} disabled={d}><List size={16} /></T>
<T title="有序列表 Ctrl+Shift+O" onClick={cmd(e => e.chain().focus().toggleOrderedList().run())} disabled={d}><ListOrdered size={16} /></T>
<T title="代码块 Ctrl+Shift+M" onClick={cmd(e => e.chain().focus().toggleCodeBlock().run())} disabled={d}><SquareCode size={16} /></T>
<T title="表格 Ctrl+Shift+T" onClick={cmd(e => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run())} disabled={d}><Table size={16} /></T>
<span style={{ width: 1, background: 'var(--control-border)', height: 20, margin: '0 4px' }} />
<T title="链接" onClick={() => { void link(); }} disabled={d}>链接</T>
<T title="插入图片(本地选图入库)" onClick={() => { void pickImage(); }} disabled={d}>图片</T>
<T title="网络图片(手输网址)" onClick={() => { void imageUrl(); }} disabled={d}>网图</T>
<T title="链接" onClick={() => { void link(); }} disabled={d}><Link size={15} /><span style={{ marginLeft: 3 }}>链接</span></T>
<T title="插入图片(本地选图入库)" onClick={() => { void pickImage(); }} disabled={d}><ImagePlus size={15} /><span style={{ marginLeft: 3 }}>图片</span></T>
<T title="网络图片(手输网址)" onClick={() => { void imageUrl(); }} disabled={d}><Globe size={15} /><span style={{ marginLeft: 3 }}>网图</span></T>
</div>
);
}
@@ -2,6 +2,7 @@ import { useStore } from '../../store';
import { invoke } from '../../ipc-client';
import { IPC } from '@hunter/shared';
import { useState } from 'react';
import { MoreHorizontal } from 'lucide-react';
export function MoreActions() {
const currentPath = useStore(s => s.currentPath);
@@ -20,7 +21,7 @@ export function MoreActions() {
const reload = async () => { await openDoc(currentPath); setOpen(false); };
return (
<div style={{ position: 'relative' }}>
<button onClick={() => setOpen(o => !o)} style={btn} title="更多操作">⋯</button>
<button onClick={() => setOpen(o => !o)} style={btn} title="更多操作"><MoreHorizontal size={16} /></button>
{open && (
<div style={{ position: 'absolute', right: 0, top: 28, background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', borderRadius: 6, padding: 4, zIndex: 500, minWidth: 140 }}>
<Item onClick={copyMd}>复制 Markdown</Item>
@@ -35,5 +36,5 @@ export function MoreActions() {
function Item({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
return <div onClick={onClick} style={{ padding: '6px 12px', cursor: 'pointer', borderRadius: 4, fontSize: 13, color: 'var(--text-primary)' }} onMouseEnter={e => (e.currentTarget.style.background = 'var(--control-border)')} onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>{children}</div>;
}
const btn: React.CSSProperties = { height: 26, border: '1px solid var(--control-border)', background: 'transparent', color: 'var(--text-primary)', cursor: 'pointer', borderRadius: 4, fontSize: 14, padding: '0 8px' };
const btn: React.CSSProperties = { height: 26, border: '1px solid var(--control-border)', background: 'transparent', color: 'var(--text-primary)', cursor: 'pointer', borderRadius: 'var(--radius-md)', fontSize: 14, padding: '0 8px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' };
function notify(msg: string, isError?: boolean) { const el = document.createElement('div'); el.textContent = msg; el.style.cssText = 'position:fixed;top:50px;right:20px;background:' + (isError ? '#e81123' : 'var(--accent)') + ';color:' + (isError ? '#fff' : 'var(--accent-text)') + ';padding:10px 16px;border-radius:6px;z-index:3000;font-size:13px'; document.body.appendChild(el); setTimeout(() => el.remove(), 2500); }
@@ -120,9 +120,11 @@ export function TiptapEditor() {
}, [currentPath, editor]);
// FR-E13:长文档底部书写——光标行距编辑区底部保持留白,避免输入内容贴着页面底缘
// B档-7:同时同步光标所在行高亮(.is-active-line)
useEffect(() => {
if (!editor) return;
const BOTTOM_MARGIN = 72; // 光标行距底部的期望留白(px)
let prevLine: HTMLElement | null = null;
const adjust = () => {
// 编辑器销毁/未挂载时 view 访问会抛异常——必须 try/catch 保护,避免关闭标签后界面报错
try {
@@ -139,9 +141,27 @@ export function TiptapEditor() {
if (delta > 0) scroller.scrollTop += delta;
} catch { /* 编辑器销毁等异常:忽略 */ }
};
editor.on('update', adjust);
editor.on('selectionUpdate', adjust);
return () => { editor.off('update', adjust); editor.off('selectionUpdate', adjust); };
const syncActiveLine = () => {
try {
const pm = editor.view.dom as HTMLElement;
const sel = editor.view.state.selection;
let el: HTMLElement | null = null;
if (sel.empty) {
const at = editor.view.domAtPos(sel.from);
let cur = (at.node.nodeType === Node.TEXT_NODE ? at.node.parentElement : at.node as Element) as HTMLElement | null;
while (cur && cur !== pm && !/^(P|H[1-6]|PRE|LI|BLOCKQUOTE|TABLE|UL|OL)$/i.test(cur.tagName)) cur = cur.parentElement;
if (cur && cur !== pm) el = cur;
}
if (prevLine && prevLine !== el) prevLine.classList.remove('is-active-line');
if (el && !el.classList.contains('is-active-line')) el.classList.add('is-active-line');
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); };
}, [editor]);
// 监听保存前冲刷——Ctrl+S / 关窗前同步编辑器内容到 store
@@ -5,6 +5,7 @@ import { invoke } from '../../ipc-client';
import { IPC } from '@hunter/shared';
import { useDialog } from '../dialog/dialog-store';
import { useTabs } from '../tabs/tab-store';
import { FilePlus, FolderPlus, Import, Pencil, Trash2, ChevronRight, ChevronDown, Folder, FolderOpen, FileText, Inbox } from 'lucide-react';
export function FileTree() {
const tree = useStore(s => s.tree);
@@ -118,10 +119,10 @@ export function FileTree() {
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)' }}>
{n.type === 'folder' ? (isCollapsed ? <ChevronRight /> : <ChevronDown />) : <span style={{ width: 16 }} />}
{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 }}>
{n.type === 'folder' ? <FolderIcon open={!isCollapsed} /> : <FileIcon />}
<span style={{ width: 18, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, color: 'var(--text-secondary)' }}>
{n.type === 'folder' ? (isCollapsed ? <Folder size={15} /> : <FolderOpen size={15} />) : <FileText size={15} />}
</span>
{isRenaming ? (
<input autoFocus value={renameVal} onChange={e => setRenameVal(e.target.value)}
@@ -133,12 +134,12 @@ export function FileTree() {
{isHover && !isRenaming && (
<span style={{ display: 'flex', gap: 2 }} onClick={e => e.stopPropagation()}>
{n.type === 'folder' && <>
<button title="新建文档" onClick={() => doCreate(n.path, false)} style={miniBtn}>+</button>
<button title="新建文件夹" onClick={() => doCreate(n.path, true)} style={miniBtn}>F+</button>
<button title="导入文件夹" onClick={() => doImportFolder(n.path)} style={miniBtn}>Imp</button>
<button title="新建文档" onClick={() => doCreate(n.path, false)} style={miniBtn}><FilePlus size={13} /></button>
<button title="新建文件夹" onClick={() => doCreate(n.path, true)} style={miniBtn}><FolderPlus size={13} /></button>
<button title="导入文件夹" onClick={() => doImportFolder(n.path)} style={miniBtn}><Import size={13} /></button>
</>}
<button title="重命名" onClick={() => { setRenaming(n.path); setRenameVal(n.name); }} style={miniBtn}>Edit</button>
<button title="删除" onClick={() => doDelete(n)} style={miniBtn}>Del</button>
<button title="重命名" onClick={() => { setRenaming(n.path); setRenameVal(n.name); }} style={miniBtn}><Pencil size={13} /></button>
<button title="删除" onClick={() => doDelete(n)} style={miniBtn}><Trash2 size={13} /></button>
</span>
)}
</div>
@@ -153,7 +154,12 @@ export function FileTree() {
<div data-testid="filetree" style={{ overflow: 'auto', padding: 8, height: '100%' }} onClick={() => setMenu(null)}>
{treeLoading && <div style={{ opacity: 0.5 }}>加载中…</div>}
{roots.map(n => renderNode(n, 0))}
{tree.length === 0 && !treeLoading && <div style={{ opacity: 0.5, fontSize: 12 }}>空库,点击上方「+ 新建文档」开始</div>}
{tree.length === 0 && !treeLoading && (
<div style={{ padding: '24px 8px', textAlign: 'center', color: 'var(--text-tertiary)' }}>
<div style={{ marginBottom: 8, opacity: 0.5 }}><Inbox size={28} strokeWidth={1.5} /></div>
<div style={{ fontSize: 12 }}>空库,点击上方「新建文档」开始</div>
</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()}>
{menuNode.type === 'doc' && <MenuItem onClick={() => { openDoc(menuNode.path); setMenu(null); }}>打开</MenuItem>}
@@ -169,43 +175,8 @@ export function FileTree() {
);
}
const miniBtn: React.CSSProperties = { border: 'none', background: 'transparent', color: 'var(--text-tertiary)', cursor: 'pointer', padding: '0 3px', fontSize: 12 };
const miniBtn: React.CSSProperties = { border: 'none', background: 'transparent', color: 'var(--text-tertiary)', cursor: 'pointer', padding: '2px', borderRadius: 'var(--radius-sm)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' };
function MenuItem({ children, onClick, danger }: { children: React.ReactNode; onClick: () => void; danger?: boolean }) {
return <div onClick={onClick} style={{ padding: '5px 10px', cursor: 'pointer', borderRadius: 4, fontSize: 13, color: danger ? '#f88' : 'var(--text-primary)' }} onMouseEnter={e => (e.currentTarget.style.background = 'var(--control-border)')} onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>{children}</div>;
}
// ---- 文件树图标(SVG,跟随主题颜色)----
function ChevronRight() {
return (
<svg width="10" height="10" viewBox="0 0 10 10" style={{ display: 'block' }}>
<path d="M3 1l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function ChevronDown() {
return (
<svg width="10" height="10" viewBox="0 0 10 10" style={{ display: 'block' }}>
<path d="M1 3l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function FolderIcon({ open }: { open: boolean }) {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" style={{ display: 'block', color: 'var(--text-secondary)' }}>
{open ? (
<path d="M3 6.5A1.5 1.5 0 0 1 4.5 5h5l2 2h8A1.5 1.5 0 0 1 21 8.5v9A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5v-11z" fill="currentColor" opacity="0.32" stroke="currentColor" strokeWidth="1.3" />
) : (
<path d="M3 6.5A1.5 1.5 0 0 1 4.5 5h5l2 2h8A1.5 1.5 0 0 1 21 8.5v9A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5v-11z" fill="currentColor" opacity="0.4" stroke="currentColor" strokeWidth="1.3" />
)}
</svg>
);
}
function FileIcon() {
return (
<svg width="14" height="15" viewBox="0 0 24 24" fill="none" style={{ display: 'block', color: 'var(--text-secondary)' }}>
<path d="M6 3h9l4 4v14H6V3z" fill="currentColor" opacity="0.18" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
<path d="M9 12.5h7M9 16h7" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" opacity="0.8" />
</svg>
);
}
@@ -123,7 +123,7 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void
return (
<div style={{ flex: 1, background: 'var(--bg-secondary)', position: 'relative', overflow: 'hidden' }}>
<svg ref={svgRef} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
<svg data-testid="graph-canvas" ref={svgRef} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
{/* FR-G4:画布控件 */}
<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>
@@ -13,7 +13,7 @@ export interface Settings {
const DEFAULTS: Settings = {
fontSize: 14,
fontFamily: 'Consolas, monospace',
fontFamily: '微软雅黑', // B档-7:默认中文字体,更适合作笔记阅读(可在设置里换回等宽)
autoSaveInterval: 1,
showLineNumbers: false,
wordWrap: true,
@@ -0,0 +1,29 @@
import { useStore } from '../../store';
// B档-6:底部状态栏——字数/行数、保存状态、当前库、视图模式
export function StatusBar({ view }: { view: 'editor' | 'graph' }) {
const currentPath = useStore(s => s.currentPath);
const body = useStore(s => s.body);
const saveState = useStore(s => s.saveState);
const vault = useStore(s => s.vault);
const chars = body.replace(/\s+/g, '').length;
const linesCount = body ? body.split('\n').length : 0;
const vaultName = vault?.currentRoot ? vault.currentRoot.split(/[\\/]/).filter(Boolean).pop() || vault.currentRoot : '';
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 style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{currentPath ? <span>{chars} 字符 · {linesCount} 行</span> : <span style={{ opacity: 0.5 }}>—</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{vaultName && <span>{vaultName}</span>}
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: saveDot, display: 'inline-block' }} />
{saveText}
</span>
<span>{view === 'editor' ? '编辑视图' : '图谱视图'}</span>
</div>
</div>
);
}
@@ -2,33 +2,51 @@ import { send, invoke } from '../../ipc-client';
import { IPC } from '@hunter/shared';
import { useStore } from '../../store';
import { useState, useEffect } from 'react';
import { PanelLeft, Palette, RefreshCw, Network, PenLine, Rows, Rows3, Minimize2, Maximize2, Settings, Minus, Square, Copy, X } from 'lucide-react';
const drag = { WebkitAppRegion: 'drag', flex: 1, height: '100%' } as React.CSSProperties;
const noDrag = { WebkitAppRegion: 'no-drag' } as React.CSSProperties;
export function TitleBar({ onToggleView, view, onSettings, onAppearance, onToggleSidebar, onToggleMinimal, onToggleTabbar, minimal, tabbarVisible }: { onToggleView: () => void; view: 'editor' | 'graph'; onSettings: () => void; onAppearance: () => void; onToggleSidebar: () => void; onToggleMinimal: () => void; onToggleTabbar: () => void; minimal: boolean; tabbarVisible: boolean }) {
const loadTree = useStore(s => s.loadTree);
const currentPath = useStore(s => s.currentPath);
const vault = useStore(s => s.vault);
const [maximized, setMaximized] = useState(false);
useEffect(() => { invoke(IPC.winIsMaximized, undefined as never).then(setMaximized).catch(() => {}); }, []);
const toggleMaximize = () => { send(IPC.winMaximize, undefined as never); setMaximized(m => !m); };
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 style={{ padding: '0 12px', color: 'var(--accent-text)', fontWeight: 600, fontSize: 13 }}>狩夜人笔记</div>
<div style={drag} />
<button title={"侧边栏开关"} onClick={onToggleSidebar} style={{ ...btn, ...noDrag, fontSize: 14 }}>▤</button>
<button title={"外观定制"} onClick={onAppearance} style={{ ...btn, ...noDrag, fontSize: 14 }}>◐</button>
<div style={{ display: 'flex', alignItems: 'center', gap: 2, padding: '0 6px', ...noDrag }}>
<button title={"刷新"} onClick={() => loadTree()} style={btn}>↻</button>
<button title={"切换 编辑器/图谱"} onClick={onToggleView} style={btn}>{view === 'editor' ? '◇' : '✎'}</button>
<button title={tabbarVisible ? '隐藏标签栏' : '显示标签栏'} onClick={onToggleTabbar} style={{ ...btn, fontSize: 14 }} data-testid="tabbar-toggle">{tabbarVisible ? '☰' : '–'}</button>
<button title={"极简模式"} onClick={onToggleMinimal} style={btn}>{minimal ? '⛶' : '⊡'}</button>
<button title={"设置"} onClick={onSettings} style={btn}>⚙</button>
<span style={{ width: 1, background: 'var(--control-border)', height: 16, margin: '0 4px' }} />
<button title={"最小化"} onClick={() => send(IPC.winMinimize, undefined as never)} style={btn}>—</button>
<button title={maximized ? '还原' : '最大化'} onClick={() => { send(IPC.winMaximize, undefined as never); setMaximized(m => !m); }} style={btn} data-testid="maximize-btn">{maximized ? '⧉' : '▢'}</button>
<button title={"关闭"} onMouseEnter={e => (e.currentTarget.style.background = '#e81123')} onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} onClick={() => send(IPC.winClose, undefined as never)} style={btn}>✕</button>
{/* 品牌区:双击最大化 */}
<div 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' }} />
<span style={{ color: 'var(--text-primary)', fontWeight: 700, fontSize: 13, letterSpacing: 0.5 }}>狩夜人笔记</span>
</div>
{docName && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, ...noDrag, color: 'var(--text-secondary)', fontSize: 12 }} onDoubleClick={toggleMaximize}>
<span style={{ width: 1, height: 14, background: 'var(--control-border)' }} />
<span style={{ maxWidth: 260, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{docName}</span>
<span style={{ width: 1, height: 14, background: 'var(--control-border)' }} />
</div>
)}
{vaultName && !docName && <div style={{ ...noDrag, color: 'var(--text-tertiary)', fontSize: 12 }}>库:{vaultName}</div>}
<div style={{ WebkitAppRegion: 'drag', flex: 1, height: '100%' } as React.CSSProperties} />
<div className="titlebar-controls" style={{ display: 'flex', alignItems: 'center', gap: 2, padding: '0 6px', ...noDrag }}>
<button title={"侧边栏开关"} onClick={onToggleSidebar} style={btn}><PanelLeft size={16} /></button>
<button title={"外观定制"} onClick={onAppearance} style={btn}><Palette size={16} /></button>
<span style={{ width: 1, background: 'var(--control-border)', height: 16, margin: '0 2px' }} />
<button title={"刷新"} onClick={() => loadTree()} style={btn}><RefreshCw size={15} /></button>
<button title={"切换 编辑器/图谱"} aria-label="切换视图" onClick={onToggleView} style={btn}>{view === 'editor' ? <Network size={15} /> : <PenLine size={15} />}</button>
<button title={tabbarVisible ? '隐藏标签栏' : '显示标签栏'} onClick={onToggleTabbar} style={btn} data-testid="tabbar-toggle">{tabbarVisible ? <Rows3 size={16} /> : <Rows size={16} />}</button>
<button title={"极简模式"} onClick={onToggleMinimal} style={btn}>{minimal ? <Maximize2 size={15} /> : <Minimize2 size={15} />}</button>
<button title={"设置"} onClick={onSettings} style={btn}><Settings size={15} /></button>
<span style={{ width: 1, background: 'var(--control-border)', height: 16, margin: '0 6px 0 2px' }} />
<button title={"最小化"} onClick={() => send(IPC.winMinimize, undefined as never)} style={btn}><Minus size={15} /></button>
<button title={maximized ? '还原' : '最大化'} onClick={toggleMaximize} style={btn} data-testid="maximize-btn">{maximized ? <Copy size={14} /> : <Square size={13} />}</button>
<button title={"关闭"} onMouseEnter={e => (e.currentTarget.style.background = '#e81123')} onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} onClick={() => send(IPC.winClose, undefined as never)} style={btn}><X size={15} /></button>
</div>
</div>
);
}
const btn: React.CSSProperties = { width: 34, height: 28, border: 'none', background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer', fontSize: 12, borderRadius: 4 };
const btn: React.CSSProperties = { width: 34, height: 28, border: 'none', background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer', fontSize: 12, borderRadius: 'var(--radius-md)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', transition: 'background 0.12s' };
@@ -4,6 +4,7 @@ import { IPC } from '@hunter/shared';
import { useDialog } from '../dialog/dialog-store';
import { useTabs } from '../tabs/tab-store';
import { useState } from 'react';
import { FilePlus, FolderPlus, Import, FolderSync, ChevronDown } from 'lucide-react';
// 极简浮层通知(FR-B1,后续抽组件)
function notify(msg: string) {
@@ -49,14 +50,14 @@ export function Toolbar() {
};
return (
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--control-border)', display: 'flex', gap: 8, background: 'var(--bg-secondary)' }}>
<button onClick={newDoc} style={btn}>+ 新建文档</button>
<button onClick={newFolder} style={btn}>▣ 新建文件夹</button>
<button onClick={importFiles} style={btn}>↓ 导入</button>
<button onClick={switchVault} style={btn}>⇄ 切换库</button>
<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 && (
<div style={{ position: 'relative' }}>
<button onClick={() => setRecentsOpen(o => !o)} style={btn} data-testid="recents-toggle">最近库 ▾</button>
<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)}>
{vault.recents.map(r => (
@@ -74,4 +75,4 @@ export function Toolbar() {
</div>
);
}
const btn: React.CSSProperties = { padding: '4px 12px', border: 'none', background: 'transparent', color: 'var(--text-secondary)', borderRadius: 4, cursor: 'pointer', fontSize: 12, transition: 'all 0.15s' };
const btn: React.CSSProperties = { padding: 'var(--space-1) var(--space-3)', border: 'none', background: 'transparent', color: 'var(--text-secondary)', borderRadius: 'var(--radius-md)', cursor: 'pointer', fontSize: 13, display: 'inline-flex', alignItems: 'center', gap: 5, transition: 'all 0.15s' };
+32 -12
View File
@@ -1,39 +1,55 @@
* { box-sizing: border-box; }
html, body, #root { margin: 0; height: 100%; }
/* 全局统一字体栈——消灭按钮/输入框回退到 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;
}
button, input, select, textarea { font-family: inherit; }
svg { display: block; }
/* 自定义滚动条——细+半透明+圆角 */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-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; }
/* Tiptap 编辑器正文 */
/* Tiptap 编辑器正文(B档-7 排版打磨:CJK 阅读优化、标题层级、选区、引用/链接/表格) */
.tiptap-wrap .ProseMirror {
font-size: var(--editor-font-size, 14px) !important;
font-family: var(--editor-font-family, Consolas, monospace) !important;
font-family: var(--editor-font-family, 'Microsoft YaHei', 'Source Han Sans SC', system-ui, sans-serif) !important;
white-space: var(--editor-white-space, pre-wrap);
min-height: 100%;
/* 底部加大留白:长文档写到末尾时,光标行不会贴着编辑区底缘(配合光标跟随滚动) */
padding: 16px 24px 140px;
font-family: 'Consolas', 'Source Code Pro', system-ui, sans-serif;
font-size: 14px;
line-height: 1.7;
line-height: 1.8;
color: var(--text-primary);
outline: none;
max-width: 860px;
margin: 0 auto;
caret-color: var(--accent);
}
.tiptap-wrap .ProseMirror p { margin: 0.6em 0; }
.tiptap-wrap .ProseMirror h1 { font-size: 1.8em; margin: 0.8em 0 0.4em; }
.tiptap-wrap .ProseMirror h2 { font-size: 1.5em; margin: 0.7em 0 0.35em; }
.tiptap-wrap .ProseMirror h3 { font-size: 1.25em; margin: 0.6em 0 0.3em; }
.tiptap-wrap .ProseMirror h1 { font-size: 1.8em; margin: 1em 0 0.5em; font-weight: 700; color: var(--text-primary); padding-bottom: 0.25em; border-bottom: 1px solid var(--control-border); }
.tiptap-wrap .ProseMirror h2 { font-size: 1.5em; margin: 0.9em 0 0.45em; font-weight: 600; color: var(--text-primary); }
.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: var(--bg-secondary); padding: 1px 5px; border-radius: 3px; font-family: 'Consolas', monospace;
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;
}
.tiptap-wrap .ProseMirror pre {
background: var(--bg-secondary); padding: 12px 16px; border-radius: 6px; overflow: auto;
background: var(--bg-secondary); padding: 12px 16px; border-radius: var(--radius-md); overflow: auto;
border: 1px solid var(--control-border);
}
.tiptap-wrap .ProseMirror pre code { background: none; padding: 0; }
.tiptap-wrap .ProseMirror pre code { background: none; padding: 0; color: inherit; }
.tiptap-wrap .ProseMirror a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
.tiptap-wrap .ProseMirror ::selection { background: color-mix(in srgb, var(--accent) 30%, transparent); }
/* B档-7:光标所在行高亮(TiptapEditor 动态加类) */
.tiptap-wrap .ProseMirror .is-active-line { background: color-mix(in srgb, var(--accent) 6%, transparent); border-radius: 4px; }
.tiptap-wrap .ProseMirror table th { background: color-mix(in srgb, var(--accent) 10%, transparent); font-weight: 600; }
/* highlight.js 暗色主题(CodeBlockLowlight 装饰 span,FR-E5) */
.tiptap-wrap .ProseMirror .hljs { color: #abb2bf; }
.tiptap-wrap .ProseMirror .hljs-comment, .tiptap-wrap .ProseMirror .hljs-quote { color: #5c6370; font-style: italic; }
@@ -84,7 +100,8 @@ html, body, #root { margin: 0; height: 100%; }
border-left: 2px solid var(--accent) !important;
}
.tiptap-wrap .ProseMirror blockquote {
border-left: 3px solid var(--control-border); padding-left: 12px; color: var(--text-secondary); margin: 0.6em 0;
border-left: 3px solid var(--accent); padding: 4px 16px; color: var(--text-secondary); margin: 0.6em 0;
background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.tiptap-wrap .ProseMirror ul, .tiptap-wrap .ProseMirror ol { padding-left: 24px; }
.tiptap-wrap .ProseMirror table { border-collapse: collapse; }
@@ -113,6 +130,9 @@ html, body, #root { margin: 0; height: 100%; }
/* FR-A12:统一过渡动效 */
button { transition: background 0.15s, color 0.15s, opacity 0.15s; }
button:hover { opacity: 0.85; }
/* B档-5:标题栏控制按钮 hover 背景 */
.titlebar-controls button:hover { opacity: 1; background: var(--control-border); }
.titlebar-controls button:last-child:hover { background: #e81123; color: #fff; }
@keyframes panel-slide-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
+2 -1
View File
@@ -37,7 +37,8 @@ test('知识图谱:卡片+连线(消费 IPC.graph)', async () => {
// UI:先经 TitleBar 切到图谱视图(view 默认 editor),再切「知识图谱」模式
await a.page.getByTitle('切换 编辑器/图谱').click();
await a.page.getByRole('button', { name: '知识图谱' }).click();
await expect(a.page.locator('svg rect')).toHaveCount(5, { timeout: 10000 });
// 限定在知识图谱画布内统计卡片 rect(标题栏等 UI 图标也是 svg,不能全页统计)
await expect(a.page.locator('[data-testid="graph-canvas"] rect')).toHaveCount(5, { timeout: 10000 });
await a.app.close();
});
+4 -4
View File
@@ -72,18 +72,18 @@ test('FR-W2:最大化按钮图标切换', async () => {
const a = await launch(ud);
await a.invoke('vault:switch', { root: v });
const maxBtn = a.page.getByTestId('maximize-btn');
// 初始可能未最大化
const initialText = await maxBtn.textContent();
// 初始可能未最大化(图标按钮无文本,用 innerHTML 比较图标是否切换)
const initialText = await maxBtn.innerHTML();
// 点击最大化
await maxBtn.click();
await a.page.waitForTimeout(300);
const afterClickText = await maxBtn.textContent();
const afterClickText = await maxBtn.innerHTML();
// 图标应变化
expect(initialText).not.toBe(afterClickText);
// 再点还原
await maxBtn.click();
await a.page.waitForTimeout(300);
const afterRestoreText = await maxBtn.textContent();
const afterRestoreText = await maxBtn.innerHTML();
expect(afterRestoreText).toBe(initialText);
await a.app.close();
});