274 lines
7.6 KiB
JavaScript
274 lines
7.6 KiB
JavaScript
import './index.scss'
|
||
import 'quill/dist/quill.core.css' // 核心样式
|
||
import 'quill/dist/quill.snow.css' // 有工具栏
|
||
import 'quill/dist/quill.bubble.css' // 无工具栏
|
||
import './font.css'
|
||
import React, { useState, useRef, useEffect } from 'react'
|
||
import Quill from 'quill'
|
||
import katex from 'katex'
|
||
|
||
import { fetchUploadImage } from '../../services/ojService.js'
|
||
import { getImageUrl } from 'educoder'
|
||
import ImageBlot from './ImageBlot'
|
||
import FillBlot from './FillBlot'
|
||
import LinkBlot from './link-blot'
|
||
import mediator from '../mediator'
|
||
import { defaultQuillOpt } from '../components/custom-editor'
|
||
|
||
let Size = Quill.import('attributors/style/size')
|
||
Size.whitelist = ['14px', '16px', '18px', '20px', false]
|
||
let fonts = ['Microsoft-YaHei', 'SimSun', 'SimHei', 'KaiTi', 'FangSong']
|
||
let Font = Quill.import('formats/font')
|
||
|
||
Font.whitelist = fonts; //将字体加入到白名单
|
||
window.Quill = Quill;
|
||
window.katex = katex;
|
||
Quill.register(ImageBlot);
|
||
Quill.register(Size);
|
||
Quill.register(LinkBlot);
|
||
Quill.register(Font, true);
|
||
|
||
Quill.register(FillBlot)
|
||
|
||
const FillStr = '▁'
|
||
const FillReg = new RegExp(FillStr, 'g')
|
||
const BRSTR = '<p><br></p>'
|
||
|
||
//hack
|
||
function quillSetValue(el, value) {
|
||
if (value && value.hasOwnProperty('ops')) {
|
||
el.setContents(value)
|
||
} else {
|
||
if (value) {
|
||
let rs = value
|
||
if (rs.endsWith(BRSTR)) {
|
||
rs += BRSTR
|
||
}
|
||
el.clipboard.dangerouslyPasteHTML(rs);
|
||
}
|
||
}
|
||
}
|
||
|
||
const BUTTONTIP = {
|
||
'bold': '加粗',
|
||
'strike': '删除线',
|
||
'italic': '斜体',
|
||
'underline': '下划线',
|
||
'ordered': '有序列表',
|
||
'bullet': '无序列表',
|
||
'color': '字体颜色',
|
||
'background': '背景色',
|
||
'sub': '下标',
|
||
'super': '上标',
|
||
'image': '上传图片',
|
||
'code-block': '代码块',
|
||
'formula': '公式',
|
||
'clean': '清除格式',
|
||
'fill': '插入填空项',
|
||
'align': '对齐',
|
||
'header1': '标题一',
|
||
'header2': '标题二',
|
||
'header3': '标题三',
|
||
'header4': '标题四',
|
||
'header5': '标题五',
|
||
'header6': '标题六'
|
||
}
|
||
function insertAfter(newNode, referenceNode) {
|
||
referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
|
||
}
|
||
|
||
function initButtonTip(el) {
|
||
const container = el.getModule('toolbar').container
|
||
const btns = container.querySelectorAll('.ql-formats > button,.ql-formats>span')
|
||
Array.from(btns).forEach(btn => {
|
||
let value = btn.getAttribute('value')
|
||
let cls = btn.classList[0].replace('ql-', '')
|
||
btn.setAttribute('title', BUTTONTIP[value || cls] || BUTTONTIP[`${cls}${value}`])
|
||
if (cls === 'fill') {
|
||
let span = document.createElement('span')
|
||
span.setAttribute('class', 'fill-tip')
|
||
span.innerText = '点击插入填空项'
|
||
span.addEventListener('click', () => { btn.click() })
|
||
insertAfter(span, btn)
|
||
}
|
||
})
|
||
}
|
||
export default ({
|
||
defaultValue,
|
||
placeholder,
|
||
readOnly,
|
||
autoFocus = false,
|
||
options,
|
||
value,
|
||
imgAttrs = {}, // 指定图片的宽高
|
||
style = {},
|
||
wrapStyle = {},
|
||
showUploadImage,
|
||
onContentChange,
|
||
className,
|
||
onAddFill,
|
||
onDeleteFill
|
||
}) => {
|
||
|
||
const editorRef = useRef(null)
|
||
// quill 实例
|
||
const [quill, setQuill] = useState(null)
|
||
|
||
const bindings = {
|
||
backspace: {
|
||
key: 'Backspace',
|
||
/**
|
||
* @param {*} range
|
||
* { index, // 删除元素的位置
|
||
* length // 删除元素的个数, 当删除一个时, length=0, 其它等于删除的元素的个数
|
||
* }
|
||
* @param {*} context 上下文
|
||
*/
|
||
handler: function (range, context) {
|
||
const [index, delArrs] = getFillInfo(range, this.quill)
|
||
if (delArrs) {
|
||
if (window.confirm('确定要删除这个填空吗?')) {
|
||
onDeleteFill && onDeleteFill(index, delArrs.length) // 调用删除回调, 返回删除的元素下标[]
|
||
return true
|
||
} else {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
const quillOption = {
|
||
modules: {
|
||
toolbar: options || defaultQuillOpt,
|
||
'syntax': false,
|
||
keyboard: {
|
||
bindings: bindings
|
||
}
|
||
},
|
||
readOnly,
|
||
placeholder,
|
||
theme: readOnly ? 'bubble' : 'snow',
|
||
}
|
||
|
||
//返回当前选中文件之前填空的索引,和选中的文本内有多少个填空
|
||
function getFillInfo(range, quill) {
|
||
const { index, length } = range
|
||
const start = length === 0 ? index - 1 : index
|
||
const selectionText = quill.getText(start, Math.max(1, length))
|
||
const beforeSelectionText = quill.getText(0, start)
|
||
return [(beforeSelectionText.match(FillReg) || []).length, selectionText.match(FillReg)]
|
||
}
|
||
|
||
useEffect(() => {
|
||
const quillInstance = new Quill(editorRef.current, quillOption)
|
||
setQuill(quillInstance)
|
||
// 处理图片上传功能
|
||
quillInstance.getModule('toolbar').addHandler('image', (e) => {
|
||
const input = document.createElement('input')
|
||
input.setAttribute('type', 'file')
|
||
input.setAttribute('accept', 'image/*')
|
||
input.click()
|
||
input.onchange = async (e) => {
|
||
const file = input.files[0] // 获取文件信息
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
|
||
const range = quillInstance.getSelection(true)
|
||
let fileUrl = '' // 保存上传成功后图片的url
|
||
|
||
const result = await fetchUploadImage(formData)
|
||
// 获取上传图片的url
|
||
if (result.data && result.data.id) {
|
||
fileUrl = getImageUrl(`api/attachments/${result.data.id}`)
|
||
}
|
||
const { width, height } = imgAttrs
|
||
if (fileUrl) {
|
||
let imgOption = {
|
||
url: fileUrl,
|
||
alt: '图片信息',
|
||
width,
|
||
height
|
||
}
|
||
quillInstance.insertEmbed(range.index, 'image', imgOption)
|
||
}
|
||
}
|
||
})
|
||
quillInstance.getModule('toolbar').addHandler('fill', (e) => {
|
||
const range = quillInstance.getSelection(true)
|
||
const [fillIndex, _] = getFillInfo(range, quillInstance)
|
||
quillInstance.insertEmbed(range.index, 'fill', { "data-index": fillIndex, text: FillStr })
|
||
quillInstance.setSelection(quillInstance.getLength(), 0, 'api')
|
||
onAddFill && onAddFill(fillIndex) // 调用添加回调
|
||
})
|
||
|
||
initButtonTip(quillInstance)
|
||
|
||
quillSetValue(quillInstance, value)
|
||
|
||
if (autoFocus) {
|
||
quillInstance.focus()
|
||
}
|
||
|
||
function onPreview(url) {
|
||
if (showUploadImage) {
|
||
showUploadImage(url)
|
||
}
|
||
}
|
||
|
||
let unsub = mediator.subscribe('on-preview-image', onPreview)
|
||
return () => {
|
||
unsub()
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (quill) {
|
||
if (readOnly === true) {
|
||
quill.enable(false)
|
||
} else {
|
||
quill.enable(true)
|
||
}
|
||
}
|
||
}, [quill, readOnly])
|
||
|
||
useEffect(() => {
|
||
quillSetValue(quill, defaultValue)
|
||
}, [quill, defaultValue])
|
||
|
||
useEffect(() => {
|
||
if (quill) {
|
||
quill.root.dataset.placeholder = placeholder;
|
||
}
|
||
}, [quill, placeholder])
|
||
|
||
useEffect(() => {
|
||
if (quill) {
|
||
autoFocus ? quill.focus() : quill.blur()
|
||
}
|
||
}, [quill, autoFocus])
|
||
|
||
useEffect(() => {
|
||
if (quill && onContentChange) {
|
||
function onChangeHandler(delta, oldDelta, source) {
|
||
let html = editorRef.current.children[0].innerHTML
|
||
let text = quill.getText()
|
||
if (html === '<p><br></p>') html = ''
|
||
if (onContentChange) {
|
||
onContentChange(quill.getContents(), html, { quill, text, delta, oldDelta, source })
|
||
}
|
||
}
|
||
quill.on('text-change', onChangeHandler)
|
||
return () => {
|
||
quill.off('text-change', onChangeHandler)
|
||
}
|
||
}
|
||
}, [quill, onContentChange])
|
||
|
||
// 返回结果
|
||
return (
|
||
<div className={`quill_editor_for_react_area ${className} `} style={wrapStyle}>
|
||
<div ref={editorRef} style={style}></div>
|
||
</div>
|
||
)
|
||
} |