提交 f75425d1 编写于 作者: V vben

perf: review tinymce code

上级 9c02d8ec
<template>
<div class="tinymce-container" :style="{ width: containerWidth }">
<tinymce-editor
:id="id"
:init="initOptions"
:modelValue="tinymceContent"
@update:modelValue="handleChange"
:tinymceScriptSrc="tinymceScriptSrc"
></tinymce-editor>
<textarea :id="tinymceId" visibility="hidden" ref="elRef"></textarea>
</div>
</template>
<script lang="ts">
import TinymceEditor from './lib'; // TinyMCE vue wrapper
import { defineComponent, computed } from 'vue';
import {
defineComponent,
computed,
onMounted,
nextTick,
ref,
unref,
watch,
onUnmounted,
onDeactivated,
} from 'vue';
import { basicProps } from './props';
import toolbar from './toolbar';
import plugins from './plugins';
import { getTinymce } from './getTinymce';
import { useScript } from '/@/hooks/web/useScript';
import { snowUuid } from '/@/utils/uuid';
import { bindHandlers } from './helper';
const CDN_URL = 'https://cdn.bootcdn.net/ajax/libs/tinymce/5.5.1';
const tinymceScriptSrc = `${CDN_URL}/tinymce.min.js`;
export default defineComponent({
name: 'Tinymce',
components: { TinymceEditor },
props: basicProps,
setup(props, { emit }) {
emits: ['change', 'update:modelValue'],
setup(props, { emit, attrs }) {
const editorRef = ref<any>(null);
const elRef = ref<Nullable<HTMLElement>>(null);
const tinymceId = computed(() => {
return snowUuid('tiny-vue');
});
const tinymceContent = computed(() => {
return props.value;
return props.modelValue;
});
function handleChange(value: string) {
emit('change', value);
}
const containerWidth = computed(() => {
const width = props.width;
// Test matches `100`, `'100'`
if (/^[\d]+(\.[\d]+)?$/.test(width.toString())) {
return `${width}px`;
}
return width;
});
const initOptions = computed(() => {
const { id, height, menubar } = props;
const { height, menubar } = props;
return {
selector: `#${id}`,
selector: `#${unref(tinymceId)}`,
height: height,
toolbar: toolbar,
theme: 'silver',
menubar: menubar,
plugins: plugins,
// 语言包
language_url: 'resource/tinymce/langs/zh_CN.js',
// 中文
language: 'zh_CN',
default_link_target: '_blank',
link_title: false,
advlist_bullet_styles: 'square',
advlist_number_styles: 'default',
object_resizing: false,
setup: (editor: any) => {
editorRef.value = editor;
editor.on('init', (e: Event) => initSetup(e));
},
};
});
return { containerWidth, initOptions, tinymceContent, handleChange, tinymceScriptSrc };
const { toPromise } = useScript({
src: tinymceScriptSrc,
});
watch(
() => attrs.disabled,
() => {
const editor = unref(editorRef);
if (!editor) return;
editor.setMode(attrs.disabled ? 'readonly' : 'design');
}
);
onMounted(() => {
nextTick(() => {
init();
});
});
onUnmounted(() => {
destory();
});
onDeactivated(() => {
destory();
});
function destory() {
if (getTinymce() !== null) {
getTinymce().remove(unref(editorRef));
}
}
function init() {
toPromise().then(() => {
initEditor();
});
}
function initEditor() {
getTinymce().init(unref(initOptions));
}
function initSetup(e: Event) {
const editor = unref(editorRef);
if (!editor) return;
const value = props.modelValue || '';
editor.setContent(value);
bindModelHandlers(editor);
bindHandlers(e, attrs, unref(editorRef));
}
function bindModelHandlers(editor: any) {
const modelEvents = attrs.modelEvents ? attrs.modelEvents : null;
const normalizedEvents = Array.isArray(modelEvents) ? modelEvents.join(' ') : modelEvents;
watch(
() => props.modelValue,
(val: string, prevVal: string) => {
if (
editor &&
typeof val === 'string' &&
val !== prevVal &&
val !== editor.getContent({ format: attrs.outputFormat })
) {
editor.setContent(val);
}
}
);
editor.on(normalizedEvents ? normalizedEvents : 'change keyup undo redo', () => {
emit('update:modelValue', editor.getContent({ format: attrs.outputFormat }));
});
}
function handleChange(value: string) {
emit('change', value);
}
return {
containerWidth,
initOptions,
tinymceContent,
handleChange,
tinymceScriptSrc,
elRef,
tinymceId,
};
},
});
</script>
......
const getGlobal = (): any => (typeof window !== 'undefined' ? window : global);
const getTinymce = () => {
export const getTinymce = () => {
const global = getGlobal();
return global && global.tinymce ? global.tinymce : null;
};
export { getTinymce };
import { ComponentPublicInstance } from 'vue';
const validEvents = [
'onActivate',
'onAddUndo',
......@@ -62,12 +60,12 @@ const validEvents = [
'onShow',
'onSubmit',
'onUndo',
'onVisualAid'
'onVisualAid',
];
const isValidKey = (key: string) => validEvents.indexOf(key) !== -1;
const bindHandlers = (initEvent: Event, listeners: any, editor: any): void => {
export const bindHandlers = (initEvent: Event, listeners: any, editor: any): void => {
Object.keys(listeners)
.filter(isValidKey)
.forEach((key: string) => {
......@@ -81,71 +79,3 @@ const bindHandlers = (initEvent: Event, listeners: any, editor: any): void => {
}
});
};
const bindModelHandlers = (ctx: ComponentPublicInstance, editor: any) => {
const modelEvents = ctx.$props.modelEvents ? ctx.$props.modelEvents : null;
const normalizedEvents = Array.isArray(modelEvents) ? modelEvents.join(' ') : modelEvents;
// @ts-ignore
ctx.$watch('modelValue', (val: string, prevVal: string) => {
if (editor && typeof val === 'string' && val !== prevVal && val !== editor.getContent({ format: ctx.$props.outputFormat })) {
editor.setContent(val);
}
});
editor.on(normalizedEvents ? normalizedEvents : 'change keyup undo redo', () => {
ctx.$emit('update:modelValue', editor.getContent({ format: ctx.$props.outputFormat }));
});
};
const initEditor = (initEvent: Event, ctx: ComponentPublicInstance, editor: any) => {
const value = ctx.$props.modelValue ? ctx.$props.modelValue : '';
const initialValue = ctx.$props.initialValue ? ctx.$props.initialValue : '';
editor.setContent(value || initialValue);
// checks if the v-model shorthand is used (which sets an v-on:input listener) and then binds either
// specified the events or defaults to "change keyup" event and emits the editor content on that event
if (ctx.$attrs['onUpdate:modelValue']) {
bindModelHandlers(ctx, editor);
}
bindHandlers(initEvent, ctx.$attrs, editor);
};
let unique = 0;
const uuid = (prefix: string): string => {
const time = Date.now();
const random = Math.floor(Math.random() * 1000000000);
unique++;
return prefix + '_' + random + unique + String(time);
};
const isTextarea = (element: Element | null): element is HTMLTextAreaElement => {
return element !== null && element.tagName.toLowerCase() === 'textarea';
};
const normalizePluginArray = (plugins?: string | string[]): string[] => {
if (typeof plugins === 'undefined' || plugins === '') {
return [];
}
return Array.isArray(plugins) ? plugins : plugins.split(' ');
};
const mergePlugins = (initPlugins: string | string[], inputPlugins?: string | string[]) =>
normalizePluginArray(initPlugins).concat(normalizePluginArray(inputPlugins));
const isNullOrUndefined = (value: any): value is null | undefined => value === null || value === undefined;
export {
bindHandlers,
bindModelHandlers,
initEditor,
uuid,
isTextarea,
mergePlugins,
isNullOrUndefined
};
\ No newline at end of file
import { uuid } from './Utils';
export type callbackFn = () => void;
export interface IStateObj {
listeners: callbackFn[];
scriptId: string;
scriptLoaded: boolean;
}
const createState = (): IStateObj => {
return {
listeners: [],
scriptId: uuid('tiny-script'),
scriptLoaded: false
};
};
interface ScriptLoader {
load: (doc: Document, url: string, callback: callbackFn) => void;
reinitialize: () => void;
}
const CreateScriptLoader = (): ScriptLoader => {
let state: IStateObj = createState();
const injectScriptTag = (scriptId: string, doc: Document, url: string, callback: callbackFn) => {
const scriptTag = doc.createElement('script');
scriptTag.referrerPolicy = 'origin';
scriptTag.type = 'application/javascript';
scriptTag.id = scriptId;
scriptTag.src = url;
const handler = () => {
scriptTag.removeEventListener('load', handler);
callback();
};
scriptTag.addEventListener('load', handler);
if (doc.head) {
doc.head.appendChild(scriptTag);
}
};
const load = (doc: Document, url: string, callback: callbackFn) => {
if (state.scriptLoaded) {
callback();
} else {
state.listeners.push(callback);
if (!doc.getElementById(state.scriptId)) {
injectScriptTag(state.scriptId, doc, url, () => {
state.listeners.forEach((fn) => fn());
state.scriptLoaded = true;
});
}
}
};
// Only to be used by tests.
const reinitialize = () => {
state = createState();
};
return {
load,
reinitialize
};
};
const ScriptLoader = CreateScriptLoader();
export {
ScriptLoader
};
\ No newline at end of file
/**
* Copyright (c) 2018-present, Ephox, Inc.
*
* This source code is licensed under the Apache 2 license found in the
* LICENSE file in the root directory of this source tree.
*
*/
// import { ThisTypedComponentOptionsWithRecordProps } from 'vue/types/options';
// import { CreateElement, Vue } from 'vue/types/vue';
import { ScriptLoader } from '../ScriptLoader';
import { getTinymce } from '../TinyMCE';
import { initEditor, isTextarea, mergePlugins, uuid, isNullOrUndefined } from '../Utils';
import { editorProps, IPropTypes } from './EditorPropTypes';
import { h, defineComponent, ComponentPublicInstance } from 'vue'
export interface IEditor {
$props: Partial<IPropTypes>
}
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
elementId: string;
element: Element | null;
editor: any;
inlineEditor: boolean;
$props: Partial<IPropTypes>;
}
}
const renderInline = (id: string, tagName?: string) => {
return h(tagName ? tagName : 'div', {
id
});
};
const renderIframe = (id: string) => {
return h('textarea', {
id,
visibility: 'hidden'
});
};
const initialise = (ctx: ComponentPublicInstance) => () => {
const finalInit = {
...ctx.$props.init,
readonly: ctx.$props.disabled,
selector: `#${ctx.elementId}`,
plugins: mergePlugins(ctx.$props.init && ctx.$props.init.plugins, ctx.$props.plugins),
toolbar: ctx.$props.toolbar || (ctx.$props.init && ctx.$props.init.toolbar),
inline: ctx.inlineEditor,
setup: (editor: any) => {
ctx.editor = editor;
editor.on('init', (e: Event) => initEditor(e, ctx, editor));
if (ctx.$props.init && typeof ctx.$props.init.setup === 'function') {
ctx.$props.init.setup(editor);
}
}
};
if (isTextarea(ctx.element)) {
ctx.element.style.visibility = '';
}
getTinymce().init(finalInit);
};
export const Editor = defineComponent({
props: editorProps,
created() {
this.elementId = this.$props.id || uuid('tiny-vue');
this.inlineEditor = (this.$props.init && this.$props.init.inline) || this.$props.inline;
},
watch: {
disabled() {
(this as any).editor.setMode(this.disabled ? 'readonly' : 'design');
}
},
mounted() {
this.element = this.$el;
if (getTinymce() !== null) {
initialise(this)();
} else if (this.element && this.element.ownerDocument) {
const channel = this.$props.cloudChannel ? this.$props.cloudChannel : '5';
const apiKey = this.$props.apiKey ? this.$props.apiKey : 'no-api-key';
const scriptSrc = isNullOrUndefined(this.$props.tinymceScriptSrc) ?
`https://cdn.tiny.cloud/1/${apiKey}/tinymce/${channel}/tinymce.min.js` :
this.$props.tinymceScriptSrc;
ScriptLoader.load(
this.element.ownerDocument,
scriptSrc,
initialise(this)
);
}
},
beforeUnmount() {
if (getTinymce() !== null) {
getTinymce().remove(this.editor);
}
},
render() {
return this.inlineEditor ? renderInline(this.elementId, this.$props.tagName) : renderIframe(this.elementId);
}
})
/**
* Copyright (c) 2018-present, Ephox, Inc.
*
* This source code is licensed under the Apache 2 license found in the
* LICENSE file in the root directory of this source tree.
*
*/
export type CopyProps<T> = { [P in keyof T]: any };
export interface IPropTypes {
apiKey: string;
cloudChannel: string;
id: string;
init: any;
initialValue: string;
outputFormat: 'html' | 'text';
inline: boolean;
modelEvents: string[] | string;
plugins: string[] | string;
tagName: string;
toolbar: string[] | string;
modelValue: string;
disabled: boolean;
tinymceScriptSrc: string;
}
export const editorProps: CopyProps<IPropTypes> = {
apiKey: String,
cloudChannel: String,
id: String,
init: Object,
initialValue: String,
inline: Boolean,
modelEvents: [String, Array],
plugins: [String, Array],
tagName: String,
toolbar: [String, Array],
modelValue: String,
disabled: Boolean,
tinymceScriptSrc: String,
outputFormat: {
type: String,
validator: (prop: string) => prop === 'html' || prop === 'text'
},
};
// Global compile-time constants
declare var __DEV__: boolean
declare var __BROWSER__: boolean
declare var __CI__: boolean
import { Editor } from './components/Editor';
export default Editor;
import { PropType } from 'vue';
export const basicProps = {
id: {
type: String as PropType<string>,
default: () => {
return `tinymce-${new Date().getTime()}${(Math.random() * 1000).toFixed(0)}`;
},
},
menubar: {
type: String as PropType<string>,
default: 'file edit insert view format table',
......@@ -15,6 +9,10 @@ export const basicProps = {
type: String as PropType<string>,
// default: ''
},
modelValue: {
type: String as PropType<string>,
// default: ''
},
// 高度
height: {
type: [Number, String] as PropType<string | number>,
......
......@@ -6,23 +6,23 @@ const menu: MenuModule = {
path: '/charts',
children: [
{
path: '/apexChart',
path: 'apexChart',
name: 'ApexChart',
},
{
path: '/echarts',
path: 'echarts',
name: 'Echarts',
children: [
{
path: '/map',
path: 'map',
name: '地图',
},
{
path: '/line',
path: 'line',
name: '折线图',
},
{
path: '/pie',
path: 'pie',
name: '饼图',
},
],
......
......@@ -6,16 +6,16 @@ const menu: MenuModule = {
path: '/comp',
children: [
{
path: '/basic',
path: 'basic',
name: '基础组件',
},
{
path: '/countTo',
path: 'countTo',
name: '数字动画',
},
{
path: '/scroll',
path: 'scroll',
name: '滚动组件',
children: [
{
......@@ -33,53 +33,39 @@ const menu: MenuModule = {
],
},
{
path: '/modal',
path: 'modal',
name: '弹窗扩展',
},
{
path: '/drawer',
path: 'drawer',
name: '抽屉扩展',
},
{
path: '/desc',
path: 'desc',
name: '详情组件',
},
{
path: '/verify',
path: 'verify',
name: '验证组件',
children: [
{
path: '/drag',
path: 'drag',
name: '拖拽校验',
},
{
path: '/rotate',
path: 'rotate',
name: '图片还原校验',
},
],
},
{
path: '/qrcode',
path: 'qrcode',
name: '二维码组件',
},
{
path: '/strength-meter',
path: 'strength-meter',
name: '密码强度组件',
},
{
path: '/tinymce',
name: '富文本',
children: [
{
path: '/index',
name: '基础使用',
},
{
path: '/editor',
name: '嵌入form使用',
},
],
},
],
},
};
......
......@@ -6,9 +6,23 @@ const menu: MenuModule = {
path: '/editor',
children: [
{
path: '/markdown',
path: 'markdown',
name: 'markdown编辑器',
},
{
path: 'tinymce',
name: '富文本',
children: [
{
path: 'index',
name: '基础使用',
},
// {
// path: 'editor',
// name: '嵌入form使用',
// },
],
},
],
},
};
......
......@@ -6,23 +6,21 @@ const menu: MenuModule = {
path: '/excel',
children: [
{
path: '/customExport',
path: 'customExport',
name: '选择导出格式',
},
{
path: '/jsonExport',
path: 'jsonExport',
name: 'JSON数据导出',
},
{
path: '/arrayExport',
path: 'arrayExport',
name: 'Array数据导出',
},
{
path: '/importExcel',
path: 'importExcel',
name: '导入',
},
// ],
// },
],
},
};
......
......@@ -6,27 +6,27 @@ const menu: MenuModule = {
path: '/exception',
children: [
{
path: '/404',
path: '404',
name: '404',
},
{
path: '/500',
path: '500',
name: '500',
},
{
path: '/net-work-error',
path: 'net-work-error',
name: '网络错误',
},
{
path: '/page-time-out',
path: 'page-time-out',
name: '页面超时',
},
{
path: '/not-data',
path: 'not-data',
name: '无数据',
},
{
path: '/error-log',
path: 'error-log',
name: '错误日志',
},
],
......
......@@ -6,55 +6,55 @@ const menu: MenuModule = {
path: '/feat',
children: [
{
path: '/icon',
path: 'icon',
name: '图标',
},
{
path: '/tabs',
path: 'tabs',
name: '标签页操作',
},
{
path: '/context-menu',
path: 'context-menu',
name: '右键菜单',
},
{
path: '/click-out-side',
path: 'click-out-side',
name: 'ClickOutSide',
},
{
path: '/img-preview',
path: 'img-preview',
name: '图片预览',
},
{
path: '/i18n',
path: 'i18n',
name: '国际化',
},
{
path: '/copy',
path: 'copy',
name: '剪切板',
},
{
path: '/msg',
path: 'msg',
name: '消息提示',
},
{
path: '/watermark',
path: 'watermark',
name: '水印',
},
{
path: '/full-screen',
path: 'full-screen',
name: '全屏',
},
{
path: '/testTab',
path: 'testTab',
name: '带参Tab',
children: [
{
path: '/id1',
path: 'id1',
name: '带参tab1',
},
{
path: '/id2',
path: 'id2',
name: '带参tab2',
},
],
......
......@@ -6,31 +6,31 @@ const menu: MenuModule = {
name: 'Form',
children: [
{
path: '/basic',
path: 'basic',
name: '基础表单',
},
{
path: '/useForm',
path: 'useForm',
name: 'useForm',
},
{
path: '/refForm',
path: 'refForm',
name: 'RefForm',
},
{
path: '/advancedForm',
path: 'advancedForm',
name: '可收缩表单',
},
{
path: '/ruleForm',
path: 'ruleForm',
name: '表单校验',
},
{
path: '/dynamicForm',
path: 'dynamicForm',
name: '动态表单',
},
{
path: '/customerForm',
path: 'customerForm',
name: '自定义组件',
},
],
......
......@@ -6,15 +6,15 @@ const menu: MenuModule = {
path: '/frame',
children: [
{
path: '/antv',
path: 'antv',
name: 'antVue文档(内嵌)',
},
{
path: '/doc',
path: 'doc',
name: '项目文档(内嵌)',
},
{
path: '/docExternal',
path: 'docExternal',
name: '项目文档(外链)',
},
],
......
......@@ -6,37 +6,37 @@ const menu: MenuModule = {
path: '/permission',
children: [
{
path: '/front',
path: 'front',
name: '基于前端',
children: [
{
path: '/page',
path: 'page',
name: '页面权限',
},
{
path: '/btn',
path: 'btn',
name: '按钮权限',
},
{
path: '/auth-pageA',
path: 'auth-pageA',
name: '权限测试页A',
},
{
path: '/auth-pageB',
path: 'auth-pageB',
name: '权限测试页B',
},
],
},
{
path: '/back',
path: 'back',
name: '基于后台',
children: [
{
path: '/page',
path: 'page',
name: '页面权限',
},
{
path: '/btn',
path: 'btn',
name: '按钮权限',
},
],
......
......@@ -6,59 +6,59 @@ const menu: MenuModule = {
name: 'Table',
children: [
{
path: '/basic',
path: 'basic',
name: '基础表格',
},
{
path: '/treeTable',
path: 'treeTable',
name: '树形表格',
},
{
path: '/fetchTable',
path: 'fetchTable',
name: '远程加载',
},
{
path: '/fixedColumn',
path: 'fixedColumn',
name: '固定列',
},
{
path: '/customerCell',
path: 'customerCell',
name: '自定义列',
},
{
path: '/formTable',
path: 'formTable',
name: '开启搜索区域',
},
{
path: '/useTable',
path: 'useTable',
name: 'UseTable',
},
{
path: '/refTable',
path: 'refTable',
name: 'RefTable',
},
{
path: '/multipleHeader',
path: 'multipleHeader',
name: '多级表头',
},
{
path: '/mergeHeader',
path: 'mergeHeader',
name: '合并单元格',
},
{
path: '/expandTable',
path: 'expandTable',
name: '可展开表格',
},
{
path: '/fixedHeight',
path: 'fixedHeight',
name: '定高/头部自定义',
},
{
path: '/footerTable',
path: 'footerTable',
name: '表尾行合计',
},
{
path: '/editCellTable',
path: 'editCellTable',
name: '可编辑单元格',
},
],
......
......@@ -136,31 +136,5 @@ export default {
title: '密码强度组件',
},
},
{
path: '/tinymce',
name: 'TinymceDemo',
meta: {
title: '富文本',
},
redirect: '/comp/tinymce/index',
children: [
{
path: 'index',
name: 'Tinymce',
component: () => import('/@/views/demo/comp/tinymce/index.vue'),
meta: {
title: '基础使用',
},
},
{
path: 'editor',
name: 'TinymceEditor',
component: () => import('/@/views/demo/comp/tinymce/Editor.vue'),
meta: {
title: '嵌入form使用',
},
},
],
},
],
} as AppRouteModule;
......@@ -23,5 +23,32 @@ export default {
title: 'markdown编辑器',
},
},
{
path: '/tinymce',
name: 'TinymceDemo',
meta: {
title: '富文本',
},
redirect: '/editor/tinymce/index',
children: [
{
path: 'index',
name: 'TinymceBasicDemo',
component: () => import('/@/views/demo/editor/tinymce/index.vue'),
meta: {
title: '基础使用',
},
},
// TODO
// {
// path: 'editor',
// name: 'TinymceFormDemo',
// component: () => import('/@/views/demo/comp/tinymce/Editor.vue'),
// meta: {
// title: '嵌入form使用',
// },
// },
],
},
],
} as AppRouteModule;
......@@ -67,3 +67,7 @@ export const isServer = typeof window === 'undefined';
export function isImageDom(o: Element) {
return o && ['IMAGE', 'IMG'].includes(o.tagName);
}
export const isTextarea = (element: Element | null): element is HTMLTextAreaElement => {
return element !== null && element.tagName.toLowerCase() === 'textarea';
};
......@@ -17,3 +17,11 @@ export function buildUUID(): string {
}
return uuid.replace(/-/g, '');
}
let unique = 0;
export function snowUuid(prefix: string): string {
const time = Date.now();
const random = Math.floor(Math.random() * 1000000000);
unique++;
return prefix + '_' + random + unique + String(time);
}
......@@ -43,7 +43,7 @@
},
];
export default defineComponent({
components: { BasicForm, CollapseContainer, Tinymce },
components: { BasicForm, CollapseContainer },
setup() {
const { createMessage } = useMessage();
......
<template>
<div class="flex p-4">
<Tinymce value="Hello, World!" @change="handleChange" width="100%" />
{{ value }}
<Tinymce v-model="value" @change="handleChange" width="100%" />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { defineComponent, ref } from 'vue';
import { Tinymce } from '/@/components/Tinymce/index';
export default defineComponent({
components: { Tinymce },
setup() {
const value = ref('hello world!');
function handleChange(value: string) {
console.log(value);
}
return { handleChange };
// setTimeout(() => {
// value.value = '1233';
// }, 5000);
return { handleChange, value };
},
});
</script>
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册