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

简介: 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>



目录
相关文章
|
2天前
|
存储 缓存 关系型数据库
MySQL事务日志-Redo Log工作原理分析
事务的隔离性和原子性分别通过锁和事务日志实现,而持久性则依赖于事务日志中的`Redo Log`。在MySQL中,`Redo Log`确保已提交事务的数据能持久保存,即使系统崩溃也能通过重做日志恢复数据。其工作原理是记录数据在内存中的更改,待事务提交时写入磁盘。此外,`Redo Log`采用简单的物理日志格式和高效的顺序IO,确保快速提交。通过不同的落盘策略,可在性能和安全性之间做出权衡。
1517 4
|
29天前
|
弹性计算 人工智能 架构师
阿里云携手Altair共拓云上工业仿真新机遇
2024年9月12日,「2024 Altair 技术大会杭州站」成功召开,阿里云弹性计算产品运营与生态负责人何川,与Altair中国技术总监赵阳在会上联合发布了最新的“云上CAE一体机”。
阿里云携手Altair共拓云上工业仿真新机遇
|
5天前
|
人工智能 Rust Java
10月更文挑战赛火热启动,坚持热爱坚持创作!
开发者社区10月更文挑战,寻找热爱技术内容创作的你,欢迎来创作!
501 19
|
2天前
|
存储 SQL 关系型数据库
彻底搞懂InnoDB的MVCC多版本并发控制
本文详细介绍了InnoDB存储引擎中的两种并发控制方法:MVCC(多版本并发控制)和LBCC(基于锁的并发控制)。MVCC通过记录版本信息和使用快照读取机制,实现了高并发下的读写操作,而LBCC则通过加锁机制控制并发访问。文章深入探讨了MVCC的工作原理,包括插入、删除、修改流程及查询过程中的快照读取机制。通过多个案例演示了不同隔离级别下MVCC的具体表现,并解释了事务ID的分配和管理方式。最后,对比了四种隔离级别的性能特点,帮助读者理解如何根据具体需求选择合适的隔离级别以优化数据库性能。
179 1
|
8天前
|
JSON 自然语言处理 数据管理
阿里云百炼产品月刊【2024年9月】
阿里云百炼产品月刊【2024年9月】,涵盖本月产品和功能发布、活动,应用实践等内容,帮助您快速了解阿里云百炼产品的最新动态。
阿里云百炼产品月刊【2024年9月】
|
21天前
|
存储 关系型数据库 分布式数据库
GraphRAG:基于PolarDB+通义千问+LangChain的知识图谱+大模型最佳实践
本文介绍了如何使用PolarDB、通义千问和LangChain搭建GraphRAG系统,结合知识图谱和向量检索提升问答质量。通过实例展示了单独使用向量检索和图检索的局限性,并通过图+向量联合搜索增强了问答准确性。PolarDB支持AGE图引擎和pgvector插件,实现图数据和向量数据的统一存储与检索,提升了RAG系统的性能和效果。
|
9天前
|
Linux 虚拟化 开发者
一键将CentOs的yum源更换为国内阿里yum源
一键将CentOs的yum源更换为国内阿里yum源
451 5
|
7天前
|
存储 人工智能 搜索推荐
数据治理,是时候打破刻板印象了
瓴羊智能数据建设与治理产品Datapin全面升级,可演进扩展的数据架构体系为企业数据治理预留发展空间,推出敏捷版用以解决企业数据量不大但需构建数据的场景问题,基于大模型打造的DataAgent更是为企业用好数据资产提供了便利。
314 2
|
23天前
|
人工智能 IDE 程序员
期盼已久!通义灵码 AI 程序员开启邀测,全流程开发仅用几分钟
在云栖大会上,阿里云云原生应用平台负责人丁宇宣布,「通义灵码」完成全面升级,并正式发布 AI 程序员。
|
25天前
|
机器学习/深度学习 算法 大数据
【BetterBench博士】2024 “华为杯”第二十一届中国研究生数学建模竞赛 选题分析
2024“华为杯”数学建模竞赛,对ABCDEF每个题进行详细的分析,涵盖风电场功率优化、WLAN网络吞吐量、磁性元件损耗建模、地理环境问题、高速公路应急车道启用和X射线脉冲星建模等多领域问题,解析了问题类型、专业和技能的需要。
2608 22
【BetterBench博士】2024 “华为杯”第二十一届中国研究生数学建模竞赛 选题分析