Bootstrap

Vue3项目集成CKEditor富文本编辑器,支持代码语法高亮显示

前言

在一些后台管理系统尤其像博客、社区、新闻和广告等内容管理平台中,大都会有集成富文本编辑器的需求。从0到1开发一个富文本编辑器还是很复杂也很耗时的,不是大咖级的程序员还不一定搞得出来。好在已经有了很多开源的富文本编辑器可以让我们直接集成到项目中以组件的方式使用。目前主流的开源富文本编辑器主要有:CKEditorTinyMCEQuillwangEditorUEditorKindeditor 等。

笔者发现 CKEditor 是功能最丰富也是文档最齐全的一款富文本编辑器,而且最新版的CKEditor5还新增了AI助理插件的功能,可以只能生成用户需要的内容。但是一些复杂的功能需要自己添加一些插件才能实现,文档也是全英文版的,有一定的使用门槛。只是无奈自己已经投入了不少时间学习了如何使用CKEditor5并把demo跑了起来,如果要改用其富文本编辑器还得去看对应的官方文档,时间成本会更高。

于是笔者就打算把CKEditor5的常用功能用法一次研究透,今天就出一篇关于如何扩展CKEditor插件并集成到Vue项目中的文章,希望对有这方面需求的读者朋友们能有帮助。

CKEditor 简介

CKEditor是一个开源的富文本编辑器,它允许用户在网页上进行所见即所得的编辑,它具有以下特点:

  • 功能丰富:CKEditor提供了多种功能,包括格式化文本、插入图片和视频、创建表格、添加超链接等。它还支持自定义样式和工具栏按钮,以满足不同需求。
  • 可扩展性:CKEditor可以通过插件进行扩展,用户可以根据自己的需求添加或删除插件。这样可以定制编辑器的功能,使其更符合特定的应用场景。
  • 跨平台支持:CKEditor可以在各种操作系统和浏览器上运行,包括Windows、Mac、Linux以及常见的主流浏览器如Chrome、Firefox、Safari等。
  • 易于集成:CKEditor提供了简单易用的API,可以方便地将编辑器集成到现有的项目中。它还提供了丰富的事件和回调函数,可以实现与其他组件的交互。
  • 多语言支持:CKEditor支持多种语言,用户可以选择编辑器界面和提示信息的语言。这使得CKEditor成为国际化项目的理想选择。

总的来说,CKEditor是一款强大而灵活的富文本编辑器,适用于各种Web应用开发场景。它提供了丰富的功能和可扩展性,并具有跨平台、易于集成和多语言支持等特点。

CKEditor 目前已更新到5版本来了,官方文档地址:https://ckeditor.com/docs/ckeditor5/latest/index.html

CKEditor5 提供了 ClassicEditorInlineEditorBalloonEditor 等几个 常用的富文本编辑器。其中以 经典编辑器 ClassicEditor 用得最多。官方提供的ClassicEditor 富文本编辑器长下面这样:
editor_classic
但是我们发现 一些重要的功能如段落对齐方式、字体大小、字体颜色和插入源码等编辑功能,官方提供的这款ClassicEditor 富文本编辑器并没有,那怎么办呢?

答案是需要我们开发者在editor5-build-classic源码的基础上通过添加我们需要的插件并增加相应的配置后自定义构建出我们需要的 ClassicEditor 组件才能实现我们预期的富文本编辑器功能。至于如何构建自定义的ClassicEditor 编辑器我们后面会讲到。

CkEditor 集成 Vue3

安装依赖包

# npm 安装
npm install --save @ckeditor/ckeditor5-vue @ckeditor/ckeditor5-build-classic
# yarn 安装
yarn add @ckeditor/ckeditor5-vue @ckeditor/ckeditor5-build-classic

主要包括@ckeditor/ckeditor5-vue@ckeditor/ckeditor5-build-classic 两个依赖包

然后通过ES6模块导入的方式在Vue项目中安装并启用CKEditor组件

ES6 模块导入
main.js 文件中创建App应用,安装CKEditor插件并将应用挂载到Dom节点下

import { createApp } from 'vue';
import CKEditor from '@ckeditor/ckeditor5-vue';

createApp( { /* options */ } ).use( CKEditor ).mount( /* DOM element */ );

页面组件中使用 CKEditor 组件

<template>
    <div id="app">
        <ckeditor :editor="editor" v-model="editorData" :config="editorConfig"></ckeditor>
    </div>
</template>

<script>
    import ClassicEditor from '@ckeditor/ckeditor5-build-classic';

    export default {
        name: 'app',
        data() {
            return {
                editor: ClassicEditor,
                editorData: '<p>Content of the editor.</p>',
                editorConfig: {
                    // The configuration of the editor.
                }
            };
        }
    }
</script>

上面这种方式在实践过程中我们发现,使用ClassicEditor编辑器后在editConfig变量中配置的自定义安装的扩展插件很配置项都无法生效。查了CKEditor5官方文档发现必须使用在线自定义构建源码构建的方式才能让自定义配置的扩展插件功能生效。CKEditor5在线构自定义建链接如下,感兴趣的同学可以自己去尝试。

https://ckeditor.com/ckeditor-5/online-builder/

实践过程中在线自定义构建的方式构建的富文本编辑器,笔者在本地运行时发现有一些功能都不生效,比如heading标题栏和字体颜色和字体背景色都不生效,于是转而采用源码构建的方式。

源码本地构建

克隆源码并安装依赖包

这种方式需要先把一种编辑器CKEditor的源码克隆到本地磁盘,然后在源码的基础上通过添加自己想要的扩展插件并添加配置项, 最后打包发布到npm仓库就可以在自己的Vue项目中使用定制化的CKEditor了。

在本地电脑D盘创建一个github文件夹,进入此文件夹后鼠标右键->Open Git Bash Here打开一个命令控制台,然后执行如下命令把ClassicEditor编辑器的源码克隆下来。

git clone -b stable https://github.com/ckeditor/ckeditor5-build-classic.git #从github克隆ckeditor5-build-classic源码并切换到stable分支
cd ckeditor5-build-classic  #切换到 ckeditor5-build-classic 目录

然后执行如下命令安装ckeditor5-build-classic项目中的依赖包

# yarn 安装依赖包
yarn install
# npm 安装
npm install

然后在添加一些我们需要的插件安装包,如AlignmentHighlightFontCode-Block

# yarn 安装
yarn add -D @ckeditor/[email protected] @ckeditor/[email protected] @ckeditor/[email protected] @ckeditor/[email protected] 
# npm 安装
npm install -save-dev @ckeditor/[email protected] @ckeditor/[email protected] @ckeditor/[email protected] @ckeditor/[email protected] 

注意因为ckeditor5-build-classic项目stable版本源码package.json 文件中的version字段代表的版本号为19.0.0版本, 因此我们安装的扩展ckeditor5-xx插件也必须与ckeditor5-build-classic的版本号保持一致,否则打开sample/index.html页面查看富文本编辑器时会报plugincollection-plugin-name-conflict 错误,富文本不可用,原因就是安装的ckeditor5扩展插件与主版本不一致导致使用富文本编辑器运行时出错。

修改配置文件

主要是修改 src/editor.js 文件中的ClassicEditor.builtinPluginsClassicEditor.defaultConfig 两个变量,

向第一个变量中添加扩展插件,向第二个变量中添加配置项。

export default class ClassicEditor extends ClassicEditorBase {}

// Plugins to include in the build.
ClassicEditor.builtinPlugins = [
	Essentials,
	UploadAdapter,
	Autoformat,
	Bold,
	Font,
	Italic,
	BlockQuote,
	CKFinder,
	EasyImage,
	Heading,
	Image,
	ImageCaption,
	ImageStyle,
	ImageToolbar,
	ImageUpload,
	Indent,
	Link,
	List,
	MediaEmbed,
	Paragraph,
	PasteFromOffice,
	Table,
	TableToolbar,
	TextTransformation,
	Alignment,
	CodeBlock,
	Highlight
	// SimpleUploadAdapter
];

// Editor configuration.
ClassicEditor.defaultConfig = {
	alignment: {
		options: [ 'left', 'right', 'center' ]
	},
	toolbar: {
		items: [
			'heading',
			'|',
			'alignment',
			'bold',
			'italic',
			'highlight',
			'fontSize',
			'fontFamily',
			'fontColor',
			'fontBackgroundColor',
			'link',
			'bulletedList',
			'numberedList',
			'|',
			'indent',
			'outdent',
			'|',
			'imageUpload',
			'blockQuote',
			'codeBlock',
			'insertTable',
			'mediaEmbed',
			'undo',
			'redo'
		]
	},
	heading: {
		options: [
			{ model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' },
			{ model: 'heading1', view: 'h2', title: 'Heading 1', class: 'ck-heading_heading1' },
			{ model: 'heading2', view: 'h3', title: 'Heading 2', class: 'ck-heading_heading2' },
			{ model: 'heading3', view: 'h4', title: 'Heading 3', class: 'ck-heading_heading3' }
		]
	},
	fontFamily: {
		options: [
			'default',
			'Arial, Helvetica, sans-serif',
			'Courier New, Courier, monospace',
			'Georgia, serif',
			'Lucida Sans Unicode, Lucida Grande, sans-serif',
			'Tahoma, Geneva, sans-serif',
			'Times New Roman, Times, serif',
			'Trebuchet MS, Helvetica, sans-serif',
			'Verdana, Geneva, sans-serif'
		],
		supportAllValues: true
	},
	fontSize: {
		options: [ 10, 12, 14, 16, 18, 20, 24, 28, 32, 36 ],
		supportAllValues: true
	},
	fontColor: {
      colorPicker: { format: 'hex' },
	  colors: [
		{
			color: '#FF0000',
			label: 'Red'
		},
		{
			color: '#FFFF00',
			label: 'Yellow'
		},
		{
			color: '#0000FF',
			label: 'Blue'
		},
		{
			color: '#008000',
			label: 'Green'
		},
		// 省略其他颜色配置
	  ],
	  columns: 10,
	  documentColors: 24
	},
	fontBackgroundColor: {
		colorPicker: 'hex',
		colors: [
			{
				color: '#FF0000',
				label: 'Red'
			},
			{
				color: '#FFFF00',
				label: 'Yellow'
			},
			{
				color: '#0000FF',
				label: 'Blue'
			},
			{
				color: '#008000',
				label: 'Green'
			},
			// 其他颜色配置省略
		],
		columns: 10,
	    documentColors: 24
	},
	image: {
		toolbar: [
			'imageStyle:full',
			'imageStyle:side',
			'|',
			'imageTextAlternative'
		],
		upload: {
			types: [ 'png', 'jpg', 'jpeg' ]
		}
	},
	table: {
		contentToolbar: [
			'tableColumn',
			'tableRow',
			'mergeTableCells'
		]
	},
	// This value must be kept in sync with the language defined in webpack.config.js.
	language: 'en',
	codeBlock: {
		languages: [
			{ language: 'plaintext', label: 'Plain text', class: 'plain-text' }, // The default language.
			{ language: 'c', label: 'C', class: 'c' },
			{ language: 'cs', label: 'C#' },
			{ language: 'cpp', label: 'C++' },
			{ language: 'css', label: 'CSS' },
			{ language: 'diff', label: 'Diff' },
			{ language: 'html', label: 'HTML' },
			{ language: 'java', label: 'Java', class: 'java' },
			{ language: 'javascript', label: 'JavaScript', class: 'js javascript' },
			{ language: 'php', label: 'PHP' },
			{ language: 'python', label: 'Python' },
			{ language: 'ruby', label: 'Ruby' },
			{ language: 'typescript', label: 'TypeScript' },
			{ language: 'xml', label: 'XML' }
		]
	},
	highlight: {
		options: [
			{
				model: 'yellowMarker',
				class: 'marker-yellow',
				title: 'Yellow marker',
				color: 'var(--ck-highlight-marker-yellow)',
				type: 'marker'
			},
			{
				model: 'greenMarker',
				class: 'marker-green',
				title: 'Green marker',
				color: 'var(--ck-highlight-marker-green)',
				type: 'marker'
			},
			// 省略其他高亮颜色配置
		]
	}
	// simpleUpload: {
	// 	uploadUrl: '',
	// 	withCredentials: true,
	// 	headers: {
	// 		'X-CSRF-TOKEN': 'CSRF-Token',
	// 		'Authorization': ''
	// 	}
	// }
};

ClassicEditor.defaultConfig配置项里笔者之所以不用simpleUpload配置,是因为这样做就必须把uploadUrl给写死了,而我们自定义扩展构建的ClassicEditor最终是作为一个组件去使用,用户如果用了我们这个编辑器,那就必须按照这里写死的uploadUrl定义自己的图片上传接口请求url,而且如果图片上传接口要带上Authorization请求头认证信息的话,我们在这里却是拿不到用户登录成功后的认证信息的。

所以我们还是自定义一个ImageUploadAdapter,在构造函数中传入uploadUrlAuthorization请求头认证token ,并在CKEditor组件的ready事件回调方法中注册上ImageUploadAdapter适配器。

修改好 src/editor.js 文件中的源码后就可以回到ckeditor5-build-classic项目的根目录下执行构建源码命令并查看效果了。

yarn run build

构建成功后命令控制台中会出现如下所示的信息:

Entrypoint main [big] = ckeditor.js ckeditor.js.map
  [9] (webpack)/buildin/harmony-module.js 573 bytes {0} [built]
 [10] (webpack)/buildin/global.js 472 bytes {0} [built]
[106] ./src/ckeditor.js + 636 modules 3.23 MiB {0} [built]
      | ./src/ckeditor.js 23.7 KiB [built]
      |     + 636 hidden modules
    + 564 hidden modules
Done in 5.99s.

然后进入sample目录使用Google浏览器打开index.html文件即可看到我们定制的富文本编辑器效果

CKEditorSample
可以看到在我们ClassicEditor的基础上定制的Editor具备了对齐方式、高亮、字体家族、字体颜色和字体背景以及代码块等ClassicEditor编辑器不具备的扩展功能。

新建FullClassicEditor项目并发布到 npm 仓库
新建FullClassicEditor项目的目的是为了把我们以ckeditor5-build-classic项目源码为基础定制的编辑器能以一个独立包的形式发布到npm仓库中去。

同样在D盘github目录下新建一个FullClassicEditor文件夹,然后将ckeditor5-build-classic项目build目录下的文件全部拷贝到FullClassicEditor目录下。鼠标右键->Open Git Bash Here,打开命令控制台后执行npm init命令

npm init

执行该命令后控制台会分步骤提示我们输入项目信息,主要项内容填写如下:

  • name: full-classic-editor
  • version: 1.0.0
  • description: A full function classic editor which extends @ckeditor/build-classic
  • main: ckeditor.js
  • repository.url: https://github.com/heshengfu26/FullClassicEditor.git
  • keywords: [“full”, “function”, “rich-text”, “Editor”]
  • author: heshengfu26
  • license: Apache-2.0
  • bugs.url: https://github.com/heshengfu26/FullClassicEditor/issues
  • homeage: https://github.com/heshengfu26/FullClassicEditor#readme
    初始化完成之后package.json文件内容如下:
{
  "name": "full-classic-editor",
  "version": "1.0.0",
  "description": "A full function classic editor which extends @ckeditor/build-classic",
  "main": "ckeditor.js",
  "scripts": {
    "test": "console.log('this is a custom full function rich text editor which extends @ckeditor/build-classic-editor');"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/heshengfu26/FullClassicEditor.git"
  },
  "keywords": [
    "full",
    "function",
    "rich-text",
    "Editor"
  ],
  "author": "heshengfu26",
  "license": "Apache-2.0",
  "bugs": {
    "url": "https://github.com/heshengfu26/FullClassicEditor/issues"
  },
  "files": [
    "dist",
    "src"
  ],
  "homepage": "https://github.com/heshengfu26/FullClassicEditor#readme"
}

初始化npm项目的时候需要登录自己的github个人账号创建一个名称为FullClassicEditor的代码仓库,对应package.json文件中的

repository.url的值。

为了将项目以 npm 包的形式发布到 npm 仓库,需要我们先注册一个npm账号,大家进入npmjs官网自行注册,官网链接:

https://www.npmjs.com/

然后我们就可以在命令控制台中依次执行下面两个命令后将我们定制的full-classic-editor项目包发布到 npm 仓库

npm login
# 输入万npm login 后浏览器会自动打开npmjs官网进入登录页面,用户输入自己注册npm账号时填写的邮箱账号收到的随机数字密码后就能登录成功
npm publish # 登录成功后就可以将包发布到 npm 仓库

发布成功后,可以在 npm 个人中心看到。后面每次更新构建后需要更新package.json文件中的verrsion字段才能发布成功,且版本号必须比之前发布的版本号数字要大。
npm_publish_package
上图是笔者发了1.2.0版本之后在npmjs官网个人中心的结果。

自定义图片上传适配器
我们参照@ckeditor/ckeditor5-upload/adapters/simpleuploadadapter.js文件中的源码定制自己的图片上传适配器ImageUploadAdapter

首先安装@ckeditor/ckeditor5-upload依赖包

yarn add -D @ckeditor/[email protected]

然后在FullClassicEditor项目的根目录下新建src文件夹, 并在该文件夹下新建 interfacesplugin 两个文件夹,分别在这两个文件夹下新建UploadOptions.tsImageUploadAdapter.ts 两个文件,两文件中的源码如下:

UploadOptions.ts

export interface UploadOptions {
    uploadUrl: string,
    withCredentials: boolean,
    headers: object
}

ImageUploadAdapter.ts

import FileLoader from '@ckeditor/ckeditor5-upload/src/filerepository'
import {UploadOptions} from '../interfaces/UploadOptions'

export class ImageUploadAdapter {
   loader: FileLoader
   options: UploadOptions
   xhr: XMLHttpRequest
   
   /**
	* ImageUploadAdapter 构造函数
	* @param loader 文件加载器 FileLoader类型
	* @param options 文件上传选项参数 UploadOptions 类型
	*/
   constructor(loader: FileLoader, options: UploadOptions){
      this.loader = loader
      this.options = options
   }
   
   /**
	 * 开始上传文件
	 * @returns {Promise}
	 */
   upload() {
      return this.loader['file']
			.then( file => new Promise( ( resolve, reject ) => {
				this.initRequest();
				this.initListeners( resolve, reject, file );
				this.sendRequest( file );
			} ) );
   }
   
   /**
	 * 初始化 XMLHttpRequest 类型文件上传请求
	 * @private
	 */
   initRequest(){
        const xhr = this.xhr = new XMLHttpRequest();
		xhr.open( 'POST', this.options.uploadUrl, true );
		xhr.responseType = 'json';
   }

   /**
	 * 初始化文件上传请求 xhr 的监听器
	 * @private
	 * @param {Function} resolve Callback function to be called when the request is successful.
	 * @param {Function} reject Callback function to be called when the request cannot be completed.
	 * @param {File} file Native File object.
	 */
   initListeners(resolve, reject, file){
      const xhr = this.xhr;
	  const loader = this.loader;
	  const genericErrorText = `Couldn't upload file: ${ file.name }.`;
      xhr.addEventListener( 'error', () => reject( genericErrorText ) ); // 监听文件上传出错事件
	  xhr.addEventListener( 'abort', () => reject() ); // 监听文件上传放弃事件
      xhr.addEventListener( 'load', () => {  // 监听文件上传加载事件
			const response = xhr.response;
			if ( !response || response.error ) {
				return reject( response && response.error && response.error.message ? response.error.message : genericErrorText );
			}
			// 解析文件上传成功响应数据,返回数据必须是一个具备 url 或 urls 字段的对象
			resolve( response.url ? { default: response.url } : response.urls );
		} );
      // 支持监听文件上传进度事件
      if ( xhr.upload ) {
			xhr.upload.addEventListener( 'progress', evt => {
				if ( evt.lengthComputable ) {
					loader.uploadTotal = evt.total;
					loader.uploaded = evt.loaded;
				}
			} );
		}
   }

   /**
	 * 放弃上传
	 * @returns {Promise}
	 */
	abort() {
		if ( this.xhr ) {
			this.xhr.abort();
		}
	}
   
   /**
	 * 发送文件上传请求
	 * @private
	 * @param {File} file File instance to be uploaded.
	 */
   sendRequest( file ){
      // Set headers if specified.
		const headers = this.options.headers || {};
		for ( const headerName of Object.keys( headers ) ) {
			this.xhr.setRequestHeader( headerName, headers[ headerName ] );
		}
		// Prepare the form data.
		const data = new FormData();
		data.append( 'upload', file );
		// Send the request.
		this.xhr.send( data );
   }
}

package.json 文件更新version字段为 1.2.0 后 重新执行npm publish 命令发布full-classic-editor包到npm 仓库。

Vue3 项目整合CKEditor5 编辑器

项目骨架搭建
参考Vite官网知道文档链接 https://cn.vitejs.dev/guide/ 使用 Vite 搭建自己的 Vue3 项目

yarn create vite exam-vue-admin --template vue
# 切换到exam-vue-admin目录
cd exam-vue-admin
# 安装全局依赖包
yarn add axios element-plus node-sass nprogress pinia vue-router@latest
# 安装开发依赖包
yarn add -D @ckeditor/ckeditor5-vue @rollup/plugin-commonjs @ckeditor/vite-plugin-ckeditor5 @highlightjs/vue-plugin full-classic-editor highlight.js sass

修改vite.config.js文件源码,修改后的源码如下:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
import commonjs from '@rollup/plugin-commonjs'
// import prismjs from 'vite-plugin-prismjs'
// import ckeditor5 from '@ckeditor/vite-plugin-ckeditor5'

// https://vitejs.dev/config/
export default defineConfig({
  server: {
    port: 3000,
    strictPort: true,//是否是严格的端口号,如果true,端口号被占用的情况下,vite会退出
    host: 'localhost',
    cors: true, //为开发服务器配置 CORS , 默认启用并允许任何源
    open: true //是否自动打开浏览器
  },
  publicDir: 'public',
  base: './',
  plugins: [vue(), 
    commonjs()
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  }
})

兼容性注意

Vite 需要 Node.js 版本 18+,20+。然而,有些模板需要依赖更高的 Node 版本才能正常运行,当你的包管理器发出警告时,请注意升级你的 Node 版本。

项目文件结构如下:

exam-vue-admin
├─.env
├─.env.development
├─index.html
├─package.json
├─README.md
├─vite.config.js
│ yarn.lock    
├─dist
├─node_modules
├─public
└─src
    ├─App.vue
    ├─main.js
    ├─style.css
    ├─api
    │  ├─user.js 
    ├─assets
    │  ├─vue.svg
    │  └─css
    │     └─main.scss  
    ├─components
    │     └─HelloWorld.vue
    ├─router
    │     └─index.js
    ├─store
    │  ├─index.js
    │  └─modules
    │      └─user.js 
    ├─utils
    │   └─request.js
    └─views
        ├─About.vue
        ├─Home.vue
        └─Login.vue 

这个 Vue 项目笔者已经上传到 gitee个人代码仓库, 地址:https://gitee.com/heshengfu1211/exam-vue-admin.git

有需要的读者朋友可以从gitee上克隆下来, 项目中登录页面和页面路由等功能笔者都已经做好了,大家直接使用即可,通过在项目根目录命令控制台执行下面的命令接口直接把项目在本地跑起来。

# yarn 安装启动
$ yarn install
$ yarn run dev
# npm 安装启动
$ npm install
$ npm run dev

main.js文件中创建Vue应用实例并安装Element-PlusVue-RouterCKEditor等插件

import { createApp } from 'vue';
import './style.css'
import App from './App.vue'
// 导入路由列表
import router from '@/router/index'
// 代替Vuex的本地响应式缓存
import { createPinia } from 'pinia'
// 导入element-plus UI组件
import ElementPlus from 'element-plus' 
// 导入element-plus UI样式
import 'element-plus/dist/index.css'
// 导入CKEditor5的Vue插件
import CKEditor from '@ckeditor/ckeditor5-vue' 
// 导入代码高亮样式
import 'highlight.js/styles/atom-one-light.css'
// 调用createApp方法创建Vue应用
const app = createApp(App)
// 安装Pinia组件
app.use(createPinia())
// 安装ElementPlus组件
app.use(ElementPlus)
// 安装路由组件
app.use(router)
// 安装CKEditor组件
app.use(CKEditor)
// 将app挂载到id为app的容器中,也就是index.html中id=app的div容器
app.mount('#app')

Home页面整合富文本编辑器
我们在src/view目录下的Home.vue文件中实现集成我们之前定制的富文本编辑器功能。我们在home页面要实现的功能不仅是富文本编辑器,还包括图片上传和代码高亮显示等功能。

Home.vue文件源码如下

<script setup>
    import { ref,reactive } from 'vue'
    // 从full-classic-editor包中导入我们定制的ClassicEditor
    import Editor from 'full-classic-editor'
    import { onMounted } from 'vue'
    // 导入代码高亮库
    import hljs from "highlight.js"
    // 导入我们之前在full-classic-editor项目中自定义的图片上传适配器
    import {ImageUploadAdapter} from 'full-classic-editor/src/plugin/ImageUploadAdapter'
    
    const editor = Editor
    // 编辑器响应式数据
    let editorData = ref('<p>Content of the editor</p>');
    // 代码块字符串数组
    let codeBlocks = reactive([])
    // 编辑器配置变量
    let editorConfig = reactive({
        language: 'zh-cn'
    })
    onMounted(()=>{
        
    })

    const editorReady = (editor)=>{
        window.ckeditor = editor
        editor.plugins.get('FileRepository').createUploadAdapter = (loader) => {
            const uploadOptions = {
                // 文件上传路径
                uploadUrl: import.meta.env.VITE_BASE_URL+'/upload/ckEditor/simpleUploadAdapter',
                withCredentials: true,
                headers: {
                  // 身份认证请求头
                  Authorization: sessionStorage.getItem('authToken')
                }
            }
            return new ImageUploadAdapter(loader, uploadOptions)
        }
    }
    // 代码高亮显示
    const highlightCode = ()=> {
        codeBlocks.value = []
        var editorMain = document.getElementsByClassName('ck ck-content ck-editor__editable')[0]
        let blocks = editorMain.querySelectorAll("pre code")
        for(var i=0; i<blocks.length;i++){
             var block = blocks[i]
             var dataLanguage =  block.parentNode.getAttribute('data-language')
             var className = block.getAttribute('class') + ' hljs'
            if(block.innerText==null || block.innerText=='' || block.innerText=='\n'){
                return
            }
            var highlightCode = hljs.highlightAuto(block.innerText).value;
            // 代码高亮后的字符串'双引号'被替换成了'&quot;', 单引号被替换成了'&#x27;', 所以需要将其还原回来
            highlightCode = highlightCode.replaceAll('&quot;', '"').replaceAll('&#x27;', '\'')
            console.log(highlightCode)
            block.innerText = ''
            block.innerHTML = highlightCode
            codeBlocks.value.push({
                dataLanguage: dataLanguage,
                className: className,
                codeBlock: highlightCode
            })
            console.log(codeBlocks)
        }
    }

</script>
<template>
   <div id="editor-container">
        <Ckeditor :editor="editor" v-model="editorData" :config="editorConfig" @ready="editorReady"></Ckeditor>
        <el-button type="primary" @click="highlightCode" style="margin-top: 10px;margin-bottom: 10px;">代码高亮显示</el-button>
        <div class="code-area">
            <!--遍历语法高亮后的代码数组-->
            <pre v-for="(item, index) in codeBlocks.value" :key="index" :data-language="item.dataLanguage">
                <code :class="item.className" style="text-align: left;" v-html="item.codeBlock"></code>
            </pre>
        </div>
   </div>
</template>

笔者在后台服务登录接口用户登录成功后返回一个jwtToken

public class FormLoginSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException {
        httpServletResponse.setContentType("application/json;charset=utf-8");
        PrintWriter out = httpServletResponse.getWriter();
        CustomUser user = (CustomUser) authentication.getPrincipal();
        Map<String, Object> successMap = new HashMap<>();
        successMap.put("status", 200);
        successMap.put("userInfo", user);
        successMap.put("msg", "success");
        String jwtToken = JwtTokenUtil.genAuthenticatedToken(user);
        successMap.put("authToken", jwtToken);
        String successMapString = JSON.toJSONString(successMap);
        out.write(successMapString);
        out.flush();
        out.close();
    }
}

同时在用户登录成功后需要在sessionStorage中存下authToken
views/Login.vue

pwdLoginFormRef.value.validate((valid)=>{
              if(valid){
                const userStore = useUserStore()
                pwdLogin(loginForm).then(res=>{
                   if(res.status==200){
                     const {userInfo, authToken} = res
                     userStore.setAuthToken(authToken)
                     const user = {
                       username: userInfo.username,
                       nickname: userInfo.nickname,
                       phoneNum: userInfo.phoneNum,
                       currentRole: userInfo.currentRole,
                       email: userInfo.email,
                       userface: userInfo.email
                     }
                     userStore.setUserInfo(user)
                     userStore.setLogined(true)
                     ElMessage({
                      message: '登录成功!',
                      type: 'success'
                    })
                     router.push('/home')
                   }else{
                     ElMessage.error('密码登录失败')
                   }
                })
              }else{
                ElMessage.error('表单验证失败')
              }
            })

store/modules/user.js

actions: {
        setAuthToken(authToken){
            this.authToken = authToken
            sessionStorage.setItem('authToken', authToken)
        },
        setUserInfo(userInfo){
            this.userInfo = userInfo
            sessionStorage.setItem('userInfo', JSON.stringify(userInfo))
        },
        setRoles(roles){
            this.roles = roles
            sessionStorage.setItem('roles', JSON.stringify(roles))
        },
        setLogined(loginFlag){
            this.logined = loginFlag
            sessionStorage.setItem('logined', loginFlag)
        }
    }

后端项目blogserver 源码笔者已上传到了个人gitee代码仓库:blogserver项目源码地址

图片上传服务端笔者使用阿里的对象存储服务实现,图片上传接口如下:

@RestController
@RequestMapping("/upload")
public class UploadFileController {

    @Resource
    private OssClientService ossClientService;
    
    @PostMapping("/ckEditor/simpleUploadAdapter")
    public Map<String, String> uploadCkEditorImage(HttpServletRequest request){
        MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest) request;
        MultipartFile file = multipartRequest.getFile("upload");// 获取上传文件对象
        assert file != null;
        String imageUrl = ossClientService.uploadImageFile(file, "simpleUploadAdapter");
        Map<String, String> resultMap = new HashMap<>();
        resultMap.put("url", imageUrl);
        return resultMap;
    }
}

这里需要注意:根据CKEditor5的官方文档说明,图片上传接口必须返回一个包含urlurls字段的json对象,若是前者对应的值是一个图片链接url;若是后者,其对应的值是一个包含多张图片链接url的数组。

富文本编辑器效果测试

在启动后端服务前需要在本地安装Mysql数据库服务和Redis服务,Mysql数据库笔者安装的8.0版本,Redis安装的是7.0版本。然后执行数据库初始化脚本,也就是执行blogserver项目src/main/resources/sql目录下的create_database_and_user.sqlcreate_tables_and_init.sql两个文件中的sql脚本。

启动服务时先运行后台项目BlogServerApplication类中的main方法,启动后台服务。后台服务启动成功后使用ApiPost调用注册接口注册一个zhangsan的用户

userRegister
点击【发送】按钮完成用户zhangsan的注册,注册成功之后我们就可以使用该账号在前端页面进行登录了。

然后在exam-vue-admin项目根目录右键->Open Git Bash Here 打开命令控制台执行如下命令启动前端服务

$ yarn run dev

前端服务启动成功后会自动打开浏览器进入系统登录页面
exam_vue_login
输入用户名:zhangsan 和 登录密码 zhangsan1990 后点击登录进入,登录成功后系统会通过路由进入Home.vue组件对应的页面,也就是我们定制的富文本编辑页面。
CKEditorPage
然后我们在富文本编辑器中输入内容,并使用Paragraph、Highlight、Font color 以及 Block quote 等菜单按钮格式化输入内容,效果如下图所示:
CKEditorContent
然后继续点击Insert code block 按钮插入代码,插入前在下拉框中选中 JavaScript 语言,并在插入的代码块中拷贝一段JavaScript代码进去,插入代码后的效果如下图所示:
JavaScriptCode
接着我们继续插入一段 Java 代码,如下图所示:
JavaCode
然后我们点击下面的【代码高亮显示】蓝色按钮,可以看到下面的代码显示区域出现了代码语法高亮显示出来了
HighlightCode01
HighlightCode02
最后我们看一下上传图片效果,点击 Insert image 菜单按钮插入一张本地jpg格式图片,可以看到图片在富文本编辑器中成功显示出来了
HilightCode
HilightCode3
最后我们看一下上传图片效果,点击 Insert image 菜单按钮插入一张本地jpg格式图片,可以看到图片在富文本编辑器中成功显示出来了
touxiang
本文首发个人微信公众号【阿福谈Web编程】,刚兴趣的读者朋友可以加个关注,通过笔者的个人公众号菜单栏中的【联系作者】按钮就可以添加我的微信,大家一起交流技术,共同进步!

小结

可以看到我们定制后的CKEditor代码编辑器的功能已经是非常强大了,后面我们可以将使用这个富文本编辑器编辑好的内容以字符串文本的格式保存到数据库中持久化,需要在前台展示的时候通过接口查询出富文本文本内容数据,然后在Vue页面组件中通过v-html指令直接渲染成html格式的内容。例如博客系统和面试题小程序,我们通过富文本编辑器就可以把文章和面试题答案内容通过渲染html格式的富文本内容通过一个页面动态渲染不同的博客文章和面试题内容明细。

需要注意的是:CKEditor5 无法在编辑器中实现代码的语法高亮显示,即时通过hljs#hilightAuto方法处理从Dom节点中取到的代码块内容,然后又将hljs#hilightAuto方法处理过的代码语法高亮显示的html文本数据设置到双向绑定数据editorData变量也无法使编辑器插入代码块中的代码语法高亮显示,这一点官方文档中的Code blocks特性部分也进行了说明,笔者之前为了实现在编辑器内代码块中实现代码语法高亮显示折腾了很久,也仔细阅读了CKEditor5官方文档的API,并试图在代码块中插入代码后尝试修改数据富文本编辑器中的模型仍然面临失败的结果,最后只是证明的官方文档的这一结论。

[可以看到我们定制后的CKEditor代码编辑器的功能已经是非常强大了,后面我们可以将使用这个富文本编辑器编辑好的内容以字符串文本的格式保存到数据库中持久化,需要在前台展示的时候通过接口查询出富文本文本内容数据,然后在Vue页面组件中通过v-html指令直接渲染成html格式的内容。例如博客系统和面试题小程序,我们通过富文本编辑器就可以把文章和面试题答案内容通过渲染html格式的富文本内容通过一个页面动态渲染不同的博客文章和面试题内容明细。

需要注意的是:CKEditor5 无法在编辑器中实现代码的语法高亮显示,即时通过hljs#hilightAuto方法处理从Dom节点中取到的代码块内容,然后又将hljs#hilightAuto方法处理过的代码语法高亮显示的html文本数据设置到双向绑定数据editorData变量也无法使编辑器插入代码块中的代码语法高亮显示,这一点官方文档中的Code blocks特性部分也进行了说明。

笔者之前为了实现在编辑器内代码块中实现代码语法高亮显示折腾了很久,也仔细阅读了CKEditor5官方文档的API,并试图在代码块中插入代码后尝试修改数据富文本编辑器中的模型仍然面临失败的结果,最后只是证明的官方文档的这一结论。

https://ckeditor.com/docs/ckeditor5/latest/features/code-blocks.html
CodeBlocksFeatures
虽然在编辑器中代码语法高亮不会生效,但是将代码高亮处理后的html格式代码块内容在前端渲染却能看到代码语法高亮效果。如果必须要在富文本编辑器中的插入代码块后实现代码高亮效果则需要将 CKEditor5 改为 CKEditor4, 同时配置codesnippt插件才能生效。感兴趣的同学可以参考CKEditor4有关代码语法高亮特性部分文档来实现, 文档链接:
CKEditor4代码高亮化参考文档

阿里云服务器推荐

作为一名程序员,想要拥有自己的独立站点,最好能有一台自己的云服务器。不论是部署自己开发的项目还是部署二次开发的开源项目,要做成一个产品,最终都需要我们部署到自己的云服务器上。就算你暂时不需要把项目部署到自己的云服务器上,就算练习安装一些中间件服务,深入学习计算机运维技术,我们最好也能有一台云服务器,方便我们在Linux系统上运行各种指令。

笔者最近趁着【阿里云】的春节促销活动,购入了两台阿里云的服务器,加上之前购入的【腾讯云】服务器,目前手上已经4台云服务器了,但是考虑到腾讯云服务器目前每个月的月租比较贵,笔者打算下个月开始停掉【腾讯云】的服务器,一年下来可以节约一两千开支。

这次的【阿里云】服务器亲身体验过,不仅好用,而且价格非常实惠。新人购买一台2核4G配置的阿里云服务器3个月只需要60多块钱,而购买一台2核2G配置的ECS云服务器一年只需要99元,非常划算。

有一台属于自己的云服务器,不仅有助于提高我们独立开发网站的能力,而且有一天说不定还能还能依靠自己的网站变现给自己带来一份收入不低于主业的副业收入,也不用担心有一天因为自己年龄太大被裁员了再也找不到工作。

我们必须未雨绸缪,为将来终会到来的中年失业危机做准备,小伙伴们有想法就赶紧去行动吧!点击下面的【阿里云小站】站外链接就可以去选购自己需要的【阿里云服务器】了!

阿里云小站

参考文章

【1】Vue.js 3+ rich text editor component

【2】CKEditor5 富文本编辑器定制

【3】CKEditor5 集成 Firebase Storage 实现图片上传

【4】npm 上传发布自定义组件以及使用详细流程(Vue包含)

;