自己动手封装一个react版本的quilljs

284 阅读3分钟

简介

react-quilljs 毕竟是别人封装过的,对自己的需求不是很满足,例如国际化,禁用状态,数据I/O的预处理等等都要配置,还不如自己弄一个。

  • 另外,查了下很多人使用的时候都是编辑器单独使用,而我需要将他嵌入表单之中,由于toolbar使用的选择器id 不能重复,所以这里用的是ref
  • 添加了图片模块

我在这里做了一个简易封装,稍作修改即可使用。

代码:


import { useDeepCompareEffect } from 'ahooks'

import Quill from 'quill'
import 'quill/dist/quill.snow.css'
import {
	forwardRef,
	memo,
	useEffect,
	useImperativeHandle,
	useRef,
	useState,
} from 'react'
import { ImageUpload } from './image.module'
import React from 'react'

Quill.register('modules/imageUpload', ImageUpload)
interface IProps {
	defaultValue?: string
	mode?: 'viewer' | 'editor'
}

export interface IOpenApiRichTextRefProps {
	getEditor: () => Quill
	getContent: () => string
	setContent: (val: string) => void
	clearContent: () => void
}

export const QUILL_EMPTY = JSON.stringify([
	{
		insert: '\n',
	},
])
export default memo(
	forwardRef((props: IProps, ref) => {
		const { defaultValue = '', mode = 'editor' } = props
		const isView = mode !== 'editor'
		const editorRef = useRef()
		const toolbarRef = useRef()
		const [editor, setEditor] = useState<Quill>()
		const [urlMap, setUrlMap] = useState<{ [k: string]: string }>({})
		const [requestLoading, setRequestLoading] = useState(false)
		const [id] = useState(`ql-toolbar-${randomId()}`)

		const options = {
			debug: false,
			modules: {
				debug: false,

				toolbar: isView
					? false
					: {
							container: `#${id}`,
						},
				imageUpload: {
					notImage: () => {
						message.error('请选择一个图片')
					},
					customUploader: async (
						file: File,
						insertUrl: (url: string) => void
					) => {
						// 自己的请求方法
						const url = await UploadFn(file)
						
						insertUrl(url)
					},
				},
			},
			placeholder: isView ? '' : '请输入',
			theme: 'snow',
		}

		const setContentFn = async (val: string) => {
			// 自定义检测json方法
			if (!checkedJSON(val) || !val) {
				editor?.setContents([{ insert: `${val}\n` }])
				console.error('传入非json值,已展示默认格式')
			} else {
				const res = JSON.parse(val)
				if (!Array.isArray(res)) return console.error('设置值需要数组')
				// 在这里进行图片的预处理
				// ...
				editor?.setContents(res)
			}
		}

		useImperativeHandle(ref, () => ({
			getEditor: () => {
				return editor
			},
			getContent: () => {
				const rawData = editor?.getContents()?.ops
				
				// const res = rawData?.map((a) => {
				// 	if (a?.attributes?.alt === 'image') {
				// 		return {
				// 			...a,
				// 			insert: {
				// 				// 图片后的处理
				// 				// image: xxx,
				// 			},
				// 		}
				// 	} else {
				// 		return a
				// 	}
				// })
				const res = rawData
				return JSON.stringify(res)
			},
			setContent: setContentFn,
			clearContent: () => {
				editor?.setContents([{ insert: '\n' }])
			},
		}))
		useEffect(() => {
			if (editorRef.current && toolbarRef.current) {
				const quill = new Quill(editorRef.current, options)
				isView && quill.enable(false)
				setEditor(quill)
			}
		}, [])

		useDeepCompareEffect(() => {
			if (editor) {
				setContentFn(defaultValue)
			}
		}, [defaultValue, editor])

		return (
			<Spin spinning={requestLoading}>
				<Box
					style={{ height: '350px', width: '100%' }}
					sx={{
						'.ql-toolbar': {
							borderTopLeftRadius: '5px',
							borderTopRightRadius: '5px',
						},
						'.ql-container': {
							height: '300px',
							borderBottomLeftRadius: '5px',
							borderBottomRightRadius: '5px',
						},
					}}
				>
					{isView ? (
						<div ref={toolbarRef}></div>
					) : (
						<CustomToolbar id={id} ref={toolbarRef}></CustomToolbar>
					)}

					<div ref={editorRef}> </div>
				</Box>
			</Spin>
		)
	})
)
const CustomButton = () => <span className='octicon octicon-star' />

const CustomToolbar = React.forwardRef((props, ref) => {
	// 在这里进行国际化
	return (
		<div ref={ref} id={props.id}>
			<button className='ql-bold' />
			<select
				className='ql-header'
				defaultValue={''}
				onChange={(e) => e.persist()}
			>
				<option value='0'>正文</option>
				<option value='1'>标题 1</option>
				<option value='2'>标题 2</option>
				<option value='3'>标题 3</option>
				<option value='4'>标题 4</option>
				<option value='5'>标题 5</option>
				<option value='6'>标题 6</option>
			</select>
			{/* <select className='ql-size'>
				<option value='small'>小号</option>
				<option selected>中号</option>
				<option value='large'>大号</option>
				<option value='huge'>特大号</option>
			</select> */}
			<button className='ql-italic' />
			<button className='ql-underline' />
			<button className='ql-strike' />
			<button className='ql-blockquote' />
			<button className='ql-code-block' />
			<button className='ql-list' value='ordered' />
			<button className='ql-list' value='bullet' />
			<select className='ql-color'>
				<option value='black' selected>
					black
				</option>
				<option value='red'>red</option>
				<option value='green'>green</option>
				<option value='blue'>blue</option>
				<option value='orange'>orange</option>
				<option value='violet'>violet</option>
				<option value='#d0d1d2'></option>
			</select>
			<button className='ql-link' />
			<button className='ql-image' />
			{/* <button className='ql-video' /> */}
			<button className='ql-script' value='sub'></button>
			<button className='ql-script' value='super'></button>
			<button className='ql-clean'></button>
			<button className='ql-custom-chart'></button>
			<button className='ql-insertStar'>
				<CustomButton />
			</button>
		</div>
	)
})

对图片模块进行了,修改便于预处理。加装了处理非图片格式的方法

模块来源于:quill-image-upload


/**
 * Custom module for quilljs to allow user to drag images from their file system into the editor
 * and paste images from clipboard (Works on Chrome, Firefox, Edge, not on Safari)
 * @see https://quilljs.com/blog/building-a-custom-module/
 */
export class ImageUpload {
	options: any
	/**
	 * Instantiate the module given a quill instance and any options
	 * @param {Quill} quill
	 * @param {Object} options
	 */
	constructor(quill, options = {}) {
		// save the quill reference
		this.quill = quill
		// save options
		this.options = options
		// listen for drop and paste events
		this.quill
			.getModule('toolbar')
			?.addHandler('image', this.selectLocalImage.bind(this))
	}

	/**
	 * Select local image
	 */
	selectLocalImage() {
		const input = document.createElement('input')
		input.style.display = 'none'
		input.id = 'newImage'
		document.body.appendChild(input)

		input.setAttribute('type', 'file')
		input.click()

		// Listen upload local image and save to server
		input.onchange = () => {
			const file = input.files[0]
			// file type is only image.
			if (/^image\//.test(file.type)) {
				const checkBeforeSend =
					this.options.checkBeforeSend || this.checkBeforeSend.bind(this)
				checkBeforeSend(file, this.sendToServer.bind(this))
			} else {
				console.warn('You could only upload images.')
				this.options?.notImage?.()
			}
			document.body.removeChild(input)
		}
	}

	/**
	 * Check file before sending to the server
	 * @param {File} file
	 * @param {Function} next
	 */
	checkBeforeSend(file, next) {
		next(file)
	}

	/**
	 * Send to server
	 * @param {File} file
	 */
	sendToServer(file: string | Blob) {
		// Handle custom upload
		if (this.options.customUploader) {
			this.options.customUploader(file, (dataUrl) => {
				this.insert(dataUrl)
			})
		} else {
			const url = this.options.url,
				method = this.options.method || 'POST',
				name = this.options.name || 'image',
				headers = this.options.headers || {},
				callbackOK =
					this.options.callbackOK || this.uploadImageCallbackOK.bind(this),
				callbackKO =
					this.options.callbackKO || this.uploadImageCallbackKO.bind(this)

			if (url) {
				const fd = new FormData()

				fd.append(name, file)

				if (this.options.csrf) {
					// add CSRF
					fd.append(this.options.csrf.token, this.options.csrf.hash)
				}

				const xhr = new XMLHttpRequest()
				// init http query
				xhr.open(method, url, true)
				// add custom headers
				for (var index in headers) {
					xhr.setRequestHeader(index, headers[index])
				}

				// listen callback
				xhr.onload = () => {
					if (xhr.status === 200) {
						callbackOK(JSON.parse(xhr.responseText), this.insert.bind(this))
					} else {
						callbackKO({
							code: xhr.status,
							type: xhr.statusText,
							body: xhr.responseText,
						})
					}
				}

				if (this.options.withCredentials) {
					xhr.withCredentials = true
				}

				xhr.send(fd)
			} else {
				const reader = new FileReader()

				reader.onload = (event) => {
					callbackOK(event.target.result, this.insert.bind(this))
				}
				reader.readAsDataURL(file)
			}
		}
	}

	/**
	 * Insert the image into the document at the current cursor position
	 * @param {String} dataUrl  The base64-encoded image URI
	 */
	insert(dataUrl) {
		const index =
			(this.quill.getSelection() || {}).index || this.quill.getLength()
		this.quill.insertEmbed(index, 'image', dataUrl, 'user')
		// 设置attributes为image
		this.quill.formatText(index, index, { alt: 'image' }, 'user')
	}

	/**
	 * callback on image upload succesfull
	 * @param {Any} response http response
	 */
	uploadImageCallbackOK(response, next) {
		next(response)
	}

	/**
	 * callback on image upload failed
	 * @param {Any} error http error
	 */
	uploadImageCallbackKO(error) {
		alert(error)
	}
}