Files
soul-yongping/soul-admin/src/components/RichEditor.tsx

682 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEditor, EditorContent, type Editor, Node, mergeAttributes } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Mention from '@tiptap/extension-mention'
import Placeholder from '@tiptap/extension-placeholder'
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'
import { useCallback, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react'
import {
Bold, Italic, Strikethrough, Code, List, ListOrdered, Quote,
Heading1, Heading2, Heading3, Image as ImageIcon, Link as LinkIcon,
Table as TableIcon, Undo, Redo, Minus, Video, Paperclip, AtSign,
} from 'lucide-react'
export interface PersonItem {
id: string // token文章 @ 时存此值,小程序用此兑换真实密钥
personId?: string // 管理端编辑/删除用
name: string
/** vip_sync超级个体开通自动同步共用后台「统一获客计划」 */
personSource?: string
/** 绑定的会员用户 idusers.id */
userId?: string
label?: string
ckbApiKey?: string // 存客宝真实密钥,管理端可见,不对外暴露
ckbPlanId?: number
// 存客宝创建计划用(与 Cunkebao 好友申请设置一致)
remarkType?: string
remarkFormat?: string
addFriendInterval?: number
startTime?: string
endTime?: string
deviceGroups?: string
isPinned?: boolean
}
export interface LinkTagItem {
id: string
label: string
aliases?: string
url: string
type: 'url' | 'miniprogram' | 'ckb' | 'wxlink'
appId?: string
pagePath?: string
/** 管理端列表用:库内是否已存目标小程序 AppSecret接口不下发明文 */
hasAppSecret?: boolean
}
/** 插入附件 HTML 时转义,防 XSS */
function escapeHtmlRich(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function escapeAttrRich(url: string): string {
return url.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
}
export interface RichEditorRef {
getHTML: () => string
getMarkdown: () => string
}
interface RichEditorProps {
content: string
onChange: (html: string) => void
onImageUpload?: (file: File) => Promise<string>
/** 视频、附件等非图片上传(同 /api/upload不传则尝试用 onImageUpload */
onMediaUpload?: (file: File) => Promise<string>
persons?: PersonItem[]
linkTags?: LinkTagItem[]
placeholder?: string
className?: string
}
function htmlToMarkdown(html: string): string {
if (!html) return ''
let md = html
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n')
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n')
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n')
md = md.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**')
md = md.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**')
md = md.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*')
md = md.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*')
md = md.replace(/<s[^>]*>(.*?)<\/s>/gi, '~~$1~~')
md = md.replace(/<del[^>]*>(.*?)<\/del>/gi, '~~$1~~')
md = md.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`')
md = md.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gi, '> $1\n\n')
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*>/gi, '![$2]($1)')
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*>/gi, '![]($1)')
md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)')
md = md.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n')
md = md.replace(/<\/?[uo]l[^>]*>/gi, '\n')
md = md.replace(/<br\s*\/?>/gi, '\n')
md = md.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n')
md = md.replace(/<hr\s*\/?>/gi, '---\n\n')
md = md.replace(/<span[^>]*data-type="mention"[^>]*data-id="([^"]*)"[^>]*>@([^<]*)<\/span>/gi, '@$2')
md = md.replace(/<span[^>]*data-type="linkTag"[^>]*data-url="([^"]*)"[^>]*>#([^<]*)<\/span>/gi, '#[$2]($1)')
md = md.replace(/<[^>]+>/g, '')
md = md.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
md = md.replace(/\n{3,}/g, '\n\n')
return md.trim()
}
function markdownToHtml(md: string): string {
if (!md) return ''
if (md.startsWith('<') && md.includes('</')) return md
let html = md
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>')
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>')
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>')
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
html = html.replace(/~~(.+?)~~/g, '<s>$1</s>')
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" />')
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
html = html.replace(/^> (.+)$/gm, '<blockquote><p>$1</p></blockquote>')
html = html.replace(/^---$/gm, '<hr />')
html = html.replace(/^- (.+)$/gm, '<li>$1</li>')
const lines = html.split('\n')
const result: string[] = []
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
if (/^<(?:h[1-6]|blockquote|hr|li|ul|ol|table|img)/.test(trimmed)) {
result.push(trimmed)
} else {
result.push(`<p>${trimmed}</p>`)
}
}
return result.join('')
}
/**
* LinkTagExtension — 自定义 TipTap 内联节点,保留所有 data-* 属性
* 解决insertContent(html) 会经过 TipTap schema 导致自定义属性被丢弃的问题
*/
/** 内嵌视频块HTML: div.rich-video-wrap > video便于编辑区限高预览 */
const VideoEmbedExtension = Node.create({
name: 'videoEmbed',
group: 'block',
atom: true,
draggable: true,
addAttributes() {
return {
src: { default: null },
}
},
parseHTML() {
return [
{
tag: 'div.rich-video-wrap',
getAttrs: (el: HTMLElement) => {
const v = el.querySelector('video')
const src = v?.getAttribute('src')
return src ? { src } : false
},
},
{
tag: 'video[src]',
getAttrs: (el: HTMLElement) => ({ src: el.getAttribute('src') }),
},
]
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
renderHTML({ node }: { node: { attrs: { src?: string | null } } }) {
const src = node.attrs.src || ''
return [
'div',
{ class: 'rich-video-wrap' },
['video', { src, controls: true, preload: 'metadata' }],
['div', { class: 'rich-video-caption' }, '视频(预览已缩小,保存后 C 端全宽播放)'],
]
},
})
const LinkTagExtension = Node.create({
name: 'linkTag',
group: 'inline',
inline: true,
selectable: true,
atom: true,
addAttributes() {
return {
label: { default: '' },
url: { default: '' },
tagType: { default: 'url', parseHTML: (el: HTMLElement) => el.getAttribute('data-tag-type') || 'url' },
tagId: { default: '', parseHTML: (el: HTMLElement) => el.getAttribute('data-tag-id') || '' },
pagePath: { default: '', parseHTML: (el: HTMLElement) => el.getAttribute('data-page-path') || '' },
appId: { default: '', parseHTML: (el: HTMLElement) => el.getAttribute('data-app-id') || '' },
mpKey: { default: '', parseHTML: (el: HTMLElement) => el.getAttribute('data-mp-key') || '' },
}
},
parseHTML() {
return [{ tag: 'span[data-type="linkTag"]', getAttrs: (el: HTMLElement) => ({
label: el.textContent?.replace(/^#/, '').trim() || '',
url: el.getAttribute('data-url') || '',
tagType: el.getAttribute('data-tag-type') || 'url',
tagId: el.getAttribute('data-tag-id') || '',
pagePath: el.getAttribute('data-page-path')|| '',
appId: el.getAttribute('data-app-id') || '',
mpKey: el.getAttribute('data-mp-key') || '',
}) }]
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
renderHTML({ node, HTMLAttributes }: { node: any; HTMLAttributes: Record<string, any> }) {
return ['span', mergeAttributes(HTMLAttributes, {
'data-type': 'linkTag',
'data-url': node.attrs.url,
'data-tag-type': node.attrs.tagType,
'data-tag-id': node.attrs.tagId,
'data-page-path': node.attrs.pagePath,
'data-app-id': node.attrs.appId || '',
'data-mp-key': node.attrs.mpKey || node.attrs.appId || '',
class: 'link-tag-node',
}), `#${node.attrs.label}`]
},
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function escapeHtml(s: string): string {
const div = document.createElement('div')
div.textContent = s
return div.innerHTML
}
const MentionSuggestion = (persons: PersonItem[]): any => ({
items: ({ query }: { query: string }) => {
const q = query.trim().toLowerCase()
const list = !q
? persons
: persons.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.id.toLowerCase().includes(q) ||
(p.label && p.label.toLowerCase().includes(q)) ||
(p.userId && p.userId.toLowerCase().includes(q)),
)
return list.slice(0, 16)
},
render: () => {
let popup: HTMLDivElement | null = null
let selectedIndex = 0
let items: PersonItem[] = []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let command: ((p: { id: string; label: string }) => void) | null = null
const update = () => {
if (!popup) return
popup.innerHTML = items.map((item, i) =>
`<div class="mention-item ${i === selectedIndex ? 'is-selected' : ''}" data-index="${i}">
<span class="mention-name">@${escapeHtml(item.name)}</span>
<span class="mention-id">${escapeHtml(item.label || item.id)}</span>
</div>`
).join('')
popup.querySelectorAll('.mention-item').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.getAttribute('data-index') || '0')
if (command && items[idx]) command({ id: items[idx].id, label: items[idx].name })
})
})
}
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onStart: (props: any) => {
popup = document.createElement('div')
popup.className = 'mention-popup'
document.body.appendChild(popup)
items = props.items
command = props.command
selectedIndex = 0
update()
if (props.clientRect) {
const rect = props.clientRect()
if (rect) {
popup.style.top = `${rect.bottom + 4}px`
popup.style.left = `${rect.left}px`
}
}
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onUpdate: (props: any) => {
items = props.items
command = props.command
selectedIndex = 0
update()
if (props.clientRect && popup) {
const rect = props.clientRect()
if (rect) {
popup.style.top = `${rect.bottom + 4}px`
popup.style.left = `${rect.left}px`
}
}
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === 'ArrowUp') { selectedIndex = Math.max(0, selectedIndex - 1); update(); return true }
if (props.event.key === 'ArrowDown') { selectedIndex = Math.min(items.length - 1, selectedIndex + 1); update(); return true }
if (props.event.key === 'Enter') { if (command && items[selectedIndex]) command({ id: items[selectedIndex].id, label: items[selectedIndex].name }); return true }
if (props.event.key === 'Escape') { popup?.remove(); popup = null; return true }
return false
},
onExit: () => { popup?.remove(); popup = null },
}
},
})
/** 从剪贴板提取图片文件(粘贴截图、复制图片时) */
function getImageFilesFromClipboard(event: ClipboardEvent): File[] {
const files: File[] = []
const items = event.clipboardData?.items
if (!items) return files
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const file = items[i].getAsFile()
if (file) files.push(file)
}
}
return files
}
const BASE64_IMG_RE = /src=["'](data:image\/([^;"']+);base64,([A-Za-z0-9+/=]+))["']/gi
/** 将 base64 转为 File */
function base64ToFile(b64: string, mime: string): File {
const ext = { png: '.png', jpeg: '.jpg', jpg: '.jpg', gif: '.gif', webp: '.webp' }[mime.toLowerCase()] || '.png'
const bin = atob(b64)
const arr = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
return new File([new Blob([arr], { type: `image/${mime}` })], `image${ext}`, { type: `image/${mime}` })
}
/** 将 HTML 中的 base64 图片替换为上传后的 URL支持多个、去重同图只传一次 */
async function replaceBase64ImagesInHtml(
html: string,
onImageUpload: (file: File) => Promise<string>,
): Promise<string> {
const matches = [...html.matchAll(BASE64_IMG_RE)]
if (matches.length === 0) return html
const urlCache = new Map<string, string>() // full dataURL -> 上传后的 url同图只传一次
let result = html
for (const m of matches) {
const full = m[1]
const mime = m[2]
const b64 = m[3]
let url = urlCache.get(full)
if (!url) {
try {
const file = base64ToFile(b64, mime)
url = await onImageUpload(file)
urlCache.set(full, url)
} catch (e) {
console.error('base64 图片上传失败', e)
continue
}
}
// 替换该 base64 的所有出现(同一张图可能被引用多次)
result = result.split(`src="${full}"`).join(`src="${url}"`).split(`src='${full}'`).join(`src="${url}"`)
}
return result
}
const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
content,
onChange,
onImageUpload,
onMediaUpload,
persons = [],
linkTags = [],
placeholder = '开始编辑内容...',
className,
}, ref) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const videoInputRef = useRef<HTMLInputElement>(null)
const attachInputRef = useRef<HTMLInputElement>(null)
const editorRef = useRef<Editor | null>(null)
const [linkUrl, setLinkUrl] = useState('')
const [showLinkInput, setShowLinkInput] = useState(false)
const initialContent = useRef(markdownToHtml(content))
const handlePaste = useCallback(
(_view: unknown, event: ClipboardEvent) => {
const ed = editorRef.current
if (!ed || !onImageUpload) return false
// 1. 粘贴截图/复制图片:剪贴板直接有 image 文件
const imageFiles = getImageFilesFromClipboard(event)
if (imageFiles.length > 0) {
event.preventDefault()
;(async () => {
for (const file of imageFiles) {
try {
const url = await onImageUpload(file)
if (url) ed.chain().focus().setImage({ src: url }).run()
} catch (e) {
console.error('粘贴图片上传失败', e)
}
}
})()
return true
}
// 2. 粘贴 HTML含 base64 图片):复制编辑器内容再粘贴时,自动上传 base64 转为 URL
const html = event.clipboardData?.getData('text/html')
if (html && /data:image\/[^;"']+;base64,/i.test(html)) {
event.preventDefault()
const { from, to } = ed.state.selection
;(async () => {
try {
const processed = await replaceBase64ImagesInHtml(html, onImageUpload)
ed.chain().focus().insertContentAt({ from, to }, processed).run()
} catch (e) {
console.error('粘贴 HTML 内 base64 转换失败', e)
}
})()
return true
}
return false
},
[onImageUpload],
)
const editor = useEditor({
extensions: [
StarterKit.configure({
link: { openOnClick: false, HTMLAttributes: { class: 'rich-link' } },
}),
Image.configure({
inline: true,
allowBase64: true,
HTMLAttributes: { class: 'rich-editor-img-thumb' },
}),
VideoEmbedExtension,
Mention.configure({
HTMLAttributes: { class: 'mention-tag' },
suggestion: MentionSuggestion(persons),
}),
LinkTagExtension,
Placeholder.configure({ placeholder }),
Table.configure({ resizable: true }),
TableRow, TableCell, TableHeader,
],
content: initialContent.current,
onUpdate: ({ editor: ed }: { editor: Editor }) => {
onChange(ed.getHTML())
},
editorProps: {
attributes: { class: 'rich-editor-content' },
handlePaste,
},
})
useEffect(() => {
editorRef.current = editor ?? null
}, [editor])
useImperativeHandle(ref, () => ({
getHTML: () => editor?.getHTML() || '',
getMarkdown: () => htmlToMarkdown(editor?.getHTML() || ''),
}))
useEffect(() => {
if (editor && content !== editor.getHTML()) {
const html = markdownToHtml(content)
if (html !== editor.getHTML()) {
editor.commands.setContent(html)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [content])
const uploadMediaFile = useCallback(
async (file: File) => {
if (onMediaUpload) return onMediaUpload(file)
if (onImageUpload) return onImageUpload(file)
throw new Error('未配置上传')
},
[onImageUpload, onMediaUpload],
)
const handleImageUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file || !editor) return
if (onImageUpload) {
const url = await onImageUpload(file)
if (url) editor.chain().focus().setImage({ src: url }).run()
} else {
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result === 'string') editor.chain().focus().setImage({ src: reader.result }).run()
}
reader.readAsDataURL(file)
}
e.target.value = ''
}, [editor, onImageUpload])
const handleVideoUpload = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file || !editor) return
try {
const url = await uploadMediaFile(file)
if (url) {
editor.chain().focus().insertContent({ type: 'videoEmbed', attrs: { src: url } }).run()
}
} catch (err) {
console.error(err)
}
e.target.value = ''
},
[editor, uploadMediaFile],
)
const handleAttachmentUpload = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file || !editor) return
try {
const url = await uploadMediaFile(file)
if (!url) return
const name = file.name || '附件'
editor
.chain()
.focus()
.insertContent(
`<p class="rich-attachment-line"><span class="rich-attachment-badge">附件</span> <a class="rich-attachment-link" href="${escapeAttrRich(url)}" target="_blank" rel="noopener noreferrer">${escapeHtmlRich(name)}</a></p>`,
)
.run()
} catch (err) {
console.error(err)
}
e.target.value = ''
},
[editor, uploadMediaFile],
)
const insertMentionTrigger = useCallback(() => {
if (!editor) return
editor.chain().focus().insertContent('@').run()
}, [editor])
const insertLinkTag = useCallback((tag: LinkTagItem) => {
if (!editor) return
editor.chain().focus().insertContent([
{
type: 'linkTag',
attrs: {
label: tag.label,
url: tag.url || '',
tagType: tag.type || 'url',
tagId: tag.id || '',
pagePath: tag.pagePath || '',
appId: tag.appId || '',
mpKey: tag.type === 'miniprogram' ? (tag.appId || '') : '',
},
},
{ type: 'text', text: ' ' },
]).run()
}, [editor])
const addLink = useCallback(() => {
if (!editor || !linkUrl) return
editor.chain().focus().setLink({ href: linkUrl }).run()
setLinkUrl('')
setShowLinkInput(false)
}, [editor, linkUrl])
if (!editor) return null
return (
<div className={`rich-editor-wrapper ${className || ''}`}>
<div className="rich-editor-toolbar">
<div className="toolbar-group">
<button onClick={() => editor.chain().focus().toggleBold().run()} className={editor.isActive('bold') ? 'is-active' : ''} type="button"><Bold className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().toggleItalic().run()} className={editor.isActive('italic') ? 'is-active' : ''} type="button"><Italic className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().toggleStrike().run()} className={editor.isActive('strike') ? 'is-active' : ''} type="button"><Strikethrough className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().toggleCode().run()} className={editor.isActive('code') ? 'is-active' : ''} type="button"><Code className="w-4 h-4" /></button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''} type="button"><Heading1 className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''} type="button"><Heading2 className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} className={editor.isActive('heading', { level: 3 }) ? 'is-active' : ''} type="button"><Heading3 className="w-4 h-4" /></button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={() => editor.chain().focus().toggleBulletList().run()} className={editor.isActive('bulletList') ? 'is-active' : ''} type="button"><List className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().toggleOrderedList().run()} className={editor.isActive('orderedList') ? 'is-active' : ''} type="button"><ListOrdered className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().toggleBlockquote().run()} className={editor.isActive('blockquote') ? 'is-active' : ''} type="button"><Quote className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().setHorizontalRule().run()} type="button"><Minus className="w-4 h-4" /></button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleImageUpload} className="hidden" />
<input ref={videoInputRef} type="file" accept="video/*" onChange={handleVideoUpload} className="hidden" />
<input ref={attachInputRef} type="file" onChange={handleAttachmentUpload} className="hidden" />
<button onClick={() => fileInputRef.current?.click()} type="button" title="上传图片"><ImageIcon className="w-4 h-4" /></button>
<button
onClick={() => videoInputRef.current?.click()}
type="button"
title="上传视频"
disabled={!onMediaUpload && !onImageUpload}
>
<Video className="w-4 h-4" />
</button>
<button
onClick={() => attachInputRef.current?.click()}
type="button"
title="上传附件(生成下载链接)"
disabled={!onMediaUpload && !onImageUpload}
>
<Paperclip className="w-4 h-4" />
</button>
<button
onClick={insertMentionTrigger}
type="button"
title="插入 @ 并选择人物"
className={persons.length ? 'mention-trigger-btn' : ''}
disabled={persons.length === 0}
>
<AtSign className="w-4 h-4" />
</button>
<button onClick={() => setShowLinkInput(!showLinkInput)} className={editor.isActive('link') ? 'is-active' : ''} type="button" title="链接"><LinkIcon className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()} type="button" title="表格"><TableIcon className="w-4 h-4" /></button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} type="button"><Undo className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} type="button"><Redo className="w-4 h-4" /></button>
</div>
{linkTags.length > 0 && (
<>
<div className="toolbar-divider" />
<div className="toolbar-group">
<select
className="link-tag-select"
onChange={(e) => {
const tag = linkTags.find(t => t.id === e.target.value)
if (tag) insertLinkTag(tag)
e.target.value = ''
}}
defaultValue=""
>
<option value="" disabled># </option>
{linkTags.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
</div>
</>
)}
</div>
{showLinkInput && (
<div className="link-input-bar">
<input
type="url"
placeholder="输入链接地址..."
value={linkUrl}
onChange={e => setLinkUrl(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addLink()}
className="link-input"
/>
<button onClick={addLink} className="link-confirm" type="button"></button>
<button onClick={() => { editor.chain().focus().unsetLink().run(); setShowLinkInput(false) }} className="link-remove" type="button"></button>
</div>
)}
<EditorContent editor={editor} />
</div>
)
})
RichEditor.displayName = 'RichEditor'
export default RichEditor