perf: 全局响应速度优化——1.核心: 所有组件 useStore() 改为窄选择器 useStore(s=>s.field), 之前每次按键 setBody 改变 store.body 导致订阅整个 store 的 14 个组件全部 re-render(TiptapEditor/Editor/FileTree/TabBar/DocInfoPanel/Toolbar/App/SearchBox/TagFilter/SettingsPanel/GraphView/LinkGraph/MoreActions/TitleBar); TiptapEditor 不再订阅 body(仅 currentPath/setBody), body 从 getState() 在 effect 内获取; 2.OutlinePanel collect debounce 300ms(之前每次按键遍历文档树); 3.OutlinePanel scroll handler rAF 节流(之前每像素滚动调 coordsAtPos); 4.修复 TabBar resetEditor 重复声明 + TiptapEditor content 引用
This commit is contained in:
@@ -29,7 +29,9 @@ import { OutlinePanel } from './features/outline/OutlinePanel';
|
||||
import { DocInfoPanel } from './features/doc-info/DocInfoPanel';
|
||||
|
||||
export function App() {
|
||||
const { loadVault, loadTree, openDoc } = useStore();
|
||||
const loadVault = useStore(s => s.loadVault);
|
||||
const loadTree = useStore(s => s.loadTree);
|
||||
const openDoc = useStore(s => s.openDoc);
|
||||
const { restore, tabs, activeId } = useTabs();
|
||||
const [rightTab, setRightTab] = useState<'outline' | 'info'>('outline');
|
||||
const [view, setView] = useState<'editor' | 'graph'>('editor');
|
||||
|
||||
@@ -5,7 +5,15 @@ import { IPC } from '@hunter/shared';
|
||||
import type { TreeNode } from '@hunter/shared';
|
||||
|
||||
export function DocInfoPanel() {
|
||||
const { currentPath, title, meta, tree, toggleStar, addTag, removeTag, addLink, removeLink } = useStore();
|
||||
const currentPath = useStore(s => s.currentPath);
|
||||
const title = useStore(s => s.title);
|
||||
const meta = useStore(s => s.meta);
|
||||
const tree = useStore(s => s.tree);
|
||||
const toggleStar = useStore(s => s.toggleStar);
|
||||
const addTag = useStore(s => s.addTag);
|
||||
const removeTag = useStore(s => s.removeTag);
|
||||
const addLink = useStore(s => s.addLink);
|
||||
const removeLink = useStore(s => s.removeLink);
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [linkInput, setLinkInput] = useState('');
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ function saveLabel(s: SaveState): string {
|
||||
}
|
||||
|
||||
export function Editor() {
|
||||
const { currentPath, title, saveState } = useStore();
|
||||
const currentPath = useStore(s => s.currentPath);
|
||||
const title = useStore(s => s.title);
|
||||
const saveState = useStore(s => s.saveState);
|
||||
if (!currentPath) {
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0.4 }}>
|
||||
|
||||
@@ -4,7 +4,9 @@ import { IPC } from '@hunter/shared';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function MoreActions() {
|
||||
const { currentPath, body, openDoc } = useStore();
|
||||
const currentPath = useStore(s => s.currentPath);
|
||||
const body = useStore(s => s.body);
|
||||
const openDoc = useStore(s => s.openDoc);
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!currentPath) return null;
|
||||
// FR-X1:复制全文——成功/失败均提示
|
||||
|
||||
@@ -40,7 +40,8 @@ const Img = Image.extend({
|
||||
});
|
||||
|
||||
export function TiptapEditor() {
|
||||
const { currentPath, body, setBody } = useStore();
|
||||
const currentPath = useStore(s => s.currentPath);
|
||||
const setBody = useStore(s => s.setBody);
|
||||
const skipUpdate = useRef(false);
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
|
||||
@@ -61,7 +62,7 @@ export function TiptapEditor() {
|
||||
MathBlock,
|
||||
InlineMath,
|
||||
],
|
||||
content: body,
|
||||
content: useStore.getState().body,
|
||||
editorProps: {
|
||||
// FR-E6:剪贴板粘贴图片 -> 入库 .attachments -> 插入相对路径
|
||||
handlePaste: (_view, event) => {
|
||||
@@ -104,6 +105,7 @@ export function TiptapEditor() {
|
||||
return;
|
||||
}
|
||||
useEditorShared.getState().setEditor(editor);
|
||||
const body = useStore.getState().body;
|
||||
skipUpdate.current = true;
|
||||
editor.commands.setContent(body || '', { emitUpdate: false });
|
||||
skipUpdate.current = false;
|
||||
|
||||
@@ -7,7 +7,13 @@ import { useDialog } from '../dialog/dialog-store';
|
||||
import { useTabs } from '../tabs/tab-store';
|
||||
|
||||
export function FileTree() {
|
||||
const { tree, loadTree, treeLoading, openDoc, currentPath, syncCurrentPath, tagFilter } = useStore();
|
||||
const tree = useStore(s => s.tree);
|
||||
const loadTree = useStore(s => s.loadTree);
|
||||
const treeLoading = useStore(s => s.treeLoading);
|
||||
const openDoc = useStore(s => s.openDoc);
|
||||
const currentPath = useStore(s => s.currentPath);
|
||||
const syncCurrentPath = useStore(s => s.syncCurrentPath);
|
||||
const tagFilter = useStore(s => s.tagFilter);
|
||||
const { showInput, showConfirm, showAlert } = useDialog();
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
|
||||
@@ -53,7 +53,8 @@ const MARKMAP_OPTIONS = {
|
||||
function MindmapTree() {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const mmRef = useRef<Markmap | null>(null);
|
||||
const { tree, openDoc } = useStore();
|
||||
const tree = useStore(s => s.tree);
|
||||
const openDoc = useStore(s => s.openDoc);
|
||||
|
||||
useEffect(() => {
|
||||
const svg = svgRef.current;
|
||||
|
||||
@@ -13,7 +13,7 @@ const truncate = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '
|
||||
|
||||
export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void }) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const { openDoc } = useStore();
|
||||
const openDoc = useStore(s => s.openDoc);
|
||||
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);
|
||||
|
||||
@@ -12,7 +12,7 @@ export function OutlinePanel() {
|
||||
const [activeIdx, setActiveIdx] = useState(-1);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 收集标题——currentPath 变化时重新收集(setContent 用 emitUpdate:false 不触发 update 事件)
|
||||
// 收集标题——debounce 300ms,避免每次按键都遍历文档树
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const doCollect = () => {
|
||||
@@ -24,11 +24,11 @@ export function OutlinePanel() {
|
||||
});
|
||||
setHeadings(items);
|
||||
};
|
||||
// rAF 延迟到下一帧,确保 TiptapEditor 的 setContent 已完成
|
||||
const raf = requestAnimationFrame(doCollect);
|
||||
// 编辑时实时跟随
|
||||
editor.on('update', doCollect);
|
||||
return () => { editor.off('update', doCollect); cancelAnimationFrame(raf); };
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const debounced = () => { if (timer) clearTimeout(timer); timer = setTimeout(doCollect, 300); };
|
||||
editor.on('update', debounced);
|
||||
return () => { editor.off('update', debounced); cancelAnimationFrame(raf); if (timer) clearTimeout(timer); };
|
||||
}, [editor, currentPath]);
|
||||
|
||||
// FR-O3:编辑器滚动联动——计算当前可见标题,高亮大纲并自动滚动大纲
|
||||
@@ -43,24 +43,30 @@ export function OutlinePanel() {
|
||||
}
|
||||
if (!scrollEl) return;
|
||||
|
||||
// rAF 节流——滚动事件高频触发,coordsAtPos 计算开销大
|
||||
let ticking = false;
|
||||
const onScroll = () => {
|
||||
const containerTop = scrollEl!.getBoundingClientRect().top;
|
||||
let current = 0; // 默认第一个标题
|
||||
for (let i = 0; i < headings.length; i++) {
|
||||
const coords = editor!.view.coordsAtPos(headings[i].pos + 1);
|
||||
if (coords.top - containerTop <= 100) current = i;
|
||||
else break;
|
||||
}
|
||||
if (current !== activeRef.current) {
|
||||
activeRef.current = current;
|
||||
setActiveIdx(current);
|
||||
// 大纲自身滚动到当前项
|
||||
const el = listRef.current?.querySelector(`[data-idx="${current}"]`) as HTMLElement | null;
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
requestAnimationFrame(() => {
|
||||
const containerTop = scrollEl!.getBoundingClientRect().top;
|
||||
let current = 0;
|
||||
for (let i = 0; i < headings.length; i++) {
|
||||
const coords = editor!.view.coordsAtPos(headings[i].pos + 1);
|
||||
if (coords.top - containerTop <= 100) current = i;
|
||||
else break;
|
||||
}
|
||||
if (current !== activeRef.current) {
|
||||
activeRef.current = current;
|
||||
setActiveIdx(current);
|
||||
const el = listRef.current?.querySelector(`[data-idx="${current}"]`) as HTMLElement | null;
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
ticking = false;
|
||||
});
|
||||
};
|
||||
scrollEl.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll(); // 初始计算
|
||||
onScroll();
|
||||
return () => { scrollEl!.removeEventListener('scroll', onScroll); };
|
||||
}, [editor, headings]);
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import type { SearchHit } from '@hunter/shared';
|
||||
import { useStore } from '../../store';
|
||||
|
||||
export function SearchBox() {
|
||||
const { openDoc, loadTree } = useStore();
|
||||
const openDoc = useStore(s => s.openDoc);
|
||||
const loadTree = useStore(s => s.loadTree);
|
||||
const [q, setQ] = useState('');
|
||||
const [hits, setHits] = useState<SearchHit[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { IPC, type TagStat } from '@hunter/shared';
|
||||
import { useStore } from '../../store';
|
||||
|
||||
export function TagFilter() {
|
||||
const { tree } = useStore();
|
||||
const tree = useStore(s => s.tree);
|
||||
const [tags, setTags] = useState<TagStat[]>([]);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
@@ -103,7 +103,9 @@ function dot(on: boolean): React.CSSProperties { return { position: 'absolute',
|
||||
|
||||
// FR-V5: 默认库路径设置页——查看当前库、浏览选择、立即切换
|
||||
function PathPage() {
|
||||
const { vault, loadVault, loadTree } = useStore();
|
||||
const vault = useStore(s => s.vault);
|
||||
const loadVault = useStore(s => s.loadVault);
|
||||
const loadTree = useStore(s => s.loadTree);
|
||||
const [val, setVal] = useState(vault?.currentRoot ?? '');
|
||||
const [msg, setMsg] = useState('');
|
||||
const browse = async () => { const p = await invoke(IPC.vaultBrowse, undefined as never); if (p) setVal(p); };
|
||||
|
||||
@@ -7,7 +7,7 @@ const drag = { WebkitAppRegion: 'drag', flex: 1, height: '100%' } as React.CSSPr
|
||||
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();
|
||||
const loadTree = useStore(s => s.loadTree);
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
useEffect(() => { invoke(IPC.winIsMaximized, undefined as never).then(setMaximized).catch(() => {}); }, []);
|
||||
return (
|
||||
|
||||
@@ -4,11 +4,12 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
export function TabBar() {
|
||||
const { tabs, activeId, setActive, closeTab, togglePin } = useTabs();
|
||||
const { openDoc, currentPath } = useStore();
|
||||
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 变化,加载对应标签的文档;标签全部关闭时清空编辑器
|
||||
const resetEditor = useStore(s => s.resetEditor);
|
||||
useEffect(() => {
|
||||
const tab = tabs.find(t => t.id === activeId);
|
||||
if (tab && tab.path !== currentPath) openDoc(tab.path);
|
||||
|
||||
Reference in New Issue
Block a user