| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490 | <template>  <div class="rich-wrapper">    <Editor      v-bind="$attrs"      :id="id"      ref="richText"      model-events="change keyup undo redo setContent"      :value="value"      :class="['rich-text', isBorder ? 'is-border' : '']"      :init="init"      v-on="$listeners"      @onBlur="handleRichTextBlur"    />    <div v-show="isShow" :style="contentmenu" class="contentmenu">      <SvgIcon icon-class="slice" size="16" @click="setFill" />      <span class="button" @click="setFill">设为填空</span>      <span class="line"></span>      <SvgIcon icon-class="close-circle" size="16" @click="deleteFill" />      <span class="button" @click="deleteFill">删除填空</span>    </div>  </div></template><script>import tinymce from 'tinymce/tinymce';import Editor from '@tinymce/tinymce-vue';import 'tinymce/icons/default/icons';import 'tinymce/themes/silver';// 引入富文本编辑器主题的js和cssimport 'tinymce/themes/silver/theme.min';import 'tinymce/skins/ui/oxide/skin.min.css';// 扩展插件import 'tinymce/plugins/image';import 'tinymce/plugins/link';// import 'tinymce/plugins/code';// import 'tinymce/plugins/table';import 'tinymce/plugins/lists';// import 'tinymce/plugins/wordcount'; // 字数统计插件import 'tinymce/plugins/media'; // 插入视频插件// import 'tinymce/plugins/template'; // 模板插件// import 'tinymce/plugins/fullscreen'; // 全屏插件import 'tinymce/plugins/paste'; // 粘贴插件// import 'tinymce/plugins/preview'; // 预览插件import 'tinymce/plugins/hr';import 'tinymce/plugins/autoresize'; // 自动调整大小插件import 'tinymce/plugins/ax_wordlimit'; // 字数限制插件import { getRandomNumber } from '@/utils';import { isNodeType } from '@/utils/validate';import { fileUpload } from '@/api/app';import { addTone, handleToneValue } from '@/views/exercise_questions/data/common';export default {  name: 'RichText',  components: {    Editor,  },  inheritAttrs: false,  props: {    inline: {      type: Boolean,      default: false,    },    placeholder: {      type: String,      default: '输入内容',    },    value: {      type: String,      required: true,    },    height: {      type: [Number, String],      default: 52,    },    isBorder: {      type: Boolean,      default: false,    },    toolbar: {      type: [String, Boolean],      /* eslint-disable max-len */      default:        'fontselect fontsizeselect forecolor backcolor | underline | bold italic strikethrough alignleft aligncenter alignright | bullist numlist | image media | link blockquote hr',    },    wordlimitNum: {      type: [Number, Boolean],      default: 1000,    },    isFill: {      type: Boolean,      default: false,    },    fontSize: {      type: Number,      default: 16,    },    isHasSpace: {      type: Boolean,      default: false,    },  },  data() {    return {      isShow: false,      contentmenu: {        top: 0,        left: 0,      },      id: getRandomNumber(),      init: {        inline: this.inline,        font_size: this.fontSize,        language_url: `${process.env.BASE_URL}tinymce/langs/zh_CN.js`,        placeholder: this.placeholder,        language: 'zh_CN',        skin_url: `${process.env.BASE_URL}tinymce/skins/ui/oxide`,        content_css: `${process.env.BASE_URL}tinymce/skins/content/${this.isHasSpace ? 'index-nospace' : 'index'}.css`,        min_height: this.height,        width: '100%',        autoresize_bottom_margin: 0,        plugins: 'link lists image hr media autoresize ax_wordlimit paste',        toolbar: this.toolbar, // 工具栏        contextmenu: false, // 右键菜单        menubar: false, // 菜单栏        branding: false, // 品牌        statusbar: false, // 状态栏        setup(editor) {          editor.on('init', () => {            editor.getBody().style.fontSize = `${this.font_size}pt`; // 设置默认字体大小            editor.getBody().style.fontFamily = 'Arial'; // 设置默认字体          });          editor.on('click', () => {            if (editor?.queryCommandState('ToggleToolbarDrawer')) {              editor.execCommand('ToggleToolbarDrawer');            }          });        },        font_formats:          '楷体=楷体,微软雅黑;' +          '黑体=黑体,微软雅黑;' +          '宋体=宋体,微软雅黑;' +          'Arial=arial,helvetica,sans-serif;' +          'Times New Roman=times new roman,times,serif;' +          '拼音=League;',        // 字数限制        fontsize_formats: '8pt 10pt 11pt 12pt 14pt 16pt 18pt 20pt 22pt 24pt 26pt 28pt 30pt 32pt 34pt 36pt',        ax_wordlimit_num: this.wordlimitNum,        ax_wordlimit_callback(editor) {          editor.execCommand('undo');        },        media_filter_html: false,        images_upload_handler: this.imagesUploadHandler,        file_picker_types: 'media', // 文件上传类型        file_picker_callback: this.filePickerCallback,        init_instance_callback: this.isFill ? this.initInstanceCallback : '',        paste_enable_default_filters: false, // 禁用默认的粘贴过滤器        // 粘贴预处理        paste_preprocess(plugin, args) {          let content = args.content;          // 使用正则表达式去掉 style 中的 background 属性          content = content.replace(/background(-color)?:[^;]+;/g, '');          args.content = content;        },        // 指定在 WebKit 中粘贴时要保留的样式        paste_webkit_styles:          'color font font-size font-family font-weight width height margin padding line-height text-align border border-radius',      },    };  },  created() {    window.addEventListener('click', this.hideToolbarDrawer);    if (this.isFill) {      window.addEventListener('click', this.hideContentmenu);    }  },  beforeDestroy() {    window.removeEventListener('click', this.hideToolbarDrawer);    if (this.isFill) {      window.removeEventListener('click', this.hideContentmenu);    }  },  methods: {    /**     * 图片上传自定义逻辑函数     * @param {object} blobInfo 文件数据     * @param {Function} success 成功回调函数     * @param {Function} fail 失败回调函数     */    imagesUploadHandler(blobInfo, success, fail) {      let file = blobInfo.blob();      const formData = new FormData();      formData.append(file.name, file, file.name);      fileUpload('Mid', formData, { isGlobalprogress: true })        .then(({ file_info_list }) => {          if (file_info_list.length > 0) {            success(file_info_list[0].file_url_open);          } else {            fail('上传失败');          }        })        .catch(() => {          fail('上传失败');        });    },    /**     * 文件上传自定义逻辑函数     * @param {Function} callback     * @param {String} value     * @param {object} meta     */    filePickerCallback(callback, value, meta) {      if (meta.filetype === 'media') {        let filetype = '.mp3, .mp4';        let input = document.createElement('input');        input.setAttribute('type', 'file');        input.setAttribute('accept', filetype);        input.click();        input.addEventListener('change', () => {          let file = input.files[0];          const formData = new FormData();          formData.append(file.name, file, file.name);          fileUpload('Mid', formData, { isGlobalprogress: true })            .then(({ file_info_list }) => {              if (file_info_list.length > 0) {                callback(file_info_list[0].file_url_open);              } else {                callback('');              }            })            .catch(() => {              callback('');            });        });      }    },    /**     * 初始化编辑器实例回调函数     * @param {Editor} editor 编辑器实例     */    initInstanceCallback(editor) {      editor.on('SetContent Undo Redo ViewUpdate keyup mousedown', () => {        this.hideContentmenu();      });      editor.on('click', (e) => {        if (e.target.classList.contains('rich-fill')) {          editor.selection.select(e.target); // 选中填空          let { offsetLeft, offsetTop } = e.target;          this.showContentmenu({            pixelsFromLeft: offsetLeft - 14,            pixelsFromTop: offsetTop,          });        }      });      let mouseX = 0;      editor.on('mousedown', (e) => {        mouseX = e.offsetX;      });      editor.on('mouseup', (e) => {        let start = editor.selection.getStart();        let end = editor.selection.getEnd();        let rng = editor.selection.getRng();        if (start !== end || rng.collapsed) {          this.hideContentmenu();          return;        }        if (e.offsetX < mouseX) {          mouseX = e.offsetX;        }        if (isNodeType(start, 'span')) {          start = start.parentNode;        }        // 获取文本内容和起始偏移位置        let text = start.textContent;        let startOffset = rng.startOffset;        let previousSibling = rng.startContainer.previousSibling;        // 判断是否选中的是 span 标签        let isSpan = isNodeType(rng.startContainer.parentNode, 'span');        if (isSpan) {          previousSibling = rng.startContainer.parentNode.previousSibling;        }        // 计算起始偏移位置        while (previousSibling) {          startOffset += previousSibling.textContent.length;          previousSibling = previousSibling.previousSibling;        }        // 获取起始偏移位置前的文本内容        const textBeforeOffset = text.substring(0, startOffset);        /* 使用 Canvas API测量文本宽度 */        // 获取字体大小和行高        let computedStyle = window.getComputedStyle(start);        const fontSize = parseFloat(computedStyle.fontSize);        const canvas = document.createElement('canvas');        const context = canvas.getContext('2d');        context.font = `${fontSize}pt ${computedStyle.fontFamily}`;        // 计算文字距离左侧的像素位置        const width = context.measureText(textBeforeOffset).width;        const lineHeight = context.measureText('M').fontBoundingBoxAscent + 3.8; // 获取行高        /* 计算偏移位置 */        const computedWidth = computedStyle.width.replace('px', ''); // 获取编辑器宽度        let row = width / computedWidth; // 计算选中文本在第几行        row = row % 1 > 0.8 ? Math.ceil(row) : Math.floor(row);        const offsetTop = start.offsetTop; // 获取选中文本距离顶部的像素位置        let pixelsFromTop = offsetTop + lineHeight * row; // 计算选中文本距离顶部的像素位置        this.showContentmenu({          pixelsFromLeft: mouseX,          pixelsFromTop,        });      });    },    // 删除填空    deleteContent() {      let editor = tinymce.get(this.id);      let start = editor.selection.getStart();      if (isNodeType(start, 'span')) {        let textContent = start.textContent;        let content = editor.selection.getContent();        let str = textContent.split(content);        start.remove();        editor.selection.setContent(str.join(content));      } else {        this.collapse();      }    },    // 设置填空    setContent() {      let editor = tinymce.get(this.id);      let start = editor.selection.getStart();      let content = editor.selection.getContent();      if (isNodeType(start, 'span')) {        let textContent = start.textContent;        let str = textContent.split(content);        start.remove();        editor.selection.setContent(str.join(this.getSpanString(content)));      } else {        let str = this.replaceSpanString(content);        editor.selection.setContent(this.getSpanString(str));      }    },    // 折叠选区    collapse() {      let editor = tinymce.get(this.id);      let rng = editor.selection.getRng();      if (!rng.collapsed) {        this.hideContentmenu();        editor.selection.collapse();      }    },    // 获取 span 标签    getSpanString(str) {      return `<span class="rich-fill" style="text-decoration: underline;cursor: pointer;">${str}</span>`;    },    // 去除 span 标签    replaceSpanString(str) {      return str.replace(/<span\b[^>]*>(.*?)<\/span>/gi, '$1');    },    handleRichTextBlur() {      this.$emit('handleRichTextBlur');      let content = tinymce.get(this.id).getContent();      // 判断富文本中是否有 字母+数字+空格 的组合,如果没有,直接返回      if (!content.match(/[a-zA-Z]+\d(\s| )*/)) {        return;      }      // 用标签分割富文本,保留标签      let reg = /(<[^>]+>)/g;      let text = content        .split(reg)        .filter((item) => item)        // 如果是标签,直接返回        // 如果是文本,将文本按空格分割为数组,如果是拼音,将拼音转为带音调的拼音        .map((item) => {          return reg.test(item) ? item : item.split(/\s+/).map((item) => handleToneValue(item));        })        // 如果是标签,直接返回        // 二维数组,转为拼音,并打平为一维数组        .map((item) => {          if (/<[^>]+>/g.test(item)) return item;          return item            .map((li) =>              li.map(({ number, con }) => (number && con ? addTone(Number(number), con) : number || con || '')),            )            .flat();        })        // 如果是数组,将数组字符串每两个之间加一个空格        .map((item) => {          if (typeof item === 'string') return item;          return item.join(' ');        })        .join('');      // 更新 v-model      this.$emit('input', text);    },    // 设置填空    setFill() {      this.setContent();      this.hideContentmenu();    },    // 删除填空    deleteFill() {      this.deleteContent();      this.hideContentmenu();    },    // 隐藏工具栏抽屉    hideToolbarDrawer() {      let editor = tinymce.get(this.id);      if (editor.queryCommandState('ToggleToolbarDrawer')) {        editor.execCommand('ToggleToolbarDrawer');      }    },    // 隐藏填空右键菜单    hideContentmenu() {      this.isShow = false;    },    showContentmenu({ pixelsFromLeft, pixelsFromTop }) {      this.isShow = true;      this.contentmenu = {        left: `${pixelsFromLeft + 14}px`,        top: `${pixelsFromTop - 18}px`,      };    },  },};</script><style lang="scss" scoped>.rich-text {  :deep + .tox {    .tox-sidebar-wrap {      border: 1px solid $fill-color;      border-radius: 4px;      &:hover {        border-color: #c0c4cc;      }    }    &.tox-tinymce {      border-width: 0;      border-radius: 0;      .tox-edit-area__iframe {        background-color: $fill-color;      }    }    &:not(.tox-tinymce-inline) .tox-editor-header {      box-shadow: none;    }  }  &.is-border {    :deep + .tox.tox-tinymce {      border: $border;      border-radius: 4px;    }  }}.contentmenu {  position: absolute;  z-index: 999;  display: flex;  column-gap: 4px;  align-items: center;  padding: 4px 8px;  font-size: 14px;  background-color: #fff;  border-radius: 2px;  box-shadow: 0 1px 10px 0 rgba(0, 0, 0, 10%);  .svg-icon,  .button {    cursor: pointer;  }  .line {    min-height: 16px;    margin: 0 4px;  }}</style>
 |