2026-03-10 18:06:10 +08:00
|
|
|
|
import { useEditor, EditorContent, type Editor, Node, mergeAttributes } from '@tiptap/react'
|
2026-03-10 11:04:34 +08:00
|
|
|
|
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,
|
2026-03-22 08:34:28 +08:00
|
|
|
|
Table as TableIcon, Undo, Redo, Minus, Video, Paperclip, AtSign,
|
2026-03-10 11:04:34 +08:00
|
|
|
|
} from 'lucide-react'
|
|
|
|
|
|
|
|
|
|
|
|
export interface PersonItem {
|
2026-03-14 14:37:17 +08:00
|
|
|
|
id: string // token,文章 @ 时存此值,小程序用此兑换真实密钥
|
|
|
|
|
|
personId?: string // 管理端编辑/删除用
|
2026-03-10 11:04:34 +08:00
|
|
|
|
name: string
|
2026-03-22 08:34:28 +08:00
|
|
|
|
/** vip_sync:超级个体开通自动同步,共用后台「统一获客计划」 */
|
|
|
|
|
|
personSource?: string
|
|
|
|
|
|
/** 绑定的会员用户 id(users.id) */
|
|
|
|
|
|
userId?: string
|
2026-03-10 11:04:34 +08:00
|
|
|
|
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
|
2026-03-19 16:20:46 +08:00
|
|
|
|
isPinned?: boolean
|
2026-03-10 11:04:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface LinkTagItem {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
label: string
|
2026-03-22 08:34:28 +08:00
|
|
|
|
aliases?: string
|
2026-03-10 11:04:34 +08:00
|
|
|
|
url: string
|
2026-03-24 12:29:46 +08:00
|
|
|
|
type: 'url' | 'miniprogram' | 'ckb' | 'wxlink'
|
2026-03-10 11:04:34 +08:00
|
|
|
|
appId?: string
|
|
|
|
|
|
pagePath?: string
|
2026-03-24 12:29:46 +08:00
|
|
|
|
/** 管理端列表用:库内是否已存目标小程序 AppSecret(接口不下发明文) */
|
|
|
|
|
|
hasAppSecret?: boolean
|
2026-03-10 11:04:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 08:34:28 +08:00
|
|
|
|
/** 插入附件 HTML 时转义,防 XSS */
|
|
|
|
|
|
function escapeHtmlRich(text: string): string {
|
|
|
|
|
|
return text
|
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function escapeAttrRich(url: string): string {
|
|
|
|
|
|
return url.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 11:04:34 +08:00
|
|
|
|
export interface RichEditorRef {
|
|
|
|
|
|
getHTML: () => string
|
|
|
|
|
|
getMarkdown: () => string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface RichEditorProps {
|
|
|
|
|
|
content: string
|
|
|
|
|
|
onChange: (html: string) => void
|
|
|
|
|
|
onImageUpload?: (file: File) => Promise<string>
|
2026-03-22 08:34:28 +08:00
|
|
|
|
/** 视频、附件等非图片上传(同 /api/upload);不传则尝试用 onImageUpload */
|
|
|
|
|
|
onMediaUpload?: (file: File) => Promise<string>
|
2026-03-10 11:04:34 +08:00
|
|
|
|
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, '')
|
|
|
|
|
|
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*>/gi, '')
|
|
|
|
|
|
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(/&/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('</')) 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('')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 18:06:10 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* LinkTagExtension — 自定义 TipTap 内联节点,保留所有 data-* 属性
|
|
|
|
|
|
* 解决:insertContent(html) 会经过 TipTap schema 导致自定义属性被丢弃的问题
|
|
|
|
|
|
*/
|
2026-03-22 08:34:28 +08:00
|
|
|
|
/** 内嵌视频块(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 端全宽播放)'],
|
|
|
|
|
|
]
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-10 18:06:10 +08:00
|
|
|
|
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') || '' },
|
2026-03-12 16:51:12 +08:00
|
|
|
|
appId: { default: '', parseHTML: (el: HTMLElement) => el.getAttribute('data-app-id') || '' },
|
|
|
|
|
|
mpKey: { default: '', parseHTML: (el: HTMLElement) => el.getAttribute('data-mp-key') || '' },
|
2026-03-10 18:06:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
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')|| '',
|
2026-03-12 16:51:12 +08:00
|
|
|
|
appId: el.getAttribute('data-app-id') || '',
|
|
|
|
|
|
mpKey: el.getAttribute('data-mp-key') || '',
|
2026-03-10 18:06:10 +08:00
|
|
|
|
}) }]
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// 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,
|
2026-03-12 16:51:12 +08:00
|
|
|
|
'data-app-id': node.attrs.appId || '',
|
|
|
|
|
|
'data-mp-key': node.attrs.mpKey || node.attrs.appId || '',
|
2026-03-10 18:06:10 +08:00
|
|
|
|
class: 'link-tag-node',
|
|
|
|
|
|
}), `#${node.attrs.label}`]
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-10 11:04:34 +08:00
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
2026-03-17 14:02:09 +08:00
|
|
|
|
function escapeHtml(s: string): string {
|
|
|
|
|
|
const div = document.createElement('div')
|
|
|
|
|
|
div.textContent = s
|
|
|
|
|
|
return div.innerHTML
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 11:04:34 +08:00
|
|
|
|
const MentionSuggestion = (persons: PersonItem[]): any => ({
|
2026-03-22 08:34:28 +08:00
|
|
|
|
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)
|
|
|
|
|
|
},
|
2026-03-10 11:04:34 +08:00
|
|
|
|
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}">
|
2026-03-17 14:02:09 +08:00
|
|
|
|
<span class="mention-name">@${escapeHtml(item.name)}</span>
|
|
|
|
|
|
<span class="mention-id">${escapeHtml(item.label || item.id)}</span>
|
2026-03-10 11:04:34 +08:00
|
|
|
|
</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 },
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-14 23:27:22 +08:00
|
|
|
|
/** 从剪贴板提取图片文件(粘贴截图、复制图片时) */
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 11:04:34 +08:00
|
|
|
|
const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
|
|
|
|
|
|
content,
|
|
|
|
|
|
onChange,
|
|
|
|
|
|
onImageUpload,
|
2026-03-22 08:34:28 +08:00
|
|
|
|
onMediaUpload,
|
2026-03-10 11:04:34 +08:00
|
|
|
|
persons = [],
|
|
|
|
|
|
linkTags = [],
|
|
|
|
|
|
placeholder = '开始编辑内容...',
|
|
|
|
|
|
className,
|
|
|
|
|
|
}, ref) => {
|
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
2026-03-22 08:34:28 +08:00
|
|
|
|
const videoInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
|
|
const attachInputRef = useRef<HTMLInputElement>(null)
|
2026-03-14 23:27:22 +08:00
|
|
|
|
const editorRef = useRef<Editor | null>(null)
|
2026-03-10 11:04:34 +08:00
|
|
|
|
const [linkUrl, setLinkUrl] = useState('')
|
|
|
|
|
|
const [showLinkInput, setShowLinkInput] = useState(false)
|
|
|
|
|
|
const initialContent = useRef(markdownToHtml(content))
|
|
|
|
|
|
|
2026-03-14 23:27:22 +08:00
|
|
|
|
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],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-10 11:04:34 +08:00
|
|
|
|
const editor = useEditor({
|
|
|
|
|
|
extensions: [
|
2026-03-14 16:23:01 +08:00
|
|
|
|
StarterKit.configure({
|
|
|
|
|
|
link: { openOnClick: false, HTMLAttributes: { class: 'rich-link' } },
|
|
|
|
|
|
}),
|
2026-03-22 08:34:28 +08:00
|
|
|
|
Image.configure({
|
|
|
|
|
|
inline: true,
|
|
|
|
|
|
allowBase64: true,
|
|
|
|
|
|
HTMLAttributes: { class: 'rich-editor-img-thumb' },
|
|
|
|
|
|
}),
|
|
|
|
|
|
VideoEmbedExtension,
|
2026-03-10 11:04:34 +08:00
|
|
|
|
Mention.configure({
|
|
|
|
|
|
HTMLAttributes: { class: 'mention-tag' },
|
|
|
|
|
|
suggestion: MentionSuggestion(persons),
|
|
|
|
|
|
}),
|
2026-03-10 18:06:10 +08:00
|
|
|
|
LinkTagExtension,
|
2026-03-10 11:04:34 +08:00
|
|
|
|
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' },
|
2026-03-14 23:27:22 +08:00
|
|
|
|
handlePaste,
|
2026-03-10 11:04:34 +08:00
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-03-14 23:27:22 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
editorRef.current = editor ?? null
|
|
|
|
|
|
}, [editor])
|
|
|
|
|
|
|
2026-03-10 11:04:34 +08:00
|
|
|
|
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])
|
|
|
|
|
|
|
2026-03-22 08:34:28 +08:00
|
|
|
|
const uploadMediaFile = useCallback(
|
|
|
|
|
|
async (file: File) => {
|
|
|
|
|
|
if (onMediaUpload) return onMediaUpload(file)
|
|
|
|
|
|
if (onImageUpload) return onImageUpload(file)
|
|
|
|
|
|
throw new Error('未配置上传')
|
|
|
|
|
|
},
|
|
|
|
|
|
[onImageUpload, onMediaUpload],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-10 11:04:34 +08:00
|
|
|
|
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])
|
|
|
|
|
|
|
2026-03-22 08:34:28 +08:00
|
|
|
|
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])
|
|
|
|
|
|
|
2026-03-10 11:04:34 +08:00
|
|
|
|
const insertLinkTag = useCallback((tag: LinkTagItem) => {
|
|
|
|
|
|
if (!editor) return
|
2026-03-22 08:34:28 +08:00
|
|
|
|
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 || '') : '',
|
|
|
|
|
|
},
|
2026-03-10 18:06:10 +08:00
|
|
|
|
},
|
2026-03-22 08:34:28 +08:00
|
|
|
|
{ type: 'text', text: ' ' },
|
|
|
|
|
|
]).run()
|
2026-03-10 11:04:34 +08:00
|
|
|
|
}, [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" />
|
2026-03-22 08:34:28 +08:00
|
|
|
|
<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>
|
2026-03-10 11:04:34 +08:00
|
|
|
|
</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
|