记:自定义内容编辑器ReactQuill、Tinymce
基础库地址
React-quill原官网地址:https://zenoamaro.github.io/react-quill/
quill官网地址:https://quilljs.com/
最后有完整的代码copy
最后有完整的代码copy
最后有完整的代码copy
最后发现还是tinymce编辑器更好用一些,tinymce免费版除了基础功能,还支持预览、html代码复制、粘贴、模版选择等实用功能
目前换成Tinymce
Tinymce原官网地址:Tinymce
Tinymce github:GitHub - tinymce/tinymce: The world's #1 JavaScript library for rich text editing. Available for React, Vue and Angular
Tinymce推荐借鉴地址:http://tinymce.ax-z.cn/
最后有完整的Tinymce代码copy
最后有完整的Tinymce代码copy
最后有完整的Tinymce代码copy
预览图
2023-11-03-14-44-45-image.png
文章内容介绍
项目中需要使用富文本编辑器来编辑文章供前端展示使用,也百度了一系列富文本编辑,感觉大部分编辑器都比较古老,最终选择了quill编辑器(目前已经更新为Tinymce),显示效果跟微信小程序文章的编辑器样式类似,基础功能支持比较多,还可以支持自定义工具。
本文章主要介绍内容
1.自定义编辑器样式
2.自定义选择图片资源功能
3.添加自定义标题输入框,支持自定义标题内容
4.react-quill使用和quill编辑器使用方式
5.配合antd部分组件使用
原编辑样式
原编辑样式已经支持大部分场景了,功能也比较完善
2023-06-30-15-30-00-image.png
项目需要样式如下图所示,需要支持操作区、标题区、内容区域以及底部操作四部分内容展示。
2023-06-30-14-27-29-image.png
react依赖安装
//npm 安装
npm i react-quill --save
//yarn 安装
yarn add react-quill
//使用emoji 本项目没有表情使用场景
npm i quillEmoji --save
yarn add quillEmoji
项目引进组件
import React, { useEffect, useRef, useState } from 'react';
//引入React Quill组件
import ReactQuill, { Delta } from 'react-quill';
//引入组件snow样式
import 'react-quill/dist/quill.snow.css';
import styles from './index.less';
//组件
<ReactQuill
placeholder="请输入内容"
ref={quillRef}
modules={modules}
theme="snow"
value={value}
onChange={handleChange}
/>
工具栏定义方式
工具栏方式定义有两种,可以定义dom来定义工具栏,也可以直接使用modules的toolbar来配置
toolbar方式
this.modules = {
toolbar: {
container: [
[{ 'size': ['small', false, 'large', 'huge'] }], //字体设置
// [{ 'header': [1, 2, 3, 4, 5, 6, false] }], //标题字号,不能设置单个字大小
['bold', 'italic', 'underline', 'strike'],
[{ 'list': 'ordered' }, { 'list': 'bullet' }, { 'indent': '-1' }, { 'indent': '+1' }],
['link', 'image'], // a链接和图片的显示
[{ 'align': [] }],
[{
'background': ['rgb( 0, 0, 0)', 'rgb(230, 0, 0)', 'rgb(255, 153, 0)',
'rgb(255, 255, 0)', 'rgb( 0, 138, 0)', 'rgb( 0, 102, 204)',
'rgb(153, 51, 255)', 'rgb(255, 255, 255)', 'rgb(250, 204, 204)',
'rgb(255, 235, 204)', 'rgb(255, 255, 204)', 'rgb(204, 232, 204)',
'rgb(204, 224, 245)', 'rgb(235, 214, 255)', 'rgb(187, 187, 187)',
'rgb(240, 102, 102)', 'rgb(255, 194, 102)', 'rgb(255, 255, 102)',
'rgb(102, 185, 102)', 'rgb(102, 163, 224)', 'rgb(194, 133, 255)',
'rgb(136, 136, 136)', 'rgb(161, 0, 0)', 'rgb(178, 107, 0)',
'rgb(178, 178, 0)', 'rgb( 0, 97, 0)', 'rgb( 0, 71, 178)',
'rgb(107, 36, 178)', 'rgb( 68, 68, 68)', 'rgb( 92, 0, 0)',
'rgb(102, 61, 0)', 'rgb(102, 102, 0)', 'rgb( 0, 55, 0)',
'rgb( 0, 41, 102)', 'rgb( 61, 20, 10)']
}],
[{
'color': ['rgb( 0, 0, 0)', 'rgb(230, 0, 0)', 'rgb(255, 153, 0)',
'rgb(255, 255, 0)', 'rgb( 0, 138, 0)', 'rgb( 0, 102, 204)',
'rgb(153, 51, 255)', 'rgb(255, 255, 255)', 'rgb(250, 204, 204)',
'rgb(255, 235, 204)', 'rgb(255, 255, 204)', 'rgb(204, 232, 204)',
'rgb(204, 224, 245)', 'rgb(235, 214, 255)', 'rgb(187, 187, 187)',
'rgb(240, 102, 102)', 'rgb(255, 194, 102)', 'rgb(255, 255, 102)',
'rgb(102, 185, 102)', 'rgb(102, 163, 224)', 'rgb(194, 133, 255)',
'rgb(136, 136, 136)', 'rgb(161, 0, 0)', 'rgb(178, 107, 0)',
'rgb(178, 178, 0)', 'rgb( 0, 97, 0)', 'rgb( 0, 71, 178)',
'rgb(107, 36, 178)', 'rgb( 68, 68, 68)', 'rgb( 92, 0, 0)',
'rgb(102, 61, 0)', 'rgb(102, 102, 0)', 'rgb( 0, 55, 0)',
'rgb( 0, 41, 102)', 'rgb( 61, 20, 10)']
}],
['clean'], //清空
['emoji'], //emoji表情,设置了才能显示
['video2'], //我自定义的视频图标,和插件提供的不一样,所以设置为video2
],
handlers: {
'image': this.imageHandler.bind(this), //点击图片标志会调用的方法
'video2': this.showVideoModal.bind(this),
},
},
// ImageExtend: {
// loading: true,
// name: 'img',
// action: RES_URL + "connector?isRelativePath=true",
// response: res => FILE_URL + res.info.url
// },
ImageDrop: true,
'emoji-toolbar': true, //是否展示出来
"emoji-textarea": false, //我不需要emoji展示在文本框所以设置为false
"emoji-shortname": true,
}
编写dom引用方式
具体工具代码配置
<div id="toolbar">
<span className="ql-formats">
<span onClick={() => modalRef.current?.openModal('image')}>导入图片</span>
</span>
<span className="ql-formats">
<Tooltip title="加粗" placement="bottom">
<button className="ql-bold"></button>
</Tooltip>
<Tooltip title="斜体" placement="bottom">
<button className="ql-italic"></button>
</Tooltip>
<Tooltip title="下划线" placement="bottom">
<button className="ql-underline"></button>
</Tooltip>
<Tooltip title="删除线" placement="bottom">
<button className="ql-strike"></button>
</Tooltip>
</span>
<span className="ql-formats">
<Tooltip title="引用" placement="bottom">
<button className="ql-blockquote"></button>
</Tooltip>
<Tooltip title="公式" placement="bottom">
<button className="ql-formula"></button>
</Tooltip>
<Tooltip title="代码块" placement="bottom">
<button className="ql-code-block"></button>
</Tooltip>
</span>
<span className="ql-formats">
<Tooltip title="链接" placement="bottom">
<button className="ql-link"></button>
</Tooltip>
<Tooltip title="图片" placement="bottom">
<button className="ql-image"></button>
</Tooltip>
<Tooltip title="视频" placement="bottom">
<button className="ql-video"></button>
</Tooltip>
</span>
<span className="ql-formats">
<Tooltip title="一级标题" placement="bottom">
<button className="ql-header" value="1"></button>
</Tooltip>
<Tooltip title="二级标题" placement="bottom">
<button className="ql-header" value="2"></button>
</Tooltip>
</span>
<span className="ql-formats">
<Tooltip title="有序列表" placement="bottom">
<button className="ql-list" value="ordered"></button>
</Tooltip>
<Tooltip title="无序列表" placement="bottom">
<button className="ql-list" value="bullet"></button>
</Tooltip>
</span>
<span className="ql-formats">
<button className="ql-script" value="sub"></button>
<button className="ql-script" value="super"></button>
</span>
<span className="ql-formats">
<Tooltip title="减少缩进" placement="bottom">
<button className="ql-indent" value="-1"></button>
</Tooltip>
<Tooltip title="增加缩进" placement="bottom">
<button className="ql-indent" value="+1"></button>
</Tooltip>
</span>
<span className="ql-formats">
<Tooltip title="文字方向" placement="bottom">
<button className="ql-direction" value="rtl"></button>
</Tooltip>
</span>
<span className="ql-formats">
<select className="ql-align" defaultValue="">
<option value=""></option>
<option value="center"></option>
<option value="right"></option>
<option value="justify"></option>
</select>
</span>
<span className="ql-formats">
<select className="ql-font" defaultValue="sans-serif">
<option value="sans-serif">Sans Serif</option>
<option value="serif">Serif</option>
<option value="monospace">Monospace</option>
{/* <option value="fantasy">fantasy</option>
<option value="cuisive">cuisive</option> */}
</select>
</span>
<span className="ql-formats">
<select className="ql-size" defaultValue="">
<option value="small"></option>
<option value=""></option>
<option value="large"></option>
<option value="huge"></option>
</select>
{/* <select className="ql-header">
<option value="1">H1</option>
<option value="2">H2</option>
<option value="3">H3</option>
<option value="4">H4</option>
<option value="5">H5</option>
<option value="6">H6</option>
<option selected></option>
</select> */}
</span>
<span className="ql-formats">
<select className="ql-color" defaultValue="">
<option value=""></option>
<option value="#e60000"></option>
<option value="#ff9900"></option>
<option value="#ffff00"></option>
<option value="#008a00"></option>
<option value="#0066cc"></option>
<option value="#9933ff"></option>
<option value="#ffffff"></option>
<option value="#facccc"></option>
<option value="#ffebcc"></option>
<option value="#ffffcc"></option>
<option value="#cce8cc"></option>
<option value="#cce0f5"></option>
<option value="#ebd6ff"></option>
<option value="#bbbbbb"></option>
<option value="#f06666"></option>
<option value="#ffc266"></option>
<option value="#ffff66"></option>
<option value="#66b966"></option>
<option value="#66a3e0"></option>
<option value="#c285ff"></option>
<option value="#888888"></option>
<option value="#a10000"></option>
<option value="#b26b00"></option>
<option value="#b2b200"></option>
<option value="#006100"></option>
<option value="#0047b2"></option>
<option value="#6b24b2"></option>
<option value="#444444"></option>
<option value="#5c0000"></option>
<option value="#663d00"></option>
<option value="#666600"></option>
<option value="#003700"></option>
<option value="#002966"></option>
<option value="#3d1466"></option>
</select>
<select className="ql-background" defaultValue="">
<option value=""></option>
<option value="#000000"></option>
<option value="#e60000"></option>
<option value="#ff9900"></option>
<option value="#ffff00"></option>
<option value="#008a00"></option>
<option value="#0066cc"></option>
<option value="#9933ff"></option>
<option value="#facccc"></option>
<option value="#ffebcc"></option>
<option value="#ffffcc"></option>
<option value="#cce8cc"></option>
<option value="#cce0f5"></option>
<option value="#ebd6ff"></option>
<option value="#bbbbbb"></option>
<option value="#f06666"></option>
<option value="#ffc266"></option>
<option value="#ffff66"></option>
<option value="#66b966"></option>
<option value="#66a3e0"></option>
<option value="#c285ff"></option>
<option value="#888888"></option>
<option value="#a10000"></option>
<option value="#b26b00"></option>
<option value="#b2b200"></option>
<option value="#006100"></option>
<option value="#0047b2"></option>
<option value="#6b24b2"></option>
<option value="#444444"></option>
<option value="#5c0000"></option>
<option value="#663d00"></option>
<option value="#666600"></option>
<option value="#003700"></option>
<option value="#002966"></option>
<option value="#3d1466"></option>
</select>
</span>
<span className="ql-formats">
<Tooltip title="样式清除" placement="bottom">
<button className="ql-clean"></button>
</Tooltip>
</span>
</div>
自定义带标题输入框编辑器
代码如下
<Card className={styles.card}>
<Input
bordered={false}
placeholder="请输入标题"
value={title}
maxLength={10}
className={styles.titleInput}
onChange={e => setTitle(e.target.value)}></Input>
<ReactQuill
placeholder="请输入内容"
ref={quillRef}
modules={modules}
theme="snow"
value={value}
onChange={handleChange}
/>
</Card>
底部工具代码
<FooterToolbar>
<div className={styles.bottomBtn}>
<Space>
<Button type="primary" onClick={saveHandler}>
保存为草稿
</Button>
<Button onClick={confirmHandler}>确认无误,可上线使用</Button>
<Button onClick={cancelHandler}>取消</Button>
</Space>
<div
className={styles.textNumber}>
正文字数 {quillRef.current?.getEditor()?.getLength()-1 || 0}
</div>
</div>
</FooterToolbar>
自定义选择项目图片资源代码 2023-06-30-15-45-53-image.png
自定义选择图片资源弹框
<SelectSourceModal multi ref={modalRef} defaultType="image" callback={handleCallback} />
handleCallback回调处理
主要逻辑是通过获取到当前的编辑器,然后获取到当前光标位置,将光标位置+1然后插入一张图片资源
const handleCallback = (datas: SourceItemProps[]) => {
datas.forEach(item => {
try {
let quill = quillRef.current?.getEditor(); //获取到编辑器本身
// console.log(quill.getLength());
const cursorPosition = quill?.selection?.savedRange?.index || 0; //获取当前光标位置;
quill.insertEmbed(cursorPosition, 'image', item.url); //插入图片
quill.setSelection(cursorPosition + 1); //光标位置加1
// setImages([...images, item.url]);
if (_.findIndex(imageDatas.current, item.id) === -1) imageDatas.current.push(item.id);
console.log(imageDatas.current);
} catch (e) {
console.log(e);
message.error('资源引用失败,请重试');
}
});
};
完整代码
CreateArticle.tsx
import { Button, Card, Divider, Input, Modal, Skeleton, Space, Spin, Tooltip, message } from 'antd';
import React, { useEffect, useRef, useState } from 'react';
import ReactQuill, { Delta } from 'react-quill';
import 'react-quill/dist/quill.snow.css';
import styles from './index.less';
import { routerRedux, useDispatch, useLocation } from 'dva';
import { FooterToolbar } from '@ant-design/pro-layout';
import SelectSourceModal from '../components/SelectSourceModal';
import { SourceItemProps } from '../data';
import { DiscoverParams, addArticle, getArticle } from '@/services';
import { getUserInfo } from '@/utils/utils';
import _ from 'lodash';
const CreateArticle: React.FC = () => {
const modalRef = useRef<any>(null);
const quillRef = useRef<any>(null);
const userInfo = getUserInfo();
const [title, setTitle] = useState('');
const [value, setValue] = useState('');
const [loading, setLoading] = useState<boolean>(false);
// const [images, setImages] = useState([]);
const imageDatas = useRef([]);
const dispatch = useDispatch();
const { state: defaultData }: { state: DiscoverParams } = useLocation();
const handleCallback = (datas: SourceItemProps[]) => {
datas.forEach(item => {
try {
let quill = quillRef.current?.getEditor(); //获取到编辑器本身
// console.log(quill.getLength());
const cursorPosition = quill?.selection?.savedRange?.index || 0; //获取当前光标位置;
quill.insertEmbed(cursorPosition, 'image', item.url); //插入图片
quill.setSelection(cursorPosition + 1); //光标位置加1
// setImages([...images, item.url]);
if (_.findIndex(imageDatas.current, item.id) === -1) imageDatas.current.push(item.id);
console.log(imageDatas.current);
} catch (e) {
console.log(e);
message.error('资源引用失败,请重试');
}
});
};
const handleSave = (state: string) => {
// const images = _.map(imageDatas.current, data => data.id);
setLoading(true);
const images = imageDatas.current;
// console.log(imageDatas.current, images);
addArticle({
id: defaultData?.id || undefined,
title,
content: value,
state,
images,
user: userInfo.name,
})
.then(res => {
if (res.code === 0) {
message.success('保存成功');
// dispatch(routerRedux.goBack());
dispatch(routerRedux.push(`/ContentManagement/discover/article?tab=${state}`));
} else {
message.error('保存失败');
}
})
.finally(() => {
setLoading(false);
});
};
const saveHandler = () => {
if (!title) {
Modal.info({
title: '提示',
width: 600,
content: '请先输入标题,再点击保存按钮',
okText: '知道了',
});
return;
}
handleSave('draft');
};
const confirmHandler = () => {
if (!title || !value) {
Modal.info({
title: '提示',
width: 600,
content: '请保证标题和正文内容完整',
okText: '知道了',
});
return;
}
handleSave('prod');
};
const cancelHandler = () => {
if (title || value) {
Modal.confirm({
title: '提示',
okText: '确认',
width: 600,
cancelText: '取消',
content: (
<span>
取消后将
<span style={{ color: '#FF7F08' }}>
<b>丢失</b>
</span>
本页面的所有内容,请确认是否取消
</span>
),
onOk: () => {
dispatch(routerRedux.goBack());
},
onCancel: () => {},
});
return;
}
dispatch(routerRedux.goBack());
};
const handleChange = _.throttle((value: string) => {
setValue(value);
if (imageDatas.current.length > 0) {
imageDatas.current.forEach(data => {
if (value.indexOf(data) === -1) {
imageDatas.current = _.filter(imageDatas.current, img => img == data);
}
});
}
}, 500);
const modules = {
// toolbar: toolbarOptions,
toolbar: '#toolbar',
history: {
// Enable with custom configurations
delay: 2500,
userOnly: true,
},
};
useEffect(() => {
console.log(defaultData);
if (defaultData) {
console.log(defaultData, 'defaultData');
setTitle(defaultData?.title || '');
setValue(defaultData?.content || '');
getArticle(defaultData?.id)
.then(res => {
console.log('res', res);
setLoading(true);
if (res.code === 0) {
setTitle(res.result?.title || '');
setValue(res.result?.content || '');
imageDatas.current = res.result?.images || [];
}
})
.finally(() => {
setLoading(false);
});
}
}, []);
return (
<div className={styles.contentCreate}>
<Spin spinning={loading}>
<div id="toolbar">
<span className="ql-formats">
<span onClick={() => modalRef.current?.openModal('image')}>导入图片</span>
</span>
<span className="ql-formats">
<Tooltip title="加粗" placement="bottom">
<button className="ql-bold"></button>
</Tooltip>
<Tooltip title="斜体" placement="bottom">
<button className="ql-italic"></button>
</Tooltip>
<Tooltip title="下划线" placement="bottom">
<button className="ql-underline"></button>
</Tooltip>
<Tooltip title="删除线" placement="bottom">
<button className="ql-strike"></button>
</Tooltip>
</span>
<span className="ql-formats">
<Tooltip title="引用" placement="bottom">
<button className="ql-blockquote"></button>
</Tooltip>
<Tooltip title="公式" placement="bottom">
<button className="ql-formula"></button>
</Tooltip>
<Tooltip title="代码块" placement="bottom">
<button className="ql-code-block"></button>
</Tooltip>
</span>
<span className="ql-formats">
<Tooltip title="链接" placement="bottom">
<button className="ql-link"></button>
</Tooltip>
<Tooltip title="图片" placement="bottom">
<button className="ql-image"></button>
</Tooltip>
<Tooltip title="视频" placement="bottom">
<button className="ql-video"></button>
</Tooltip>
</span>
<span className="ql-formats">
<Tooltip title="一级标题" placement="bottom">
<button className="ql-header" value="1"></button>
</Tooltip>
<Tooltip title="二级标题" placement="bottom">
<button className="ql-header" value="2"></button>
</Tooltip>
</span>
<span className="ql-formats">
<Tooltip title="有序列表" placement="bottom">
<button className="ql-list" value="ordered"></button>
</Tooltip>
<Tooltip title="无序列表" placement="bottom">
<button className="ql-list" value="bullet"></button>
</Tooltip>
</span>
<span className="ql-formats">
<button className="ql-script" value="sub"></button>
<button className="ql-script" value="super"></button>
</span>
<span className="ql-formats">
<Tooltip title="减少缩进" placement="bottom">
<button className="ql-indent" value="-1"></button>
</Tooltip>
<Tooltip title="增加缩进" placement="bottom">
<button className="ql-indent" value="+1"></button>
</Tooltip>
</span>
<span className="ql-formats">
<Tooltip title="文字方向" placement="bottom">
<button className="ql-direction" value="rtl"></button>
</Tooltip>
</span>
<span className="ql-formats">
<select className="ql-align" defaultValue="">
<option value=""></option>
<option value="center"></option>
<option value="right"></option>
<option value="justify"></option>
</select>
</span>
<span className="ql-formats">
<select className="ql-font" defaultValue="sans-serif">
<option value="sans-serif">Sans Serif</option>
<option value="serif">Serif</option>
<option value="monospace">Monospace</option>
{/* <option value="fantasy">fantasy</option>
<option value="cuisive">cuisive</option> */}
</select>
</span>
<span className="ql-formats">
<select className="ql-size" defaultValue="">
<option value="small"></option>
<option value=""></option>
<option value="large"></option>
<option value="huge"></option>
</select>
{/* <select className="ql-header">
<option value="1">H1</option>
<option value="2">H2</option>
<option value="3">H3</option>
<option value="4">H4</option>
<option value="5">H5</option>
<option value="6">H6</option>
<option selected></option>
</select> */}
</span>
<span className="ql-formats">
<select className="ql-color" defaultValue="">
<option value=""></option>
<option value="#e60000"></option>
<option value="#ff9900"></option>
<option value="#ffff00"></option>
<option value="#008a00"></option>
<option value="#0066cc"></option>
<option value="#9933ff"></option>
<option value="#ffffff"></option>
<option value="#facccc"></option>
<option value="#ffebcc"></option>
<option value="#ffffcc"></option>
<option value="#cce8cc"></option>
<option value="#cce0f5"></option>
<option value="#ebd6ff"></option>
<option value="#bbbbbb"></option>
<option value="#f06666"></option>
<option value="#ffc266"></option>
<option value="#ffff66"></option>
<option value="#66b966"></option>
<option value="#66a3e0"></option>
<option value="#c285ff"></option>
<option value="#888888"></option>
<option value="#a10000"></option>
<option value="#b26b00"></option>
<option value="#b2b200"></option>
<option value="#006100"></option>
<option value="#0047b2"></option>
<option value="#6b24b2"></option>
<option value="#444444"></option>
<option value="#5c0000"></option>
<option value="#663d00"></option>
<option value="#666600"></option>
<option value="#003700"></option>
<option value="#002966"></option>
<option value="#3d1466"></option>
</select>
<select className="ql-background" defaultValue="">
<option value=""></option>
<option value="#000000"></option>
<option value="#e60000"></option>
<option value="#ff9900"></option>
<option value="#ffff00"></option>
<option value="#008a00"></option>
<option value="#0066cc"></option>
<option value="#9933ff"></option>
<option value="#facccc"></option>
<option value="#ffebcc"></option>
<option value="#ffffcc"></option>
<option value="#cce8cc"></option>
<option value="#cce0f5"></option>
<option value="#ebd6ff"></option>
<option value="#bbbbbb"></option>
<option value="#f06666"></option>
<option value="#ffc266"></option>
<option value="#ffff66"></option>
<option value="#66b966"></option>
<option value="#66a3e0"></option>
<option value="#c285ff"></option>
<option value="#888888"></option>
<option value="#a10000"></option>
<option value="#b26b00"></option>
<option value="#b2b200"></option>
<option value="#006100"></option>
<option value="#0047b2"></option>
<option value="#6b24b2"></option>
<option value="#444444"></option>
<option value="#5c0000"></option>
<option value="#663d00"></option>
<option value="#666600"></option>
<option value="#003700"></option>
<option value="#002966"></option>
<option value="#3d1466"></option>
</select>
</span>
<span className="ql-formats">
<Tooltip title="样式清除" placement="bottom">
<button className="ql-clean"></button>
</Tooltip>
</span>
</div>
<Divider className={styles.line}></Divider>
<Card className={styles.card}>
<Input
bordered={false}
placeholder="请输入标题"
value={title}
maxLength={10}
className={styles.titleInput}
onChange={e => setTitle(e.target.value)}></Input>
<ReactQuill
placeholder="请输入内容"
ref={quillRef}
modules={modules}
theme="snow"
value={value}
onChange={handleChange}
/>
</Card>
<FooterToolbar>
<div className={styles.bottomBtn}>
<Space>
<Button type="primary" onClick={saveHandler}>
保存为草稿
</Button>
<Button onClick={confirmHandler}>确认无误,可上线使用</Button>
<Button onClick={cancelHandler}>取消</Button>
</Space>
<div
className={styles.textNumber}>
正文字数 {quillRef.current?.getEditor()?.getLength()-1 || 0}
</div>
</div>
</FooterToolbar>
<SelectSourceModal multi ref={modalRef} defaultType="image" callback={handleCallback} />
</Spin>
</div>
);
};
export default CreateArticle;
SelectSourceModal.tsx
import { Button, Empty, List, Modal, message } from 'antd';
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import { SourceItemProps, audios, pictures, videos } from '../data';
import styles from '../index.less';
import _ from 'lodash';
import { getSource } from '@/services';
import SourceCard from './SourceCard';
type SelectType = 'image' | 'audio' | 'video';
interface SelectSourceModalProps {
ref: any;
multi?: boolean;
defaultType: SelectType;
callback?: (data: SourceItemProps[], type?: string) => void;
}
const SelectSourceModal: React.FC<SelectSourceModalProps> = forwardRef(
({ defaultType = 'image', multi = false, callback }, ref) => {
const [open, setOpen] = useState<boolean>(false);
const [dataSource, setDataSource] = useState<SourceItemProps[]>([]);
const [type, setType] = useState<SelectType>(defaultType);
const [loading, setLoading] = useState<boolean>(false);
const [page, setPage] = useState<number>(1);
const [total, setTotal] = useState<number>(0);
const selectData = _.filter(dataSource, item => item.checked);
const getSourceList = (page: number) => {
setLoading(true);
getSource({ type: type || 'image', numPage: page })
.then(res => {
if (res && res.code === 0) {
const { data = [], page_info } = res?.result || {};
setTotal(page_info?.total_items);
setPage(page_info?.current_page);
const filesArr = data.map(item => {
return {
id: item.id,
key: item.id,
url: item.site,
name: item.name,
tag: item.tag,
type: item.type,
isNet: true,
} as SourceItemProps;
});
if (page === 1) setDataSource(filesArr);
else setDataSource(dataSource.concat(filesArr));
}
})
.finally(() => {
setLoading(false);
});
};
const onConfirm = () => {
const selectData = _.filter(dataSource, item => item.checked);
if (selectData.length === 0) {
message.error('请选择素材');
return;
}
callback(selectData, type || defaultType);
setOpen(false);
};
useImperativeHandle(ref, () => ({
openModal: (type: SelectType) => {
console.log('type', type);
setType(type || 'image');
setOpen(true);
},
}));
const handleChoose = (checked: boolean, item: SourceItemProps) => {
if (multi) {
const index = _.findIndex(dataSource, item);
dataSource[index].checked = checked;
setDataSource([...dataSource]);
} else {
const selectIndex = _.findIndex(dataSource, { checked: true });
if (selectIndex > -1) dataSource[selectIndex].checked = false;
const index = _.findIndex(dataSource, item);
if (index > -1) dataSource[index].checked = checked;
setDataSource([...dataSource]);
}
};
const loadMore =
!loading && total > dataSource.length ? (
<div
style={{
textAlign: 'center',
marginTop: 12,
height: 32,
lineHeight: '32px',
}}>
<Button onClick={() => getSourceList(page + 1)}>加载更多</Button>
</div>
) : null;
useEffect(() => {
if (open) getSourceList(1);
}, [open]);
return (
<Modal
title={
(type === 'image' ? '选择图片' : type === 'audio' ? '选择音频' : '选择视频') +
(multi ? `(已选${selectData.length})` : `(已选${selectData.length}/1)`)
}
centered
open={open}
onOk={onConfirm}
destroyOnClose={true}
onCancel={() => setOpen(false)}
width={1200}>
{dataSource && dataSource.length > 0 ? (
<List
style={{ maxHeight: '600px', overflow: 'auto' }}
loadMore={loadMore}
grid={{
gutter: 16,
xs: 2,
sm: 3,
md: 3,
lg: 4,
xl: 4,
xxl: 4,
}}
dataSource={dataSource}
renderItem={(item, index) => (
<List.Item key={item?.name + index}>
<SourceCard type={type} onChoose={handleChoose} item={item} mode="choose" />
</List.Item>
)}
/>
) : (
<Empty
description="暂无图片"
className={styles.empty}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
)}
</Modal>
);
}
);
export default SelectSourceModal;
index.less
.contentCreate {
// position: absolute;
// top: 50px;
// bottom: 0;
// left: 0;
// right: 0;
// display: flex;
background-color: #F5F5F5;
width: 100%;
height: 100%;
display: flex;
overflow: hidden;
flex-direction: column;
// justify-content: center;
// #toolbar {
// display: inline-block;
// }
:global {
.ant-spin-nested-loading {
width: 100%;
height: 100%;
overflow: hidden;
}
.ant-spin-container {
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
}
.card {
flex: 1;
border-radius: 6px;
width: 70%;
overflow: auto;
margin: 0 auto;
padding-bottom: 60px;
}
.titleInput {
font-size: 24px;
}
.titleInput::placeholder {
font-size: 24px;
color: #949AAA;
}
:global {
.ql-snow .ql-tooltip {
left: 0px !important;
}
.ql-editor {
font-family: inherit !important;
padding: 10px !important;
font-size: 16px !important;
}
.ql-editor .ql-video {
min-width: 50%;
min-height: 300px;
}
.ql-editor.ql-blank::before {
font-style: normal !important;
color: #949AAA;
left: 10px;
height: 10px;
}
.ql-container.ql-snow {
border: 0px solid #fff !important;
}
.ql-toolbar.ql-snow {
margin: 0 auto !important;
padding: 12px 16px 0px 16px !important;
border: 0px solid #fff !important;
// min-width: 600px;
// border-bottom: 1px solid @border-color !important;
}
.ql-editor {
min-height: 600px;
}
}
}
小结
实现过程还是需要踩坑的,我本身也是尝试了很多遍最后才实现到最终的样子。
续 Tinymce示例代码
目前换成Tinymce
Tinymce原官网地址:Tinymce
Tinymce github:GitHub - tinymce/tinymce: The world's #1 JavaScript library for rich text editing. Available for React, Vue and Angular
Tinymce推荐借鉴地址:http://tinymce.ax-z.cn/
最后有完整的Tinymce代码copy
最后有完整的Tinymce代码copy
最后有完整的Tinymce代码copy
贴图
选择文件
2023-11-03-15-07-05-image.png 2023-11-03-15-07-26-image.png插入模版
2023-11-03-15-08-25-image.png预览
2023-11-03-15-08-52-image.png源代码
2023-11-03-15-09-13-image.pngTinymce源码
ArticleDetail.tsx
import {
Button,
Col,
Form,
Image,
Input,
InputNumber,
Modal,
Row,
Space,
Spin,
message,
} from 'antd';
import React, { useEffect, useRef, useState } from 'react';
import { routerRedux, useDispatch, useLocation } from 'dva';
import { FooterToolbar } from '@ant-design/pro-layout';
import { SourceItemProps } from '../data';
import { ResourceRequestType, getResourceDetail, getResourceList, postResource } from '@/services';
import _ from 'lodash';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { Editor as TinyMCEEditor } from 'public/tinymce/tinymce';
import { useForm } from 'antd/lib/form/Form';
import { Editor } from '@/components/TinymceEditor';
import styles from './index.less';
const ArticleDetail: React.FC = () => {
const [name, setName] = useState('');
const [desc, setDesc] = useState('');
const [content, setContent] = useState('');
const [loading, setLoading] = useState<boolean>(false);
const [tagsModalVisible, setTagsModalVisible] = useState<boolean>(false);
const [templateVisiable, setTemplateVisiable] = useState<boolean>(false);
const [sourceData, setSourceData] = useState<SourceItemProps>(null);
const [imageDatas, setImageDatas] = useState([]);
const dispatch = useDispatch();
const { state: defaultData }: { state: any } = useLocation();
const editorRef = useRef<TinyMCEEditor>(null);
const [inputValue, setInputValue] = useState(16);
const [form] = useForm();
const previewRef = useRef<any>(null);
const saveTemplate = () => {
const articleContent = editorRef.current?.getContent();
form.validateFields().then((values: any) => {
//写模版保存接口
postResource({
name,
type: 'article_template',
metadata: {
content: articleContent,
name: values?.name,
desc: values?.desc,
type: 'article_template',
},
reference: {},
}).then(res => {
if (res?.code === 0) {
message.success('模版保存成功');
setTemplateVisiable(false);
} else {
message.error('模版保存失败,请重试');
}
});
});
};
const handleSave = (preview?: boolean) => {
const articleContent = editorRef.current?.getContent();
if (!name || !articleContent) {
Modal.info({
title: '提示',
content: '请先填写文章标题和内容',
okText: '知道了',
});
return;
}
//文章内容保存位置
setLoading(true);
const images = _.filter(imageDatas, item => articleContent.indexOf(item?.value) > -1)?.map(
item => item.id
);
console.log('images', articleContent, imageDatas, images);
const data = {
id: defaultData?.id || undefined,
name,
type: defaultData?.type || 'article',
metadata: {
content: articleContent,
name,
padding: inputValue,
desc,
type: defaultData?.type || 'article',
},
reference: {
images,
cover: sourceData?.id,
},
};
postResource(data)
.then(res => {
if (res?.code === 0) {
console.log('保存成功');
// dispatch(routerRedux.goBack());
setLoading(false);
} else {
message.error('文章保存失败,请重试');
}
})
.finally(() => {
setLoading(false);
});
};
const cancelHandler = () => {
const articleContent = editorRef.current?.getContent();
if ((name || articleContent) && !defaultData?.id) {
Modal.confirm({
title: '提示',
okText: '确认',
width: 600,
cancelText: '取消',
content: (
<span>
取消后将
<span style={{ color: '#7F7CCE' }}>
<b>丢失</b>
</span>
本页面的所有内容,请确认是否取消
</span>
),
onOk: () => {
dispatch(routerRedux.goBack());
},
onCancel: () => {},
});
return;
}
if (defaultData?.id) {
Modal.confirm({
title: '提示',
okText: '确认',
width: 600,
cancelText: '取消',
content: (
<span>
取消后将
<span style={{ color: '#7F7CCE' }}>
<b>丢失</b>
</span>
本页面的所有内容,请确认是否取消
</span>
),
onOk: () => {
dispatch(routerRedux.goBack());
},
onCancel: () => {},
});
return;
}
dispatch(routerRedux.goBack());
};
useEffect(() => {
setLoading(true);
if (defaultData) {
console.log(defaultData, 'defaultData');
setName(defaultData?.name || '');
setContent(defaultData?.content || '');
getResourceDetail(defaultData?.id).then(res => {
console.log('res', res);
if (res?.code === 0) {
const data: ResourceRequestType = res.data || {};
const metaData = data?.metadata || {};
const reference = data?.reference || {};
const padding = metaData?.padding || metaData?.padding === 0 ? metaData?.padding : 16;
setName(data?.name || '');
setInputValue(padding);
setDesc(metaData?.desc || '');
setContent(metaData?.content || '');
setImageDatas(reference?.images || []);
setSourceData(reference?.cover || undefined);
} else {
message.error('文章详情获取失败,请重试');
}
});
// .finally(() => {
// setLoading(false);
// });
}
}, []);
const onChange = (value: any) => {
setInputValue(value);
// editorRef.current.execCommand('mceSetContent', false, content);
// // editorRef.current?.execCommand('mceSetContent', false, content);
// console.log('editorRef.current', editorRef.current);
// const htmlContent = editorRef.current?.getContent();
// const html = `<div style='margin-left: ${value}px; margin-right: ${value}px'>${htmlContent}</div>`;
// console.log('htmlContent', htmlContent,html);
// editorRef.current?.setContent(html);
// // editorRef.current?.setContent(content);
};
return (
<div className={styles.contentCreate}>
<Spin spinning={loading}>
<div style={{ height: '100%' }}>
<Editor
tinymceScriptSrc={'/tinymce/tinymce.min.js'}
onInit={(evt, editor) => (editorRef.current = editor)}
onLoadContent={() => setLoading(false)}
initialValue={content}
// onEditorChange={(content, editor) => {
// setContent(content);
// }}
init={{
height: '100%',
placeholder: '请输入文章内容',
menubar: true,
toolbar_mode: 'sliding',
statusbar: false,
resize: true,
language: 'zh_CN',
plugins: [
'advlist',
'autolink',
'lists',
'link',
'image',
'charmap',
'preview',
'anchor',
'searchreplace',
'visualblocks',
'code',
'fullscreen',
'insertdatetime',
'media',
'table',
'code',
'help',
'wordcount',
'codesample',
'emoticons',
// 'inlinecss',
'quickbars',
'image',
'pagebreak',
'accordion',
'directionality',
'nonbreaking',
'save',
'template',
'autosave',
],
file_picker_callback: function (callback, value, meta) {
// if (meta.filetype == 'file') {
// callback('mypage.html', { text: 'My text' });
// }
// Provide image and alt text for the image dialog
if (meta.filetype == 'image') {
//自定义的文件选择逻辑
console.log('select image')
}
// Provide alternative source and posted for the media dialog
if (meta.filetype == 'media') {
//自定义的文件选择逻辑
console.log('select media')
}
},
quickbars_selection_toolbar:
'bold italic underline strikethrough forecolor backcolor hr | alignleft aligncenter alignright alignjustify | fontfamily lineheight indent outdent quicklink blockquote removeformat',
toolbar:
'undo redo restoredraft | selectall preview code template fullscreen | forecolor backcolor bold italic underline strikethrough hr | blocks fontfamily fontsize | pagebreak removeformat | align lineheight indent outdent | numlist bullist | link image media table | visualblocks ltr rtl subscript superscript | blockquote nonbreaking charmap emoticons codesample anchor accordion | cut copy paste print | language insertdatetime | wordcount help',
quickbars_image_toolbar: 'alignleft aligncenter alignright image',
quickbars_insert_toolbar: '',
// 'link image media table | hr pagebreak | charmap emoticons codesample',
font_size_formats:
'10px 12px 14px 16px 18px 20px 22px 24px 26px 28px 30px 32px 36px 38px',
font_size_input_default_units: 'px',
line_height_formats: '1 1.25 1.5 1.75 2 2.25 2.5 2.75 3 3.5 4',
content_style: `body { font-family:PingFangSC-Regular; font-size:14px; }`,
link_context_toolbar: true,
autosave_restore_when_empty: true,
autosave_retention: '60m',
indent_use_margin: true,
branding: false,
font_family_formats:
'微软雅黑=Microsoft YaHei,微软雅黑,sans-serif; 宋体=宋体,sans-serif; 黑体=黑体,sans-serif; 苹果平方极细=PingFangSC-Ultralight,sans-serif; 苹果平方细=PingFangSC-Light, sans-serif; 苹果平方常规=PingFangSC-Regular, sans-serif; 苹果平方中等=PingFangSC-Medium, sans-serif; 苹果平方粗=PingFangSC-Semibold, sans-serif; Arial=arial,helvetica,sans-serif; helvetica=helvetica,sans-serif; Tahoma=Tahoma, sans-serif;',
spellchecker_language: 'zh_CN',
content_langs: [
{ title: 'English (US)', code: 'en_US' },
{ title: 'Chinese', code: 'zh_CN' },
],
table_toolbar:
'tableprops tabledelete | tableinsertrowbefore tableinsertrowafter tabledeleterow | tableinsertcolbefore tableinsertcolafter tabledeletecol',
// template_replace_values: {
// username: 'Jack Black',
// staffid: '991234',
// inboth_username: 'Famous Person',
// inboth_staffid: '2213',
// },
// template_preview_replace_values: {
// preview_username: 'Jack Black',
// preview_staffid: '991234',
// inboth_username: 'Famous Person',
// inboth_staffid: '2213',
// },
// templates: [
// {
// title: 'Date modified example',
// description: 'Adds a timestamp indicating the last time the document modified.',
// content:
// '<p>Last Modified: <time class="mdate">This will be replaced with the date modified.</time></p>',
// },
// ],
templates: (callback: any) => {
getResourceList({
type: 'article_template',
page_info: { page_num: 1, page_size: 200 },
}).then(res => {
if (res.code === 0) {
const { data } = res?.data;
const articles = data?.map((item: any) => {
return {
title: item?.metadata?.name || item?.name || '',
description: item?.metadata?.desc || '',
content: item?.metadata?.content || '',
};
});
console.log('article_template', articles);
callback?.(articles || []);
} else {
message.error(res?.msg || '资源库列表获取失败,请重试');
callback([]);
}
});
},
}}
/>
</div>
<div style={{ height: '50px' }}></div>
<FooterToolbar>
<div className={styles.bottomBtn}>
<Space>
<div
style={{ width: 200, display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<InputNumber
step={1}
min={0}
max={100}
addonAfter="px"
addonBefore="容器边距"
controls
value={inputValue}
onChange={onChange}
/>
</div>
<Button
onClick={() => {
const articleContent = editorRef.current?.getContent();
if (_.isEmpty(articleContent)) {
message.error('请先输入文章内容');
return;
}
setTemplateVisiable(true);
}}>
保存为模版
</Button>
<Button onClick={cancelHandler}>取消</Button>
<Button onClick={() => handleSave(false)} type="primary">
保存文章
</Button>
</Space>
</div>
</FooterToolbar>
<Modal
open={tagsModalVisible}
title="标签管理"
// cancelText="取消"
// okText="确定"
onCancel={() => {
setTagsModalVisible(false);
}}
// onOk={handleSave}
destroyOnClose
footer={
<Space>
<Button
onClick={() => {
setTagsModalVisible(false);
}}>
取消
</Button>
<Button
onClick={() => {
handleSave(true);
}}>
预览
</Button>
<Button type="primary" onClick={() => handleSave()}>
保存
</Button>
</Space>
}
width={800}>
<Row gutter={24}>
<Form layout="vertical">
<Form.Item
label="文章标题"
name="name"
rules={[{ required: true, message: '请输入文章标题' }]}
initialValue={name}>
<Input
// bordered={false}
placeholder="请输入标题,最多20个字"
value={name}
maxLength={20}
className={styles.titleInput}
onChange={e => setName(e.target.value)}></Input>
</Form.Item>
<Form.Item label="描述" name="desc" initialValue={name}>
<Input
// bordered={false}
placeholder="请输入描述"
value={desc}
className={styles.titleInput}
onChange={e => setDesc(e.target.value)}></Input>
</Form.Item>
<Form.Item label="封面">
<div className={styles.media}>
{sourceData ? (
<span>
<Image className={styles?.coverImg} src={sourceData?.value} />
<div
className={styles.deleteTv}
onClick={() => {
setSourceData(null);
}}>
<DeleteOutlined /> 删除
</div>
</span>
) : (
<div
className={styles?.coverImg}
onClick={() => {
//自定义选择逻辑
console.log('select image')
}}>
<PlusOutlined />
<div style={{ marginTop: 8 }}>选择封面</div>
</div>
)}
</div>
</Form.Item>
</Form>
</Row>
</Modal>
<Modal
open={templateVisiable}
title="模版保存"
onCancel={() => {
setTemplateVisiable(false);
}}
destroyOnClose
cancelText="取消"
okText="保存"
onOk={saveTemplate}>
<Form layout="vertical" form={form}>
<Form.Item
label="文章标题"
name="name"
rules={[{ required: true, message: '请输入文章标题' }]}
initialValue={name}>
<Input
// bordered={false}
placeholder="请输入标题,最多20个字"
value={name}
maxLength={20}
className={styles.titleInput}></Input>
</Form.Item>
<Form.Item label="描述" name="desc" initialValue={name}>
<Input
// bordered={false}
placeholder="请输入描述"
value={desc}
className={styles.titleInput}></Input>
</Form.Item>
</Form>
</Modal>
</Spin>
</div>
);
};
export default ArticleDetail;
index.less
@import '~antd/lib/style/themes/default.less';
@border-color: #CCD2E3;
@error-color: #E90000;
@text-gray-color: #999999;
@time-color: #949AAA;
.contentCreate {
background-color: #F5F5F5;
width: 100%;
height: 100%;
display: flex;
overflow: hidden;
flex-direction: column;
.line {
width: 100%;
height: 1px;
// background: @border-color;
// margin: 12px 0px
}
// justify-content: center;
// #toolbar {
// display: inline-block;
// }
:global {
.ant-spin-nested-loading {
width: 100%;
height: 100%;
overflow: hidden;
}
.ant-spin-container {
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.tox-tinymce {
border: 0px solid #fff !important;
border-radius: 0px !important;
border-top: 1px solid #f2f2f2 !important;
}
.tox-tinymce-aux {
z-index: 500 !important;
}
.tox .tox-dialog-wrap {
z-index: 500 !important;
}
.tox .tox-dialog--width-lg {
width: 500 !important;
max-width: 500 !important;
height: 80vh;
}
.tox .tox-dialog__body-content {
height: 100% !important;
}
.tox .tox-sidebar-wrap {
width: 500px;
margin: 0 auto;
}
.tox .tox-promotion-link {
display: none;
}
}
.card {
flex: 1;
border-radius: 6px;
width: 70%;
overflow: auto;
margin: 0 auto;
padding-bottom: 60px;
}
.titleInput {
font-size: 24px;
}
.titleInput::placeholder {
font-size: 24px;
color: #949AAA;
}
.bottomBtn {
// margin-top: 20px;
padding: 5px 0px;
display: flex;
flex-direction: row;
align-items: center;
// flex-direction: row-reverse;
}
}
网友评论