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

news2025/4/13 7:46:05

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

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2331966.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

博途 TIA Portal之1200做主站与汇川EASY的TCP通讯

前言,虽然已经做了几篇关于TCP通讯的文章,但是不同的PLC之间的配合可能不同,下面将演示这种差异。 关于汇川EASY做从站的配置请参见下方链接文章:汇川EASY系列之以太网通讯(套接字socket做从站)_汇川以太网tcp套接字fb块-CSDN博客 1、硬件准备: 1200PLC,汇川EASY320…

蓝桥杯速成刷题清单(上)

一、1.排序 - 蓝桥云课 &#xff08;快速排序&#xff09;算法代码&#xff1a; #include <bits/stdc.h> using namespace std; const int N 5e5 10; int a[N];int main() {int n;cin >> n;for (int i 0; i < n; i) {cin >> a[i];}sort(a, a n);for …

Go并发背后的双引擎:CSP通信模型与GMP调度|Go语言进阶(4)

为什么需要理解CSP与GMP&#xff1f; 当我们启动一个Go程序时&#xff0c;可能会创建成千上万个goroutine&#xff0c;它们是如何被调度到有限的CPU核心上的&#xff1f;为什么Go能够如此轻松地处理高并发场景&#xff1f;为什么有时候我们的并发程序会出现奇怪的性能瓶颈&…

Linux服务器——Samba服务器

简介 Samba 是一个开源的跨平台文件共享服务​​&#xff0c;允许 Linux/Unix 系统与 Windows 系统实现文件和打印机的共享与互操作。其核心协议为 ​​SMB/CIFS​​&#xff08;Server Message Block / Common Internet File System&#xff09;&#xff0c;是 Windows 网络中…

华为网路设备学习-17

目录 一、加密算法 二、验证算法 三、IPsec协议 1.IKE协议&#xff08;密钥交换协议&#xff09; ①‌ISAKMP&#xff08;Internet Security Association and Key Management Protocol&#xff09;互联网安全关联和密钥管理协议 ②安全关联&#xff08;SA&#xff09; ③…

机器学习12-集成学习-案例

参考 【数据挖掘】基于XGBoost的垃圾短信分类与预测 【分类】使用XGBoost算法对信用卡交易进行诈骗预测 银行卡电信诈骗危险预测(LightGBM版本) 【数据挖掘】基于XGBoost的垃圾短信分类与预测 基于XGBoost的垃圾短信分类与预测 我分享了一个项目给你《【数据挖掘】基于XG…

【数据库原理及安全实验】实验二 数据库的语句操作

目录 指导书原文 实操备注 指导书原文 【实验目的】 1) 掌握使用SQL语言进行数据操纵的方法。 【实验原理】 1) 面对三个关系表student&#xff0c;course&#xff0c;sc。利用SQL语句向表中插入数据&#xff08;insert&#xff09;&#xff0c;然后对数据进行delete&…

【BFT帝国】20250409更新PBFT总结

2411 2411 2411 Zhang G R, Pan F, Mao Y H, et al. Reaching Consensus in the Byzantine Empire: A Comprehensive Review of BFT Consensus Algorithms[J]. ACM COMPUTING SURVEYS, 2024,56(5).出版时间: MAY 2024 索引时间&#xff08;可被引用&#xff09;: 240412 被引:…

Linux-CentOS-7—— 配置静态IP地址

文章目录 CentOS-7——配置静态IP地址VMware workstation的三种网络模式配置静态IP地址1. 编辑虚拟网络2. 确定网络接口名称3. 切换到网卡所在的目录4. 编辑网卡配置文件5. 查看网卡文件信息6. 重启网络服务7. 测试能否通网8. 远程虚拟主机&#xff08;可选&#xff09; 其他补…

Jupyter Lab 无法启动 Kernel 问题排查与解决总结

&#x1f4c4; Jupyter Lab 无法启动 Kernel 问题排查与解决总结 一、问题概述 &#x1f6a8; 现象描述&#xff1a; 用户通过浏览器访问远程服务器的 Jupyter Lab 页面&#xff08;http://xx.xx.xx.xx:8891/lab&#xff09;后&#xff0c;.ipynb 文件可以打开&#xff0c;但无…

算法训练之位运算

♥♥♥~~~~~~欢迎光临知星小度博客空间~~~~~~♥♥♥ ♥♥♥零星地变得优秀~也能拼凑出星河~♥♥♥ ♥♥♥我们一起努力成为更好的自己~♥♥♥ ♥♥♥如果这一篇博客对你有帮助~别忘了点赞分享哦~♥♥♥ ♥♥♥如果有什么问题可以评论区留言或者私信我哦~♥♥♥ ✨✨✨✨✨✨ 个…

C++设计模式+异常处理

#include <iostream> #include <cstring> #include <cstdlib> #include <unistd.h> #include <sstream> #include <vector> #include <memory> #include <stdexcept> // 包含异常类using namespace std;// 该作业要求各位写一…

checkra1n越狱出现的USB error -10问题解决

使用checkra1n进行越狱是出现&#xff1a; 解决办法(使用命令行进行越狱)&#xff1a; 1. cd /Applications/checkra1n.app/Contents/MacOS 2. ./checkra1n -cv 3. 先进入恢复模式 a .可使用爱思助手 b. 或者长按home,出现关机的滑条&#xff0c;同时按住home和电源键&#…

golang-defer延迟机制

defer延迟机制 defer是什么 defer是go中一种延迟调用机制。 执行时机 defer后面的函数只有在当前函数执行完毕后才能执行。 执行顺序 将延迟的语句按defer的逆序进行执行&#xff0c;也就是说先被defer的语句最后被执行&#xff0c;最后被defer的语句&#xff0c;最先被执…

【小沐学Web3D】three.js 加载三维模型(Angular)

文章目录 1、简介1.1 three.js1.2 angular.js 2、three.js Angular.js结语 1、简介 1.1 three.js Three.js 是一款 webGL&#xff08;3D绘图标准&#xff09;引擎&#xff0c;可以运行于所有支持 webGL 的浏览器。Three.js 封装了 webGL 底层的 API &#xff0c;为我们提供了…

一种替代DOORS在WORD中进行需求管理的方法 (二)

一、前景 参考&#xff1a; 一种替代DOORS在WORD中进行需求管理的方法&#xff08;基于WORD插件的应用&#xff09;_doors aspice-CSDN博客 二、界面和资源 WORD2013/WORD2016 插件 【已使用该工具通过第三方功能安全产品认证】&#xff1a; 1、 核心功能 1、需求编号和跟…

一个基于ragflow的工业文档智能解析和问答系统

工业复杂文档解析系统 一个基于ragflow的工业文档智能解析和问答系统,支持多种文档格式的解析、知识库管理和智能问答功能。 系统功能 1. 文档管理 支持多种格式文档上传(PDF、Word、Excel、PPT、图片等)文档自动解析和分块处理实时处理进度显示文档解析结果预览批量文档…

23种设计模式-行为型模式-访问者

文章目录 简介场景解决完整代码核心实现 总结 简介 访问者是一种行为设计模式&#xff0c;它能把算法跟他所作用的对象隔离开来。 场景 假如你的团队开发了一款能够使用图像里地理信息的应用程序。图像中的每个节点既能代表复杂实体&#xff08;例如一座城市&#xff09;&am…

组播网络构建:IGMP、PIM 原理及应用实践

IP组播基础 组播基本架构 组播IP地址 一个组播IP地址并不是表示具体的某台主机&#xff0c;而是一组主机的集合&#xff0c;主机声明加入某组播组即标识自己需要接收目的地址为该组播地址的数据IP组播常见模型分为ASM模型和SSM模型ASM&#xff1a;成员接收任意源组播数据&…

建筑兔零基础自学记录69|爬虫Requests-2

Requests库初步尝试 #导入requests库 import requests #requests.get读取百度网页 rrequests.get(http://www.baidu.com) #输出读取网页状态 print(r.status_code) #输出网页源代码 print(r.text) HTTP 状态码是三位数字&#xff0c;用于表示 HTTP 请求的结果。常见的状态码有…