1.引入相关插件
yarn add react-quill
yarn add quill-image-resize-module
我下载的版本是
"quill-image-resize-module": "^3.0.0",
"react-quill": "^2.0.0",
2项目引入
话不多说直接上效果,上代码【自行查找自己查找的代码吧】
import React, { PureComponent, Component } from 'react';
import ReactQuill, { Quill } from 'react-quill';
import { each, isArray, isEqual, isObject, map } from 'lodash';
import 'react-quill/dist/quill.snow.css';
import 'react-quill/dist/quill.bubble.css';
import { services } from '@comall-backend-builder/core';
// @ts-ignore
import loadingUrl from './loading.gif';
import { message, Modal, Tooltip as AntTooltip } from 'antd';
import { ApiRequestConfig, ApiUploadConfig } from '@comall-backend-builder/core/lib/services/api';
import { VideoModalContent } from '../../../../components/rich-text-plus/video-modal-content';
import { language } from '@comall-backend-builder/core/lib/services';
export interface EditorModulesProps {
/**
* 工具栏id
*/
toolbarId: string;
}
export interface EditorProps {
/**
* 图片提示
*/
imagePlaceholder: string;
/**
* 图片上传配置
*/
uploadConfig: ApiRequestConfig & ApiUploadConfig;
/**
* 当前值
*/
value: string;
className?: string;
style?: React.CSSProperties;
/**
* 占位提示
*/
placeholder?: string;
/**
* 是否禁用
*/
disabled?: boolean;
/**
* 输入组件的 name,作为该输入组件在其所属表单内的唯一识别符
*/
name: string;
/**
* 内容改变回调
* @param value 新值
* @param name 输入组件的 name,作为该输入组件在其所属表单内的唯一识别符
*/
onChange: (value: string, name: string) => void;
}
declare var window: Window & { Quill: any };
const SIZE_LIST = [
'10px',
'12px',
'14px',
'16px',
'18px',
'20px',
'22px',
'24px',
'26px',
'28px',
'30px',
'32px',
'34px',
'36px',
];
const richTextFormats = [
'header',
'bold',
'italic',
'underline',
'strike',
'blockquote',
'code-block',
'list',
'bullet',
'script',
'indent',
'direction',
'size',
'color',
'background',
'font',
'align',
'link',
'image',
'video',
'hr',
];
const modules = [
[{ header: [1, 2, 3, 4, 5, 6, 0] }],
['bold', 'italic', 'underline', 'strike'],
['blockquote', 'code-block'],
[{ header: '1' }, { header: '2' }],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ script: 'sub' }, { script: 'super' }],
[{ indent: '-1' }, { indent: '+1' }],
[{ direction: 'rtl' }],
[{ size: SIZE_LIST }],
[{ color: [] }, { background: [] }],
[{ font: [] }],
[{ align: [] }],
['link', 'image'],
['video'],
['clean'],
];
//quill的调整图片大小模块需要全局的quill变量
window.Quill = Quill;
const ImageResize = require('quill-image-resize-module');
Quill.register('modules/ImageResize', ImageResize);
const Size = Quill.import('attributors/style/size');
Size.whitelist = SIZE_LIST;
Quill.register(Size, true);
const BlockEmbed = Quill.import('blots/block/embed');
class DividerBlot extends BlockEmbed {
static blotName = 'hr';
static tagName = 'hr';
}
Quill.register(DividerBlot);
function getObjectFirstKey(object: any) {
return Reflect.ownKeys(object)[0];
}
let id = 0;
function generateToolbarId() {
const toolbarId = 'toolbar-' + id;
id++;
return toolbarId;
}
class EditorModules extends PureComponent<EditorModulesProps> {
/**
* 渲染具有下拉选项的toolbar
* @param {String} keyName quill中的toolbar名称,需要渲染成ql-{keyName}的格式
* @param {Array} value
* @return {ReactElement}
*/
static renderSelectableBar(keyName: string, value: any) {
return (
<span>
<select className={`ql-${keyName}`}>
{map(value, (option, optionIndex) => (
<option key={optionIndex} value={option} />
))}
</select>
</span>
);
}
/**
* 渲染format中的每一个toolbar以及其对应的tooltip
* @param {Object|String} format
* @param {Number} toolIndex
* @return {ReactElement}
*/
static renderToolBarWithTip(format: any, toolIndex: string) {
let renderedComponent;
let keyName = '';
let tipKey = '';
if (!isObject(format)) {
keyName = format;
tipKey = format;
//最简单的字符串,渲染成指定class的button
renderedComponent = <button className={`ql-${keyName}`} />;
} else {
keyName = getObjectFirstKey(format) as string;
// @ts-ignore
const value = format[keyName];
if (isArray(value)) {
tipKey = keyName;
//对象值为数组时,渲染成指定class的下拉bar
renderedComponent = EditorModules.renderSelectableBar(keyName, value);
} else {
tipKey = `${keyName}${value}`;
//对象值为非数组时,渲染成指定class并带有特定值的button
renderedComponent = <button className={`ql-${keyName}`} value={value} />;
}
}
const TOOLTIPS = services.language.getText('components.RichText.tooltips');
// @ts-ignore
const title = TOOLTIPS[tipKey];
return (
<AntTooltip key={toolIndex} title={title}>
{renderedComponent}
</AntTooltip>
);
}
/**
* 根据modules中的每一项渲染一组format,
* @return {Array<ReactElement>}
* @param {Array} formats
*/
static renderFormats(formats: any) {
return map(formats, (format, toolIndex) =>
EditorModules.renderToolBarWithTip(format, toolIndex)
);
}
render() {
const { toolbarId } = this.props;
return (
<div id={toolbarId}>
{map(modules, (formats, formatIndex) => (
<span key={formatIndex} className="ql-formats">
{EditorModules.renderFormats(formats)}
</span>
))}
</div>
);
}
}
export class RichTextNew extends Component<EditorProps> {
quillRef: any;
formats: string[];
toolbarId: string;
richTextModules: {
[key: string]: any;
};
static defaultProps = {
theme: 'snow',
disabled: false,
imagePlaceholder: loadingUrl,
};
createQuillRef = (e: any) => (this.quillRef = e);
/**
* 富文本上传图片时默认是base64编码,需要将其进行文件上传,并根据上传返回的图片地址显示在富文本中
*/
imageHandler = () => {
let { imagePlaceholder, uploadConfig } = this.props;
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.setAttribute('class', 'hide');
input.setAttribute('multiple', 'multiple');
document.body.appendChild(input);
input.click();
const editor = this.quillRef.getEditor();
input.onchange = (event) => {
const target = event.target as HTMLInputElement;
const files = target.files;
const range = editor.getSelection(true);
let success = 0;
let finish = 0;
//防止清空loading图后索引变化导致删除的位置错误,此处记录并在请求结束后统一进行删除
let errorIndexs: any = [];
each(files, (_file, index) => {
const cursor = index + range.index;
//在此放置一个过渡用内容,暂时只支持图片形式的过渡
editor.insertEmbed(cursor, 'image', imagePlaceholder);
editor.setSelection(cursor + 1);
services.api
.upload({ files: files![index] }, uploadConfig)
.then((response) => {
editor.deleteText(cursor, 1);
editor.insertEmbed(cursor, 'image', response.path);
success++;
})
.catch(() => {
//请求失败后也要清空loading图,防止误解仍在上传中。
errorIndexs.push(cursor);
})
.finally(() => {
finish++;
if (finish >= files!.length) {
//删除时需要从大到小的进行删除,所以先排序
each(
errorIndexs.sort((before: any, after: any) => after - before),
(errorIndex) => {
editor.deleteText(errorIndex, 1);
}
);
//为了确保所有请求完毕后,光标处于正确的位置
editor.setSelection(range.index + success);
}
});
});
};
document.body.removeChild(input);
};
videoUrl: string | undefined;
onVideoUrlChange = (url?: string) => {
this.videoUrl = url;
};
clearVideoUrl = () => {
this.videoUrl = undefined;
};
handleVideo = () => {
return new Promise((resolve, reject) => {
if (!this.videoUrl) {
message.warning(language.getText('videoAddressOrUploadVideo'));
reject();
return;
}
const editor = this.quillRef.getEditor();
const range = editor.getSelection(true);
const cursor = range.index;
editor.insertEmbed(cursor, 'video', this.videoUrl);
editor.setSelection(cursor + 2);
resolve(null);
}).finally(() => {
this.clearVideoUrl();
});
};
videoHandler = () => {
Modal.confirm({
content: <VideoModalContent onChange={this.onVideoUrlChange} />,
icon: null,
okText: language.getText('common.ok'),
cancelText: language.getText('common.cancel'),
title: language.getText('uploadVideo'),
onCancel: this.clearVideoUrl,
onOk: this.handleVideo,
});
};
constructor(props: any) {
super(props);
this.formats = richTextFormats;
this.toolbarId = generateToolbarId();
this.handleChange = this.handleChange.bind(this);
this.richTextModules = {
imageResize: {},
toolbar: {
container: `#${this.toolbarId}`,
handlers: {
image: this.imageHandler,
video: this.videoHandler,
},
},
};
}
handleChange(html: any) {
const { onChange, name } = this.props;
if (onChange) {
onChange(html, name);
}
}
shouldComponentUpdate(nextProps: any, _nextState: any) {
return !isEqual(this.props, nextProps);
}
render() {
const {
value,
placeholder = services.language.getText('common.pleaseInput'),
disabled,
className,
style,
} = this.props;
let props = {
theme: disabled ? 'bubble' : 'snow',
onChange: this.handleChange,
modules: this.richTextModules,
formats: this.formats,
placeholder: placeholder,
readOnly: disabled,
className: className,
style: style,
value: value || '',
ref: this.createQuillRef,
};
return (
<div className="quill-editor">
<EditorModules toolbarId={this.toolbarId} />
<ReactQuill {...props} />
</div>
);
}
}
在开发中,遇到的问题有:
1.问题:历史数据中有hr标签,但是react-quill给自动过滤了,导致hr水平线丢失
解决方式:formats添加了hr,但并不能解决问题,还缺失Quill.register(DividerBlot);,添加上即可完美解决。(具体代码看上方)