RichText.vue 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965
  1. <template>
  2. <div ref="richArea" 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. <div v-if="isViewNote" @click="openExplanatoryNoteDialog">
  16. <SvgIcon icon-class="mark" size="14" />
  17. <span class="button"> 编辑注释</span>
  18. </div>
  19. <div v-else>
  20. <SvgIcon icon-class="slice" size="16" @click="setFill" />
  21. <span class="button" @click="setFill">设为填空</span>
  22. <span class="line"></span>
  23. <SvgIcon icon-class="close-circle" size="16" @click="deleteFill" />
  24. <span class="button" @click="deleteFill">删除填空</span>
  25. </div>
  26. </div>
  27. <MathDialog :visible.sync="isViewMathDialog" @confirm="mathConfirm" />
  28. </div>
  29. </template>
  30. <script>
  31. import tinymce from 'tinymce/tinymce';
  32. import Editor from '@tinymce/tinymce-vue';
  33. import MathDialog from '@/components/MathDialog.vue';
  34. import 'tinymce/icons/default/icons';
  35. import 'tinymce/themes/silver';
  36. // 引入富文本编辑器主题的js和css
  37. import 'tinymce/themes/silver/theme.min';
  38. import 'tinymce/skins/ui/oxide/skin.min.css';
  39. // 扩展插件
  40. import 'tinymce/plugins/image';
  41. import 'tinymce/plugins/link';
  42. // import 'tinymce/plugins/code';
  43. // import 'tinymce/plugins/table';
  44. import 'tinymce/plugins/lists';
  45. // import 'tinymce/plugins/wordcount'; // 字数统计插件
  46. import 'tinymce/plugins/media'; // 插入视频插件
  47. // import 'tinymce/plugins/template'; // 模板插件
  48. // import 'tinymce/plugins/fullscreen'; // 全屏插件
  49. import 'tinymce/plugins/paste'; // 粘贴插件
  50. // import 'tinymce/plugins/preview'; // 预览插件
  51. import 'tinymce/plugins/hr';
  52. import 'tinymce/plugins/autoresize'; // 自动调整大小插件
  53. import 'tinymce/plugins/ax_wordlimit'; // 字数限制插件
  54. import { getRandomNumber } from '@/utils';
  55. import { isNodeType } from '@/utils/validate';
  56. import { fileUpload } from '@/api/app';
  57. import { addTone, handleToneValue } from '@/utils/common';
  58. export default {
  59. name: 'RichText',
  60. components: {
  61. Editor,
  62. MathDialog,
  63. },
  64. inheritAttrs: false,
  65. props: {
  66. inline: {
  67. type: Boolean,
  68. default: false,
  69. },
  70. placeholder: {
  71. type: String,
  72. default: '输入内容',
  73. },
  74. value: {
  75. type: String,
  76. default: '',
  77. },
  78. height: {
  79. type: [Number, String],
  80. default: 52,
  81. },
  82. isBorder: {
  83. type: Boolean,
  84. default: false,
  85. },
  86. toolbar: {
  87. type: [String, Boolean],
  88. /* eslint-disable max-len */
  89. default:
  90. 'fontselect fontsizeselect forecolor backcolor | lineheight underline | bold italic strikethrough alignleft aligncenter alignright | bullist numlist | image media | link blockquote hr mathjax',
  91. },
  92. wordlimitNum: {
  93. type: [Number, Boolean],
  94. default: 1000000,
  95. },
  96. isFill: {
  97. type: Boolean,
  98. default: false,
  99. },
  100. fontSize: {
  101. type: String,
  102. default: '12pt',
  103. },
  104. fontFamily: {
  105. type: String,
  106. default: 'Arial',
  107. },
  108. isHasSpace: {
  109. type: Boolean,
  110. default: false,
  111. },
  112. isViewPinyin: {
  113. type: Boolean,
  114. default: false,
  115. },
  116. pageFrom: {
  117. type: String,
  118. default: '',
  119. },
  120. isViewNote: {
  121. type: Boolean,
  122. default: false,
  123. },
  124. itemIndex: {
  125. type: Number,
  126. default: null,
  127. },
  128. },
  129. data() {
  130. return {
  131. isViewMathDialog: false,
  132. mathEleIsInit: true,
  133. math: '',
  134. isShow: false,
  135. contentmenu: {
  136. top: 0,
  137. left: 0,
  138. },
  139. id: getRandomNumber(),
  140. init: {
  141. content_style: `
  142. mjx-container, mjx-container * {
  143. font-size: 16px !important; /* 强制固定字体 */
  144. line-height: 1.2 !important; /* 避免行高影响 */
  145. }`, // 解决公式每点击一次字体就变大
  146. valid_elements: '*[*]', // 允许所有标签和属性
  147. valid_children: '+body[style]', // 允许 MathJax 的样式
  148. extended_valid_elements: 'span[*],mjx-container[*],svg[*],path[*]', // 明确允许 MathJax 标签
  149. inline: this.inline,
  150. font_size: this.fontSize,
  151. font_family: this.fontFamily,
  152. language_url: `${process.env.BASE_URL}tinymce/langs/zh_CN.js`,
  153. placeholder: this.placeholder,
  154. language: 'zh_CN',
  155. skin_url: `${process.env.BASE_URL}tinymce/skins/ui/oxide`,
  156. content_css: `${process.env.BASE_URL}tinymce/skins/content/${this.isHasSpace ? 'index-nospace' : 'index'}.css`,
  157. min_height: this.height,
  158. width: '100%',
  159. autoresize_bottom_margin: 0,
  160. plugins: 'link lists image hr media autoresize ax_wordlimit paste', // 移除 lineheight
  161. toolbar: this.toolbar, // 工具栏
  162. lineheight_formats: '0.5 1.0 1.2 1.5 2.0 2.5 3.0', // 行高选项(倍数)
  163. contextmenu: false, // 右键菜单
  164. menubar: false, // 菜单栏
  165. branding: false, // 品牌
  166. statusbar: false, // 状态栏
  167. entity_encoding: 'raw', // raw不编码任何字符;named: 使用命名实体(如 &nbsp;);numeric: 使用数字实体(如 &#160;)
  168. setup: (editor) => {
  169. editor.on('GetContent', (e) => {
  170. if (e.format === 'html') {
  171. e.content = this.smartPreserveLineBreaks(editor, e.content);
  172. }
  173. });
  174. let isRendered = false; // 标记是否已渲染
  175. editor.on('init', () => {
  176. editor.getBody().style.fontSize = this.init.font_size; // 设置默认字体大小
  177. editor.getBody().style.fontFamily = this.init.font_family; // 设置默认字体
  178. });
  179. // 自定义行高下拉(因为没有内置 lineheight 插件)
  180. editor.ui.registry.addMenuButton('lineheight', {
  181. text: '行高',
  182. tooltip: '行高',
  183. fetch: (callback) => {
  184. const formats = (this.init.lineheight_formats || '').split(/\s+/).filter(Boolean);
  185. const items = formats.map((v) => {
  186. return {
  187. type: 'menuitem',
  188. text: v,
  189. onAction: () => {
  190. try {
  191. const name = `lineheight_${v.replace(/\./g, '_')}`;
  192. // 动态注册格式(会覆盖同名注册,安全)
  193. editor.formatter.register(name, {
  194. inline: 'span',
  195. styles: { lineHeight: v },
  196. });
  197. editor.formatter.apply(name);
  198. } catch (err) {
  199. // 容错处理
  200. }
  201. },
  202. };
  203. });
  204. callback(items);
  205. },
  206. });
  207. editor.on('click', () => {
  208. if (editor?.queryCommandState('ToggleToolbarDrawer')) {
  209. editor.execCommand('ToggleToolbarDrawer');
  210. }
  211. if (!isRendered && window.MathJax) {
  212. isRendered = true;
  213. window.MathJax.typesetPromise([editor.getBody()]);
  214. }
  215. });
  216. // 添加 MathJax 按钮
  217. editor.ui.registry.addButton('mathjax', {
  218. text: '∑',
  219. tooltip: '插入公式',
  220. onAction: () => {
  221. this.isViewMathDialog = true;
  222. },
  223. });
  224. // 内容变化时重新渲染公式
  225. editor.on('change', () => this.renderMath());
  226. editor.on('KeyDown', (e) => {
  227. // 检测删除或退格键
  228. if (e.keyCode === 8 || e.keyCode === 46) {
  229. // 延迟执行以确保删除已完成
  230. setTimeout(() => {
  231. this.cleanupRemovedAnnotations(editor);
  232. }, 500);
  233. }
  234. });
  235. // 也可以监听剪切操作
  236. editor.on('Cut', () => {
  237. setTimeout(() => {
  238. this.cleanupRemovedAnnotations(editor);
  239. }, 500);
  240. });
  241. // editor.on('NodeChange', function (e) {
  242. // if (
  243. // e.element &&
  244. // e.element.tagName === 'SPAN' &&
  245. // e.element.hasAttribute('data-annotation-id') &&
  246. // (!e.element.textContent || /^\s*$/.test(e.element.textContent))
  247. // ) {
  248. // const annotationId = e.element.getAttribute('data-annotation-id');
  249. // e.element.parentNode.removeChild(e.element);
  250. // this.$emit('selectContentSetMemo', null, annotationId);
  251. // }
  252. // });
  253. },
  254. font_formats:
  255. '楷体=楷体,微软雅黑;' +
  256. '黑体=黑体,微软雅黑;' +
  257. '宋体=宋体,微软雅黑;' +
  258. 'Arial=arial,helvetica,sans-serif;' +
  259. 'Times New Roman=times new roman,times,serif;' +
  260. '拼音=League;',
  261. fontsize_formats: '8pt 10pt 11pt 12pt 14pt 16pt 18pt 20pt 22pt 24pt 26pt 28pt 30pt 32pt 34pt 36pt',
  262. // 字数限制
  263. ax_wordlimit_num: this.wordlimitNum,
  264. ax_wordlimit_callback(editor) {
  265. editor.execCommand('undo');
  266. },
  267. media_filter_html: false,
  268. images_upload_handler: this.imagesUploadHandler,
  269. file_picker_types: 'media', // 文件上传类型
  270. file_picker_callback: this.filePickerCallback,
  271. init_instance_callback: this.isFill || this.isViewNote ? this.initInstanceCallback : '',
  272. paste_enable_default_filters: false, // 禁用默认的粘贴过滤器
  273. paste_as_text: true, // 默认作为纯文本粘贴
  274. // 粘贴预处理
  275. paste_preprocess(plugin, args) {
  276. let content = args.content;
  277. // 使用正则表达式去掉 style 中的 background 属性
  278. content = content.replace(/background(-color)?:[^;]+;/g, '');
  279. content = content.replace(/\t/g, ' '); // 将制表符替换为4个空格
  280. args.content = content;
  281. },
  282. // 指定在 WebKit 中粘贴时要保留的样式
  283. paste_webkit_styles:
  284. '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',
  285. },
  286. };
  287. },
  288. watch: {
  289. isViewNote: {
  290. handler(newVal) {
  291. if (newVal) {
  292. let editor = tinymce.get(this.id);
  293. if (editor) {
  294. let start = editor.selection.getStart();
  295. this.$emit('selectNote', start.getAttribute('data-annotation-id'));
  296. }
  297. }
  298. },
  299. },
  300. fontSize: {
  301. handler(newVal) {
  302. const editor = tinymce.get(this.id);
  303. if (!editor || typeof editor.execCommand !== 'function') return;
  304. const applyFontSize = () => {
  305. try {
  306. editor.execCommand('FontSize', false, newVal);
  307. editor.setContent(this.value); // 触发内容更新,解决 value 在设置后变为空字符串的问题
  308. } catch (e) {
  309. // 容错:某些情况下 execCommand 会抛错,忽略即可
  310. // console.warn('apply fontSize failed', e);
  311. }
  312. };
  313. // 如果 selection 暂不可用,延迟一次执行以避免其他监听器中访问 selection 报错
  314. if (!editor.selection || typeof editor.selection.getRng !== 'function') {
  315. setTimeout(applyFontSize, 100);
  316. } else {
  317. applyFontSize();
  318. }
  319. },
  320. },
  321. fontFamily: {
  322. handler(newVal) {
  323. const editor = tinymce.get(this.id);
  324. if (!editor || typeof editor.execCommand !== 'function') return;
  325. const applyFontFamily = () => {
  326. try {
  327. editor.execCommand('FontName', false, newVal);
  328. editor.setContent(this.value); // 触发内容更新,解决 value 在设置后变为空字符串的问题
  329. } catch (e) {
  330. // 容错:忽略因 selection 不可用或其它原因导致的错误
  331. }
  332. };
  333. // 如果 selection 暂不可用,延迟执行
  334. if (!editor.selection || typeof editor.selection.getRng !== 'function') {
  335. setTimeout(applyFontFamily, 100);
  336. } else {
  337. applyFontFamily();
  338. }
  339. },
  340. },
  341. },
  342. created() {
  343. if (this.pageFrom !== 'audit') {
  344. window.addEventListener('click', this.hideToolbarDrawer);
  345. }
  346. if (this.isFill || this.isViewNote) {
  347. window.addEventListener('click', this.hideContentmenu);
  348. }
  349. this.setBackgroundColor();
  350. },
  351. beforeDestroy() {
  352. if (this.pageFrom !== 'audit') {
  353. window.removeEventListener('click', this.hideToolbarDrawer);
  354. }
  355. if (this.isFill || this.isViewNote) {
  356. window.removeEventListener('click', this.hideContentmenu);
  357. }
  358. },
  359. methods: {
  360. smartPreserveLineBreaks(editor, content) {
  361. let body = editor.getBody();
  362. let originalParagraphs = Array.from(body.getElementsByTagName('p'));
  363. let tempDiv = document.createElement('div');
  364. tempDiv.innerHTML = content;
  365. let outputParagraphs = Array.from(tempDiv.getElementsByTagName('p'));
  366. outputParagraphs.forEach((outputP, index) => {
  367. let originalP = originalParagraphs[index];
  368. if (originalP && outputP.innerHTML === '') {
  369. // 判断这个空段落是否应该包含 <br>
  370. let shouldHaveBr = this.shouldPreserveLineBreak(originalP, index, originalParagraphs);
  371. if (shouldHaveBr) {
  372. outputP.innerHTML = '<br>';
  373. }
  374. }
  375. });
  376. return tempDiv.innerHTML;
  377. },
  378. shouldPreserveLineBreak(paragraph, index, allParagraphs) {
  379. // 规则1:如果段落原本包含 <br>
  380. if (paragraph.innerHTML.includes('<br>')) {
  381. return true;
  382. }
  383. // 规则2:如果段落位于内容中间(不是第一个或最后一个)
  384. if (index > 0 && index < allParagraphs.length - 1) {
  385. let prevHasContent = allParagraphs[index - 1].textContent.trim() !== '';
  386. let nextHasContent = allParagraphs[index + 1].textContent.trim() !== '';
  387. if (prevHasContent && nextHasContent) {
  388. return true;
  389. }
  390. }
  391. // 规则3:如果段落是通过回车创建的(前后有内容)
  392. let isBetweenContent = false;
  393. if (index > 0 && allParagraphs[index - 1].textContent.trim() !== '') {
  394. isBetweenContent = true;
  395. }
  396. if (index < allParagraphs.length - 1 && allParagraphs[index + 1].textContent.trim() !== '') {
  397. isBetweenContent = true;
  398. }
  399. return isBetweenContent;
  400. },
  401. // 设置背景色
  402. setBackgroundColor() {
  403. let iframes = document.getElementsByTagName('iframe');
  404. for (let i = 0; i < iframes.length; i++) {
  405. let iframe = iframes[i];
  406. // 获取 <iframe> 内部的文档对象
  407. let iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
  408. let bodyElement = iframeDocument.body;
  409. if (bodyElement) {
  410. // 设置背景色
  411. bodyElement.style.backgroundColor = '#f2f3f5';
  412. }
  413. }
  414. },
  415. /**
  416. * 判断内容是否全部加粗
  417. */
  418. isAllBold() {
  419. let editor = tinymce.get(this.id);
  420. let body = editor.getBody();
  421. function getTextNodes(node) {
  422. let textNodes = [];
  423. if (node.nodeType === 3 && node.nodeValue.trim() !== '') {
  424. textNodes.push(node);
  425. } else {
  426. for (let child of node.childNodes) {
  427. textNodes = textNodes.concat(getTextNodes(child));
  428. }
  429. }
  430. return textNodes;
  431. }
  432. let textNodes = getTextNodes(body);
  433. if (textNodes.length === 0) return false;
  434. return textNodes.every((node) => {
  435. let el = node.parentElement;
  436. while (el && el !== body) {
  437. const tag = el.tagName.toLowerCase();
  438. const fontWeight = window.getComputedStyle(el).fontWeight;
  439. if (tag === 'b' || tag === 'strong' || fontWeight === 'bold' || parseInt(fontWeight) >= 600) {
  440. return true;
  441. }
  442. el = el.parentElement;
  443. }
  444. return false;
  445. });
  446. },
  447. /**
  448. * 设置整体富文本格式
  449. * @param {string} type 格式名称
  450. * @param {string} val 格式值
  451. */
  452. setRichFormat(type, val) {
  453. let editor = tinymce.get(this.id);
  454. if (!editor) return;
  455. editor.execCommand('SelectAll');
  456. switch (type) {
  457. case 'bold': {
  458. if (this.isAllBold()) {
  459. editor.formatter.remove('bold');
  460. } else {
  461. editor.formatter.apply('bold');
  462. }
  463. break;
  464. }
  465. case 'fontSize':
  466. case 'lineHeight':
  467. case 'color':
  468. case 'fontFamily': {
  469. editor.formatter.register('my_customformat', {
  470. inline: 'span',
  471. styles: { [type]: val },
  472. });
  473. editor.formatter.apply('my_customformat');
  474. break;
  475. }
  476. case 'align': {
  477. if (val === 'LEFT') {
  478. editor.execCommand('JustifyLeft');
  479. } else if (val === 'MIDDLE') {
  480. editor.execCommand('JustifyCenter');
  481. } else if (val === 'RIGHT') {
  482. editor.execCommand('JustifyRight');
  483. }
  484. break;
  485. }
  486. default: {
  487. editor.formatter.toggle(type);
  488. }
  489. }
  490. editor.selection.collapse(false);
  491. },
  492. /**
  493. * 图片上传自定义逻辑函数
  494. * @param {object} blobInfo 文件数据
  495. * @param {Function} success 成功回调函数
  496. * @param {Function} fail 失败回调函数
  497. */
  498. imagesUploadHandler(blobInfo, success, fail) {
  499. let file = blobInfo.blob();
  500. const formData = new FormData();
  501. formData.append(file.name, file, file.name);
  502. fileUpload('Mid', formData, { isGlobalprogress: true })
  503. .then(({ file_info_list }) => {
  504. if (file_info_list.length > 0) {
  505. success(file_info_list[0].file_url_open);
  506. } else {
  507. fail('上传失败');
  508. }
  509. })
  510. .catch(() => {
  511. fail('上传失败');
  512. });
  513. },
  514. /**
  515. * 文件上传自定义逻辑函数
  516. * @param {Function} callback
  517. * @param {String} value
  518. * @param {object} meta
  519. */
  520. filePickerCallback(callback, value, meta) {
  521. if (meta.filetype === 'media') {
  522. let filetype = '.mp3, .mp4';
  523. let input = document.createElement('input');
  524. input.setAttribute('type', 'file');
  525. input.setAttribute('accept', filetype);
  526. input.click();
  527. input.addEventListener('change', () => {
  528. let file = input.files[0];
  529. const formData = new FormData();
  530. formData.append(file.name, file, file.name);
  531. fileUpload('Mid', formData, { isGlobalprogress: true })
  532. .then(({ file_info_list }) => {
  533. if (file_info_list.length > 0) {
  534. callback(file_info_list[0].file_url_open);
  535. } else {
  536. callback('');
  537. }
  538. })
  539. .catch(() => {
  540. callback('');
  541. });
  542. });
  543. }
  544. },
  545. /**
  546. * 初始化编辑器实例回调函数
  547. * @param {Editor} editor 编辑器实例
  548. */
  549. initInstanceCallback(editor) {
  550. editor.on('SetContent Undo Redo ViewUpdate keyup mousedown', () => {
  551. this.hideContentmenu();
  552. });
  553. editor.on('click', (e) => {
  554. if (e.target.classList.contains('rich-fill')) {
  555. editor.selection.select(e.target); // 选中填空
  556. let { offsetLeft, offsetTop } = e.target;
  557. this.showContentmenu({
  558. pixelsFromLeft: offsetLeft - 14,
  559. pixelsFromTop: offsetTop,
  560. });
  561. }
  562. });
  563. let mouseX = 0;
  564. editor.on('mousedown', (e) => {
  565. mouseX = e.offsetX;
  566. });
  567. editor.on('mouseup', (e) => {
  568. let start = editor.selection.getStart();
  569. let end = editor.selection.getEnd();
  570. let rng = editor.selection.getRng();
  571. if (start !== end || rng.collapsed) {
  572. this.hideContentmenu();
  573. return;
  574. }
  575. if (e.offsetX < mouseX) {
  576. mouseX = e.offsetX;
  577. }
  578. if (isNodeType(start, 'span')) {
  579. start = start.parentNode;
  580. }
  581. // 获取文本内容和起始偏移位置
  582. let text = start.textContent;
  583. let startOffset = rng.startOffset;
  584. let previousSibling = rng.startContainer.previousSibling;
  585. // 判断是否选中的是 span 标签
  586. let isSpan = isNodeType(rng.startContainer.parentNode, 'span');
  587. if (isSpan) {
  588. previousSibling = rng.startContainer.parentNode.previousSibling;
  589. }
  590. // 计算起始偏移位置
  591. while (previousSibling) {
  592. startOffset += previousSibling.textContent.length;
  593. previousSibling = previousSibling.previousSibling;
  594. }
  595. // 获取起始偏移位置前的文本内容
  596. const textBeforeOffset = text.substring(0, startOffset);
  597. /* 使用 Canvas API测量文本宽度 */
  598. // 获取字体大小和行高
  599. let computedStyle = window.getComputedStyle(start);
  600. const fontSize = parseFloat(computedStyle.fontSize);
  601. const canvas = document.createElement('canvas');
  602. const context = canvas.getContext('2d');
  603. context.font = `${fontSize}pt ${computedStyle.fontFamily}`;
  604. // 计算文字距离左侧的像素位置
  605. const width = context.measureText(textBeforeOffset).width;
  606. const lineHeight = context.measureText('M').fontBoundingBoxAscent + 3.8; // 获取行高
  607. /* 计算偏移位置 */
  608. const computedWidth = computedStyle.width.replace('px', ''); // 获取编辑器宽度
  609. let row = width / computedWidth; // 计算选中文本在第几行
  610. row = row % 1 > 0.8 ? Math.ceil(row) : Math.floor(row);
  611. const offsetTop = start.offsetTop; // 获取选中文本距离顶部的像素位置
  612. let pixelsFromTop = offsetTop + lineHeight * row; // 计算选中文本距离顶部的像素位置
  613. this.showContentmenu({
  614. pixelsFromLeft: mouseX,
  615. pixelsFromTop,
  616. });
  617. });
  618. },
  619. // 删除填空
  620. deleteContent() {
  621. let editor = tinymce.get(this.id);
  622. let start = editor.selection.getStart();
  623. if (isNodeType(start, 'span')) {
  624. let textContent = start.textContent;
  625. let content = editor.selection.getContent();
  626. let str = textContent.split(content);
  627. start.remove();
  628. editor.selection.setContent(str.join(content));
  629. } else {
  630. this.collapse();
  631. }
  632. },
  633. // 设置填空
  634. setContent() {
  635. let editor = tinymce.get(this.id);
  636. let start = editor.selection.getStart();
  637. let content = editor.selection.getContent();
  638. if (isNodeType(start, 'span')) {
  639. let textContent = start.textContent;
  640. let str = textContent.split(content);
  641. start.remove();
  642. editor.selection.setContent(str.join(this.getSpanString(content)));
  643. } else {
  644. let str = this.replaceSpanString(content);
  645. editor.selection.setContent(this.getSpanString(str));
  646. }
  647. },
  648. // 折叠选区
  649. collapse() {
  650. let editor = tinymce.get(this.id);
  651. let rng = editor.selection.getRng();
  652. if (!rng.collapsed) {
  653. this.hideContentmenu();
  654. editor.selection.collapse();
  655. }
  656. },
  657. // 获取 span 标签
  658. getSpanString(str) {
  659. return `<span class="rich-fill" style="text-decoration: underline;cursor: pointer;">${str}</span>`;
  660. },
  661. // 去除 span 标签
  662. replaceSpanString(str) {
  663. return str.replace(/<span\b[^>]*>(.*?)<\/span>/gi, '$1');
  664. },
  665. createParsedTextInfoPinyin(content) {
  666. let text = content.replace(/<[^>]+>/g, '');
  667. this.$emit('createParsedTextInfoPinyin', text);
  668. },
  669. handleRichTextBlur() {
  670. this.$emit('handleRichTextBlur', this.itemIndex);
  671. let content = tinymce.get(this.id).getContent();
  672. if (this.isViewPinyin) {
  673. this.createParsedTextInfoPinyin(content);
  674. return;
  675. }
  676. // 判断富文本中是否有 字母+数字+空格 的组合,如果没有,直接返回
  677. let isHasPinyin = content
  678. .split(/<[^>]+>/g)
  679. .filter((item) => item)
  680. .some((item) => item.match(/[a-zA-Z]+\d+(\s|&nbsp;)+/));
  681. if (!isHasPinyin) {
  682. return;
  683. }
  684. // 用标签分割富文本,保留标签
  685. let reg = /(<[^>]+>)/g;
  686. let text = content
  687. .split(reg)
  688. .filter((item) => item)
  689. // 如果是标签,直接返回
  690. // 如果是文本,将文本按空格分割为数组,如果是拼音,将拼音转为带音调的拼音
  691. .map((item) => {
  692. // 重置正则,使用全局匹配 g 时需要重置,否则会出现匹配不到的情况,因为 lastIndex 会一直累加,test 方法会从 lastIndex 开始匹配
  693. reg.lastIndex = 0;
  694. if (reg.test(item)) {
  695. return item;
  696. }
  697. return item.split(/\s+/).map((item) => handleToneValue(item));
  698. })
  699. // 如果是标签,直接返回
  700. // 二维数组,转为拼音,并打平为一维数组
  701. .map((item) => {
  702. if (/<[^>]+>/g.test(item)) return item;
  703. return item
  704. .map(({ number, con }) => (number && con ? addTone(Number(number), con) : number || con || ''))
  705. .flat();
  706. })
  707. // 如果是数组,将数组字符串每两个之间加一个空格
  708. .map((item) => {
  709. if (typeof item === 'string') return item;
  710. return item.join(' ');
  711. })
  712. .join('');
  713. // 更新 v-model
  714. this.$emit('input', text);
  715. },
  716. // 设置填空
  717. setFill() {
  718. this.setContent();
  719. this.hideContentmenu();
  720. },
  721. // 删除填空
  722. deleteFill() {
  723. this.deleteContent();
  724. this.hideContentmenu();
  725. },
  726. // 隐藏工具栏抽屉
  727. hideToolbarDrawer() {
  728. let editor = tinymce.get(this.id);
  729. if (editor.queryCommandState('ToggleToolbarDrawer')) {
  730. editor.execCommand('ToggleToolbarDrawer');
  731. }
  732. },
  733. // 隐藏填空右键菜单
  734. hideContentmenu() {
  735. this.isShow = false;
  736. },
  737. showContentmenu({ pixelsFromLeft, pixelsFromTop }) {
  738. this.isShow = true;
  739. this.contentmenu = {
  740. left: `${this.isViewNote ? pixelsFromLeft : pixelsFromLeft + 14}px`,
  741. top: `${this.isViewNote ? pixelsFromTop + 62 : pixelsFromTop + 22}px`,
  742. };
  743. },
  744. mathConfirm(math) {
  745. let editor = tinymce.get(this.id);
  746. let tmpId = getRandomNumber();
  747. editor.insertContent(`
  748. <span id="${tmpId}" contenteditable="false" class="mathjax-container editor-math">
  749. ${math}
  750. </span>
  751. `);
  752. this.mathEleIsInit = false;
  753. this.renderMath(tmpId);
  754. this.isViewMathDialog = false;
  755. },
  756. // 渲染公式
  757. async renderMath(id) {
  758. if (this.mathEleIsInit) return; // 如果公式已经渲染过,返回
  759. if (window.MathJax) {
  760. let editor = tinymce.get(this.id);
  761. let eleMathArs = [];
  762. if (id) {
  763. // 插入的时候,会传递ID,执行单个渲染
  764. let ele = editor.dom.select(`#${id}`)[0];
  765. eleMathArs = [ele];
  766. } else {
  767. // 否则,查询编辑器里面所有的公式
  768. eleMathArs = editor.dom.select(`.editor_math`);
  769. }
  770. if (eleMathArs.length === 0) return;
  771. await this.$nextTick();
  772. window.MathJax.typesetPromise(eleMathArs).catch((err) => console.error('MathJax error:', err));
  773. this.mathEleIsInit = true;
  774. }
  775. },
  776. // 获取高亮 span 标签
  777. getLightSpanString(noteId, str) {
  778. return `<span data-annotation-id="${noteId}" class="rich-fill" style="background-color:#fff3b7;cursor: pointer;">${str}</span>`;
  779. },
  780. // 选中文本打开弹窗
  781. openExplanatoryNoteDialog() {
  782. let editor = tinymce.get(this.id);
  783. let start = editor.selection.getStart();
  784. this.$emit('view-explanatory-note', { visible: true, noteId: start.getAttribute('data-annotation-id') });
  785. this.hideContentmenu();
  786. },
  787. // 设置高亮背景,并保留备注
  788. setExplanatoryNote(richData) {
  789. let noteId = '';
  790. let editor = tinymce.get(this.id);
  791. let start = editor.selection.getStart();
  792. let content = editor.selection.getContent();
  793. if (isNodeType(start, 'span')) {
  794. noteId = start.getAttribute('data-annotation-id');
  795. } else {
  796. noteId = `${this.id}_${Math.floor(Math.random() * 1000000)
  797. .toString()
  798. .padStart(10, '0')}`;
  799. let str = this.replaceSpanString(content);
  800. editor.selection.setContent(this.getLightSpanString(noteId, str));
  801. }
  802. let selectText = content.replace(/<[^>]+>/g, '');
  803. let note = { id: noteId, note: richData.note, selectText };
  804. this.$emit('selectContentSetMemo', note);
  805. },
  806. // 取消注释
  807. cancelExplanatoryNote() {
  808. let editor = tinymce.get(this.id);
  809. let start = editor.selection.getStart();
  810. if (isNodeType(start, 'span')) {
  811. let textContent = start.textContent;
  812. let content = editor.selection.getContent();
  813. let str = textContent.split(content);
  814. start.remove();
  815. editor.selection.setContent(str.join(content));
  816. this.$emit('selectContentSetMemo', null, start.getAttribute('data-annotation-id'));
  817. } else {
  818. this.collapse();
  819. }
  820. },
  821. // 删除,监听处理备注
  822. cleanupRemovedAnnotations(editor) {
  823. if (!this.isViewNote) return; // 只有富文本才处理
  824. const body = editor.getBody();
  825. const annotations = body.querySelectorAll('span[data-annotation-id]');
  826. this.handleEmptySpan(editor);
  827. // 存储所有现有的注释ID
  828. const existingIds = new Set();
  829. annotations.forEach((span) => {
  830. existingIds.add(span.getAttribute('data-annotation-id'));
  831. });
  832. // 与你存储的注释数据对比,清理不存在的
  833. this.$emit('compareAnnotationAndSave', existingIds);
  834. },
  835. // 删除span里面的文字之后,会出现空 span 标签残留,需处理掉
  836. handleEmptySpan(editor) {
  837. const selection = editor.selection;
  838. const selectedNode = selection.getNode();
  839. // 如果选中的是注释span内的内容
  840. if (selectedNode.nodeType === 1 && selectedNode.hasAttribute('data-annotation-id')) {
  841. const span = selectedNode;
  842. // 检查删除后是否为空
  843. if (!span.textContent || /^\s*$/.test(span.textContent)) {
  844. // 保存注释ID
  845. const annotationId = span.getAttribute('data-annotation-id');
  846. // 用其父节点替换span
  847. span.parentNode.replaceChild(document.createTextNode(''), span);
  848. // 从存储中移除注释
  849. this.$emit('selectContentSetMemo', null, annotationId);
  850. }
  851. }
  852. },
  853. },
  854. };
  855. </script>
  856. <style lang="scss" scoped>
  857. .rich-text {
  858. :deep + .tox {
  859. .tox-sidebar-wrap {
  860. border: 1px solid $fill-color;
  861. border-radius: 4px;
  862. &:hover {
  863. border-color: #c0c4cc;
  864. }
  865. }
  866. &.tox-tinymce {
  867. border-width: 0;
  868. border-radius: 0;
  869. .tox-edit-area__iframe {
  870. background-color: $fill-color;
  871. }
  872. }
  873. &:not(.tox-tinymce-inline) .tox-editor-header {
  874. box-shadow: none;
  875. }
  876. }
  877. &.is-border {
  878. :deep + .tox.tox-tinymce {
  879. border: $border;
  880. border-radius: 4px;
  881. }
  882. }
  883. }
  884. .contentmenu {
  885. position: absolute;
  886. z-index: 999;
  887. display: flex;
  888. column-gap: 4px;
  889. align-items: center;
  890. padding: 4px 8px;
  891. font-size: 14px;
  892. background-color: #fff;
  893. border-radius: 2px;
  894. box-shadow: 0 1px 10px 0 rgba(0, 0, 0, 10%);
  895. .svg-icon,
  896. .button {
  897. cursor: pointer;
  898. }
  899. .line {
  900. min-height: 16px;
  901. margin: 0 4px;
  902. }
  903. }
  904. </style>