|
@@ -3,16 +3,16 @@
|
|
|
v-bind="$attrs"
|
|
|
:id="id"
|
|
|
ref="richText"
|
|
|
+ model-events="change keyup undo redo setContent"
|
|
|
:value="value"
|
|
|
:class="['rich-text', isBorder ? 'is-border' : '']"
|
|
|
:init="init"
|
|
|
- @input="updateValue"
|
|
|
v-on="$listeners"
|
|
|
/>
|
|
|
</template>
|
|
|
|
|
|
<script>
|
|
|
-import 'tinymce/tinymce';
|
|
|
+import tinymce from 'tinymce/tinymce';
|
|
|
import Editor from '@tinymce/tinymce-vue';
|
|
|
|
|
|
import 'tinymce/icons/default/icons';
|
|
@@ -37,6 +37,7 @@ import 'tinymce/plugins/autoresize'; // 自动调整大小插件
|
|
|
import 'tinymce/plugins/ax_wordlimit'; // 字数限制插件
|
|
|
|
|
|
import { getRandomNumber } from '@/utils';
|
|
|
+import { isNodeType } from '@/utils/validate';
|
|
|
import { fileUpload } from '@/api/app';
|
|
|
|
|
|
export default {
|
|
@@ -66,6 +67,20 @@ export default {
|
|
|
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: 500,
|
|
|
+ },
|
|
|
+ isFill: {
|
|
|
+ type: Boolean,
|
|
|
+ default: false,
|
|
|
+ },
|
|
|
},
|
|
|
data() {
|
|
|
return {
|
|
@@ -82,77 +97,204 @@ export default {
|
|
|
width: '100%',
|
|
|
autoresize_bottom_margin: 0,
|
|
|
plugins: 'link lists image hr media autoresize ax_wordlimit',
|
|
|
- /* eslint-disable max-len */
|
|
|
- toolbar:
|
|
|
- 'fontselect fontsizeselect forecolor backcolor | underline | bold italic strikethrough alignleft aligncenter alignright | bullist numlist | image media | link blockquote hr',
|
|
|
- menubar: false,
|
|
|
- branding: false,
|
|
|
- statusbar: false,
|
|
|
+ toolbar: this.toolbar, // 工具栏
|
|
|
+ contextmenu: false, // 右键菜单
|
|
|
+ menubar: false, // 菜单栏
|
|
|
+ branding: false, // 品牌
|
|
|
+ statusbar: false, // 状态栏
|
|
|
// 字数限制
|
|
|
- ax_wordlimit_num: 500,
|
|
|
+ ax_wordlimit_num: this.wordlimitNum,
|
|
|
ax_wordlimit_callback(editor) {
|
|
|
editor.execCommand('undo');
|
|
|
},
|
|
|
- /**
|
|
|
- * 图片上传自定义逻辑函数
|
|
|
- * @param {object} blobInfo 文件数据
|
|
|
- * @param {Function} success 成功回调函数
|
|
|
- * @param {Function} fail 失败回调函数
|
|
|
- */
|
|
|
- images_upload_handler(blobInfo, success, fail) {
|
|
|
- let file = blobInfo.blob();
|
|
|
+ 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 : '',
|
|
|
+ },
|
|
|
+ };
|
|
|
+ },
|
|
|
+ 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)
|
|
|
+ .then(({ file_info_list }) => {
|
|
|
+ if (file_info_list.length > 0) {
|
|
|
+ success(file_info_list[0].file_url);
|
|
|
+ } 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)
|
|
|
.then(({ file_info_list }) => {
|
|
|
if (file_info_list.length > 0) {
|
|
|
- success(file_info_list[0].file_url);
|
|
|
+ callback(file_info_list[0].file_url);
|
|
|
} else {
|
|
|
- fail('上传失败');
|
|
|
+ callback('');
|
|
|
}
|
|
|
})
|
|
|
.catch(() => {
|
|
|
- fail('上传失败');
|
|
|
- });
|
|
|
- },
|
|
|
- file_picker_types: 'media', // 文件上传类型
|
|
|
- /**
|
|
|
- * 文件上传自定义逻辑函数
|
|
|
- * @param {Function} callback
|
|
|
- * @param {String} value
|
|
|
- * @param {object} meta
|
|
|
- */
|
|
|
- file_picker_callback(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)
|
|
|
- .then(({ file_info_list }) => {
|
|
|
- if (file_info_list.length > 0) {
|
|
|
- callback(file_info_list[0].file_url);
|
|
|
- } else {
|
|
|
- callback('');
|
|
|
- }
|
|
|
- })
|
|
|
- .catch(() => {
|
|
|
- callback('');
|
|
|
- });
|
|
|
+ callback('');
|
|
|
});
|
|
|
- }
|
|
|
- },
|
|
|
- },
|
|
|
- };
|
|
|
- },
|
|
|
- methods: {
|
|
|
- updateValue(data) {
|
|
|
- this.$emit('update:value', data);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ },
|
|
|
+ /**
|
|
|
+ * 初始化编辑器实例回调函数
|
|
|
+ * @param {Editor} editor 编辑器实例
|
|
|
+ */
|
|
|
+ initInstanceCallback(editor) {
|
|
|
+ editor.on('SetContent Undo Redo ViewUpdate keyup mousedown', () => {
|
|
|
+ this.$emit('hideContentmenu');
|
|
|
+ });
|
|
|
+
|
|
|
+ editor.on('click', (e) => {
|
|
|
+ if (e.target.classList.contains('rich-fill')) {
|
|
|
+ editor.selection.select(e.target); // 选中填空
|
|
|
+ let { offsetLeft, offsetTop } = e.target;
|
|
|
+ this.$emit('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.$emit('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}px ${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.$emit('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.$emit('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');
|
|
|
},
|
|
|
},
|
|
|
};
|