preface

My first project involved a rich text editor. When I took over, the function of the editor was well developed, while I was standing on the shoulders of predecessors. The purpose of this article is to share how to build a rich text editor and my own experience in the development process. Since the project uses Quill, this article also describes it on this basis.

Foreplay: Build a rich text editor quickly

Although the project is Quill with Vue, Quill is actually a complete “green tea”, as long as you are “cool”, whether you are popular Vue, React, or “aged and robust” jQuery, it is willing to “with” ~

This article uses React to demonstrate how to build using a Parcel for ease of building

Rely on

yarn add parcel-bundler quill react react-dom sass

npmJi ǒ this

{/ /... "Scripts ": {"clean": "rm -rf dist/./cache", // do not clean./cache may cause page refresh problem "start": "yarn clean && parcel src/index.html" }, // ... }Copy the code

welcomeQuillCome on stage

Index. Js:

import React from 'react';
import {render} from 'react-dom';
import App from './app';

render(<App />, document.getElementById('app'));

Copy the code

Index.html:


      
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Quill</title>
</head>
<body>
    <div id="app"></div>
    <script src="./index.js"></script>
</body>
</html>
Copy the code

App. Js:

import React, {Component} from 'react';
import Quill from 'quill';
import './app.scss';

export default class QuillEditor extends Component {
  constructor(props) {
    super(props);
    this.state = {
      quill: null}; } componentDidMount() {const quill = new Quill('#editor', {	// param1: node mounted by the editor
      theme: 'snow'.// Select the desired editor "skin"
    });
    this.setState({quill});
  }

  render() {
    return <div className="editor-wrapper">
      <div id="editor"></div>
    </div>; }};Copy the code

App. SCSS:

@import ".. /node_modules/quill/dist/quill.snow.css";

* {
  margin: 0;
  padding: 0;
}

.editor-wrapper {
  width: 800px;
}

.ql-container.ql-snow {
  height: 600px;
}

Copy the code

The basic editor is set up. Run YARN Start. The following interface is displayed in the browser:

From the above code, you can see that the “green tea” does not have anything to do with the frame, as long as it knows its “prey”!

Warm up, let’s roll up our sleeves

Enemy and know yourself

The above editor has simple ordered/unordered list/bold/italic/underline/hyperlink functionality, which is simply not enough, so more poses for Quill will be unlocked next. Extend the functionality before need to know a few basic concepts of the Quill: Parchment/Blot/Attributor/Formats/Modules and Delta.

Parchment: The document model of Parchment for Quill, which is a parallel tree structure to the DOM tree, provides some useful functions for Quill.

Blot: A Parchment consists of multiple blots, corresponding to a DOM node, which can provide formatting or content of Parchment. Let’s take a look at what’s in the Source code for The Kangkan Blot to help us understand:

Parchment/SRC/blot/inline. Which s:

// Compare it to DOM for a moment to describe the attributes or methods
declare class TextBlot extends LeafBlot implements Leaf {
  	// ...
    static blotName: string;	// The name of Blot
	  // Is Blot in line/block level or other scope(not block level elements in HTML, etc., for Blot)
    static scope: Registry.Scope;	
    static value(domNode: Text): string;	// The DOM node
  	// ...
}
export default TextBlot;
Copy the code

Attributor: Provides formatting information (described below in conjunction with format);

Format: Each function in the toolbar corresponds to a Format. Of course, there are more functions in the toolbar than in the figure above. Users can also customize the toolbar (this article does not repeat the definition of the toolbar, please click here for the official document).

Quill/formats/align. Js:

import Parchment from 'parchment';

let config = {
  scope: Parchment.Scope.BLOCK,	// Align's scope is block-level
  whitelist: ['right'.'center'.'justify']	// Whitelist the text content to right-justify, center, and justify
};

// The following three lines of code illustrate Attributor's ability to "provide formatting information.
let AlignAttribute = new Parchment.Attributor.Attribute('align'.'align', config);
let AlignClass = new Parchment.Attributor.Class('align'.'ql-align', config);
let AlignStyle = new Parchment.Attributor.Style('align'.'text-align', config);

export { AlignAttribute, AlignClass, AlignStyle };

/* The above code is the align format, which corresponds to the toolbar center/right alignment operation */

Copy the code

Module: You can use the Module to customize the behavior and functionality of Quill.

Currently, the following five modules can be customized:

  • ToolBar: ToolBar
  • Keyboard: Keyboard events such as⌘ + BSet the text to bold and so on
  • History: you can set the maximum number of times to return the previous step
  • Clipboard: Can handle Clipboard events
  • Syntax: code formatting can be enhanced with automatic monitoring and Syntax highlighting

Delta: Describes the modified content and data format.

Start thinking about things

Start by changing the font in the editor

We need to customize the format to modify our font:

import Quill from 'quill';

const Parchment = Quill.import('parchment');

const config = {
  scope: Parchment.Scope.INLINE,
  whitelist: ['Times New Roman']};class FontStyleAttributor extends Parchment.Attributor.Style{
  value(node) {
    return super.value(node).replace(/["']/g.' '); }}const FontStyle = new FontStyleAttributor('font'.'font-family', config);

export default FontStyle;
Copy the code

Add a splitter feature to the editor

The split line is defined with format and module:

Format:

import Quill from 'quill';

const BlockEmbed = Quill.import('blots/block/embed');

class Divider extends BlockEmbed {
  static create() {
    const node = super.create();
    node.setAttribute('style'.'border: 1px solid #eee; margin: 12px 0; ');
    return node;
  }
}

Divider.blotName = 'divider';
Divider.tagName = 'HR';

export default Divider;

Copy the code

The Module:

import Quill from 'quill';

export default class Divider {
  constructor(quill) {
    this.quill = quill;
    this.toolbar = quill.getModule('toolbar');
    if (typeof this.toolbar ! = ='undefined') {
      this.toolbar.addHandler('divider'.this.insertDivider);
    }
  }

  insertDivider() {
    const range = this.quill.getSelection(true);
    // The third parameter source set to user means that the call is ignored when the editor is disabled
    this.quill.insertText(range.index, '\n', Quill.sources.USER);	// Insert a blank line
    this.quill.insertEmbed(range.index + 1.'divider'.true, Quill.sources.USER);	// Insert the splitter line
    this.quill.setSelection(range.index + 2, Quill.sources.USER);	// Set the cursor position}}Copy the code

Processing images

At present, although the editor can paste the picture, but the picture is an external network address for us! What if you want to upload to the server and use our Intranet address to display? At this point we need to customize the module:

import Quill from 'quill';

export default class ImageDrop {
  constructor(quill, options = {}) {
    this.quill = quill;
    // Bind paste to handle events
    this.handlePaste = this.handlePaste.bind(this);
    // Listen for the paste event
    this.quill.root.addEventListener('paste'.this.handlePaste, false);
  }
 
  handlePaste(e) {
    // Check whether there is content in the clipboard
    if (e.clipboardData && e.clipboardData.items && e.clipboardData.items.length) {
      this.readFiles(e.clipboardData.items, () => {
        this.loading = false;
      });
    }
  }

  readFiles(files, callback) {
    // Get the picture in the clipboard
    [].forEach.call(files, file => {
      if (file.type.match(/^image\/(gif|jpe? g|a? png|svg|webp|bmp|vnd\.microsoft\.icon)/i)) {
        const { index } = this.quill.getSelection();
        const asFile = file.getAsFile();
        if (asFile && asFile.size > MaxSize) {
          // If the file size exceeds the limit is detected
         	// Can be handled or prompted here
        } else if (asFile) {
          // Insert the image
          this.quill.insertEmbed(index, 'pasteImage', {file: asFile, callback}, Quill.sources.USER);
          // Set the cursor position
          this.quill.setSelection(index + 2); }})}}Copy the code

You can format text, you can insert pictures, is that enough?

No! We are people with higher aspirations! When we do business, we have to think about the business scenario. For the use of rich text editor, will the user honestly type in the editor word by word? ‘Not really! Therefore, this requires us to paste in the content of processing, although the editor can retain the pasted content format, but this is not compound business, for the use of the editor output of the article, the content format and style should be consistent. Therefore, we should unify the content processing, such as: converting H1 tags into our H1 tags, and converting H2 /3/4 etc. into text (of course, it depends on the requirements, but here is an example).

Handling pasted content

Remember the official 5 modules? One of them is Clipboard! Now let’s customize the Clipboard. It’s a big project. Here’s the idea:

  1. Obtained in the paste eventDelta;
  2. rightDeltaFormat processing;
  3. Will be processed afterDeltaUpdate to the editor.
To obtainDelta
export default class PlainClipboard extends Clipboard {
  constructor(quill, options) {
    super(quill, options);
    this.quill = quill;
  }

  onPaste(e) {
    e.preventDefault();
    
    const html = e.clipboardData.getData('text/html');
    const text = e.clipboardData.getData('text/plain');
    // The contents of a Clipboard can be converted to a delta using the convert method of the clipboard
    let delta = this.quill.clipboard.convert(html || text);

    console.info(delta);
  }
  // do something else ...
}
Copy the code

I selected and copied all the contents from the homepage of Quill’s official website, and the output Delta is as shown below:

Delta describes the modified content and data format. Delta describes the modified content and data format. Delta describes the modified content and data format.

To deal withDelta
export default class PlainClipboard extends Clipboard {

  // ...
  
  onPaste(e) {
    this.updateContents(this.quill, delta);
    this.quill.selection.scrollIntoView(this.quill.scrollingContainer);
  }

  updateContents(quill, delta) {
    let ops = [];
    let opsLength = 0;
    const {index, length} = quill.getSelection();
    delta.ops.forEach((op, idx, arr) = > {
      const {attributes, insert} = op;
      const newOp = {};
      if (attributes) {
        newOp.attributes = {};
        / / keep bold | underline | italic format
        newOp.attributes.bold = attributes.bold || false;
        newOp.attributes.underline = attributes.underline || false;
        newOp.attributes.italic = attributes.italic || false;
        / / keep the align: center | right alignment
        // Rule out pasting separate lines from Word
        if (typeof insert === 'string' && attributes.align) {
          newOp.attributes.align = attributes.align;
          newOp.insert = '\n';
        }
        // Keep the split line
        if (typeof insert === 'object' && insert.divider) {
          newOp.insert = insert;
        }
      }

        newOp.insert = insert;

        // Handle multiple empty lines at the end of the insert
        if (typeof newOp.insert === 'string') {
          newOp.insert = newOp.insert.replace(/\n+$/g.'\n');
        }
        if(newOp.insert && ! insert.image) { ops.push(newOp); } opsLength += insert.length ||0;
      }

      // Handle pasted images
      [op, ops, opsLength] = this.handleImagePaste({op, ops, opsLength});
    });

		// Update the editor content
    this.handleUpdateContents({quill, ops, opsLength, index, length});
  }

  handleImagePaste(config) {
    const {op} = config;
    let {ops, opsLength} = config;
    if (
        op.insert &&
        typeof op.insert === 'object' &&
        op.insert.image
    ) {
      ops = [
          ...ops,
        {
          insert: '\n'}, {insert: {
            image: op.insert.image,
          },
        },
        {
          insert: '\n'.attributes: {	// The image is centered
            align: 'center',}},]; opsLength +=3;
    }

    return [op, ops, opsLength];
  }

  handleUpdateContents(config) {
    const {quill, ops, opsLength, index, length} = config;
    if (length) {
      ops.unshift({
        delete: length,
      });
    }
    if (index) {
      ops.unshift({
        retain: index,
      });
    }

    // Update the processed delta to the editor and set the cursor position
    quill.updateContents(ops);
    quill.setSelection(index + opsLength, 0, Quill.sources.SILENT);
  }

  isTextAlign(attributes) {
    return attributes.align === 'center' || attributes.align === 'right'; }};Copy the code

The pit of tread

1. Keep the Word format

It can be seen that part of the above code is written for Word. In fact, there are many contents of Word copy-paste. Here is a summary of the pit points encountered in Word:

The divider

Users can input multiple dash lines and press Enter to generate a splitter line, or insert a splitter line, as shown in the figure below:

Only the splitter inserted by the latter can be retrieved from the Delta and processed accordingly;

Ordered/unordered list

Some developers have found that pasting an ordered unordered list after customizing a font does not display the sequence number or symbol of the previous list. This is because the sequence number and symbol of the pasted list in Word is a special font: Windings (on Windows), so if you want to display the serial number properly, add Windings to the white-list.

When we copy a list into the editor, we find that the Delta has a lot of “Spaces”. On Windows, the five Spaces in the Delta are not normal Spaces. The char code of the Delta is 160, and the char code of the space is 32.

2. The escape

Although the editor has escaped for us, the clipboard.convert method has a bug. When we paste plain text into the editor, the convert method converts the text string to an IMG instance. The onError event is raised.

3. Determine if content is equal

When a user edits a saved article and then clicks exit or return, in order to better user experience, it is necessary to compare the edited article content with the previously saved content, so as to determine whether to pop up a second popup window for the user to confirm whether to exit the editing page. But sometimes the comparison method returns inconsistent content even though it looks exactly the same visually, and the comparison method writes just fine. This may be because \uFEFF! The Unicode character can be found in quill/blots/cursor, at the end of the source:

The comment says “no width”, but this character is used to process the next character to be typed.

After the event

Use the Quill. Register method to create a custom format and module. For example:

import Quill from 'quill';

const AlignStyle = Quill.import('attributors/style/align');

Quill.register(AlignStyle, true);
Quill.register({'formats/font': FontStyle}, true);

Quill.register('modules/imageDrop', ImageDrop);
Quill.register('modules/clipboard', PlainClipboard);

Copy the code

This article is slightly longer, if there is any mistake, please point out and correct in time!

You are welcome to follow our public account: Refactor, and we will share more interesting and useful articles with you in the future. Thanks for reading