feat(M2): FR-E6 链接气泡+图片入库——LinkBubbleMenu(编辑/移除href);ImageView 节点视图(相对路径转 vaultasset:// 协议展示);assetPickImage/assetWrite IPC + .attachments 入库;剪贴板粘贴图片入库;工具栏 链接/图片/网图 按钮;CSP 放行 vaultasset:;editor-link-image E2E 3 项

This commit is contained in:
hunter-dev
2026-08-26 13:32:13 +08:00
parent 4beebbc8d0
commit 2f4bcfd1e4
11 changed files with 295 additions and 5 deletions
+22 -2
View File
@@ -1,13 +1,19 @@
// main 进程入口:创建窗口 + 初始化库 + 注册 IPC + 迁移检测
import { app, BrowserWindow, ipcMain } from 'electron';
import { app, BrowserWindow, ipcMain, protocol, net } from 'electron';
import nodePath from 'node:path';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { VaultService } from './services/vault-service.js';
import { registerIpc } from './ipc/handlers.js';
import { MigrationService } from './services/migration-service.js';
import { IPC } from '@hunter/shared';
const __dirname = nodePath.dirname(fileURLToPath(import.meta.url));
// FR-E6:注册 vaultasset:// 协议——渲染层用相对路径展示库内本地图片(须在 app ready 前注册)
protocol.registerSchemesAsPrivileged([
{ scheme: 'vaultasset', privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } },
]);
let win: BrowserWindow | null = null;
function createWindow() {
@@ -34,6 +40,20 @@ function createWindow() {
app.whenReady().then(async () => {
const vault = new VaultService();
await vault.init();
// FR-E6:vaultasset:// 协议处理器——安全提供库内文件给渲染层(限定在库根内)
protocol.handle('vaultasset', async (req) => {
const root = vault.fileService?.getRoot();
if (!root) return new Response('no vault', { status: 503 });
const rel = decodeURIComponent(new URL(req.url).pathname).replace(/^\/+/, '');
const abs = nodePath.resolve(root, rel);
const relFromRoot = nodePath.relative(root, abs);
if (relFromRoot.startsWith('..') || nodePath.isAbsolute(relFromRoot)) return new Response('forbidden', { status: 403 });
try {
return await net.fetch(pathToFileURL(abs).toString());
} catch {
return new Response('not found', { status: 404 });
}
});
// 旧版数据迁移检测(FR-D1/D2/D3):幂等,无旧数据则跳过
const migrator = new MigrationService(app.getPath('userData'));
const oldDataDir = nodePath.join(app.getPath('userData'), 'legacy-data');
+5
View File
@@ -39,6 +39,11 @@ export function registerIpc(vault: VaultService) {
return fs2().importFiles(sources, p.destDir);
});
// FR-E6:图片入库
ipcMain.handle(IPC.assetPickImage, async () => vault.pickImage());
ipcMain.handle(IPC.assetWrite, async (_e, p: { name: string; dataBase64: string }) =>
fs2().writeAsset(p.name, p.dataBase64));
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 }));
@@ -119,6 +119,24 @@ export class FileService {
return destRel;
}
// FR-E6:导入本地图片到 .attachments,返回库内相对路径(POSIX)
async importAsset(srcAbs: string): Promise<string> {
await fs.mkdir(nodePath.join(this.root, ATTACHMENTS_DIR), { recursive: true });
const finalName = await dedupeName(this.root, ATTACHMENTS_DIR, sanitizeName(nodePath.basename(srcAbs)));
const destRel = joinRel(ATTACHMENTS_DIR, finalName);
await fs.copyFile(srcAbs, this.abs(destRel));
return destRel;
}
// FR-E6:写入图片字节(base64)到 .attachments,返回库内相对路径(剪贴板粘贴入库)
async writeAsset(name: string, dataBase64: string): Promise<string> {
await fs.mkdir(nodePath.join(this.root, ATTACHMENTS_DIR), { recursive: true });
const finalName = await dedupeName(this.root, ATTACHMENTS_DIR, sanitizeName(name));
const destRel = joinRel(ATTACHMENTS_DIR, finalName);
await fs.writeFile(this.abs(destRel), Buffer.from(dataBase64, 'base64'));
return destRel;
}
async readDoc(path: string): Promise<ReadDocResult> {
const abs = this.abs(path);
const raw = await fs.readFile(abs, 'utf8');
@@ -46,6 +46,17 @@ export class VaultService {
return result.filePaths[0];
}
// FR-E6:选择本地图片并导入 .attachments,返回库内相对路径(null=取消)
async pickImage(): Promise<string | null> {
if (!this.fileService) throw new Error('vault not initialized');
const r = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico'] }],
});
if (r.canceled || r.filePaths.length === 0) return null;
return this.fileService.importAsset(r.filePaths[0]);
}
async switch(root: string): Promise<VaultMeta> {
// 校验是存在的目录
const stat = await fs.stat(root).catch(() => null);
@@ -1,6 +1,9 @@
import type { Editor } from '@tiptap/react';
import { useStore } from '../../store';
import { useEditorRef } from './editor-context';
import { useDialog } from '../dialog/dialog-store';
import { invoke } from '../../ipc-client';
import { IPC } from '@hunter/shared';
const btn: React.CSSProperties = { minWidth: 30, height: 28, border: '1px solid #444', background: 'transparent', color: '#ccc', cursor: 'pointer', borderRadius: 4, fontSize: 13 };
@@ -12,6 +15,25 @@ export function EditorToolbar() {
const editor = useEditorRef();
const d = !editor;
const cmd = (fn: (e: Editor) => void) => () => { if (editor) { fn(editor); editor.commands.focus(); } };
// FR-E6:链接 / 图片 插入(异步:对话框/IPC)
const link = async () => {
if (!editor) return;
const url = await useDialog.getState().showInput('插入链接', 'https://example.com');
if (!url) { editor.commands.focus(); return; }
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
};
const pickImage = async () => {
if (!editor) return;
const rel = await invoke(IPC.assetPickImage, undefined);
if (!rel) { editor.commands.focus(); return; }
editor.chain().focus().setImage({ src: rel }).run();
};
const imageUrl = async () => {
if (!editor) return;
const url = await useDialog.getState().showInput('插入网络图片', '粘贴图片地址 https://...');
if (!url) { editor.commands.focus(); return; }
editor.chain().focus().setImage({ src: url }).run();
};
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>
@@ -32,6 +54,10 @@ export function EditorToolbar() {
<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>
<span style={{ width: 1, background: '#333', height: 20, margin: '0 4px' }} />
<T title="链接" onClick={() => { void link(); }} disabled={d}>链接</T>
<T title="插入图片(本地选图入库)" onClick={() => { void pickImage(); }} disabled={d}>图片</T>
<T title="网络图片(手输网址)" onClick={() => { void imageUrl(); }} disabled={d}>网图</T>
</div>
);
}
@@ -0,0 +1,19 @@
import { NodeViewWrapper, type NodeViewProps } from '@tiptap/react';
// 相对路径(库内) -> vaultasset:// 可加载 URL;远程/数据 URL 原样透传
function toDisplaySrc(src: string): string {
if (!src) return '';
if (/^(https?:|file:|data:|blob:|vaultasset:)/i.test(src)) return src;
return 'vaultasset://vault/' + encodeURI(src);
}
// FR-E6:图片节点视图——相对路径自动转 vaultasset:// 展示,存储保持相对路径可移植
export function ImageView({ node, selected }: NodeViewProps) {
const src = (node.attrs.src as string) || '';
const display = toDisplaySrc(src);
return (
<NodeViewWrapper as="div" className="img-nodeview" style={{ textAlign: 'center', margin: '0.6em 0', outline: selected ? '2px solid #4a6a8a' : 'none' }}>
<img src={display} alt={(node.attrs.alt as string) || ''} title={(node.attrs.title as string) || ''} style={{ maxWidth: '100%', borderRadius: 4 }} draggable={false} />
</NodeViewWrapper>
);
}
@@ -0,0 +1,28 @@
import { useState, useEffect } from 'react';
import { BubbleMenu } from '@tiptap/react/menus';
import type { Editor } from '@tiptap/react';
// FR-E6:链接气泡——选中/光标处于链接时浮现,可编辑 href 或移除链接
export function LinkBubbleMenu({ editor }: { editor: Editor }) {
const [href, setHref] = useState('');
useEffect(() => {
const update = () => {
if (editor.isActive('link')) setHref((editor.getAttributes('link').href as string) || '');
};
editor.on('selectionUpdate', update);
editor.on('transaction', update);
return () => { editor.off('selectionUpdate', update); editor.off('transaction', update); };
}, [editor]);
const apply = () => { const u = href.trim(); if (u) editor.chain().focus().extendMarkRange('link').setLink({ href: u }).run(); };
const remove = () => editor.chain().focus().extendMarkRange('link').unsetLink().run();
return (
<BubbleMenu editor={editor} shouldShow={({ editor }) => editor.isActive('link')} className="link-bubble"
style={{ display: 'flex', gap: 4, alignItems: 'center', background: '#1e1e2a', border: '1px solid #3a3a4a', borderRadius: 6, padding: 4, boxShadow: '0 4px 16px rgba(0,0,0,0.4)' }}>
<input value={href} onChange={e => setHref(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); apply(); } }}
placeholder="链接地址" style={{ width: 180, background: '#15151f', border: '1px solid #3a3a4a', color: '#ddd', borderRadius: 4, padding: '3px 8px', fontSize: 13, outline: 'none' }} />
<button onClick={apply} style={btn}>更新</button>
<button onClick={remove} style={btn}>移除</button>
</BubbleMenu>
);
}
const btn: React.CSSProperties = { padding: '3px 10px', border: '1px solid #3a3a4a', background: 'transparent', color: '#ccc', borderRadius: 4, cursor: 'pointer', fontSize: 12 };
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react';
import { useEditor, EditorContent, ReactNodeViewRenderer } from '@tiptap/react';
import { useEditor, EditorContent, ReactNodeViewRenderer, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
@@ -13,13 +13,17 @@ import Image from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder';
import { Markdown } from 'tiptap-markdown';
import { createLowlight, common } from 'lowlight';
import { IPC } from '@hunter/shared';
import { useStore } from '../../store';
import { invoke } from '../../ipc-client';
import { EditorProvider } from './editor-context';
import { EditorToolbar } from './EditorToolbar';
import { FindReplace } from './FindReplace';
import { SlashMenu } from './SlashMenu';
import { BlockOps } from './BlockOps';
import { CodeBlockView } from './CodeBlockView';
import { ImageView } from './ImageView';
import { LinkBubbleMenu } from './LinkBubbleMenu';
const lowlight = createLowlight(common);
@@ -28,9 +32,15 @@ const CodeBlock = CodeBlockLowlight.extend({
addNodeView() { return ReactNodeViewRenderer(CodeBlockView); },
});
// FR-E6:图片自定义节点视图——相对路径转 vaultasset:// 展示,存储保持相对路径可移植
const Img = Image.extend({
addNodeView() { return ReactNodeViewRenderer(ImageView); },
});
export function TiptapEditor() {
const { currentPath, body, setBody } = useStore();
const skipUpdate = useRef(false);
const editorRef = useRef<Editor | null>(null);
// editor 实例稳定,不随文档切换重建(避免销毁/重建过渡期 commands 为 null)
const editor = useEditor({
@@ -41,18 +51,48 @@ export function TiptapEditor() {
TaskItem.configure({ nested: true }),
Table.configure({ resizable: true }), TableRow, TableCell, TableHeader,
Link.configure({ openOnClick: false }),
Image,
Img,
Placeholder.configure({ placeholder: '开始写作…' }),
Markdown.configure({ html: false, breaks: false, linkify: true, transformPastedText: true, transformCopiedText: true }),
BlockOps,
],
content: body,
editorProps: {
// FR-E6:剪贴板粘贴图片 -> 入库 .attachments -> 插入相对路径
handlePaste: (_view, event) => {
const ev = event as ClipboardEvent;
const items = ev.clipboardData?.items;
if (!items) return false;
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.kind === 'file' && it.type.startsWith('image/')) {
const file = it.getAsFile();
if (file) {
const reader = new FileReader();
reader.onload = async () => {
const base64 = (reader.result as string).split(',')[1] ?? '';
const ext = (file.type.split('/')[1] || 'png').replace('jpeg', 'jpg').replace('svg+xml', 'svg');
const name = `paste-${Date.now()}.${ext}`;
try {
const rel = await invoke(IPC.assetWrite, { name, dataBase64: base64 });
editorRef.current?.chain().focus().setImage({ src: rel }).run();
} catch { /* 入库失败忽略 */ }
};
reader.readAsDataURL(file);
return true;
}
}
}
return false;
},
},
onUpdate: ({ editor }) => { if (!skipUpdate.current) setBody((editor.storage as Record<string, any>).markdown.getMarkdown()); },
});
// 切换文档时只同步内容,不重建 editor
useEffect(() => {
if (!editor) return;
editorRef.current = editor;
skipUpdate.current = true;
editor.commands.setContent(body || '', { emitUpdate: false });
skipUpdate.current = false;
@@ -71,6 +111,7 @@ export function TiptapEditor() {
<EditorToolbar />
<FindReplace />
<SlashMenu />
{editor && <LinkBubbleMenu editor={editor} />}
<div className="tiptap-wrap" style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<EditorContent editor={editor} style={{ flex: 1, overflow: 'auto' }} />
</div>
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:5173 http://127.0.0.1:5173; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self' http://localhost:5173 ws://localhost:5173;" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:5173 http://127.0.0.1:5173; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: vaultasset:; connect-src 'self' http://localhost:5173 ws://localhost:5173 vaultasset:;" />
<title>狩夜人笔记</title>
</head>
<body>
@@ -0,0 +1,118 @@
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');
const PNG_B64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
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');
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 newDoc(a: { page: any; invoke: any }, title: string) {
await a.page.getByRole('button', { name: /新建文档/ }).click();
await a.page.getByPlaceholder(/输入文档标题/).fill(title);
await a.page.getByPlaceholder(/输入文档标题/).press('Enter');
await expect(a.page.getByTestId('tabbar').getByText(title, { exact: true })).toBeVisible({ timeout: 10000 });
}
test('FR-E6:链接气泡编辑(添加+移除)', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-e6-link-'));
const userData = nodePath.join(tmp, 'userdata');
const vault = nodePath.join(tmp, 'vault');
await fs.mkdir(userData, { recursive: true });
await fs.mkdir(vault, { recursive: true });
const a = await launch(userData);
await a.invoke('vault:switch', { root: vault });
await newDoc(a, '链接测试');
const editor = a.page.locator('.ProseMirror');
await editor.click();
await a.page.keyboard.type('点这里');
await a.page.keyboard.press('Control+A');
await a.page.locator('button[title="链接"]').click();
const dlg = a.page.getByPlaceholder(/example\.com/);
await dlg.fill('https://example.com');
await dlg.press('Enter');
let html: string = await a.page.evaluate(() => document.querySelector('.ProseMirror')?.innerHTML ?? '');
expect(html).toContain('href="https://example.com"');
await a.page.locator('.ProseMirror a').click();
await expect(a.page.locator('.link-bubble')).toBeVisible({ timeout: 5000 });
await expect(a.page.locator('.link-bubble input')).toHaveValue('https://example.com');
await a.page.locator('.link-bubble').getByText('移除').click();
html = await a.page.evaluate(() => document.querySelector('.ProseMirror')?.innerHTML ?? '');
expect(html).not.toContain('<a');
expect(html).not.toContain('href');
await a.app.close();
});
test('FR-E6:网络图片(手输网址)', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-e6-url-'));
const userData = nodePath.join(tmp, 'userdata');
const vault = nodePath.join(tmp, 'vault');
await fs.mkdir(userData, { recursive: true });
await fs.mkdir(vault, { recursive: true });
const a = await launch(userData);
await a.invoke('vault:switch', { root: vault });
await newDoc(a, '网图测试');
await a.page.locator('.ProseMirror').click();
await a.page.locator('button[title="网络图片(手输网址)"]').click();
const dlg = a.page.getByPlaceholder(/粘贴图片地址/);
await dlg.fill('https://cdn.example.com/x.png');
await dlg.press('Enter');
await expect(a.page.locator('.ProseMirror img')).toHaveCount(1, { timeout: 10000 });
const src = await a.page.locator('.ProseMirror img').getAttribute('src');
expect(src).toBe('https://cdn.example.com/x.png');
await a.app.close();
});
test('FR-E6:本地图片入库+展示(vaultasset 协议)', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-e6-local-'));
const userData = nodePath.join(tmp, 'userdata');
const vault = nodePath.join(tmp, 'vault');
await fs.mkdir(userData, { recursive: true });
await fs.mkdir(vault, { recursive: true });
const a = await launch(userData);
await a.invoke('vault:switch', { root: vault });
const rel: string = await a.invoke('asset:write', { name: 't.png', dataBase64: PNG_B64 });
expect(rel.startsWith('.attachments/')).toBeTruthy();
// 磁盘文件确实写入
const disk = await fs.readFile(nodePath.join(vault, rel));
expect(disk.length).toBeGreaterThan(0);
await newDoc(a, 'doca');
await newDoc(a, 'docb');
await a.invoke('doc:write', { path: 'doca.md', meta: { title: 'doca' }, body: '![](' + rel + ')' });
await a.page.getByTestId('filetree').getByText('doca', { exact: true }).click();
await expect(a.page.locator('.ProseMirror img')).toHaveCount(1, { timeout: 10000 });
const src = await a.page.locator('.ProseMirror img').getAttribute('src');
expect(src?.startsWith('vaultasset:')).toBeTruthy();
await expect.poll(
() => a.page.locator('.ProseMirror img').evaluate((el: HTMLImageElement) => el.naturalWidth),
{ timeout: 8000 },
).toBeGreaterThan(0);
await a.app.close();
});