記:自定義內(nèi)容編輯器ReactQuill、Tinymce
基礎(chǔ)庫(kù)地址
React-quill原官網(wǎng)地址:https://zenoamaro.github.io/react-quill/
quill官網(wǎng)地址:https://quilljs.com/
最后有完整的代碼copy
最后有完整的代碼copy
最后有完整的代碼copy
最后發(fā)現(xiàn)還是tinymce編輯器更好用一些,tinymce免費(fèi)版除了基礎(chǔ)功能,還支持預(yù)覽、html代碼復(fù)制、粘貼、模版選擇等實(shí)用功能
目前換成Tinymce
Tinymce原官網(wǎng)地址: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
預(yù)覽圖

文章內(nèi)容介紹
項(xiàng)目中需要使用富文本編輯器來(lái)編輯文章供前端展示使用,也百度了一系列富文本編輯,感覺(jué)大部分編輯器都比較古老,最終選擇了quill編輯器(目前已經(jīng)更新為Tinymce),顯示效果跟微信小程序文章的編輯器樣式類似,基礎(chǔ)功能支持比較多,還可以支持自定義工具。
本文章主要介紹內(nèi)容
1.自定義編輯器樣式
2.自定義選擇圖片資源功能
3.添加自定義標(biāo)題輸入框,支持自定義標(biāo)題內(nèi)容
4.react-quill使用和quill編輯器使用方式
5.配合antd部分組件使用
原編輯樣式
原編輯樣式已經(jīng)支持大部分場(chǎng)景了,功能也比較完善

項(xiàng)目需要樣式如下圖所示,需要支持操作區(qū)、標(biāo)題區(qū)、內(nèi)容區(qū)域以及底部操作四部分內(nèi)容展示。

react依賴安裝
//npm 安裝
npm i react-quill --save
//yarn 安裝
yarn add react-quill
//使用emoji 本項(xiàng)目沒(méi)有表情使用場(chǎng)景
npm i quillEmoji --save
yarn add quillEmoji
項(xiàng)目引進(jìn)組件
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="請(qǐng)輸入內(nèi)容"
ref={quillRef}
modules={modules}
theme="snow"
value={value}
onChange={handleChange}
/>
工具欄定義方式
工具欄方式定義有兩種,可以定義dom來(lái)定義工具欄,也可以直接使用modules的toolbar來(lái)配置
toolbar方式
this.modules = {
toolbar: {
container: [
[{ 'size': ['small', false, 'large', 'huge'] }], //字體設(shè)置
// [{ 'header': [1, 2, 3, 4, 5, 6, false] }], //標(biāo)題字號(hào),不能設(shè)置單個(gè)字大小
['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表情,設(shè)置了才能顯示
['video2'], //我自定義的視頻圖標(biāo),和插件提供的不一樣,所以設(shè)置為video2
],
handlers: {
'image': this.imageHandler.bind(this), //點(diǎn)擊圖片標(biāo)志會(huì)調(diào)用的方法
'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, //是否展示出來(lái)
"emoji-textarea": false, //我不需要emoji展示在文本框所以設(shè)置為false
"emoji-shortname": true,
}
編寫dom引用方式
具體工具代碼配置
<div id="toolbar">
<span className="ql-formats">
<span onClick={() => modalRef.current?.openModal('image')}>導(dǎo)入圖片</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="一級(jí)標(biāo)題" placement="bottom">
<button className="ql-header" value="1"></button>
</Tooltip>
<Tooltip title="二級(jí)標(biāo)題" 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="無(wú)序列表" 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="減少縮進(jìn)" placement="bottom">
<button className="ql-indent" value="-1"></button>
</Tooltip>
<Tooltip title="增加縮進(jìn)" 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>
自定義帶標(biāo)題輸入框編輯器
代碼如下
<Card className={styles.card}>
<Input
bordered={false}
placeholder="請(qǐng)輸入標(biāo)題"
value={title}
maxLength={10}
className={styles.titleInput}
onChange={e => setTitle(e.target.value)}></Input>
<ReactQuill
placeholder="請(qǐng)輸入內(nèi)容"
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}>確認(rèn)無(wú)誤,可上線使用</Button>
<Button onClick={cancelHandler}>取消</Button>
</Space>
<div
className={styles.textNumber}>
正文字?jǐn)?shù) {quillRef.current?.getEditor()?.getLength()-1 || 0}
</div>
</div>
</FooterToolbar>
自定義選擇項(xiàng)目圖片資源代碼
2023-06-30-15-45-53-image.png

自定義選擇圖片資源彈框
<SelectSourceModal multi ref={modalRef} defaultType="image" callback={handleCallback} />
handleCallback回調(diào)處理
主要邏輯是通過(guò)獲取到當(dāng)前的編輯器,然后獲取到當(dāng)前光標(biāo)位置,將光標(biāo)位置+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; //獲取當(dāng)前光標(biāo)位置;
quill.insertEmbed(cursorPosition, 'image', item.url); //插入圖片
quill.setSelection(cursorPosition + 1); //光標(biāo)位置加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('資源引用失敗,請(qǐng)重試');
}
});
};
完整代碼
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; //獲取當(dāng)前光標(biāo)位置;
quill.insertEmbed(cursorPosition, 'image', item.url); //插入圖片
quill.setSelection(cursorPosition + 1); //光標(biāo)位置加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('資源引用失敗,請(qǐng)重試');
}
});
};
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: '請(qǐng)先輸入標(biāo)題,再點(diǎn)擊保存按鈕',
okText: '知道了',
});
return;
}
handleSave('draft');
};
const confirmHandler = () => {
if (!title || !value) {
Modal.info({
title: '提示',
width: 600,
content: '請(qǐng)保證標(biāo)題和正文內(nèi)容完整',
okText: '知道了',
});
return;
}
handleSave('prod');
};
const cancelHandler = () => {
if (title || value) {
Modal.confirm({
title: '提示',
okText: '確認(rèn)',
width: 600,
cancelText: '取消',
content: (
<span>
取消后將
<span style={{ color: '#FF7F08' }}>
<b>丟失</b>
</span>
本頁(yè)面的所有內(nèi)容,請(qǐng)確認(rèn)是否取消
</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')}>導(dǎo)入圖片</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="一級(jí)標(biāo)題" placement="bottom">
<button className="ql-header" value="1"></button>
</Tooltip>
<Tooltip title="二級(jí)標(biāo)題" 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="無(wú)序列表" 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="減少縮進(jìn)" placement="bottom">
<button className="ql-indent" value="-1"></button>
</Tooltip>
<Tooltip title="增加縮進(jìn)" 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="請(qǐng)輸入標(biāo)題"
value={title}
maxLength={10}
className={styles.titleInput}
onChange={e => setTitle(e.target.value)}></Input>
<ReactQuill
placeholder="請(qǐng)輸入內(nèi)容"
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}>確認(rèn)無(wú)誤,可上線使用</Button>
<Button onClick={cancelHandler}>取消</Button>
</Space>
<div
className={styles.textNumber}>
正文字?jǐn)?shù) {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('請(qǐng)選擇素材');
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="暫無(wú)圖片"
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;
}
}
}
小結(jié)
實(shí)現(xiàn)過(guò)程還是需要踩坑的,我本身也是嘗試了很多遍最后才實(shí)現(xiàn)到最終的樣子。
續(xù) Tinymce示例代碼
目前換成Tinymce
Tinymce原官網(wǎng)地址: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
貼圖
選擇文件


插入模版

預(yù)覽

源代碼

Tinymce源碼
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('模版保存失敗,請(qǐng)重試');
}
});
});
};
const handleSave = (preview?: boolean) => {
const articleContent = editorRef.current?.getContent();
if (!name || !articleContent) {
Modal.info({
title: '提示',
content: '請(qǐng)先填寫文章標(biāo)題和內(nèi)容',
okText: '知道了',
});
return;
}
//文章內(nèi)容保存位置
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('文章保存失敗,請(qǐng)重試');
}
})
.finally(() => {
setLoading(false);
});
};
const cancelHandler = () => {
const articleContent = editorRef.current?.getContent();
if ((name || articleContent) && !defaultData?.id) {
Modal.confirm({
title: '提示',
okText: '確認(rèn)',
width: 600,
cancelText: '取消',
content: (
<span>
取消后將
<span style={{ color: '#7F7CCE' }}>
<b>丟失</b>
</span>
本頁(yè)面的所有內(nèi)容,請(qǐng)確認(rèn)是否取消
</span>
),
onOk: () => {
dispatch(routerRedux.goBack());
},
onCancel: () => {},
});
return;
}
if (defaultData?.id) {
Modal.confirm({
title: '提示',
okText: '確認(rèn)',
width: 600,
cancelText: '取消',
content: (
<span>
取消后將
<span style={{ color: '#7F7CCE' }}>
<b>丟失</b>
</span>
本頁(yè)面的所有內(nèi)容,請(qǐng)確認(rèn)是否取消
</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('文章詳情獲取失敗,請(qǐng)重試');
}
});
// .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: '請(qǐng)輸入文章內(nèi)容',
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; 蘋果平方極細(xì)=PingFangSC-Ultralight,sans-serif; 蘋果平方細(xì)=PingFangSC-Light, sans-serif; 蘋果平方常規(guī)=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 || '資源庫(kù)列表獲取失敗,請(qǐng)重試');
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('請(qǐng)先輸入文章內(nèi)容');
return;
}
setTemplateVisiable(true);
}}>
保存為模版
</Button>
<Button onClick={cancelHandler}>取消</Button>
<Button onClick={() => handleSave(false)} type="primary">
保存文章
</Button>
</Space>
</div>
</FooterToolbar>
<Modal
open={tagsModalVisible}
title="標(biāo)簽管理"
// cancelText="取消"
// okText="確定"
onCancel={() => {
setTagsModalVisible(false);
}}
// onOk={handleSave}
destroyOnClose
footer={
<Space>
<Button
onClick={() => {
setTagsModalVisible(false);
}}>
取消
</Button>
<Button
onClick={() => {
handleSave(true);
}}>
預(yù)覽
</Button>
<Button type="primary" onClick={() => handleSave()}>
保存
</Button>
</Space>
}
width={800}>
<Row gutter={24}>
<Form layout="vertical">
<Form.Item
label="文章標(biāo)題"
name="name"
rules={[{ required: true, message: '請(qǐng)輸入文章標(biāo)題' }]}
initialValue={name}>
<Input
// bordered={false}
placeholder="請(qǐng)輸入標(biāo)題,最多20個(gè)字"
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="請(qǐng)輸入描述"
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="文章標(biāo)題"
name="name"
rules={[{ required: true, message: '請(qǐng)輸入文章標(biāo)題' }]}
initialValue={name}>
<Input
// bordered={false}
placeholder="請(qǐng)輸入標(biāo)題,最多20個(gè)字"
value={name}
maxLength={20}
className={styles.titleInput}></Input>
</Form.Item>
<Form.Item label="描述" name="desc" initialValue={name}>
<Input
// bordered={false}
placeholder="請(qǐng)輸入描述"
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;
}
}