| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401 |
- <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,
- },
- inject: ['processHtmlString'],
- 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 letterSpacing 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',
- },
- fontColor: {
- type: String,
- default: '#000000',
- },
- 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 {
- --letter-spacing-value: attr(data-letter-spacing number, 0);
- border-bottom: none;
- background-image: radial-gradient(
- circle at center,
- currentColor 0.15em, /* 圆点大小相对于字体 */
- transparent 0.16em
- );
- background-size: calc((1 + var(--letter-spacing-value)) * 1em) 0.3em;
- background-repeat: repeat-x;
- background-position: calc(var(--letter-spacing-value) * -0.5em) 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], // 段落间距
- letterSpacing_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; // 设置默认字体
- editor.getBody().style.color = this.fontColor; // 设置默认字体颜色
- this.init.paragraphheight_formats.forEach((config) => {
- const formatName = `paragraphSpacing${config}_em`;
- editor.formatter.register(formatName, {
- selector: 'p',
- styles: { 'margin-bottom': `${config}em` },
- });
- });
- this.init.letterSpacing_formats.forEach((config) => {
- const formatName = `letterSpacing${config}_em`;
- editor.formatter.register(formatName, {
- inline: 'span',
- styles: { 'letter-spacing': `${config}em`},
- attributes: { 'data-letter-spacing': `${config}` },
- wrapper: true,
- remove_similar: true,
- });
- });
- 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.addIcon(
- 'letter-spacing-icon',
- `<svg viewBox="4 4 18 18" width="44" height="24">
- <text x="17" y="18" text-anchor="middle" font-size="14" font-weight="bold" fill="#000">A</text>
- <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"/>
- <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"/>
- </svg>`,
- );
- // 添加字间距下拉菜单
- editor.ui.registry.addMenuButton('letterSpacing', {
- icon: 'letter-spacing-icon',
- // text: '字间距',
- tooltip: '字间距',
- fetch: (callback) => {
- const items = [];
- // 动态生成菜单项
- this.init.letterSpacing_formats.forEach((config) => {
- const formatName = `letterSpacing${config}_em`;
- items.push({
- type: 'menuitem',
- text: `${config}`,
- onAction: () => {
- // 先清除其他字间距格式
- this.init.letterSpacing_formats.forEach((cfg) => {
- const fmtName = `letterSpacing${cfg}_em`;
- editor.formatter.remove(fmtName);
- });
- // 应用当前选择的字间距
- editor.formatter.apply(formatName);
- },
- });
- });
- // 添加清除字间距选项
- items.push({
- type: 'separator', // 分隔线
- });
- items.push({
- type: 'menuitem',
- text: '清除字间距',
- onAction: () => {
- // 清除所有字间距格式
- this.init.letterSpacing_formats.forEach((config) => {
- const formatName = `letterSpacing${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',
- },
- };
- },
- computed: {},
- 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;
- }
- }
- },
- },
- },
- 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: {
- getRichContent() {
- return tinymce.get(this.id).getContent();
- },
- getRichSelectionContent() {
- return tinymce.get(this.id).selection.getContent();
- },
- 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);
- editor.fire('change'); // 触发内容变化事件,确保内容更新
- if (this.isViewPinyin) {
- this.$nextTick(() => {
- let styles = this.getFirstCharStyles();
- this.$emit('createParsedTextInfoPinyin', null, styles);
- });
- }
- },
- 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 = this.getRichSelectionContent();
- 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 = this.getRichSelectionContent();
- 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 = this.getRichContent();
- if (this.isViewPinyin) {
- // updated by 2026-3-24 失去焦点时,不再生成拼音,改成手动点击生成拼音
- // 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 = this.getRichSelectionContent();
- 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 = this.getRichSelectionContent();
- 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>
|