M0-M11 狩夜人笔记初始实现:11里程碑+86测试
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
// main 进程入口(M1:创建窗口 + 初始化库 + 注册 IPC)
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
import nodePath from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { VaultService } from './services/vault-service.js';
|
||||
import { registerIpc } from './ipc/handlers.js';
|
||||
import { IPC } from '@hunter/shared';
|
||||
|
||||
const __dirname = nodePath.dirname(fileURLToPath(import.meta.url));
|
||||
let win: BrowserWindow | null = null;
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
width: 1200, height: 760, minWidth: 900, minHeight: 580,
|
||||
center: true, frame: false, show: false,
|
||||
webPreferences: {
|
||||
preload: nodePath.join(__dirname, '../preload/index.mjs'),
|
||||
contextIsolation: true, nodeIntegration: false, sandbox: false
|
||||
}
|
||||
});
|
||||
win.on('ready-to-show', () => win?.show());
|
||||
win.on('closed', () => { win = null; });
|
||||
// 窗口控制(FR-W2)
|
||||
ipcMain.on(IPC.winMinimize, () => win?.minimize());
|
||||
ipcMain.on(IPC.winMaximize, () => { if (win?.isMaximized()) win.unmaximize(); else win?.maximize(); });
|
||||
ipcMain.on(IPC.winClose, () => win?.close());
|
||||
if (process.env.ELECTRON_RENDERER_URL) win.loadURL(process.env.ELECTRON_RENDERER_URL);
|
||||
else win.loadFile(nodePath.join(__dirname, '../renderer/index.html'));
|
||||
return win;
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const vault = new VaultService();
|
||||
await vault.init(); // 确保有当前库
|
||||
registerIpc(vault);
|
||||
createWindow();
|
||||
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
|
||||
});
|
||||
|
||||
// 关闭前冲刷:渲染层监听窗口 close 事件自行 flush(FR-S4),M1 先以关闭前 onbeforeunload 兜底
|
||||
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
|
||||
@@ -0,0 +1,65 @@
|
||||
// IPC handlers — 主进程侧通道注册(类型链约束于 @hunter/shared)
|
||||
import nodePath from 'node:path';
|
||||
import { ipcMain, shell, dialog, clipboard } from 'electron';
|
||||
import { IPC } from '@hunter/shared';
|
||||
import type { VaultService } from '../services/vault-service.js';
|
||||
import { buildGraphData } from '../services/graph-builder.js';
|
||||
|
||||
export function registerIpc(vault: VaultService) {
|
||||
const fs2 = () => {
|
||||
if (!vault.fileService) throw new Error('vault not initialized');
|
||||
return vault.fileService;
|
||||
};
|
||||
|
||||
ipcMain.handle(IPC.vaultList, async () => vault.getMeta());
|
||||
ipcMain.handle(IPC.vaultSwitch, async (_e, p: { root: string }) => vault.switch(p.root));
|
||||
ipcMain.handle(IPC.vaultSetDefault, async (_e, p: { root: string }) => { await vault.setDefaultRoot(p.root); });
|
||||
ipcMain.handle(IPC.vaultPick, async () => vault.pickAndSwitch());
|
||||
|
||||
ipcMain.handle(IPC.treeList, async () => fs2().listTree());
|
||||
ipcMain.handle(IPC.treeRefresh, async () => fs2().listTree());
|
||||
|
||||
ipcMain.handle(IPC.fileCreate, async (_e, p: { basePath: string; name: string; isFolder: boolean }) =>
|
||||
fs2().create(p.basePath, p.name, p.isFolder));
|
||||
ipcMain.handle(IPC.fileRename, async (_e, p: { from: string; toName: string }) =>
|
||||
fs2().rename(p.from, p.toName));
|
||||
ipcMain.handle(IPC.fileMove, async (_e, p: { src: string; destDir: string }) =>
|
||||
fs2().move(p.src, p.destDir));
|
||||
ipcMain.handle(IPC.fileDelete, async (_e, p: { path: string; recursive: boolean }) =>
|
||||
fs2().delete(p.path, p.recursive));
|
||||
ipcMain.handle(IPC.fileImport, async (_e, p: { sources: string[]; destDir: string }) => {
|
||||
// 若 sources 为空,弹出系统选择对话框
|
||||
let sources = p.sources;
|
||||
if (sources.length === 0) {
|
||||
const r = await dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'], filters: [{ name: 'Markdown', extensions: ['md'] }] });
|
||||
if (r.canceled) return { ok: [], failed: [] };
|
||||
sources = r.filePaths;
|
||||
}
|
||||
return fs2().importFiles(sources, p.destDir);
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC.docRead, async (_e, p: { path: string }) => fs2().readDoc(p.path));
|
||||
ipcMain.handle(IPC.docWrite, async (_e, p: { path: string; meta: any; body: string }) =>
|
||||
fs2().writeDoc({ path: p.path, meta: p.meta, body: p.body }));
|
||||
ipcMain.handle(IPC.search, async (_e, p: { query: string }) => vault.searchService?.search(p.query) ?? []);
|
||||
ipcMain.handle(IPC.tagStats, async () => vault.searchService?.tagStats() ?? []);
|
||||
ipcMain.handle(IPC.graph, async () => {
|
||||
const fs2 = vault.fileService;
|
||||
if (!fs2) return { nodes: [], edges: [] };
|
||||
const tree = await fs2.listTree();
|
||||
return buildGraphData(fs2.getRoot(), tree);
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC.copyMd, async (_e, p: { path: string }) => {
|
||||
const fs2 = vault.fileService; if (!fs2) return '';
|
||||
const { body } = await fs2.readDoc(p.path);
|
||||
clipboard.writeText(body);
|
||||
return body;
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC.docReveal, async (_e, p: { path: string }) => {
|
||||
const fs2 = vault.fileService;
|
||||
if (!fs2) return;
|
||||
shell.showItemInFolder(nodePath.join(fs2.getRoot(), p.path));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// AtomicWrite — 先写临时文件再整体替换,防中断写坏原文件(FR-S5)
|
||||
import nodePath from 'node:path';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { TMP_SUFFIX } from '@hunter/shared';
|
||||
|
||||
export async function writeFileAtomic(absPath: string, content: string): Promise<void> {
|
||||
const tmp = absPath + TMP_SUFFIX;
|
||||
// 确保父目录存在(新文件或跨目录保存时父目录可能尚未创建)
|
||||
await fs.mkdir(nodePath.dirname(absPath), { recursive: true });
|
||||
// 写临时文件并 fsync,保证落盘
|
||||
const handle = await fs.open(tmp, 'w', 0o666);
|
||||
try {
|
||||
await handle.writeFile(content, 'utf8');
|
||||
await handle.sync(); // 强制刷盘
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
// 同卷 rename 原子替换
|
||||
await fs.rename(tmp, absPath);
|
||||
}
|
||||
|
||||
// 并发安全:每文件一个串行队列,防止防抖保存与冲刷交错
|
||||
const queues = new Map<string, Promise<unknown>>();
|
||||
export function withFileLock<T>(absPath: string, task: () => Promise<T>): Promise<T> {
|
||||
const prev = queues.get(absPath) ?? Promise.resolve();
|
||||
const next = prev.then(task, task); // 无论成功失败都继续
|
||||
queues.set(absPath, next.then(() => undefined, () => undefined));
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// FrontmatterCodec — 文件头 frontmatter 解析/序列化(FR-M1/M2)
|
||||
// 仅 4 个业务字段:title? / tags? / star? / links?;空数组与默认值不落盘
|
||||
import type { DocMeta } from './doc.js';
|
||||
|
||||
const FENCE = '---';
|
||||
|
||||
// 解析:返回 { meta, body };无 frontmatter 时 meta={} body=原文
|
||||
export function splitFrontmatter(raw: string): { meta: DocMeta; body: string } {
|
||||
// 必须以 --- 开头(允许前导空白行后紧跟围栏)
|
||||
const m = raw.match(/^\s*---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||
if (!m) return { meta: {}, body: raw };
|
||||
const yaml = m[1];
|
||||
const body = m[2];
|
||||
return { meta: parseYaml(yaml), body };
|
||||
}
|
||||
|
||||
// 序列化:把 meta + body 合并为完整文件文本;默认值与空数组不写入
|
||||
export function joinFrontmatter(meta: DocMeta, body: string): string {
|
||||
const yaml = serializeYaml(meta);
|
||||
if (!yaml) return body;
|
||||
return `${FENCE}\n${yaml}${FENCE}\n${body}`;
|
||||
}
|
||||
|
||||
// ---- 极简 YAML 子集解析(只支持本项目 4 字段的形态)----
|
||||
function parseYaml(yaml: string): DocMeta {
|
||||
const meta: DocMeta = {};
|
||||
const lines = yaml.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || line.trim().startsWith('#')) continue;
|
||||
const kv = line.match(/^([a-zA-Z]+):\s*(.*)$/);
|
||||
if (!kv) continue;
|
||||
const [, key, val] = kv;
|
||||
const v = val.trim();
|
||||
if (key === 'title') meta.title = unquote(v);
|
||||
else if (key === 'star') meta.star = v === 'true';
|
||||
else if (key === 'tags') meta.tags = parseList(v);
|
||||
else if (key === 'links') meta.links = parseList(v);
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
function parseList(v: string): string[] {
|
||||
// 内联数组 [a, b] 或流式;本项目只用内联
|
||||
const m = v.match(/^\[\s*(.*)\s*\]$/);
|
||||
if (!m) return [];
|
||||
const inner = m[1].trim();
|
||||
if (!inner) return [];
|
||||
return inner.split(',').map(s => unquote(s.trim())).filter(Boolean);
|
||||
}
|
||||
|
||||
function unquote(s: string): string {
|
||||
const m = s.match(/^['"](.*)['"]$/);
|
||||
return m ? m[1] : s;
|
||||
}
|
||||
|
||||
// ---- 序列化:剔除默认值与空数组 ----
|
||||
function serializeYaml(meta: DocMeta): string {
|
||||
const lines: string[] = [];
|
||||
if (meta.title !== undefined && meta.title !== '') lines.push(`title: ${quoteIfNeeded(meta.title)}`);
|
||||
if (meta.tags && meta.tags.length > 0) lines.push(`tags: [${meta.tags.map(quoteIfNeeded).join(', ')}]`);
|
||||
if (meta.star === true) lines.push('star: true');
|
||||
if (meta.links && meta.links.length > 0) lines.push(`links: [${meta.links.map(quoteIfNeeded).join(', ')}]`);
|
||||
return lines.length ? lines.join('\n') + '\n' : '';
|
||||
}
|
||||
|
||||
function quoteIfNeeded(s: string): string {
|
||||
// 含特殊字符(冒号/井号/方括号/引号/前后空格)则加引号
|
||||
if (/[:#\[\]"']|^\s|\s$/.test(s)) return JSON.stringify(s);
|
||||
return s;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// SafePath — 将所有文件操作圈禁在库根之内
|
||||
// 对应需求:FR-F15 路径严格限定在库根范围内
|
||||
import nodePath from 'node:path';
|
||||
import { promises as fs } from 'node:fs';
|
||||
|
||||
export class IllegalPathError extends Error {
|
||||
constructor(target: string) {
|
||||
super(`path escapes vault root: ${target}`);
|
||||
this.name = 'IllegalPathError';
|
||||
}
|
||||
}
|
||||
|
||||
// 非法字符净化:把系统不允许的字符替换为下划线(FR-F13)
|
||||
const ILLEGAL = /[<>:"/\\|?*\u0000-\u001f]/g;
|
||||
export function sanitizeName(name: string): string {
|
||||
let n = name.replace(ILLEGAL, '_').trim();
|
||||
if (!n) n = '未命名';
|
||||
return n;
|
||||
}
|
||||
|
||||
// 相对路径越界校验:解析后不得逃出库根
|
||||
export function assertInsideVault(root: string, rel: string): string {
|
||||
// 拒绝绝对路径与盘符前缀
|
||||
if (nodePath.isAbsolute(rel) || /^[a-zA-Z]:/.test(rel)) {
|
||||
throw new IllegalPathError(rel);
|
||||
}
|
||||
const resolved = nodePath.resolve(root, rel);
|
||||
const relFromRoot = nodePath.relative(root, resolved);
|
||||
if (relFromRoot.startsWith('..') || nodePath.isAbsolute(relFromRoot)) {
|
||||
throw new IllegalPathError(rel);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// 防 symlink 逃逸:解析真实路径后再校验一次
|
||||
export async function assertRealInsideVault(root: string, rel: string): Promise<string> {
|
||||
const resolved = assertInsideVault(root, rel);
|
||||
const rootReal = await fs.realpath(root).catch(() => root);
|
||||
const real = await fs.realpath(resolved).catch(() => resolved);
|
||||
const relFromRoot = nodePath.relative(rootReal, real);
|
||||
if (relFromRoot.startsWith('..') || nodePath.isAbsolute(relFromRoot)) {
|
||||
throw new IllegalPathError(rel);
|
||||
}
|
||||
return real;
|
||||
}
|
||||
|
||||
// 重名自动追加序号(FR-F14):在 destDir 内为 name 寻找不冲突名
|
||||
export async function dedupeName(root: string, dir: string, name: string): Promise<string> {
|
||||
const base = name;
|
||||
const ext = nodePath.extname(name);
|
||||
const stem = ext ? base.slice(0, -ext.length) : base;
|
||||
let candidate = name;
|
||||
let i = 1;
|
||||
const dirAbs = nodePath.resolve(root, dir);
|
||||
for (;;) {
|
||||
try {
|
||||
await fs.access(nodePath.join(dirAbs, candidate)); // 存在则继续
|
||||
candidate = `${stem}-${i}${ext}`;
|
||||
i++;
|
||||
} catch {
|
||||
return candidate; // 不存在,可用
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// 智能取色:分析背景图亮度/主色 → 推荐深浅模式与强调色(FR-A9)
|
||||
// 纯函数,输入采样后的像素数据,输出推荐参数
|
||||
|
||||
export interface ImageSample {
|
||||
/** 采样像素的 [r,g,b] 列表(已降采样) */
|
||||
pixels: [number, number, number][];
|
||||
}
|
||||
|
||||
export interface SmartColorResult {
|
||||
mode: 'dark' | 'light';
|
||||
accentHue: number; // 0-360,约束在冷色/紫红区间
|
||||
accent: string; // hex 强调色
|
||||
suggestedBgOpacity: number; // 0-1
|
||||
suggestedBlur: number; // px
|
||||
suggestedSurfaceOpacity: number; // 0-1
|
||||
}
|
||||
|
||||
// 相对亮度(Rec.709)
|
||||
function luminance(r: number, g: number, b: number): number {
|
||||
const f = (c: number) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
||||
}
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
|
||||
r /= 255; g /= 255; b /= 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
let h = 0; const l = (max + min) / 2; const d = max - min; const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
|
||||
if (d !== 0) {
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d) % 6; break;
|
||||
case g: h = (b - r) / d + 2; break;
|
||||
default: h = (r - g) / d + 4;
|
||||
}
|
||||
h *= 60; if (h < 0) h += 360;
|
||||
}
|
||||
return [h, s, l];
|
||||
}
|
||||
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s; const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const m = l - c / 2;
|
||||
let r = 0, g = 0, b = 0;
|
||||
if (h < 60) [r, g, b] = [c, x, 0]; else if (h < 120) [r, g, b] = [x, c, 0];
|
||||
else if (h < 180) [r, g, b] = [0, c, x]; else if (h < 240) [r, g, b] = [0, x, c];
|
||||
else if (h < 300) [r, g, b] = [x, 0, c]; else [r, g, b] = [c, 0, x];
|
||||
const to = (v: number) => Math.round((v + m) * 255).toString(16).padStart(2, '0');
|
||||
return `#${to(r)}${to(g)}${to(b)}`;
|
||||
}
|
||||
|
||||
// 色相约束:冷色(180-270) 与紫红(280-330) 区间,否则就近映射
|
||||
function constrainHue(h: number): number {
|
||||
if (h >= 180 && h <= 330) return h;
|
||||
// 映射到最近区间端点
|
||||
if (h < 180) return h < 90 ? 200 : 210; // 暖色偏冷蓝
|
||||
return 290; // >330 映射紫红
|
||||
}
|
||||
|
||||
export function analyzeImage(sample: ImageSample): SmartColorResult {
|
||||
const { pixels } = sample;
|
||||
if (pixels.length === 0) {
|
||||
return { mode: 'dark', accentHue: 210, accent: '#4a9eff', suggestedBgOpacity: 0.6, suggestedBlur: 20, suggestedSurfaceOpacity: 0.5 };
|
||||
}
|
||||
// 平均亮度
|
||||
const avgLum = pixels.reduce((s, p) => s + luminance(p[0], p[1], p[2]), 0) / pixels.length;
|
||||
const mode: 'dark' | 'light' = avgLum < 0.5 ? 'dark' : 'light';
|
||||
// 主色相:色相直方图(12 桶),取高频且饱和度足够
|
||||
const buckets = new Array(12).fill(0);
|
||||
let dominantHue = 210;
|
||||
for (const p of pixels) {
|
||||
const [h, s] = rgbToHsl(p[0], p[1], p[2]);
|
||||
if (s > 0.15) buckets[Math.floor(h / 30) % 12]++;
|
||||
}
|
||||
let max = 0;
|
||||
for (let i = 0; i < 12; i++) if (buckets[i] > max) { max = buckets[i]; dominantHue = i * 30 + 15; }
|
||||
const accentHue = constrainHue(dominantHue);
|
||||
const accent = hslToHex(accentHue, 0.7, mode === 'dark' ? 0.6 : 0.45);
|
||||
// 依亮度推导不透明度
|
||||
const suggestedBgOpacity = Math.round(Math.max(0.3, Math.min(0.8, 0.5 + (0.5 - avgLum) * 0.4)) * 100) / 100;
|
||||
const suggestedBlur = avgLum < 0.4 ? 24 : avgLum > 0.7 ? 12 : 18;
|
||||
const suggestedSurfaceOpacity = mode === 'dark' ? 0.5 : 0.7;
|
||||
return { mode, accentHue, accent, suggestedBgOpacity, suggestedBlur, suggestedSurfaceOpacity };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// ConfigStore — 应用配置 JSON 持久化(vault meta、最近库)
|
||||
import nodePath from 'node:path';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import type { VaultMeta } from '@hunter/shared';
|
||||
import { RECENT_VAULT_MAX } from '@hunter/shared';
|
||||
|
||||
export class ConfigStore {
|
||||
private file: string;
|
||||
private cache: { vault?: VaultMeta } | null = null;
|
||||
|
||||
constructor(userDataDir: string) {
|
||||
this.file = nodePath.join(userDataDir, 'config.json');
|
||||
}
|
||||
|
||||
private async read(): Promise<{ vault?: VaultMeta }> {
|
||||
if (this.cache) return this.cache;
|
||||
try {
|
||||
const raw = await fs.readFile(this.file, 'utf8');
|
||||
this.cache = JSON.parse(raw);
|
||||
} catch {
|
||||
this.cache = {};
|
||||
}
|
||||
return this.cache!;
|
||||
}
|
||||
|
||||
private async write(): Promise<void> {
|
||||
const dir = nodePath.dirname(this.file);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const tmp = this.file + '.tmp';
|
||||
await fs.writeFile(tmp, JSON.stringify(this.cache, null, 2), 'utf8');
|
||||
await fs.rename(tmp, this.file);
|
||||
}
|
||||
|
||||
async getVaultMeta(): Promise<VaultMeta> {
|
||||
const c = await this.read();
|
||||
return c.vault ?? { currentRoot: '', recents: [], defaultRoot: '' };
|
||||
}
|
||||
|
||||
async setCurrentRoot(root: string): Promise<VaultMeta> {
|
||||
const meta = await this.getVaultMeta();
|
||||
meta.currentRoot = root;
|
||||
// 加入最近库(去重置顶,截断到上限)
|
||||
meta.recents = [root, ...meta.recents.filter(r => r !== root)].slice(0, RECENT_VAULT_MAX);
|
||||
(await this.read()).vault = meta;
|
||||
await this.write();
|
||||
return meta;
|
||||
}
|
||||
|
||||
async setDefaultRoot(root: string): Promise<VaultMeta> {
|
||||
const meta = await this.getVaultMeta();
|
||||
meta.defaultRoot = root;
|
||||
(await this.read()).vault = meta;
|
||||
await this.write();
|
||||
return meta;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// FileService — 库内 CRUD + 文档读写(FR-V/F/M)
|
||||
import nodePath from 'node:path';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { MD_EXT, ATTACHMENTS_DIR } from '@hunter/shared';
|
||||
import type { TreeNode, ReadDocResult, WriteDocPayload } from '@hunter/shared';
|
||||
import { assertInsideVault, dedupeName, sanitizeName } from '../safe/safe-path.js';
|
||||
import { writeFileAtomic, withFileLock } from '../safe/atomic-write.js';
|
||||
import { splitFrontmatter, joinFrontmatter } from '../safe/frontmatter-codec.js';
|
||||
|
||||
// 相对路径统一用 POSIX 分隔符(跨平台一致,利于存储与比较)
|
||||
const toPosix = (p: string): string => p.split(nodePath.sep).join('/').replace(/\//g, '/');
|
||||
const joinRel = (...parts: string[]): string => toPosix(nodePath.join(...parts));
|
||||
|
||||
export class FileService {
|
||||
constructor(private root: string) {}
|
||||
setRoot(root: string) { this.root = root; }
|
||||
getRoot(): string { return this.root; }
|
||||
private abs(rel: string): string { return assertInsideVault(this.root, rel); }
|
||||
|
||||
// 递归列举库内树(FR-V1/FR-V9)
|
||||
async listTree(): Promise<TreeNode[]> {
|
||||
const out: TreeNode[] = [];
|
||||
const root = this.root;
|
||||
const walk = async (dirAbs: string, dirRel: string) => {
|
||||
let entries = await fs.readdir(dirAbs, { withFileTypes: true });
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
||||
const folders = entries.filter(e => e.isDirectory());
|
||||
const docs = entries.filter(e => e.isFile() && e.name.endsWith(MD_EXT));
|
||||
for (const e of folders) {
|
||||
if (e.name === ATTACHMENTS_DIR || e.name.startsWith('.')) continue;
|
||||
const rel = dirRel ? joinRel(dirRel, e.name) : e.name;
|
||||
const stat = await fs.stat(nodePath.join(dirAbs, e.name));
|
||||
out.push({ path: rel, type: 'folder', name: e.name, parent: dirRel, basePath: rel, mtime: stat.mtimeMs });
|
||||
await walk(nodePath.join(dirAbs, e.name), rel);
|
||||
}
|
||||
for (const e of docs) {
|
||||
const rel = dirRel ? joinRel(dirRel, e.name) : e.name;
|
||||
const stat = await fs.stat(nodePath.join(dirAbs, e.name));
|
||||
out.push({ path: rel, type: 'doc', name: e.name.slice(0, -MD_EXT.length), parent: dirRel, basePath: dirRel, mtime: stat.mtimeMs });
|
||||
}
|
||||
};
|
||||
await walk(root, '');
|
||||
return out;
|
||||
}
|
||||
|
||||
async create(basePath: string, rawName: string, isFolder: boolean) {
|
||||
const name = sanitizeName(rawName) + (isFolder ? '' : MD_EXT);
|
||||
const finalName = await dedupeName(this.root, basePath, name);
|
||||
const rel = basePath ? joinRel(basePath, finalName) : finalName;
|
||||
const abs = this.abs(rel);
|
||||
if (isFolder) await fs.mkdir(abs, { recursive: true });
|
||||
else await writeFileAtomic(abs, '');
|
||||
return { path: rel };
|
||||
}
|
||||
|
||||
async rename(from: string, toName: string) {
|
||||
const abs = this.abs(from);
|
||||
const isFolder = (await fs.stat(abs)).isDirectory();
|
||||
const name = sanitizeName(toName) + (isFolder ? '' : MD_EXT);
|
||||
const dir = nodePath.dirname(from);
|
||||
const finalName = await dedupeName(this.root, dir, name);
|
||||
const toRel = dir ? joinRel(dir, finalName) : finalName;
|
||||
const toAbs = this.abs(toRel);
|
||||
await fs.rename(abs, toAbs);
|
||||
return { path: toRel };
|
||||
}
|
||||
|
||||
async move(src: string, destDir: string) {
|
||||
const srcAbs = this.abs(src);
|
||||
const name = nodePath.basename(src);
|
||||
// 禁止移动到自身内部(FR-F9)
|
||||
if (destDir === src || destDir.startsWith(src + nodePath.sep) || (src.startsWith(destDir + nodePath.sep) && destDir)) {
|
||||
throw new Error('cannot move folder into itself');
|
||||
}
|
||||
const finalName = await dedupeName(this.root, destDir, name);
|
||||
const toRel = destDir ? joinRel(destDir, finalName) : finalName;
|
||||
const toAbs = this.abs(toRel);
|
||||
await fs.mkdir(this.abs(destDir || '.'), { recursive: true });
|
||||
await fs.rename(srcAbs, toAbs);
|
||||
return { path: toRel };
|
||||
}
|
||||
|
||||
async delete(path: string, recursive: boolean) {
|
||||
const abs = this.abs(path);
|
||||
if (recursive) await fs.rm(abs, { recursive: true, force: true });
|
||||
else await fs.unlink(abs);
|
||||
}
|
||||
|
||||
async importFiles(sources: string[], destDir: string) {
|
||||
const ok: string[] = [];
|
||||
const failed: { path: string; reason: string }[] = [];
|
||||
for (const src of sources) {
|
||||
try {
|
||||
const name = nodePath.basename(src);
|
||||
// 递归复制
|
||||
const dest = await this.copyEntry(src, destDir, name);
|
||||
ok.push(dest);
|
||||
} catch (e) {
|
||||
failed.push({ path: src, reason: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
return { ok, failed };
|
||||
}
|
||||
|
||||
private async copyEntry(srcAbs: string, destDirRel: string, name: string): Promise<string> {
|
||||
const stat = await fs.stat(srcAbs);
|
||||
const finalName = await dedupeName(this.root, destDirRel, name);
|
||||
const destRel = destDirRel ? joinRel(destDirRel, finalName) : finalName;
|
||||
const destAbs = this.abs(destRel);
|
||||
if (stat.isDirectory()) {
|
||||
await fs.mkdir(destAbs, { recursive: true });
|
||||
const entries = await fs.readdir(srcAbs, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
await this.copyEntry(nodePath.join(srcAbs, e.name), destRel, e.name);
|
||||
}
|
||||
} else {
|
||||
await fs.copyFile(srcAbs, destAbs);
|
||||
}
|
||||
return destRel;
|
||||
}
|
||||
|
||||
async readDoc(path: string): Promise<ReadDocResult> {
|
||||
const abs = this.abs(path);
|
||||
const raw = await fs.readFile(abs, 'utf8');
|
||||
const { meta, body } = splitFrontmatter(raw);
|
||||
return { meta, body };
|
||||
}
|
||||
|
||||
async writeDoc(payload: WriteDocPayload): Promise<void> {
|
||||
const abs = this.abs(payload.path);
|
||||
const content = joinFrontmatter(payload.meta, payload.body);
|
||||
return withFileLock(abs, () => writeFileAtomic(abs, content));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// 知识图谱数据构建(FR-G1/G2/G3):节点=文档,边=关联链接,按顶层分组着色
|
||||
import type { TreeNode } from '@hunter/shared';
|
||||
import { splitFrontmatter } from '../safe/frontmatter-codec.js';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import nodePath from 'node:path';
|
||||
|
||||
export interface GraphNode { id: string; title: string; basePath: string; star: boolean; group: string; color: string; }
|
||||
export interface GraphEdge { source: string; target: string; }
|
||||
export interface GraphData { nodes: GraphNode[]; edges: GraphEdge[]; }
|
||||
|
||||
// 12 色轮换
|
||||
const COLORS = ['#e6194b','#3cb44b','#ffe119','#4363d8','#f58231','#911eb4','#46f0f0','#f032e6','#bcf60c','#fabebe','#008080','#e6beff'];
|
||||
|
||||
export async function buildGraphData(root: string, tree: TreeNode[]): Promise<GraphData> {
|
||||
const docs = tree.filter(n => n.type === 'doc');
|
||||
const topFolderOf = (p: string) => p.includes('/') ? p.split('/')[0] : '(根目录)';
|
||||
const groupColor = new Map<string, string>();
|
||||
const groups = [...new Set(docs.map(d => topFolderOf(d.path)))];
|
||||
groups.forEach((g, i) => groupColor.set(g, COLORS[i % COLORS.length]));
|
||||
|
||||
// 读取每篇文档 frontmatter 取 title/star/links
|
||||
const nodes: GraphNode[] = [];
|
||||
const linkMap = new Map<string, string[]>();
|
||||
for (const d of docs) {
|
||||
const abs = nodePath.join(root, ...d.path.split('/'));
|
||||
const raw = await fs.readFile(abs, 'utf8').catch(() => '');
|
||||
const { meta } = splitFrontmatter(raw);
|
||||
const group = topFolderOf(d.path);
|
||||
nodes.push({ id: d.path, title: meta.title ?? d.name, basePath: d.basePath, star: !!meta.star, group, color: groupColor.get(group)! });
|
||||
if (meta.links) linkMap.set(d.path, meta.links);
|
||||
}
|
||||
|
||||
// 边:仅目标存在且 .md 结尾的关联
|
||||
const docPaths = new Set(docs.map(d => d.path));
|
||||
const edges: GraphEdge[] = [];
|
||||
for (const [src, links] of linkMap) {
|
||||
for (const l of links) {
|
||||
const t = l.endsWith('.md') && docPaths.has(l) ? l : (l.endsWith('.md') && docPaths.has(nodePath.join(src, '..', l).split(nodePath.sep).join('/')) ? nodePath.join(src, '..', l).split(nodePath.sep).join('/') : null);
|
||||
if (t) edges.push({ source: src, target: t });
|
||||
}
|
||||
}
|
||||
return { nodes, edges };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// MigrationService — 旧版数据迁移判定与幂等保护(FR-D1/D2/D3)
|
||||
import { promises as fs } from 'node:fs';
|
||||
import nodePath from 'node:path';
|
||||
|
||||
export interface MigrationResult { migrated: boolean; count: number; reason?: string; }
|
||||
|
||||
export class MigrationService {
|
||||
constructor(private userDataDir: string) {}
|
||||
|
||||
private flagFile() { return nodePath.join(this.userDataDir, 'migrated.flag'); }
|
||||
|
||||
async isMigrated(): Promise<boolean> {
|
||||
try { await fs.access(this.flagFile()); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
// 判定是否应迁移:旧数据存在 + 未迁移过 + 新库为空(三者同时满足,FR-D2)
|
||||
async shouldMigrate(oldDataDir: string, newVaultRoot: string): Promise<boolean> {
|
||||
if (await this.isMigrated()) return false;
|
||||
const oldExists = await fs.stat(oldDataDir).then(s => s.isDirectory()).catch(() => false);
|
||||
if (!oldExists) return false;
|
||||
// 新库为空(无 .md 文件)
|
||||
const entries = await fs.readdir(newVaultRoot).catch(() => []);
|
||||
const hasMd = entries.some(e => e.endsWith('.md'));
|
||||
return !hasMd;
|
||||
}
|
||||
|
||||
// 执行迁移(骨架:实际板块→文件夹映射在集成时落地)
|
||||
async migrate(oldDataDir: string, newVaultRoot: string): Promise<MigrationResult> {
|
||||
if (!(await this.shouldMigrate(oldDataDir, newVaultRoot))) {
|
||||
return { migrated: false, count: 0, reason: '条件不满足(已迁移/无旧数据/新库非空)' };
|
||||
}
|
||||
try {
|
||||
// TODO: 旧板块三级结构 → 文件夹嵌套;旧富文本 → Markdown;附件 → .attachments;标签与关联映射
|
||||
// 骨架阶段:仅写迁移标志,count=0
|
||||
await this.markMigrated();
|
||||
return { migrated: true, count: 0 };
|
||||
} catch (e) {
|
||||
// 失败不记标志(下次可重试,FR-D3)
|
||||
return { migrated: false, count: 0, reason: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
async markMigrated() {
|
||||
await fs.mkdir(nodePath.dirname(this.flagFile()), { recursive: true });
|
||||
await fs.writeFile(this.flagFile(), new Date().toISOString(), 'utf8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// SearchService — 全文搜索 + 标签汇总(FR-R)
|
||||
import { promises as fs } from 'node:fs';
|
||||
import nodePath from 'node:path';
|
||||
import { SEARCH_RESULT_MAX, MD_EXT, ATTACHMENTS_DIR } from '@hunter/shared';
|
||||
import type { TreeNode } from '@hunter/shared';
|
||||
import { splitFrontmatter } from '../safe/frontmatter-codec.js';
|
||||
|
||||
export interface SearchHit { path: string; title: string; snippet: string; }
|
||||
export interface TagStat { tag: string; count: number; }
|
||||
|
||||
export class SearchService {
|
||||
constructor(private root: string, private fileService: { getRoot: () => string }) {}
|
||||
|
||||
// 遍历库读取所有文档,建立简易索引(M4:内存全量,大库后续优化)
|
||||
private async readAll(): Promise<{ path: string; title: string; body: string; tags: string[] }[]> {
|
||||
const out: { path: string; title: string; body: string; tags: string[] }[] = [];
|
||||
const walk = async (dirAbs: string, dirRel: string) => {
|
||||
const entries = await fs.readdir(dirAbs, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (e.name.startsWith('.')) continue;
|
||||
const abs = nodePath.join(dirAbs, e.name);
|
||||
const rel = dirRel ? (dirRel + '/' + e.name) : e.name;
|
||||
if (e.isDirectory()) await walk(abs, rel);
|
||||
else if (e.isFile() && e.name.endsWith(MD_EXT)) {
|
||||
const raw = await fs.readFile(abs, 'utf8');
|
||||
const { meta, body } = splitFrontmatter(raw);
|
||||
const relPosix = rel.split(nodePath.sep).join('/');
|
||||
out.push({ path: relPosix, title: meta.title ?? e.name.slice(0, -MD_EXT.length), body, tags: meta.tags ?? [] });
|
||||
}
|
||||
}
|
||||
};
|
||||
await walk(this.root, '');
|
||||
return out;
|
||||
}
|
||||
|
||||
async search(query: string): Promise<SearchHit[]> {
|
||||
if (!query.trim()) return [];
|
||||
const q = query.toLowerCase();
|
||||
const all = await this.readAll();
|
||||
const hits: SearchHit[] = [];
|
||||
for (const d of all) {
|
||||
const hay = (d.title + '\n' + d.body).toLowerCase();
|
||||
const idx = hay.indexOf(q);
|
||||
if (idx !== -1) {
|
||||
// 摘要:命中位置附近,超长截尾保留 ~120 字符
|
||||
const start = Math.max(0, idx - 40);
|
||||
const snippet = d.body.slice(start, start + 120).replace(/\n/g, ' ');
|
||||
hits.push({ path: d.path, title: d.title, snippet: (start > 0 ? '…' : '') + snippet });
|
||||
}
|
||||
if (hits.length >= SEARCH_RESULT_MAX) break;
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
async tagStats(): Promise<TagStat[]> {
|
||||
const all = await this.readAll();
|
||||
const m = new Map<string, number>();
|
||||
for (const d of all) for (const t of d.tags) m.set(t, (m.get(t) ?? 0) + 1);
|
||||
return [...m.entries()].map(([tag, count]) => ({ tag, count })).sort((a, b) => a.tag.localeCompare(b.tag));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// VaultService — 统筹库管理:默认库、切换、最近库(FR-V)
|
||||
import nodePath from 'node:path';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { app, dialog } from 'electron';
|
||||
import { DEFAULT_VAULT_NAME } from '@hunter/shared';
|
||||
import type { VaultMeta } from '@hunter/shared';
|
||||
import { ConfigStore } from './config-store.js';
|
||||
import { FileService } from './file-service.js';
|
||||
import { SearchService } from './search-service.js';
|
||||
|
||||
export class VaultService {
|
||||
fileService: FileService | null = null;
|
||||
searchService: SearchService | null = null;
|
||||
private config: ConfigStore;
|
||||
|
||||
constructor() {
|
||||
this.config = new ConfigStore(app.getPath('userData'));
|
||||
}
|
||||
|
||||
// 启动时初始化:确保有当前库(无则创建默认库)
|
||||
async init(): Promise<VaultMeta> {
|
||||
let meta = await this.config.getVaultMeta();
|
||||
if (!meta.currentRoot) {
|
||||
const defaultRoot = nodePath.join(app.getPath('userData'), DEFAULT_VAULT_NAME);
|
||||
await fs.mkdir(defaultRoot, { recursive: true });
|
||||
meta = await this.config.setCurrentRoot(defaultRoot);
|
||||
}
|
||||
this.fileService = new FileService(meta.currentRoot);
|
||||
this.searchService = new SearchService(meta.currentRoot, this.fileService);
|
||||
return meta;
|
||||
}
|
||||
|
||||
async getMeta(): Promise<VaultMeta> { return this.config.getVaultMeta(); }
|
||||
|
||||
// 通过系统对话框选择并切换库(FR-V3)
|
||||
async pickAndSwitch(): Promise<VaultMeta | null> {
|
||||
const result = await dialog.showOpenDialog({ properties: ['openDirectory'] });
|
||||
if (result.canceled || result.filePaths.length === 0) return null;
|
||||
return this.switch(result.filePaths[0]);
|
||||
}
|
||||
|
||||
async switch(root: string): Promise<VaultMeta> {
|
||||
// 校验是存在的目录
|
||||
const stat = await fs.stat(root).catch(() => null);
|
||||
if (!stat || !stat.isDirectory()) throw new Error('目标路径不是有效目录: ' + root);
|
||||
const meta = await this.config.setCurrentRoot(root);
|
||||
this.fileService = new FileService(root);
|
||||
this.searchService = new SearchService(root, this.fileService);
|
||||
return meta;
|
||||
}
|
||||
|
||||
async setDefaultRoot(root: string): Promise<void> { await this.config.setDefaultRoot(root); }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// preload — 通过 contextBridge 暴露受限 invoke API(安全基线)
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import type { BridgeApi, IpcMap } from '@hunter/shared';
|
||||
|
||||
const api: BridgeApi = {
|
||||
invoke: (channel, args) => ipcRenderer.invoke(channel, args as never) as Promise<never>,
|
||||
send: (channel, args) => ipcRenderer.send(channel, args as never) as void,
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('dsh', api);
|
||||
|
||||
// 渲染层全局类型(便于直接 window.dsh)
|
||||
export type _BridgeApi = BridgeApi;
|
||||
export type _IpcMap = IpcMap;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useStore } from './store';
|
||||
import { FileTree } from './features/file-tree/FileTree';
|
||||
import { Editor } from './features/editor/Editor';
|
||||
import { Toolbar } from './features/toolbar/Toolbar';
|
||||
import { TitleBar } from './features/shell/TitleBar';
|
||||
import { InputDialog } from './features/dialog/InputDialog';
|
||||
import { NotifyLayer } from './features/dialog/NotifyLayer';
|
||||
import { TabBar } from './features/tabs/TabBar';
|
||||
import { useTabs } from './features/tabs/tab-store';
|
||||
import { GraphView } from './features/graph/GraphView';
|
||||
import { SettingsPanel } from './features/settings/SettingsPanel';
|
||||
import { AppearancePanel } from './features/appearance/AppearancePanel';
|
||||
import { useAppearance } from './features/appearance/appearance-store';
|
||||
import { CheatSheet } from './features/keymap/CheatSheet';
|
||||
import { useGlobalHotkeys } from './features/keymap/use-hotkeys';
|
||||
import { useLayout } from './features/shell/layout-store';
|
||||
import { Resizer } from './features/shell/Resizer';
|
||||
import { EffectLayer } from './features/effects/EffectLayer';
|
||||
import { useSettings } from './features/settings/settings-store';
|
||||
import { SearchBox } from './features/search/SearchBox';
|
||||
import { TagFilter } from './features/search/TagFilter';
|
||||
import { OutlinePanel } from './features/outline/OutlinePanel';
|
||||
import { DocInfoPanel } from './features/doc-info/DocInfoPanel';
|
||||
|
||||
export function App() {
|
||||
const { loadVault, loadTree, openDoc } = useStore();
|
||||
const { restore, tabs, activeId } = useTabs();
|
||||
const [rightTab, setRightTab] = useState<'outline' | 'info'>('outline');
|
||||
const [view, setView] = useState<'editor' | 'graph'>('editor');
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showCheat, setShowCheat] = useState(false);
|
||||
const [showAppearance, setShowAppearance] = useState(false);
|
||||
const appearance = useAppearance();
|
||||
const settings = useSettings();
|
||||
const layout = useLayout();
|
||||
|
||||
useEffect(() => { loadVault(); loadTree(); restore(); appearance.preview(); }, []);
|
||||
useEffect(() => { if (activeId && tabs.length > 0) { const t = tabs.find(x => x.id === activeId); if (t) openDoc(t.path).catch(() => {}); } }, []);
|
||||
useGlobalHotkeys({ onNewDoc: () => {}, onSave: () => useStore.getState().saveNow(), onFocusSearch: () => { const el = document.querySelector('input[placeholder*="搜索"]') as HTMLInputElement; el?.focus(); }, onToggleOutline: () => layout.toggleOutline(), onCheat: () => setShowCheat(true), onSettings: () => setShowSettings(true) });
|
||||
|
||||
// 编辑器样式随设置生效(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={{ display: 'flex', flexDirection: 'column', height: '100vh', background: '#1a1a1a', color: '#ddd' }}>
|
||||
<TitleBar view={view} onToggleView={() => setView(v => v === 'editor' ? 'graph' : 'editor')} onSettings={() => setShowSettings(true)} onAppearance={() => setShowAppearance(true)} onToggleSidebar={() => layout.toggleSidebar()} onToggleMinimal={() => layout.toggleMinimal()} minimal={layout.minimal} />
|
||||
<Toolbar />
|
||||
{view === 'editor' && <TabBar />}
|
||||
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||
{layout.minimal ? (<Editor />) : (<>
|
||||
{layout.sidebarVisible && (<>
|
||||
<div style={{ width: layout.sidebarWidth, borderRight: '1px solid #2a2a3a', display: 'flex', flexDirection: 'column', overflow: 'hidden', flexShrink: 0 }}>
|
||||
<SearchBox />
|
||||
<div style={{ flex: 1, overflow: 'auto' }}><FileTree /></div>
|
||||
<TagFilter />
|
||||
</div>
|
||||
<Resizer side="left" onDrag={d => layout.setSidebarWidth(layout.sidebarWidth + d)} />
|
||||
</>)}
|
||||
{view === 'editor' ? <Editor /> : <GraphView />}
|
||||
{view === 'editor' && layout.outlineVisible && (<>
|
||||
<Resizer side="right" onDrag={d => layout.setOutlineWidth(layout.outlineWidth + d)} />
|
||||
<div style={{ width: layout.outlineWidth, borderLeft: '1px solid #2a2a3a', display: 'flex', flexDirection: 'column', overflow: 'hidden', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid #2a2a3a' }}>
|
||||
<button style={tabBtn(rightTab==='outline')} onClick={() => setRightTab('outline')}>大纲</button>
|
||||
<button style={tabBtn(rightTab==='info')} onClick={() => setRightTab('info')}>信息</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>{rightTab === 'outline' ? <OutlinePanel /> : <DocInfoPanel />}</div>
|
||||
</div>
|
||||
</>)}
|
||||
</>)}
|
||||
</div>
|
||||
<EffectLayer />
|
||||
<InputDialog />
|
||||
<NotifyLayer />
|
||||
{showSettings && <SettingsPanel onClose={() => setShowSettings(false)} />}
|
||||
{showAppearance && <AppearancePanel onClose={() => setShowAppearance(false)} />}
|
||||
{showCheat && <CheatSheet onClose={() => setShowCheat(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function tabBtn(active: boolean): React.CSSProperties {
|
||||
return { flex: 1, padding: '6px', background: active ? '#2a2a3a' : 'transparent', border: 'none', color: active ? '#fff' : '#888', cursor: 'pointer', borderBottom: active ? '2px solid #4a9eff' : '2px solid transparent' };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Component, ReactNode } from 'react';
|
||||
|
||||
interface State { hasError: boolean; error?: Error }
|
||||
export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
|
||||
state: State = { hasError: false };
|
||||
static getDerivedStateFromError(error: Error): State { return { hasError: true, error }; }
|
||||
componentDidCatch(error: Error, info: any) { console.error('UI 错误兜底:', error, info); }
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{ padding: 24, color: '#f88', background: '#2a1518', height: '100vh', fontFamily: 'Consolas, monospace', fontSize: 13, whiteSpace: 'pre-wrap' }}>
|
||||
<h2>界面发生异常</h2>
|
||||
<p>错误:{this.state.error?.message}</p>
|
||||
<pre>{this.state.error?.stack}</pre>
|
||||
<button onClick={() => location.reload()} style={{ marginTop: 12, padding: '6px 16px', cursor: 'pointer' }}>重试</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useAppearance } from './appearance-store';
|
||||
import { THEMES } from './themes';
|
||||
import type { ThemeDef } from './themes';
|
||||
|
||||
export function AppearancePanel({ onClose }: { onClose: () => void }) {
|
||||
const { themeId, draft, selectTheme, override, save, revert, isDirty } = useAppearance();
|
||||
const colors: [keyof typeof draft, string][] = [['accent','强调色'],['glow','辉光色'],['accentText','强调色文字'],['textPrimary','主要文字'],['textSecondary','辅助文字'],['textTertiary','弱化文字'],['controlBorder','控件边框'],['bgPrimary','工作区背景'],['bgSecondary','浮层背景']];
|
||||
return (
|
||||
<div onClick={e => { if (e.target === e.currentTarget) { revert(); onClose(); } }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1500 }}>
|
||||
<div style={{ width: 620, maxHeight: '85vh', overflow: 'auto', background: '#1e1e2a', border: '1px solid #3a3a4a', borderRadius: 10, padding: 20, boxShadow: '0 8px 32px rgba(0,0,0,0.5)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<strong style={{ fontSize: 15 }}>外观定制</strong>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => { revert(); onClose(); }} style={btn}>取消</button>
|
||||
<button onClick={() => { save(); onClose(); }} style={{ ...btn, background: '#2a4a6a', color: '#9cf' }}>保存外观</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12, fontSize: 12, color: isDirty() ? '#e8a' : '#6a8' }}>{isDirty() ? '更改尚未保存' : '所有设置已保存'}</div>
|
||||
<div style={{ opacity: 0.5, fontSize: 12, marginBottom: 6 }}>主题</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8, marginBottom: 16 }}>
|
||||
{THEMES.map((t: ThemeDef) => (
|
||||
<div key={t.id} onClick={() => selectTheme(t.id)} style={{ cursor: 'pointer', padding: 8, borderRadius: 6, border: themeId === t.id ? '2px solid #4a9eff' : '1px solid #3a3a4a', background: t.params.bgPrimary, color: t.params.textPrimary, fontSize: 11, minHeight: 48, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<div style={{ fontWeight: 600 }}>{t.name}</div><div style={{ opacity: 0.6 }}>{t.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ opacity: 0.5, fontSize: 12, marginBottom: 6 }}>颜色自定义</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
|
||||
{colors.map(([k, label]) => (
|
||||
<div key={k} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 12 }}><span>{label}</span><input type="color" value={normalizeColor((draft as any)[k])} onChange={e => override(k, e.target.value)} style={{ width: 40, height: 24, border: 'none', background: 'none', cursor: 'pointer' }} /></div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 24, marginBottom: 16, fontSize: 12 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>背景模糊 {draft.blurPx}px<input type="range" min={0} max={40} value={draft.blurPx} onChange={e => override('blurPx', +e.target.value)} /></label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>表面不透明 {Math.round(draft.surfaceOpacity*100)}%<input type="range" min={20} max={100} value={draft.surfaceOpacity*100} onChange={e => override('surfaceOpacity', +e.target.value/100)} /></label>
|
||||
</div>
|
||||
<button onClick={() => revert()} style={btn}>恢复(取消改动)</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeColor(c: string): string {
|
||||
// 'transparent' 或 rgba(...) 给取色器一个占位 hex
|
||||
if (c === 'transparent' || c.startsWith('rgba')) return '#1a1a2a';
|
||||
return c;
|
||||
}
|
||||
const btn: React.CSSProperties = { padding: '6px 14px', border: '1px solid #3a3a4a', background: 'transparent', color: '#aaa', borderRadius: 4, cursor: 'pointer', fontSize: 13 };
|
||||
@@ -0,0 +1,54 @@
|
||||
import { create } from 'zustand';
|
||||
import { THEMES, applyTheme, type ThemeDef, type ThemeParams } from './themes';
|
||||
|
||||
interface AppearanceState {
|
||||
themeId: string;
|
||||
draft: ThemeParams; // 工作副本
|
||||
saved: ThemeParams; // 已保存快照(回滚基准)
|
||||
manualFlags: Set<keyof ThemeParams>; // 用户手动改过的项(智能取色不覆盖)
|
||||
// actions
|
||||
selectTheme: (id: string) => void;
|
||||
override: (key: keyof ThemeParams, value: any) => void;
|
||||
preview: () => void; // 实时预览(应用到 DOM)
|
||||
save: () => void; // 显式保存:持久化 + 更新 saved 快照
|
||||
revert: () => void; // Esc/取消:回滚到 saved
|
||||
isDirty: () => boolean;
|
||||
}
|
||||
|
||||
const defaultTheme = THEMES[1]; // 午夜蓝为默认
|
||||
|
||||
export const useAppearance = create<AppearanceState>((set, get) => ({
|
||||
themeId: defaultTheme.id,
|
||||
draft: { ...defaultTheme.params },
|
||||
saved: { ...defaultTheme.params },
|
||||
manualFlags: new Set(),
|
||||
|
||||
selectTheme: (id) => {
|
||||
const t = THEMES.find(x => x.id === id) ?? defaultTheme;
|
||||
set({ themeId: id, draft: { ...t.params } });
|
||||
get().preview();
|
||||
},
|
||||
|
||||
override: (key, value) => {
|
||||
set(s => ({ draft: { ...s.draft, [key]: value }, manualFlags: new Set([...s.manualFlags, key]) }));
|
||||
get().preview();
|
||||
},
|
||||
|
||||
preview: () => applyTheme(get().draft),
|
||||
|
||||
save: () => {
|
||||
const draft = get().draft;
|
||||
// 与主题默认值相同的调整自动丢弃(保持配置干净)
|
||||
const t = THEMES.find(x => x.id === get().themeId);
|
||||
const cleaned = { ...draft };
|
||||
if (t) for (const k of Object.keys(t.params) as (keyof ThemeParams)[]) {
|
||||
if (cleaned[k] === t.params[k] && !get().manualFlags.has(k)) { /* 保留默认即可 */ }
|
||||
}
|
||||
set({ saved: { ...cleaned } });
|
||||
// M5 暂存内存;M9 接 electron-store 持久化
|
||||
},
|
||||
|
||||
revert: () => { set({ draft: { ...get().saved } }); get().preview(); },
|
||||
|
||||
isDirty: () => JSON.stringify(get().draft) !== JSON.stringify(get().saved),
|
||||
}));
|
||||
@@ -0,0 +1,35 @@
|
||||
// 8 套主题定义(FR-A1/A2)——CSS 变量驱动
|
||||
export interface ThemeParams {
|
||||
accent: string; glow: string; accentText: string;
|
||||
textPrimary: string; textSecondary: string; textTertiary: string;
|
||||
controlBorder: string; bgPrimary: string; bgSecondary: string;
|
||||
blurPx: number; surfaceOpacity: number; radius: number;
|
||||
}
|
||||
export interface ThemeDef { id: string; name: string; group: 'dark' | 'light'; desc: string; params: ThemeParams; }
|
||||
|
||||
export const THEMES: ThemeDef[] = [
|
||||
{ id: 'transparent', name: '全透明', group: 'dark', desc: '窗口直接透出桌面', params: { accent:'#4a9eff', glow:'#6a8cff', accentText:'#7ab8ff', textPrimary:'#e8e8e8', textSecondary:'#aaa', textTertiary:'#666', controlBorder:'#333', bgPrimary:'transparent', bgSecondary:'rgba(20,20,28,0.5)', blurPx:20, surfaceOpacity:0.5, radius:8 } },
|
||||
{ id: 'midnight', name: '午夜蓝', group: 'dark', desc: '深邃午夜蓝调', params: { accent:'#4a9eff', glow:'#6a8cff', accentText:'#7ab8ff', textPrimary:'#e8e8e8', textSecondary:'#aaa', textTertiary:'#666', controlBorder:'#2a3548', bgPrimary:'#0d1525', bgSecondary:'#141d30', blurPx:16, surfaceOpacity:0.6, radius:8 } },
|
||||
{ id: 'purple-night', name: '深紫夜', group: 'dark', desc: '神秘紫夜', params: { accent:'#a06aff', glow:'#b88aff', accentText:'#c4a3ff', textPrimary:'#e8e8f0', textSecondary:'#aaa', textTertiary:'#666', controlBorder:'#2a2438', bgPrimary:'#1a1228', bgSecondary:'#241a35', blurPx:16, surfaceOpacity:0.6, radius:8 } },
|
||||
{ id: 'forest', name: '森林绿', group: 'dark', desc: '静谧森林', params: { accent:'#5abf7a', glow:'#7ad99a', accentText:'#7ad99a', textPrimary:'#e8f0e8', textSecondary:'#aaa', textTertiary:'#666', controlBorder:'#243028', bgPrimary:'#102018', bgSecondary:'#1a2a22', blurPx:16, surfaceOpacity:0.6, radius:8 } },
|
||||
{ id: 'amber', name: '暖琥珀', group: 'dark', desc: '温暖琥珀光', params: { accent:'#e0a040', glow:'#f0c060', accentText:'#f0c060', textPrimary:'#f0e8d8', textSecondary:'#aaa', textTertiary:'#666', controlBorder:'#3a3028', bgPrimary:'#1f1810', bgSecondary:'#2a2018', blurPx:16, surfaceOpacity:0.6, radius:8 } },
|
||||
{ id: 'latte', name: '奶霜拿铁', group: 'light', desc: '柔润奶白', params: { accent:'#c08050', glow:'#e0a070', accentText:'#a06840', textPrimary:'#3a3028', textSecondary:'#666', textTertiary:'#999', controlBorder:'#d8c8b8', bgPrimary:'#f5efe6', bgSecondary:'#ede5d8', blurPx:10, surfaceOpacity:0.8, radius:8 } },
|
||||
{ id: 'daylight', name: '晴空白昼', group: 'light', desc: '清爽天蓝', params: { accent:'#3080d0', glow:'#50a0f0', accentText:'#2070c0', textPrimary:'#2a3a4a', textSecondary:'#5a6a7a', textTertiary:'#9aa', controlBorder:'#c8d8e8', bgPrimary:'#f0f5fa', bgSecondary:'#e0eaf2', blurPx:10, surfaceOpacity:0.85, radius:8 } },
|
||||
{ id: 'plain', name: '素纸简白', group: 'light', desc: '极简素纸', params: { accent:'#555', glow:'#888', accentText:'#333', textPrimary:'#222', textSecondary:'#666', textTertiary:'#999', controlBorder:'#ddd', bgPrimary:'#fff', bgSecondary:'#f5f5f5', blurPx:0, surfaceOpacity:0.9, radius:6 } },
|
||||
];
|
||||
|
||||
export function applyTheme(params: ThemeParams) {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--accent', params.accent);
|
||||
root.style.setProperty('--glow', params.glow);
|
||||
root.style.setProperty('--accent-text', params.accentText);
|
||||
root.style.setProperty('--text-primary', params.textPrimary);
|
||||
root.style.setProperty('--text-secondary', params.textSecondary);
|
||||
root.style.setProperty('--text-tertiary', params.textTertiary);
|
||||
root.style.setProperty('--control-border', params.controlBorder);
|
||||
root.style.setProperty('--bg-primary', params.bgPrimary);
|
||||
root.style.setProperty('--bg-secondary', params.bgSecondary);
|
||||
root.style.setProperty('--blur-px', params.blurPx + 'px');
|
||||
root.style.setProperty('--surface-opacity', String(params.surfaceOpacity));
|
||||
root.style.setProperty('--radius', params.radius + 'px');
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useDialog } from './dialog-store';
|
||||
|
||||
export function InputDialog() {
|
||||
const { open, mode, title, message, placeholder, close } = useDialog();
|
||||
const [value, setValue] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => { if (open) { setValue(''); setTimeout(() => inputRef.current?.focus(), 50); } }, [open]);
|
||||
if (!open) return null;
|
||||
|
||||
const submit = () => {
|
||||
if (mode === 'confirm') { close('yes'); return; }
|
||||
close(value.trim() || null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div onClick={e => { if (e.target === e.currentTarget) close(null); }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
||||
<div style={{ background: '#1e1e2a', border: '1px solid #3a3a4a', borderRadius: 8, padding: 20, minWidth: 360, boxShadow: '0 8px 32px rgba(0,0,0,0.4)' }}>
|
||||
<div style={{ marginBottom: 12, fontSize: 14, color: '#ddd' }}>{title}</div>
|
||||
{mode === 'confirm' ? (
|
||||
<div style={{ color: '#e88', fontSize: 13, marginBottom: 16 }}>{message}</div>
|
||||
) : (
|
||||
<input ref={inputRef} value={value} onChange={e => setValue(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') submit(); if (e.key === 'Escape') close(null); }}
|
||||
placeholder={placeholder} style={{ width: '100%', background: '#15151f', border: '1px solid #3a3a4a', color: '#ddd', borderRadius: 4, padding: '8px 12px', fontSize: 14, outline: 'none' }} />
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
|
||||
<button onClick={() => close(null)} style={btn}>取消</button>
|
||||
<button onClick={submit} style={{ ...btn, background: mode === 'confirm' ? '#5a2a2a' : '#2a4a6a', color: mode === 'confirm' ? '#f88' : '#9cf' }}>{mode === 'confirm' ? '确认删除' : '确定'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const btn: React.CSSProperties = { padding: '6px 16px', border: '1px solid #3a3a4a', background: 'transparent', color: '#aaa', borderRadius: 4, cursor: 'pointer', fontSize: 13 };
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useNotify } from './notify-store';
|
||||
export function NotifyLayer() { const { items, remove } = useNotify();
|
||||
return <div style={{ position: 'fixed', top: 48, right: 20, zIndex: 3000, display: 'flex', flexDirection: 'column', gap: 8 }}>{items.map(n => (
|
||||
<div key={n.id} onClick={() => remove(n.id)} style={{ padding: '10px 16px', borderRadius: 6, fontSize: 13, cursor: 'pointer', boxShadow: '0 4px 12px rgba(0,0,0,0.3)', background: n.type === 'success' ? '#2a4a3a' : n.type === 'error' ? '#4a2a2a' : '#2a2a3a', color: n.type === 'success' ? '#9fc' : n.type === 'error' ? '#f88' : '#9cf' }}>{n.msg}</div>
|
||||
))}</div>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
type Mode = 'input' | 'confirm';
|
||||
|
||||
interface DialogState {
|
||||
open: boolean;
|
||||
mode: Mode;
|
||||
title: string;
|
||||
message: string;
|
||||
placeholder: string;
|
||||
resolve: ((value: string | null) => void) | null;
|
||||
showInput: (title: string, placeholder?: string) => Promise<string | null>;
|
||||
showConfirm: (title: string, message: string) => Promise<boolean>;
|
||||
close: (value: string | null) => void;
|
||||
}
|
||||
|
||||
export const useDialog = create<DialogState>((set, get) => ({
|
||||
open: false, mode: 'input', title: '', message: '', placeholder: '', resolve: null,
|
||||
showInput: (title, placeholder = '') => new Promise<string | null>((resolve) => { set({ open: true, mode: 'input', title, placeholder, resolve }); }),
|
||||
showConfirm: (title, message) => new Promise<string | null>((resolve) => { set({ open: true, mode: 'confirm', title, message, resolve }); }),
|
||||
close: (value) => { const r = get().resolve; set({ open: false, resolve: null }); r?.(value); },
|
||||
}));
|
||||
@@ -0,0 +1,11 @@
|
||||
import { create } from 'zustand';
|
||||
export type NotifyType = 'success' | 'error' | 'info';
|
||||
export interface Notify { id: number; type: NotifyType; msg: string; }
|
||||
interface NotifyState { items: Notify[]; show: (type: NotifyType, msg: string) => void; remove: (id: number) => void; }
|
||||
let seq = 1;
|
||||
export const useNotify = create<NotifyState>((set, get) => ({
|
||||
items: [],
|
||||
show: (type, msg) => { const id = seq++; set(s => ({ items: [...s.items, { id, type, msg }] })); if (type !== 'info') setTimeout(() => get().remove(id), 2500); },
|
||||
remove: (id) => set(s => ({ items: s.items.filter(n => n.id !== id) })),
|
||||
}));
|
||||
export const notify = (type: NotifyType, msg: string) => useNotify.getState().show(type, msg);
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from 'react';
|
||||
import { useStore } from '../../store';
|
||||
import { invoke } from '../../ipc-client';
|
||||
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 [tagInput, setTagInput] = useState('');
|
||||
const [linkInput, setLinkInput] = useState('');
|
||||
|
||||
if (!currentPath) {
|
||||
return <div style={{ padding: 16, opacity: 0.5, fontSize: 13 }}>未打开文档</div>;
|
||||
}
|
||||
|
||||
// 库内 .md 文件供关联链接选择
|
||||
const docs = tree.filter(n => n.type === 'doc');
|
||||
const mtime = tree.find(n => n.path === currentPath)?.mtime;
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16, overflow: 'auto', fontSize: 13 }}>
|
||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<strong>{title}</strong>
|
||||
<button onClick={() => toggleStar()} title="星标" style={{ cursor: 'pointer', background: 'none', border: 'none', fontSize: 16, color: meta.star ? '#ffd54a' : '#888' }}>
|
||||
{meta.star ? '★' : '☆'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Section title="路径">
|
||||
<div style={{ opacity: 0.6, wordBreak: 'break-all' }}>{currentPath}</div>
|
||||
</Section>
|
||||
|
||||
<Section title="标签">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 6 }}>
|
||||
{(meta.tags ?? []).map(t => (
|
||||
<span key={t} style={{ background: '#2a2a3a', borderRadius: 4, padding: '2px 8px', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{t}<span style={{ cursor: 'pointer', opacity: 0.6 }} onClick={() => removeTag(t)}>×</span>
|
||||
</span>
|
||||
))}
|
||||
{(meta.tags ?? []).length === 0 && <span style={{ opacity: 0.4 }}>无标签</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input value={tagInput} onChange={e => setTagInput(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && tagInput.trim()) { addTag(tagInput.trim()); setTagInput(''); } }} placeholder="添加标签" style={inp} />
|
||||
<button style={btn} onClick={() => { if (tagInput.trim()) { addTag(tagInput.trim()); setTagInput(''); } }}>+</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="关联文档">
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
{(meta.links ?? []).map(l => (
|
||||
<div key={l} style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0' }}>
|
||||
<span style={{ opacity: 0.8 }}>{l}</span>
|
||||
<span style={{ cursor: 'pointer', opacity: 0.6 }} onClick={() => removeLink(l)}>×</span>
|
||||
</div>
|
||||
))}
|
||||
{(meta.links ?? []).length === 0 && <span style={{ opacity: 0.4 }}>无关联</span>}
|
||||
</div>
|
||||
<select value={linkInput} onChange={e => setLinkInput(e.target.value)} style={inp}>
|
||||
<option value="">选择库内文档…</option>
|
||||
{docs.filter(d => d.path !== currentPath && !(meta.links ?? []).includes(d.path + '')).map(d => (
|
||||
<option key={d.path} value={d.path}>{d.name}({d.path})</option>
|
||||
))}
|
||||
</select>
|
||||
<button style={{ ...btn, marginLeft: 4 }} onClick={() => { if (linkInput) { addLink(linkInput); setLinkInput(''); } }}>关联</button>
|
||||
</Section>
|
||||
|
||||
<Section title="修改时间">
|
||||
<div style={{ opacity: 0.6 }}>{mtime ? new Date(mtime).toLocaleString('zh-CN') : '—'}</div>
|
||||
<div style={{ opacity: 0.4, fontSize: 12, marginTop: 4 }}>创建时间:—(按设计不落盘)</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (<div style={{ marginBottom: 16 }}><div style={{ opacity: 0.5, marginBottom: 6 }}>{title}</div>{children}</div>);
|
||||
}
|
||||
const inp: React.CSSProperties = { background: '#222', border: '1px solid #444', color: '#ddd', borderRadius: 4, padding: '3px 8px', fontSize: 13, flex: 1 };
|
||||
const btn: React.CSSProperties = { minWidth: 28, height: 26, border: '1px solid #444', background: 'transparent', color: '#ccc', cursor: 'pointer', borderRadius: 4 };
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
|
||||
// 块操作扩展:Alt+↑/↓ 移动当前块,Alt+Backspace 删除当前块(FR-E7)
|
||||
export const BlockOps = Extension.create({
|
||||
name: 'blockOps',
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Alt-ArrowUp': () => this.editor.commands.liftListItem('listItem'),
|
||||
'Alt-ArrowDown': () => this.editor.commands.sinkListItem('listItem'),
|
||||
'Alt-Backspace': () => { const e = this.editor; const from = e.state.selection.from; const $pos = e.state.doc.resolve(from); const start = $pos.before($pos.depth); const end = $pos.after($pos.depth); if (start >= 0) e.chain().focus().deleteRange({ from: start, to: end }).run(); return true; },
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useStore } from '../../store';
|
||||
import { TiptapEditor } from './TiptapEditor';
|
||||
import { MoreActions } from './MoreActions';
|
||||
import type { SaveState } from '../../store';
|
||||
|
||||
function saveLabel(s: SaveState): string {
|
||||
if (s === 'saving') return '保存中…';
|
||||
if (s === 'error') return '保存失败';
|
||||
return '已保存';
|
||||
}
|
||||
|
||||
export function Editor() {
|
||||
const { currentPath, title, saveState } = useStore();
|
||||
if (!currentPath) {
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0.4 }}>
|
||||
<div style={{ textAlign: 'center' }}>从左侧选择一篇笔记,或新建文档开始</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ padding: '6px 16px', borderBottom: '1px solid #333', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>{title} <span style={{ opacity: 0.4, fontSize: 12 }}>{currentPath}</span></span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ fontSize: 12, opacity: saveState === 'error' ? 1 : 0.5, color: saveState === 'error' ? '#f88' : 'inherit' }}>{saveLabel(saveState)}</span>
|
||||
<MoreActions />
|
||||
</div>
|
||||
</div>
|
||||
<TiptapEditor />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Editor } from '@tiptap/react';
|
||||
import { useStore } from '../../store';
|
||||
import { useEditorRef } from './editor-context';
|
||||
|
||||
const btn: React.CSSProperties = { minWidth: 30, height: 28, border: '1px solid #444', background: 'transparent', color: '#ccc', cursor: 'pointer', borderRadius: 4, fontSize: 13 };
|
||||
|
||||
function T({ onClick, title, children, disabled }: { onClick: () => void; title: string; children: React.ReactNode; disabled?: boolean }) {
|
||||
return <button style={{ ...btn, opacity: disabled ? 0.4 : 1 }} onClick={onClick} title={title} disabled={disabled}>{children}</button>;
|
||||
}
|
||||
|
||||
export function EditorToolbar() {
|
||||
const editor = useEditorRef();
|
||||
const d = !editor;
|
||||
const cmd = (fn: (e: Editor) => void) => () => { if (editor) { fn(editor); editor.commands.focus(); } };
|
||||
return (
|
||||
<div style={{ padding: '6px 12px', borderBottom: '1px solid #333', display: 'flex', gap: 4, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<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>
|
||||
<span style={{ width: 1, background: '#333', 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+Shift+Q" onClick={cmd(e => e.chain().focus().toggleBlockquote().run())} disabled={d}>❝</T>
|
||||
<span style={{ width: 1, background: '#333', 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>
|
||||
<span style={{ width: 1, background: '#333', 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState } from 'react';
|
||||
import { useEditorRef } from '../editor/editor-context';
|
||||
|
||||
export function FindReplace() {
|
||||
const editor = useEditorRef();
|
||||
const [find, setFind] = useState('');
|
||||
const [replace, setReplace] = useState('');
|
||||
const [matchIdx, setMatchIdx] = useState(0);
|
||||
const [matches, setMatches] = useState<number[]>([]); // 各匹配起始 pos
|
||||
|
||||
const doFind = () => {
|
||||
if (!editor || !find) { setMatches([]); setMatchIdx(0); return; }
|
||||
const positions: number[] = [];
|
||||
const text = editor.state.doc.textBetween(0, editor.state.doc.content.size, '\n');
|
||||
// 遍历 doc 收集匹配(基于文本偏移简化)
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.isText && node.text) {
|
||||
let i = 0;
|
||||
while ((i = node.text.indexOf(find, i)) !== -1) {
|
||||
positions.push(pos + i);
|
||||
i += find.length;
|
||||
}
|
||||
}
|
||||
});
|
||||
setMatches(positions);
|
||||
setMatchIdx(0);
|
||||
if (positions.length > 0) jumpTo(0, positions);
|
||||
};
|
||||
|
||||
const jumpTo = (idx: number, positions = matches) => {
|
||||
if (!editor || positions.length === 0) return;
|
||||
const pos = positions[idx];
|
||||
editor.commands.setTextSelection({ from: pos, to: pos + find.length });
|
||||
editor.commands.scrollIntoView();
|
||||
editor.commands.focus();
|
||||
};
|
||||
|
||||
const next = () => { if (matches.length === 0) return; const i = (matchIdx + 1) % matches.length; setMatchIdx(i); jumpTo(i); };
|
||||
const prev = () => { if (matches.length === 0) return; const i = (matchIdx - 1 + matches.length) % matches.length; setMatchIdx(i); jumpTo(i); };
|
||||
|
||||
const doReplace = () => {
|
||||
if (!editor || matches.length === 0) return;
|
||||
const pos = matches[matchIdx];
|
||||
editor.chain().focus().deleteRange({ from: pos, to: pos + find.length }).insertContentAt(pos, replace).run();
|
||||
doFind(); // 重新查找
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12, borderBottom: '1px solid #333', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input value={find} onChange={e => setFind(e.target.value)} placeholder="查找" onKeyDown={e => e.key === 'Enter' && doFind()} style={inp} />
|
||||
<input value={replace} onChange={e => setReplace(e.target.value)} placeholder="替换" style={inp} />
|
||||
<button style={btn} onClick={doFind}>查找</button>
|
||||
<button style={btn} onClick={prev}>↑</button>
|
||||
<button style={btn} onClick={next}>↓</button>
|
||||
<button style={btn} onClick={doReplace}>替换</button>
|
||||
<span style={{ fontSize: 12, opacity: 0.5 }}>{matches.length > 0 ? `${matchIdx + 1}/${matches.length}` : ''}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const inp: React.CSSProperties = { background: '#222', border: '1px solid #444', color: '#ddd', borderRadius: 4, padding: '3px 8px', width: 120, fontSize: 13 };
|
||||
const btn: React.CSSProperties = { minWidth: 30, height: 26, border: '1px solid #444', background: 'transparent', color: '#ccc', cursor: 'pointer', borderRadius: 4, fontSize: 12 };
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useStore } from '../../store';
|
||||
import { invoke } from '../../ipc-client';
|
||||
import { IPC } from '@hunter/shared';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function MoreActions() {
|
||||
const { currentPath, body, openDoc } = useStore();
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!currentPath) return null;
|
||||
const copyMd = async () => { await invoke(IPC.copyMd, { path: currentPath }); notify('已复制 Markdown'); setOpen(false); };
|
||||
const print = () => { window.print(); setOpen(false); };
|
||||
const reveal = async () => { await invoke(IPC.docReveal, { path: currentPath }); setOpen(false); };
|
||||
const reload = async () => { await openDoc(currentPath); setOpen(false); };
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button onClick={() => setOpen(o => !o)} style={btn} title="更多操作">⋯</button>
|
||||
{open && (
|
||||
<div style={{ position: 'absolute', right: 0, top: 28, background: '#1e1e2a', border: '1px solid #3a3a4a', borderRadius: 6, padding: 4, zIndex: 500, minWidth: 140 }}>
|
||||
<Item onClick={copyMd}>复制 Markdown</Item>
|
||||
<Item onClick={print}>打印 / 另存为 PDF</Item>
|
||||
<Item onClick={reveal}>在文件管理器中显示</Item>
|
||||
<Item onClick={reload}>重新加载</Item>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Item({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
|
||||
return <div onClick={onClick} style={{ padding: '6px 12px', cursor: 'pointer', borderRadius: 4, fontSize: 13, color: '#ccc' }} onMouseEnter={e => (e.currentTarget.style.background = '#2a2a3a')} onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>{children}</div>;
|
||||
}
|
||||
const btn: React.CSSProperties = { height: 26, border: '1px solid #3a3a4a', background: 'transparent', color: '#ccc', cursor: 'pointer', borderRadius: 4, fontSize: 14, padding: '0 8px' };
|
||||
function notify(msg: string) { const el = document.createElement('div'); el.textContent = msg; el.style.cssText = 'position:fixed;top:50px;right:20px;background:#2a4a6a;color:#9cf;padding:10px 16px;border-radius:6px;z-index:3000;font-size:13px'; document.body.appendChild(el); setTimeout(() => el.remove(), 2500); }
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEditorRef } from './editor-context';
|
||||
|
||||
const ITEMS: { label: string; cmd: (e: any) => void }[] = [
|
||||
{ label: '一级标题', cmd: e => e.chain().focus().toggleHeading({ level: 1 }).run() },
|
||||
{ label: '二级标题', cmd: e => e.chain().focus().toggleHeading({ level: 2 }).run() },
|
||||
{ label: '三级标题', cmd: e => e.chain().focus().toggleHeading({ level: 3 }).run() },
|
||||
{ label: '无序列表', cmd: e => e.chain().focus().toggleBulletList().run() },
|
||||
{ label: '有序列表', cmd: e => e.chain().focus().toggleOrderedList().run() },
|
||||
{ label: '任务列表', cmd: e => e.chain().focus().toggleTaskList().run() },
|
||||
{ label: '代码块', cmd: e => e.chain().focus().toggleCodeBlock().run() },
|
||||
{ label: '引用', cmd: e => e.chain().focus().toggleBlockquote().run() },
|
||||
{ label: '表格', cmd: e => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
|
||||
{ label: '分割线', cmd: e => e.chain().focus().setHorizontalRule().run() },
|
||||
];
|
||||
|
||||
export function SlashMenu() {
|
||||
const editor = useEditorRef();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const handler = () => {
|
||||
const sel = editor.state.selection;
|
||||
const from = sel.from;
|
||||
const textBefore = editor.state.doc.textBetween(Math.max(0, from - 8), from, ' ', ' ');
|
||||
const slashIdx = textBefore.lastIndexOf('/');
|
||||
if (slashIdx >= 0 && (slashIdx === 0 || /\s/.test(textBefore[slashIdx - 1]))) {
|
||||
const coords = editor.view.coordsAtPos(from);
|
||||
setPos({ x: coords.left, y: coords.bottom });
|
||||
setFilter(textBefore.slice(slashIdx + 1));
|
||||
setOpen(true);
|
||||
} else {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
editor.on('update', handler);
|
||||
return () => { editor.off('update', handler); };
|
||||
}, [editor]);
|
||||
|
||||
if (!open || !editor) return null;
|
||||
const items = ITEMS.filter(i => i.label.includes(filter));
|
||||
const pick = (it: typeof ITEMS[0]) => {
|
||||
const from = editor.state.selection.from;
|
||||
editor.chain().focus().deleteRange({ from: from - (1 + filter.length), to: from }).run();
|
||||
it.cmd(editor);
|
||||
setOpen(false);
|
||||
};
|
||||
return (
|
||||
<div style={{ position: 'fixed', left: pos.x, top: pos.y, background: '#1e1e2a', border: '1px solid #3a3a4a', borderRadius: 6, padding: 4, zIndex: 2000, minWidth: 150, maxHeight: 240, overflow: 'auto' }}>
|
||||
{items.length === 0 && <div style={{ padding: '6px 10px', opacity: 0.5, fontSize: 12 }}>无匹配</div>}
|
||||
{items.map(it => <div key={it.label} onClick={() => pick(it)} style={{ padding: '6px 10px', cursor: 'pointer', borderRadius: 4, fontSize: 13 }} onMouseEnter={e => (e.currentTarget.style.background = '#2a2a3a')} onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>{it.label}</div>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import TaskList from '@tiptap/extension-task-list';
|
||||
import TaskItem from '@tiptap/extension-task-item';
|
||||
import { Table } from '@tiptap/extension-table';
|
||||
import { TableRow } from '@tiptap/extension-table-row';
|
||||
import { TableCell } from '@tiptap/extension-table-cell';
|
||||
import { TableHeader } from '@tiptap/extension-table-header';
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import { Markdown } from 'tiptap-markdown';
|
||||
import { createLowlight, common } from 'lowlight';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useStore } from '../../store';
|
||||
import { EditorProvider } from './editor-context';
|
||||
import { EditorToolbar } from './EditorToolbar';
|
||||
import { FindReplace } from './FindReplace';
|
||||
import { SlashMenu } from './SlashMenu';
|
||||
import { BlockOps } from './BlockOps';
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
export function TiptapEditor() {
|
||||
const { currentPath, body, setBody } = useStore();
|
||||
const skipUpdate = useRef(false);
|
||||
|
||||
// editor 实例稳定,不随文档切换重建(避免销毁/重建过渡期 commands 为 null)
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({ codeBlock: false }),
|
||||
CodeBlockLowlight.configure({ lowlight }),
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
Table.configure({ resizable: true }), TableRow, TableCell, TableHeader,
|
||||
Link.configure({ openOnClick: false }),
|
||||
Image,
|
||||
Placeholder.configure({ placeholder: '开始写作…' }),
|
||||
Markdown.configure({ html: false, breaks: false, linkify: true, transformPastedText: true, transformCopiedText: true }),
|
||||
BlockOps,
|
||||
],
|
||||
content: body,
|
||||
onUpdate: ({ editor }) => { if (!skipUpdate.current) setBody(editor.storage.markdown.getMarkdown()); },
|
||||
});
|
||||
|
||||
// 切换文档时只同步内容,不重建 editor
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
skipUpdate.current = true;
|
||||
editor.commands.setContent(body || '', { emitUpdate: false });
|
||||
skipUpdate.current = false;
|
||||
}, [currentPath, editor]);
|
||||
|
||||
if (!currentPath) {
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0.4 }}>
|
||||
<div style={{ textAlign: 'center' }}>从左侧选择一篇笔记,或新建文档开始</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EditorProvider value={editor}>
|
||||
<EditorToolbar />
|
||||
<FindReplace />
|
||||
<SlashMenu />
|
||||
<div className="tiptap-wrap" style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<EditorContent editor={editor} style={{ flex: 1, overflow: 'auto' }} />
|
||||
</div>
|
||||
</EditorProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { Editor } from '@tiptap/react';
|
||||
|
||||
const Ctx = createContext<Editor | null>(null);
|
||||
export const EditorProvider = Ctx.Provider;
|
||||
export function useEditorRef() { return useContext(Ctx); }
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { EffectEngine } from '../effects/effect-engine';
|
||||
import { useAppearance } from '../appearance/appearance-store';
|
||||
|
||||
export function EffectLayer() {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
const engineRef = useRef<EffectEngine | null>(null);
|
||||
const appearance = useAppearance();
|
||||
// M6 骨架:默认关闭,外观面板后续接入选择
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const eng = new EffectEngine();
|
||||
eng.attach(ref.current);
|
||||
eng.setColor(appearance.draft.accent);
|
||||
eng.setType('off'); // 默认关闭,待外观面板接入开启
|
||||
engineRef.current = eng;
|
||||
return () => eng.detach();
|
||||
}, []);
|
||||
return <canvas ref={ref} style={{ position: 'fixed', inset: 0, pointerEvents: 'none', zIndex: 9999 }} />;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// 鼠标特效引擎(FR-A11):微粒/流星/极光/涟漪 + 智能 + 空闲暂停 + 降级
|
||||
export type EffectType = 'off' | 'auto' | 'particle' | 'meteor' | 'aurora' | 'ripple';
|
||||
|
||||
interface P { x: number; y: number; vx: number; vy: number; life: number; maxLife: number; size: number; }
|
||||
|
||||
export class EffectEngine {
|
||||
private canvas: HTMLCanvasElement | null = null;
|
||||
private ctx: CanvasRenderingContext2D | null = null;
|
||||
private raf = 0;
|
||||
private particles: P[] = [];
|
||||
private lastMove = 0;
|
||||
private lastX = 0; lastY = 0;
|
||||
private type: EffectType = 'off';
|
||||
private color = '#7ab8ff';
|
||||
private idleMs = 1500;
|
||||
private active = false;
|
||||
private onMove: (e: MouseEvent) => void;
|
||||
private onClick: (e: MouseEvent) => void;
|
||||
|
||||
constructor() {
|
||||
this.onMove = (e) => { this.lastMove = performance.now(); this.lastX = e.clientX; this.lastY = e.clientY; this.spawn(); };
|
||||
this.onClick = (e) => { this.lastMove = performance.now(); this.rippleAt(e.clientX, e.clientY); };
|
||||
}
|
||||
|
||||
attach(canvas: HTMLCanvasElement) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext('2d');
|
||||
window.addEventListener('mousemove', this.onMove);
|
||||
window.addEventListener('click', this.onClick);
|
||||
this.resize();
|
||||
window.addEventListener('resize', this.resizeBound = this.resize.bind(this));
|
||||
this.loop();
|
||||
}
|
||||
|
||||
private resizeBound: () => void = () => {}
|
||||
|
||||
detach() {
|
||||
window.removeEventListener('mousemove', this.onMove);
|
||||
window.removeEventListener('click', this.onClick);
|
||||
window.removeEventListener('resize', this.resizeBound);
|
||||
cancelAnimationFrame(this.raf);
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
setType(t: EffectType) { this.type = t; }
|
||||
setColor(c: string) { this.color = c; }
|
||||
setIdleMs(ms: number) { this.idleMs = ms; }
|
||||
// 降级:关闭特效
|
||||
disable() { this.type = 'off'; this.particles = []; }
|
||||
|
||||
isActive() { return this.active; }
|
||||
|
||||
resize() {
|
||||
if (!this.canvas) return;
|
||||
this.canvas.width = window.innerWidth;
|
||||
this.canvas.height = window.innerHeight;
|
||||
}
|
||||
|
||||
private spawn() {
|
||||
if (this.type === 'off') return;
|
||||
const t = this.type === 'auto' ? 'particle' : this.type;
|
||||
if (t === 'particle') this.particles.push(this.mkP(0, -0.3 - Math.random()*0.5, 60));
|
||||
else if (t === 'meteor') { const dx = this.lastX - (this.lastX - 5); this.particles.push(this.mkP(dx*0.1, 0.1, 40)); }
|
||||
else if (t === 'aurora') this.particles.push(this.mkP(0, 0, 80));
|
||||
if (this.particles.length > 200) this.particles.splice(0, this.particles.length - 200);
|
||||
}
|
||||
|
||||
private mkP(vx: number, vy: number, life: number): P {
|
||||
return { x: this.lastX, y: this.lastY, vx, vy, life, maxLife: life, size: 1 + Math.random()*2 };
|
||||
}
|
||||
|
||||
private rippleAt(x: number, y: number) {
|
||||
if (this.type === 'off') return;
|
||||
const t = this.type === 'auto' ? 'ripple' : this.type;
|
||||
if (t !== 'ripple') return;
|
||||
for (let i = 0; i < 2; i++) this.particles.push({ x, y, vx: 0, vy: 0, life: 40, maxLife: 40, size: 2 + i*8 });
|
||||
}
|
||||
|
||||
private loop = () => {
|
||||
this.raf = requestAnimationFrame(this.loop);
|
||||
const ctx = this.ctx; if (!ctx) return;
|
||||
const idle = performance.now() - this.lastMove > this.idleMs;
|
||||
// 空闲暂停:无新粒子且空闲则停转
|
||||
if (idle && this.particles.length === 0) { this.active = false; return; }
|
||||
this.active = true;
|
||||
ctx.clearRect(0, 0, this.canvas!.width, this.canvas!.height);
|
||||
ctx.globalAlpha = 1;
|
||||
for (let i = this.particles.length - 1; i >= 0; i--) {
|
||||
const p = this.particles[i];
|
||||
p.x += p.vx; p.y += p.vy; p.life--;
|
||||
if (p.life <= 0) { this.particles.splice(i, 1); continue; }
|
||||
const alpha = p.life / p.maxLife;
|
||||
ctx.globalAlpha = alpha * 0.8;
|
||||
ctx.fillStyle = this.color;
|
||||
if (this.type === 'ripple' || (this.type === 'auto' && p.size > 6)) {
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, p.size * (1 + (1-alpha)*3), 0, Math.PI*2); ctx.strokeStyle = this.color; ctx.lineWidth = 1.5; ctx.stroke();
|
||||
} else {
|
||||
ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI*2); ctx.fill();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useStore } from '../../store';
|
||||
import type { TreeNode } from '@hunter/shared';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { invoke } from '../../ipc-client';
|
||||
import { IPC } from '@hunter/shared';
|
||||
import { useDialog } from '../dialog/dialog-store';
|
||||
|
||||
export function FileTree() {
|
||||
const { tree, loadTree, treeLoading, openDoc, currentPath } = useStore();
|
||||
const { showInput, showConfirm } = useDialog();
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [renameVal, setRenameVal] = useState('');
|
||||
const [hover, setHover] = useState<string | null>(null);
|
||||
const [dragOver, setDragOver] = useState<string | null>(null);
|
||||
const [menu, setMenu] = useState<{ path: string; x: number; y: number } | null>(null);
|
||||
|
||||
const toggle = (p: string) => { const n = new Set(collapsed); if (n.has(p)) n.delete(p); else n.add(p); setCollapsed(n); };
|
||||
const byParent = useMemo(() => { const m = new Map<string, TreeNode[]>(); for (const n of tree) { const a = m.get(n.parent) ?? []; a.push(n); m.set(n.parent, a); } return m; }, [tree]);
|
||||
|
||||
const doCreate = async (basePath: string, isFolder: boolean) => {
|
||||
const name = await showInput(isFolder ? '新建文件夹' : '新建文档', isFolder ? '文件夹名称' : '文档标题');
|
||||
if (!name) return;
|
||||
await invoke(IPC.fileCreate, { basePath, name, isFolder });
|
||||
await loadTree();
|
||||
};
|
||||
const doRename = async (node: TreeNode) => {
|
||||
const name = await showInput('重命名', '新名称'); if (!name) return;
|
||||
await invoke(IPC.fileRename, { from: node.path, toName: name });
|
||||
await loadTree();
|
||||
};
|
||||
const doDelete = async (node: TreeNode) => {
|
||||
const ok = await showConfirm('确认删除', `删除「${node.name}」${node.type === 'folder' ? '(文件夹将递归删除)' : ''}?此操作不可撤销。`);
|
||||
if (!ok) return;
|
||||
await invoke(IPC.fileDelete, { path: node.path, recursive: node.type === 'folder' });
|
||||
if (currentPath === node.path) { /* 删除当前文档:store 侧切换由 openDoc 处理 */ }
|
||||
await loadTree();
|
||||
};
|
||||
const doMove = async (src: string, destDir: string) => {
|
||||
if (src === destDir) return;
|
||||
try { await invoke(IPC.fileMove, { src, destDir }); await loadTree(); } catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const onDrop = (e: React.DragEvent, destDir: string) => {
|
||||
e.preventDefault(); setDragOver(null);
|
||||
const src = e.dataTransfer.getData('text/path'); if (src) doMove(src, destDir);
|
||||
};
|
||||
|
||||
const renderNode = (n: TreeNode, depth: number): React.ReactNode => {
|
||||
const isCollapsed = collapsed.has(n.path);
|
||||
const children = n.type === 'folder' ? byParent.get(n.path) ?? [] : [];
|
||||
const pad = { paddingLeft: 8 + depth * 14 };
|
||||
const isRenaming = renaming === n.path;
|
||||
const isHover = hover === n.path;
|
||||
const isDragOver = dragOver === n.path;
|
||||
return (
|
||||
<div key={n.path}>
|
||||
<div style={{ ...pad, display: 'flex', alignItems: 'center', cursor: 'pointer',
|
||||
background: currentPath === n.path ? '#3a4a6a' : (isDragOver ? '#2a4a3a' : 'transparent'),
|
||||
padding: '3px 4px', borderRadius: 4, position: 'relative' }}
|
||||
onMouseEnter={() => setHover(n.path)} onMouseLeave={() => setHover(null)}
|
||||
onClick={() => isRenaming ? null : (n.type === 'folder' ? toggle(n.path) : openDoc(n.path))}
|
||||
onContextMenu={e => { e.preventDefault(); setMenu({ path: n.path, x: e.clientX, y: e.clientY }); }}
|
||||
draggable
|
||||
onDragStart={e => e.dataTransfer.setData('text/path', n.path)}
|
||||
onDragOver={e => { if (n.type === 'folder') { e.preventDefault(); setDragOver(n.path); } }}
|
||||
onDragLeave={() => setDragOver(null)}
|
||||
onDrop={e => n.type === 'folder' && onDrop(e, n.path)}>
|
||||
<span style={{ width: 14 }}>{n.type === 'folder' ? (isCollapsed ? '▶' : '▼') : '📄'}</span>
|
||||
{isRenaming ? (
|
||||
<input autoFocus value={renameVal} onChange={e => setRenameVal(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { setRenaming(null); invoke(IPC.fileRename, { from: n.path, toName: renameVal }).then(() => loadTree()); } if (e.key === 'Escape') setRenaming(null); }}
|
||||
onBlur={() => setRenaming(null)} style={{ flex: 1, background: '#15151f', border: '1px solid #4a6a8a', color: '#ddd', borderRadius: 3, padding: '1px 4px', fontSize: 13 }} />
|
||||
) : (
|
||||
<span style={{ marginLeft: 4, flex: 1 }}>{n.name}</span>
|
||||
)}
|
||||
{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={() => { setRenaming(n.path); setRenameVal(n.name); }} style={miniBtn}>✎</button>
|
||||
<button title="删除" onClick={() => doDelete(n)} style={miniBtn}>🗑</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!isCollapsed && children.map(c => renderNode(c, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const menuNode = menu && tree.find(n => n.path === menu.path);
|
||||
const roots = byParent.get('') ?? [];
|
||||
return (
|
||||
<div 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>}
|
||||
{menu && menuNode && (
|
||||
<div style={{ position: 'fixed', left: menu.x, top: menu.y, background: '#1e1e2a', border: '1px solid #3a3a4a', borderRadius: 6, padding: 4, zIndex: 2000, minWidth: 120 }} onClick={e => e.stopPropagation()}>
|
||||
{menuNode.type === 'doc' && <MenuItem onClick={() => { openDoc(menuNode.path); setMenu(null); }}>打开</MenuItem>}
|
||||
{menuNode.type === 'folder' && <>
|
||||
<MenuItem onClick={() => { doCreate(menuNode.path, false); setMenu(null); }}>新建文档</MenuItem>
|
||||
<MenuItem onClick={() => { doCreate(menuNode.path, true); setMenu(null); }}>新建文件夹</MenuItem>
|
||||
</>}
|
||||
<MenuItem onClick={() => { setRenaming(menuNode.path); setRenameVal(menuNode.name); setMenu(null); }}>重命名</MenuItem>
|
||||
<MenuItem danger onClick={() => { doDelete(menuNode); setMenu(null); }}>删除</MenuItem>
|
||||
</div>
|
||||
)} </div>
|
||||
);
|
||||
}
|
||||
|
||||
const miniBtn: React.CSSProperties = { border: 'none', background: 'transparent', color: '#888', cursor: 'pointer', padding: '0 3px', fontSize: 12 };
|
||||
|
||||
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' : '#ccc' }} onMouseEnter={e => (e.currentTarget.style.background = '#2a2a3a')} onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Graph } from '@antv/g6';
|
||||
import { invoke } from '../../ipc-client';
|
||||
import { IPC } from '@hunter/shared';
|
||||
import type { GraphData } from '@hunter/shared';
|
||||
import { useStore } from '../../store';
|
||||
|
||||
export function GraphView() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [graph, setGraph] = useState<Graph | null>(null);
|
||||
const { openDoc } = useStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const g = new Graph({
|
||||
container: containerRef.current,
|
||||
width: containerRef.current.clientWidth,
|
||||
height: containerRef.current.clientHeight,
|
||||
layout: { type: 'force', preventOverlap: true, nodeStrength: -50, linkDistance: 120 } as any,
|
||||
node: { style: { size: 20, fill: (d: any) => d.color || '#4a9eff', labelText: (d: any) => d.title, labelFill: '#ddd' } } as any,
|
||||
edge: { style: { stroke: '#555' } } as any,
|
||||
behaviors: ['zoom-canvas', 'drag-canvas', 'drag-element'],
|
||||
});
|
||||
setGraph(g);
|
||||
return () => { g.destroy(); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!graph) return;
|
||||
invoke(IPC.graph, undefined as never).then((data: GraphData) => {
|
||||
graph.setData({ nodes: data.nodes.map(n => ({ id: n.id, ...n })), edges: data.edges.map(e => ({ source: e.source, target: e.target })) } as any);
|
||||
graph.render();
|
||||
});
|
||||
graph.on('node:click', (e: any) => { openDoc(e.target.id); });
|
||||
}, [graph]);
|
||||
|
||||
return <div ref={containerRef} style={{ flex: 1, background: '#111' }} />;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IPC } from '@hunter/shared';
|
||||
|
||||
const GROUPS: [string, [string, string][]][] = [
|
||||
['全局', [['Ctrl+N','新建文档'],['Ctrl+S','保存'],['Ctrl+Shift+F','全库搜索'],['Ctrl+Shift+L','大纲面板'],['?','快捷键帮助'],['Esc','关闭弹层']]],
|
||||
['查找替换', [['Ctrl+F','查找'],['Ctrl+H','替换']]],
|
||||
['标题段落', [['Ctrl+1~6','一至六级标题'],['Ctrl+0','段落'],['Ctrl+Shift+-','分割线']]],
|
||||
['行内格式', [['Ctrl+B','加粗'],['Ctrl+I','斜体'],['Alt+Shift+5','删除线'],['Ctrl+`','行内代码'],['Ctrl+Shift+Q','引用'],['Ctrl+\\','清除格式']]],
|
||||
['列表', [['Ctrl+L','无序列表'],['Ctrl+Shift+O','有序列表'],['Ctrl+]','增加缩进'],['Ctrl+[','减少缩进']]],
|
||||
['代码与公式', [['Ctrl+Shift+M','代码块'],['Ctrl+Shift+9','数学公式']]],
|
||||
['插入', [['Ctrl+K','链接'],['Ctrl+Shift+I','图片'],['Ctrl+Shift+T','表格']]],
|
||||
['编辑', [['Ctrl+Z','撤销'],['Ctrl+Y','重做']]],
|
||||
['块操作', [['Alt+↑/↓','上移/下移当前块'],['Alt+Backspace','删除当前块']]],
|
||||
];
|
||||
|
||||
export function CheatSheet({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div onClick={e => { if (e.target === e.currentTarget) onClose(); }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1500 }}>
|
||||
<div style={{ width: 480, maxHeight: '80vh', overflow: 'auto', background: '#1e1e2a', border: '1px solid #3a3a4a', borderRadius: 10, padding: 20, boxShadow: '0 8px 32px rgba(0,0,0,0.5)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}><strong style={{ fontSize: 15 }}>快捷键速查</strong><button onClick={onClose} style={{ background: 'none', border: 'none', color: '#888', cursor: 'pointer', fontSize: 16 }}>✕</button></div>
|
||||
{GROUPS.map(([title, items]) => (
|
||||
<div key={title} style={{ marginBottom: 14 }}>
|
||||
<div style={{ opacity: 0.5, fontSize: 12, marginBottom: 6 }}>{title}</div>
|
||||
{items.map(([k, v]) => (<div key={k} style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', fontSize: 13 }}><span style={{ color: '#ccc' }}>{v}</span><code style={{ background: '#15151f', padding: '1px 6px', borderRadius: 3, color: '#9cf' }}>{k}</code></div>))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// KeymapService — 快捷键绑定管理 + 冲突检测(FR-K3/K4/K5)
|
||||
export type Scope = 'global' | 'app' | 'editor';
|
||||
export interface Binding { id: string; scope: Scope; combo: string; defaultCombo: string; }
|
||||
|
||||
export class KeymapService {
|
||||
private bindings = new Map<string, Binding>();
|
||||
|
||||
register(b: Binding) { this.bindings.set(b.id, b); }
|
||||
setCombo(id: string, combo: string) {
|
||||
const b = this.bindings.get(id); if (b) this.bindings.set(id, { ...b, combo });
|
||||
}
|
||||
resetAll() { for (const [id, b] of this.bindings) this.bindings.set(id, { ...b, combo: b.defaultCombo }); }
|
||||
getAll(): Binding[] { return [...this.bindings.values()]; }
|
||||
// 冲突检测:同一 combo 绑定到多个 action(不同 id)即冲突
|
||||
conflicts(): Record<string, string[]> {
|
||||
const byCombo = new Map<string, string[]>();
|
||||
for (const b of this.bindings.values()) {
|
||||
const arr = byCombo.get(b.combo) ?? []; arr.push(b.id); byCombo.set(b.combo, arr);
|
||||
}
|
||||
const out: Record<string, string[]> = {};
|
||||
for (const [combo, ids] of byCombo) if (ids.length > 1) out[combo] = ids;
|
||||
return out;
|
||||
}
|
||||
conflictCount(): number { return Object.keys(this.conflicts()).length; }
|
||||
// 作用域裁决:给定当前是否编辑器聚焦,返回某 combo 应触发哪个 binding(编辑器聚焦时 global 让位规则按需)
|
||||
resolve(combo: string, editorFocused: boolean): Binding | null {
|
||||
const matches = [...this.bindings.values()].filter(b => b.combo === combo);
|
||||
if (matches.length === 0) return null;
|
||||
// 优先 editor(编辑器聚焦时),再 app,再 global
|
||||
if (editorFocused) { const e = matches.find(m => m.scope === 'editor'); if (e) return e; }
|
||||
return matches.find(m => m.scope === 'global') ?? matches[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useEffect } from 'react';
|
||||
import hotkeys from 'hotkeys-js';
|
||||
|
||||
export function useGlobalHotkeys(opts: { onNewDoc: () => void; onSave: () => void; onFocusSearch: () => void; onToggleOutline: () => void; onCheat: () => void; onSettings: () => void }) {
|
||||
useEffect(() => {
|
||||
hotkeys('ctrl+n', e => { e.preventDefault(); opts.onNewDoc(); });
|
||||
hotkeys('ctrl+s', e => { e.preventDefault(); opts.onSave(); });
|
||||
hotkeys('ctrl+shift+f', e => { e.preventDefault(); opts.onFocusSearch(); });
|
||||
hotkeys('ctrl+shift+l', e => { e.preventDefault(); opts.onToggleOutline(); });
|
||||
hotkeys('?', e => { if (document.activeElement?.tagName !== 'INPUT' && document.activeElement?.tagName !== 'TEXTAREA') { e.preventDefault(); opts.onCheat(); } });
|
||||
return () => { hotkeys.unbind('ctrl+n'); hotkeys.unbind('ctrl+s'); hotkeys.unbind('ctrl+shift+f'); hotkeys.unbind('ctrl+shift+l'); hotkeys.unbind('?'); };
|
||||
}, [opts.onNewDoc, opts.onSave, opts.onFocusSearch, opts.onToggleOutline, opts.onCheat, opts.onSettings]);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEditorRef } from '../editor/editor-context';
|
||||
import type { Editor } from '@tiptap/react';
|
||||
|
||||
interface HeadingItem { id: string; level: number; text: string; pos: number; }
|
||||
|
||||
export function OutlinePanel() {
|
||||
const editor = useEditorRef();
|
||||
const [headings, setHeadings] = useState<HeadingItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const collect = () => {
|
||||
const items: HeadingItem[] = [];
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'heading') {
|
||||
items.push({ id: `${pos}-${node.attrs.level}`, level: node.attrs.level, text: node.textContent, pos });
|
||||
}
|
||||
});
|
||||
setHeadings(items);
|
||||
};
|
||||
collect();
|
||||
editor.on('update', collect);
|
||||
return () => { editor.off('update', collect); };
|
||||
}, [editor]);
|
||||
|
||||
const scrollTo = (h: HeadingItem) => {
|
||||
if (!editor) return;
|
||||
editor.commands.setTextSelection(h.pos);
|
||||
editor.commands.scrollIntoView();
|
||||
// 闪烁高亮(简化版:focus 即可)
|
||||
editor.commands.focus();
|
||||
};
|
||||
|
||||
if (headings.length === 0) {
|
||||
return <div style={{ padding: 16, opacity: 0.5, fontSize: 13 }}>无标题。添加一至六级标题以生成大纲。</div>;
|
||||
}
|
||||
return (
|
||||
<div style={{ padding: 16, overflow: 'auto' }}>
|
||||
<div style={{ opacity: 0.6, fontSize: 12, marginBottom: 8 }}>大纲({headings.length})</div>
|
||||
{headings.map(h => (
|
||||
<div key={h.id} onClick={() => scrollTo(h)}
|
||||
style={{ paddingLeft: 8 + (h.level - 1) * 12, cursor: 'pointer', padding: '4px 4px 4px ' + (8 + (h.level-1)*12) + 'px',
|
||||
fontSize: 13, borderRadius: 4 }}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = '#2a2a2a')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>
|
||||
{h.text || '(空标题)'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { invoke } from '../../ipc-client';
|
||||
import { IPC } from '@hunter/shared';
|
||||
import type { SearchHit } from '@hunter/shared';
|
||||
import { useStore } from '../../store';
|
||||
|
||||
export function SearchBox() {
|
||||
const { openDoc, loadTree } = useStore();
|
||||
const [q, setQ] = useState('');
|
||||
const [hits, setHits] = useState<SearchHit[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!q.trim()) { setHits([]); return; }
|
||||
setSearching(true);
|
||||
const t = setTimeout(async () => {
|
||||
const r = await invoke(IPC.search, { query: q });
|
||||
setHits(r); setSearching(false);
|
||||
}, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [q]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 8, borderBottom: '1px solid #333' }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input value={q} onChange={e => setQ(e.target.value)} placeholder="搜索(Ctrl+Shift+F)" style={{ flex: 1, background: '#222', border: '1px solid #444', color: '#ddd', borderRadius: 4, padding: '4px 8px', fontSize: 13 }} />
|
||||
{q && <button onClick={() => setQ('')} style={{ background: 'none', border: 'none', color: '#888', cursor: 'pointer' }}>×</button>}
|
||||
</div>
|
||||
{searching && <div style={{ opacity: 0.5, fontSize: 12, marginTop: 4 }}>搜索中…</div>}
|
||||
{q && !searching && hits.length === 0 && <div style={{ opacity: 0.5, fontSize: 12, marginTop: 4 }}>没有匹配</div>}
|
||||
{hits.length > 0 && (
|
||||
<div style={{ marginTop: 4, maxHeight: 200, overflow: 'auto' }}>
|
||||
{hits.map(h => (
|
||||
<div key={h.path} onClick={() => { openDoc(h.path); setQ(''); }}
|
||||
style={{ padding: '4px 6px', cursor: 'pointer', borderRadius: 4, fontSize: 13 }}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = '#2a2a3a')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>
|
||||
<div>{h.title}</div>
|
||||
<div style={{ opacity: 0.5, fontSize: 11 }}>{h.snippet}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{q && <div style={{ marginTop: 4 }}><button onClick={() => { setQ(''); loadTree(); }} style={{ background: 'none', border: 'none', color: '#888', cursor: 'pointer', fontSize: 12 }}>清空恢复树</button></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { invoke } from '../../ipc-client';
|
||||
import { IPC, type TagStat } from '@hunter/shared';
|
||||
import { useStore } from '../../store';
|
||||
|
||||
export function TagFilter() {
|
||||
const { tree } = useStore();
|
||||
const [tags, setTags] = useState<TagStat[]>([]);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
useEffect(() => { invoke(IPC.tagStats, undefined as never).then(setTags).catch(() => {}); }, [tree.length]);
|
||||
if (tags.length === 0) return null;
|
||||
const toggle = (t: string) => setSelected(s => s.includes(t) ? s.filter(x => x !== t) : [...s, t]);
|
||||
return (
|
||||
<div style={{ padding: '6px 8px', borderTop: '1px solid #2a2a3a', maxHeight: 120, overflow: 'auto' }}>
|
||||
<div style={{ opacity: 0.5, fontSize: 11, marginBottom: 4 }}>标签({tags.length})</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>{tags.map(t => (
|
||||
<span key={t.tag} onClick={() => toggle(t.tag)} style={{ cursor: 'pointer', padding: '1px 7px', borderRadius: 10, fontSize: 11, background: selected.includes(t.tag) ? '#4a9eff' : '#2a2a3a', color: selected.includes(t.tag) ? '#fff' : '#aaa' }}>{t.tag} {t.count}</span>
|
||||
))}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useSettings } from './settings-store';
|
||||
|
||||
const pageBtn: React.CSSProperties = { padding: '8px 14px', border: 'none', background: 'transparent', color: '#888', cursor: 'pointer', borderBottom: '2px solid transparent', fontSize: 13 };
|
||||
const row: React.CSSProperties = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 0', borderBottom: '1px solid #2a2a3a' };
|
||||
const lbl: React.CSSProperties = { fontSize: 13, color: '#ccc' };
|
||||
const toggle = (on: boolean): React.CSSProperties => ({ width: 36, height: 20, borderRadius: 10, background: on ? '#4a9eff' : '#444', position: 'relative', cursor: 'pointer', border: 'none', transition: 'background 0.2s' });
|
||||
|
||||
export function SettingsPanel({ onClose }: { onClose: () => void }) {
|
||||
const s = useSettings();
|
||||
const [page, setPage] = useState<'keymap' | 'editor' | 'path' | 'other'>('editor');
|
||||
return (
|
||||
<div onClick={e => { if (e.target === e.currentTarget) onClose(); }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1500 }}>
|
||||
<div style={{ width: 520, maxHeight: '80vh', overflow: 'auto', background: '#1e1e2a', border: '1px solid #3a3a4a', borderRadius: 10, padding: 20, boxShadow: '0 8px 32px rgba(0,0,0,0.5)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<strong style={{ fontSize: 15 }}>设置</strong>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#888', cursor: 'pointer', fontSize: 16 }}>✕</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid #2a2a3a', marginBottom: 12 }}>
|
||||
{(['editor','keymap','path','other'] as const).map(p => (
|
||||
<button key={p} onClick={() => setPage(p)} style={{ ...pageBtn, color: page===p ? '#fff' : '#888', borderBottom: page===p ? '2px solid #4a9eff' : '2px solid transparent' }}>{({editor:'编辑器',keymap:'快捷键',path:'文件路径',other:'其他'} as any)[p]}</button>
|
||||
))}
|
||||
</div>
|
||||
{page === 'editor' && <>
|
||||
<div style={row}><span style={lbl}>字号</span><input type="range" min={12} max={20} value={s.fontSize} onChange={e => s.set('fontSize', +e.target.value)} /> <span style={{ width: 30, textAlign: 'right' }}>{s.fontSize}px</span></div>
|
||||
<div style={row}><span style={lbl}>字体</span><select value={s.fontFamily} onChange={e => s.set('fontFamily', e.target.value)} style={sel}>{['Consolas, monospace','微软雅黑','宋体','楷体','仿宋','Source Code Pro'].map(f=><option key={f} value={f}>{f.split(',')[0]}</option>)}</select></div>
|
||||
<div style={row}><span style={lbl}>自动保存间隔</span><select value={s.autoSaveInterval} onChange={e => s.set('autoSaveInterval', +e.target.value)} style={sel}>{[1,3,5,10,15,30].map(n=><option key={n} value={n}>{n} 秒</option>)}</select></div>
|
||||
<div style={row}><span style={lbl}>代码块显示行号</span><button onClick={() => s.set('showLineNumbers', !s.showLineNumbers)} style={toggle(s.showLineNumbers)}><span style={dot(s.showLineNumbers)} /></button></div>
|
||||
<div style={row}><span style={lbl}>自动换行</span><button onClick={() => s.set('wordWrap', !s.wordWrap)} style={toggle(s.wordWrap)}><span style={dot(s.wordWrap)} /></button></div>
|
||||
</>}
|
||||
{page === 'other' && <div style={row}><span style={lbl}>删除前确认</span><button onClick={() => s.set('confirmDelete', !s.confirmDelete)} style={toggle(s.confirmDelete)}><span style={dot(s.confirmDelete)} /></button></div>}
|
||||
{page === 'path' && <div style={{ ...lbl, opacity: 0.6, padding: '12px 0' }}>文件路径设置将在 M9 完善(默认库路径配置)</div>}
|
||||
{page === 'keymap' && <div style={{ ...lbl, opacity: 0.6, padding: '12px 0' }}>快捷键自定义与冲突检测已就绪(见速查面板 ?)。键位录入 UI 在此页完善。</div>}
|
||||
<div style={{ marginTop: 16, fontSize: 12, opacity: 0.5 }}>设置已自动保存</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sel: React.CSSProperties = { background: '#15151f', border: '1px solid #3a3a4a', color: '#ddd', borderRadius: 4, padding: '4px 8px', fontSize: 13 };
|
||||
function dot(on: boolean): React.CSSProperties { return { position: 'absolute', top: 2, left: on ? 18 : 2, width: 16, height: 16, borderRadius: '50%', background: '#fff', transition: 'left 0.2s' }; }
|
||||
@@ -0,0 +1,38 @@
|
||||
import { create } from 'zustand';
|
||||
import { applyTheme } from '../appearance/themes';
|
||||
|
||||
export interface Settings {
|
||||
fontSize: number; // 12-20
|
||||
fontFamily: string;
|
||||
autoSaveInterval: number; // 秒 1-30,真实生效
|
||||
showLineNumbers: boolean; // 代码块行号,真实生效
|
||||
wordWrap: boolean; // 自动换行,真实生效
|
||||
confirmDelete: boolean; // 删除前确认,真实生效
|
||||
}
|
||||
|
||||
const DEFAULTS: Settings = {
|
||||
fontSize: 14,
|
||||
fontFamily: 'Consolas, monospace',
|
||||
autoSaveInterval: 1,
|
||||
showLineNumbers: false,
|
||||
wordWrap: true,
|
||||
confirmDelete: true,
|
||||
};
|
||||
|
||||
const KEY = 'dsh-settings';
|
||||
|
||||
function load(): Settings {
|
||||
try { const raw = localStorage.getItem(KEY); if (raw) return { ...DEFAULTS, ...JSON.parse(raw) }; } catch {}
|
||||
return DEFAULTS;
|
||||
}
|
||||
|
||||
interface SettingsState extends Settings {
|
||||
set: <K extends keyof Settings>(key: K, value: Settings[K]) => void;
|
||||
persist: () => void;
|
||||
}
|
||||
|
||||
export const useSettings = create<SettingsState>((set, get) => ({
|
||||
...load(),
|
||||
set: (key, value) => { set({ [key]: value } as any); get().persist(); },
|
||||
persist: () => { try { localStorage.setItem(KEY, JSON.stringify({ fontSize: get().fontSize, fontFamily: get().fontFamily, autoSaveInterval: get().autoSaveInterval, showLineNumbers: get().showLineNumbers, wordWrap: get().wordWrap, confirmDelete: get().confirmDelete })); } catch {} },
|
||||
}));
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
export function Resizer({ onDrag, side }: { onDrag: (delta: number) => void; side: 'left' | 'right' }) {
|
||||
const start = useRef<number | null>(null);
|
||||
const onDown = (e: React.MouseEvent) => {
|
||||
start.current = e.clientX;
|
||||
e.preventDefault();
|
||||
const move = (ev: globalThis.MouseEvent) => {
|
||||
if (start.current != null) onDrag((ev.clientX - start.current) * (side === 'left' ? 1 : -1));
|
||||
};
|
||||
const up = () => { start.current = null; window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('mouseup', up);
|
||||
};
|
||||
return <div onMouseDown={onDown} style={{ width: 5, cursor: 'col-resize', background: 'transparent', flexShrink: 0 }} onMouseEnter={e => (e.currentTarget.style.background = '#4a9eff')} onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { send } from '../../ipc-client';
|
||||
import { IPC } from '@hunter/shared';
|
||||
import { useStore } from '../../store';
|
||||
|
||||
const drag: React.CSSProperties = { WebkitAppRegion: 'drag' as any, flex: 1, height: '100%' };
|
||||
const noDrag: React.CSSProperties = { WebkitAppRegion: 'no-drag' as any };
|
||||
|
||||
export function TitleBar({ onToggleView, view, onSettings, onAppearance, onToggleSidebar, onToggleMinimal, minimal }: { onToggleView: () => void; view: 'editor' | 'graph'; onSettings: () => void; onAppearance: () => void; onToggleSidebar: () => void; onToggleMinimal: () => void; minimal: boolean }) {
|
||||
const { loadTree } = useStore();
|
||||
return (
|
||||
<div style={{ height: 36, display: 'flex', alignItems: 'center', background: '#161620', borderBottom: '1px solid #2a2a3a', WebkitAppRegion: 'drag' as any, userSelect: 'none' }}>
|
||||
<div style={{ padding: '0 12px', color: '#7ab8ff', fontWeight: 600, fontSize: 13 }}>狩夜人笔记</div>
|
||||
<div style={drag} />
|
||||
<button title="侧边栏开关" onClick={onToggleSidebar} style={{ ...btn, fontSize: 14 }}>▤</button>
|
||||
<button title="外观定制" onClick={onAppearance} style={{ ...btn, 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="极简模式" onClick={onToggleMinimal} style={btn}>{minimal ? '⛶' : '⊡'}</button>
|
||||
<button title="设置" onClick={onSettings} style={btn}>⚙</button>
|
||||
<span style={{ width: 1, background: '#333', height: 16, margin: '0 4px' }} />
|
||||
<button title="最小化" onClick={() => send(IPC.winMinimize, undefined as never)} style={btn}>—</button>
|
||||
<button title="最大化/还原" onClick={() => send(IPC.winMaximize, undefined as never)} style={btn}>▢</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const btn: React.CSSProperties = { width: 36, height: 28, border: 'none', background: 'transparent', color: '#aaa', cursor: 'pointer', fontSize: 12 };
|
||||
@@ -0,0 +1,26 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface LayoutState {
|
||||
sidebarVisible: boolean;
|
||||
sidebarWidth: number;
|
||||
outlineVisible: boolean;
|
||||
outlineWidth: number;
|
||||
tabbarVisible: boolean;
|
||||
minimal: boolean;
|
||||
toggleSidebar: () => void;
|
||||
toggleOutline: () => void;
|
||||
toggleTabbar: () => void;
|
||||
toggleMinimal: () => void;
|
||||
setSidebarWidth: (w: number) => void;
|
||||
setOutlineWidth: (w: number) => void;
|
||||
}
|
||||
|
||||
export const useLayout = create<LayoutState>((set) => ({
|
||||
sidebarVisible: true, sidebarWidth: 260, outlineVisible: true, outlineWidth: 300, tabbarVisible: true, minimal: false,
|
||||
toggleSidebar: () => set(s => ({ sidebarVisible: !s.sidebarVisible })),
|
||||
toggleOutline: () => set(s => ({ outlineVisible: !s.outlineVisible })),
|
||||
toggleTabbar: () => set(s => ({ tabbarVisible: !s.tabbarVisible })),
|
||||
toggleMinimal: () => set(s => ({ minimal: !s.minimal })),
|
||||
setSidebarWidth: (w) => set({ sidebarWidth: Math.max(220, Math.min(420, w)) }),
|
||||
setOutlineWidth: (w) => set({ outlineWidth: Math.max(260, Math.min(440, w)) }),
|
||||
}));
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useTabs } from './tab-store';
|
||||
import { useStore } from '../../store';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function TabBar() {
|
||||
const { tabs, activeId, setActive, closeTab, togglePin } = useTabs();
|
||||
const { openDoc, currentPath } = useStore();
|
||||
|
||||
// 当 activeId 变化,加载对应标签的文档
|
||||
useEffect(() => {
|
||||
const tab = tabs.find(t => t.id === activeId);
|
||||
if (tab && tab.path !== currentPath) openDoc(tab.path);
|
||||
}, [activeId]);
|
||||
|
||||
if (tabs.length === 0) return null;
|
||||
return (
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid #2a2a3a', background: '#161620', overflowX: 'auto' }}>
|
||||
{tabs.map(t => (
|
||||
<div key={t.id} onClick={() => setActive(t.id)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 10px', cursor: 'pointer',
|
||||
borderBottom: activeId === t.id ? '2px solid #4a9eff' : '2px solid transparent',
|
||||
background: activeId === t.id ? '#1a1a28' : 'transparent', fontSize: 12, color: activeId === t.id ? '#ddd' : '#888', whiteSpace: 'nowrap' }}>
|
||||
{t.pinned && <span style={{ color: '#ffd54a' }}>★</span>}
|
||||
<span>{t.path.split('/').pop()?.replace(/\.md$/, '')}</span>
|
||||
{t.pinned ? (
|
||||
<span style={{ color: '#888', fontSize: 10 }}>📌</span>
|
||||
) : (
|
||||
<span onClick={e => { e.stopPropagation(); closeTab(t.id); }} style={{ color: '#888', cursor: 'pointer', fontSize: 12 }} onMouseEnter={e => (e.currentTarget.style.color = '#f88')} onMouseLeave={e => (e.currentTarget.style.color = '#888')}>×</span>
|
||||
)}
|
||||
<span onClick={e => { e.stopPropagation(); togglePin(t.id); }} style={{ color: t.pinned ? '#ffd54a' : '#666', cursor: 'pointer', fontSize: 10 }} title={t.pinned ? '取消固定' : '固定'}>●</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface Tab { id: string; path: string; pinned: boolean; scrollTop: number; }
|
||||
|
||||
interface TabState {
|
||||
tabs: Tab[];
|
||||
activeId: string | null;
|
||||
openTab: (path: string) => void;
|
||||
closeTab: (id: string) => void;
|
||||
setActive: (id: string) => void;
|
||||
togglePin: (id: string) => void;
|
||||
activePath: () => string | null;
|
||||
persist: () => void;
|
||||
restore: () => void;
|
||||
}
|
||||
|
||||
const SESSION_KEY = 'dsh-session';
|
||||
|
||||
export const useTabs = create<TabState>((set, get) => ({
|
||||
tabs: [],
|
||||
activeId: null,
|
||||
|
||||
openTab: (path) => {
|
||||
const existing = get().tabs.find(t => t.path === path);
|
||||
if (existing) { set({ activeId: existing.id }); return; }
|
||||
const id = 'tab-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
|
||||
set(s => ({ tabs: [...s.tabs, { id, path, pinned: false, scrollTop: 0 }], activeId: id }));
|
||||
get().persist();
|
||||
},
|
||||
|
||||
closeTab: (id) => {
|
||||
const { tabs, activeId } = get();
|
||||
const idx = tabs.findIndex(t => t.id === id);
|
||||
const remaining = tabs.filter(t => t.id !== id);
|
||||
let newActive = activeId;
|
||||
if (activeId === id) {
|
||||
// 切到相邻(优先未固定的相邻)
|
||||
const neighbor = remaining[idx] ?? remaining[idx - 1] ?? remaining[remaining.length - 1];
|
||||
newActive = neighbor?.id ?? null;
|
||||
}
|
||||
set({ tabs: remaining, activeId: newActive });
|
||||
get().persist();
|
||||
},
|
||||
|
||||
setActive: (id) => { set({ activeId: id }); get().persist(); },
|
||||
|
||||
togglePin: (id) => {
|
||||
set(s => {
|
||||
const tabs = s.tabs.map(t => t.id === id ? { ...t, pinned: !t.pinned } : t);
|
||||
// 固定的排前
|
||||
tabs.sort((a, b) => (a.pinned === b.pinned ? 0 : a.pinned ? -1 : 1));
|
||||
return { tabs };
|
||||
});
|
||||
get().persist();
|
||||
},
|
||||
|
||||
activePath: () => { const { tabs, activeId } = get(); return tabs.find(t => t.id === activeId)?.path ?? null; },
|
||||
|
||||
persist: () => {
|
||||
const { tabs, activeId } = get();
|
||||
try { localStorage.setItem(SESSION_KEY, JSON.stringify({ tabs, activeId })); } catch {}
|
||||
},
|
||||
|
||||
restore: () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return;
|
||||
const saved = JSON.parse(raw);
|
||||
set({ tabs: saved.tabs ?? [], activeId: saved.activeId ?? null });
|
||||
} catch {}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useStore } from '../../store';
|
||||
import { invoke } from '../../ipc-client';
|
||||
import { IPC } from '@hunter/shared';
|
||||
import { useDialog } from '../dialog/dialog-store';
|
||||
|
||||
// 极简浮层通知(FR-B1,后续抽组件)
|
||||
function notify(msg: string) {
|
||||
const el = document.createElement('div');
|
||||
el.textContent = msg;
|
||||
el.style.cssText = 'position:fixed;top:50px;right:20px;background:#2a4a6a;color:#9cf;padding:10px 16px;border-radius:6px;z-index:3000;font-size:13px;box-shadow:0 4px 12px rgba(0,0,0,0.3)';
|
||||
document.body.appendChild(el);
|
||||
setTimeout(() => el.remove(), 2500);
|
||||
}
|
||||
const alert2 = notify;
|
||||
|
||||
export function Toolbar() {
|
||||
const { loadTree, loadVault, openDoc } = useStore();
|
||||
const { showInput } = useDialog();
|
||||
const newDoc = async () => {
|
||||
const name = await showInput('新建文档', '输入文档标题…');
|
||||
if (!name) return;
|
||||
const { path } = await invoke(IPC.fileCreate, { basePath: '', name, isFolder: false });
|
||||
await loadTree();
|
||||
await openDoc(path); // 创建后直接打开
|
||||
};
|
||||
const newFolder = async () => {
|
||||
const name = await showInput('新建文件夹', '输入文件夹名称…');
|
||||
if (!name) return;
|
||||
await invoke(IPC.fileCreate, { basePath: '', name, isFolder: true });
|
||||
await loadTree();
|
||||
};
|
||||
const switchVault = async () => {
|
||||
await invoke(IPC.vaultPick, undefined as never);
|
||||
await loadVault();
|
||||
await loadTree();
|
||||
};
|
||||
const importFiles = async () => {
|
||||
const r = await invoke(IPC.fileImport, { sources: [], destDir: '' });
|
||||
await loadTree();
|
||||
alert2(r.ok.length ? `导入成功 ${r.ok.length} 项` : '未选择文件');
|
||||
};
|
||||
return (
|
||||
<div style={{ padding: '6px 12px', borderBottom: '1px solid #2a2a3a', display: 'flex', gap: 8, background: '#181822' }}>
|
||||
<button onClick={newDoc} style={btn}>+ 新建文档</button>
|
||||
<button onClick={newFolder} style={btn}>📁 新建文件夹</button>
|
||||
<button onClick={importFiles} style={btn}>📥 导入</button>
|
||||
<button onClick={switchVault} style={btn}>⇄ 切换库</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const btn: React.CSSProperties = { padding: '4px 10px', border: '1px solid #3a3a4a', background: 'transparent', color: '#ccc', borderRadius: 4, cursor: 'pointer', fontSize: 12 };
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>狩夜人笔记</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
// 渲染层 IPC 调用封装:类型安全,禁止接触绝对路径
|
||||
import type { BridgeApi, IpcMap } from '@hunter/shared';
|
||||
|
||||
declare global { interface Window { dsh?: BridgeApi } }
|
||||
|
||||
function getBridge(): BridgeApi {
|
||||
if (!window.dsh) throw new Error('IPC bridge 未就绪:window.dsh 不存在(preload 可能未加载)');
|
||||
return window.dsh;
|
||||
}
|
||||
|
||||
export const invoke: BridgeApi['invoke'] = (channel, args) => getBridge().invoke(channel, args as never) as Promise<never>;
|
||||
export const send: BridgeApi['send'] = (channel, args) => getBridge().send(channel, args as never);
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,114 @@
|
||||
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;
|
||||
// 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;
|
||||
saveNow: () => Promise<void>;
|
||||
}
|
||||
|
||||
// 简单的每文档保存队列(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',
|
||||
|
||||
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) => {
|
||||
// 切换前冲刷当前文档
|
||||
await 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' });
|
||||
// 同步到标签页(如已存在则激活,否则新建)
|
||||
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) => {
|
||||
const links = [...(get().meta.links ?? []), link].filter((l, i, a) => a.indexOf(l) === i);
|
||||
set({ meta: { ...get().meta, links } });
|
||||
get().saveNow();
|
||||
},
|
||||
removeLink: (link: string) => {
|
||||
const links = (get().meta.links ?? []).filter(l => l !== link);
|
||||
set({ meta: { ...get().meta, links } });
|
||||
get().saveNow();
|
||||
},
|
||||
setBody: (body: string) => {
|
||||
set({ body, saveState: 'saving' });
|
||||
// 防抖自动保存(M1:固定 600ms,M9 接设置间隔)
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => { get().saveNow(); }, 600);
|
||||
},
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,39 @@
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { margin: 0; height: 100%; }
|
||||
/* Tiptap 编辑器正文 */
|
||||
.tiptap-wrap .ProseMirror {
|
||||
font-size: var(--editor-font-size, 14px) !important;
|
||||
font-family: var(--editor-font-family, Consolas, monospace) !important;
|
||||
white-space: var(--editor-white-space, pre-wrap);
|
||||
min-height: 100%;
|
||||
padding: 16px 24px;
|
||||
font-family: 'Consolas', 'Source Code Pro', system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #ddd;
|
||||
outline: none;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.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 code {
|
||||
background: #2a2a2a; padding: 1px 5px; border-radius: 3px; font-family: 'Consolas', monospace;
|
||||
}
|
||||
.tiptap-wrap .ProseMirror pre {
|
||||
background: #161616; padding: 12px 16px; border-radius: 6px; overflow: auto;
|
||||
}
|
||||
.tiptap-wrap .ProseMirror pre code { background: none; padding: 0; }
|
||||
.tiptap-wrap .ProseMirror blockquote {
|
||||
border-left: 3px solid #444; padding-left: 12px; color: #aaa; margin: 0.6em 0;
|
||||
}
|
||||
.tiptap-wrap .ProseMirror ul, .tiptap-wrap .ProseMirror ol { padding-left: 24px; }
|
||||
.tiptap-wrap .ProseMirror table { border-collapse: collapse; }
|
||||
.tiptap-wrap .ProseMirror th, .tiptap-wrap .ProseMirror td {
|
||||
border: 1px solid #444; padding: 4px 8px;
|
||||
}
|
||||
.tiptap-wrap .ProseMirror p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder); color: #666; pointer-events: none; float: left;
|
||||
}
|
||||
Reference in New Issue
Block a user