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

405 lines
17 KiB
TypeScript
Raw Normal View History

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,
} from 'lucide-react'
export interface PersonItem {
2026-03-14 14:37:17 +08:00
id: string // token文章 @ 时存此值,小程序用此兑换真实密钥
personId?: string // 管理端编辑/删除用
name: string
label?: string
2026-03-14 14:37:17 +08:00
ckbApiKey?: string // 存客宝真实密钥,管理端可见,不对外暴露
ckbPlanId?: number
// 存客宝创建计划用(与 Cunkebao 好友申请设置一致)
remarkType?: string
remarkFormat?: string
addFriendInterval?: number
startTime?: string
endTime?: string
deviceGroups?: string
}
export interface LinkTagItem {
id: string
label: string
url: string
type: 'url' | 'miniprogram' | 'ckb'
appId?: string
pagePath?: string
}
export interface RichEditorRef {
getHTML: () => string
getMarkdown: () => string
}
interface RichEditorProps {
content: string
onChange: (html: string) => void
onImageUpload?: (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
*/
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
const MentionSuggestion = (persons: PersonItem[]): any => ({
items: ({ query }: { query: string }) =>
persons.filter(p => p.name.toLowerCase().includes(query.toLowerCase()) || p.id.includes(query)).slice(0, 8),
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">@${item.name}</span>
<span class="mention-id">${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 },
}
},
})
const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
content,
onChange,
onImageUpload,
persons = [],
linkTags = [],
placeholder = '开始编辑内容...',
className,
}, ref) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const [linkUrl, setLinkUrl] = useState('')
const [showLinkInput, setShowLinkInput] = useState(false)
const initialContent = useRef(markdownToHtml(content))
const editor = useEditor({
extensions: [
StarterKit.configure({
link: { openOnClick: false, HTMLAttributes: { class: 'rich-link' } },
}),
Image.configure({ inline: true, allowBase64: true }),
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' },
},
})
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 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 insertLinkTag = useCallback((tag: LinkTagItem) => {
if (!editor) return
// 通过自定义扩展节点插入,确保 data-* 属性不被 TipTap schema 丢弃
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 || '') : '',
},
}).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" />
<button onClick={() => fileInputRef.current?.click()} type="button"><ImageIcon className="w-4 h-4" /></button>
<button onClick={() => setShowLinkInput(!showLinkInput)} className={editor.isActive('link') ? 'is-active' : ''} type="button"><LinkIcon className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()} type="button"><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