Files
MyNote/apps/desktop/src/renderer/store.ts
T
hunter-dev dc8d844a1f feat(info): 信息面板 UI 重做 + 双向批量关联 + 代码块内全选复制
1. 信息面板 UI 重做:设计 token 统一、lucide 图标、分区小节、星标/标签/关联 chip 化
2. 关联文档升级:新增 doc:link-batch IPC,A↔B 双向写入 links;面板支持快速双向关联 + 批量勾选关联/解除
3. 代码块内全选复制:Ctrl+A 在代码块内仅全选该代码块(TyporaKeys selectInsideCodeBlock),Ctrl+C 复制纯代码;代码块内 Ctrl+V 按纯文本粘贴
4. 修复 React error #62:btn 改为函数后遗漏 style={btn} 裸用,导致 render 期间更新
2026-09-09 20:11:08 +08:00

154 lines
5.9 KiB
TypeScript

import { create } from 'zustand';
import { IPC } from '@hunter/shared';
import type { TreeNode, VaultMeta, DocMeta, ReadDocResult } from '@hunter/shared';
import { invoke } from './ipc-client';
import { useTabs } from './features/tabs/tab-store';
export type SaveState = 'saved' | 'saving' | 'error';
interface EditorState {
vault: VaultMeta | null;
tree: TreeNode[];
treeLoading: boolean;
currentPath: string | null;
title: string;
body: string;
meta: DocMeta;
saveState: SaveState;
docMtime: number; // FR-S6:已加载文档的 mtime,用于检测外部改动
tagFilter: string[]; // FR-R7:选中标签列表,FileTree 据此 AND 过滤
// actions
loadVault: () => Promise<void>;
loadTree: () => Promise<void>;
openDoc: (path: string) => Promise<void>;
setBody: (body: string) => void;
setMeta: (meta: DocMeta) => void;
addTag: (tag: string) => void;
removeTag: (tag: string) => void;
toggleStar: () => void;
addLink: (link: string) => void;
removeLink: (link: string) => void;
linkBatch: (targets: string[], remove: boolean) => Promise<void>;
saveNow: () => Promise<void>;
reloadDoc: () => Promise<boolean>; // FR-S6:重新加载——检测 mtime 变化,有未保存改动时返回 false 需确认
resetEditor: () => void; // FR-V6: 切库时重置编辑区
syncCurrentPath: (oldPath: string, newPath: string) => void; // FR-T7: 重命名/移动后同步当前文档路径
}
// 简单的每文档保存队列(M1 单文档编辑)
let saveTimer: ReturnType<typeof setTimeout> | null = null;
export const useStore = create<EditorState>((set, get) => ({
vault: null,
tree: [],
treeLoading: false,
currentPath: null,
title: '',
body: '',
meta: {},
saveState: 'saved',
tagFilter: [],
docMtime: 0,
loadVault: async () => {
const vault = await invoke(IPC.vaultList, undefined as never);
set({ vault });
},
loadTree: async () => {
set({ treeLoading: true });
try {
const tree = await invoke(IPC.treeList, undefined as never);
set({ tree, treeLoading: false });
} catch (e) {
set({ treeLoading: false });
console.error('loadTree failed', e);
}
},
openDoc: async (path: string) => {
// 切换前冲刷当前文档——先同步编辑器内容到 store 再保存
window.dispatchEvent(new CustomEvent('dsh-flush-body'));
void get().saveNow();
const doc: ReadDocResult = await invoke(IPC.docRead, { path });
set({ currentPath: path, body: doc.body, meta: doc.meta, title: doc.meta.title ?? path.split('/').pop()!.replace(/\.md$/, ''), saveState: 'saved', docMtime: doc.mtime });
// 同步到标签页(如已存在则激活,否则新建)
useTabs.getState().openTab(path);
},
setMeta: (meta: DocMeta) => { set({ meta }); get().saveNow(); },
addTag: (tag: string) => {
const tags = [...(get().meta.tags ?? []), tag].filter((t, i, a) => a.indexOf(t) === i);
set({ meta: { ...get().meta, tags } });
get().saveNow();
},
removeTag: (tag: string) => {
const tags = (get().meta.tags ?? []).filter(t => t !== tag);
set({ meta: { ...get().meta, tags } });
get().saveNow();
},
toggleStar: () => {
set({ meta: { ...get().meta, star: !get().meta.star } });
get().saveNow();
},
addLink: (link: string) => { void get().linkBatch([link], false); },
removeLink: (link: string) => { void get().linkBatch([link], true); },
// 双向批量关联:先冲刷编辑器内容落盘,再由主进程把当前文档与每个目标文档互相写入 links
linkBatch: async (targets: string[], remove: boolean) => {
const { currentPath } = get();
if (!currentPath || targets.length === 0) return;
window.dispatchEvent(new CustomEvent('dsh-flush-body'));
await get().saveNow();
try {
const res = await invoke(IPC.docLinkBatch, { path: currentPath, targets, remove });
set({ meta: { ...get().meta, links: res.links } });
} catch (e) {
console.error('linkBatch failed', e);
}
},
setBody: (body: string) => {
set({ body, saveState: 'saving' });
// FR-S1:防抖自动保存——间隔读 settings.autoSaveInterval(秒→毫秒)
if (saveTimer) clearTimeout(saveTimer);
const interval = (() => { try { const s = JSON.parse(localStorage.getItem('dsh-settings') ?? '{}'); return (s.autoSaveInterval ?? 3) * 1000; } catch { return 3000; } })();
saveTimer = setTimeout(() => { get().saveNow(); }, interval);
},
saveNow: async () => {
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
const { currentPath, body, meta } = get();
if (!currentPath) return;
try {
await invoke(IPC.docWrite, { path: currentPath, meta, body });
set({ saveState: 'saved' });
} catch (e) {
set({ saveState: 'error' });
console.error('save failed', e);
}
},
// FR-S6:重新加载文档——检测 mtime 变化,有未保存改动时返回 false 需确认
reloadDoc: async () => {
const { currentPath, docMtime, saveState } = get();
if (!currentPath) return false;
try {
const doc: ReadDocResult = await invoke(IPC.docRead, { path: currentPath });
if (doc.mtime === docMtime) return true; // 未变
if (saveState !== 'saved') return false; // 有未保存改动,需确认
set({ body: doc.body, meta: doc.meta, docMtime: doc.mtime, saveState: 'saved' });
return true;
} catch { return false; }
},
resetEditor: () => { set({ currentPath: null, title: '', body: '', meta: {}, saveState: 'saved', docMtime: 0 }); },
syncCurrentPath: (oldPath, newPath) => {
const { currentPath } = get();
if (!currentPath) return;
if (currentPath === oldPath) { set({ currentPath: newPath }); return; }
if (oldPath && currentPath.startsWith(oldPath + '/')) set({ currentPath: newPath + currentPath.slice(oldPath.length) });
},
}));
// FR-S6:暴露 store 供 E2E 验证
if (typeof window !== 'undefined') (window as any).__STORE__ = useStore;