uniapp富文本editor输入二次扩展兼容微信小程序

在uni-app中开发富文本输入功能,并使其兼容微信小程序,需要注意一些特定的限制和解决方案。由于微信小程序本身对HTML的支持有限,直接在小程序中实现像Web那样完整的富文本编辑功能(如使用CKEditor、Quill等)是不可能的。但你可以通过一些方法来实现基本的富文本输入或近似功能。

富文本编辑器,可以对图片、文字格式进行编辑和混排。

在web开发时,可以使用contenteditable来实现内容编辑。但这是一个dom API,在非H5平台无法使用。于是微信小程序和uni-app的App-vue提供了editor组件来实现这个功能,并且在uni-app的H5平台也提供了兼容。从技术本质来讲,这个组件仍然运行在视图层webview中,利用的也是浏览器的contenteditable功能。

编辑器导出内容支持带标签的 html和纯文本的 text,编辑器内部采用 delta 格式进行存储。

通过setContents接口设置内容时,解析插入的 html 可能会由于一些非法标签导致解析错误,建议开发者在应用内使用时通过 delta 进行插入。

组件扩展

<template>
	<view class="diygw-col-24">
		<view :style="{height:height}" class='flex  flex-direction-column wrapper'>
			<view class='toolbar' @tap="format">
				<view v-if="tools.indexOf('undo')>-1" class="iconfont icon-undo" @tap="undo"></view>
				<view v-if="tools.indexOf('redo')>-1" class="iconfont icon-redo" @tap="redo"></view>
				<view v-if="tools.indexOf('bold')>-1" :class="formats.bold ? 'ql-active' : ''" class="iconfont icon-zitijiacu" data-name="bold"></view>
				<view v-if="tools.indexOf('italic')>-1" :class="formats.italic ? 'ql-active' : ''" class="iconfont icon-zitixieti" data-name="italic"></view>
				<view v-if="tools.indexOf('underline')>-1" :class="formats.underline ? 'ql-active' : ''" class="iconfont icon-zitixiahuaxian" data-name="underline"></view>
				<view v-if="tools.indexOf('strike')>-1" :class="formats.strike ? 'ql-active' : ''" class="iconfont icon-zitishanchuxian" data-name="strike"></view>
				<view v-if="tools.indexOf('align-left')>-1" :class="formats.align === 'left' ? 'ql-active' : ''" class="iconfont icon-zuoduiqi" data-name="align" data-value="left"></view>
				<view v-if="tools.indexOf('align-center')>-1" :class="formats.align === 'center' ? 'ql-active' : ''" class="iconfont icon-juzhongduiqi" data-name="align" data-value="center"></view>
				<view v-if="tools.indexOf('align-right')>-1" :class="formats.align === 'right' ? 'ql-active' : ''" class="iconfont icon-youduiqi" data-name="align" data-value="right"></view>
				<view v-if="tools.indexOf('align-justify')>-1" :class="formats.align === 'justify' ? 'ql-active' : ''" class="iconfont icon-zuoyouduiqi" data-name="align" data-value="justify"></view>
				<view v-if="tools.indexOf('lineHeight')>-1" :class="formats.lineHeight ? 'ql-active' : ''" class="iconfont icon-line-height" data-name="lineHeight" data-value="2"></view>
				<view v-if="tools.indexOf('letterSpacing')>-1" :class="formats.letterSpacing ? 'ql-active' : ''" class="iconfont icon-Character-Spacing" data-name="letterSpacing" data-value="2em"></view>
				<view v-if="tools.indexOf('marginTop')>-1" :class="formats.marginTop ? 'ql-active' : ''" class="iconfont icon-722bianjiqi_duanqianju" data-name="marginTop" data-value="20px"></view>
				<view v-if="tools.indexOf('previewarginBottom')>-1" :class="formats.previewarginBottom ? 'ql-active' : ''" class="iconfont icon-723bianjiqi_duanhouju" data-name="marginBottom" data-value="20px"></view>
				<view v-if="tools.indexOf('removeFormat')>-1" class="iconfont icon-clearedformat" @tap="removeFormat"></view>
				<view v-if="tools.indexOf('fontFamily')>-1" :class="formats.fontFamily ? 'ql-active' : ''" class="iconfont icon-font" data-name="fontFamily" data-value="仿宋, 仿宋_GB2312"></view>
			
				<!-- <picker v-if="tools.indexOf('fontSize')>-1" :range="fontSizelist" @change="formatsChange" @tap.stop="formatsChange" data-name="size" class="iconfont icon-fontsize" :class="formats.size? ' ql-active' : ''"></picker> -->
				<picker v-if="tools.indexOf('fontSize')>-1" range-key="name" :range="fontSizelist" @change="formatsChange" @tap.stop="formatsChange" data-name="fontSize" class="iconfont icon-fontsize" :class="formats.fontSize? ' ql-active' : ''"></picker>
				<!-- <view v-if="tools.indexOf('fontSize')>-1" :class="formats.fontSize === '24px' ? 'ql-active' : ''" class="iconfont icon-fontsize" data-name="fontSize" data-value="24px"></view> -->
				<view v-if="tools.indexOf('color')>-1" :style="(formats.color != '#FFFFFF'&&formats.color != '#fff'&&formats.color != '#ffffff')? 'color:' + formats.color : ''" class="iconfont icon-text_color" data-name="color" @tap.stop="openColor"></view>
				<view v-if="tools.indexOf('backgroundColor')>-1" :style="(formats.backgroundColor != '#FFFFFF'&&formats.backgroundColor != '#fff'&&formats.backgroundColor != '#ffffff') ? 'color:' + formats.backgroundColor : ''" class="iconfont icon-fontbgcolor" data-name="backgroundColor" @tap.stop="openColor"></view>

				<view v-if="tools.indexOf('insertDate')>-1" class="iconfont icon-date" @tap="insertDate"></view>
				<view v-if="tools.indexOf('list')>-1" class="iconfont icon--checklist" data-name="list" data-value="check"></view>
				<view v-if="tools.indexOf('ordered')>-1" :class="formats.list === 'ordered' ? 'ql-active' : ''" class="iconfont icon-youxupailie" data-name="list" data-value="ordered"></view>
				<view v-if="tools.indexOf('bullet')>-1" :class="formats.list === 'bullet' ? 'ql-active' : ''" class="iconfont icon-wuxupailie" data-name="list" data-value="bullet"></view>
				
				<view v-if="tools.indexOf('indent-reduce')>-1" class="iconfont icon-outdent" data-name="indent" data-value="-1"></view>
				<view v-if="tools.indexOf('indent-add')>-1" class="iconfont icon-indent" data-name="indent" data-value="+1"></view>
				<view v-if="tools.indexOf('insert-divider')>-1" class="iconfont icon-fengexian" @tap="insertDivider"></view>
				<view v-if="tools.indexOf('insert-image')>-1" class="iconfont icon-charutupian" @tap="selectImage"></view>
				<picker v-if="tools.indexOf('header')>-1" :range="headerlist" @change="formatsChange" @tap.stop="formatsChange" data-name="header" :class="'iconfont icon-format-header-'+(headerindex==0?1:headerindex)+(formats.header? ' ql-active' : '')"></picker>
				<!-- <view v-if="tools.indexOf('header')>-1" :class="formats.header === 1 ? 'ql-active' : ''" class="iconfont icon-format-header-1" data-name="header" :data-value="3"></view> -->
				<view v-if="tools.indexOf('script-sub')>-1" :class="formats.script === 'sub' ? 'ql-active' : ''" class="iconfont icon-zitixiabiao" data-name="script" data-value="sub"></view>
				<view v-if="tools.indexOf('script-super')>-1" :class="formats.script === 'super' ? 'ql-active' : ''" class="iconfont icon-zitishangbiao" data-name="script" data-value="super"></view>
				<view v-if="tools.indexOf('direction')>-1" :class="formats.direction === 'rtl' ? 'ql-active' : ''" class="iconfont icon-direction-rtl" data-name="direction" data-value="rtl"></view>
				<view v-if="tools.indexOf('clear')>-1" class="iconfont icon-shanchu" @tap="clear"></view>
			
			</view>
			<view class="flex-sub editor-wrapper">
				<editor id="editor" class="ql-container" :placeholder="placeholder" showImgSize showImgToolbar showImgResize @statuschange="onStatusChange" :read-only="readOnly" @ready="onEditorReady" @input="editorChange">
				</editor>
			</view>
		</view>
		<block v-if="modal.show">
			<view class="mask" />
			<view class="modal">
				<view class="modal_title">{{modal.title}}</view>
				<input type="text" class="modal_input" v-model="modal.value" />
				<view class="modal_foot">
					<view class="modal_button" @tap="modalCancel">取消</view>
					<view class="modal_button" style="color:#576b95;border-left:1px solid rgba(0,0,0,.1)" @tap="modalConfirm">确定</view>
				</view>
			</view>
		</block>
		<diy-color-picker v-model="showColorPicker" :hexcolor="hexcolor" @confirm="getColor"></diy-color-picker>
	</view>
</template>

<script>
	import Emitter from "../../libs/util/emitter.js";
	
	export default {
		mixins: [Emitter],
		emits: ["update:modelValue", "change"],
		props: {
			value: {
				type: String
			},
			modelValue:{
				type: String
			},
			placeholder: {
				type: String,
				default: '开始输入...'
			},
			height:{
				type:String,
				default: '100vh'
			},
			tools: {
				type: Array,
				default: function() {
					return [
						'bold',
						'italic',
						'underline',
						'strike',
						'align-left',
						'align-center',
						'align-right',
						'align-justify',
						'lineHeight',
						'letterSpacing',
						'marginTop',
						'previewarginBottom',
						'removeFormat',
						'fontFamily',
						'fontSize',
						'color',
						'backgroundColor',
						'insertDate',
						'list',
						'ordered',
						'bullet',
						'redo',
						'undo',
						'indent-reduce',
						'indent-add',
						'insert-divider',
						'insert-image',
						'header',
						'script-sub',
						'script-super',
						'clear',
						'direction'
					];
				}
			},
			//上传图片
			action:{
				type:String,
				default: '/sys/storage/upload'
			}/*,
			uploadFile: {
				type: Function
			}*/
		},
		data() {
			return {
				modal: {
					show: false,
					title: '',
					value: ''
				},
				showColorPicker:false,
				html: '',
				fontSizelist: [{
					code: "",
					name: "默认"
				}, {
					code: "x-small",
					name: "超小"
				}, {
					code: "small",
					name: "小"
				}, {
					code: "medium",
					name: "中等"
				}, {
					code: "large",
					name: "大"
				}, {
					code: "x-large",
					name: "超大"
				}, {
					code: "xx-large",
					name: "超级大"
				}],
				headerlist: ['默认', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6'],
				headerindex: 0,
				colorPickerName: '',
				hexcolor: "#0000ff",
				readOnly: false,
				formats: {},
				update: 0,
				uForm:{
					inputAlign: "",
					clearable: ""
				}
			}
		},
		watch: {
			value: function(newval) {
				this.html = newval
			},
			modelValue: function(newval) {
				this.html = newval
			},
			html: function(newvar) {
				if (this.editorCtx) {
					if (this.update == 0) {
						this.editorCtx.setContents({
							html: this.html
						});
					} else {
						this.update = 0
					}
				}
			}
		},
		created() {
			this.html = this.value;
		},
		mounted() {
			let parent = this.$u.$parent.call(this, 'u-form');
			if (parent) {
				Object.keys(this.uForm).map(key => {
					this.uForm[key] = parent[key];
				});
			}
		},
		methods: {
			openColor(e) {
				let dataset = e.target.dataset
				this.colorPickerName = dataset.name;
				this.hexcolor = dataset.value;
				this.showColorPicker = true
				// this.$refs.colorPicker.open();
			},
			getColor(e) {
				let msg = '';
				switch (this.colorPickerName) {
					case 'backgroundColor':
						if (e.hex.toUpperCase() == '#FFFFFF') {
							e.hex = '';
						}
						msg = '背景色';
						break;
					case 'color':
						msg = '颜色';
						break;
				}
				this.setformat(this.colorPickerName, e.hex, msg + e.hex);
			},

			modalConfirm() {
				let src = this.modal.value || '';
				if (src) {
					this.insertImage(src, null, null)
				}
				this.modal.show = false;
			},
			modalCancel() {
				this.modal.show = false;
			},

			formatsChange(e) {
				if (e.type == 'click') { //不让上层触发点击事件
					return false;
				}

				let value = e.detail.value;
				let name = e.target.dataset.name
				if (name == 'header') {
					this.headerindex = value;
					if (value == 0) {
						value = null;
					}
				} else if (name == 'fontSize') {
					value = this.fontSizelist[value].code;
				} else if (name == 'size') {
					value = value > 0 ? value : 1;
				}
				let msg = name + '设置成功';
				console.log(value);
				this.setformat(name, value, msg)
				return false;
			},
			editorChange(e) {
				this.update = 1
				this.$emit('input', e.detail.html);
				
				this.$emit("update:modelValue", e.detail.html);
				
				// vue 原生的方法 return 出去
				this.$emit("change", e.detail.html);
				// 将当前的值发送到 u-form-item 进行校验
				this.dispatch("u-form-item", "onFieldBlur", e.detail.html);
			},
			readOnlyChange() {
				this.readOnly = !this.readOnly
			},
			onEditorReady() {
				const query = uni.createSelectorQuery().in(this);
				query.select('#editor').context((res) => {
					this.editorCtx = res.context
					if (this.html) {
						this.editorCtx.setContents({
							html: this.html
						});
					}
				}).exec()
			},
			undo() {
				this.editorCtx.undo()
			},
			redo() {
				this.editorCtx.redo()
			},
			format(e) {
				let {
					name,
					value
				} = e.target.dataset
				if (!name) return
				// console.log('format', name, value)
				this.editorCtx.format(name, value)

			},
			setformat(name, value, msg) {
				this.editorCtx.format(name, value);
				// this.toast(msg);
			},
			toast(msg) {
				uni.showToast({
					duration: 600,
					icon: 'none',
					title: msg
				});
			},
			onStatusChange(e) {
				const formats = e.detail
				this.formats = formats
			},
			insertDivider() {
				this.editorCtx.insertDivider({
					success: function() {
						console.log('insert divider success')
					}
				})
			},
			clear() {
				uni.showModal({
					content: "确定清空编辑器内容?",
					complete: (rs) => {
						if (rs.confirm) {
							this.editorCtx.clear({
								success: function(res) {
									console.log("clear success")
								}
							})
						}
					}
				})
			},
			removeFormat() {
				this.editorCtx.removeFormat()
			},
			insertDate() {
				const date = new Date()
				let month = date.getMonth() + 1
				if(month<10){
					month = "0" +month
				}
				let day = date.getDate()
				if(day<10){
					day = "0" + day
				}
				const formatDate = `${date.getFullYear()}-${month}-${day}`
				this.editorCtx.insertText({
					text: formatDate
				})
			},
			selectImage() {
				let thiz = this
				// 本地选取 自已处理上传方法,包括选择文件
				uni.chooseImage({
					count: 9,
					sizeType: ['original', 'compressed'], // 可以指定是原图还是压缩图,默认二者都有
					sourceType: ['album', 'camera'], // 可以指定来源是相册还是相机,默认二者都有javascript:;
					success: function (res) {
						// 返回选定照片的本地文件路径列表,tempFilePath可以作为img标签的src属性显示图片
						let tempFilePaths = res.tempFilePaths;
						for (let i = 0; i < tempFilePaths.length; i++) {
							let header = {}
							if(getApp().globalData.currentPage && getApp().globalData.currentPage.$session){
								header.Authorization =  getApp().globalData.currentPage.$session.getToken()||''
							}
							uni.uploadFile({
								url: getApp().globalData.currentPage && getApp().globalData.currentPage.$http?getApp().globalData.currentPage.$http.setUrl(thiz.action,{}):thiz.action,
								filePath: tempFilePaths[i],
								name: 'file',
								header:header,
								success(res) {
									let data = getApp().globalData.currentPage.$tools.fromJson(res.data);
									let url = ''
									if(data.url){
										url = getApp().globalData.currentPage.$tools.renderImage(data.url);
									}
									if(data.data &&getApp().globalData.currentPage.$tools.isObject(data.data) && data.data.url){
										url = getApp().globalData.currentPage.$tools.renderImage(data.data.url);
									}
									if(url){
										thiz.insertImage(url,null,null);
									}
								}
							});
						}
					},
				});
				// uni.showActionSheet({
				// 	itemList: ['本地选取', '远程链接'],
				// 	success: res => {
				// 		if (res.tapIndex === 0) {
							
				// 		} else {
				// 			thiz.modal = {
				// 				show: true,
				// 				title: '图片链接',
				// 				value: ''
				// 			}
				// 		}
				// 	}
				// })
			},
			insertImage(src, data, alt) {
				debugger
				let inserdata = {
					src: src
				}
				if (data) {
					inserdata.data = data
				}
				if (alt) {
					inserdata.alt = alt
				}
				this.editorCtx.insertImage({
					...inserdata,
					success: function() {
						console.log('insert image success')
					}
				})
			}
		}
	}
</script>

<style>
	@import "./editor-icon.css";

	.container {
		width: 100%;
	}

	.wrapper {
		width: 100%;
	}

	.editor-wrapper {
		width: 100%;
		background: #fff;
	}

	.iconfont {
		display: inline-block;
		padding: 8px 8px;
		cursor: pointer;
		font-size: 25px;
	}

	.toolbar {
		box-sizing: border-box;
		border-bottom: 0;
		font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
	}

	.ql-container {
		box-sizing: border-box;
		padding: 10px;
		width: 100%;
		min-height: 30vh;
		height: 100%;
		font-size: 16px;
		line-height: 1.5;
	}

	.ql-active {
		color: #06c;
	}

	/* 模态框 */
	.modal {
		position: fixed;
		z-index: 999999;
		top: 50%;
		left: 16px;
		right: 16px;
		background-color: #fff;
		border-radius: 12px;
		transform: translateY(-50%);
	}

	.modal_title {
		padding: 32px 24px 16px;
		font-size: 17px;
		font-weight: 700;
		text-align: center;
	}

	.modal_input {
		display: block;
		padding: 5px;
		line-height: 2.5em;
		height: 2.5em;
		margin: 0 24px 32px 24px;
		font-size: 14px;
		border: 1px solid #dfe2e5;
	}

	.modal_foot {
		display: flex;
		line-height: 56px;
		font-weight: 700;
		border-top: 1px solid rgba(0, 0, 0, .1);
	}

	.modal_button {
		flex: 1;
		text-align: center;
	}

	/* 遮罩版 */
	.mask {
		position: fixed;
		z-index: 99999;
		top: 0;
		right: 0;
		bottom: 0;
		left: 0;
		background-color: black;
		opacity: 0.5;
	}
</style>

组件调用

<template>
	<view class="container container329152">
		<u-form-item :borderBottom="false" class="diygw-col-24" labelPosition="top" prop="editor">
			<diy-editor height="500px" v-model="editor"></diy-editor>
		</u-form-item>
		<view class="clearfix"></view>
	</view>
</template>

<script>
	export default {
		data() {
			return {
				//用户全局信息
				userInfo: {},
				//页面传参
				globalOption: {},
				//自定义全局变量
				globalData: {},
				editor: ''
			};
		},
		onShow() {
			this.setCurrentPage(this);
		},
		onLoad(option) {
			this.setCurrentPage(this);
			if (option) {
				this.setData({
					globalOption: this.getOption(option)
				});
			}

			this.init();
		},
		methods: {
			async init() {},
			// 新增方法 自定义方法
			async testFunction(param) {
				let thiz = this;
				console.log(this.checkbox);
			}
		}
	};
</script>

<style lang="scss" scoped>
	.container329152 {
	}
</style>
相关推荐
汤姆yu3 小时前
基于微信小程序的乡村旅游系统
微信小程序·旅游·乡村旅游
曲辒净3 小时前
微信小程序实现二维码海报保存分享功能
微信小程序·小程序
何极光5 小时前
uniapp小程序样式穿透
前端·小程序·uni-app
User_undefined17 小时前
uniapp Native.js 调用安卓arr原生service
android·javascript·uni-app
流氓也是种气质 _Cookie18 小时前
uniapp blob格式转换为video .mp4文件使用ffmpeg工具
ffmpeg·uni-app
oil欧哟18 小时前
🤔认真投入一个月做的小程序,能做成什么样子?有人用吗?
前端·vue.js·微信小程序
爱笑的眼睛1120 小时前
uniapp 极速上手鸿蒙开发
华为·uni-app·harmonyos
汤姆yu1 天前
基于微信小程序的消防隐患在线举报系统
微信小程序·小程序·消防隐患
郏国上1 天前
微信小程序的消息头增加的字段不能有下滑线,字段大写字母自动转换消息字母
微信小程序·小程序·
小刘鸭!1 天前
notepad++快捷键-多行编辑中如何使所有行的光标都向后移动一个单词的长度(每行单词长度不一定一致)
前端·javascript·notepad++