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,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>
|
||||
|
||||
Reference in New Issue
Block a user