Overall implementation idea

Webpack configuration

Since I’m mainly using TypeScript to dump the code here, we’ll start with webPack to build the basic structure of our project. From the mind map, we can see that the configuration of WebPack is divided into two modes, development environment and online environment, where two files can be written to achieve different configuration modes. Then use — config. /webpack.xxx.config.js in the package.json script to specify the Webpack configuration to use for each command. Since these are the basic configurations of WebPack, I won’t go into details. The specific configurations can be viewed in the warehouse.

Tips:

  • Error: Webpack-cli and webpack-dev-server versions are incompatible

The solution is as simple as dropping the webpack-CLI version down to 3.x. Here are the versions I rely on for the project

Determine the use mode

All things are difficult before they are easy, so I think the first step should be to determine the use mode of this component, through which to define our project structure and the writing mode of file entry. Here we mainly adopt the way introduced by wangEditor using CDN:

  const E = window.Editor;
  const editor = new E('#container');
  editor.create()
Copy the code

The main implementation

Before we start the main code implementation, we need to increase our knowledge base so that we can write the following logic. Let’s start with a few questions:

  1. How can an element be turned into an editable text area?
  2. How can I get the selected text?
  3. How to bold the selected text? Change colors? Set the title?

The above three problems were the first ones that came to my mind when I first started coding, and then we went to collect relevant information to solve these three problems, so that the implementation of a simple rich text editor won’t be so difficult.

Let’s take a look at each of the three problems mentioned above: The browser solves all three problems for us, and there are corresponding apis to help us implement them. Some of the apis may have been deprecated, but browsers still use them, which is probably why many editors implement their own editing engines.

How can an element be turned into an editable text area?

  • contenteditable="true"

  • For an element that wants to become an editable region, you only need to set this property.

For details, see MDN

How can I get the selected text?

This problem is essentially the operation to get the cursor. Here the browser also exposes the corresponding API for us to use

The Selection object

The Selection object represents the range of text or the current position of the caret selected by the user. It represents a text selection on a page that may span multiple elements. Text selection is created by the user dragging the mouse over the text. To get a Selection object for inspection or modification, call window.getSelection().

For Selection we need the isCollapsed property:

  • isCollapsed: true: indicates that the text is not selected

  • isCollapsed: false: indicates that the selected text is in blue

But we usually don’t manipulate that object directly, we manipulate the range object

Range object

Selection objects correspond to the ranges selected by the user, commonly known as drag blue. By default, this function is for only one region. We can use this function like this:

const selObj = window.getSelection();
const range  = selObj.getRangeAt(0);// The argument represents the NTH region
Copy the code

MDN Document introduction

Let’s use two diagrams to illustrate the parameters of the range object:

  • Collapsed: Returns a Boolean value that determines whether the starting and ending ranges are the same.
  • CommonAncestorContainer: Returns the deepest node that contains startContainer and endContainer.
  • EndContainer: Indicates the end node of the range
  • StartContainer: Indicates the start node of the range.
  • StartOffset: Indicates the offset of the starting position of the range.
  • EndOffset: Indicates the offset of the end point in the range.

How to bold the selected text? Change colors? Set the title?

Once we have set the contenteditable=”true” property for the element, we can use Document.execcommand () to manipulate the active edit area.

This method takes three parameters:

  1. ACommandName: a DOMString, the name of the command (see MDN for a list of commands)
  2. AShowDefaultUI: A Boolean indicating whether to display the user interface
  3. AValueArgument: Some commands require additional arguments. Default is null.

use

document.execCommand('bold'.false.undefined) // Make the range text bold, no extra arguments are required, so the third argument can be passed
document.execCommand('fontSize'.false.7) // Change the size of the text in the range. The third parameter is the specified size (1-7). 7 is the maximum font size and 3 is the default
Copy the code

Ok, now we have enough knowledge to implement our editor.

The implementation process

Editor

Let’s first complete the main area of the editor. (Only the key code is shown here, and the detailed code can be viewed in the warehouse)

Depending on how we use this constructor, we can determine that we need a cteate method to build the editor.

/** The root node passed by the user */
    public wrapperElem: HTMLElement | null
    /** Menu element */
    public menuElem: Menu
    /** as the element of the edit area */
    public editorElement: HTMLElement | null

    constructor(selector) {
        this.wrapperElem = selectElem(selector, 'content-wrapper') // selectElem this is my own method for selecting elements
        this.menuElem = new Menu(this)
        this.editorElement = createElem('div'.'editor-wrapper') // createElem creates a new element
    }
  /** Generate the editor */
    public create(): void {
        // Initialize the menu bar
        this.menuElem.init()
        // Set selected DOM elements to editable regions
        this.editorElement? .setAttribute('contenteditable'.'true')
        // fix: This element is added to wrap the p tag around user input
        this.createPlaceholder()
        // Bind events
        this.bindEvent()
        // Add the editor element to the parent element
        this.wrapperElem? .appendChild(this.editorElement!)
    }
Copy the code

Tips: Be careful here because I pass in a selector as the parent of the entire editor, rather than making the element editable. The advantage of this is that the generated menu elements and the main editing area can be laid out within the parent element.

One other thing to note here is that with the contenteditable=”true” property set, the browser defaults to putting text directly into the current editable element when we enter text in the edit area, and to using the div element after a line break, so we need to modify the browser’s default behavior here


    /** * the user's input is in the p-tag wrap */
    private createPlaceholder(): void {
        const pElem = createElem('p')
        // We need to note that the p tag needs to use the BR tag for space
        pElem.innerHTML = `<br>`
        this.editorElement? .appendChild(pElem) }Copy the code

By default, we type in the p tag, br is a placeholder, and without that element, it would not be in the P tag the first time we type.

Tips: We also need to note that if the user hits the delete button for the first time without typing any text, our placeholder P tag will be deleted, so we need to add an event listener to capture the user’s keystroke

/** Keyboard incident */
        document.addEventListener('keyup'.(e: KeyboardEvent) = > {
            // Delete button
            if (e.keyCode === 8) {
                // Prevent the user from pressing the delete key, resulting in text cannot be wrapped by the P tag
                if (!this.editorElement? .innerHTML) {this.createPlaceholder()
                }
            } 
         }
Copy the code

Now that the input part of the editor is complete, let’s consider text selection in the editor.

Here is also by listening to the event to capture the text selected by the user, here is the mouse lifted event listening

  // Cache the selected text
  export let cachedRange: Range | null | undefined = null
  /** Mouse click event */
        document.addEventListener('mouseup'.() = > {
            const selection = window.getSelection()
            // isCollapsed = false
            if(! selection? .isCollapsed) { cachedRange = selection? .getRangeAt(0)}})Copy the code

We need to cache the range here, because when we click on an item in the menu bar after selecting the text, we will retrieve the range, invalidating the text we previously selected. This will not set the menu function correctly.

This completes the main function of the editor area, followed by the function from the Menu Menu.

Menu

First of all, the Menu Menu needs a list of menus, that is, the functions we need to display. Here, I created a separate file to manage the list

export interface MenuBtnListProps {
	/** corresponds to the command */command? :string
    /** button display icon */
    icon: string
    /** child of the button, for example set h1/h2/h3 title */children? : MenuBtnListProps[] }// Menu item button
export const menuList: MenuBtnListProps[] = [
    {
        icon: 'heading'.children: [{icon: 'h-1'.command: 'fontSize-6' },
            { icon: 'h-2'.command: 'fontSize-5' },
            { icon: 'h-3'.command: 'fontSize-4' },
            { icon: 'h-4'.command: 'fontSize-3' },
            { icon: 'h-5'.command: 'fontSize-2' },
            { icon: 'h-6'.command: 'fontSize-1'},],}, {command: 'bold'.icon: 'bold'}, {icon: 'font-color'.children: [{icon: 'font-color'.command: 'foreColor-black' },
            { icon: 'red'.command: 'foreColor-red' },
            { icon: 'yellow'.command: 'foreColor-yellow' },
            { icon: 'blue'.command: 'foreColor-blue' },
            { icon: 'green'.command: 'foreColor-green'},],},]Copy the code

The icon I use here is the font generated by aliyun’s online ICONS. It is also very simple to use, as long as online CSS is introduced into HTML (of course, it can also be downloaded locally).

<! -- Introducing online ICONS -->
 <link rel="stylesheet" href="//at.alicdn.com/t/xxxxxxx.css">
Copy the code

Then set the corresponding class name

<! -- Introducing online ICONS -->
<span class="iconfont icon-heading"></span>
Copy the code

Next comes the initialization menu

 public init(): void {
        const ulElem = createElem('ul')
        ulElem.classList.add('menu-wrapper')
        this.btnList? .forEach(_item= > {
            const liElem = createElem('li'.`menu-item iconfont icon-${_item.icon}`)
            liElem.setAttribute('flag', _item.flag!)
            // Bind events using event delegates
            this.bindEvent(liElem)
            // Add custom attributes
            if(_item? .command) { liElem.setAttribute('command', _item? .command) }// If there are children
            if (_item.children) {
                const pElem = createElem('p'.'menu-item-child')
                // Iterate over the child element to generate the corresponding node
                _item.children.forEach((_child: MenuBtnListProps) = > {
                    const spanElem = createElem('span'.`iconfont icon-${_child.icon}`)
                    pElem.appendChild(spanElem)
                    // Add the corresponding attributes to the child
                    if(_child? .command) { spanElem.setAttribute('command', _child? .command) } liElem.appendChild(pElem) }) } ulElem.appendChild(liElem) })this.editor.wrapperElem! .appendChild(ulElem) }/** * bind the event of clicking the menu bar */
    public bindEvent(element: HTMLElement): void {
        element.addEventListener('click'.e= > {
            const clickElem = e.target as HTMLElement
            if(! cachedRange) {// If no element is selected, create a new range and apply the new rules to subsequent text
                // Note that you need to move the cursor to the end
                this.editor.editorElement? .focus()const range = document.createRange()
                range.selectNodeContents(this.editor.editorElement!)
                range.collapse(false)
                const selection = window.getSelection() selection? .removeAllRanges() selection? .addRange(range) }else {
                const selection = window.getSelection() selection? .removeAllRanges() selection? .addRange(cachedRange!) }this.handleCommand(clickElem)
        })
    }

    /** * processes each menu item command */
    public handleCommand(target: HTMLElement): void {
    	// command_KV : ["foreColor", "red"]
        const command_KV: string[] = target? .getAttribute('command')? .split(The '-')! command_KV? .length &&document.execCommand(command_KV[0].false, command_KV[1] | |undefined)}Copy the code

When we initialize it, we just walk through our MenuBtnList and create the corresponding elements, and then set the corresponding attributes. The important thing to note here is that we set the command corresponding to each button as a custom property, so that when we click the corresponding button we can get the property and then execute the corresponding command

In the handleCommand method, you parse the custom command property and execute the document.execCommand method to execute the command

Tips: One thing to note in the menu-bound event approach is that I’m determining if cachedRange is currently cached, considering that: When the user clicks the button in the menu without selecting the Chinese text, it should be the text input later that applies the corresponding function of the button, so a new range is created here to complete this function. According to the above code, we can see that the focus should be set in the editing area after clicking the menu button, and then create the corresponding range. If no new range is added here, the cursor will always be in the front after focusing, so we actually move the cursor to the last position.

This completes the entire editor code. I have only made a brief description here, sorting out the important code and problems encountered during the writing process. There may be some problems that have not been taken into account in the hope that you can put forward ~ practical effect:

(Please ignore the green background caused by my browser plugin (^▽^))

conclusion

  • In the process, I learned apis that I had barely heard of before, such as range, Selection, and Contenteditable, which really broadened my knowledge
  • In addition, I experienced a process from 0 to 1 in this process, because I may use the existing framework to do the daily work, and rarely configure webpack. This time, I also reviewed the previous engineering related content ~
  • Knowing the basic flow of a rich text editor should be the most important. Because before I used rich text editor, I just blindly used it, and did not say to explore some of its principles, this time I deeply realized part of the workflow of rich text editor