refactor: TinyMCE组件重构 移除冗余代码/功能 增加loading

This commit is contained in:
dap 2025-04-04 22:55:07 +08:00
parent 104039cdfb
commit a2ed3fa48b
5 changed files with 90 additions and 468 deletions

View File

@ -28,6 +28,7 @@
- modalLoading/drawerLoading改为调用内部的lock/unlock方法
- 登录验证码 增加loading
- DictEnum使用const代替enum
- TinyMCE组件重构 移除冗余代码/功能 增加loading
**BUG FIX**

View File

@ -1,80 +1,50 @@
<script lang="ts" setup>
<script setup lang="ts">
import type { IPropTypes } from '@tinymce/tinymce-vue/lib/cjs/main/ts/components/EditorPropTypes';
import type { Editor as EditorType } from 'tinymce/tinymce';
import type { PropType } from 'vue';
import type { UploadResult } from '#/api';
import type { UploadResult } from '#/api/core/upload';
import {
computed,
nextTick,
onActivated,
onBeforeUnmount,
onDeactivated,
onMounted,
ref,
unref,
useAttrs,
watch,
} from 'vue';
import { computed, nextTick, ref, shallowRef, useAttrs, watch } from 'vue';
import { preferences, usePreferences } from '@vben/preferences';
import { buildShortUUID } from '@vben/utils';
import Editor from '@tinymce/tinymce-vue';
import { isNumber } from 'lodash-es';
import { Spin } from 'ant-design-vue';
import { camelCase } from 'lodash-es';
import { uploadApi } from '#/api/core/upload';
import { bindHandlers } from './helper';
import ImgUpload from './img-upload.vue';
import { uploadApi } from '#/api';
import {
plugins as defaultPlugins,
toolbar as defaultToolbar,
} from './tinymce';
defineOptions({ inheritAttrs: false });
const props = defineProps({
height: {
default: 400,
required: false,
type: [Number, String] as PropType<number | string>,
},
options: {
default: () => ({}),
// eslint-disable-next-line no-use-before-define
type: Object as PropType<Partial<InitOptions>>,
},
plugins: {
default: defaultPlugins,
type: String,
},
showImageUpload: {
default: true,
type: Boolean,
},
toolbar: {
default: defaultToolbar,
type: String,
},
width: {
default: 'auto',
required: false,
type: [Number, String] as PropType<number | string>,
},
});
const emit = defineEmits(['change']);
} from '#/components/tinymce/src/tinymce';
type InitOptions = IPropTypes['init'];
/**
* 外部使用 v-model 绑定值
*/
const modelValue = defineModel('modelValue', { default: '', type: String });
interface Props {
height?: number | string;
options?: Partial<InitOptions>;
plugins?: string;
toolbar?: string;
disabled?: boolean;
}
defineOptions({
name: 'Tinymce',
inheritAttrs: false,
});
const props = withDefaults(defineProps<Props>(), {
height: 400,
options: () => ({}),
plugins: defaultPlugins,
toolbar: defaultToolbar,
disabled: false,
});
const emit = defineEmits<{
mounted: [];
}>();
/**
* https://www.jianshu.com/p/59a9c3802443
* 使用自托管方案本地代替cdn 没有key的限制
@ -82,21 +52,13 @@ const modelValue = defineModel('modelValue', { default: '', type: String });
*/
const tinymceScriptSrc = `${import.meta.env.VITE_BASE}tinymce/tinymce.min.js`;
const attrs = useAttrs();
const editorRef = ref<EditorType>();
const fullscreen = ref(false);
const tinymceId = ref<string>(buildShortUUID('tiny-vue'));
const elRef = ref<HTMLElement | null>(null);
const containerWidth = computed(() => {
const width = props.width;
if (isNumber(width)) {
return `${width}px`;
}
return width;
const content = defineModel<string>('modelValue', {
default: '',
});
const { isDark } = usePreferences();
const editorRef = shallowRef<EditorType | null>(null);
const { isDark, locale } = usePreferences();
const skinName = computed(() => {
return isDark.value ? 'oxide-dark' : 'oxide';
});
@ -105,30 +67,6 @@ const contentCss = computed(() => {
return isDark.value ? 'dark' : 'default';
});
/**
* 通过v-if来挂载/卸载组件
* 来完成主题切换/语言切换
*/
const init = ref(true);
watch(
() => [preferences.theme.mode, preferences.app.locale],
() => {
if (!editorRef.value) {
return;
}
destroy();
init.value = false;
// tick
// v-iftrue editorRef setTimeout(onMounted)
nextTick(() => {
init.value = true;
setTimeout(() => {
setEditorMode();
});
});
},
);
/**
* tinymce支持 en zh_CN
*/
@ -140,6 +78,26 @@ const langName = computed(() => {
return 'zh_CN';
});
/**
* 通过v-if来挂载/卸载组件来完成主题切换切换
* 语言切换也需要监听 不监听在切换时候会显示原始<textarea>样式
*/
const init = ref(true);
watch([isDark, locale], async () => {
if (!editorRef.value) {
return;
}
// unmounted
editorRef.value.destroy();
init.value = false;
// tick
// v-iftrue editorRef setTimeout(onMounted)
await nextTick();
init.value = true;
});
// spin
const loading = ref(true);
const initOptions = computed((): InitOptions => {
const { height, options, plugins, toolbar } = props;
return {
@ -194,191 +152,54 @@ const initOptions = computed((): InitOptions => {
},
setup: (editor) => {
editorRef.value = editor;
editor.on('init', (e) => initSetup(e));
editor.on('init', () => {
emit('mounted');
loading.value = false;
});
},
};
});
const attrs = useAttrs();
/**
* 监听options.readonly
* 获取透传的事件 通过v-on绑定
* 可绑定的事件 https://www.tiny.cloud/docs/tinymce/latest/vue-ref/#event-binding
*/
watch(
() => props.options,
(options) => {
const getDisabled = options && Reflect.get(options, 'readonly');
const editor = unref(editorRef);
if (editor) {
editor.mode.set(getDisabled ? 'readonly' : 'design');
const events = computed(() => {
const onEvents: Record<string, any> = {};
for (const key in attrs) {
if (key.startsWith('on')) {
const eventKey = camelCase(key.split('on')[1]!);
onEvents[eventKey] = attrs[key];
}
},
);
onMounted(() => {
if (!initOptions.value.inline) {
tinymceId.value = buildShortUUID('tiny-vue');
}
nextTick(() => {
setTimeout(() => {
initEditor();
setEditorMode();
}, 30);
});
return onEvents;
});
onBeforeUnmount(() => {
destroy();
});
onDeactivated(() => {
destroy();
});
onActivated(() => {
setEditorMode();
});
function setEditorMode() {
const editor = unref(editorRef);
if (editor) {
const mode = props.options.readonly ? 'readonly' : 'design';
editor.mode.set(mode);
}
}
function destroy() {
const editor = unref(editorRef);
editor?.destroy();
}
function initEditor() {
const el = unref(elRef);
if (el) {
el.style.visibility = '';
}
}
function initSetup(e: any) {
const editor = unref(editorRef);
if (!editor) {
return;
}
const value = modelValue.value || '';
editor.setContent(value);
bindModelHandlers(editor);
bindHandlers(e, attrs, unref(editorRef));
}
function setValue(editor: Record<string, any>, val?: string, prevVal?: string) {
if (
editor &&
typeof val === 'string' &&
val !== prevVal &&
val !== editor.getContent({ format: attrs.outputFormat })
) {
editor.setContent(val);
}
}
function bindModelHandlers(editor: any) {
const modelEvents = attrs.modelEvents ?? null;
const normalizedEvents = Array.isArray(modelEvents)
? modelEvents.join(' ')
: modelEvents;
watch(
() => modelValue.value,
(val, prevVal) => {
setValue(editor, val, prevVal);
},
);
editor.on(normalizedEvents || 'change keyup undo redo', () => {
const content = editor.getContent({ format: attrs.outputFormat });
emit('change', content);
});
editor.on('FullscreenStateChanged', (e: any) => {
fullscreen.value = e.state;
});
}
const disabled = computed(() => props.options.readonly ?? false);
function getUploadingImgName(name: string) {
return `[uploading:${name}]`;
}
function handleImageUploading(name: string) {
const editor = unref(editorRef);
if (!editor) {
return;
}
editor.execCommand('mceInsertContent', false, getUploadingImgName(name));
const content = editor?.getContent() ?? '';
setValue(editor, content);
}
function handleDone(name: string, url: string) {
const editor = unref(editorRef);
if (!editor) {
return;
}
const content = editor?.getContent() ?? '';
const val =
content?.replace(getUploadingImgName(name), `<img src="${url}"/>`) ?? '';
setValue(editor, val);
}
</script>
<template>
<div :style="{ width: containerWidth }" class="app-tinymce">
<ImgUpload
v-if="showImageUpload"
v-show="editorRef"
:disabled="disabled"
:fullscreen="fullscreen"
@done="handleDone"
@uploading="handleImageUploading"
/>
<Editor
v-if="!initOptions.inline && init"
v-model="modelValue"
:init="initOptions"
:style="{ visibility: 'hidden', zIndex: 3000 }"
:tinymce-script-src="tinymceScriptSrc"
license-key="gpl"
/>
<slot v-else></slot>
<div class="app-tinymce">
<Spin :spinning="loading">
<Editor
v-if="init"
v-model="content"
:init="initOptions"
:tinymce-script-src="tinymceScriptSrc"
:disabled="disabled"
license-key="gpl"
v-on="events"
/>
</Spin>
</div>
</template>
<style lang="scss">
/***
由于modal/drawer的zIndex升级后为2000
这里会造成遮挡 修改为更高的zIndex
*/
.tox.tox-silver-sink.tox-tinymce-aux {
/** 该样式默认为1300的zIndex */
z-index: 2025;
}
</style>
<style lang="scss" scoped>
/**
.app-tinymce {
/**
隐藏右上角upgrade按钮
*/
:deep(.tox-promotion) {
display: none !important;
}
.app-tinymce {
position: relative;
line-height: normal;
:deep(.textarea) {
z-index: -1;
visibility: hidden;
.tox-promotion {
display: none;
}
}
</style>

View File

@ -1,85 +0,0 @@
const validEvents = new Set([
'onActivate',
'onAddUndo',
'onBeforeAddUndo',
'onBeforeExecCommand',
'onBeforeGetContent',
'onBeforePaste',
'onBeforeRenderUI',
'onBeforeSetContent',
'onBlur',
'onChange',
'onClearUndos',
'onClick',
'onContextMenu',
'onCopy',
'onCut',
'onDblclick',
'onDeactivate',
'onDirty',
'onDrag',
'onDragDrop',
'onDragEnd',
'onDragGesture',
'onDragOver',
'onDrop',
'onExecCommand',
'onFocus',
'onFocusIn',
'onFocusOut',
'onGetContent',
'onHide',
'onInit',
'onKeyDown',
'onKeyPress',
'onKeyUp',
'onLoadContent',
'onMouseDown',
'onMouseEnter',
'onMouseLeave',
'onMouseMove',
'onMouseOut',
'onMouseOver',
'onMouseUp',
'onNodeChange',
'onObjectResized',
'onObjectResizeStart',
'onObjectSelected',
'onPaste',
'onPostProcess',
'onPostRender',
'onPreProcess',
'onProgressState',
'onRedo',
'onRemove',
'onReset',
'onSaveContent',
'onSelectionChange',
'onSetAttrib',
'onSetContent',
'onShow',
'onSubmit',
'onUndo',
'onVisualAid',
]);
const isValidKey = (key: string) => validEvents.has(key);
export const bindHandlers = (
initEvent: Event,
listeners: any,
editor: any,
): void => {
Object.keys(listeners)
.filter((element) => isValidKey(element))
.forEach((key: string) => {
const handler = listeners[key];
if (typeof handler === 'function') {
if (key === 'onInit') {
handler(initEvent, editor);
} else {
editor.on(key.slice(2), (e: any) => handler(e, editor));
}
}
});
};

View File

@ -1,115 +0,0 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useAppConfig } from '@vben/hooks';
import { useAccessStore } from '@vben/stores';
import { message, Upload } from 'ant-design-vue';
defineOptions({ name: 'TinymceImageUpload' });
const props = defineProps({
disabled: {
default: false,
type: Boolean,
},
fullscreen: {
type: Boolean,
},
});
const emit = defineEmits(['uploading', 'done', 'error']);
let uploading = false;
const { apiURL, clientId } = useAppConfig(
import.meta.env,
import.meta.env.PROD,
);
const accessStore = useAccessStore();
const uploadUrl = `${apiURL}/resource/oss/upload`;
// 使upload
const headers = {
Authorization: `Bearer ${accessStore.accessToken}`,
clientId,
};
const getButtonProps = computed(() => {
const { disabled } = props;
return {
disabled,
};
});
function handleChange(info: Record<string, any>) {
const file = info.file;
const status = file?.status;
// const url = file?.response?.data.url;
const name = file?.name;
switch (status) {
case 'done': {
// http 200
const { response } = file;
const { code, data, msg = '服务器错误' } = response;
if (code === 200) {
const { url } = data;
emit('done', name, url);
} else {
message.error(msg);
}
// emit('done', name, url);
uploading = false;
break;
}
case 'error': {
emit('error');
uploading = false;
break;
}
case 'uploading': {
if (!uploading) {
emit('uploading', name);
uploading = true;
}
break;
}
// No default
}
}
</script>
<template>
<div :class="[{ fullscreen }]" class="tinymce-image-upload">
<Upload
:action="uploadUrl"
:headers="headers"
:show-upload-list="false"
accept=".jpg,.jpeg,.gif,.png,.webp"
multiple
name="file"
@change="handleChange"
>
<!-- 这里要改成i18n -->
<a-button type="primary" v-bind="{ ...getButtonProps }">
图片上传
</a-button>
</Upload>
</div>
</template>
<style lang="scss" scoped>
.tinymce-image-upload {
position: absolute;
top: 4px;
right: 10px;
z-index: 20;
&.fullscreen {
position: fixed;
z-index: 10000;
}
}
</style>

View File

@ -15,10 +15,10 @@ const content = ref('');
<Page title="Tinymce富文本">
<div class="flex flex-col gap-[16px]">
<div class="flex items-center gap-[16px]">
<span>只读</span>
<span>禁用</span>
<Switch v-model:checked="readonly" />
</div>
<Tinymce v-model="content" :height="800" :options="{ readonly }" />
<Tinymce v-model="content" :height="800" :disabled="readonly" />
</div>
</Page>
</template>