123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501 |
- <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和css
- import '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, '');
- // 去掉 p 标签 style 中的 line-height 属性
- content = content.replace(/<p[^>]+>/g, (match) => match.replace(/line-height:[^;]+;/g, ''));
- args.content = content;
- },
- // 指定在 WebKit 中粘贴时要保留的样式
- paste_webkit_styles:
- 'display gap flex-wrap color min-height font font-size font-family font-weight width height margin-bottom margin padding line-height text-align border border-radius white-space',
- },
- };
- },
- 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();
- // 判断富文本中是否有 字母+数字+空格 的组合,如果没有,直接返回
- let isHasPinyin = content
- .split(/<[^>]+>/g)
- .filter((item) => item)
- .some((item) => item.match(/[a-zA-Z]+\d(\s| )*/));
- if (!isHasPinyin) {
- return;
- }
- // 用标签分割富文本,保留标签
- let reg = /(<[^>]+>)/g;
- let text = content
- .split(reg)
- .filter((item) => item)
- // 如果是标签,直接返回
- // 如果是文本,将文本按空格分割为数组,如果是拼音,将拼音转为带音调的拼音
- .map((item) => {
- // 重置正则,使用全局匹配 g 时需要重置,否则会出现匹配不到的情况,因为 lastIndex 会一直累加,test 方法会从 lastIndex 开始匹配
- reg.lastIndex = 0;
- if (reg.test(item)) {
- return item;
- }
- return 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>
|