Vue3項目中集成最強的tinymce富文本編輯器

前言

tinymce是目前公認的最好的富文本編輯器。本篇文章將詳細說明在vue3項目中如何集成tinymce,並將 tinymce封裝成組件使用

最終效果

正文

1. 安裝依賴,

這裏我們使用了tinymce5,最新已經到tinymce6了。不同版本之間插件上會有差異

npm install [email protected]

2. 安裝漢化包

我們在 vue3 項目的public/resource/ 目錄下創建文件夾 tinymce,在tinymce下再創建 langs文件夾用來存放語言漢化包

我們將 這個中文包https://gitee.com/shuiche/tinymce-vue3/blob/master/langs/zh_CN.js下載後放到langs文件夾下

3. 遷移ui皮膚文件

我們在 node_modules/tinymce 中找到 skins 文件夾,將其複製到 public/resource/tinymce/ 裏

4. 封裝組件

在src/components/ 下新建 Tinymce 文件夾。在 Tinymce/ 文件夾下新建3個文件: helper.js , tinymce.js, Tinymce.vue

內容分別是:

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 && 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(() => {
    destory()
})

onDeactivated(() => {
    destory()
})

function destory () {
    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 &&
        typeof val === 'string' &&
        val !== prevVal &&
        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>

在Tinymce中引入了兩個外部函數和一個hook,具體內容是:

// ==== isNumber  函數====
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  函數====
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()
    }
  })
}

使用組件

使用時非常簡單,我們用 v-model 和 @change 即可使用。

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

// ......

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

後記

如有問題,可以留言溝通

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章