react-quill 富文本组件编写和应用

  • index.tsx文件
javascript 复制代码
import React, { useRef, useState } from 'react';
import { Modal, Button } from 'antd';
import RichEditor from './RichEditor';

const AnchorTouchHistory: React.FC = () => {
  const editorRef = useRef<any>(null);
  const [isModalVisible, setIsModalVisible] = useState(false);
  const [isEditModalVisible, setIsEditModalVisible] = useState(false);
  const [contentHtml, setContentHtml] = useState('<p>heheda</p>' );

  const openAddModal = () => setIsModalVisible(true);
  const submitContent = () => {
    const content = editorRef.current?.getRichContent();
    console.log(content);
    setIsModalVisible(false);
    editorRef.current?.resetContent();
  };

  const openEditModal = () => setIsEditModalVisible(true);
  const submitEditContent = () => {
    const content = editorRef.current?.getRichContent();
    console.log(content);
    setIsEditModalVisible(false);
    editorRef.current?.resetContent();
  };

  return (
    <div>
      <Button onClick={openAddModal}>打开添加对话框</Button>
      <Modal
        visible={isModalVisible}
        onCancel={() => setIsModalVisible(false)}
        onOk={submitContent}
      >
        <RichEditor ref={editorRef} />
      </Modal>

      <Button onClick={openEditModal}>打开编辑对话框</Button>
      <Modal
        visible={isEditModalVisible}
        onCancel={() => setIsEditModalVisible(false)}
        onOk={submitEditContent}
      >
        <RichEditor ref={editorRef} initialContent={contentHtml} />
      </Modal>
    </div>
  );
};

export default AnchorTouchHistory;
  • RichEditor.tsx
javascript 复制代码
import React, { useState, useEffect, useRef, useMemo, useImperativeHandle, forwardRef } from 'react';
import ReactQuill, { Quill } from 'react-quill';
import 'react-quill/dist/quill.core.css';
import 'react-quill/dist/quill.snow.css';
import 'react-quill/dist/quill.bubble.css';

import { Modal, Input, Upload, Button, Tabs, Alert } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import COS from 'cos-js-sdk-v5';
import ImageResize from 'quill-image-resize-module-react';
import { getTxyCosConf } from '@/services/anchor-touch/history';

Quill.register('modules/imageResize', ImageResize);

import '@/styles/quillEditor.css';

const RichEditor = forwardRef(({ initialContent = '' }, ref) => {
  const [value, setValue] = useState(initialContent);
  const [isCosReady, setIsCosReady] = useState(false);
  const quillRef = useRef<any>(null);
  const [isModalVisible, setIsModalVisible] = useState(false);
  const [isLinkModalVisible, setIsLinkModalVisible] = useState(false);
  const [bucket, setBucket] = useState('');
  const [region, setRegion] = useState('');
  const [cos, setCos] = useState<COS | null>(null);
  const [width, setWidth] = useState('');
  const [height, setHeight] = useState('');
  const [previewUrl, setPreviewUrl] = useState<string | null>(null);
  const [currentFile, setCurrentFile] = useState<File | null>(null);
  const [originalWidth, setOriginalWidth] = useState<number | null>(null);
  const [originalHeight, setOriginalHeight] = useState<number | null>(null);
  const [imageUrl, setImageUrl] = useState('');
  const [uploadMode, setUploadMode] = useState<'local' | 'url'>('local');
  const [linkUrl, setLinkUrl] = useState('');
  const [linkText, setLinkText] = useState('');
  const [urlError, setUrlError] = useState('');

  useImperativeHandle(ref, () => ({
    getRichContent: () => value,
    resetContent: () => setValue(initialContent)
  }));

  useEffect(() => {
    const fetchCosConfig = async () => {
      try {
        const response = await getTxyCosConf();
        setBucket(response.data.bucket);
        setRegion(response.data.region);
        const cosInstance = new COS({
          SecretId: response.data.secretid,
          SecretKey: response.data.secretkey,
        });
        setCos(cosInstance);
        setIsCosReady(true);
      } catch (error) {
        console.error('获取 COS 配置失败:', error);
      }
    };

    fetchCosConfig();
  }, []);

  const showImageUploadModal = () => {
    setIsModalVisible(true);
  };

  const showLinkModal = () => {
    setIsLinkModalVisible(true);
  };

  const handleLinkOk = () => {
    if (!linkUrl.startsWith('http://') && !linkUrl.startsWith('https://')) {
      setUrlError('链接地址格式不正确,请输入有效的链接地址。');
      return;
    }

    const editor = quillRef.current?.getEditor();
    const range = editor?.getSelection()?.index || 0;

    editor?.insertText(range, linkText, 'link', linkUrl);
    editor?.setSelection(range + linkText.length);
    handleLinkCancel();
  };

  const handleLinkCancel = () => {
    setIsLinkModalVisible(false);
    setLinkUrl('');
    setLinkText('');
    setUrlError('');
  };

  const handleOk = () => {
    if (uploadMode === 'local') {
      if (!currentFile || !cos) {
        handleCancel();
        return;
      }

      const uniqueFileName = `${Date.now()}_${currentFile.name}`;

      cos.uploadFile(
        {
          Bucket: bucket,
          Region: region,
          Key: uniqueFileName,
          Body: currentFile,
          SliceSize: 1024 * 1024,
        },
        (err, data) => {
          if (err) {
            console.error('上传失败:', err);
          } else {
            const imageUrl = `https://${data.Location}`;
            insertImageToEditor(imageUrl);
          }
        }
      );
    } else {
      insertImageToEditor(imageUrl);
    }
  };

  const insertImageToEditor = (imageUrl: string) => {
    const editor = quillRef.current?.getEditor();
    const range = editor?.getSelection()?.index || 0;

    editor?.insertEmbed(range, 'image', imageUrl);
    editor?.formatText(range, 1, {
      width: width ? width : undefined,
      height: height ? height : undefined,
    });
    editor?.setSelection(range + 1);
    handleCancel();
    console.log('图片的链接为: ', imageUrl);
  };

  const handleCancel = () => {
    setIsModalVisible(false);
    setPreviewUrl(null);
    setCurrentFile(null);
    setWidth('');
    setHeight('');
    setImageUrl('');
  };

  const beforeUpload = (file: File) => {
    if (!file.type.startsWith('image/')) {
      console.error('不是有效的图像文件');
      return false;
    }

    const reader = new FileReader();
    reader.onload = (e) => {
      const preview = e.target?.result as string;
      setPreviewUrl(preview);
      setCurrentFile(file);

      const img = new Image();
      img.onload = () => {
        console.log('Image loaded:', img.naturalWidth, img.naturalHeight);
        setOriginalWidth(img.naturalWidth);
        setOriginalHeight(img.naturalHeight);
        setWidth(img.naturalWidth.toString());
        setHeight(img.naturalHeight.toString());
      };
      img.onerror = (error) => {
        console.error('图像加载失败:', error);
      };
      img.src = preview;
    };

    reader.onerror = (error) => {
      console.error('文件读取失败:', error);
    };

    reader.readAsDataURL(file);
    return false;
  };

  const handleWidthBlur = () => {
    const widthValue = parseFloat(width);
    if (isNaN(widthValue)) {
      console.error('Invalid width: ', width);
      return;
    }
    if (originalWidth && originalHeight && widthValue > 0) {
      const calculatedHeight = (widthValue / originalWidth) * originalHeight;
      setHeight(calculatedHeight.toFixed(0).toString());
    }
  };

  const handleHeightBlur = () => {
    const heightValue = parseFloat(height);
    if (originalWidth && originalHeight && heightValue > 0) {
      const calculatedWidth = (heightValue / originalHeight) * originalWidth;
      setWidth(calculatedWidth.toFixed(0).toString());
    }
  };

  const handleLinkUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const url = e.target.value;
    setLinkUrl(url);

    if (url.startsWith('http://') || url.startsWith('https://')) {
      setUrlError('');
    } else if (url) {
      setUrlError('链接地址格式不正确,请输入有效的链接地址。');
    }
  };

  const sizes = [false, '14px', '16px', '18px', '20px', '22px', '26px', '28px', '30px'];
  const Size = Quill.import('formats/size');
  Size.whitelist = sizes;

  const fonts = ['SimSun', 'SimHei', 'Microsoft-YaHei', 'KaiTi', 'FangSong', 'Arial', 'Times-New-Roman', 'sans-serif'];
  const Font = Quill.import('formats/font');
  Font.whitelist = fonts;
  Quill.register(Font, true);

  const modules = useMemo(() => ({
    toolbar: {
      container: [
        ['bold', 'italic', 'underline'],
        [{ size: sizes }],
        [{ header: [1, 2, 3, 4, 5, false] }],
        [{ color: [] }, { background: [] }],
        ['link', 'image', 'clean']
      ],
      handlers: {
        image: showImageUploadModal,
        link: showLinkModal,
      },
    },
    imageResize: {
      modules: ['DisplaySize'],
      handleStyles: {
        backgroundColor: 'transparent',
        border: 'none',
      },
      resizeWidth: false,
    },
  }), [cos]);

  const formats = [
    'font',
    'header',
    'size',
    'bold',
    'italic',
    'underline',
    'strike',
    'list',
    'bullet',
    'link',
    'image',
    'width',
    'height',
    'color', // include color format
    'background', // include background color format
  ];

  if (!isCosReady) {
    return <div>加载中...</div>;
  }

  return (
    <>
      <ReactQuill
        ref={quillRef}
        value={value}
        onChange={setValue}
        modules={modules}
        formats={formats}
      />
      {/* Modes for Image and Link Modals */}
      <Modal
        title="插入图片"
        visible={isModalVisible}
        onCancel={handleCancel}
        footer={null}
      >
        <Tabs defaultActiveKey="local" onChange={setUploadMode}>
          <Tabs.TabPane tab="本地图片" key="local">
            <Upload beforeUpload={beforeUpload} showUploadList={false}>
              <Button icon={<UploadOutlined />}>选择图片</Button>
            </Upload>
            {previewUrl && (
              <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginTop: 10, width: 150, height: 150, overflow: 'hidden', border: '1px solid #e8e8e8' }}>
                <img src={previewUrl} alt="预览" style={{ width: 150, maxHeight: '100%' }} />
              </div>
            )}
          </Tabs.TabPane>
          <Tabs.TabPane tab="链接图片" key="url">
            <Input placeholder="图片链接" value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} onBlur={() => {
              const img = new Image();
              img.onload = () => {
                setOriginalWidth(img.naturalWidth);
                setOriginalHeight(img.naturalHeight);
                setWidth(img.naturalWidth.toString());
                setHeight(img.naturalHeight.toString());
                setPreviewUrl(imageUrl);
              };
              img.onerror = (error) => {
                console.error('图像加载失败:', error);
                setPreviewUrl(null);
              };
              img.src = imageUrl;
            }} />
            {previewUrl && (
              <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginTop: 10, width: 150, height: 150, overflow: 'hidden', border: '1px solid #e8e8e8' }}>
                <img src={previewUrl} alt="预览" style={{ width: 150, maxHeight: '100%' }} />
              </div>
            )}
          </Tabs.TabPane>
        </Tabs>
        <Input placeholder="设置宽度" value={width} onChange={(e) => setWidth(e.target.value)} onBlur={handleWidthBlur} style={{ marginTop: 10 }} />
        <Input placeholder="设置高度" value={height} onChange={(e) => setHeight(e.target.value)} onBlur={handleHeightBlur} style={{ marginTop: 10 }} />
        <div style={{ marginTop: 10, textAlign: 'right' }}>
          <Button type="primary" onClick={handleOk} disabled={uploadMode === 'local' ? !currentFile : !imageUrl}>确认</Button>
          <Button onClick={handleCancel} style={{ marginLeft: 10 }}>取消</Button>
        </div>
      </Modal>

      <Modal title="添加链接" visible={isLinkModalVisible} onCancel={handleLinkCancel} onOk={handleLinkOk}>
        {urlError && <Alert message={urlError} type="error" />}
        <Input placeholder="链接地址" value={linkUrl} onChange={handleLinkUrlChange} style={{ marginBottom: 10 }} />
        <Input placeholder="备注" value={linkText} onChange={(e) => setLinkText(e.target.value)} />
      </Modal>
    </>
  );
});

export default RichEditor;
  • quillEditor.css
css 复制代码
/* 字体风格 */
/* 处理下拉字体选择器中选项的文本溢出并显示省略号 */
.ql-snow .ql-picker.ql-font .ql-picker-label::before {
  width: 88px; /* 设置下拉选项宽度,可以根据需要调整 */
  white-space: nowrap; /* 不换行显示 */
  overflow: hidden; /* 隐藏溢出部分 */
  text-overflow: ellipsis; /* 使用省略号显示溢出文本 */
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="SimSun"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="SimSun"]::before {
  content: "宋体";
  font-family: "SimSun";
}

.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="SimHei"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="SimHei"]::before {
  content: "黑体";
  font-family: "SimHei";
}

.ql-snow
.ql-picker.ql-font
.ql-picker-label[data-value="Microsoft-YaHei"]::before,
.ql-snow
.ql-picker.ql-font
.ql-picker-item[data-value="Microsoft-YaHei"]::before {
  content: "微软雅黑";
  font-family: "Microsoft YaHei";
}

.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="KaiTi"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="KaiTi"]::before {
  content: "楷体";
  font-family: "KaiTi";
}

.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="FangSong"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="FangSong"]::before {
  content: "仿宋";
  font-family: "FangSong";
}

.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="Arial"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="Arial"]::before {
  content: "Arial";
  font-family: "Arial";
}

.ql-snow
.ql-picker.ql-font
.ql-picker-label[data-value="Times-New-Roman"]::before,
.ql-snow
.ql-picker.ql-font
.ql-picker-item[data-value="Times-New-Roman"]::before {
  content: "Times New Roman";
  font-family: "Times New Roman";
}

.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="sans-serif"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="sans-serif"]::before {
  content: "sans-serif";
  font-family: "sans-serif";
}

.ql-font-SimSun { font-family: "SimSun"; }
.ql-font-SimHei { font-family: "SimHei"; }
.ql-font-Microsoft-YaHei { font-family: "Microsoft YaHei"; }
.ql-font-KaiTi { font-family: "KaiTi"; }
.ql-font-FangSong { font-family: "FangSong"; }
.ql-font-Arial { font-family: "Arial"; }
.ql-font-Times-New-Roman { font-family: "Times New Roman"; }
.ql-font-sans-serif { font-family: "sans-serif"; }

/* 字体大小 */
.ql-snow .ql-picker.ql-size .ql-picker-label::before { content: "字体大小"; }
.ql-snow .ql-picker.ql-size .ql-picker-item::before { content: "常规"; }
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="14px"]::before{
  content: "14px";
  font-size: 14px;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="16px"]::before{
  content: "16px";
  font-size: 14px;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="18px"]::before{
  content: "18px";
  font-size: 14px;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="20px"]::before{
  content: "20px";
  font-size: 14px;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="22px"]::before{
  content: "22px";
  font-size: 14px;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="26px"]::before{
  content: "26px";
  font-size: 14px;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="30px"]::before {
  content: "30px";
  font-size: 14px;
}

.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="14px"]::before {
  content: "14px";
  font-size: 14px;
}

.ql-size-14px { font-size: 14px; }

/* .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="16px"]::before, */
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="16px"]::before {
  content: "16px";
  font-size: 16px;
}

.ql-size-16px { font-size: 16px; }

/* .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="18px"]::before, */
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="18px"]::before {
  content: "18px";
  font-size: 18px;
}

.ql-size-18px { font-size: 18px; }

/* .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="20px"]::before, */
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="20px"]::before {
  content: "20px";
  font-size: 20px;
}

.ql-size-20px { font-size: 20px; }

/* .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="22px"]::before, */
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="22px"]::before {
  content: "22px";
  font-size: 22px;
}

.ql-size-22px { font-size: 22px; }

/* .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="26px"]::before, */
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="26px"]::before {
  content: "26px";
  font-size: 26px;
}

.ql-size-26px { font-size: 26px; }

/* .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="28px"]::before, */
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="28px"]::before {
  content: "28px";
  font-size: 28px;
}

.ql-size-28px { font-size: 28px; }

/* .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="30px"]::before, */
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="30px"]::before {
  content: "30px";
  font-size: 30px;
}

.ql-size-30px { font-size: 30px; }

/* 段落大小 */
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before {
  content: "标题1";
}

.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="2"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before {
  content: "标题2";
}

.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="3"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before {
  content: "标题3";
}

.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="4"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before {
  content: "标题4";
}

.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="5"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before {
  content: "标题5";
}

.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="6"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before {
  content: "标题6";
}

.ql-snow .ql-picker.ql-header .ql-picker-item::before {
  content: "常规";
}

/* .ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]::before, */
.ql-snow .ql-picker.ql-header .ql-picker-label::before {
  content: "标题大小";
}

/* 默认设置 */
.ql-snow .ql-editor { font-size: 14px; }
/* 查看样式 */
.view-editor .ql-toolbar { display: none; }
.view-editor .ql-container.ql-snow { border: 0; }
.view-editor .ql-container.ql-snow .ql-editor { padding: 0; }
/* 编辑样式 */
.edit-editor .ql-toolbar { display: block; }
.edit-editor .ql-container.ql-snow {
  border: 1px solid #ccc;
  min-height: inherit;
}
  • golang后端接口,获取

    复制代码
    TxyCosConf:
      SecretId: 'xxxxx'
      SecretKey: 'xxxxx'
      Bucket: 'xxxxx'
      Region: 'xxxx'
Go 复制代码
import {request} from "@@/plugin-request/request";
export function getTxyCosConf() {
  return request('/api/v1/xxxx/getTxyCosConf', {
    method: 'get',
  })
    .then(response => {
      return response;
    })
    .catch(error => {
      console.error('Error get data:', error);
      throw error;
    });
}
相关推荐
咖啡の猫38 分钟前
Shell脚本-for循环应用案例
前端·chrome
百万蹄蹄向前冲3 小时前
Trae分析Phaser.js游戏《洋葱头捡星星》
前端·游戏开发·trae
朝阳5814 小时前
在浏览器端使用 xml2js 遇到的报错及解决方法
前端
GIS之路4 小时前
GeoTools 读取影像元数据
前端
ssshooter4 小时前
VSCode 自带的 TS 版本可能跟项目TS 版本不一样
前端·面试·typescript
你的人类朋友4 小时前
【Node.js】什么是Node.js
javascript·后端·node.js
Jerry5 小时前
Jetpack Compose 中的状态
前端
dae bal6 小时前
关于RSA和AES加密
前端·vue.js
柳杉6 小时前
使用three.js搭建3d隧道监测-2
前端·javascript·数据可视化
lynn8570_blog6 小时前
低端设备加载webp ANR
前端·算法