一、版本
"@tinymce/tinymce-vue": "4.0.5",
"tinymce": "5.10.2",
二、步骤
具体步骤可以参考tinymce在vue2中的用法中的步骤
三、在项目index.html-body中引入tinymcejs
<script src="tinymce/tinymce.min.js"></script>
四、tinymce引入自定义插件
在项目的public/tinymce文件夹下引入按需放置自定义插件文件,比如formatpainter和indent2em
在使用tinymce插件的vue中引入自定义插件
import '@PublicDir/tinymce/plugins/formatpainter';
import '@PublicDir/tinymce/plugins/indent2em';
五、具体代码
1、封装组件
把tinymce初始化以及文本流式输出封装成组件放到项目src/components文件夹下:
tinymce.vue
<template>
<div class="tinymce-page">
<Editor v-model="editorValue" class="tinymce-editor" :init="init" @focus="handleFocus">
</Editor>
<!-- AI生成modal -->
<FModal v-model:show="modalObj.showModal" :maskClosable="false" :closable="modalObj.state === '2'" :title="modalObj.title" vertical-center :width="860" contentClass="ai-modal-wrapper" @cancel="cancel">
<div class="ai">
<FScrollbar ref="scrollbarRef" :maxHeight="modalMaxHeight" style="width: 100%">
<div v-if="modalObj.state === '1'" class="ai-loading">
<EditOutlined />AI生成中
</div>
<FTooltip v-else mode="popover" trigger="click" placement="right-start" popperClass="popover-regular">
<div id="message" class="message">
<span v-html="modalObj.message"></span>
<LoadingOutlined v-if="modalObj.messageState === '2'" />
</div>
<template #content>
<div class="list">
<div class="title">编辑诉求或选择以下方式</div>
<div v-for="(item, index) in aiToolbarList"
:key="index"
class="item"
:title="item.label"
@click="handleClick(item.value)"
>
<EditOutlined />{{item.label}}
</div>
</div>
</template>
</FTooltip>
</FScrollbar>
</div>
<template #footer></template>
</FModal>
</div>
</template>
<script setup>
import {
FInput, FMessage, FModal, FScrollbar,
} from '@fesjs/fes-design';
import {
ref, defineEmits, defineProps, computed, nextTick, watch, onUnmounted,
} from 'vue';
import { EditOutlined, LoadingOutlined } from '@fesjs/fes-design/icon';
import { request } from '@fesjs/fes';
import tinymce from 'tinymce'; // tinymce默认hidden,不引入不显示
import Editor from '@tinymce/tinymce-vue';// 编辑器引入;
import 'tinymce/icons/default/icons';
import 'tinymce/themes/silver/theme';// 编辑器主题
import 'tinymce/plugins/contextmenu'; // 上下文菜单
import 'tinymce/plugins/table';
import 'tinymce/plugins/image';
import 'tinymce/plugins/lists';
import 'tinymce/plugins/paste';
import '@PublicDir/tinymce/plugins/formatpainter';
import '@PublicDir/tinymce/plugins/indent2em';
const props = defineProps({
value: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
aiId: { // ai生成时所需ID
type: String,
default: '',
},
title: { // 文档标题
type: String,
default: '',
},
textTitle: { // 输入框标题,用来做校验错误信息提示
type: String,
default: '',
},
placeholder: { // placeholder
type: String,
default: '',
},
});
const emits = defineEmits(['update:value', 'selectEditor']);
// 通过重写计算属性的set和get方法,将计算属性的结果绑定在文本框的model中
const editorValue = computed({
get: () => props.value,
set: (val) => {
emits('update:value', val);
},
});
const myEditor = ref(null);
const modalObj = ref({
title: '',
showModal: false,
message: '',
state: '1', // 1、AI生成中 2、生成成功
messageState: '1', // 加载状态显示 1、生成成功或者未触发生成 2、文案生成中
});
const tinymceSelectText = ref('');// 文本框选中的文案
// const queryAIResult = sessionId => new Promise((resolve, reject) => {
// request('/projectDoc/ai/write/result', { sessionId }, 'post')
// .then((result) => {
// if (result.status === 'PENDING') {
// reject('pending');
// } else {
// resolve(result);
// }
// }).catch((error) => {
// reject(error);
// });
// });
// const queryAiResultCircle = (sessionId) => {
// queryAIResult(sessionId).then((result) => {
// if (result.status === 'FAIL') {
// modalObj.value.message = '生成失败';
// } else {
// modalObj.value.message = result.output || '生成失败';
// }
// modalObj.value.state = '2';
// }).catch((error) => {
// setTimeout(() => queryAiResultCircle(sessionId), 5000);
// });
// };
// const queryAiResultCircle = async (sessionId) => {
// queryAIResult(sessionId).then((result) => {
// if (result.status === 'FAIL') {
// modalObj.value.message = '生成失败';
// } else {
// modalObj.value.message = result.output || '生成失败';
// }
// modalObj.value.state = '2';
// }).catch((error) => {
// setTimeout(() => queryAiResultCircle(sessionId), 5000);
// });
// const result = await request('/projectDoc/ai/write/body', {
// sessionId,
// }, {
// method: 'post',
// });
// };
const scrollbarRef = ref(null);
const modalMaxHeight = 800;
// EventSource文本流失输出
const eventSource = ref();
const setupEventSource = (sessionId) => {
modalObj.value.message = '';
eventSource.value = new EventSource(`${BASEURL}/projectDoc/ai/write/stream?sessionId=${sessionId}`); // 创建EventSource对象,参数为接口URL
eventSource.value.addEventListener('message', (event) => {
console.log('message:', event.data);
modalObj.value.state = '2';
modalObj.value.messageState = '2';
modalObj.value.message = event.data;
const messageDom = document.getElementById('message');
// 文本框滚动条始终置于元素底部
if (messageDom && messageDom.scrollHeight >= modalMaxHeight) {
// console.log(messageDom.scrollHeight, scrollbarRef.value, scrollbarRef.value.height);
scrollbarRef.value?.setScrollTop(messageDom.scrollHeight, 1000);
}
});
eventSource.value.addEventListener('finish', (event) => {
console.log('Finish:', event.data);
modalObj.value.messageState = '1';
eventSource.value.close(); // 关闭连接
});
eventSource.value.onerror = function (event) {
console.error('EventSource failed.', event);
modalObj.value.state = '2';
modalObj.value.messageState = '1';
modalObj.value.message = '生成失败';
eventSource.value.close(); // 在发生错误时关闭连接
};
};
const againAIId = ref(''); // 重新生成内容时的id
const againAIInput = ref(''); // 重新生成内容时的input
const getMessage = async ({
id,
input,
}) => {
try {
// 请求后台生成文案
againAIId.value = id;
againAIInput.value = input;
const inputText = input.length > 4000 ? input.slice(input.length - 4000, input.length) : input;
const result = await request('/projectDoc/ai/write/body', {
id,
input: inputText,
title: props.title,
}, {
method: 'post',
});
const sessionId = result.sessionId;
console.log(sessionId);
setupEventSource(sessionId);
// 每5s查询一次ai生成结果
// queryAiResultCircle(sessionId);
} catch (error) {
console.log('getMessage error:', error);
modalObj.value.message = '生成失败';
modalObj.value.state = '2';
}
};
// 上下文菜单items action
const tinymceAction = (modalTitle, text, aiId) => {
if (!text) return FMessage.error('请选择文案');
tinymceSelectText.value = text;
modalObj.value.title = modalTitle;
modalObj.value.showModal = true;
getMessage({
id: aiId,
input: text,
});
};
// fontsize获取
const getFontSize = (editor) => {
const fontSize = editor.queryCommandValue('FontSize');
return parseInt(fontSize.match(/(\S*)px/)[1]);
};
const tinymceId = `tinymce${new Date().getTime()}`;
// 富文本框初始化
const init = {
selector: `${tinymceId}`, // 富文本编辑器的id,
placeholder: props.placeholder,
language_url: 'tinymce/langs/zh_CN.js', // 引入语言包文件
language: 'zh_CN', // 语言类型
menubar: false,
skin_url: 'tinymce/skins/ui/oxide', // 皮肤:浅色
content_css: 'tinymce/skins/content/default/content.css',
plugins: 'formatpainter image lists table indent2em contextmenu paste',
toolbar: [
'formatpainter | styleselect | fontselect | fontsizeselect | customInsert | fontSizeA+ fontSizeA- | bold italic underline strikethrough forecolor backcolor | numlist bullist | indent2em outdent indent | alignleft aligncenter alignright | lineheight blockquote | undo redo',
],
toolbar_groups: {
customInsert: {
text: '插入',
tooltip: 'insert',
items: 'table image',
},
},
fontsize_formats: '12px 14px 16px 18px 24px 36px 48px 56px 72px',
font_formats: '微软雅黑=Microsoft YaHei,Helvetica Neue,PingFang SC,sans-serif;苹果苹方=PingFang SC,Microsoft YaHei,sans-serif;宋体=simsun,serif;仿宋体=FangSong,serif;黑体=SimHei,sans-serif;Arial=arial,helvetica,sans-serif;Arial Black=arial black,avant garde;Book Antiqua=book antiqua,palatino;',
contextmenu: 'title | textGeneration expandWordCount extractKeyPoints changeLanguage',
setup(editor) {
myEditor.value = editor;
editor.ui.registry.addIcon('file', '<svg width="14" height="14" viewBox="0 0 1024 1024"><path d="M533.419 96.555a21.333 21.333 0 0 1 21.333 21.333v21.333a21.333 21.333 0 0 1-17.493 20.992l-3.84.342-372.907-.043v702.933h702.933V533.333A21.333 21.333 0 0 1 884.78 512h21.333a21.333 21.333 0 0 1 21.333 21.333v342.443c0 26.752-20.309 48.768-46.378 51.413l-5.291.256H148.224a51.67 51.67 0 0 1-51.413-46.378l-.256-5.291V148.224c0-26.752 20.309-48.768 46.378-51.413l5.291-.256h385.195zm263.296 3.84L917.376 221.14a21.333 21.333 0 0 1 0 30.123l-371.627 371.67a21.333 21.333 0 0 1-13.184 6.143l-132.693 12.075a21.333 21.333 0 0 1-23.21-23.168l12.074-132.736a21.333 21.333 0 0 1 6.144-13.141l371.627-371.67a21.333 21.333 0 0 1 30.208 0zm-15.062 75.392L451.072 506.368l-6.059 66.39 66.39-6.06 330.581-330.58-60.33-60.331z"></path></svg>');
editor.ui.registry.addIcon('a-', '<svg width="20" height="20" viewBox="0 0 1024 1024" p-id="1183" width="200" height="200"><path d="M631.1 266.9h345.4v41H631.1zM368.5 150.5L57.2 855.2h73.2l101.3-236.8h346.7l98.9 236.8h78.1L444.2 150.5h-75.7zM559 572H251.3l54.9-127.6c35.4-82.2 65.9-156.6 96.4-241.7h4.9c31.7 85.1 61 159.5 97.7 241.7L559 572z" fill="#000" p-id="1184"></path></svg>');
editor.ui.registry.addIcon('a+', '<svg width="20" height="20" viewBox="0 0 1024 1024" p-id="902" width="200" height="200"><path d="M631.1 266.9h152.2v41H631.1zM824.3 266.9h152.2v41H824.3zM783.3 307.8h41V460h-41zM783.3 114.6h41v152.2h-41z" fill=" #000" p-id="903"></path><path d="M783.3 266.9h41v41h-41zM368.5 150.5L57.2 855.2h73.2l101.3-236.8h346.7l98.9 236.8h78.1L444.2 150.5h-75.7zM559 572H251.3l54.9-127.6c35.4-82.2 65.9-156.6 96.4-241.7h4.9c31.7 85.1 61 159.5 97.7 241.7L559 572z" fill=" #000" p-id="904"></path></svg>');
editor.ui.registry.addButton('fontSizeA+', {
icon: 'a+',
tooltip: '字号放大',
onAction() {
const num = getFontSize(editor);
if (num === 72) return;
editor.execCommand('fontSize', false, `${num + 2}px`);
},
});
editor.ui.registry.addButton('fontSizeA-', {
icon: 'a-',
tooltip: '字号缩小',
onAction() {
const num = getFontSize(editor);
if (num === 12) return;
editor.execCommand('fontSize', false, `${num - 2}px`);
},
});
editor.ui.registry.addMenuItem('title', {
text: '请选择以下方式使用AI',
});
editor.ui.registry.addMenuItem('textGeneration', {
text: '文本生成',
context: 'tools',
icon: 'file',
onAction(e) {
console.log(e, '文本生成', editor.selection.getContent({ format: 'text' }));
const text = editor.selection.getContent({ format: 'text' });
tinymceAction('文本生成', text, `${props.aiId}-SC`);
},
});
editor.ui.registry.addMenuItem('expandWordCount', {
text: '扩充字数',
context: 'tools',
icon: 'file',
onAction() {
console.log('扩充字数');
const text = editor.selection.getContent({ format: 'text' });
tinymceAction('扩充字数', text, `${props.aiId}-KC`);
},
});
editor.ui.registry.addMenuItem('extractKeyPoints', {
text: '提炼要点',
context: 'tools',
icon: 'file',
onAction() {
console.log('提炼要点');
const text = editor.selection.getContent({ format: 'text' });
tinymceAction('提炼要点', text, `${props.aiId}-TL`);
},
});
editor.ui.registry.addMenuItem('changeLanguage', {
text: '转换语言风格',
context: 'tools',
icon: 'file',
onAction() {
console.log('转换语言风格');
const text = editor.selection.getContent({ format: 'text' });
tinymceAction('转换语言风格', text, `${props.aiId}-ZH`);
},
});
},
indentation: '2em',
branding: false,
elementpath: false,
width: '100%',
min_height: 600,
paste_preprocess(plugin, args) {
// 复制粘贴去掉原有样式
console.log(args.content, plugin);
args.content = args.content.replace(/(\sface=".*"\scolor)/g, ' color');
},
file_picker_callback(callback, value, meta) {
// 上传需要处理
console.log(callback, value, meta);
// 文件分类
let filetypeAccept = '.pdf, .png';
const apiFileType = '1';
// 后端接收上传文件的地址
let upurl = 'file/upload';
// 为不同插件指定文件类型及后端地址
switch (meta.filetype) {
case 'image':
filetypeAccept = '.jpg, .jpeg, .png, .gif';
upurl = '/oacgs-bms/commonFile/uploadFile';
// apiFileType = '1';
break;
case 'file':
default:
}
// 模拟出一个input用于添加本地文件
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', filetypeAccept);
input.click();
input.onchange = function () {
const file = this.files[0];
console.log(file.name);
const xhr = new XMLHttpRequest();
xhr.withCredentials = false;
xhr.open('POST', upurl);
xhr.onload = function () {
if (xhr.status !== 200) {
FMessage.error(`HTTP Error: ${xhr.status}`);
return;
}
const json = JSON.parse(xhr.responseText);
console.log('json', json);
if (!json || json.code !== '0') {
FMessage.error(json?.msg || '上传失败');
return;
}
console.log('json', json?.result);
const recordId = json?.result?.recordId;
const fileUrl = `${window.location.origin}/oacgs-bms/commonFile/image/download?recordId=${recordId}`;
console.log('fileUrl', fileUrl);
if (meta.filetype === 'file') return callback(fileUrl || '', { title: file.name, text: file.name });
callback(fileUrl || '', {});
};
const formData = new FormData();
formData.append('file', file, file.name);
formData.append('fileType', apiFileType);
xhr.send(formData);
};
},
};
const aiToolbarList = [
{ label: '替换选择区域', value: 'replace' },
{ label: '添加到所选区域之后', value: 'append' },
{ label: '将生成内容扩充字数', value: 'continueExpandWordCount' },
{ label: '重新生成', value: 'regenerate' },
{ label: '放弃文本', value: 'cancel' },
];
const again = ({
id, input,
}) => {
modalObj.value.state = '1';
modalObj.value.message = '';
getMessage({ id, input });
};
const cancel = () => {
modalObj.value.state = '1';
modalObj.value.message = '';
modalObj.value.showModal = false;
if (!eventSource.value) return;
eventSource.value.close();
};
// 生成文案点击选择items
const handleClick = (type) => {
const selectText = myEditor.value.selection.getContent();
switch (type) {
case 'replace':
// 替换选择区域
myEditor.value.insertContent(modalObj.value.message);
cancel();
break;
case 'append':
// 添加到所选区域之后
myEditor.value.selection.setContent(`${selectText}${modalObj.value.message}`);
cancel();
break;
case 'continueExpandWordCount':
// 将生成内容扩充字数
console.log('将生成内容扩充字数');
again({
id: `${props.aiId}-KC`,
input: modalObj.value.message,
});
break;
case 'regenerate':
// 重新生成
console.log('重新生成');
again({
id: againAIId.value,
input: againAIInput.value,
});
break;
case 'cancel':
// 放弃文本
cancel();
break;
default:
break;
}
};
const handleFocus = (event) => {
// console.log('focus in', event)
emits('selectEditor', props.aiId);
};
const openContextMenu = () => {
if (!myEditor.value.selection.getContent().length) {
myEditor.value.execCommand('selectAll');
}
console.log(myEditor.value.selection.getContent());
myEditor.value.fire('contextMenu');
};
const validateLength = () => myEditor.value.getContent({ format: 'text' }).length < 10001;
// console.log(myEditor.value.getContent({format:'text'}), myEditor.value.getContent({format:'text'}).length)
watch(() => props.disabled, async () => {
// console.log(props.disabled, myEditor.value)
await nextTick();
if (myEditor.value) {
// console.log(myEditor.value);
// 由于tinymce 5.x使用的setMode方式来控制文本框的disable态,查阅源码发现该函数内有异步事件,在连续调用时会出现后面的被前面覆盖的问题,故使用setTimeout解决该问题 (tinymce 5.10.2 Mode.ts line37-setMode)
setTimeout(() => myEditor.value.setMode(props.disabled ? 'readonly' : 'design'), 300);
}
}, { immediate: true });
// eslint-disable-next-line no-undef
defineExpose({
openContextMenu, validateLength, textTitle: props.textTitle,
});
onUnmounted(() => {
if (!eventSource.value) return;
eventSource.value.close();
});
</script>
<style lang="less" >
.ai {
.ai-loading {
display: flex;
align-items: center;
.fes-design-icon {
margin-right: 6px;
}
}
}
.message {
cursor: pointer;
line-height: 2;
.fes-design-icon {
line-height: 2;
margin-left: 6px;
}
}
.popover-regular {
padding: 0!important;
border-radius: 2px !important;
overflow: hidden;
border: 1px solid #ccc;
.list {
padding: 4px 0;
.title,
.item {
display: flex;
align-items: center;
line-height: 32px;
font-size: 14px;
padding: 0 16px;
color: #222f3e;
}
.title {
border-bottom: 1px solid #ccc;
padding-left: 20px;
margin-bottom: 4px;
}
.item {
cursor: pointer;
.fes-design-icon {
width: 24px;
margin-right: 8px;
}
&:hover {
background: #eee;
}
}
}
.fes-popper-arrow {
display: none;
}
}
.tox .tox-collection--list .tox-collection__item--active {
background-color: #eee !important;
}
.tox-collection__item {
cursor: pointer;
}
.tox .tox-collection--list .tox-collection__group:first-child {
.tox-collection__item--active {
background-color: transparent !important;
}
.tox-collection__item {
cursor: default;
&:hover {
background: transparent;
}
.tox-collection__item-icon {
display: none;
}
}
}
.ai-modal-wrapper {
padding: 16px 0 !important;
.fes-modal-header {
margin: 16px;
}
.fes-modal-body {
.fes-scrollbar {
padding: 0 16px;
}
}
}
</style>
代码中的@PublicDir需要在alias设置
alias: {
'@': path.resolve(__dirname, 'src'),
'@PublicDir': path.resolve(__dirname, 'public')
},
说明: fesjs是基于最新 Vue3 + webpack5
的前端应用框架
2、调用组件
<template>
<TEditor v-model:value="editorContent" />
</template>
<script setup>
import {ref} from 'vue';
import TEditor from '@/components/TEditor.vue';
const editorContent = ref('');
</script>
tinymce中文文档地址