RichText.vue 11 KB

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