241 lines
7.5 KiB
TypeScript
241 lines
7.5 KiB
TypeScript
/**
|
||
* @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved.
|
||
* For licensing, see LICENSE.md.
|
||
*/
|
||
|
||
/* global window, console */
|
||
|
||
/* Source: https://github.com/ckeditor/ckeditor5-vue/blob/master/src/ckeditor.ts */
|
||
|
||
import { debounce } from 'lodash-es'
|
||
import { defineComponent, h, markRaw, type PropType } from 'vue'
|
||
import type { Editor, EditorConfig } from '@ckeditor/ckeditor5-core'
|
||
|
||
const SAMPLE_READ_ONLY_LOCK_ID = 'Integration Sample'
|
||
const INPUT_EVENT_DEBOUNCE_WAIT = 300
|
||
|
||
export interface CKEditorComponentData {
|
||
instance: Editor | null
|
||
lastEditorData: string | null
|
||
}
|
||
|
||
export default defineComponent({
|
||
name: 'Ckeditor',
|
||
|
||
model: {
|
||
prop: 'modelValue',
|
||
event: 'update:modelValue'
|
||
},
|
||
|
||
props: {
|
||
editor: {
|
||
type: Function as unknown as PropType<{
|
||
create(...args: any): Promise<Editor>
|
||
}>,
|
||
required: true
|
||
},
|
||
config: {
|
||
type: Object as PropType<EditorConfig>,
|
||
default: () => ({})
|
||
},
|
||
modelValue: {
|
||
type: String,
|
||
default: ''
|
||
},
|
||
tagName: {
|
||
type: String,
|
||
default: 'div'
|
||
},
|
||
disabled: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
disableTwoWayDataBinding: {
|
||
type: Boolean,
|
||
default: false
|
||
}
|
||
},
|
||
|
||
emits: [
|
||
'ready',
|
||
'destroy',
|
||
'blur',
|
||
'focus',
|
||
'input',
|
||
'update:modelValue',
|
||
'click',
|
||
'editorReady',
|
||
'contextedLinkAutocomplete',
|
||
'contextedKeypress'
|
||
],
|
||
|
||
data(): CKEditorComponentData {
|
||
return {
|
||
// Don't define it in #props because it produces a warning.
|
||
// https://v3.vuejs.org/guide/component-props.html#one-way-data-flow
|
||
instance: null,
|
||
lastEditorData: null
|
||
}
|
||
},
|
||
|
||
watch: {
|
||
modelValue(value) {
|
||
// Synchronize changes of #modelValue. There are two sources of changes:
|
||
//
|
||
// External modelValue change ──────╮
|
||
// ╰─────> ┏━━━━━━━━━━━┓
|
||
// ┃ Component ┃
|
||
// ╭─────> ┗━━━━━━━━━━━┛
|
||
// Internal data change ──────╯
|
||
// (typing, commands, collaboration)
|
||
//
|
||
// Case 1: If the change was external (via props), the editor data must be synced with
|
||
// the component using instance#setData() and it is OK to destroy the selection.
|
||
//
|
||
// Case 2: If the change is the result of internal data change, the #modelValue is the
|
||
// same as this.lastEditorData, which has been cached on #change:data. If we called
|
||
// instance#setData() at this point, that would demolish the selection.
|
||
//
|
||
// To limit the number of instance#setData() which is time-consuming when there is a
|
||
// lot of data we make sure:
|
||
// * the new modelValue is at least different than the old modelValue (Case 1.)
|
||
// * the new modelValue is different than the last internal instance state (Case 2.)
|
||
//
|
||
// See: https://github.com/ckeditor/ckeditor5-vue/issues/42.
|
||
if (this.instance && value !== this.lastEditorData) {
|
||
this.instance.data.set(value)
|
||
}
|
||
},
|
||
|
||
// Synchronize changes of #disabled.
|
||
disabled(readOnlyMode) {
|
||
if (readOnlyMode) {
|
||
this.instance!.enableReadOnlyMode(SAMPLE_READ_ONLY_LOCK_ID)
|
||
} else {
|
||
this.instance!.disableReadOnlyMode(SAMPLE_READ_ONLY_LOCK_ID)
|
||
}
|
||
}
|
||
},
|
||
|
||
created() {
|
||
const { CKEDITOR_VERSION } = window
|
||
|
||
if (CKEDITOR_VERSION) {
|
||
const [major] = CKEDITOR_VERSION.split('.').map(Number)
|
||
|
||
if (major < 37) {
|
||
console.warn('The <CKEditor> component requires using CKEditor 5 in version 37 or higher.')
|
||
}
|
||
} else {
|
||
console.warn('Cannot find the "CKEDITOR_VERSION" in the "window" scope.')
|
||
}
|
||
},
|
||
|
||
mounted() {
|
||
// Clone the config first so it never gets mutated (across multiple editor instances).
|
||
// https://github.com/ckeditor/ckeditor5-vue/issues/101
|
||
const editorConfig: EditorConfig = Object.assign({}, this.config)
|
||
|
||
if (this.modelValue) {
|
||
editorConfig.initialData = this.modelValue
|
||
}
|
||
|
||
this.editor
|
||
.create(this.$el, editorConfig)
|
||
.then((editor) => {
|
||
// Save the reference to the instance for further use.
|
||
this.instance = markRaw(editor)
|
||
|
||
this.setUpEditorEvents()
|
||
|
||
// Synchronize the editor content. The #modelValue may change while the editor is being created, so the editor content has
|
||
// to be synchronized with these potential changes as soon as it is ready.
|
||
if (this.modelValue !== editorConfig.initialData) {
|
||
editor.data.set(this.modelValue)
|
||
}
|
||
|
||
// Set initial disabled state.
|
||
if (this.disabled) {
|
||
editor.enableReadOnlyMode(SAMPLE_READ_ONLY_LOCK_ID)
|
||
}
|
||
|
||
// Let the world know the editor is ready.
|
||
this.$emit('ready', editor)
|
||
})
|
||
.catch((error) => {
|
||
console.error(error)
|
||
})
|
||
},
|
||
|
||
beforeUnmount() {
|
||
if (this.instance) {
|
||
this.instance.destroy()
|
||
this.instance = null
|
||
}
|
||
|
||
// Note: By the time the editor is destroyed (promise resolved, editor#destroy fired)
|
||
// the Vue component will not be able to emit any longer. So emitting #destroy a bit earlier.
|
||
this.$emit('destroy', this.instance)
|
||
},
|
||
|
||
methods: {
|
||
setUpEditorEvents() {
|
||
const editor = this.instance!
|
||
this.$emit('editorReady', editor)
|
||
|
||
// Use the leading edge so the first event in the series is emitted immediately.
|
||
// Failing to do so leads to race conditions, for instance, when the component modelValue
|
||
// is set twice in a time span shorter than the debounce time.
|
||
// See https://github.com/ckeditor/ckeditor5-vue/issues/149.
|
||
const emitDebouncedInputEvent = debounce(
|
||
(evt) => {
|
||
if (this.disableTwoWayDataBinding) {
|
||
return
|
||
}
|
||
|
||
// Cache the last editor data. This kind of data is a result of typing,
|
||
// editor command execution, collaborative changes to the document, etc.
|
||
// This data is compared when the component modelValue changes in a 2-way binding.
|
||
const data = (this.lastEditorData = editor.data.get())
|
||
|
||
// The compatibility with the v-model and general Vue.js concept of input–like components.
|
||
this.$emit('update:modelValue', data, evt, editor)
|
||
this.$emit('input', data, evt, editor)
|
||
},
|
||
INPUT_EVENT_DEBOUNCE_WAIT,
|
||
{ leading: true }
|
||
)
|
||
|
||
// Debounce emitting the #input event. When data is huge, instance#getData()
|
||
// takes a lot of time to execute on every single key press and ruins the UX.
|
||
//
|
||
// See: https://github.com/ckeditor/ckeditor5-vue/issues/42
|
||
editor.model.document.on('change:data', emitDebouncedInputEvent)
|
||
|
||
editor.editing.view.document.on('focus', (evt) => {
|
||
this.$emit('focus', evt, editor)
|
||
})
|
||
|
||
editor.editing.view.document.on('blur', (evt) => {
|
||
this.$emit('blur', evt, editor)
|
||
})
|
||
|
||
// Custom event
|
||
editor.editing.view.document.on('click', (evt, data) => {
|
||
this.$emit('click', { evt, data }, editor)
|
||
})
|
||
|
||
editor.model.document.on('contextedLinkAutocomplete', (_, data) => {
|
||
this.$emit('contextedLinkAutocomplete', data)
|
||
})
|
||
editor.model.document.on('contextedKeypress', (_, eventData) => {
|
||
this.$emit('contextedKeypress', eventData)
|
||
})
|
||
}
|
||
},
|
||
|
||
render() {
|
||
return h(this.tagName)
|
||
}
|
||
})
|