feat(info): 信息面板 UI 重做 + 双向批量关联 + 代码块内全选复制

1. 信息面板 UI 重做:设计 token 统一、lucide 图标、分区小节、星标/标签/关联 chip 化
2. 关联文档升级:新增 doc:link-batch IPC,A↔B 双向写入 links;面板支持快速双向关联 + 批量勾选关联/解除
3. 代码块内全选复制:Ctrl+A 在代码块内仅全选该代码块(TyporaKeys selectInsideCodeBlock),Ctrl+C 复制纯代码;代码块内 Ctrl+V 按纯文本粘贴
4. 修复 React error #62:btn 改为函数后遗漏 style={btn} 裸用,导致 render 期间更新
This commit is contained in:
hunter-dev
2026-09-09 20:11:08 +08:00
parent f77324afe7
commit dc8d844a1f
10 changed files with 353 additions and 58 deletions
+4 -4
View File
@@ -200,12 +200,12 @@ test('DocInfoPanel: 星标+标签+关联可用', async () => {
await a.page.getByPlaceholder('添加标签').press('Enter');
await a.page.waitForTimeout(300);
await expect(a.page.getByText('testtag')).toBeVisible({ timeout: 3000 });
// 关联文档
// 关联文档(快速双向关联:下拉为文档名,按钮 title 建立双向关联)
const select = a.page.locator('select').first();
await select.selectOption('DocB.md');
await a.page.getByRole('button', { name: '关联' }).click();
await select.selectOption('DocB');
await a.page.getByRole('button', { name: '关联', exact: true }).click();
await a.page.waitForTimeout(300);
await expect(a.page.getByText('DocB.md')).toBeVisible({ timeout: 3000 });
await expect(a.page.getByTitle('DocB.md')).toBeVisible({ timeout: 3000 });
await a.app.close();
});
@@ -0,0 +1,68 @@
import { test, expect } from '@playwright/test';
import { _electron as electron } from 'playwright';
import { promises as fs } from 'node:fs';
import nodePath from 'node:path';
import os from 'node:os';
const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js');
async function launch(userData: string) {
const app = await electron.launch({ args: ['--user-data-dir=' + userData, MAIN_JS] });
const page = await app.firstWindow();
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(800);
const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args });
const clip = () => app.evaluate(({ clipboard }) => clipboard.readText());
return { app, page, invoke, clip };
}
// 代码块内 Ctrl+A 全选后 Ctrl+C:应只复制代码块内容(纯代码无围栏),即使文档有其他内容
test('代码块内 Ctrl+A 全选复制纯代码(不受文档其他内容影响)', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-sel1-'));
const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v');
await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true });
const a = await launch(ud);
await a.invoke('vault:switch', { root: v });
// 文档:代码块上下都有其他内容,确保 Ctrl+A 只选代码块而非整篇
const body = '# 标题\n\n上方段落\n\n```bash\nsudo apt-get update\nls -la\n```\n\n下方段落';
await a.invoke('doc:write', { path: 'code.md', meta: {}, body });
await a.page.evaluate((p: string) => (globalThis as any).__STORE__?.getState?.().openDoc(p), 'code.md');
await a.page.waitForTimeout(1500);
const code = a.page.locator('.ProseMirror pre code');
await code.click();
await a.page.keyboard.press('Control+a');
await a.page.waitForTimeout(200);
// 校验选中范围只是代码块内容(可通过 selection anchor 判断,或直接 Ctrl+C 后看剪贴板)
await a.page.keyboard.press('Control+c');
await a.page.waitForTimeout(300);
const text = await a.clip();
console.log('SELALL_CLIP', JSON.stringify(text));
expect(text.trim().replace(/\r\n/g, '\n')).toBe('sudo apt-get update\nls -la');
await a.app.close();
});
// 代码块内 Ctrl+V 粘贴按纯文本插入(不产生 markdown 转换/围栏)
test('代码块内 Ctrl+V 粘贴纯文本', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-sel2-'));
const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v');
await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true });
const a = await launch(ud);
await a.invoke('vault:switch', { root: v });
await a.invoke('doc:write', { path: 'code2.md', meta: {}, body: '```js\nlet x = 1;\n```' });
await a.page.evaluate((p: string) => (globalThis as any).__STORE__?.getState?.().openDoc(p), 'code2.md');
await a.page.waitForTimeout(1500);
const code = a.page.locator('.ProseMirror pre code');
await code.click();
// 全选代码块,然后粘贴纯文本
await a.page.keyboard.press('Control+a');
await a.page.evaluate(() => navigator.clipboard.writeText('# not a heading\n- not a list'));
await a.page.waitForTimeout(200);
await a.page.keyboard.press('Control+v');
await a.page.waitForTimeout(500);
const inner = await a.page.evaluate(() => (document.querySelector('.ProseMirror pre code') as HTMLElement)?.innerText ?? '');
console.log('PASTE_INNER', JSON.stringify(inner));
// 粘贴内容按纯文本插入(不出现标题/列表节点结构变化)
expect(inner).toContain('# not a heading');
expect(inner).toContain('- not a list');
await a.app.close();
});
@@ -0,0 +1,94 @@
import { test, expect } from '@playwright/test';
import { _electron as electron } from 'playwright';
import { promises as fs } from 'node:fs';
import nodePath from 'node:path';
import os from 'node:os';
const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js');
async function launch(userData: string) {
const app = await electron.launch({ args: ['--user-data-dir=' + userData, MAIN_JS] });
const page = await app.firstWindow();
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(800);
const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args });
return { app, page, invoke };
}
async function setupVault(a: { invoke: any; page: any }, names: string[]) {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-assoc-'));
const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v');
await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true });
await a.invoke('vault:switch', { root: v });
for (const n of names) {
await a.invoke('doc:write', { path: n + '.md', meta: { title: n }, body: '# ' + n });
}
await a.page.evaluate(() => (globalThis as any).__STORE__?.getState?.().loadTree());
await a.page.waitForTimeout(500);
return { v };
}
async function openInfo(a: { page: any }, docName: string) {
await a.page.getByTestId('filetree').getByText(docName, { exact: true }).click();
await a.page.waitForTimeout(500);
await a.page.getByRole('button', { name: '信息', exact: true }).click();
await a.page.waitForTimeout(300);
}
// 双向关联:当前文档关联目标后,目标文档的 links 也包含当前文档
test('关联文档:快速双向关联互相写入 links', async () => {
const a = await launch(nodePath.join(os.tmpdir(), 'hn-a1-'));
await setupVault(a, ['DocA', 'DocB', 'DocC']);
await openInfo(a, 'DocA');
const select = a.page.locator('select').first();
await select.selectOption('DocB');
await a.page.getByRole('button', { name: '关联', exact: true }).click();
await a.page.waitForTimeout(500);
await expect(a.page.getByTitle('DocB.md')).toBeVisible({ timeout: 3000 });
const docb = await a.invoke('doc:read', { path: 'DocB.md' });
const doca = await a.invoke('doc:read', { path: 'DocA.md' });
expect(docb.meta.links).toContain('DocA.md');
expect(doca.meta.links).toContain('DocB.md');
await a.app.close();
});
// 批量关联:勾选多个文档一次性建立双向关联
test('关联文档:批量勾选多文档双向关联', async () => {
const a = await launch(nodePath.join(os.tmpdir(), 'hn-a2-'));
await setupVault(a, ['DocA', 'DocB', 'DocC']);
await openInfo(a, 'DocA');
await a.page.getByRole('button', { name: /批量关联/ }).click();
await a.page.waitForTimeout(200);
const boxes = a.page.locator('input[type=checkbox]');
const count = await boxes.count();
expect(count).toBeGreaterThanOrEqual(2);
await boxes.nth(0).check();
await boxes.nth(1).check();
await a.page.getByRole('button', { name: /关联选中/ }).click();
await a.page.waitForTimeout(600);
const doca = await a.invoke('doc:read', { path: 'DocA.md' });
const docb = await a.invoke('doc:read', { path: 'DocB.md' });
const docc = await a.invoke('doc:read', { path: 'DocC.md' });
expect(doca.meta.links).toContain('DocB.md');
expect(doca.meta.links).toContain('DocC.md');
expect(docb.meta.links).toContain('DocA.md');
expect(docc.meta.links).toContain('DocA.md');
await a.app.close();
});
// 解除关联为双向:移除后两侧 links 均清除
test('关联文档:解除关联双向清除', async () => {
const a = await launch(nodePath.join(os.tmpdir(), 'hn-a3-'));
await setupVault(a, ['DocA', 'DocB']);
await a.invoke('doc:link-batch', { path: 'DocA.md', targets: ['DocB.md'], remove: true });
await a.invoke('doc:link-batch', { path: 'DocA.md', targets: ['DocB.md'], remove: false });
await openInfo(a, 'DocA');
await expect(a.page.getByTitle('DocB.md')).toBeVisible({ timeout: 3000 });
await a.page.getByTitle('解除关联(双向)').click();
await a.page.waitForTimeout(500);
const doca = await a.invoke('doc:read', { path: 'DocA.md' });
const docb = await a.invoke('doc:read', { path: 'DocB.md' });
expect(doca.meta.links ?? []).not.toContain('DocB.md');
expect(docb.meta.links ?? []).not.toContain('DocA.md');
await a.app.close();
});