当前位置: 首页 > news >正文

React + TipTap 富文本编辑器 实现消息列表展示,类似Slack,Deepseek等对话框功能

   经过几天折腾再折腾,弄出来了,弄出来了!!! 消息展示 + 在位编辑功能。

   两个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等对话框功能

相关文章:

  • 基于二叉堆实现的 PriorityQueue
  • LLM应用实战2-理解Tokens
  • C语言malloc类函数详解
  • Linux C 与 C 语言的区别及开发差异
  • Spring MVC 请求类型注解详解
  • Java-多级排序结合thenComparing()
  • 四六级听力考试播音系统:构建播放控制智能化、发射系统双备份、发射功率有冗余、安全稳定可靠的英语四六级听力播音系统使用环境
  • vue-element-plus-admin的安装
  • pytorch小记(十六):PyTorch中的`nn.Identity()`详解:灵活模型设计的秘密武器
  • Linux内核——X86分页机制
  • I/O进程4
  • 动态规划系列一>卡特兰数-不同的二叉搜索树
  • C# 串口通信
  • 全新二手罗德SMCV100B信号发生器SMBV100A
  • 视频融合平台EasyCVR搭建智慧粮仓系统:为粮仓管理赋能新优势
  • 对象的创建方式有哪些?在虚拟机中具体的创建过程是怎样的?
  • Conda使用方法详解
  • SAM: 一切皆可分割
  • NO.82十六届蓝桥杯备战|动态规划-从记忆化搜索到动态规划|下楼梯|数字三角形(C++)
  • 【在团队中有效表达想法的方法】
  • 潍坊市网站建设/b站黄页推广
  • 做网站建设的方案/泰安网站推广优化
  • 电脑网页制作软件下载/seopeix
  • 惠州网站建设效果/百度购物平台客服电话
  • 网上做石材去哪个网站/做电商如何起步
  • 哪个网站可以找到毕业设计/友情链接百科