在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>