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 /** 绑定的会员用户 id(users.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, '>') .replace(/"/g, '"') } function escapeAttrRich(url: string): string { return url.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''') } export interface RichEditorRef { getHTML: () => string getMarkdown: () => string } interface RichEditorProps { content: string onChange: (html: string) => void onImageUpload?: (file: File) => Promise /** 视频、附件等非图片上传(同 /api/upload);不传则尝试用 onImageUpload */ onMediaUpload?: (file: File) => Promise persons?: PersonItem[] linkTags?: LinkTagItem[] placeholder?: string className?: string } function htmlToMarkdown(html: string): string { if (!html) return '' let md = html md = md.replace(/]*>(.*?)<\/h1>/gi, '# $1\n\n') md = md.replace(/]*>(.*?)<\/h2>/gi, '## $1\n\n') md = md.replace(/]*>(.*?)<\/h3>/gi, '### $1\n\n') md = md.replace(/]*>(.*?)<\/strong>/gi, '**$1**') md = md.replace(/]*>(.*?)<\/b>/gi, '**$1**') md = md.replace(/]*>(.*?)<\/em>/gi, '*$1*') md = md.replace(/]*>(.*?)<\/i>/gi, '*$1*') md = md.replace(/]*>(.*?)<\/s>/gi, '~~$1~~') md = md.replace(/]*>(.*?)<\/del>/gi, '~~$1~~') md = md.replace(/]*>(.*?)<\/code>/gi, '`$1`') md = md.replace(/]*>(.*?)<\/blockquote>/gi, '> $1\n\n') md = md.replace(/]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*>/gi, '![$2]($1)') md = md.replace(/]*src="([^"]*)"[^>]*>/gi, '![]($1)') md = md.replace(/]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)') md = md.replace(/]*>(.*?)<\/li>/gi, '- $1\n') md = md.replace(/<\/?[uo]l[^>]*>/gi, '\n') md = md.replace(//gi, '\n') md = md.replace(/]*>(.*?)<\/p>/gi, '$1\n\n') md = md.replace(//gi, '---\n\n') md = md.replace(/]*data-type="mention"[^>]*data-id="([^"]*)"[^>]*>@([^<]*)<\/span>/gi, '@$2') md = md.replace(/]*data-type="linkTag"[^>]*data-url="([^"]*)"[^>]*>#([^<]*)<\/span>/gi, '#[$2]($1)') md = md.replace(/<[^>]+>/g, '') md = md.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/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('$1') html = html.replace(/^## (.+)$/gm, '

$1

') html = html.replace(/^# (.+)$/gm, '

$1

') html = html.replace(/\*\*(.+?)\*\*/g, '$1') html = html.replace(/\*(.+?)\*/g, '$1') html = html.replace(/~~(.+?)~~/g, '$1') html = html.replace(/`([^`]+)`/g, '$1') html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1') html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') html = html.replace(/^> (.+)$/gm, '

$1

') html = html.replace(/^---$/gm, '
') html = html.replace(/^- (.+)$/gm, '
  • $1
  • ') 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(`

    ${trimmed}

    `) } } 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 }) { 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) => `
    @${escapeHtml(item.name)} ${escapeHtml(item.label || item.id)}
    ` ).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, ): Promise { const matches = [...html.matchAll(BASE64_IMG_RE)] if (matches.length === 0) return html const urlCache = new Map() // 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(({ content, onChange, onImageUpload, onMediaUpload, persons = [], linkTags = [], placeholder = '开始编辑内容...', className, }, ref) => { const fileInputRef = useRef(null) const videoInputRef = useRef(null) const attachInputRef = useRef(null) const editorRef = useRef(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) => { 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) => { 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) => { 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( `

    附件 ${escapeHtmlRich(name)}

    `, ) .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 (
    {linkTags.length > 0 && ( <>
    )}
    {showLinkInput && (
    setLinkUrl(e.target.value)} onKeyDown={e => e.key === 'Enter' && addLink()} className="link-input" />
    )}
    ) }) RichEditor.displayName = 'RichEditor' export default RichEditor