| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315 |
- <template>
- <div ref="richArea" class="rich-wrapper">
- <Editor
- v-bind="$attrs"
- :id="id"
- ref="richText"
- model-events="change keyup undo redo setContent"
- :value="value"
- :class="['rich-text', isBorder ? 'is-border' : '']"
- :init="init"
- v-on="$listeners"
- @onBlur="handleRichTextBlur"
- />
- <div v-show="isShow" :style="contentmenu" class="contentmenu">
- <div v-if="isViewNote" @click="openExplanatoryNoteDialog">
- <SvgIcon icon-class="mark" size="14" />
- <span class="button"> 编辑注释</span>
- </div>
- <div v-else>
- <SvgIcon icon-class="slice" size="16" @click="setFill" />
- <span class="button" @click="setFill">设为填空</span>
- <span class="line"></span>
- <SvgIcon icon-class="close-circle" size="16" @click="deleteFill" />
- <span class="button" @click="deleteFill">删除填空</span>
- </div>
- </div>
- <MathDialog :visible.sync="isViewMathDialog" @confirm="mathConfirm" />
- </div>
- </template>
- <script>
- import tinymce from 'tinymce/tinymce';
- import Editor from '@tinymce/tinymce-vue';
- import MathDialog from '@/components/MathDialog.vue';
- import 'tinymce/icons/default/icons';
- import 'tinymce/themes/silver';
- // 引入富文本编辑器主题的js和css
- import 'tinymce/themes/silver/theme.min';
- import 'tinymce/skins/ui/oxide/skin.min.css';
- // 扩展插件
- import 'tinymce/plugins/image';
- import 'tinymce/plugins/link';
- // import 'tinymce/plugins/code';
- // import 'tinymce/plugins/table';
- import 'tinymce/plugins/lists';
- // import 'tinymce/plugins/wordcount'; // 字数统计插件
- import 'tinymce/plugins/media'; // 插入视频插件
- // import 'tinymce/plugins/template'; // 模板插件
- // import 'tinymce/plugins/fullscreen'; // 全屏插件
- import 'tinymce/plugins/paste'; // 粘贴插件
- // import 'tinymce/plugins/preview'; // 预览插件
- import 'tinymce/plugins/hr';
- import 'tinymce/plugins/autoresize'; // 自动调整大小插件
- import 'tinymce/plugins/ax_wordlimit'; // 字数限制插件
- import { getRandomNumber } from '@/utils';
- import { isNodeType } from '@/utils/validate';
- import { fileUpload } from '@/api/app';
- import { addTone, handleToneValue } from '@/utils/common';
- export default {
- name: 'RichText',
- components: {
- Editor,
- MathDialog,
- },
- inheritAttrs: false,
- props: {
- inline: {
- type: Boolean,
- default: false,
- },
- placeholder: {
- type: String,
- default: '输入内容',
- },
- value: {
- type: String,
- default: '',
- },
- height: {
- type: [Number, String],
- default: 52,
- },
- isBorder: {
- type: Boolean,
- default: false,
- },
- toolbar: {
- type: [String, Boolean],
- /* eslint-disable max-len */
- default:
- 'fontselect fontsizeselect forecolor backcolor lineheight paragraphSpacing indent outdent customUnderline bold italic strikethrough alignleft aligncenter alignright bullist numlist dotEmphasis image media link blockquote hr mathjax',
- },
- wordlimitNum: {
- type: [Number, Boolean],
- default: 1000000,
- },
- isFill: {
- type: Boolean,
- default: false,
- },
- fontSize: {
- type: String,
- default: '12pt',
- },
- fontFamily: {
- type: String,
- default: 'Arial',
- },
- isHasSpace: {
- type: Boolean,
- default: false,
- },
- isViewPinyin: {
- type: Boolean,
- default: false,
- },
- pageFrom: {
- type: String,
- default: '',
- },
- isViewNote: {
- type: Boolean,
- default: false,
- },
- itemIndex: {
- type: Number,
- default: null,
- },
- isTitle: {
- type: Boolean,
- default: false,
- },
- },
- data() {
- return {
- isViewMathDialog: false,
- mathEleIsInit: true,
- math: '',
- isShow: false,
- contentmenu: {
- top: 0,
- left: 0,
- },
- id: getRandomNumber(),
- editorIsInited: false,
- editorBeforeInitConfig: {},
- init: {
- content_style: `
- mjx-container, mjx-container * {
- font-size: 16px !important; /* 强制固定字体 */
- line-height: 1.2 !important; /* 避免行高影响 */
- }
- .rich-text-emphasis-dot {
- border-bottom: none;
- background-image: radial-gradient(
- circle at center,
- currentColor 0.15em, /* 圆点大小相对于字体 */
- transparent 0.16em
- );
- background-size: 1em 0.3em; /* 间距相对于字体大小,高度相对字体 */
- background-repeat: repeat-x;
- background-position: 0 100%;
- padding-bottom: 0.3em; /* 间距也相对于字体 */
- display: inline;
- }
- `, // 解决公式每点击一次字体就变大
- valid_elements: '*[*]', // 允许所有标签和属性
- valid_children: '+body[style]', // 允许 MathJax 的样式
- extended_valid_elements: 'span[*],mjx-container[*],svg[*],path[*]', // 明确允许 MathJax 标签
- inline: this.inline,
- font_size: this.fontSize,
- font_family: this.fontFamily,
- language_url: `${process.env.BASE_URL}tinymce/langs/zh_CN.js`,
- placeholder: this.placeholder,
- language: 'zh_CN',
- skin_url: `${process.env.BASE_URL}tinymce/skins/ui/oxide`,
- content_css: `${process.env.BASE_URL}tinymce/skins/content/${this.isHasSpace ? 'index-nospace' : 'index'}.css`,
- min_height: this.height,
- width: '100%',
- autoresize_bottom_margin: 0,
- plugins: 'link lists image hr media autoresize ax_wordlimit paste', // 移除 lineheight
- toolbar: this.toolbar, // 工具栏
- lineheight_formats: '0.5 1.0 1.2 1.5 2.0 2.5 3.0', // 行高选项(倍数)
- paragraphheight_formats: [1.0, 1.2, 1.5, 2.0, 2.5, 3.0], // 段落间距
- contextmenu: false, // 右键菜单
- menubar: false, // 菜单栏
- branding: false, // 品牌
- statusbar: false, // 状态栏
- entity_encoding: 'raw', // raw不编码任何字符;named: 使用命名实体(如 );numeric: 使用数字实体(如  )
- target_list: false,
- default_link_target: '_blank', // 或 '_self'
- setup: (editor) => {
- editor.on('GetContent', (e) => {
- if (e.format === 'html') {
- e.content = this.smartPreserveLineBreaks(editor, e.content);
- }
- });
- let isRendered = false; // 标记是否已渲染
- editor.on('init', () => {
- editor.getBody().style.fontSize = this.init.font_size; // 设置默认字体大小
- editor.getBody().style.fontFamily = this.init.font_family; // 设置默认字体
- this.init.paragraphheight_formats.forEach((config) => {
- const formatName = `paragraphSpacing${config}_em`;
- editor.formatter.register(formatName, {
- selector: 'p',
- styles: { 'margin-bottom': `${config}em` },
- });
- });
- if (!editor.formatter.has('emphasisDot')) {
- editor.formatter.register('emphasisDot', {
- inline: 'span',
- classes: 'rich-text-emphasis-dot',
- // styles: {
- // 'border-bottom': 'none',
- // 'background-image':
- // 'radial-gradient(circle at center, currentColor 0.15em, transparent 0.16em)' /* 圆点大小相对于字体 */,
- // 'background-size': '1em 0.3em' /* 间距相对于字体大小,高度相对字体 */,
- // 'background-repeat': 'repeat-x',
- // 'background-position': '0 100%',
- // 'padding-bottom': '0.3em' /* 间距也相对于字体 */,
- // display: 'inline',
- // },
- wrapper: true,
- remove_similar: true,
- });
- }
- if (!editor.formatter.has('coloredunderline')) {
- editor.formatter.register('coloredunderline', {
- inline: 'span',
- styles: {
- 'border-bottom': `0.1em solid #000000`, // 要固定线粗细的话,就使用2px
- 'padding-bottom': '1px',
- },
- merge_siblings: false,
- exact: true, // 添加 exact 属性
- wrapper: true,
- remove_similar: true,
- });
- }
- this.editorIsInited = true;
- });
- // 自定义行高下拉(因为没有内置 lineheight 插件)
- editor.ui.registry.addMenuButton('lineheight', {
- text: '行高',
- tooltip: '行高',
- fetch: (callback) => {
- const formats = (this.init.lineheight_formats || '').split(/\s+/).filter(Boolean);
- const items = formats.map((v) => {
- return {
- type: 'menuitem',
- text: v,
- onAction: () => {
- try {
- const name = `lineheight_${v.replace(/\./g, '_')}`;
- // 动态注册格式(会覆盖同名注册,安全)
- editor.formatter.register(name, {
- inline: 'span',
- styles: { lineHeight: v },
- });
- editor.formatter.apply(name);
- } catch (err) {
- // 容错处理
- }
- },
- };
- });
- callback(items);
- },
- });
- // 重写下划线
- editor.ui.registry.addButton('customUnderline', {
- icon: 'underline',
- tooltip: '下划线',
- onAction: () => {
- editor.windowManager.open({
- title: '选择下划线颜色',
- body: {
- type: 'panel',
- items: [
- {
- type: 'colorpicker',
- name: 'color',
- label: '颜色',
- value: '#000000',
- },
- ],
- },
- buttons: [
- {
- type: 'custom',
- name: 'cancelUndeline',
- text: '取消下划线',
- },
- {
- type: 'cancel',
- text: '取消',
- },
- {
- type: 'submit',
- text: '确定',
- buttonType: 'primary',
- enabled: true,
- },
- ],
- initialData: {
- color: '#000000',
- },
- onSubmit: (api) => {
- const color = api.getData().color;
- // 加下划线
- // editor.execCommand('Underline');
- // 注册自定义格式
- editor.formatter.register('coloredunderline', {
- inline: 'span',
- styles: {
- 'border-bottom': `0.1em solid ${color}`, // 要固定线粗细的话,就使用2px
- 'padding-bottom': '1px',
- },
- merge_siblings: false,
- exact: true, // 添加 exact 属性
- wrapper: true,
- remove_similar: true,
- });
- // 应用格式
- editor.formatter.apply('coloredunderline');
- api.close();
- },
- onAction: (api, details) => {
- if (details.name === 'cancelUndeline') {
- editor.formatter.remove('coloredunderline');
- api.close();
- }
- },
- });
- },
- });
- // 添加段落间距下拉菜单
- editor.ui.registry.addMenuButton('paragraphSpacing', {
- icon: 'paragraph',
- // text: '段落间距',
- tooltip: '段落间距',
- fetch: (callback) => {
- const items = [];
- // 动态生成菜单项
- this.init.paragraphheight_formats.forEach((config) => {
- const formatName = `paragraphSpacing${config}_em`;
- items.push({
- type: 'menuitem',
- text: `${config}`,
- onAction: () => {
- // 先清除其他间距格式
- this.init.paragraphheight_formats.forEach((cfg) => {
- const fmtName = `paragraphSpacing${cfg}_em`;
- editor.formatter.remove(fmtName);
- });
- // 应用当前选择的间距
- editor.formatter.apply(formatName);
- },
- });
- });
- // 添加清除间距选项
- items.push({
- type: 'separator', // 分隔线
- });
- items.push({
- type: 'menuitem',
- text: '清除间距',
- onAction: () => {
- // 清除所有间距格式
- this.init.paragraphheight_formats.forEach((config) => {
- const formatName = `paragraphSpacing${config}_em`;
- editor.formatter.remove(formatName);
- });
- },
- });
- callback(items);
- },
- });
- // 添加 添加着重点 按钮
- editor.ui.registry.addButton('dotEmphasis', {
- text: '●',
- tooltip: '着重点',
- onAction: () => {
- const editor = tinymce.activeEditor;
- if (editor.formatter.match('emphasisDot')) {
- editor.formatter.remove('emphasisDot');
- } else {
- editor.formatter.apply('emphasisDot');
- }
- },
- });
- // 添加 MathJax 按钮
- editor.ui.registry.addButton('mathjax', {
- text: '∑',
- tooltip: '插入公式',
- onAction: () => {
- this.isViewMathDialog = true;
- },
- });
- editor.on('click', () => {
- if (editor?.queryCommandState('ToggleToolbarDrawer')) {
- editor.execCommand('ToggleToolbarDrawer');
- }
- if (!isRendered && window.MathJax) {
- isRendered = true;
- window.MathJax.typesetPromise([editor.getBody()]);
- }
- });
- // 内容变化时重新渲染公式
- editor.on('change', () => this.renderMath());
- editor.on('KeyDown', (e) => {
- // 检测删除或退格键
- if (e.keyCode === 8 || e.keyCode === 46) {
- // 延迟执行以确保删除已完成
- setTimeout(() => {
- this.cleanupRemovedAnnotations(editor);
- }, 500);
- }
- });
- // 也可以监听剪切操作
- editor.on('Cut', () => {
- setTimeout(() => {
- this.cleanupRemovedAnnotations(editor);
- }, 500);
- });
- // editor.on('NodeChange', function (e) {
- // if (
- // e.element &&
- // e.element.tagName === 'SPAN' &&
- // e.element.hasAttribute('data-annotation-id') &&
- // (!e.element.textContent || /^\s*$/.test(e.element.textContent))
- // ) {
- // const annotationId = e.element.getAttribute('data-annotation-id');
- // e.element.parentNode.removeChild(e.element);
- // this.$emit('selectContentSetMemo', null, annotationId);
- // }
- // });
- },
- font_formats:
- '楷体=楷体,微软雅黑;' +
- '黑体=黑体,微软雅黑;' +
- '宋体=宋体,微软雅黑;' +
- 'Arial=arial,helvetica,sans-serif;' +
- 'Times New Roman=times new roman,times,serif;' +
- '拼音=League;',
- fontsize_formats: '8pt 10pt 12pt 14pt 16pt 18pt 20pt 22pt 24pt 26pt 28pt 30pt 32pt 34pt 36pt',
- // 字数限制
- ax_wordlimit_num: this.wordlimitNum,
- ax_wordlimit_callback(editor) {
- editor.execCommand('undo');
- },
- media_filter_html: false,
- images_upload_handler: this.imagesUploadHandler,
- file_picker_types: 'media', // 文件上传类型
- file_picker_callback: this.filePickerCallback,
- init_instance_callback: this.isFill || this.isViewNote ? this.initInstanceCallback : '',
- paste_enable_default_filters: false, // 禁用默认的粘贴过滤器
- paste_as_text: true, // 默认作为纯文本粘贴
- // 粘贴预处理
- paste_preprocess(plugin, args) {
- let content = args.content;
- // 使用正则表达式去掉 style 中的 background 属性
- content = content.replace(/background(-color)?:[^;]+;/g, '');
- content = content.replace(/\t/g, ' '); // 将制表符替换为4个空格
- args.content = content;
- },
- // 指定在 WebKit 中粘贴时要保留的样式
- paste_webkit_styles:
- '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',
- },
- };
- },
- inject: ['processHtmlString'],
- watch: {
- isViewNote: {
- handler(newVal) {
- if (newVal) {
- let editor = tinymce.get(this.id);
- if (editor) {
- let start = editor.selection.getStart();
- this.$emit('selectNote', start.getAttribute('data-annotation-id'));
- }
- }
- },
- },
- fontSize: {
- handler(newVal) {
- const editor = tinymce.get(this.id);
- if (!editor || typeof editor.execCommand !== 'function') return;
- const applyFontSize = () => {
- try {
- editor.execCommand('FontSize', false, newVal);
- editor.setContent(this.value); // 触发内容更新,解决 value 在设置后变为空字符串的问题
- } catch (e) {
- // 容错:某些情况下 execCommand 会抛错,忽略即可
- // console.warn('apply fontSize failed', e);
- }
- };
- // 如果 selection 暂不可用,延迟一次执行以避免其他监听器中访问 selection 报错
- if (!editor.selection || typeof editor.selection.getRng !== 'function') {
- setTimeout(applyFontSize, 100);
- } else {
- applyFontSize();
- }
- },
- },
- fontFamily: {
- handler(newVal) {
- const editor = tinymce.get(this.id);
- if (!editor || typeof editor.execCommand !== 'function') return;
- const applyFontFamily = () => {
- try {
- editor.execCommand('FontName', false, newVal);
- editor.setContent(this.value); // 触发内容更新,解决 value 在设置后变为空字符串的问题
- } catch (e) {
- // 容错:忽略因 selection 不可用或其它原因导致的错误
- }
- };
- // 如果 selection 暂不可用,延迟执行
- if (!editor.selection || typeof editor.selection.getRng !== 'function') {
- setTimeout(applyFontFamily, 100);
- } else {
- applyFontFamily();
- }
- },
- },
- isTitle: {
- handler(newVal) {
- this.displayToolbar(newVal);
- },
- },
- // 未初始化完成或者数据未加载完成的时候,执行会有问题
- editorIsInited: {
- handler(newVal) {
- if (newVal) {
- let isTitle = this.editorBeforeInitConfig['isTitle'];
- if (isTitle === true || isTitle === false) {
- this.displayToolbar(isTitle);
- this.editorBeforeInitConfig['isTitle'] = null;
- }
- let style = this.editorBeforeInitConfig['style'];
- if (style) {
- this.setRichTitleFormat(style);
- this.editorBeforeInitConfig['style'] = null;
- }
- }
- },
- },
- },
- computed: {},
- created() {
- if (this.pageFrom !== 'audit') {
- window.addEventListener('click', this.hideToolbarDrawer);
- }
- if (this.isFill || this.isViewNote) {
- window.addEventListener('click', this.hideContentmenu);
- }
- this.setBackgroundColor();
- },
- beforeDestroy() {
- if (this.pageFrom !== 'audit') {
- window.removeEventListener('click', this.hideToolbarDrawer);
- }
- if (this.isFill || this.isViewNote) {
- window.removeEventListener('click', this.hideContentmenu);
- }
- },
- methods: {
- displayToolbar(isTitle, isInit) {
- if (!this.editorIsInited) {
- this.editorBeforeInitConfig['isTitle'] = isTitle;
- return;
- }
- let editor = tinymce.get(this.id);
- if (!editor) return;
- const header = editor.editorContainer?.querySelector('.tox-editor-header');
- if (header) {
- header.style.display = isTitle ? 'none' : '';
- if (!isInit) {
- const body = editor.getBody();
- if (!body) return;
- const pElements = body.querySelectorAll('p');
- if (this.processHtmlString && typeof this.processHtmlString === 'function') {
- this.processHtmlString(pElements, {}, true);
- }
- editor.fire('change');
- editor.nodeChanged();
- this.setPinYinStyleForTitle();
- }
- }
- },
- smartPreserveLineBreaks(editor, content) {
- let body = editor.getBody();
- let originalParagraphs = Array.from(body.getElementsByTagName('p'));
- let tempDiv = document.createElement('div');
- tempDiv.innerHTML = content;
- let outputParagraphs = Array.from(tempDiv.getElementsByTagName('p'));
- outputParagraphs.forEach((outputP, index) => {
- let originalP = originalParagraphs[index];
- if (originalP && outputP.innerHTML === '') {
- // 判断这个空段落是否应该包含 <br>
- let shouldHaveBr = this.shouldPreserveLineBreak(originalP, index, originalParagraphs);
- if (shouldHaveBr) {
- outputP.innerHTML = '<br>';
- }
- }
- });
- return tempDiv.innerHTML;
- },
- shouldPreserveLineBreak(paragraph, index, allParagraphs) {
- // 规则1:如果段落原本包含 <br>
- if (paragraph.innerHTML.includes('<br>')) {
- return true;
- }
- // 规则2:如果段落位于内容中间(不是第一个或最后一个)
- if (index > 0 && index < allParagraphs.length - 1) {
- let prevHasContent = allParagraphs[index - 1].textContent.trim() !== '';
- let nextHasContent = allParagraphs[index + 1].textContent.trim() !== '';
- if (prevHasContent && nextHasContent) {
- return true;
- }
- }
- // 规则3:如果段落是通过回车创建的(前后有内容)
- let isBetweenContent = false;
- if (index > 0 && allParagraphs[index - 1].textContent.trim() !== '') {
- isBetweenContent = true;
- }
- if (index < allParagraphs.length - 1 && allParagraphs[index + 1].textContent.trim() !== '') {
- isBetweenContent = true;
- }
- return isBetweenContent;
- },
- // 设置背景色
- setBackgroundColor() {
- let iframes = document.getElementsByTagName('iframe');
- for (let i = 0; i < iframes.length; i++) {
- let iframe = iframes[i];
- // 获取 <iframe> 内部的文档对象
- let iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
- let bodyElement = iframeDocument.body;
- if (bodyElement) {
- // 设置背景色
- bodyElement.style.backgroundColor = '#f2f3f5';
- }
- }
- },
- /**
- * 判断内容是否全部加粗
- */
- isAllBold() {
- let editor = tinymce.get(this.id);
- let body = editor.getBody();
- function getTextNodes(node) {
- let textNodes = [];
- if (node.nodeType === 3 && node.nodeValue.trim() !== '') {
- textNodes.push(node);
- } else {
- for (let child of node.childNodes) {
- textNodes = textNodes.concat(getTextNodes(child));
- }
- }
- return textNodes;
- }
- let textNodes = getTextNodes(body);
- if (textNodes.length === 0) return false;
- return textNodes.every((node) => {
- let el = node.parentElement;
- while (el && el !== body) {
- const tag = el.tagName.toLowerCase();
- const fontWeight = window.getComputedStyle(el).fontWeight;
- if (tag === 'b' || tag === 'strong' || fontWeight === 'bold' || parseInt(fontWeight) >= 600) {
- return true;
- }
- el = el.parentElement;
- }
- return false;
- });
- },
- /**
- * 设置整体富文本格式
- * @param {string} type 格式名称
- * @param {string} val 格式值
- */
- setRichFormat(type, val) {
- let editor = tinymce.get(this.id);
- if (!editor) return;
- editor.execCommand('SelectAll');
- switch (type) {
- case 'bold': {
- if (this.isAllBold()) {
- editor.formatter.remove('bold');
- } else {
- editor.formatter.apply('bold');
- }
- break;
- }
- case 'fontSize':
- case 'lineHeight':
- case 'color':
- case 'fontFamily': {
- editor.formatter.register('my_customformat', {
- inline: 'span',
- styles: { [type]: val },
- });
- editor.formatter.apply('my_customformat');
- break;
- }
- case 'align': {
- if (val === 'LEFT') {
- editor.execCommand('JustifyLeft');
- } else if (val === 'MIDDLE') {
- editor.execCommand('JustifyCenter');
- } else if (val === 'RIGHT') {
- editor.execCommand('JustifyRight');
- }
- break;
- }
- default: {
- editor.formatter.toggle(type);
- }
- }
- editor.selection.collapse(false);
- },
- setRichTitleFormat(config) {
- if (!this.editorIsInited) {
- this.editorBeforeInitConfig['style'] = config;
- return;
- }
- let editor = tinymce.get(this.id);
- if (!editor) return;
- // 获取编辑器内容区域
- const body = editor.getBody();
- if (!body) return;
- const pElements = body.querySelectorAll('p');
- if (typeof this.processHtmlString === 'function') {
- this.processHtmlString(pElements, config);
- }
- editor.fire('change');
- editor.nodeChanged();
- this.setPinYinStyleForTitle();
- },
- //标题类型的富文本,如果开启了拼音,需要同步拼音样式
- setPinYinStyleForTitle() {
- if (this.isViewPinyin) {
- let styles = this.getFirstCharStyles();
- this.$emit('createParsedTextStyleForTitle', styles);
- return;
- }
- },
- /**
- * 图片上传自定义逻辑函数
- * @param {object} blobInfo 文件数据
- * @param {Function} success 成功回调函数
- * @param {Function} fail 失败回调函数
- */
- imagesUploadHandler(blobInfo, success, fail) {
- let file = blobInfo.blob();
- const formData = new FormData();
- formData.append(file.name, file, file.name);
- fileUpload('Mid', formData, { isGlobalprogress: true })
- .then(({ file_info_list }) => {
- if (file_info_list.length > 0) {
- success(file_info_list[0].file_url_open);
- } else {
- fail('上传失败');
- }
- })
- .catch(() => {
- fail('上传失败');
- });
- },
- /**
- * 文件上传自定义逻辑函数
- * @param {Function} callback
- * @param {String} value
- * @param {object} meta
- */
- filePickerCallback(callback, value, meta) {
- if (meta.filetype === 'media') {
- let filetype = '.mp3, .mp4';
- let input = document.createElement('input');
- input.setAttribute('type', 'file');
- input.setAttribute('accept', filetype);
- input.click();
- input.addEventListener('change', () => {
- let file = input.files[0];
- const formData = new FormData();
- formData.append(file.name, file, file.name);
- fileUpload('Mid', formData, { isGlobalprogress: true })
- .then(({ file_info_list }) => {
- if (file_info_list.length > 0) {
- callback(file_info_list[0].file_url_open);
- } else {
- callback('');
- }
- })
- .catch(() => {
- callback('');
- });
- });
- }
- },
- /**
- * 初始化编辑器实例回调函数
- * @param {Editor} editor 编辑器实例
- */
- initInstanceCallback(editor) {
- editor.on('SetContent Undo Redo ViewUpdate keyup mousedown', () => {
- this.hideContentmenu();
- });
- editor.on('click', (e) => {
- if (e.target.classList.contains('rich-fill')) {
- editor.selection.select(e.target); // 选中填空
- let { offsetLeft, offsetTop } = e.target;
- this.showContentmenu({
- pixelsFromLeft: offsetLeft - 14,
- pixelsFromTop: offsetTop,
- });
- }
- });
- let mouseX = 0;
- editor.on('mousedown', (e) => {
- mouseX = e.offsetX;
- });
- editor.on('mouseup', (e) => {
- let start = editor.selection.getStart();
- let end = editor.selection.getEnd();
- let rng = editor.selection.getRng();
- if (start !== end || rng.collapsed) {
- this.hideContentmenu();
- return;
- }
- if (e.offsetX < mouseX) {
- mouseX = e.offsetX;
- }
- if (isNodeType(start, 'span')) {
- start = start.parentNode;
- }
- // 获取文本内容和起始偏移位置
- let text = start.textContent;
- let startOffset = rng.startOffset;
- let previousSibling = rng.startContainer.previousSibling;
- // 判断是否选中的是 span 标签
- let isSpan = isNodeType(rng.startContainer.parentNode, 'span');
- if (isSpan) {
- previousSibling = rng.startContainer.parentNode.previousSibling;
- }
- // 计算起始偏移位置
- while (previousSibling) {
- startOffset += previousSibling.textContent.length;
- previousSibling = previousSibling.previousSibling;
- }
- // 获取起始偏移位置前的文本内容
- const textBeforeOffset = text.substring(0, startOffset);
- /* 使用 Canvas API测量文本宽度 */
- // 获取字体大小和行高
- let computedStyle = window.getComputedStyle(start);
- const fontSize = parseFloat(computedStyle.fontSize);
- const canvas = document.createElement('canvas');
- const context = canvas.getContext('2d');
- context.font = `${fontSize}pt ${computedStyle.fontFamily}`;
- // 计算文字距离左侧的像素位置
- const width = context.measureText(textBeforeOffset).width;
- const lineHeight = context.measureText('M').fontBoundingBoxAscent + 3.8; // 获取行高
- /* 计算偏移位置 */
- const computedWidth = computedStyle.width.replace('px', ''); // 获取编辑器宽度
- let row = width / computedWidth; // 计算选中文本在第几行
- row = row % 1 > 0.8 ? Math.ceil(row) : Math.floor(row);
- const offsetTop = start.offsetTop; // 获取选中文本距离顶部的像素位置
- let pixelsFromTop = offsetTop + lineHeight * row; // 计算选中文本距离顶部的像素位置
- this.showContentmenu({
- pixelsFromLeft: mouseX,
- pixelsFromTop,
- });
- });
- },
- // 删除填空
- deleteContent() {
- let editor = tinymce.get(this.id);
- let start = editor.selection.getStart();
- if (isNodeType(start, 'span')) {
- let textContent = start.textContent;
- let content = editor.selection.getContent();
- let str = textContent.split(content);
- start.remove();
- editor.selection.setContent(str.join(content));
- } else {
- this.collapse();
- }
- },
- // 设置填空
- setContent() {
- let editor = tinymce.get(this.id);
- let start = editor.selection.getStart();
- let content = editor.selection.getContent();
- if (isNodeType(start, 'span')) {
- let textContent = start.textContent;
- let str = textContent.split(content);
- start.remove();
- editor.selection.setContent(str.join(this.getSpanString(content)));
- } else {
- let str = this.replaceSpanString(content);
- editor.selection.setContent(this.getSpanString(str));
- }
- },
- // 折叠选区
- collapse() {
- let editor = tinymce.get(this.id);
- let rng = editor.selection.getRng();
- if (!rng.collapsed) {
- this.hideContentmenu();
- editor.selection.collapse();
- }
- },
- // 获取 span 标签
- getSpanString(str) {
- return `<span class="rich-fill" style="text-decoration: underline;cursor: pointer;">${str}</span>`;
- },
- // 去除 span 标签
- replaceSpanString(str) {
- return str.replace(/<span\b[^>]*>(.*?)<\/span>/gi, '$1');
- },
- // createParsedTextInfoPinyin(content) {
- // let styles = this.getFirstCharStyles();
- // let text = content.replace(/<[^>]+>/g, '');
- // this.$emit('createParsedTextInfoPinyin', text, styles);
- // },
- handleRichTextBlur() {
- this.$emit('handleRichTextBlur', this.itemIndex);
- let content = tinymce.get(this.id).getContent();
- if (this.isViewPinyin) {
- // this.createParsedTextInfoPinyin(content);
- let styles = this.getFirstCharStyles();
- let text = content.replace(/<[^>]+>/g, '');
- this.$emit('createParsedTextInfoPinyin', text, styles);
- return;
- }
- // 判断富文本中是否有 字母+数字+空格 的组合,如果没有,直接返回
- let isHasPinyin = content
- .split(/<[^>]+>/g)
- .filter((item) => item)
- .some((item) => item.match(/[a-zA-Z]+\d+(\s| )+/));
- if (!isHasPinyin) {
- return;
- }
- // 用标签分割富文本,保留标签
- let reg = /(<[^>]+>)/g;
- let text = content
- .split(reg)
- .filter((item) => item)
- // 如果是标签,直接返回
- // 如果是文本,将文本按空格分割为数组,如果是拼音,将拼音转为带音调的拼音
- .map((item) => {
- // 重置正则,使用全局匹配 g 时需要重置,否则会出现匹配不到的情况,因为 lastIndex 会一直累加,test 方法会从 lastIndex 开始匹配
- reg.lastIndex = 0;
- if (reg.test(item)) {
- return item;
- }
- return item.split(/\s+/).map((item) => handleToneValue(item));
- })
- // 如果是标签,直接返回
- // 二维数组,转为拼音,并打平为一维数组
- .map((item) => {
- if (/<[^>]+>/g.test(item)) return item;
- return item
- .map(({ number, con }) => (number && con ? addTone(Number(number), con) : number || con || ''))
- .flat();
- })
- // 如果是数组,将数组字符串每两个之间加一个空格
- .map((item) => {
- if (typeof item === 'string') return item;
- return item.join(' ');
- })
- .join('');
- // 更新 v-model
- this.$emit('input', text);
- },
- // 设置填空
- setFill() {
- this.setContent();
- this.hideContentmenu();
- },
- // 删除填空
- deleteFill() {
- this.deleteContent();
- this.hideContentmenu();
- },
- // 隐藏工具栏抽屉
- hideToolbarDrawer() {
- let editor = tinymce.get(this.id);
- if (editor.queryCommandState('ToggleToolbarDrawer')) {
- editor.execCommand('ToggleToolbarDrawer');
- }
- },
- // 隐藏填空右键菜单
- hideContentmenu() {
- this.isShow = false;
- },
- showContentmenu({ pixelsFromLeft, pixelsFromTop }) {
- this.isShow = true;
- this.contentmenu = {
- left: `${this.isViewNote ? pixelsFromLeft : pixelsFromLeft + 14}px`,
- top: `${this.isViewNote ? pixelsFromTop + 62 : pixelsFromTop + 22}px`,
- };
- },
- mathConfirm(math) {
- let editor = tinymce.get(this.id);
- let tmpId = getRandomNumber();
- editor.insertContent(`
- <span id="${tmpId}" contenteditable="false" class="mathjax-container editor-math">
- ${math}
- </span>
- `);
- this.mathEleIsInit = false;
- this.renderMath(tmpId);
- this.isViewMathDialog = false;
- },
- // 渲染公式
- async renderMath(id) {
- if (this.mathEleIsInit) return; // 如果公式已经渲染过,返回
- if (window.MathJax) {
- let editor = tinymce.get(this.id);
- let eleMathArs = [];
- if (id) {
- // 插入的时候,会传递ID,执行单个渲染
- let ele = editor.dom.select(`#${id}`)[0];
- eleMathArs = [ele];
- } else {
- // 否则,查询编辑器里面所有的公式
- eleMathArs = editor.dom.select(`.editor_math`);
- }
- if (eleMathArs.length === 0) return;
- await this.$nextTick();
- window.MathJax.typesetPromise(eleMathArs).catch((err) => console.error(err));
- this.mathEleIsInit = true;
- }
- },
- // 获取高亮 span 标签
- getLightSpanString(noteId, str) {
- return `<span data-annotation-id="${noteId}" class="rich-fill" style="background-color:#fff3b7;cursor: pointer;">${str}</span>`;
- },
- // 选中文本打开弹窗
- openExplanatoryNoteDialog() {
- let editor = tinymce.get(this.id);
- let start = editor.selection.getStart();
- this.$emit('view-explanatory-note', { visible: true, noteId: start.getAttribute('data-annotation-id') });
- this.hideContentmenu();
- },
- // 设置高亮背景,并保留备注
- setExplanatoryNote(richData) {
- let noteId = '';
- let editor = tinymce.get(this.id);
- let start = editor.selection.getStart();
- let content = editor.selection.getContent();
- if (isNodeType(start, 'span')) {
- noteId = start.getAttribute('data-annotation-id');
- } else {
- noteId = `${this.id}_${Math.floor(Math.random() * 1000000)
- .toString()
- .padStart(10, '0')}`;
- let str = this.replaceSpanString(content);
- editor.selection.setContent(this.getLightSpanString(noteId, str));
- }
- let selectText = content.replace(/<[^>]+>/g, '');
- let note = { id: noteId, note: richData.note, selectText };
- this.$emit('selectContentSetMemo', note);
- },
- // 取消注释
- cancelExplanatoryNote() {
- let editor = tinymce.get(this.id);
- let start = editor.selection.getStart();
- if (isNodeType(start, 'span')) {
- let textContent = start.textContent;
- let content = editor.selection.getContent();
- let str = textContent.split(content);
- start.remove();
- editor.selection.setContent(str.join(content));
- this.$emit('selectContentSetMemo', null, start.getAttribute('data-annotation-id'));
- } else {
- this.collapse();
- }
- },
- // 删除,监听处理备注
- cleanupRemovedAnnotations(editor) {
- if (!this.isViewNote) return; // 只有富文本才处理
- const body = editor.getBody();
- const annotations = body.querySelectorAll('span[data-annotation-id]');
- this.handleEmptySpan(editor);
- // 存储所有现有的注释ID
- const existingIds = new Set();
- annotations.forEach((span) => {
- existingIds.add(span.getAttribute('data-annotation-id'));
- });
- // 与你存储的注释数据对比,清理不存在的
- this.$emit('compareAnnotationAndSave', existingIds);
- },
- // 删除span里面的文字之后,会出现空 span 标签残留,需处理掉
- handleEmptySpan(editor) {
- const selection = editor.selection;
- const selectedNode = selection.getNode();
- // 如果选中的是注释span内的内容
- if (selectedNode.nodeType === 1 && selectedNode.hasAttribute('data-annotation-id')) {
- const span = selectedNode;
- // 检查删除后是否为空
- if (!span.textContent || /^\s*$/.test(span.textContent)) {
- // 保存注释ID
- const annotationId = span.getAttribute('data-annotation-id');
- // 用其父节点替换span
- span.parentNode.replaceChild(document.createTextNode(''), span);
- // 从存储中移除注释
- this.$emit('selectContentSetMemo', null, annotationId);
- }
- }
- },
- getFirstCharStyles() {
- const editor = tinymce.activeEditor;
- if (!editor) return {};
- const firstTextNode = this.findFirstTextNode(editor.getBody());
- if (!firstTextNode) return {};
- const styles = {};
- let element = firstTextNode.parentElement;
- while (element && element !== editor.getBody().parentElement) {
- const computed = window.getComputedStyle(element);
- if (!styles.fontFamily && computed.fontFamily && computed.fontFamily !== 'inherit') {
- styles.fontFamily = computed.fontFamily;
- }
- if (!styles.fontSize && computed.fontSize && computed.fontSize !== 'inherit') {
- const fontSize = computed.fontSize;
- const pxValue = parseFloat(fontSize);
- if (isNaN(pxValue)) {
- styles.fontSize = fontSize;
- } else {
- // px转pt公式:pt = px * 3/4
- const ptValue = Math.round(pxValue * 0.75 * 10) / 10;
- styles.fontSize = `${ptValue}pt`;
- }
- }
- if (!styles.color && computed.color && computed.color !== 'inherit') {
- styles.color = computed.color;
- }
- if (!styles.bold && (computed.fontWeight === 'bold' || computed.fontWeight >= '700')) {
- styles.bold = true;
- styles.fontWeight = 'bold';
- } else {
- styles.bold = false;
- styles.fontWeight = '';
- }
- if (!styles.underline && computed.textDecoration.includes('underline')) {
- styles.underline = true;
- styles.textDecoration = 'underline';
- }
- if (!styles.strikethrough && computed.textDecoration.includes('line-through')) {
- styles.strikethrough = true;
- styles.textDecoration = 'line-through';
- }
- if (Object.keys(styles).length >= 6) break;
- element = element.parentElement;
- }
- return styles;
- },
- findFirstTextNode(element) {
- const walker = document.createTreeWalker(
- element,
- NodeFilter.SHOW_TEXT,
- {
- acceptNode: (node) => (node.textContent.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT),
- },
- false
- );
- return walker.nextNode();
- },
- },
- };
- </script>
- <style lang="scss" scoped>
- .rich-text {
- :deep + .tox {
- .tox-sidebar-wrap {
- border: 1px solid $fill-color;
- border-radius: 4px;
- &:hover {
- border-color: #c0c4cc;
- }
- }
- &.tox-tinymce {
- border-width: 0;
- border-radius: 0;
- .tox-edit-area__iframe {
- background-color: $fill-color;
- }
- }
- &:not(.tox-tinymce-inline) .tox-editor-header {
- box-shadow: none;
- }
- }
- &.is-border {
- :deep + .tox.tox-tinymce {
- border: $border;
- border-radius: 4px;
- }
- }
- }
- .contentmenu {
- position: absolute;
- z-index: 999;
- display: flex;
- column-gap: 4px;
- align-items: center;
- padding: 4px 8px;
- font-size: 14px;
- background-color: #fff;
- border-radius: 2px;
- box-shadow: 0 1px 10px 0 rgba(0, 0, 0, 10%);
- .svg-icon,
- .button {
- cursor: pointer;
- }
- .line {
- min-height: 16px;
- margin: 0 4px;
- }
- }
- </style>
|