diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 269ec0b..85da16b 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -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'); diff --git a/apps/desktop/src/main/ipc/handlers.ts b/apps/desktop/src/main/ipc/handlers.ts index 8f8037e..c8bb187 100644 --- a/apps/desktop/src/main/ipc/handlers.ts +++ b/apps/desktop/src/main/ipc/handlers.ts @@ -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 })); diff --git a/apps/desktop/src/main/services/file-service.ts b/apps/desktop/src/main/services/file-service.ts index 0257bf3..6b5a2b3 100644 --- a/apps/desktop/src/main/services/file-service.ts +++ b/apps/desktop/src/main/services/file-service.ts @@ -119,6 +119,24 @@ export class FileService { return destRel; } + // FR-E6:导入本地图片到 .attachments,返回库内相对路径(POSIX) + async importAsset(srcAbs: string): Promise { + 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 { + 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 { const abs = this.abs(path); const raw = await fs.readFile(abs, 'utf8'); diff --git a/apps/desktop/src/main/services/vault-service.ts b/apps/desktop/src/main/services/vault-service.ts index dc1aa0a..d75225d 100644 --- a/apps/desktop/src/main/services/vault-service.ts +++ b/apps/desktop/src/main/services/vault-service.ts @@ -46,6 +46,17 @@ export class VaultService { return result.filePaths[0]; } + // FR-E6:选择本地图片并导入 .attachments,返回库内相对路径(null=取消) + async pickImage(): Promise { + 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 { // 校验是存在的目录 const stat = await fs.stat(root).catch(() => null); diff --git a/apps/desktop/src/renderer/features/editor/EditorToolbar.tsx b/apps/desktop/src/renderer/features/editor/EditorToolbar.tsx index b942567..51d8306 100644 --- a/apps/desktop/src/renderer/features/editor/EditorToolbar.tsx +++ b/apps/desktop/src/renderer/features/editor/EditorToolbar.tsx @@ -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 (
e.chain().focus().undo().run())} disabled={d}>↶ @@ -32,6 +54,10 @@ export function EditorToolbar() { e.chain().focus().toggleOrderedList().run())} disabled={d}>1. e.chain().focus().toggleCodeBlock().run())} disabled={d}>{''} e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run())} disabled={d}>▦ + + { void link(); }} disabled={d}>链接 + { void pickImage(); }} disabled={d}>图片 + { void imageUrl(); }} disabled={d}>网图
); } diff --git a/apps/desktop/src/renderer/features/editor/ImageView.tsx b/apps/desktop/src/renderer/features/editor/ImageView.tsx new file mode 100644 index 0000000..a8634dd --- /dev/null +++ b/apps/desktop/src/renderer/features/editor/ImageView.tsx @@ -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 ( + + {(node.attrs.alt + + ); +} diff --git a/apps/desktop/src/renderer/features/editor/LinkBubbleMenu.tsx b/apps/desktop/src/renderer/features/editor/LinkBubbleMenu.tsx new file mode 100644 index 0000000..4ec265c --- /dev/null +++ b/apps/desktop/src/renderer/features/editor/LinkBubbleMenu.tsx @@ -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 ( + 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)' }}> + 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' }} /> + + + + ); +} +const btn: React.CSSProperties = { padding: '3px 10px', border: '1px solid #3a3a4a', background: 'transparent', color: '#ccc', borderRadius: 4, cursor: 'pointer', fontSize: 12 }; diff --git a/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx b/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx index fde0886..d6ef277 100644 --- a/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx +++ b/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx @@ -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(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).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() { + {editor && }
diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index 0b91e42..8260060 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -3,7 +3,7 @@ - + 狩夜人笔记 diff --git a/apps/desktop/tests/e2e/editor-link-image.spec.ts b/apps/desktop/tests/e2e/editor-link-image.spec.ts new file mode 100644 index 0000000..5f6caf9 --- /dev/null +++ b/apps/desktop/tests/e2e/editor-link-image.spec.ts @@ -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(' { + 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(); +}); diff --git a/packages/shared/src/types/ipc.ts b/packages/shared/src/types/ipc.ts index c977210..266ccc8 100644 --- a/packages/shared/src/types/ipc.ts +++ b/packages/shared/src/types/ipc.ts @@ -17,6 +17,8 @@ export const IPC = { fileMove: 'file:move', fileDelete: 'file:delete', fileImport: 'file:import', + assetPickImage: 'asset:pick-image', + assetWrite: 'asset:write', docRead: 'doc:read', docWrite: 'doc:write', docReveal: 'doc:reveal', @@ -49,6 +51,8 @@ export interface IpcMap { [IPC.fileMove]: { args: { src: string; destDir: string }; result: { path: string } }; [IPC.fileDelete]: { args: { path: string; recursive: boolean }; result: void }; [IPC.fileImport]: { args: { sources: string[]; destDir: string }; result: { ok: string[]; failed: { path: string; reason: string }[] } }; + [IPC.assetPickImage]: { args: void; result: string | null }; + [IPC.assetWrite]: { args: { name: string; dataBase64: string }; result: string }; [IPC.docRead]: { args: { path: string }; result: ReadDocResult }; [IPC.docWrite]: { args: WriteDocPayload; result: void }; [IPC.docReveal]: { args: { path: string }; result: void };