CKeditor5系列三

2020/04/11 editor

创建一个功能相对完善的插件

1、概述

上篇那个只是一个简单插件的demo,这篇我们来创建一个可正常编辑,换行的插件,因为按上篇那种方式创建出来,设置默认的文字之类的然后去进行编辑会有一些问题。

比如你想插入一个文字然后渲染成一下结构,文字可正常编辑换行

<div><p>default word</p></div>

但是你执行writer.insertText('default word', parent);之后插入的元素会渲染成下面的 (这是在command文件内执行的)

<div>default word</div>

这个时候如果你编辑或者干嘛,会生成下面的结构

<div>default word<p>你编辑的内容</p></div>

还有创建一个插件想选中或者怎样上篇是不能实现的,我们在这个插件里都会一一解决。

2、创建项目

初始化项目:

npm init -y

安装项目依赖

npm install --save postcss-loader@3 raw-loader@3  style-loader@1 webpack@4 webpack-cli@3

创建webpack.config.js文件

'use strict';
const path = require('path');
const { styles } = require('@ckeditor/ckeditor5-dev-utils');
module.exports = {
  // https://webpack.js.org/configuration/entry-context/
  entry: './app.js',
  // https://webpack.js.org/configuration/output/
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },
  module: {
    rules: [
      {
        test: /ckeditor5-[^/\\]+[/\\]theme[/\\]icons[/\\][^/\\]+\.svg$/,
        use: ['raw-loader']
      },
      {
        test: /ckeditor5-[^/\\]+[/\\]theme[/\\].+\.css$/,
        use: [
          {
            loader: 'style-loader',
            options: {
              injectType: 'singletonStyleTag'
            }
          },
          {
            loader: 'postcss-loader',
            options: styles.getPostCssConfig({
              themeImporter: {
                themePath: require.resolve('@ckeditor/ckeditor5-theme-lark')
              },
              minify: true
            })
          }
        ]
      }
    ]
  },
  // Useful for debugging.
  devtool: 'source-map',
  // By default webpack logs warnings if the bundle is bigger than 200kb.
  performance: { hints: false }
};

安装编辑器依赖

 npm install --save @ckeditor/ckeditor5-dev-utils @ckeditor/ckeditor5-editor-classic @ckeditor/ckeditor5-essentials @ckeditor/ckeditor5-paragraph @ckeditor/ckeditor5-basic-styles @ckeditor/ckeditor5-theme-lark

创建APP.js

import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';

ClassicEditor
  .create(document.querySelector('#editor'), {
    plugins: [Essentials, Paragraph, Bold, Italic],
    toolbar: ['bold', 'italic']
  })
  .then(editor => {
    console.log('Editor was initialized', editor);
  })
  .catch(error => {
    console.error(error.stack);
  });

修改package.json,因为要经常编译,所以我加了--watch

  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "webpack --mode development --watch"
  }

然后构建

npm run build

创建HTML文件index.html

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8">
  <title>CKEditor 5 Framework – Quick start</title>
</head>
<body>
  <div id="editor">
    <p>Editor content goes here.</p>
  </div>
  <script src="dist/bundle.js"></script>
</body>
</html>

然后浏览器打开HTML,这个时候就展示出了一个基本的编辑器最初的模样~以上都是基于文档:https://ckeditor.com/docs/ckeditor5/latest/framework/guides/quick-start.html 现在我们来正式开始创建插件

3、创建插件

我们来创建一个提示框插件,插入一个提示框,上面有一个提示的标题,中间部分内容可编辑,输入想要的文字

首先我们来创建一个tip文件夹,在里面创建四个文件tip.jstipEdit.js,tipUi.js,tipCommand.js,不清楚为什么要创建这四个的可以看前面的。

3.1 编写tip.js

import ui from './tipUi'
import edit from './tipEdit'
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import Widget from '@ckeditor/ckeditor5-widget/src/widget';

export default class writeBox extends Plugin {
  static get requires() {
    return [ui, edit, Widget];
  }
}

3.2 确定HTML结构

在编辑edit.js文件之前,我们来确定一下HTML结构,方便写模型及转换器,如果脑子好的话也可以不先写HTML模板,直接开始操作,我脑子不行,所以先写个HTML结构来明确DOM结构

<div class="edit-item-box box">
  <div class="tip-content-box">
    <div class="tip-content" >
      <p>1. 多喝水</p>
      <p>2. 多喝热水</p>
    </div>
    <div class="close"><span class="closeIcon">x</span></div>
    <div class="tip-title">
      <p>提示的标题</p>
    </div>
  </div>
</div>

CSS样式,真正写插件的时候写成单独的文件,然后引入到edit.js里面即可,这里我就直接扔index.html文件里了

<style>
  .tip-content-box{
    width: 100%;
    position: relative;
    border: 1px solid #ccc;
    padding: 6px 10px;
    box-sizing: border-box;
    border-radius: 4px;
    cursor: pointer;
  }
  .tip-content-box:hover .close{
    display: block;
  }
  .tip-title{
    background: white;
    text-align: center;
    font-size: 14px;
    position: absolute;
    top: -10px;
    cursor: text;
    height: 20px;
    left: 50%;
    transform: translateX(-50%);
  }
  .edit-item-box{
    margin: 20px 0;
  }
  .tip-content{
    font-size: 14px;
    cursor: text;
  }
  .close{
    position: absolute;
    top: -7px;
    right: -4px;
    width: 14px;
    height: 14px;
    color:white;
    background:#ccc;
    text-align: center;
    line-height: 12px;
    border-radius: 50%;
    display: none;
    z-index: 99;
    cursor: pointer;
  }
  .tip-title p{
    padding: 0 10px;
    color: #365f93;
    font-weight: bold;
    transform: translateY(-15px);
  }
  .closeIcon{
    transform: scaleX(1.2) translateX(0px) translateY(-1.5px);
    display: inline-block;
    font-size: 12px;
  }
</style>

3.3 编写edit.js

先来安装一个插件npm install @ckeditor/ckeditor5-widget --save,安装完之后记得重新build

import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import command from './tipCommand';
import { toWidget } from '@ckeditor/ckeditor5-widget/src/utils';

export default class tipEdit extends Plugin {
  init() {
    this._defineSchema();
    this._defineConverters();
    this.editor.commands.add('tip', new command(this.editor));
  }

  _defineSchema() {
    const schema = this.editor.model.schema;
    // 注册模型
    schema.register('editItemBox', {
      // 是否为一个完整的对象,不可被回车拆分,意思是回车等行为都是在它自身容器内进行
      isObject: true,
      allowWhere: '$block',
    })

    schema.register('tipContentBox', {
      allowWhere: '$block',
      isLimit: true,
      allowIn: 'editItemBox',
    })

    schema.register('tipTitle', {
      isLimit: true,
      allowIn: 'tipContentBox',
      isObject: true,
      allowContentOf: '$root'
    })

    schema.register('tipContent', {
      isLimit: true,
      allowIn: 'tipContentBox',
      isObject: true,
      allowContentOf: '$root'
    })

    schema.register('tipClose', {
      isLimit: true,
      allowIn: 'tipContentBox',
      allowContentOf: '$block'
    });


    schema.register('tipSpan', {
      isLimit: true,
      allowIn: 'tipClose',
      allowContentOf: '$block'
    });
  }

  _defineConverters() {
    const conversion = this.editor.conversion;

    // 使用editingDowncast数据管道
    // 创建一个元件
    // model的名称整个项目需要唯一
    conversion.for('editingDowncast').elementToElement({
      model: 'editItemBox',
      view: (modelItem, viewWriter) => {
        const viewWrapper = viewWriter.createContainerElement('div');
        viewWriter.addClass('edit-item-box', viewWrapper);
        return toHorizontalLineWidget(viewWrapper, viewWriter);
      }
    });

    // 创建一个普通的转换器
    conversion.elementToElement({
      model: 'tipContentBox',
      view: {
        name: 'div',
        classes: 'tip-content-box'
      }
    });

    conversion.elementToElement({
      model: 'tipClose',
      view: {
        name: 'div',
        classes: 'close'
      }
    });

    conversion.elementToElement({
      model: 'tipSpan',
      view: {
        name: 'span',
        classes: 'closeIcon'
      }
    });

    // 创建一个普通的可编辑可选择的转换器
    conversion.for('editingDowncast').elementToElement({
      model: 'tipTitle',
      view: (modelItem, writer) => {
        const nested = writer.createEditableElement('div', { class: 'tip-title' });
        writer.setAttribute('contenteditable', nested.isReadOnly ? 'false' : 'true', nested);
        nested.on('change:isReadOnly', (evt, property, is) => {
          writer.setAttribute('contenteditable', is ? 'false' : 'true', nested);
        });
        return nested;
      }
    });

    conversion.for('editingDowncast').elementToElement({
      model: 'tipContent',
      view: (modelItem, writer) => {
        const nested = writer.createEditableElement('div', { class: 'tip-content' });
        writer.setAttribute('contenteditable', nested.isReadOnly ? 'false' : 'true', nested);
        nested.on('change:isReadOnly', (evt, property, is) => {
          writer.setAttribute('contenteditable', is ? 'false' : 'true', nested);
        });
        return nested;
      }
    });

  }
}

// 这个地方转换原件之后就可以进行选择之类的操作,但是这个方法是不能编辑的,因为源码里面给设置false了,具体操作可以看源码
// 源码:node_modules/@ckeditor/ckeditor5-widget/src/utils.js  => export function toWidget( element, writer, options = {} ) {}

function toHorizontalLineWidget(viewElement, writer, label) {
  // 在当前元素上设置自定义属性,  key  value  targetElement
  writer.setCustomProperty('tip', true, viewElement);
  // toWidget 转化为元件
  return toWidget(viewElement, writer, { label });
}

3.4 编写tipUi.js

import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver';

// import icon from './icons/insert.svg';

export default class tipUi extends Plugin {
  init() {
    const editor = this.editor;
    const t = editor.t;
    editor.ui.componentFactory.add('tip', locale => {
      const view = editor.editing.view;
      const command = editor.commands.get('tip');
      view.addObserver(ClickObserver);
      const buttonView = new ButtonView(locale);

      buttonView.set({
        label: t('插入tip'),
        // 如果要用icon可以把这里打开,等会儿再说icon的问题
        // icon: icon,
        tooltip: true,
        // true表示使用文字,false表示用icon,可以一起用,但是会并列
        withText:true
      });
      buttonView.bind('isOn', 'tip').to(command, 'value', 'isEnabled');

      this.listenTo(buttonView, 'execute', () => editor.execute('tip'));
      return buttonView;
    });
  }
}

3.5 编写tipCommand.js

import Command from '@ckeditor/ckeditor5-core/src/command';

export default class tipCommand extends Command {
  execute() {
    this.editor.model.change(writer => {
      this.editor.model.insertContent(createTip(writer));
    });
  }
}

function createTip(writer) {
  const editItemBox = writer.createElement('editItemBox');
  const tipContentBox = writer.createElement('tipContentBox');
  const tipTitle = writer.createElement('tipTitle');
  const tipContent = writer.createElement('tipContent');

  // 创建关闭按钮
  const tipClose = writer.createElement('tipClose');
  const span = writer.createElement('tipSpan');
  writer.insertText('x', writer.createPositionAt(span, 0));
  writer.append(span, tipClose);

  // 创建标题
  const paragraph = writer.createElement('paragraph');
  writer.appendText('提示的标题', paragraph);

  // 创建内容
  const paragraph1 = writer.createElement('paragraph');
  writer.appendText('1. 多喝水', paragraph1);
  const paragraph2 = writer.createElement('paragraph');
  writer.appendText('2. 多喝热水', paragraph2);
  writer.insert(paragraph, tipTitle, 0);
  writer.insert(paragraph1, tipContent, 0);
  writer.insert(paragraph2, tipContent, 1);
  writer.append(tipContent, tipContentBox);
  writer.append(tipClose, tipContentBox);
  writer.append(tipTitle, tipContentBox);
  writer.append(tipContentBox, editItemBox);
  
  return editItemBox;
}

3.5 注册tip插件

import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
import tip from './tip/tip'

ClassicEditor
  .create(document.querySelector('#editor'), {
    plugins: [Essentials, Paragraph, Bold, tip,  Italic, tip],
    toolbar: ['bold', 'italic', 'tip']
  })
  .then(editor => {
    // 注册删除事件
    editor.editing.view.document.on('click', (evt, data) => {
      if (data.domTarget.className == 'closeIcon' || data.domTarget.className == 'close' ) {
        const selection = editor.model.document.selection;
        editor.model.document.model.deleteContent(selection)
      };
    });
  })
  .catch(error => {
    console.error(error.stack);
  });

4、重置样式

在style标签里面加入下面代码就重置了

/* 选中及鼠标移入样式重置 */
.ck .ck-widget_selected{
  background: rgba(72,145,255,.16);
}
.ck .ck-widget{
  outline-style: none;
  outline-color:transparent !important;
  transition: none !important;
}
.ck .ck-widget:hover{
  outline-style:dashed;
  outline-color:#ccc !important;
  outline-width:1.2px;
}
.ck .ck-widget_selected.ck-widget{
  outline-style:dashed !important;
  outline-color:#ccc !important;
  outline-width:1.2px !important;
}
.tip-title, .tip-content, .tip-content-box{
  outline:none !important;
}
/* 功能按钮区域移入变色 */
.ck.ck-button:not(.ck-disabled):hover, a.ck.ck-button:not(.ck-disabled):hover{
  color: green;
}
/* 重置宽高 */
.ck-editor__editable {
  min-height: 300px;
  width: 800px !important;
}
.ck-toolbar, .ck-sticky-panel__content{
  width: 821px !important;
}

Search

    Table of Contents