feat(M2): FR-E6 链接气泡+图片入库——LinkBubbleMenu(编辑/移除href);ImageView 节点视图(相对路径转 vaultasset:// 协议展示);assetPickImage/assetWrite IPC + .attachments 入库;剪贴板粘贴图片入库;工具栏 链接/图片/网图 按钮;CSP 放行 vaultasset:;editor-link-image E2E 3 项
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '' });
|
||||
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user