RichText.vue 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791
  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 | underline | bold italic strikethrough alignleft aligncenter alignright | bullist numlist | image media | link blockquote hr mathjax',
  91. },
  92. wordlimitNum: {
  93. type: [Number, Boolean],
  94. default: 1000,
  95. },
  96. isFill: {
  97. type: Boolean,
  98. default: false,
  99. },
  100. fontSize: {
  101. type: Number,
  102. default: 16,
  103. },
  104. isHasSpace: {
  105. type: Boolean,
  106. default: false,
  107. },
  108. isViewPinyin: {
  109. type: Boolean,
  110. default: false,
  111. },
  112. pageFrom: {
  113. type: String,
  114. default: '',
  115. },
  116. isViewNote: {
  117. type: Boolean,
  118. default: false,
  119. },
  120. },
  121. data() {
  122. return {
  123. isViewMathDialog: false,
  124. mathEleIsInit: true,
  125. math: '',
  126. isShow: false,
  127. contentmenu: {
  128. top: 0,
  129. left: 0,
  130. },
  131. id: getRandomNumber(),
  132. init: {
  133. content_style: `
  134. mjx-container, mjx-container * {
  135. font-size: 16px !important; /* 强制固定字体 */
  136. line-height: 1.2 !important; /* 避免行高影响 */
  137. } `, // 解决公式每点击一次字体就变大
  138. valid_elements: '*[*]', // 允许所有标签和属性
  139. valid_children: '+body[style]', // 允许 MathJax 的样式
  140. extended_valid_elements: 'span[*],mjx-container[*],svg[*],path[*]', // 明确允许 MathJax 标签
  141. inline: this.inline,
  142. font_size: this.fontSize,
  143. language_url: `${process.env.BASE_URL}tinymce/langs/zh_CN.js`,
  144. placeholder: this.placeholder,
  145. language: 'zh_CN',
  146. skin_url: `${process.env.BASE_URL}tinymce/skins/ui/oxide`,
  147. content_css: `${process.env.BASE_URL}tinymce/skins/content/${this.isHasSpace ? 'index-nospace' : 'index'}.css`,
  148. min_height: this.height,
  149. width: '100%',
  150. autoresize_bottom_margin: 0,
  151. plugins: 'link lists image hr media autoresize ax_wordlimit paste',
  152. toolbar: this.toolbar, // 工具栏
  153. contextmenu: false, // 右键菜单
  154. menubar: false, // 菜单栏
  155. branding: false, // 品牌
  156. statusbar: false, // 状态栏
  157. setup: (editor) => {
  158. let isRendered = false; // 标记是否已渲染
  159. let that = this;
  160. editor.on('init', () => {
  161. editor.getBody().style.fontSize = `${this.font_size}pt`; // 设置默认字体大小
  162. editor.getBody().style.fontFamily = 'Arial'; // 设置默认字体
  163. });
  164. editor.on('click', (e) => {
  165. if (editor?.queryCommandState('ToggleToolbarDrawer')) {
  166. editor.execCommand('ToggleToolbarDrawer');
  167. }
  168. if (!isRendered && window.MathJax) {
  169. isRendered = true;
  170. window.MathJax.typesetPromise([editor.getBody()]);
  171. }
  172. });
  173. // 添加 MathJax 按钮
  174. editor.ui.registry.addButton('mathjax', {
  175. text: '∑',
  176. tooltip: '插入公式',
  177. onAction: () => {
  178. this.isViewMathDialog = true;
  179. },
  180. });
  181. // 内容变化时重新渲染公式
  182. editor.on('change', () => this.renderMath());
  183. editor.on('KeyDown', function (e) {
  184. // 检测删除或退格键
  185. if (e.keyCode === 8 || e.keyCode === 46) {
  186. // 延迟执行以确保删除已完成
  187. setTimeout(function () {
  188. that.cleanupRemovedAnnotations(editor);
  189. }, 500);
  190. }
  191. });
  192. // 也可以监听剪切操作
  193. editor.on('Cut', function () {
  194. setTimeout(function () {
  195. that.cleanupRemovedAnnotations(editor);
  196. }, 500);
  197. });
  198. // editor.on('NodeChange', function (e) {
  199. // if (
  200. // e.element &&
  201. // e.element.tagName === 'SPAN' &&
  202. // e.element.hasAttribute('data-annotation-id') &&
  203. // (!e.element.textContent || /^\s*$/.test(e.element.textContent))
  204. // ) {
  205. // const annotationId = e.element.getAttribute('data-annotation-id');
  206. // e.element.parentNode.removeChild(e.element);
  207. // that.$emit('selectContentSetMemo', null, annotationId);
  208. // }
  209. // });
  210. },
  211. font_formats:
  212. '楷体=楷体,微软雅黑;' +
  213. '黑体=黑体,微软雅黑;' +
  214. '宋体=宋体,微软雅黑;' +
  215. 'Arial=arial,helvetica,sans-serif;' +
  216. 'Times New Roman=times new roman,times,serif;' +
  217. '拼音=League;',
  218. fontsize_formats: '8pt 10pt 11pt 12pt 14pt 16pt 18pt 20pt 22pt 24pt 26pt 28pt 30pt 32pt 34pt 36pt',
  219. // 字数限制
  220. ax_wordlimit_num: this.wordlimitNum,
  221. ax_wordlimit_callback(editor) {
  222. editor.execCommand('undo');
  223. },
  224. media_filter_html: false,
  225. images_upload_handler: this.imagesUploadHandler,
  226. file_picker_types: 'media', // 文件上传类型
  227. file_picker_callback: this.filePickerCallback,
  228. init_instance_callback: this.isFill || this.isViewNote ? this.initInstanceCallback : '',
  229. paste_enable_default_filters: false, // 禁用默认的粘贴过滤器
  230. // 粘贴预处理
  231. paste_preprocess(plugin, args) {
  232. let content = args.content;
  233. // 使用正则表达式去掉 style 中的 background 属性
  234. content = content.replace(/background(-color)?:[^;]+;/g, '');
  235. args.content = content;
  236. },
  237. // 指定在 WebKit 中粘贴时要保留的样式
  238. paste_webkit_styles:
  239. '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',
  240. },
  241. };
  242. },
  243. watch: {
  244. isViewNote: {
  245. handler(newVal, oldVal) {
  246. if (newVal) {
  247. let editor = tinymce.get(this.id);
  248. if (editor) {
  249. let start = editor.selection.getStart();
  250. this.$emit('selectNote', start.getAttribute('data-annotation-id'));
  251. }
  252. }
  253. },
  254. },
  255. },
  256. created() {
  257. if (this.pageFrom !== 'audit') {
  258. window.addEventListener('click', this.hideToolbarDrawer);
  259. }
  260. if (this.isFill || this.isViewNote) {
  261. window.addEventListener('click', this.hideContentmenu);
  262. }
  263. this.setBackgroundColor();
  264. },
  265. beforeDestroy() {
  266. if (this.pageFrom !== 'audit') {
  267. window.removeEventListener('click', this.hideToolbarDrawer);
  268. }
  269. if (this.isFill || this.isViewNote) {
  270. window.removeEventListener('click', this.hideContentmenu);
  271. }
  272. },
  273. methods: {
  274. // 设置背景色
  275. setBackgroundColor() {
  276. let iframes = document.getElementsByTagName('iframe');
  277. for (let i = 0; i < iframes.length; i++) {
  278. let iframe = iframes[i];
  279. // 获取 <iframe> 内部的文档对象
  280. let iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
  281. let bodyElement = iframeDocument.body;
  282. if (bodyElement) {
  283. // 设置背景色
  284. bodyElement.style.backgroundColor = '#f2f3f5';
  285. }
  286. }
  287. },
  288. /**
  289. * 判断内容是否全部加粗
  290. */
  291. isAllBold() {
  292. let editor = tinymce.get(this.id);
  293. let body = editor.getBody();
  294. function getTextNodes(node) {
  295. let textNodes = [];
  296. if (node.nodeType === 3 && node.nodeValue.trim() !== '') {
  297. textNodes.push(node);
  298. } else {
  299. for (let child of node.childNodes) {
  300. textNodes = textNodes.concat(getTextNodes(child));
  301. }
  302. }
  303. return textNodes;
  304. }
  305. let textNodes = getTextNodes(body);
  306. if (textNodes.length === 0) return false;
  307. return textNodes.every((node) => {
  308. let el = node.parentElement;
  309. while (el && el !== body) {
  310. const tag = el.tagName.toLowerCase();
  311. const fontWeight = window.getComputedStyle(el).fontWeight;
  312. if (tag === 'b' || tag === 'strong' || fontWeight === 'bold' || parseInt(fontWeight) >= 600) {
  313. return true;
  314. }
  315. el = el.parentElement;
  316. }
  317. return false;
  318. });
  319. },
  320. /**
  321. * 设置整体富文本格式
  322. * @param {string} text 格式名称
  323. */
  324. setRichFormat(text) {
  325. let editor = tinymce.get(this.id);
  326. editor.execCommand('SelectAll');
  327. if (text === 'bold') {
  328. if (this.isAllBold()) {
  329. editor.formatter.remove('bold');
  330. } else {
  331. editor.formatter.apply('bold');
  332. }
  333. } else {
  334. editor.formatter.toggle(text);
  335. }
  336. editor.selection.collapse(false);
  337. },
  338. /**
  339. * 图片上传自定义逻辑函数
  340. * @param {object} blobInfo 文件数据
  341. * @param {Function} success 成功回调函数
  342. * @param {Function} fail 失败回调函数
  343. */
  344. imagesUploadHandler(blobInfo, success, fail) {
  345. let file = blobInfo.blob();
  346. const formData = new FormData();
  347. formData.append(file.name, file, file.name);
  348. fileUpload('Mid', formData, { isGlobalprogress: true })
  349. .then(({ file_info_list }) => {
  350. if (file_info_list.length > 0) {
  351. success(file_info_list[0].file_url_open);
  352. } else {
  353. fail('上传失败');
  354. }
  355. })
  356. .catch(() => {
  357. fail('上传失败');
  358. });
  359. },
  360. /**
  361. * 文件上传自定义逻辑函数
  362. * @param {Function} callback
  363. * @param {String} value
  364. * @param {object} meta
  365. */
  366. filePickerCallback(callback, value, meta) {
  367. if (meta.filetype === 'media') {
  368. let filetype = '.mp3, .mp4';
  369. let input = document.createElement('input');
  370. input.setAttribute('type', 'file');
  371. input.setAttribute('accept', filetype);
  372. input.click();
  373. input.addEventListener('change', () => {
  374. let file = input.files[0];
  375. const formData = new FormData();
  376. formData.append(file.name, file, file.name);
  377. fileUpload('Mid', formData, { isGlobalprogress: true })
  378. .then(({ file_info_list }) => {
  379. if (file_info_list.length > 0) {
  380. callback(file_info_list[0].file_url_open);
  381. } else {
  382. callback('');
  383. }
  384. })
  385. .catch(() => {
  386. callback('');
  387. });
  388. });
  389. }
  390. },
  391. /**
  392. * 初始化编辑器实例回调函数
  393. * @param {Editor} editor 编辑器实例
  394. */
  395. initInstanceCallback(editor) {
  396. editor.on('SetContent Undo Redo ViewUpdate keyup mousedown', () => {
  397. this.hideContentmenu();
  398. });
  399. editor.on('click', (e) => {
  400. if (e.target.classList.contains('rich-fill')) {
  401. editor.selection.select(e.target); // 选中填空
  402. let { offsetLeft, offsetTop } = e.target;
  403. this.showContentmenu({
  404. pixelsFromLeft: offsetLeft - 14,
  405. pixelsFromTop: offsetTop,
  406. });
  407. }
  408. });
  409. let mouseX = 0;
  410. editor.on('mousedown', (e) => {
  411. mouseX = e.offsetX;
  412. });
  413. editor.on('mouseup', (e) => {
  414. let start = editor.selection.getStart();
  415. let end = editor.selection.getEnd();
  416. let rng = editor.selection.getRng();
  417. if (start !== end || rng.collapsed) {
  418. this.hideContentmenu();
  419. return;
  420. }
  421. if (e.offsetX < mouseX) {
  422. mouseX = e.offsetX;
  423. }
  424. if (isNodeType(start, 'span')) {
  425. start = start.parentNode;
  426. }
  427. // 获取文本内容和起始偏移位置
  428. let text = start.textContent;
  429. let startOffset = rng.startOffset;
  430. let previousSibling = rng.startContainer.previousSibling;
  431. // 判断是否选中的是 span 标签
  432. let isSpan = isNodeType(rng.startContainer.parentNode, 'span');
  433. if (isSpan) {
  434. previousSibling = rng.startContainer.parentNode.previousSibling;
  435. }
  436. // 计算起始偏移位置
  437. while (previousSibling) {
  438. startOffset += previousSibling.textContent.length;
  439. previousSibling = previousSibling.previousSibling;
  440. }
  441. // 获取起始偏移位置前的文本内容
  442. const textBeforeOffset = text.substring(0, startOffset);
  443. /* 使用 Canvas API测量文本宽度 */
  444. // 获取字体大小和行高
  445. let computedStyle = window.getComputedStyle(start);
  446. const fontSize = parseFloat(computedStyle.fontSize);
  447. const canvas = document.createElement('canvas');
  448. const context = canvas.getContext('2d');
  449. context.font = `${fontSize}pt ${computedStyle.fontFamily}`;
  450. // 计算文字距离左侧的像素位置
  451. const width = context.measureText(textBeforeOffset).width;
  452. const lineHeight = context.measureText('M').fontBoundingBoxAscent + 3.8; // 获取行高
  453. /* 计算偏移位置 */
  454. const computedWidth = computedStyle.width.replace('px', ''); // 获取编辑器宽度
  455. let row = width / computedWidth; // 计算选中文本在第几行
  456. row = row % 1 > 0.8 ? Math.ceil(row) : Math.floor(row);
  457. const offsetTop = start.offsetTop; // 获取选中文本距离顶部的像素位置
  458. let pixelsFromTop = offsetTop + lineHeight * row; // 计算选中文本距离顶部的像素位置
  459. this.showContentmenu({
  460. pixelsFromLeft: mouseX,
  461. pixelsFromTop,
  462. });
  463. });
  464. },
  465. // 删除填空
  466. deleteContent() {
  467. let editor = tinymce.get(this.id);
  468. let start = editor.selection.getStart();
  469. if (isNodeType(start, 'span')) {
  470. let textContent = start.textContent;
  471. let content = editor.selection.getContent();
  472. let str = textContent.split(content);
  473. start.remove();
  474. editor.selection.setContent(str.join(content));
  475. } else {
  476. this.collapse();
  477. }
  478. },
  479. // 设置填空
  480. setContent() {
  481. let editor = tinymce.get(this.id);
  482. let start = editor.selection.getStart();
  483. let content = editor.selection.getContent();
  484. if (isNodeType(start, 'span')) {
  485. let textContent = start.textContent;
  486. let str = textContent.split(content);
  487. start.remove();
  488. editor.selection.setContent(str.join(this.getSpanString(content)));
  489. } else {
  490. let str = this.replaceSpanString(content);
  491. editor.selection.setContent(this.getSpanString(str));
  492. }
  493. },
  494. // 折叠选区
  495. collapse() {
  496. let editor = tinymce.get(this.id);
  497. let rng = editor.selection.getRng();
  498. if (!rng.collapsed) {
  499. this.hideContentmenu();
  500. editor.selection.collapse();
  501. }
  502. },
  503. // 获取 span 标签
  504. getSpanString(str) {
  505. return `<span class="rich-fill" style="text-decoration: underline;cursor: pointer;">${str}</span>`;
  506. },
  507. // 去除 span 标签
  508. replaceSpanString(str) {
  509. return str.replace(/<span\b[^>]*>(.*?)<\/span>/gi, '$1');
  510. },
  511. crateParsedTextInfoPinyin(content) {
  512. let text = content.replace(/<[^>]+>/g, '');
  513. this.$emit('crateParsedTextInfoPinyin', text);
  514. },
  515. handleRichTextBlur() {
  516. this.$emit('handleRichTextBlur');
  517. let content = tinymce.get(this.id).getContent();
  518. if (this.isViewPinyin) {
  519. this.crateParsedTextInfoPinyin(content);
  520. return;
  521. }
  522. // 判断富文本中是否有 字母+数字+空格 的组合,如果没有,直接返回
  523. let isHasPinyin = content
  524. .split(/<[^>]+>/g)
  525. .filter((item) => item)
  526. .some((item) => item.match(/[a-zA-Z]+\d(\s|&nbsp;)*/));
  527. if (!isHasPinyin) {
  528. return;
  529. }
  530. // 用标签分割富文本,保留标签
  531. let reg = /(<[^>]+>)/g;
  532. let text = content
  533. .split(reg)
  534. .filter((item) => item)
  535. // 如果是标签,直接返回
  536. // 如果是文本,将文本按空格分割为数组,如果是拼音,将拼音转为带音调的拼音
  537. .map((item) => {
  538. // 重置正则,使用全局匹配 g 时需要重置,否则会出现匹配不到的情况,因为 lastIndex 会一直累加,test 方法会从 lastIndex 开始匹配
  539. reg.lastIndex = 0;
  540. if (reg.test(item)) {
  541. return item;
  542. }
  543. return item.split(/\s+/).map((item) => handleToneValue(item));
  544. })
  545. // 如果是标签,直接返回
  546. // 二维数组,转为拼音,并打平为一维数组
  547. .map((item) => {
  548. if (/<[^>]+>/g.test(item)) return item;
  549. return item
  550. .map(({ number, con }) => (number && con ? addTone(Number(number), con) : number || con || ''))
  551. .flat();
  552. })
  553. // 如果是数组,将数组字符串每两个之间加一个空格
  554. .map((item) => {
  555. if (typeof item === 'string') return item;
  556. return item.join(' ');
  557. })
  558. .join('');
  559. // 更新 v-model
  560. this.$emit('input', text);
  561. },
  562. // 设置填空
  563. setFill() {
  564. this.setContent();
  565. this.hideContentmenu();
  566. },
  567. // 删除填空
  568. deleteFill() {
  569. this.deleteContent();
  570. this.hideContentmenu();
  571. },
  572. // 隐藏工具栏抽屉
  573. hideToolbarDrawer() {
  574. let editor = tinymce.get(this.id);
  575. if (editor.queryCommandState('ToggleToolbarDrawer')) {
  576. editor.execCommand('ToggleToolbarDrawer');
  577. }
  578. },
  579. // 隐藏填空右键菜单
  580. hideContentmenu() {
  581. this.isShow = false;
  582. },
  583. showContentmenu({ pixelsFromLeft, pixelsFromTop }) {
  584. this.isShow = true;
  585. // console.log(pixelsFromLeft, pixelsFromTop);
  586. this.contentmenu = {
  587. left: `${this.isViewNote ? pixelsFromLeft : pixelsFromLeft + 14}px`,
  588. top: `${this.isViewNote ? pixelsFromTop + 62 : pixelsFromTop + 22}px`,
  589. };
  590. },
  591. mathConfirm(math) {
  592. let editor = tinymce.get(this.id);
  593. let tmpId = getRandomNumber();
  594. editor.insertContent(`
  595. <span id="${tmpId}" contenteditable="false" class="mathjax-container editor-math">
  596. ${math}
  597. </span>
  598. `);
  599. this.mathEleIsInit = false;
  600. this.renderMath(tmpId);
  601. this.isViewMathDialog = false;
  602. },
  603. // 渲染公式
  604. async renderMath(id) {
  605. if (this.mathEleIsInit) return; // 如果公式已经渲染过,返回
  606. if (window.MathJax) {
  607. let editor = tinymce.get(this.id);
  608. let eleMathArs = [];
  609. if (id) {
  610. // 插入的时候,会传递ID,执行单个渲染
  611. let ele = editor.dom.select(`#${id}`)[0];
  612. eleMathArs = [ele];
  613. } else {
  614. // 否则,查询编辑器里面所有的公式
  615. eleMathArs = editor.dom.select(`.editor_math`);
  616. }
  617. if (eleMathArs.length === 0) return;
  618. await this.$nextTick();
  619. window.MathJax.typesetPromise(eleMathArs).catch((err) => console.error('MathJax error:', err));
  620. this.mathEleIsInit = true;
  621. }
  622. },
  623. // 获取高亮 span 标签
  624. getLightSpanString(noteId, str) {
  625. return `<span data-annotation-id="${noteId}" class="rich-fill" style="background-color:#fff3b7;cursor: pointer;">${str}</span>`;
  626. },
  627. // 选中文本打开弹窗
  628. openExplanatoryNoteDialog() {
  629. let editor = tinymce.get(this.id);
  630. let start = editor.selection.getStart();
  631. this.$emit('view-explanatory-note', { visible: true, noteId: start.getAttribute('data-annotation-id') });
  632. this.hideContentmenu();
  633. },
  634. // 设置高亮背景,并保留备注
  635. setExplanatoryNote(richData) {
  636. let noteId = '';
  637. let editor = tinymce.get(this.id);
  638. let start = editor.selection.getStart();
  639. let content = editor.selection.getContent();
  640. if (isNodeType(start, 'span')) {
  641. noteId = start.getAttribute('data-annotation-id');
  642. } else {
  643. noteId = `${this.id}_${Math.floor(Math.random() * 1000000)
  644. .toString()
  645. .padStart(10, '0')}`;
  646. let str = this.replaceSpanString(content);
  647. editor.selection.setContent(this.getLightSpanString(noteId, str));
  648. }
  649. let selectText = content.replace(/<[^>]+>/g, '');
  650. let note = { id: noteId, note: richData.note, selectText };
  651. this.$emit('selectContentSetMemo', note);
  652. },
  653. // 取消注释
  654. cancelExplanatoryNote() {
  655. let editor = tinymce.get(this.id);
  656. let start = editor.selection.getStart();
  657. if (isNodeType(start, 'span')) {
  658. let textContent = start.textContent;
  659. let content = editor.selection.getContent();
  660. let str = textContent.split(content);
  661. start.remove();
  662. editor.selection.setContent(str.join(content));
  663. this.$emit('selectContentSetMemo', null, start.getAttribute('data-annotation-id'));
  664. } else {
  665. this.collapse();
  666. }
  667. },
  668. // 删除,监听处理备注
  669. cleanupRemovedAnnotations(editor) {
  670. if (!this.isViewNote) return; // 只有富文本才处理
  671. const body = editor.getBody();
  672. const annotations = body.querySelectorAll('span[data-annotation-id]');
  673. this.handleEmptySpan(editor);
  674. // 存储所有现有的注释ID
  675. const existingIds = new Set();
  676. annotations.forEach((span) => {
  677. existingIds.add(span.getAttribute('data-annotation-id'));
  678. });
  679. // 与你存储的注释数据对比,清理不存在的
  680. this.$emit('compareAnnotationAndSave', existingIds);
  681. },
  682. // 删除span里面的文字之后,会出现空 span 标签残留,需处理掉
  683. handleEmptySpan(editor) {
  684. let that = this;
  685. const selection = editor.selection;
  686. const selectedNode = selection.getNode();
  687. // 如果选中的是注释span内的内容
  688. if (selectedNode.nodeType === 1 && selectedNode.hasAttribute('data-annotation-id')) {
  689. const span = selectedNode;
  690. // 检查删除后是否为空
  691. if (!span.textContent || /^\s*$/.test(span.textContent)) {
  692. // 保存注释ID
  693. const annotationId = span.getAttribute('data-annotation-id');
  694. // 用其父节点替换span
  695. span.parentNode.replaceChild(document.createTextNode(''), span);
  696. // 从存储中移除注释
  697. that.$emit('selectContentSetMemo', null, annotationId);
  698. }
  699. }
  700. },
  701. },
  702. };
  703. </script>
  704. <style lang="scss" scoped>
  705. .rich-text {
  706. :deep + .tox {
  707. .tox-sidebar-wrap {
  708. border: 1px solid $fill-color;
  709. border-radius: 4px;
  710. &:hover {
  711. border-color: #c0c4cc;
  712. }
  713. }
  714. &.tox-tinymce {
  715. border-width: 0;
  716. border-radius: 0;
  717. .tox-edit-area__iframe {
  718. background-color: $fill-color;
  719. }
  720. }
  721. &:not(.tox-tinymce-inline) .tox-editor-header {
  722. box-shadow: none;
  723. }
  724. }
  725. &.is-border {
  726. :deep + .tox.tox-tinymce {
  727. border: $border;
  728. border-radius: 4px;
  729. }
  730. }
  731. }
  732. .contentmenu {
  733. position: absolute;
  734. z-index: 999;
  735. display: flex;
  736. column-gap: 4px;
  737. align-items: center;
  738. padding: 4px 8px;
  739. font-size: 14px;
  740. background-color: #fff;
  741. border-radius: 2px;
  742. box-shadow: 0 1px 10px 0 rgba(0, 0, 0, 10%);
  743. .svg-icon,
  744. .button {
  745. cursor: pointer;
  746. }
  747. .line {
  748. min-height: 16px;
  749. margin: 0 4px;
  750. }
  751. }
  752. mjx-container {
  753. font-size: 16px !important;
  754. }
  755. </style>