perf: review tinymce code

This commit is contained in:
vben
2020-10-22 23:56:33 +08:00
parent 9c02d8ec08
commit f75425d13b
26 changed files with 350 additions and 535 deletions

View File

@@ -1,59 +1,169 @@
<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 }) {
const tinymceContent = computed(() => {
return props.value;
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');
});
function handleChange(value: string) {
emit('change', value);
}
const tinymceContent = computed(() => {
return props.modelValue;
});
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>

View File

@@ -1,9 +1,6 @@
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 };

View File

@@ -0,0 +1,81 @@
const validEvents = [
'onActivate',
'onAddUndo',
'onBeforeAddUndo',
'onBeforeExecCommand',
'onBeforeGetContent',
'onBeforeRenderUI',
'onBeforeSetContent',
'onBeforePaste',
'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',
'onObjectResizeStart',
'onObjectResized',
'onObjectSelected',
'onPaste',
'onPostProcess',
'onPostRender',
'onPreProcess',
'onProgressState',
'onRedo',
'onRemove',
'onReset',
'onSaveContent',
'onSelectionChange',
'onSetAttrib',
'onSetContent',
'onShow',
'onSubmit',
'onUndo',
'onVisualAid',
];
const isValidKey = (key: string) => validEvents.indexOf(key) !== -1;
export const bindHandlers = (initEvent: Event, listeners: any, editor: any): void => {
Object.keys(listeners)
.filter(isValidKey)
.forEach((key: string) => {
const handler = listeners[key];
if (typeof handler === 'function') {
if (key === 'onInit') {
handler(initEvent, editor);
} else {
editor.on(key.substring(2), (e: any) => handler(e, editor));
}
}
});
};

View File

@@ -1,72 +0,0 @@
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
};

View File

@@ -1,151 +0,0 @@
import { ComponentPublicInstance } from 'vue';
const validEvents = [
'onActivate',
'onAddUndo',
'onBeforeAddUndo',
'onBeforeExecCommand',
'onBeforeGetContent',
'onBeforeRenderUI',
'onBeforeSetContent',
'onBeforePaste',
'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',
'onObjectResizeStart',
'onObjectResized',
'onObjectSelected',
'onPaste',
'onPostProcess',
'onPostRender',
'onPreProcess',
'onProgressState',
'onRedo',
'onRemove',
'onReset',
'onSaveContent',
'onSelectionChange',
'onSetAttrib',
'onSetContent',
'onShow',
'onSubmit',
'onUndo',
'onVisualAid'
];
const isValidKey = (key: string) => validEvents.indexOf(key) !== -1;
const bindHandlers = (initEvent: Event, listeners: any, editor: any): void => {
Object.keys(listeners)
.filter(isValidKey)
.forEach((key: string) => {
const handler = listeners[key];
if (typeof handler === 'function') {
if (key === 'onInit') {
handler(initEvent, editor);
} else {
editor.on(key.substring(2), (e: any) => handler(e, editor));
}
}
});
};
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
};

View File

@@ -1,111 +0,0 @@
/**
* 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);
}
})

View File

@@ -1,46 +0,0 @@
/**
* 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'
},
};

View File

@@ -1,4 +0,0 @@
// Global compile-time constants
declare var __DEV__: boolean
declare var __BROWSER__: boolean
declare var __CI__: boolean

View File

@@ -1,3 +0,0 @@
import { Editor } from './components/Editor';
export default Editor;

View File

@@ -1,12 +1,6 @@
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>,