feat(tinymce): add rich editor

This commit is contained in:
jq
2020-10-22 22:30:50 +08:00
parent d8b25b488b
commit c0e4c9e5a5
19 changed files with 1047 additions and 0 deletions

View File

@@ -0,0 +1 @@
export { default as Tinymce } from './src/Editor.vue';

View File

@@ -0,0 +1,90 @@
<template>
<div class="tinymce-container" :style="{ width: containerWidth }">
<tinymce-editor
:id="id"
:init="initOptions"
:modelValue="tinymceContent"
@update:modelValue="handleChange"
:tinymceScriptSrc="tinymceScriptSrc"
></tinymce-editor>
</div>
</template>
<script lang="ts">
import TinymceEditor from './lib'; // TinyMCE vue wrapper
import { defineComponent, computed } from 'vue';
import { basicProps } from './props';
import toolbar from './toolbar';
import plugins from './plugins';
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;
});
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;
return {
selector: `#${id}`,
height: height,
toolbar: toolbar,
menubar: menubar,
plugins: plugins,
// 语言包
language_url: 'resource/tinymce/langs/zh_CN.js',
// 中文
language: 'zh_CN',
};
});
return { containerWidth, initOptions, tinymceContent, handleChange, tinymceScriptSrc };
},
});
</script>
<style lang="less" scoped>
.tinymce-container {
position: relative;
line-height: normal;
.mce-fullscreen {
z-index: 10000;
}
}
.editor-custom-btn-container {
position: absolute;
top: 6px;
right: 6px;
&.fullscreen {
position: fixed;
z-index: 10000;
}
}
.editor-upload-btn {
display: inline-block;
}
textarea {
z-index: -1;
visibility: hidden;
}
</style>

View File

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

@@ -0,0 +1,9 @@
const getGlobal = (): any => (typeof window !== 'undefined' ? window : global);
const getTinymce = () => {
const global = getGlobal();
return global && global.tinymce ? global.tinymce : null;
};
export { getTinymce };

View File

@@ -0,0 +1,151 @@
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

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

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

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

View File

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

View File

@@ -0,0 +1,10 @@
// Any plugins you want to use has to be imported
// Detail plugins list see https://www.tinymce.com/docs/plugins/
// Custom builds see https://www.tinymce.com/download/custom-builds/
// colorpicker/contextmenu/textcolor plugin is now built in to the core editor, please remove it from your editor configuration
const plugins = [
'advlist anchor autolink autosave code codesample directionality emoticons fullscreen hr image imagetools insertdatetime link lists media nonbreaking noneditable pagebreak paste preview print save searchreplace spellchecker tabfocus table template textpattern visualblocks visualchars wordcount',
];
export default plugins;

View File

@@ -0,0 +1,31 @@
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',
},
value: {
type: String as PropType<string>,
// default: ''
},
// 高度
height: {
type: [Number, String] as PropType<string | number>,
required: false,
default: 400,
},
// 宽度
width: {
type: [Number, String] as PropType<string | number>,
required: false,
default: 'auto',
},
};

View File

@@ -0,0 +1,9 @@
// Here is a list of the toolbar
// Detail list see https://www.tinymce.com/docs/advanced/editor-control-identifiers/#toolbarcontrols
const toolbar = [
'searchreplace bold italic underline strikethrough alignleft aligncenter alignright outdent indent blockquote undo redo removeformat subscript superscript code codesample',
'hr bullist numlist link image charmap preview anchor pagebreak insertdatetime media table emoticons forecolor backcolor fullscreen',
];
export default toolbar;

View File

@@ -66,6 +66,20 @@ const menu: MenuModule = {
path: '/strength-meter',
name: '密码强度组件',
},
{
path: '/tinymce',
name: '富文本',
children: [
{
path: '/index',
name: '基础使用',
},
{
path: '/editor',
name: '嵌入form使用',
},
],
},
],
},
};

View File

@@ -136,5 +136,31 @@ 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;

View File

@@ -0,0 +1,58 @@
<template>
<div class="m-4">
<CollapseContainer title="富文本表单">
<BasicForm
:labelWidth="100"
:schemas="schemas"
:actionColOptions="{ span: 24 }"
@submit="handleSubmit"
>
</BasicForm>
</CollapseContainer>
</div>
</template>
<script lang="ts">
import { defineComponent, h } from 'vue';
import { BasicForm, FormSchema } from '/@/components/Form/index';
import { CollapseContainer } from '/@/components/Container/index';
import { useMessage } from '/@/hooks/web/useMessage';
import { Tinymce } from '/@/components/Tinymce/index';
const schemas: FormSchema[] = [
{
field: 'title',
component: 'Input',
label: 'title',
defaultValue: 'defaultValue',
rules: [{ required: true }],
},
{
field: 'tinymce',
component: 'Input',
label: 'tinymce',
defaultValue: 'defaultValue',
rules: [{ required: true }],
render: ({ model, field }) => {
return h(Tinymce, {
value: model[field],
onChange: (value: string) => {
model[field] = value;
},
});
},
},
];
export default defineComponent({
components: { BasicForm, CollapseContainer, Tinymce },
setup() {
const { createMessage } = useMessage();
return {
schemas,
handleSubmit: (values: any) => {
createMessage.success('click search,values:' + JSON.stringify(values));
},
};
},
});
</script>

View File

@@ -0,0 +1,19 @@
<template>
<div class="flex p-4">
<Tinymce value="Hello, World!" @change="handleChange" width="100%" />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { Tinymce } from '/@/components/Tinymce/index';
export default defineComponent({
components: { Tinymce },
setup() {
function handleChange(value: string) {
console.log(value);
}
return { handleChange };
},
});
</script>