RichText.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. <template>
  2. <Editor
  3. v-bind="$attrs"
  4. :id="id"
  5. ref="richText"
  6. model-events="change keyup undo redo setContent"
  7. :value="value"
  8. :class="['rich-text', isBorder ? 'is-border' : '']"
  9. :init="init"
  10. v-on="$listeners"
  11. @onBlur="handleRichTextBlur"
  12. />
  13. </template>
  14. <script>
  15. import tinymce from 'tinymce/tinymce';
  16. import Editor from '@tinymce/tinymce-vue';
  17. import 'tinymce/icons/default/icons';
  18. import 'tinymce/themes/silver';
  19. // 引入富文本编辑器主题的js和css
  20. import 'tinymce/themes/silver/theme.min';
  21. import 'tinymce/skins/ui/oxide/skin.min.css';
  22. // 扩展插件
  23. import 'tinymce/plugins/image';
  24. import 'tinymce/plugins/link';
  25. // import 'tinymce/plugins/code';
  26. // import 'tinymce/plugins/table';
  27. import 'tinymce/plugins/lists';
  28. // import 'tinymce/plugins/wordcount'; // 字数统计插件
  29. import 'tinymce/plugins/media'; // 插入视频插件
  30. // import 'tinymce/plugins/template'; // 模板插件
  31. // import 'tinymce/plugins/fullscreen'; // 全屏插件
  32. // import 'tinymce/plugins/paste';
  33. // import 'tinymce/plugins/preview'; // 预览插件
  34. import 'tinymce/plugins/hr';
  35. import 'tinymce/plugins/autoresize'; // 自动调整大小插件
  36. import 'tinymce/plugins/ax_wordlimit'; // 字数限制插件
  37. import { getRandomNumber } from '@/utils';
  38. import { isNodeType } from '@/utils/validate';
  39. import { fileUpload } from '@/api/app';
  40. export default {
  41. name: 'RichText',
  42. components: {
  43. Editor,
  44. },
  45. inheritAttrs: false,
  46. props: {
  47. inline: {
  48. type: Boolean,
  49. default: false,
  50. },
  51. placeholder: {
  52. type: String,
  53. default: '输入内容',
  54. },
  55. value: {
  56. type: String,
  57. required: true,
  58. },
  59. height: {
  60. type: [Number, String],
  61. default: 52,
  62. },
  63. isBorder: {
  64. type: Boolean,
  65. default: false,
  66. },
  67. toolbar: {
  68. type: [String, Boolean],
  69. /* eslint-disable max-len */
  70. default:
  71. 'fontselect fontsizeselect forecolor backcolor | underline | bold italic strikethrough alignleft aligncenter alignright | bullist numlist | image media | link blockquote hr',
  72. },
  73. wordlimitNum: {
  74. type: [Number, Boolean],
  75. default: 1000,
  76. },
  77. isFill: {
  78. type: Boolean,
  79. default: false,
  80. },
  81. fontSize: {
  82. type: Number,
  83. default: 16,
  84. },
  85. },
  86. data() {
  87. return {
  88. id: getRandomNumber(),
  89. init: {
  90. inline: this.inline,
  91. font_size: this.fontSize,
  92. language_url: `${process.env.BASE_URL}tinymce/langs/zh_CN.js`,
  93. placeholder: this.placeholder,
  94. language: 'zh_CN',
  95. skin_url: `${process.env.BASE_URL}tinymce/skins/ui/oxide`,
  96. content_css: `${process.env.BASE_URL}tinymce/skins/content/index.css`,
  97. min_height: this.height,
  98. width: '100%',
  99. autoresize_bottom_margin: 0,
  100. plugins: 'link lists image hr media autoresize ax_wordlimit',
  101. toolbar: this.toolbar, // 工具栏
  102. contextmenu: false, // 右键菜单
  103. menubar: false, // 菜单栏
  104. branding: false, // 品牌
  105. statusbar: false, // 状态栏
  106. setup(editor) {
  107. editor.on('init', () => {
  108. editor.getBody().style.fontSize = `${this.font_size}pt`; // 设置默认字体大小
  109. editor.getBody().style.fontFamily = 'Arial'; // 设置默认字体
  110. });
  111. },
  112. font_formats:
  113. '楷体=楷体,微软雅黑;' +
  114. '黑体=黑体,微软雅黑;' +
  115. '宋体=宋体,微软雅黑;' +
  116. 'Arial=arial,helvetica,sans-serif;' +
  117. 'Times New Roman=times new roman,times,serif;' +
  118. '拼音=League;',
  119. // 字数限制
  120. fontsize_formats: '8pt 10pt 11pt 12pt 14pt 16pt 18pt 20pt 22pt 24pt 26pt 28pt 30pt 32pt 34pt 36pt',
  121. ax_wordlimit_num: this.wordlimitNum,
  122. ax_wordlimit_callback(editor) {
  123. editor.execCommand('undo');
  124. },
  125. media_filter_html: false,
  126. images_upload_handler: this.imagesUploadHandler,
  127. file_picker_types: 'media', // 文件上传类型
  128. file_picker_callback: this.filePickerCallback,
  129. init_instance_callback: this.isFill ? this.initInstanceCallback : '',
  130. },
  131. };
  132. },
  133. methods: {
  134. /**
  135. * 图片上传自定义逻辑函数
  136. * @param {object} blobInfo 文件数据
  137. * @param {Function} success 成功回调函数
  138. * @param {Function} fail 失败回调函数
  139. */
  140. imagesUploadHandler(blobInfo, success, fail) {
  141. let file = blobInfo.blob();
  142. const formData = new FormData();
  143. formData.append(file.name, file, file.name);
  144. fileUpload('Mid', formData, { isGlobalprogress: true })
  145. .then(({ file_info_list }) => {
  146. if (file_info_list.length > 0) {
  147. success(file_info_list[0].file_url_open);
  148. } else {
  149. fail('上传失败');
  150. }
  151. })
  152. .catch(() => {
  153. fail('上传失败');
  154. });
  155. },
  156. /**
  157. * 文件上传自定义逻辑函数
  158. * @param {Function} callback
  159. * @param {String} value
  160. * @param {object} meta
  161. */
  162. filePickerCallback(callback, value, meta) {
  163. if (meta.filetype === 'media') {
  164. let filetype = '.mp3, .mp4';
  165. let input = document.createElement('input');
  166. input.setAttribute('type', 'file');
  167. input.setAttribute('accept', filetype);
  168. input.click();
  169. input.addEventListener('change', () => {
  170. let file = input.files[0];
  171. const formData = new FormData();
  172. formData.append(file.name, file, file.name);
  173. fileUpload('Mid', formData, { isGlobalprogress: true })
  174. .then(({ file_info_list }) => {
  175. if (file_info_list.length > 0) {
  176. callback(file_info_list[0].file_url_open);
  177. } else {
  178. callback('');
  179. }
  180. })
  181. .catch(() => {
  182. callback('');
  183. });
  184. });
  185. }
  186. },
  187. /**
  188. * 初始化编辑器实例回调函数
  189. * @param {Editor} editor 编辑器实例
  190. */
  191. initInstanceCallback(editor) {
  192. editor.on('SetContent Undo Redo ViewUpdate keyup mousedown', () => {
  193. this.$emit('hideContentmenu');
  194. });
  195. editor.on('click', (e) => {
  196. if (e.target.classList.contains('rich-fill')) {
  197. editor.selection.select(e.target); // 选中填空
  198. let { offsetLeft, offsetTop } = e.target;
  199. this.$emit('showContentmenu', {
  200. pixelsFromLeft: offsetLeft - 14,
  201. pixelsFromTop: offsetTop,
  202. });
  203. }
  204. });
  205. let mouseX = 0;
  206. editor.on('mousedown', (e) => {
  207. mouseX = e.offsetX;
  208. });
  209. editor.on('mouseup', (e) => {
  210. let start = editor.selection.getStart();
  211. let end = editor.selection.getEnd();
  212. let rng = editor.selection.getRng();
  213. if (start !== end || rng.collapsed) {
  214. this.$emit('hideContentmenu');
  215. return;
  216. }
  217. if (e.offsetX < mouseX) {
  218. mouseX = e.offsetX;
  219. }
  220. if (isNodeType(start, 'span')) {
  221. start = start.parentNode;
  222. }
  223. // 获取文本内容和起始偏移位置
  224. let text = start.textContent;
  225. let startOffset = rng.startOffset;
  226. let previousSibling = rng.startContainer.previousSibling;
  227. // 判断是否选中的是 span 标签
  228. let isSpan = isNodeType(rng.startContainer.parentNode, 'span');
  229. if (isSpan) {
  230. previousSibling = rng.startContainer.parentNode.previousSibling;
  231. }
  232. // 计算起始偏移位置
  233. while (previousSibling) {
  234. startOffset += previousSibling.textContent.length;
  235. previousSibling = previousSibling.previousSibling;
  236. }
  237. // 获取起始偏移位置前的文本内容
  238. const textBeforeOffset = text.substring(0, startOffset);
  239. /* 使用 Canvas API测量文本宽度 */
  240. // 获取字体大小和行高
  241. let computedStyle = window.getComputedStyle(start);
  242. const fontSize = parseFloat(computedStyle.fontSize);
  243. const canvas = document.createElement('canvas');
  244. const context = canvas.getContext('2d');
  245. context.font = `${fontSize}px ${computedStyle.fontFamily}`;
  246. // 计算文字距离左侧的像素位置
  247. const width = context.measureText(textBeforeOffset).width;
  248. const lineHeight = context.measureText('M').fontBoundingBoxAscent + 3.8; // 获取行高
  249. /* 计算偏移位置 */
  250. const computedWidth = computedStyle.width.replace('px', ''); // 获取编辑器宽度
  251. let row = width / computedWidth; // 计算选中文本在第几行
  252. row = row % 1 > 0.8 ? Math.ceil(row) : Math.floor(row);
  253. const offsetTop = start.offsetTop; // 获取选中文本距离顶部的像素位置
  254. let pixelsFromTop = offsetTop + lineHeight * row; // 计算选中文本距离顶部的像素位置
  255. this.$emit('showContentmenu', {
  256. pixelsFromLeft: mouseX,
  257. pixelsFromTop,
  258. });
  259. });
  260. },
  261. // 删除填空
  262. deleteContent() {
  263. let editor = tinymce.get(this.id);
  264. let start = editor.selection.getStart();
  265. if (isNodeType(start, 'span')) {
  266. let textContent = start.textContent;
  267. let content = editor.selection.getContent();
  268. let str = textContent.split(content);
  269. start.remove();
  270. editor.selection.setContent(str.join(content));
  271. } else {
  272. this.collapse();
  273. }
  274. },
  275. // 设置填空
  276. setContent() {
  277. let editor = tinymce.get(this.id);
  278. let start = editor.selection.getStart();
  279. let content = editor.selection.getContent();
  280. if (isNodeType(start, 'span')) {
  281. let textContent = start.textContent;
  282. let str = textContent.split(content);
  283. start.remove();
  284. editor.selection.setContent(str.join(this.getSpanString(content)));
  285. } else {
  286. let str = this.replaceSpanString(content);
  287. editor.selection.setContent(this.getSpanString(str));
  288. }
  289. },
  290. // 折叠选区
  291. collapse() {
  292. let editor = tinymce.get(this.id);
  293. let rng = editor.selection.getRng();
  294. if (!rng.collapsed) {
  295. this.$emit('hideContentmenu');
  296. editor.selection.collapse();
  297. }
  298. },
  299. // 获取 span 标签
  300. getSpanString(str) {
  301. return `<span class="rich-fill" style="text-decoration: underline;cursor: pointer;">${str}</span>`;
  302. },
  303. // 去除 span 标签
  304. replaceSpanString(str) {
  305. return str.replace(/<span\b[^>]*>(.*?)<\/span>/gi, '$1');
  306. },
  307. handleRichTextBlur() {
  308. this.$emit('handleRichTextBlur');
  309. },
  310. },
  311. };
  312. </script>
  313. <style lang="scss" scoped>
  314. .rich-text {
  315. :deep + .tox {
  316. .tox-sidebar-wrap {
  317. border: 1px solid $fill-color;
  318. border-radius: 4px;
  319. &:hover {
  320. border-color: #c0c4cc;
  321. }
  322. }
  323. &.tox-tinymce {
  324. border-width: 0;
  325. border-radius: 0;
  326. .tox-edit-area__iframe {
  327. background-color: $fill-color;
  328. }
  329. }
  330. &:not(.tox-tinymce-inline) .tox-editor-header {
  331. box-shadow: none;
  332. }
  333. }
  334. &.is-border {
  335. :deep + .tox.tox-tinymce {
  336. border: $border;
  337. border-radius: 4px;
  338. }
  339. }
  340. }
  341. </style>