提交 c0e4c9e5 作者: jq

feat(tinymce): add rich editor

上级 d8b25b48
......@@ -5,3 +5,5 @@
**/*.svg
**/*.sh
/public/*
/dist/*
/public/*
export { default as Tinymce } from './src/Editor.vue';
<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>
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
const getGlobal = (): any => (typeof window !== 'undefined' ? window : global);
const getTinymce = () => {
const global = getGlobal();
return global && global.tinymce ? global.tinymce : null;
};
export { getTinymce };
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
};
\ 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;
// 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;
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',
},
};
// 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;
......@@ -66,6 +66,20 @@ const menu: MenuModule = {
path: '/strength-meter',
name: '密码强度组件',
},
{
path: '/tinymce',
name: '富文本',
children: [
{
path: '/index',
name: '基础使用',
},
{
path: '/editor',
name: '嵌入form使用',
},
],
},
],
},
};
......
......@@ -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;
<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>
<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>
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论