RichText.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. <template>
  2. <div class="rich-wrapper">
  3. <Editor
  4. v-bind="$attrs"
  5. :id="id"
  6. ref="richText"
  7. model-events="change keyup undo redo setContent"
  8. :value="value"
  9. :class="['rich-text', isBorder ? 'is-border' : '']"
  10. :init="init"
  11. v-on="$listeners"
  12. @onBlur="handleRichTextBlur"
  13. />
  14. <div v-show="isShow" :style="contentmenu" class="contentmenu">
  15. <SvgIcon icon-class="slice" size="16" @click="setFill" />
  16. <span class="button" @click="setFill">设为填空</span>
  17. <span class="line"></span>
  18. <SvgIcon icon-class="close-circle" size="16" @click="deleteFill" />
  19. <span class="button" @click="deleteFill">删除填空</span>
  20. </div>
  21. </div>
  22. </template>
  23. <script>
  24. import tinymce from 'tinymce/tinymce';
  25. import Editor from '@tinymce/tinymce-vue';
  26. import 'tinymce/icons/default/icons';
  27. import 'tinymce/themes/silver';
  28. // 引入富文本编辑器主题的js和css
  29. import 'tinymce/themes/silver/theme.min';
  30. import 'tinymce/skins/ui/oxide/skin.min.css';
  31. // 扩展插件
  32. import 'tinymce/plugins/image';
  33. import 'tinymce/plugins/link';
  34. // import 'tinymce/plugins/code';
  35. // import 'tinymce/plugins/table';
  36. import 'tinymce/plugins/lists';
  37. // import 'tinymce/plugins/wordcount'; // 字数统计插件
  38. import 'tinymce/plugins/media'; // 插入视频插件
  39. // import 'tinymce/plugins/template'; // 模板插件
  40. // import 'tinymce/plugins/fullscreen'; // 全屏插件
  41. import 'tinymce/plugins/paste'; // 粘贴插件
  42. // import 'tinymce/plugins/preview'; // 预览插件
  43. import 'tinymce/plugins/hr';
  44. import 'tinymce/plugins/autoresize'; // 自动调整大小插件
  45. import 'tinymce/plugins/ax_wordlimit'; // 字数限制插件
  46. import { getRandomNumber } from '@/utils';
  47. import { isNodeType } from '@/utils/validate';
  48. import { fileUpload } from '@/api/app';
  49. import { addTone, handleToneValue } from '@/views/exercise_questions/data/common';
  50. export default {
  51. name: 'RichText',
  52. components: {
  53. Editor,
  54. },
  55. inheritAttrs: false,
  56. props: {
  57. inline: {
  58. type: Boolean,
  59. default: false,
  60. },
  61. placeholder: {
  62. type: String,
  63. default: '输入内容',
  64. },
  65. value: {
  66. type: String,
  67. required: true,
  68. },
  69. height: {
  70. type: [Number, String],
  71. default: 52,
  72. },
  73. isBorder: {
  74. type: Boolean,
  75. default: false,
  76. },
  77. toolbar: {
  78. type: [String, Boolean],
  79. /* eslint-disable max-len */
  80. default:
  81. 'fontselect fontsizeselect forecolor backcolor | underline | bold italic strikethrough alignleft aligncenter alignright | bullist numlist | image media | link blockquote hr',
  82. },
  83. wordlimitNum: {
  84. type: [Number, Boolean],
  85. default: 1000,
  86. },
  87. isFill: {
  88. type: Boolean,
  89. default: false,
  90. },
  91. fontSize: {
  92. type: Number,
  93. default: 16,
  94. },
  95. isHasSpace: {
  96. type: Boolean,
  97. default: false,
  98. },
  99. },
  100. data() {
  101. return {
  102. isShow: false,
  103. contentmenu: {
  104. top: 0,
  105. left: 0,
  106. },
  107. id: getRandomNumber(),
  108. init: {
  109. inline: this.inline,
  110. font_size: this.fontSize,
  111. language_url: `${process.env.BASE_URL}tinymce/langs/zh_CN.js`,
  112. placeholder: this.placeholder,
  113. language: 'zh_CN',
  114. skin_url: `${process.env.BASE_URL}tinymce/skins/ui/oxide`,
  115. content_css: `${process.env.BASE_URL}tinymce/skins/content/${this.isHasSpace ? 'index-nospace' : 'index'}.css`,
  116. content_style: 'p { margin:0 }', // 去掉p标签上下间距
  117. min_height: this.height,
  118. width: '100%',
  119. autoresize_bottom_margin: 0,
  120. plugins: 'link lists image hr media autoresize ax_wordlimit paste',
  121. toolbar: this.toolbar, // 工具栏
  122. contextmenu: false, // 右键菜单
  123. menubar: false, // 菜单栏
  124. branding: false, // 品牌
  125. statusbar: false, // 状态栏
  126. setup(editor) {
  127. editor.on('init', () => {
  128. editor.getBody().style.fontSize = `${this.font_size}pt`; // 设置默认字体大小
  129. editor.getBody().style.fontFamily = 'Arial'; // 设置默认字体
  130. });
  131. editor.on('click', () => {
  132. if (editor?.queryCommandState('ToggleToolbarDrawer')) {
  133. editor.execCommand('ToggleToolbarDrawer');
  134. }
  135. });
  136. },
  137. font_formats:
  138. '楷体=楷体,微软雅黑;' +
  139. '黑体=黑体,微软雅黑;' +
  140. '宋体=宋体,微软雅黑;' +
  141. 'Arial=arial,helvetica,sans-serif;' +
  142. 'Times New Roman=times new roman,times,serif;' +
  143. '拼音=League;',
  144. // 字数限制
  145. fontsize_formats: '8pt 10pt 11pt 12pt 14pt 16pt 18pt 20pt 22pt 24pt 26pt 28pt 30pt 32pt 34pt 36pt',
  146. ax_wordlimit_num: this.wordlimitNum,
  147. ax_wordlimit_callback(editor) {
  148. editor.execCommand('undo');
  149. },
  150. media_filter_html: false,
  151. images_upload_handler: this.imagesUploadHandler,
  152. file_picker_types: 'media', // 文件上传类型
  153. file_picker_callback: this.filePickerCallback,
  154. init_instance_callback: this.isFill ? this.initInstanceCallback : '',
  155. paste_enable_default_filters: false, // 禁用默认的粘贴过滤器
  156. // 粘贴预处理
  157. paste_preprocess(plugin, args) {
  158. let content = args.content;
  159. // 使用正则表达式去掉 style 中的 background 属性
  160. content = content.replace(/background(-color)?:[^;]+;/g, '');
  161. // 去掉 p 标签 style 中的 line-height 属性
  162. content = content.replace(/<p[^>]+>/g, (match) => match.replace(/line-height:[^;]+;/g, ''));
  163. args.content = content;
  164. },
  165. // 指定在 WebKit 中粘贴时要保留的样式
  166. paste_webkit_styles:
  167. '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',
  168. },
  169. };
  170. },
  171. created() {
  172. window.addEventListener('click', this.hideToolbarDrawer);
  173. if (this.isFill) {
  174. window.addEventListener('click', this.hideContentmenu);
  175. }
  176. },
  177. beforeDestroy() {
  178. window.removeEventListener('click', this.hideToolbarDrawer);
  179. if (this.isFill) {
  180. window.removeEventListener('click', this.hideContentmenu);
  181. }
  182. },
  183. methods: {
  184. /**
  185. * 图片上传自定义逻辑函数
  186. * @param {object} blobInfo 文件数据
  187. * @param {Function} success 成功回调函数
  188. * @param {Function} fail 失败回调函数
  189. */
  190. imagesUploadHandler(blobInfo, success, fail) {
  191. let file = blobInfo.blob();
  192. const formData = new FormData();
  193. formData.append(file.name, file, file.name);
  194. fileUpload('Mid', formData, { isGlobalprogress: true })
  195. .then(({ file_info_list }) => {
  196. if (file_info_list.length > 0) {
  197. success(file_info_list[0].file_url_open);
  198. } else {
  199. fail('上传失败');
  200. }
  201. })
  202. .catch(() => {
  203. fail('上传失败');
  204. });
  205. },
  206. /**
  207. * 文件上传自定义逻辑函数
  208. * @param {Function} callback
  209. * @param {String} value
  210. * @param {object} meta
  211. */
  212. filePickerCallback(callback, value, meta) {
  213. if (meta.filetype === 'media') {
  214. let filetype = '.mp3, .mp4';
  215. let input = document.createElement('input');
  216. input.setAttribute('type', 'file');
  217. input.setAttribute('accept', filetype);
  218. input.click();
  219. input.addEventListener('change', () => {
  220. let file = input.files[0];
  221. const formData = new FormData();
  222. formData.append(file.name, file, file.name);
  223. fileUpload('Mid', formData, { isGlobalprogress: true })
  224. .then(({ file_info_list }) => {
  225. if (file_info_list.length > 0) {
  226. callback(file_info_list[0].file_url_open);
  227. } else {
  228. callback('');
  229. }
  230. })
  231. .catch(() => {
  232. callback('');
  233. });
  234. });
  235. }
  236. },
  237. /**
  238. * 初始化编辑器实例回调函数
  239. * @param {Editor} editor 编辑器实例
  240. */
  241. initInstanceCallback(editor) {
  242. editor.on('SetContent Undo Redo ViewUpdate keyup mousedown', () => {
  243. this.hideContentmenu();
  244. });
  245. editor.on('click', (e) => {
  246. if (e.target.classList.contains('rich-fill')) {
  247. editor.selection.select(e.target); // 选中填空
  248. let { offsetLeft, offsetTop } = e.target;
  249. this.showContentmenu({
  250. pixelsFromLeft: offsetLeft - 14,
  251. pixelsFromTop: offsetTop,
  252. });
  253. }
  254. });
  255. let mouseX = 0;
  256. editor.on('mousedown', (e) => {
  257. mouseX = e.offsetX;
  258. });
  259. editor.on('mouseup', (e) => {
  260. let start = editor.selection.getStart();
  261. let end = editor.selection.getEnd();
  262. let rng = editor.selection.getRng();
  263. if (start !== end || rng.collapsed) {
  264. this.hideContentmenu();
  265. return;
  266. }
  267. if (e.offsetX < mouseX) {
  268. mouseX = e.offsetX;
  269. }
  270. if (isNodeType(start, 'span')) {
  271. start = start.parentNode;
  272. }
  273. // 获取文本内容和起始偏移位置
  274. let text = start.textContent;
  275. let startOffset = rng.startOffset;
  276. let previousSibling = rng.startContainer.previousSibling;
  277. // 判断是否选中的是 span 标签
  278. let isSpan = isNodeType(rng.startContainer.parentNode, 'span');
  279. if (isSpan) {
  280. previousSibling = rng.startContainer.parentNode.previousSibling;
  281. }
  282. // 计算起始偏移位置
  283. while (previousSibling) {
  284. startOffset += previousSibling.textContent.length;
  285. previousSibling = previousSibling.previousSibling;
  286. }
  287. // 获取起始偏移位置前的文本内容
  288. const textBeforeOffset = text.substring(0, startOffset);
  289. /* 使用 Canvas API测量文本宽度 */
  290. // 获取字体大小和行高
  291. let computedStyle = window.getComputedStyle(start);
  292. const fontSize = parseFloat(computedStyle.fontSize);
  293. const canvas = document.createElement('canvas');
  294. const context = canvas.getContext('2d');
  295. context.font = `${fontSize}pt ${computedStyle.fontFamily}`;
  296. // 计算文字距离左侧的像素位置
  297. const width = context.measureText(textBeforeOffset).width;
  298. const lineHeight = context.measureText('M').fontBoundingBoxAscent + 3.8; // 获取行高
  299. /* 计算偏移位置 */
  300. const computedWidth = computedStyle.width.replace('px', ''); // 获取编辑器宽度
  301. let row = width / computedWidth; // 计算选中文本在第几行
  302. row = row % 1 > 0.8 ? Math.ceil(row) : Math.floor(row);
  303. const offsetTop = start.offsetTop; // 获取选中文本距离顶部的像素位置
  304. let pixelsFromTop = offsetTop + lineHeight * row; // 计算选中文本距离顶部的像素位置
  305. this.showContentmenu({
  306. pixelsFromLeft: mouseX,
  307. pixelsFromTop,
  308. });
  309. });
  310. },
  311. // 删除填空
  312. deleteContent() {
  313. let editor = tinymce.get(this.id);
  314. let start = editor.selection.getStart();
  315. if (isNodeType(start, 'span')) {
  316. let textContent = start.textContent;
  317. let content = editor.selection.getContent();
  318. let str = textContent.split(content);
  319. start.remove();
  320. editor.selection.setContent(str.join(content));
  321. } else {
  322. this.collapse();
  323. }
  324. },
  325. // 设置填空
  326. setContent() {
  327. let editor = tinymce.get(this.id);
  328. let start = editor.selection.getStart();
  329. let content = editor.selection.getContent();
  330. if (isNodeType(start, 'span')) {
  331. let textContent = start.textContent;
  332. let str = textContent.split(content);
  333. start.remove();
  334. editor.selection.setContent(str.join(this.getSpanString(content)));
  335. } else {
  336. let str = this.replaceSpanString(content);
  337. editor.selection.setContent(this.getSpanString(str));
  338. }
  339. },
  340. // 折叠选区
  341. collapse() {
  342. let editor = tinymce.get(this.id);
  343. let rng = editor.selection.getRng();
  344. if (!rng.collapsed) {
  345. this.hideContentmenu();
  346. editor.selection.collapse();
  347. }
  348. },
  349. // 获取 span 标签
  350. getSpanString(str) {
  351. return `<span class="rich-fill" style="text-decoration: underline;cursor: pointer;">${str}</span>`;
  352. },
  353. // 去除 span 标签
  354. replaceSpanString(str) {
  355. return str.replace(/<span\b[^>]*>(.*?)<\/span>/gi, '$1');
  356. },
  357. handleRichTextBlur() {
  358. this.$emit('handleRichTextBlur');
  359. let content = tinymce.get(this.id).getContent();
  360. // 判断富文本中是否有 字母+数字+空格 的组合,如果没有,直接返回
  361. let isHasPinyin = content
  362. .split(/<[^>]+>/g)
  363. .filter((item) => item)
  364. .some((item) => item.match(/[a-zA-Z]+\d(\s|&nbsp;)*/));
  365. if (!isHasPinyin) {
  366. return;
  367. }
  368. // 用标签分割富文本,保留标签
  369. let reg = /(<[^>]+>)/g;
  370. let text = content
  371. .split(reg)
  372. .filter((item) => item)
  373. // 如果是标签,直接返回
  374. // 如果是文本,将文本按空格分割为数组,如果是拼音,将拼音转为带音调的拼音
  375. .map((item) => {
  376. // 重置正则,使用全局匹配 g 时需要重置,否则会出现匹配不到的情况,因为 lastIndex 会一直累加,test 方法会从 lastIndex 开始匹配
  377. reg.lastIndex = 0;
  378. if (reg.test(item)) {
  379. return item;
  380. }
  381. return item.split(/\s+/).map((item) => handleToneValue(item));
  382. })
  383. // 如果是标签,直接返回
  384. // 二维数组,转为拼音,并打平为一维数组
  385. .map((item) => {
  386. if (/<[^>]+>/g.test(item)) return item;
  387. return item
  388. .map((li) =>
  389. li.map(({ number, con }) => (number && con ? addTone(Number(number), con) : number || con || '')),
  390. )
  391. .flat();
  392. })
  393. // 如果是数组,将数组字符串每两个之间加一个空格
  394. .map((item) => {
  395. if (typeof item === 'string') return item;
  396. return item.join(' ');
  397. })
  398. .join('');
  399. // 更新 v-model
  400. this.$emit('input', text);
  401. },
  402. // 设置填空
  403. setFill() {
  404. this.setContent();
  405. this.hideContentmenu();
  406. },
  407. // 删除填空
  408. deleteFill() {
  409. this.deleteContent();
  410. this.hideContentmenu();
  411. },
  412. // 隐藏工具栏抽屉
  413. hideToolbarDrawer() {
  414. let editor = tinymce.get(this.id);
  415. if (editor?.queryCommandState('ToggleToolbarDrawer')) {
  416. editor.execCommand('ToggleToolbarDrawer');
  417. }
  418. },
  419. // 隐藏填空右键菜单
  420. hideContentmenu() {
  421. this.isShow = false;
  422. },
  423. showContentmenu({ pixelsFromLeft, pixelsFromTop }) {
  424. this.isShow = true;
  425. this.contentmenu = {
  426. left: `${pixelsFromLeft + 14}px`,
  427. top: `${pixelsFromTop - 18}px`,
  428. };
  429. },
  430. },
  431. };
  432. </script>
  433. <style lang="scss" scoped>
  434. .rich-text {
  435. :deep + .tox {
  436. .tox-sidebar-wrap {
  437. border: 1px solid $fill-color;
  438. border-radius: 4px;
  439. &:hover {
  440. border-color: #c0c4cc;
  441. }
  442. }
  443. &.tox-tinymce {
  444. border-width: 0;
  445. border-radius: 0;
  446. .tox-edit-area__iframe {
  447. background-color: $fill-color;
  448. }
  449. }
  450. &:not(.tox-tinymce-inline) .tox-editor-header {
  451. box-shadow: none;
  452. }
  453. }
  454. &.is-border {
  455. :deep + .tox.tox-tinymce {
  456. border: $border;
  457. border-radius: 4px;
  458. }
  459. }
  460. }
  461. .contentmenu {
  462. position: absolute;
  463. z-index: 999;
  464. display: flex;
  465. column-gap: 4px;
  466. align-items: center;
  467. padding: 4px 8px;
  468. font-size: 14px;
  469. background-color: #fff;
  470. border-radius: 2px;
  471. box-shadow: 0 1px 10px 0 rgba(0, 0, 0, 10%);
  472. .svg-icon,
  473. .button {
  474. cursor: pointer;
  475. }
  476. .line {
  477. min-height: 16px;
  478. margin: 0 4px;
  479. }
  480. }
  481. </style>