RichText.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659
  1. <template>
  2. <div class="rich-wrapper">
  3. <Editor
  4. v-bind="$attrs"
  5. :id="id"
  6. ref="richText"
  7. model-events="change keyup undo redo setContent"
  8. :value="value"
  9. :class="['rich-text', isBorder ? 'is-border' : '']"
  10. :init="init"
  11. v-on="$listeners"
  12. @onBlur="handleRichTextBlur"
  13. />
  14. <div v-show="isShow" :style="contentmenu" class="contentmenu">
  15. <SvgIcon icon-class="slice" size="16" @click="setFill" />
  16. <span class="button" @click="setFill">设为填空</span>
  17. <span class="line"></span>
  18. <SvgIcon icon-class="close-circle" size="16" @click="deleteFill" />
  19. <span class="button" @click="deleteFill">删除填空</span>
  20. </div>
  21. <el-dialog
  22. title="输入公式"
  23. :visible.sync="isViewMathDialog"
  24. width="400px"
  25. top="20vh"
  26. :show-close="false"
  27. :close-on-press-escape="false"
  28. :close-on-click-modal="false"
  29. @close="isViewMathDialog = false"
  30. >
  31. <el-input
  32. v-model="mathData.math"
  33. type="textarea"
  34. :autosize="{ minRows: 4, maxRows: 8 }"
  35. resize="none"
  36. placeholder="请输入公式"
  37. />
  38. <div ref="mathContainer" v-data-h="mathData.math" class="formula-render" v-html="mathData.math"></div>
  39. <template slot="footer">
  40. <el-button size="medium" @click="isViewMathDialog = false">取消</el-button>
  41. <el-button type="primary" size="medium" @click="mathConfirm">确定</el-button>
  42. </template>
  43. </el-dialog>
  44. </div>
  45. </template>
  46. <script>
  47. import tinymce from 'tinymce/tinymce';
  48. import Editor from '@tinymce/tinymce-vue';
  49. import 'tinymce/icons/default/icons';
  50. import 'tinymce/themes/silver';
  51. // 引入富文本编辑器主题的js和css
  52. import 'tinymce/themes/silver/theme.min';
  53. import 'tinymce/skins/ui/oxide/skin.min.css';
  54. // 扩展插件
  55. import 'tinymce/plugins/image';
  56. import 'tinymce/plugins/link';
  57. // import 'tinymce/plugins/code';
  58. // import 'tinymce/plugins/table';
  59. import 'tinymce/plugins/lists';
  60. // import 'tinymce/plugins/wordcount'; // 字数统计插件
  61. import 'tinymce/plugins/media'; // 插入视频插件
  62. // import 'tinymce/plugins/template'; // 模板插件
  63. // import 'tinymce/plugins/fullscreen'; // 全屏插件
  64. import 'tinymce/plugins/paste'; // 粘贴插件
  65. // import 'tinymce/plugins/preview'; // 预览插件
  66. import 'tinymce/plugins/hr';
  67. import 'tinymce/plugins/autoresize'; // 自动调整大小插件
  68. import 'tinymce/plugins/ax_wordlimit'; // 字数限制插件
  69. import { getRandomNumber } from '@/utils';
  70. import { isNodeType } from '@/utils/validate';
  71. import { fileUpload } from '@/api/app';
  72. import { addTone, handleToneValue } from '@/utils/common';
  73. import { getMathData } from '@/views/book/courseware/data/math';
  74. export default {
  75. name: 'RichText',
  76. components: {
  77. Editor,
  78. },
  79. inheritAttrs: false,
  80. props: {
  81. inline: {
  82. type: Boolean,
  83. default: false,
  84. },
  85. placeholder: {
  86. type: String,
  87. default: '输入内容',
  88. },
  89. value: {
  90. type: String,
  91. default: '',
  92. },
  93. height: {
  94. type: [Number, String],
  95. default: 52,
  96. },
  97. isBorder: {
  98. type: Boolean,
  99. default: false,
  100. },
  101. toolbar: {
  102. type: [String, Boolean],
  103. /* eslint-disable max-len */
  104. default:
  105. 'fontselect fontsizeselect forecolor backcolor | underline | bold italic strikethrough alignleft aligncenter alignright | bullist numlist | image media | link blockquote hr mathjax',
  106. },
  107. wordlimitNum: {
  108. type: [Number, Boolean],
  109. default: 1000,
  110. },
  111. isFill: {
  112. type: Boolean,
  113. default: false,
  114. },
  115. fontSize: {
  116. type: Number,
  117. default: 16,
  118. },
  119. isHasSpace: {
  120. type: Boolean,
  121. default: false,
  122. },
  123. isViewPinyin: {
  124. type: Boolean,
  125. default: false,
  126. },
  127. pageFrom: {
  128. type: String,
  129. default: '',
  130. },
  131. },
  132. data() {
  133. return {
  134. mathData: getMathData(),
  135. isViewMathDialog: false,
  136. dialogMathValidate: false,
  137. mathEleIsInit: true,
  138. math: '',
  139. isShow: false,
  140. contentmenu: {
  141. top: 0,
  142. left: 0,
  143. },
  144. id: getRandomNumber(),
  145. init: {
  146. content_style: `
  147. mjx-container, mjx-container * {
  148. font-size: 16px !important; /* 强制固定字体 */
  149. line-height: 1.2 !important; /* 避免行高影响 */
  150. } `, // 解决公式每点击一次字体就变大
  151. valid_elements: '*[*]', // 允许所有标签和属性
  152. valid_children: '+body[style]', // 允许 MathJax 的样式
  153. extended_valid_elements: 'span[*],mjx-container[*],svg[*],path[*]', // 明确允许 MathJax 标签
  154. inline: this.inline,
  155. font_size: this.fontSize,
  156. language_url: `${process.env.BASE_URL}tinymce/langs/zh_CN.js`,
  157. placeholder: this.placeholder,
  158. language: 'zh_CN',
  159. skin_url: `${process.env.BASE_URL}tinymce/skins/ui/oxide`,
  160. content_css: `${process.env.BASE_URL}tinymce/skins/content/${this.isHasSpace ? 'index-nospace' : 'index'}.css`,
  161. min_height: this.height,
  162. width: '100%',
  163. autoresize_bottom_margin: 0,
  164. plugins: 'link lists image hr media autoresize ax_wordlimit paste',
  165. toolbar: this.toolbar, // 工具栏
  166. contextmenu: false, // 右键菜单
  167. menubar: false, // 菜单栏
  168. branding: false, // 品牌
  169. statusbar: false, // 状态栏
  170. setup: (editor) => {
  171. let isRendered = false; // 标记是否已渲染
  172. editor.on('init', () => {
  173. editor.getBody().style.fontSize = `${this.font_size}pt`; // 设置默认字体大小
  174. editor.getBody().style.fontFamily = 'Arial'; // 设置默认字体
  175. });
  176. editor.on('click', () => {
  177. if (editor?.queryCommandState('ToggleToolbarDrawer')) {
  178. editor.execCommand('ToggleToolbarDrawer');
  179. }
  180. if (!isRendered && window.MathJax) {
  181. isRendered = true;
  182. window.MathJax.typesetPromise([editor.getBody()]);
  183. }
  184. });
  185. // 添加 MathJax 按钮
  186. editor.ui.registry.addButton('mathjax', {
  187. text: '∑',
  188. tooltip: '插入公式',
  189. onAction: () => {
  190. this.isViewMathDialog = true;
  191. },
  192. });
  193. // 内容变化时重新渲染公式
  194. editor.on('change', () => this.renderMath());
  195. },
  196. font_formats:
  197. '楷体=楷体,微软雅黑;' +
  198. '黑体=黑体,微软雅黑;' +
  199. '宋体=宋体,微软雅黑;' +
  200. 'Arial=arial,helvetica,sans-serif;' +
  201. 'Times New Roman=times new roman,times,serif;' +
  202. '拼音=League;',
  203. // 字数限制
  204. fontsize_formats: '8pt 10pt 11pt 12pt 14pt 16pt 18pt 20pt 22pt 24pt 26pt 28pt 30pt 32pt 34pt 36pt',
  205. ax_wordlimit_num: this.wordlimitNum,
  206. ax_wordlimit_callback(editor) {
  207. editor.execCommand('undo');
  208. },
  209. media_filter_html: false,
  210. images_upload_handler: this.imagesUploadHandler,
  211. file_picker_types: 'media', // 文件上传类型
  212. file_picker_callback: this.filePickerCallback,
  213. init_instance_callback: this.isFill ? this.initInstanceCallback : '',
  214. paste_enable_default_filters: false, // 禁用默认的粘贴过滤器
  215. // 粘贴预处理
  216. paste_preprocess(plugin, args) {
  217. let content = args.content;
  218. // 使用正则表达式去掉 style 中的 background 属性
  219. content = content.replace(/background(-color)?:[^;]+;/g, '');
  220. args.content = content;
  221. },
  222. // 指定在 WebKit 中粘贴时要保留的样式
  223. paste_webkit_styles:
  224. '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',
  225. },
  226. };
  227. },
  228. watch: {
  229. // 监听数据变化,重新渲染 MathJax
  230. 'mathData.math': {
  231. handler(n, o) {
  232. if (n) this.renderMathDialog();
  233. },
  234. deep: true,
  235. },
  236. isViewMathDialog: {
  237. handler() {
  238. this.dialogMathValidate = false;
  239. this.mathData.math = '';
  240. },
  241. },
  242. },
  243. created() {
  244. if (this.pageFrom !== 'audit') {
  245. window.addEventListener('click', this.hideToolbarDrawer);
  246. }
  247. if (this.isFill) {
  248. window.addEventListener('click', this.hideContentmenu);
  249. }
  250. this.setBackgroundColor();
  251. },
  252. beforeDestroy() {
  253. if (this.pageFrom !== 'audit') {
  254. window.removeEventListener('click', this.hideToolbarDrawer);
  255. }
  256. if (this.isFill) {
  257. window.removeEventListener('click', this.hideContentmenu);
  258. }
  259. },
  260. methods: {
  261. // 设置背景色
  262. setBackgroundColor() {
  263. let iframes = document.getElementsByTagName('iframe');
  264. for (let i = 0; i < iframes.length; i++) {
  265. let iframe = iframes[i];
  266. // 获取 <iframe> 内部的文档对象
  267. let iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
  268. let bodyElement = iframeDocument.body;
  269. if (bodyElement) {
  270. // 设置背景色
  271. bodyElement.style.backgroundColor = '#f2f3f5';
  272. }
  273. }
  274. },
  275. /**
  276. * 图片上传自定义逻辑函数
  277. * @param {object} blobInfo 文件数据
  278. * @param {Function} success 成功回调函数
  279. * @param {Function} fail 失败回调函数
  280. */
  281. imagesUploadHandler(blobInfo, success, fail) {
  282. let file = blobInfo.blob();
  283. const formData = new FormData();
  284. formData.append(file.name, file, file.name);
  285. fileUpload('Mid', formData, { isGlobalprogress: true })
  286. .then(({ file_info_list }) => {
  287. if (file_info_list.length > 0) {
  288. success(file_info_list[0].file_url_open);
  289. } else {
  290. fail('上传失败');
  291. }
  292. })
  293. .catch(() => {
  294. fail('上传失败');
  295. });
  296. },
  297. /**
  298. * 文件上传自定义逻辑函数
  299. * @param {Function} callback
  300. * @param {String} value
  301. * @param {object} meta
  302. */
  303. filePickerCallback(callback, value, meta) {
  304. if (meta.filetype === 'media') {
  305. let filetype = '.mp3, .mp4';
  306. let input = document.createElement('input');
  307. input.setAttribute('type', 'file');
  308. input.setAttribute('accept', filetype);
  309. input.click();
  310. input.addEventListener('change', () => {
  311. let file = input.files[0];
  312. const formData = new FormData();
  313. formData.append(file.name, file, file.name);
  314. fileUpload('Mid', formData, { isGlobalprogress: true })
  315. .then(({ file_info_list }) => {
  316. if (file_info_list.length > 0) {
  317. callback(file_info_list[0].file_url_open);
  318. } else {
  319. callback('');
  320. }
  321. })
  322. .catch(() => {
  323. callback('');
  324. });
  325. });
  326. }
  327. },
  328. /**
  329. * 初始化编辑器实例回调函数
  330. * @param {Editor} editor 编辑器实例
  331. */
  332. initInstanceCallback(editor) {
  333. editor.on('SetContent Undo Redo ViewUpdate keyup mousedown', () => {
  334. this.hideContentmenu();
  335. });
  336. editor.on('click', (e) => {
  337. if (e.target.classList.contains('rich-fill')) {
  338. editor.selection.select(e.target); // 选中填空
  339. let { offsetLeft, offsetTop } = e.target;
  340. this.showContentmenu({
  341. pixelsFromLeft: offsetLeft - 14,
  342. pixelsFromTop: offsetTop,
  343. });
  344. }
  345. });
  346. let mouseX = 0;
  347. editor.on('mousedown', (e) => {
  348. mouseX = e.offsetX;
  349. });
  350. editor.on('mouseup', (e) => {
  351. let start = editor.selection.getStart();
  352. let end = editor.selection.getEnd();
  353. let rng = editor.selection.getRng();
  354. if (start !== end || rng.collapsed) {
  355. this.hideContentmenu();
  356. return;
  357. }
  358. if (e.offsetX < mouseX) {
  359. mouseX = e.offsetX;
  360. }
  361. if (isNodeType(start, 'span')) {
  362. start = start.parentNode;
  363. }
  364. // 获取文本内容和起始偏移位置
  365. let text = start.textContent;
  366. let startOffset = rng.startOffset;
  367. let previousSibling = rng.startContainer.previousSibling;
  368. // 判断是否选中的是 span 标签
  369. let isSpan = isNodeType(rng.startContainer.parentNode, 'span');
  370. if (isSpan) {
  371. previousSibling = rng.startContainer.parentNode.previousSibling;
  372. }
  373. // 计算起始偏移位置
  374. while (previousSibling) {
  375. startOffset += previousSibling.textContent.length;
  376. previousSibling = previousSibling.previousSibling;
  377. }
  378. // 获取起始偏移位置前的文本内容
  379. const textBeforeOffset = text.substring(0, startOffset);
  380. /* 使用 Canvas API测量文本宽度 */
  381. // 获取字体大小和行高
  382. let computedStyle = window.getComputedStyle(start);
  383. const fontSize = parseFloat(computedStyle.fontSize);
  384. const canvas = document.createElement('canvas');
  385. const context = canvas.getContext('2d');
  386. context.font = `${fontSize}pt ${computedStyle.fontFamily}`;
  387. // 计算文字距离左侧的像素位置
  388. const width = context.measureText(textBeforeOffset).width;
  389. const lineHeight = context.measureText('M').fontBoundingBoxAscent + 3.8; // 获取行高
  390. /* 计算偏移位置 */
  391. const computedWidth = computedStyle.width.replace('px', ''); // 获取编辑器宽度
  392. let row = width / computedWidth; // 计算选中文本在第几行
  393. row = row % 1 > 0.8 ? Math.ceil(row) : Math.floor(row);
  394. const offsetTop = start.offsetTop; // 获取选中文本距离顶部的像素位置
  395. let pixelsFromTop = offsetTop + lineHeight * row; // 计算选中文本距离顶部的像素位置
  396. this.showContentmenu({
  397. pixelsFromLeft: mouseX,
  398. pixelsFromTop,
  399. });
  400. });
  401. },
  402. // 删除填空
  403. deleteContent() {
  404. let editor = tinymce.get(this.id);
  405. let start = editor.selection.getStart();
  406. if (isNodeType(start, 'span')) {
  407. let textContent = start.textContent;
  408. let content = editor.selection.getContent();
  409. let str = textContent.split(content);
  410. start.remove();
  411. editor.selection.setContent(str.join(content));
  412. } else {
  413. this.collapse();
  414. }
  415. },
  416. // 设置填空
  417. setContent() {
  418. let editor = tinymce.get(this.id);
  419. let start = editor.selection.getStart();
  420. let content = editor.selection.getContent();
  421. if (isNodeType(start, 'span')) {
  422. let textContent = start.textContent;
  423. let str = textContent.split(content);
  424. start.remove();
  425. editor.selection.setContent(str.join(this.getSpanString(content)));
  426. } else {
  427. let str = this.replaceSpanString(content);
  428. editor.selection.setContent(this.getSpanString(str));
  429. }
  430. },
  431. // 折叠选区
  432. collapse() {
  433. let editor = tinymce.get(this.id);
  434. let rng = editor.selection.getRng();
  435. if (!rng.collapsed) {
  436. this.hideContentmenu();
  437. editor.selection.collapse();
  438. }
  439. },
  440. // 获取 span 标签
  441. getSpanString(str) {
  442. return `<span class="rich-fill" style="text-decoration: underline;cursor: pointer;">${str}</span>`;
  443. },
  444. // 去除 span 标签
  445. replaceSpanString(str) {
  446. return str.replace(/<span\b[^>]*>(.*?)<\/span>/gi, '$1');
  447. },
  448. crateParsedTextInfoPinyin(content) {
  449. let text = content.replace(/<[^>]+>/g, '');
  450. this.$emit('crateParsedTextInfoPinyin', text);
  451. },
  452. handleRichTextBlur() {
  453. this.$emit('handleRichTextBlur');
  454. let content = tinymce.get(this.id).getContent();
  455. if (this.isViewPinyin) {
  456. this.crateParsedTextInfoPinyin(content);
  457. return;
  458. }
  459. // 判断富文本中是否有 字母+数字+空格 的组合,如果没有,直接返回
  460. let isHasPinyin = content
  461. .split(/<[^>]+>/g)
  462. .filter((item) => item)
  463. .some((item) => item.match(/[a-zA-Z]+\d(\s|&nbsp;)*/));
  464. if (!isHasPinyin) {
  465. return;
  466. }
  467. // 用标签分割富文本,保留标签
  468. let reg = /(<[^>]+>)/g;
  469. let text = content
  470. .split(reg)
  471. .filter((item) => item)
  472. // 如果是标签,直接返回
  473. // 如果是文本,将文本按空格分割为数组,如果是拼音,将拼音转为带音调的拼音
  474. .map((item) => {
  475. // 重置正则,使用全局匹配 g 时需要重置,否则会出现匹配不到的情况,因为 lastIndex 会一直累加,test 方法会从 lastIndex 开始匹配
  476. reg.lastIndex = 0;
  477. if (reg.test(item)) {
  478. return item;
  479. }
  480. return item.split(/\s+/).map((item) => handleToneValue(item));
  481. })
  482. // 如果是标签,直接返回
  483. // 二维数组,转为拼音,并打平为一维数组
  484. .map((item) => {
  485. if (/<[^>]+>/g.test(item)) return item;
  486. return item
  487. .map((li) =>
  488. li.map(({ number, con }) => (number && con ? addTone(Number(number), con) : number || con || '')),
  489. )
  490. .flat();
  491. })
  492. // 如果是数组,将数组字符串每两个之间加一个空格
  493. .map((item) => {
  494. if (typeof item === 'string') return item;
  495. return item.join(' ');
  496. })
  497. .join('');
  498. // 更新 v-model
  499. this.$emit('input', text);
  500. },
  501. // 设置填空
  502. setFill() {
  503. this.setContent();
  504. this.hideContentmenu();
  505. },
  506. // 删除填空
  507. deleteFill() {
  508. this.deleteContent();
  509. this.hideContentmenu();
  510. },
  511. // 隐藏工具栏抽屉
  512. hideToolbarDrawer() {
  513. let editor = tinymce.get(this.id);
  514. if (editor.queryCommandState('ToggleToolbarDrawer')) {
  515. editor.execCommand('ToggleToolbarDrawer');
  516. }
  517. },
  518. // 隐藏填空右键菜单
  519. hideContentmenu() {
  520. this.isShow = false;
  521. },
  522. showContentmenu({ pixelsFromLeft, pixelsFromTop }) {
  523. this.isShow = true;
  524. this.contentmenu = {
  525. left: `${pixelsFromLeft + 14}px`,
  526. top: `${pixelsFromTop - 18}px`,
  527. };
  528. },
  529. mathConfirm() {
  530. if (!this.dialogMathValidate) return;
  531. let editor = tinymce.get(this.id);
  532. let tmpId = getRandomNumber();
  533. let tmpMathData = this.mathData.math;
  534. editor.insertContent(`
  535. <span id="${tmpId}" contenteditable="false" class="mathjax-container editor-math">
  536. ${tmpMathData}
  537. </span>
  538. `);
  539. // editor.insertContent(`${latex}`);
  540. this.mathEleIsInit = false;
  541. this.renderMath(tmpId);
  542. this.isViewMathDialog = false;
  543. },
  544. // 渲染公式
  545. async renderMath(id) {
  546. if (this.mathEleIsInit) return; // 如果公式已经渲染过,返回
  547. if (window.MathJax) {
  548. let editor = tinymce.get(this.id);
  549. let eleMathArs = [];
  550. if (id) {
  551. // 插入的时候,会传递ID,执行单个渲染
  552. let ele = editor.dom.select(`#${id}`)[0];
  553. eleMathArs = [ele];
  554. } else {
  555. // 否则,查询编辑器里面所有的公式
  556. eleMathArs = editor.dom.select(`.editor_math`);
  557. }
  558. if (eleMathArs.length === 0) return;
  559. await this.$nextTick();
  560. window.MathJax.typesetPromise(eleMathArs).catch((err) => console.error('MathJax error:', err));
  561. this.mathEleIsInit = true;
  562. }
  563. },
  564. async renderMathDialog() {
  565. this.dialogMathValidate = false;
  566. await this.$nextTick();
  567. try {
  568. await window.MathJax.typesetPromise([this.$refs.mathContainer]);
  569. let mathRes = this.$refs.mathContainer.innerHTML;
  570. if (mathRes && mathRes.indexOf('merror') === -1) this.dialogMathValidate = true;
  571. } catch (err) {
  572. console.error('公式渲染失败:', err);
  573. }
  574. },
  575. },
  576. };
  577. </script>
  578. <style lang="scss" scoped>
  579. .rich-text {
  580. :deep + .tox {
  581. .tox-sidebar-wrap {
  582. border: 1px solid $fill-color;
  583. border-radius: 4px;
  584. &:hover {
  585. border-color: #c0c4cc;
  586. }
  587. }
  588. &.tox-tinymce {
  589. border-width: 0;
  590. border-radius: 0;
  591. .tox-edit-area__iframe {
  592. background-color: $fill-color;
  593. }
  594. }
  595. &:not(.tox-tinymce-inline) .tox-editor-header {
  596. box-shadow: none;
  597. }
  598. }
  599. &.is-border {
  600. :deep + .tox.tox-tinymce {
  601. border: $border;
  602. border-radius: 4px;
  603. }
  604. }
  605. }
  606. .contentmenu {
  607. position: absolute;
  608. z-index: 999;
  609. display: flex;
  610. column-gap: 4px;
  611. align-items: center;
  612. padding: 4px 8px;
  613. font-size: 14px;
  614. background-color: #fff;
  615. border-radius: 2px;
  616. box-shadow: 0 1px 10px 0 rgba(0, 0, 0, 10%);
  617. .svg-icon,
  618. .button {
  619. cursor: pointer;
  620. }
  621. .line {
  622. min-height: 16px;
  623. margin: 0 4px;
  624. }
  625. }
  626. mjx-container {
  627. font-size: 16px !important;
  628. }
  629. </style>