The most powerful tinymce rich text editor integrated in the Vue3 project

Foreword

tinymce is currently recognized as the best rich text editor. This article will explain in detail how to integrate tinymce in the vue3 project and encapsulate tinymce into a component.

final effect

Text

1. Install dependencies,

Here we use tinymce5, and the latest version is tinymce6. There will be differences in plug-ins between different versions

npm install [email protected]

2. Install the Chinese package

We create the folder tinymce in the public/resource/ directory of the vue3 project, and create the langs folder under tinymce to store the language Chinese package.

We will download this Chinese package https://gitee.com/shuiche/tinymce-vue3/blob/master/langs/zh_CN.js and put it in the langs folder

3. Migrate ui skin files

We find the skins folder in node_modules/tinymce and copy it to public/resource/tinymce/

4. Encapsulated Component

Create a new Tinymce folder under src/components/. Create 3 new files in the Tinymce/ folder: helper.js, tinymce.js, Tinymce.vue

The contents are:

helper.js

// ==========helper.js==========
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) => validEvents.indexOf(key) !== -1

export const bindHandlers = (initEvent, listeners, editor) => {
  Object.keys(listeners)
    .filter(isValidKey)
    .forEach((key) => {
      const handler = listeners[key]
      if (typeof handler === 'function') {
        if (key === 'onInit') {
          handler(initEvent, editor)
        } else {
          editor.on(key.substring(2), (e) => handler(e, editor))
        }
      }
    })
}

tinymce.js

// ==========tinymce.js==========
// Any plugins you want to setting 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

export const plugins = [
  'advlist anchor autolink autosave code codesample directionality fullscreen hr insertdatetime link lists media nonbreaking noneditable pagebreak paste preview print save searchreplace spellchecker tabfocus template textpattern visualblocks visualchars wordcount'
]

export const toolbar = [
  'fontsizeselect lineheight searchreplace bold italic underline strikethrough alignleft aligncenter alignright outdent indent blockquote undo redo removeformat subscript superscript code codesample',
  'hr bullist numlist link preview anchor pagebreak insertdatetime media forecolor backcolor fullscreen'
]
// ==========Tinymce.vue==========
<template>
    <div class="prefixCls" :style="{ width: containerWidth }">
        <textarea
            :id="tinymceId"
            ref="elRef"
            :style="{ visibility: 'hidden' }"
        ></textarea>
    </div>
</template>

<script setup>
import tinymce from 'tinymce/tinymce'
import 'tinymce/themes/silver'
import 'tinymce/icons/default/icons'
import 'tinymce/plugins/advlist'
import 'tinymce/plugins/anchor'
import 'tinymce/plugins/autolink'
import 'tinymce/plugins/autosave'
import 'tinymce/plugins/code'
import 'tinymce/plugins/codesample'
import 'tinymce/plugins/directionality'
import 'tinymce/plugins/fullscreen'
import 'tinymce/plugins/hr'
import 'tinymce/plugins/insertdatetime'
import 'tinymce/plugins/link'
import 'tinymce/plugins/lists'
import 'tinymce/plugins/media'
import 'tinymce/plugins/nonbreaking'
import 'tinymce/plugins/noneditable'
import 'tinymce/plugins/pagebreak'
import 'tinymce/plugins/paste'
import 'tinymce/plugins/preview'
import 'tinymce/plugins/print'
import 'tinymce/plugins/save'
import 'tinymce/plugins/searchreplace'
import 'tinymce/plugins/spellchecker'
import 'tinymce/plugins/tabfocus'
import 'tinymce/plugins/template'
import 'tinymce/plugins/textpattern'
import 'tinymce/plugins/visualblocks'
import 'tinymce/plugins/visualchars'
import 'tinymce/plugins/wordcount'
// import 'tinymce/plugins/table';

import { computed, nextTick, ref, unref, watch, onDeactivated, onBeforeUnmount, defineProps, defineEmits, getCurrentInstance } from 'vue'
import { toolbar, plugins } from './tinymce'
import { buildShortUUID } from '@/utils/uuid'
import { bindHandlers } from './helper'
import { onMountedOrActivated } from '@/hooks/core/onMountedOrActivated'
import { isNumber } from '@/utils/is'

const props = defineProps({
    options: {
        type: Object,
        default: () => {}
    },
    value: {
        type: String
    },

    toolbar: {
        type: Array,
        default: toolbar
    },
    plugins: {
        type: Array,
        default: plugins
    },
    modelValue: {
        type: String
    },
    height: {
        type: [Number, String],
        required: false,
        default: 400
    },
    width: {
        type: [Number, String],
        required: false,
        default: 'auto'
    },
    showImageUpload: {
        type: Boolean,
        default: true
    }
})
const emits = defineEmits(['change', 'update:modelValue', 'inited', 'init-error'])
const { attrs } = getCurrentInstance()
const tinymceId = ref(buildShortUUID('tiny-vue'))
const containerWidth = computed(() => {
    const width = props.width
    if (isNumber(width)) {
        return `${width}px`
    }
    return width
})
const editorRef = ref(null)
const fullscreen = ref(false)
const elRef = ref(null)
const tinymceContent = computed(() => props.modelValue)

const initOptions = computed(() => {
    const { height, options, toolbar, plugins } = props
    const publicPath = '/'
    return {
        selector: `#${unref(tinymceId)}`,
        height,
        toolbar,
        menubar: 'file edit insert view format table',
        plugins,
        language_url: '/resource/tinymce/langs/zh_CN.js',
        language: 'zh_CN',
        branding: false,
        default_link_target: '_blank',
        link_title: false,
        object_resizing: false,
        auto_focus: true,
        skin: 'oxide',
        skin_url: '/resource/tinymce/skins/ui/oxide',
        content_css: '/resource/tinymce/skins/ui/oxide/content.min.css',
        ...options,
        setup: (editor) => {
            editorRef.value = editor
            editor.on('init', (e) => initSetup(e))
        }
    }
})

const disabled = computed(() => {
    const { options } = props
    const getdDisabled = options & amp; & amp; Reflect.get(options, 'readonly')
    const editor = unref(editorRef)
    if (editor) {
        editor.setMode(getdDisabled ? 'readonly' : 'design')
    }
    return getdDisabled  false
})

watch(
    () => attrs.disabled,
    () => {
        const editor = unref(editorRef)
        if (!editor) {
            return
        }
        editor.setMode(attrs.disabled ? 'readonly' : 'design')
    }
)

onMountedOrActivated(() => {
    if (!initOptions.value.inline) {
        tinymceId.value = buildShortUUID('tiny-vue')
    }
    nextTick(() => {
        setTimeout(() => {
            initEditor()
        }, 30)
    })
})

onBeforeUnmount(() => {
    destroy()
})

onDeactivated(() => {
    destroy()
})

function destroy () {
    if (tinymce !== null) {
        // tinymce?.remove?.(unref(initOptions).selector!);
    }
}

function initSetup (e) {
    const editor = unref(editorRef)
    if (!editor) {
        return
    }
    const value = props.modelValue || ''

    editor.setContent(value)
    bindModelHandlers(editor)
    bindHandlers(e, attrs, unref(editorRef))
}

function initEditor () {
    const el = unref(elRef)
    if (el) {
        el.style.visibility = ''
    }
    tinymce
        .init(unref(initOptions))
        .then((editor) => {
            emits('inited', editor)
        })
        .catch((err) => {
            emits('init-error', err)
        })
}

function setValue (editor, val, prevVal) {
    if (
        editor & amp; & amp;
        typeof val === 'string' & amp; & amp;
        val !== prevVal & amp; & amp;
        val !== editor.getContent({ format: attrs.outputFormat })
    ) {
        editor.setContent(val)
    }
}

function bindModelHandlers (editor) {
    const modelEvents = attrs.modelEvents ? attrs.modelEvents : null
    const normalizedEvents = Array.isArray(modelEvents) ? modelEvents.join(' ') : modelEvents

    watch(
        () => props.modelValue,
        (val, prevVal) => {
            setValue(editor, val, prevVal)
        }
    )

    watch(
        () => props.value,
        (val, prevVal) => {
            setValue(editor, val, prevVal)
        },
        {
            immediate: true
        }
    )

    editor.on(normalizedEvents || 'change keyup undo redo', () => {
        const content = editor.getContent({ format: attrs.outputFormat })
        emits('update:modelValue', content)
        emits('change', content)
    })

    editor.on('FullscreenStateChanged', (e) => {
        fullscreen.value = e.state
    })
}

function handleImageUploading (name) {
    const editor = unref(editorRef)
    if (!editor) {
        return
    }
    editor.execCommand('mceInsertContent', false, getUploadingImgName(name))
    const content = editor?.getContent()  ''
    setValue(editor, content)
}

function handleDone (name, url) {
    const editor = unref(editorRef)
    if (!editor) {
        return
    }
    const content = editor?.getContent()  ''
    const val = content?.replace(getUploadingImgName(name), `<img src="${url}"/>`)  ''
    setValue(editor, val)
}

function getUploadingImgName (name) {
    return `[uploading:${name}]`
}
</script>

<style lang="scss" scoped>
.prefixCls{
    position: relative;
    line-height: normal;
}
textarea {
    z-index: -1;
    visibility: hidden;
}
</style>

Two external functions and a hook are introduced in Tinymce. The specific contents are:

// ==== isNumber function ====
const toString = Object.prototype.toString
export function is (val, type) {
  return toString.call(val) === `[object ${type}]`
}
export function isNumber (val) {
  return is(val, 'Number')
}


// ==== buildShortUUID function ====
export function buildShortUUID (prefix = '') {
    const time = Date.now()
    const random = Math.floor(Math.random() * 1000000000)
    unique++
    return prefix + '_' + random + unique + String(time)
}


// ==== onMountedOrActivated hook====
import { nextTick, onMounted, onActivated } from 'vue'
export function onMountedOrActivated (hook) {
  let mounted
  onMounted(() => {
    hook()
    nextTick(() => {
      mounted = true
    })
  })
  onActivated(() => {
    if (mounted) {
      hook()
    }
  })
}

Use components

It is very simple to use, we can use v-model and @change.

<Tinymce v-model="content" @change="handleChange" width="100%" />

//......

let content = ref('')
function handleChange (item) {
    console.log('change', item)
}

Postscript

If you have any questions, you can leave a message to communicate