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
@@ -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>