经过几天折腾再折腾,弄出来了,弄出来了!!! 消息展示 + 在位编辑功能。
两个tiptap实例1个用来展示 消息列表,一个用来在位编辑消息。
tiptap灵活富文本编辑器,拓展性太好了!!! !!!
关键点:实现只用了两个TipTap 实例。
每条消息创建一个tiptap实例简单AI可以给你直接生成,用两个tiptap实例完成就难了。出于对性能考虑,迭代几个版本更新,选用两个实例,完成所有工作,性能好了编码复杂度高了不少。
1.TipTap 展示AI聊天消息思路,自定拓展来显示结构内容
content: [
{ type: 'text', text: '你好,我是 AI 🤖' },
{ type: 'heading', level: 3, text: '功能介绍' },
{
type: 'bulletList',
items: ['文字回复', '插入图片', '代码高亮'],
},
{ type: 'img', src: 'https://placekitten.com/200/200' },
{
type: 'codeBlock',
language: 'js',
code: 'console.log("你好 Tiptap!")',
},
],
2.Tiptap拓展ChatMessage,消息展示+在位编辑
renderContent把消息结构体渲染为reac标签
const renderContent = (content: any[]) => {
return content.map((item, index) => {
const key = `${item.type}-${index}` // 构造一个稳定的 key
switch (item.type) {
case 'text':
return <p key={key}>{item.text}</p>
case 'img':
return (
<img
key={key}
src={item.src}
alt="chat image"
style={{ maxWidth: '100%', margin: '0.5em 0' }}
/>
)
case 'bulletList':
return (
<ul key={key} className="list-disc list-inside">
{item.items.map((text: string, i: number) => (
<li key={`bullet-${index}-${i}`}>{text}</li>
))}
</ul>
)
case 'heading':
const HeadingTag = `h${item.level || 2}` as keyof JSX.IntrinsicElements
return <HeadingTag key={key}>{item.text}</HeadingTag>
case 'codeBlock':
return (
<pre key={key}>
<code className={`language-${item.language || 'js'}`}>
{item.code}
</code>
</pre>
)
default:
return ''
}
})
}
在位编辑html 传给shareEditor在位编辑。
const startEdit = () => {
if (!sharedEditor) return
const html = ReactDOMServer.renderToStaticMarkup(<>{renderContent(content)}</>)
sharedEditor.commands.setContent(html)
setIsEditing(true)
}
完整ChatMessageEx.tsx
import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer } from '@tiptap/react'
import React, { useState } from 'react'
import { NodeViewWrapper } from '@tiptap/react'
import ReactDOMServer from 'react-dom/server'
import { EditorContent, Editor } from "@tiptap/react";
export interface ChatMessageOptions {
HTMLAttributes: Record<string, any>
sharedEditor?: Editor | null
onEdit?: (node: any, updateAttributes: (attrs: any) => void) => void
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
chatMessage: {
insertChatMessage: (props: {
author: string
content: any[] // structured array content
avatar?: string
time?: string
}) => ReturnType
}
}
}
const renderContent = (content: any[]) => {
return content.map((item, index) => {
const key = `${item.type}-${index}` // 构造一个稳定的 key
switch (item.type) {
case 'text':
return <p key={key}>{item.text}</p>
case 'img':
return (
<img
key={key}
src={item.src}
alt="chat image"
style={{ maxWidth: '100%', margin: '0.5em 0' }}
/>
)
case 'bulletList':
return (
<ul key={key} className="list-disc list-inside">
{item.items.map((text: string, i: number) => (
<li key={`bullet-${index}-${i}`}>{text}</li>
))}
</ul>
)
case 'heading':
const HeadingTag = `h${item.level || 2}` as keyof JSX.IntrinsicElements
return <HeadingTag key={key}>{item.text}</HeadingTag>
case 'codeBlock':
return (
<pre key={key}>
<code className={`language-${item.language || 'js'}`}>
{item.code}
</code>
</pre>
)
default:
return ''
}
})
}
const MessageView = ({ node, ...props }: any) => {
const { author, content, avatar, time } = node.attrs
const [isEditing, setIsEditing] = useState(false)
const sharedEditor = props.sharedEditor as Editor
const startEdit = () => {
if (!sharedEditor) return
const html = ReactDOMServer.renderToStaticMarkup(<>{renderContent(content)}</>)
sharedEditor.commands.setContent(html)
setIsEditing(true)
}
const saveEdit = () => {
// 消息发送到服务器来更新
setIsEditing(false)
}
return (
<NodeViewWrapper
as="div"
data-type="chat-message"
className="group relative flex items-start gap-2 pl-1 hover:bg-gray-100 dark:hover:bg-gray-900 pt-1 pb-1"
>
<div className="flex items-start w-full">
<div className="w-8 h-8 rounded-full overflow-hidden absolute top-2 left-3 z-10">
<img src={avatar} className="w-full h-full object-cover" />
</div>
<div className="pl-12 relative w-full">
<div className="flex mb-1 text-xs text-gray-500 dark:text-gray-400">
<span className="font-medium">{author}</span>
<span className="ml-1">{time}</span>
</div>
{!isEditing ? (
<div className="text-sm">{renderContent(content)}</div>
) : (
<div className="border p-2 rounded dark:bg-gray-800">
<EditorContent editor={sharedEditor} />
</div>
)}
<div className="absolute -top-1 right-0 hidden group-hover:flex gap-2 z-10 bg-white dark:bg-gray-800 dark:text-white shadow">
{!isEditing ? (
<button
onClick={startEdit}
className="text-xs px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded"
>
编辑
</button>
) : (
<button
onClick={saveEdit}
className="text-xs px-2 py-1 bg-blue-500 text-white rounded"
>
保存
</button>
)}
<button
onClick={() => alert(`转发消息`)}
className="text-xs px-2 py-0.5 hover:bg-gray-100 dark:hover:bg-gray-700 p-1"
>
回复
</button>
<button
onClick={() => alert(`你点赞了`)}
className="text-xs px-2 py-0.5 hover:bg-gray-100 dark:hover:bg-gray-700 p-1"
>
收到
</button>
</div>
</div>
</div>
</NodeViewWrapper>
)
}
const ChatMessageEx = Node.create<ChatMessageOptions>({
name: 'chatMessage',
group: 'block',
atom: true,
selectable: true,
addOptions() {
return {
HTMLAttributes: {},
sharedEditor: null,
onEdit: undefined,
}
},
addAttributes() {
return {
author: { default: 'User' },
content: { default: [] },
avatar: { default: '' },
time: { default: '' },
side: { default: 'left' },
}
},
parseHTML() {
return [{ tag: 'div[data-type="chat-message"]' }]
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'chat-message' })]
},
addNodeView() {
return ReactNodeViewRenderer((props) => (
<MessageView
{...props}
sharedEditor={this.options.sharedEditor}
onEdit={this.options.onEdit}
/>
))
},
addCommands() {
return {
insertChatMessage:
({ author, content, avatar, time }) =>
({ chain, state }) => {
const endPos = state.doc.content.size
return chain()
.insertContent([
{
type: 'chatMessage',
attrs: {
author,
content: content,
avatar,
time,
},
},
{ type: 'paragraph' },
])
.focus(endPos)
.run()
},
}
},
})
export default ChatMessageEx
3.使用ChatMessageEx拓展
为chatMessage传入一个 共享sharedEditor
const shardEditor = useEditor({
extensions: [
StarterKit,
ChatMessageEx,
Placeholder.configure({
placeholder: "# 给发送消息",
})
],
editable: true,
})
const editor = useEditor({
extensions: [
StarterKit,
ChatMessageEx.configure({
sharedEditor: shardEditor
}),
Placeholder.configure({
placeholder: "# 给发送消息",
})
],
editable: false
})
完整channel.tsx
// Channel.tsx
import React, { useState, createContext, useEffect, useRef } from "react";
import useChannelsStore from "@/Stores/useChannelListStore";
import { MessageSquare, Settings, Folder, Plus, Pencil, Check } from "lucide-react";
import InputMessage from "@/Components/Tiptap/InputMessage";
import { useMessageStore } from '@/Stores/UseChannelMessageStore' // 引入 Zustand store
import StarterKit from '@tiptap/starter-kit'
import { useEditor, EditorContent, Editor } from "@tiptap/react";
import TurndownService from 'turndown'
import ChatMessageEx from "@/Components/Tiptap/ChatMessageEx";
import Placeholder from '@tiptap/extension-placeholder'
interface MessageItemProps {
msg: {
id: string;
content: string;
dateTime: string;
};
editor: Editor;
updateMessage: (id: string, newContent: string) => void;
}
const TabB = () => <div className="p-4">这是选项卡 B 的内容</div>;
const TabC = () => <div className="p-4">这是选项卡 C 的内容</div>;
const ChatMessages = () => {
const shardEditor = useEditor({
extensions: [
StarterKit,
ChatMessageEx,
Placeholder.configure({
placeholder: "# 给发送消息",
})
],
editable: true,
})
const editor = useEditor({
extensions: [
StarterKit,
ChatMessageEx.configure({
sharedEditor: shardEditor
}),
Placeholder.configure({
placeholder: "# 给发送消息",
})
],
editable: false
})
const onInputMessage = () => {
editor?.commands.insertChatMessage({
author: '小助手',
time: '11:11 AM',
avatar: 'https://i.pravatar.cc/32?img=5',
content: [
{ type: 'text', text: '你好,我是 AI 🤖' },
{ type: 'heading', level: 3, text: '功能介绍' },
{
type: 'bulletList',
items: ['文字回复', '插入图片', '代码高亮'],
},
{ type: 'img', src: 'https://placekitten.com/200/200' },
{
type: 'codeBlock',
language: 'js',
code: 'console.log("你好 Tiptap!")',
},
],
})
}
const onOutMessage = () => {
console.log("onOutMessage", editor?.getJSON());
}
return (
// 1.显示高度
<div className=" h-full flex flex-col ">
<button className=" cursor-pointer hover:bg-amber-400" onClick={() => onInputMessage()}>插入信息</button>
<button className=" cursor-pointer hover:bg-amber-400" onClick={() => onOutMessage()}>显示信息</button>
{/* 滚动 显示内容 */}
<div className=" p-3 pl-0 flex-1 overflow-y-scroll custom-scrollbar ">
<EditorContent editor={editor} />
</div>
<div className="w-full min-h-12 ">
<InputMessage></InputMessage>
</div>
</div>
)
};
const Channel: React.FC = () => {
const { currentChannel } = useChannelsStore();
const [activeTab, setActiveTab] = useState("chatMessage");
// 选项卡列表,每个选项卡增加 `icon` 属性
const [tabs, setTabs] = useState([
{ id: "chatMessage", name: "消息", icon: <MessageSquare size={16} />, component: <ChatMessages /> },
{ id: "tabB", name: "文件", icon: <Folder size={16} />, component: <TabB /> },
{ id: "tabC", name: "设置", icon: <Settings size={16} />, component: <TabC /> },
]);
// 添加新选项卡
const addTab = () => {
const newTabId = `tab${tabs.length + 1}`;
const newTab = {
id: newTabId,
name: `选项卡${tabs.length + 1}`,
icon: <Folder size={16} />, // 默认使用 Folder 图标
component: <div className="p-4">这是 {newTabId} 的内容</div>,
};
setTabs([...tabs, newTab]);
};
return (
<div className="flex flex-col h-full w-full justify-center">
{/* 顶部 */}
<div className="h-20 justify-between border-b flex flex-col border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200">
<div className="p-2 text-[16px] font-bold cursor-pointer"># {currentChannel?.name}</div>
{/* 选项卡导航 */}
<div className="flex gap-2 ml-2">
{tabs.map((tab) => (
<div
key={tab.id}
className={`pl-2 pr-2 pt-1 pb-1 flex items-center gap-1 cursor-pointer rounded-t-sm hover:bg-gray-200 dark:hover:bg-gray-700 ${activeTab === tab.id ? "border-b-2 bg-gray-200 dark:bg-gray-700 font-bold" : ""
}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.icon} {/* 渲染图标 */}
{tab.name}
</div>
))}
<div
className="ml-2 p-1 mb-1 mt-1 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full"
onClick={addTab}
>
<Plus size={18} />
</div>
</div>
</div>
{/* 内容区 */}
<div className="border-gray-300 dark:border-gray-600 h-full overflow-hidden">
{tabs.find((tab) => tab.id === activeTab)?.component}
</div>
</div>
)
};
export default Channel;
React + TipTap 富文本编辑器 实现消息列表展示,类似Slack,Deepseek等对话框功能