RichText.vue 47 KB

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