代码语言
知识点思维导图
13 个知识节点
参考资料
富文本编辑器(05) - 插件开发实战
读完后,你应能完成以下任务:
- 绘制“富文本编辑器(05) - 插件开发实战 / 高亮 Callout 组件”的关键对象与数据流,解释“执行 editor.commands.setCallout({ background: 'blue' }),确认文档中新增 type: "callout" 的节点。 -> 修改图标和背景色,再调用 editor.getJSON(),确认 icon、background 属性已经写入文档,而不只是 React 本地状态变化。 -> 用 editor.commands.setContent(savedJson) 回填 JSON,确认 Callout 内容、图标和背景色能够恢复。 -> 执行 pnpm exec tsc --noEmit,确保扩展后的 Commands 类型、NodeView 属性和调用参数一致。”,并用源码位置、日志或 Trace 标注证据。
- 为“富文本编辑器(05) - 插件开发实战 / 功能特性”设计正常与异常输入,验证“🎨 多色背景支持:支持 8 种不同的背景颜色”,输出首个偏差位置与回归测试结果。
- 实现“富文本编辑器(05) - 插件开发实战 / 技术亮点”的最小代码或配置,检验“点击背景:打开颜色选择器,支持 8 种背景色”,输出命令、结果与 Diff,并说明不适用边界。
一、高亮 Callout 组件
这是 Tiptap 中没有的一个功能,Notion 中是有的,但是我们可以通过自定义插件来实现。

1.1 功能特性
- 🎨 多色背景支持:支持 8 种不同的背景颜色
- 🎯 图标选择器:内置 Emoji 选择器,支持分类和搜索
- 🔄 实时编辑:点击背景切换颜色,点击图标更换图标
- 📱 响应式设计:完美适配桌面端和移动端
- 🌙 主题支持:自动适配浅色/深色主题
- 📋 Notion 兼容:支持从 Notion 复制粘贴
1.2 核心实现
扩展定义 (callout-node-extension.ts)
import { Node } from '@tiptap/core'
import { ReactNodeViewRenderer } from '@tiptap/react'
import { CalloutNode as CalloutNodeComponent } from '@/components/tiptap-node/callout-node/callout-node'
// 扩展Tiptap核心模块,为Commands接口添加callout相关命令的类型定义
declare module '@tiptap/core' {
interface Commands<ReturnType> {
callout: {
setCallout: (attributes?: { background?: string }) => ReturnType
}
}
}
// 创建Callout节点扩展
export const Callout = Node.create({
name: 'callout',
group: 'block',
selectable: true,
atom: false,
content: '(paragraph)+',
defining: true,
addOptions() {
return {
defaultIcon: '💡',
defaultBackground: null
}
},
addAttributes() {
return {
icon: {
default: this.options.defaultIcon,
parseHTML: (element) => element.dataset.icon || this.options.defaultIcon,
renderHTML: (attributes) => ({
'data-icon': attributes.icon
})
},
background: {
default: null,
parseHTML: (element) => element.dataset.background || null,
renderHTML: (attributes) => ({
'data-background': attributes.background
})
}
}
},
parseHTML() {
return [
{
tag: 'div[data-type="callout"]'
}
]
},
renderHTML({ HTMLAttributes }) {
return ['div', { ...HTMLAttributes, 'data-type': 'callout' }, 0]
},
addNodeView() {
return ReactNodeViewRenderer(CalloutNodeComponent)
},
addCommands() {
return {
setCallout: () => {
return ({ commands }) => {
return commands.wrapIn(this.name)
}
}
}
}
})
React 组件 (callout-node.tsx)
import React, { useState, useEffect, useRef } from 'react'
import { NodeViewWrapper, NodeViewProps, NodeViewContent } from '@tiptap/react'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/tiptap-ui-primitive/popover'
import { cn } from '@/lib/utils'
import { calloutEmojisByCategory, colorOptions } from './const'
export const CalloutNode: React.FC<NodeViewProps> = (props) => {
const { icon, background } = props.node.attrs
const [searchTerm, setSearchTerm] = useState('')
const [activeCategory, setActiveCategory] = useState('Callout')
const [colorPopoverOpen, setColorPopoverOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
// 处理图标选择
const handleEmojiClick = (emoji: string) => {
try {
if (typeof emoji === 'string' && emoji.trim()) {
encodeURIComponent(emoji)
props.updateAttributes({ icon: emoji })
}
} catch (error) {
console.warn('无法更新emoji图标:', error)
}
}
// 处理颜色选择
const handleColorSelect = (colorValue: string | null, e?: React.MouseEvent) => {
try {
if (e) {
e.stopPropagation()
}
props.updateAttributes({ background: colorValue })
setColorPopoverOpen(false)
} catch (error) {
console.warn('无法更新背景颜色:', error)
}
}
// 处理背景点击事件
const handleBackgroundClick = (e: React.MouseEvent) => {
const target = e.target as HTMLElement
const isEmojiTrigger = target.closest('.absolute.left-3')
const isPopoverContent = target.closest('[data-popover-content]')
const isColorSelector = target.closest('.rounded-md.border.border-stone-200')
const isButton = target.closest('button')
if (!isEmojiTrigger && !isPopoverContent && !isColorSelector && !isButton) {
e.stopPropagation()
setColorPopoverOpen(true)
}
}
// 根据background属性获取对应的背景类
const getBackgroundClass = () => {
const isDarkMode = document.documentElement.classList.contains('dark')
const selectedColor = colorOptions.find((c) => c.value === background)
if (selectedColor) {
return selectedColor.light
}
return isDarkMode ? 'bg-[#30302e]' : 'bg-stone-50'
}
return (
<NodeViewWrapper
ref={containerRef}
className={cn(
`tiptap-callout relative my-3 cursor-pointer rounded-md py-4 pr-6 pl-10 dark:text-black ${getBackgroundClass()}`
)}
style={{ borderLeft: background ? '4px solid var(--tw-border-opacity, 1)' : 'none' }}
onClick={handleBackgroundClick}
>
{/* 颜色选择器弹出层 */}
{colorPopoverOpen && (
<div className="absolute -top-1 left-1/2 z-10 flex -translate-x-1/2 -translate-y-full transform gap-2 rounded-md border border-stone-200 bg-white p-1 px-4 py-2 shadow-lg transition-opacity dark:border-stone-300 dark:bg-stone-800">
{colorOptions.map((color) => (
<button
key={color.value || 'default'}
className={`relative flex h-6 w-6 cursor-pointer items-center justify-center rounded-sm transition-colors ${color.light}`}
onClick={(e) => handleColorSelect(color.value, e)}
aria-label={`选择${color.name}背景`}
>
{background === color.value && (
<div className="absolute inset-0 flex items-center justify-center rounded-sm bg-black/20">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</div>
)}
</button>
))}
</div>
)}
{/* Emoji 选择器 */}
<Popover>
<PopoverTrigger asChild>
<span className="absolute top-7 left-3 -translate-y-1/2 cursor-pointer text-xl">{icon}</span>
</PopoverTrigger>
<PopoverContent className="max-h-[300px] max-w-[400px] min-w-[320px] overflow-hidden rounded-md border-0 bg-white p-0 shadow-lg dark:bg-[#252525]">
{/* 搜索栏 */}
<div className="border-b border-stone-200 px-4 py-2 dark:border-stone-700">
<div className="relative">
<input
type="text"
placeholder="Filter..."
className="w-full rounded-md border-none bg-stone-100 px-3 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none dark:bg-stone-800"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{searchTerm && (
<button
className="absolute top-1/2 right-2 -translate-y-1/2 text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-200"
onClick={() => setSearchTerm('')}
>
✕
</button>
)}
</div>
</div>
{/* 分类标签 */}
{!searchTerm && (
<div className="overflow-x-auto border-b border-stone-200 px-2 py-2 whitespace-nowrap dark:border-stone-700">
{Object.keys(calloutEmojisByCategory).map((category) => (
<button
key={category}
className={`mr-1 cursor-pointer rounded-md px-3 py-1 text-sm transition-colors ${
activeCategory === category
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200'
: 'hover:bg-stone-100 dark:hover:bg-stone-800'
}`}
onClick={() => setActiveCategory(category)}
>
{category}
</button>
))}
</div>
)}
{/* Emoji网格 */}
<div className="max-h-[200px] overflow-y-auto p-2">
<div className="grid grid-cols-8 gap-1">
{displayEmojis.map((emoji) => (
<button
key={emoji}
className="flex h-10 w-10 cursor-pointer items-center justify-center rounded-md text-xl transition-colors hover:bg-stone-200 dark:hover:bg-stone-700"
onClick={() => handleEmojiClick(emoji)}
aria-label={`选择图标 ${emoji}`}
>
{emoji}
</button>
))}
</div>
{displayEmojis.length === 0 && (
<div className="py-8 text-center text-stone-500 dark:text-stone-400">没有找到匹配的图标</div>
)}
</div>
</PopoverContent>
</Popover>
<NodeViewContent />
</NodeViewWrapper>
)
}
常量定义 (const.ts)
// 分类的emoji选项
export const calloutEmojisByCategory = {
Callout: [
'💡',
'📝',
'❗',
'⚠️',
'✨',
'🔥',
'🔔',
'💬',
'🔍',
'🎯',
'✅',
'❌',
'💪',
'🤔',
'💎',
'⭐',
'🌟',
'💫',
'🌈',
'🌊',
'🌞',
'❓',
'❔',
'💭',
'💤',
'💢',
'💯',
'🙏'
],
People: [
'😊',
'😃',
'😁',
'😄',
'😆',
'😅',
'😂',
'🤣',
'😉',
'😎',
'🤩',
'😍',
'🥰',
'😘',
'😗',
'😙',
'👍',
'👎',
'👌',
'✌️',
'🤞',
'🤟',
'🤘',
'🤙'
],
Objects: ['📚', '✏️', '📝', '📋', '📄', '📃', '📑', '📊', '🔧', '⚙️', '🔨', '🗜️', '⛏️', '🔩', '⚖️', '⚗️']
}
// 定义背景颜色选项列表
export const colorOptions = [
{ name: '默认', value: null, light: 'bg-stone-50' },
{ name: '蓝色', value: 'blue', light: 'bg-blue-50' },
{ name: '浅蓝色', value: 'light-blue', light: 'bg-sky-50' },
{ name: '绿色', value: 'green', light: 'bg-green-50' },
{ name: '黄色', value: 'yellow', light: 'bg-yellow-50' },
{ name: '橙色', value: 'orange', light: 'bg-orange-50' },
{ name: '粉色', value: 'pink', light: 'bg-pink-50' },
{ name: '紫色', value: 'purple', light: 'bg-purple-50' }
]
1.3 技术亮点
交互设计
- 点击背景:打开颜色选择器,支持 8 种背景色
- 点击图标:打开 Emoji 选择器,支持分类和搜索
- 事件处理:精确的事件冒泡控制,避免冲突
搜索功能
- 直接匹配:支持 Emoji 字符直接搜索
- 分类搜索:支持按分类名称搜索
- 关键词搜索:支持
light、warning、success等关键词
主题适配
- 自动检测:根据
document.documentElement.classList.contains('dark')检测主题 - 动态样式:根据主题动态应用对应的背景色
- 一致性:与整体设计系统保持一致
无障碍支持
- ARIA 标签:为所有交互元素添加适当的
aria-label - 键盘导航:支持键盘操作
- 语义化:使用语义化的 HTML 结构
1.4 使用方式
// 在编辑器中添加 Callout
editor.commands.setCallout()
// 设置特定背景色的 Callout
editor.commands.setCallout({ background: 'blue' })
1.5 集成、运行与验证
上面的三个源码文件需要放进同一个 Tiptap React 项目,并把 Callout 注册到编辑器的 extensions 数组。本文代码适用于 Tiptap 3,最小依赖如下:
pnpm add @tiptap/core@3.29.2 @tiptap/react@3.29.2 @tiptap/starter-kit@3.29.2
import StarterKit from '@tiptap/starter-kit'
import { Callout } from './callout-node-extension'
const editorOptions = {
extensions: [StarterKit, Callout]
}
启动项目后不要只看组件是否出现,需要验证节点模型和序列化结果:
- 执行
editor.commands.setCallout({ background: 'blue' }),确认文档中新增type: "callout"的节点。 - 修改图标和背景色,再调用
editor.getJSON(),确认icon、background属性已经写入文档,而不只是 React 本地状态变化。 - 用
editor.commands.setContent(savedJson)回填 JSON,确认 Callout 内容、图标和背景色能够恢复。 - 执行
pnpm exec tsc --noEmit,确保扩展后的Commands类型、NodeView 属性和调用参数一致。 - 用键盘聚焦图标与颜色按钮,检查
aria-label、焦点顺序和 Escape 关闭弹层;浅色、深色主题各验证一次。
如果命令返回 false,先检查 Callout 是否注册;如果 JSON 中有节点但页面不显示,检查 addNodeView() 和组件导出;如果重新加载后属性丢失,检查 parseHTML()、renderHTML() 与属性名是否一致。
二、总结
- 高亮 Callout 组件:执行 editor.commands.setCallout({ background: 'blue' }),确认文档中新增 type: "callout" 的节点。 -> 修改图标和背景色,再调用 editor.getJSON(),确认 icon、background 属性已经写入文档,而不只是 React 本地状态变化。 -> 用 editor.commands.setContent(savedJson) 回填 JSON,确认 Callout 内容、图标和背景色能够恢复。 -> 执行 pnpm exec tsc --noEmit,确保扩展后的 Commands 类型、NodeView 属性和调用参数一致。
- 功能特性:🎨 多色背景支持:支持 8 种不同的背景颜色
- 技术亮点:点击背景:打开颜色选择器,支持 8 种背景色
- 交互设计:点击背景:打开颜色选择器,支持 8 种背景色
- 搜索功能:直接匹配:支持 Emoji 字符直接搜索
- 主题适配:自动检测:根据 document.documentElement.classList.contains('dark') 检测主题
学完自测
选择所有正确答案;提交后逐项核对判断依据。