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();
});
+35 -7
View File
@@ -95,6 +95,9 @@ importers:
lowlight:
specifier: ^3.3.0
version: 3.3.0
lucide-react:
specifier: ^1.37.0
version: 1.37.0(react@18.3.1)
markmap-view:
specifier: ^0.18.12
version: 0.18.12(markmap-common@0.18.9)
@@ -1071,6 +1074,10 @@ packages:
resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==}
engines: {node: '>=10.0.0'}
'@xmldom/xmldom@0.9.12':
resolution: {integrity: sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==}
engines: {node: '>=14.6'}
abbrev@4.0.0:
resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==}
engines: {node: ^20.17.0 || >=22.9.0}
@@ -1714,8 +1721,8 @@ packages:
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
engines: {node: '>=8'}
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
glob@7.2.0:
resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
global-agent@3.0.0:
@@ -1931,6 +1938,11 @@ packages:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
lucide-react@1.37.0:
resolution: {integrity: sha512-LPsB4rD1TD6wZu1djKOf9vUnS1jTNaHbolXebXDgiTdb6jeA1agIJhJsIybCmjKmQClcOaal1o1OaiYahEftyQ==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -2247,6 +2259,10 @@ packages:
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
engines: {node: '>=10.4.0'}
plist@3.1.1:
resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==}
engines: {node: '>=10.4.0'}
postcss@8.5.26:
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
engines: {node: ^10 || ^12 || >=14}
@@ -2999,7 +3015,7 @@ snapshots:
'@electron/asar@3.4.1':
dependencies:
commander: 5.1.0
glob: 7.2.3
glob: 7.2.0
minimatch: 3.1.5
'@electron/fuses@1.8.0':
@@ -3051,7 +3067,7 @@ snapshots:
fs-extra: 10.1.0
isbinaryfile: 4.0.10
minimist: 1.2.8
plist: 3.1.0
plist: 3.1.1
transitivePeerDependencies:
- supports-color
@@ -3074,7 +3090,7 @@ snapshots:
dir-compare: 4.2.0
fs-extra: 11.4.0
minimatch: 9.0.9
plist: 3.1.0
plist: 3.1.1
transitivePeerDependencies:
- supports-color
@@ -3695,6 +3711,8 @@ snapshots:
'@xmldom/xmldom@0.8.15': {}
'@xmldom/xmldom@0.9.12': {}
abbrev@4.0.0: {}
agent-base@7.1.4: {}
@@ -4460,7 +4478,7 @@ snapshots:
dependencies:
pump: 3.0.4
glob@7.2.3:
glob@7.2.0:
dependencies:
fs.realpath: 1.0.0
inflight: 1.0.6
@@ -4691,6 +4709,10 @@ snapshots:
dependencies:
yallist: 4.0.0
lucide-react@1.37.0(react@18.3.1):
dependencies:
react: 18.3.1
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -5181,6 +5203,12 @@ snapshots:
base64-js: 1.5.1
xmlbuilder: 15.1.1
plist@3.1.1:
dependencies:
'@xmldom/xmldom': 0.9.12
base64-js: 1.5.1
xmlbuilder: 15.1.1
postcss@8.5.26:
dependencies:
nanoid: 3.3.18
@@ -5385,7 +5413,7 @@ snapshots:
rimraf@2.6.3:
dependencies:
glob: 7.2.3
glob: 7.2.0
roarr@2.15.4:
dependencies: