挑战用React封装100个组件【005】

项目地址
https://github.com/hismeyy/react-component-100

组件描述

该组件适用于论坛,发帖等地方。可以发布信息,表情包,图片。

样式展示

前置依赖

今天,我分享的组件,需要用到的依赖有:

  1. react-icons(提供图标)
  2. emoji-mart(提供emoji表情包)
  3. 在上一个挑战中,制作的Img组件(展示照片)

安装 react-icons

cmd 复制代码
# 使用 npm
npm install react-icons

# 或者使用 yarn
yarn add react-icons

使用的话,大家可以看这个网站。大家进去可以找需要的图标。具体使用里面有介绍,非常简单。
react-icons 图标

安装emoji-mart

cmd 复制代码
# 使用npm
npm install --save emoji-mart @emoji-mart/data @emoji-mart/react

# 使用yarn
yarn add emoji-mart @emoji-mart/data @emoji-mart/react

具体文档,大家可以查看这个地址。emoji-mart 仓库

使用Img
Img的话,大家可以查看我的上一篇文章。
挑战用React封装100个组件【004】

好了,下面我们展示代码。(注意:如果实际使用的时候,大家需要按照实际需要修改,比如对接接口等等

代码展示

ChatBox.tsx
js 复制代码
import './ChatBox.css';
import { useState, useEffect, useRef, useCallback } from 'react';
import { BiLaugh, BiImage } from "react-icons/bi";
import data from '@emoji-mart/data'
import Picker from '@emoji-mart/react'
import Img from '../../img/img01/Img';

// 全局常量定义
const MAX_LENGTH = 5000;  // 最大文本长度
const MAX_IMAGES = 9;     // 最大图片数量

// 类型定义
interface ImageItem {
    id: string;      // 图片唯一标识
    url: string;     // 图片URL或base64
    file: File;      // 图片文件对象
}

interface ChatContent {
    text: string;    // 聊天文本内容
    images: ImageItem[]; // 图片列表
}

const ChatBox = () => {
    // =============== 状态管理 ===============
    const [chatContent, setChatContent] = useState<ChatContent>({
        text: '',
        images: []
    });
    const [textLength, setTextLength] = useState(0);          // 当前文本长度
    const [showEmojiPicker, setShowEmojiPicker] = useState(false);  // 表情选择器显示状态

    // =============== DOM引用 ===============
    const textareaRef = useRef<HTMLTextAreaElement>(null);    // 文本框引用
    const emojiPickerRef = useRef<HTMLDivElement>(null);      // 表情选择器引用
    const fileInputRef = useRef<HTMLInputElement>(null);      // 文件输入框引用

    // =============== 文本处理 ===============
    // 调整文本框高度
    const adjustTextareaHeight = useCallback((textarea: HTMLTextAreaElement) => {
        textarea.style.height = 'auto';
        textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
    }, []);

    // 处理文本输入
    const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
        const newText = e.target.value;
        if (newText.length <= MAX_LENGTH) {
            setTextLength(newText.length);
            setChatContent(prev => ({
                ...prev,
                text: newText
            }));
            adjustTextareaHeight(e.target);
        }
    }, [adjustTextareaHeight]);

    // =============== 表情处理 ===============
    // 处理表情选择
    const handleEmojiSelect = useCallback((emoji: any) => {
        if (!textareaRef.current) return;

        const start = textareaRef.current.selectionStart;
        const end = textareaRef.current.selectionEnd;
        const currentText = textareaRef.current.value;
        const newText = currentText.slice(0, start) + emoji.native + currentText.slice(end);

        if (newText.length <= MAX_LENGTH) {
            setTextLength(newText.length);
            setChatContent(prev => ({
                ...prev,
                text: newText
            }));

            // 更新光标位置到表情后面
            setTimeout(() => {
                if (textareaRef.current) {
                    const newPosition = start + emoji.native.length;
                    textareaRef.current.selectionStart = newPosition;
                    textareaRef.current.selectionEnd = newPosition;
                    textareaRef.current.focus();
                    adjustTextareaHeight(textareaRef.current);
                }
            }, 0);
        }
        setShowEmojiPicker(false);
    }, [adjustTextareaHeight]);

    // 切换表情选择器显示状态
    const toggleEmojiPicker = useCallback(() => {
        setShowEmojiPicker(prev => !prev);
    }, []);

    // =============== 图片处理 ===============
    // 创建图片对象
    const createImageItem = useCallback((file: File, url: string): ImageItem => {
        return {
            id: Date.now().toString(),
            url,
            file
        };
    }, []);

    // 处理图片上传
    const handleImageUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
        const files = e.target.files;
        if (!files) return;

        // 检查图片数量是否超出限制
        if (chatContent.images.length + files.length > MAX_IMAGES) {
            alert(`最多只能上传${MAX_IMAGES}张图片`);
            if (fileInputRef.current) {
                fileInputRef.current.value = '';
            }
            return;
        }

        // 处理每个选中的图片文件
        Array.from(files).forEach(file => {
            const reader = new FileReader();
            reader.onload = (e) => {
                if (e.target?.result) {
                    const newImage = createImageItem(file, e.target.result as string);
                    setChatContent(prev => ({
                        ...prev,
                        images: [...prev.images, newImage]
                    }));
                }
            };
            reader.readAsDataURL(file);
        });

        // 清空文件输入框,以便重复选择相同文件
        if (fileInputRef.current) {
            fileInputRef.current.value = '';
        }
    }, [chatContent.images.length, createImageItem]);

    // 处理图片选择按钮点击
    const handleImageClick = useCallback(() => {
        if (chatContent.images.length >= MAX_IMAGES) {
            alert(`最多只能上传${MAX_IMAGES}张图片`);
            return;
        }
        fileInputRef.current?.click();
    }, [chatContent.images.length]);

    // 处理图片删除
    const handleImageDelete = useCallback((id: string) => {
        setChatContent(prev => ({
            ...prev,
            images: prev.images.filter(img => img.id !== id)
        }));
    }, []);

    // =============== 副作用处理 ===============
    // 点击外部关闭表情选择器
    useEffect(() => {
        const handleClickOutside = (event: MouseEvent) => {
            if (emojiPickerRef.current && !emojiPickerRef.current.contains(event.target as Node)) {
                setShowEmojiPicker(false);
            }
        };

        document.addEventListener('mousedown', handleClickOutside);
        return () => document.removeEventListener('mousedown', handleClickOutside);
    }, []);

    // =============== 渲染 ===============
    return (
        <div className="chat-box">
            {/* 文本输入区域 */}
            <div className="chat-input">
                <textarea
                    ref={textareaRef}
                    placeholder="# 要不要发点什么话...."
                    onChange={handleInput}
                    value={chatContent.text}
                    rows={1}
                    maxLength={MAX_LENGTH}
                />
                <div>
                    <span>{textLength}/{MAX_LENGTH}</span>
                </div>
            </div>

            {/* 图片预览区域 */}
            {chatContent.images.length > 0 && (
                <div className='chat-imgs'>
                    {chatContent.images.map((img) => (
                        <Img
                            key={img.id}
                            src={img.url}
                            alt="已上传图片"
                            size="small"
                            onClose={() => handleImageDelete(img.id)}
                        />
                    ))}
                </div>
            )}

            {/* 功能按钮区域 */}
            <div className='chat-functions'>
                <div className='left'>
                    {/* 表情选择器 */}
                    <div>
                        <div onClick={toggleEmojiPicker} className="emoji-trigger">
                            <BiLaugh /> &nbsp;表情
                        </div>
                        {showEmojiPicker && (
                            <div className='emoji-picker' ref={emojiPickerRef}>
                                <Picker
                                    data={data}
                                    onEmojiSelect={handleEmojiSelect}
                                    theme="light"
                                    locale="zh"
                                />
                            </div>
                        )}
                    </div>
                    {/* 图片上传 */}
                    <div onClick={handleImageClick}>
                        <BiImage /> &nbsp;图片
                    </div>
                    <input
                        type="file"
                        ref={fileInputRef}
                        onChange={handleImageUpload}
                        accept="image/*"
                        multiple
                        style={{ display: 'none' }}
                    />
                </div>
                <div className='right'>
                    <button>发布</button>
                </div>
            </div>
        </div>
    );
};

export default ChatBox;
ChatBox.css
js 复制代码
/* 聊天框主容器 */
.chat-box {
    width: 100%;
    background-color: #FFFFFF;
    border-radius: 10px;
    padding: 15px;
    box-sizing: border-box;
}

/* 输入区域样式 */
.chat-box .chat-input {
    width: 100%;
    background-color: #F3F4F6;
    box-sizing: border-box;
    border-radius: 10px;
    padding: 20px 20px 10px;
}

/* 文本输入框样式 */
.chat-box .chat-input textarea {
    width: 100%;
    min-height: 45px;
    border: none;
    outline: none;
    background-color: transparent;
    font-size: 14px;
    line-height: 1.5;
    resize: none;
    overflow-y: auto;
    word-break: break-all;
}

/* 滚动条样式 */
.chat-box .chat-input textarea::-webkit-scrollbar {
    width: 4px;
}

.chat-box .chat-input textarea::-webkit-scrollbar-thumb {
    background-color: #d4d4d4;
    border-radius: 2px;
}

.chat-box .chat-input textarea::-webkit-scrollbar-track {
    background: transparent;
}

/* 字数统计容器 */
.chat-box .chat-input div {
    display: flex;
    justify-content: flex-end;
    margin-top: 5px;
}

/* 字数统计文本 */
.chat-box .chat-input span {
    font-size: 14px;
    color: #bbbbbb;
}

/* 功能区域样式 */
.chat-functions {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-top: 15px;
    font-size: 14px;
}

/* 左侧功能区 */
.chat-functions .left {
    display: flex;
    gap: 10px;
}

.chat-functions .left div {
    display: flex;
    align-items: center;
    cursor: pointer;
    position: relative;
}

/* 表情选择器触发器 */
.chat-functions .left .emoji-trigger {
    display: flex;
    align-items: center;
    transition: color 0.3s ease;
}

/* 表情选择器定位 */
.chat-functions .left .emoji-picker {
    position: absolute;
    top: 30px;
    left: -10px;
    z-index: 5;
}

/* 功能按钮悬浮效果 */
.chat-functions .left div:hover {
    color: #f08a5d;
}

/* 发布按钮样式 */
.chat-functions .right button {
    all: unset;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 60px;
    padding: 5px;
    border-radius: 15px;
    background-color: #f08a5d;
    color: #FFFFFF;
    cursor: pointer;
    transition: background-color 0.3s ease;
}

.chat-functions .right button:hover {
    background-color: #f1946c;
}

/* 图片展示区域 */
.chat-box .chat-imgs {
    width: 100%;
    margin-top: 15px;
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-start;
    gap: 15px;
}

使用

App.tsx
js 复制代码
import './App.css'
import ChatBox from './components/chatBox/chatBox01/ChatBox';

function App() {
  return (
    <>
      <div className="App">
        <ChatBox />
      </div>
    </>
  );
}

export default App
相关推荐
崔庆才丨静觅4 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60615 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了5 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅5 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅6 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment6 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼6 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax