RichText.vue 29 KB

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