RichText.vue 27 KB

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