RichText.vue 43 KB

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