Image source unsplash.com/photos/tWjz…

What is a rich text editor?

Rich Text Editor, or RTE for short, is a wySIWYG Text Editor that can be embedded in the browser. It is a text editor to solve the general user different HTML and other page tags but need to set the font color, size, style and other information on the web page.

A rich text editor commonly used on the front end

  • WangEditor: wangEditor is a rich text editor for the Web developed in Typescript. It is lightweight, concise, easy to use, open source and free.
  • Quill: Quill is a modern WYSIWYG editor designed for compatibility and extensibility.
  • TinyMCE: TinyMCE is an easy-to-use, powerful WYSIWYG rich text editor.

The basic principle of

For browsers that support rich text editing, by setting the designMode property of Document to ON, ExecCommand (aCommandName, aShowDefaultUI, aValueArgument). For example, we want to make the font bold, ExecCommand (‘bold’, false).

document.execCommand

When an HTML document switches to design mode, the document exposes the execCommand method, which allows commands to be run to manipulate elements in the editable content area. Syntax: bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument). The return value is a Boolean. The Document. execCommand API is deprecated and could be deleted at any time, so using it now is risky. This API is not a standard API, but a private API of Internet Explorer. It was introduced in Internet Explorer 9, and has been supported by Chrome/Firefix/Opera for several years, but it has not become a standard.

The main reason for abandonment

  • Security issues, inUnauthorized UserCan perform some sensitive operations;
  • Browsers do not behave consistently, and different browsers produce different results.
  • The content generated by Document. execCommand produces a lotUnnecessary labels;
  • It’s because this is aSynchronized methodsAnd operatedDOM Object that blocks page rendering and script execution because it hasn’t been done yetPromiseSo it’s not designed to be asynchronous.

Rich text editor technology stage division

  • Level 0Is the initial stage of the editor, representing the implementation of the older generation of editors;
  • Level 1Developed from the first stage, has a certain degree of advanced, but also introduced some mainstream programming ideas, rich text content has a certain degree of abstraction;
  • Level 2The third stage, completely independent of the browser editing ability, independent cursor and typesetting;

How to implement a rich text editor without using Document. execCommand?

Clipboard

The Clipboard interface implements the Clipboard API and provides read and write access to the system Clipboard if the user grants the appropriate permissions. In Web applications, the Clipboard API can be used to implement cut, copy, and paste capabilities. The system clipboard is exposed in the global property navigator.clipboard. All clipboard API methods are asynchronous; They return a Promise object that is executed after the clipboard access is complete, but it’s not very compatible.

To establishAbstract data layer

Rich text editors are complicated, and it is difficult to abstract data structures on this basis, but without data layers, higher-level functions cannot be achieved. With the foundation of the data layer, advanced features such as flexible custom rendering, cross-platform, and online multiplayer collaboration can be realized.

Take quill, a rich text editor, for example. Quilljs comes with a data system to support content production, Parchment and Delta.

Parchment

Parchment was a document model of Quill. It is a parallel tree structure and provides the ability to edit content, such as Quill. A Parchment tree is composed of Blots which reflect a DOM corresponding node. Blots can provide structure, format, and content, or just content.

The official example

import Parchment from 'parchment';

class LinkBlot extends Parchment.Inline {
  static create(url) {
    let node = super.create();
    node.setAttribute('href', url);
    node.setAttribute('target'.'_blank');
    node.setAttribute('title', node.textContent);
    return node;
  }

  static formats(domNode) {
    return domNode.getAttribute('href') | |true;
  }

  format(name, value) {
    if (name === 'link' && value) {
      this.domNode.setAttribute('href', value);
    } else {
      super.format(name, value); }}formats() {
    let formats = super.formats();
    formats['link'] = LinkBlot.formats(this.domNode);
    return formats;
  }
}
LinkBlot.blotName = 'link';
LinkBlot.tagName = 'A';

Parchment.register(LinkBlot);
Copy the code
Parchment Framework Analysis

According to the figure above, Blot can be roughly divided into two types:

  1. The first is inherited fromParentBlot
  • ContainerBlotRepresents the container node;
  • ScrollBlotRepresents the root node of the document and cannot be formatted.
  • BlockBlotRepresents block-level nodes, formatted nodes;
  • InlineBlotInline nodes, formatted nodes;
  1. The second is inheritedLeafBlot
  • EmbedBlotEmbedded node;
  • TextBlotText node;

The main difference between these two types is that the nodes inherited from LeafBlot belong to the same atomic node and cannot be used as the parent node. So the leaf node, there’s no way to operate on the child node.

Blots

Blots are an essential part of the Parchment document. Several basic implementations are provided, such as Block, Inline, and Embed.

export interface Blot extends LinkedNode {
  scroll: Parent;
  parent: Parent;
  prev: Blot;
  next: Blot;
  domNode: Node;

  attach(): void;
  clone(): Blot;
  detach(): void; insertInto(parentBlot: Parent, refBlot? : Blot):void;
  isolate(index: number, length: number): Blot; offset(root? : Blot): number; remove():void;
  replace(target: Blot): void;
  replaceWith(name: string, value: any): Blot; replaceWith(replacement: Blot): Blot; split(index: number, force? : boolean): Blot; wrap(name: string,value: any): Parent;
  wrap(wrapper: Parent): Parent;

  deleteAt(index: number, length: number): void;
  formatAt(index: number, length: number, name: string, value: any): void;
  insertAt(index: number, value: string, def? : any):void;
  optimize(context: { [key: string]: any }): void;
  optimize(mutations: MutationRecord[], context: { [key: string]: any }): void;
  update(mutations: MutationRecord[], context: { [key: string]: any }): void;
}

Copy the code
interface LinkedNode {
  prev: LinkedNode | null;
  next: LinkedNode | null;

  length(): number;
}

export default LinkedNode;
Copy the code

As you can see from the above code, Blot uses a linked list as the storage structure. Linked lists are a slightly more complex data structure than arrays, so let’s look at the differences in the underlying storage structure.

As we can see from the figure above, arrays require a contiguous memory space to store, which is relatively high memory requirements. If we apply for an array of 10 MB size, the application will fail even if the total free space of memory is greater than 10 MB if there is no contiguous enough storage space in memory. Linked lists, on the other hand, do not require a contiguous memory space. Instead, they use “Pointers” to concatenate a set of discrete memory blocks, so if we had requested a 10 MB linked list, we would have no problem.

The figure above is a single linked list, and we habitually call the first node the head node and the last node the tail node. The header is used to record the base address of the linked list. And with that, we can go through the whole list. The special thing about endpoints is that instead of pointing to the next node, the pointer points to an empty address, NULL, indicating that this is the last node on the list.

In order to maintain the continuity of memory data, a large number of data moves are needed during the operation of array insertion and deletion, so the time complexity is O(n). To insert or delete data in a linked list, we do not need to move nodes in order to maintain the continuity of memory, because the storage space in a linked list is not contiguous. So, inserting and deleting a piece of data into a linked list is very fast.

Rich text editing involves a lot of insertion and deletion, so it is more efficient to choose linked lists.

Delta

Delta is a JSON-based format for describing content and modifications. You can describe any rich text document, including text and formatting information. Delta is a digitized representation of a user operation, and a Delta object has a unique bidirectional correspondence to a DOM structure. Delta is a subset of JSON that contains only one OPS property, whose value is an array of objects, each entry representing an operation to the editor.

// Document with text "Gandalf the Grey"
// with "Gandalf" bolded, and "Grey" in grey
const delta = new Delta([
  { insert: 'Gandalf'.attributes: { bold: true}}, {insert: ' the ' },
  { insert: 'Grey'.attributes: { color: '#ccc'}}]);// Change intended to be applied to above:
// Keep the first 12 characters, delete the next 4,
// and insert a white 'White'
const death = new Delta().retain(12)
                         .delete(4)
                         .insert('White', { color: '#fff' });
/ / {
// ops: [
// { retain: 12 },
// { delete: 4 },
// { insert: 'White', attributes: { color: '#fff' } }
/ /]
// }

// Applying the above:
const restored = delta.compose(death);
/ / {
// ops: [
// { insert: 'Gandalf', attributes: { bold: true } },
// { insert: ' the ' },
// { insert: 'White', attributes: { color: '#fff' } }
/ /]
// }
Copy the code

Delta has only three operations and one attribute.

Three operations:

  • Insert the insert:
  • Retain: keep
  • Delete: delete

1 attributes:

  • Attributes: format attributes

fast-diff

The fast-diff library is used to compare the differences between two deltas. The fast-diff library can only handle diff of string data. Fast-diff is a simplified version of Google’s diff-match-patch library imported into node.js.

The diff – match – patch: github.com/google/diff…

Fast – diff: github.com/jhchen/fast…

Diff method location in source: github.com/quilljs/del…

const NULL_CHARACTER = String.fromCharCode(0); // Placeholder char for embed in diff()diff(other: Delta, cursor? : number | diff.CursorInfo): Delta {if (this.ops === other.ops) {
      return new Delta();
    }
    const strings = [this, other].map((delta) = > {
      return delta
        .map((op) = > {
          if(op.insert ! =null) {
            return typeof op.insert === 'string' ? op.insert : NULL_CHARACTER;
          }
          const prep = delta === other ? 'on' : 'with';
          throw new Error('diff() called ' + prep + ' non-document');
        })
        .join(' '); }); . }Copy the code

Insert (); insert (); insert (); insert (); insert (); insert (); insert (); insert (); insert (); It’s a big string, and it’s fast-diff processed. The main reason for converting to a string and then diff is that diff between strings is faster.

How do I synchronize DOM changes to delta?

A ScrollBlot is a top-level ContainerBlot, or root Blot, that wraps all blots and manages content changes in the editor. The ScrollBlot creates a MutationObserver that monitors DOM updates. The UPDATE method of ScrollBlot is called when DOM is updated. Update method is rewritten in Quill’s Scroll blot, which throws out SCROLL_UPDATE event and mutations parameter.

 update(mutations){...if (mutations.length > 0) {
      this.emitter.emit(Emitter.events.SCROLL_BEFORE_UPDATE, source, mutations); }... }Copy the code

The Editor listens for the SCROLL_UPDATE event, which triggers the Editor’s update method, passes in the mutations parameter, and in the Editor’s update method, The corresponding delta array will be constructed based on mutations, which will be merged with the existing delta to keep the current delta up to date.

this.emitter.on(Emitter.events.SCROLL_UPDATE, (source, mutations) = > {
      const oldRange = this.selection.lastRange;
      const [newRange] = this.selection.getRange();
      const selectionInfo =
        oldRange && newRange ? { oldRange, newRange } : undefined;
      modify.call(
        this.() = > this.editor.update(null, mutations, selectionInfo),
        source,
      );
    });
    
    

Copy the code

Location in the source github.com/quilljs/qui…

update(change, mutations = [], selectionInfo = undefined) {
    const oldDelta = this.delta;
    if (
      mutations.length === 1 &&
      mutations[0].type === 'characterData' &&
      mutations[0].target.data.match(ASCII) &&
      this.scroll.find(mutations[0].target)
    ) {
      // Optimization for character changes
      const textBlot = this.scroll.find(mutations[0].target);
      const formats = bubbleFormats(textBlot);
      const index = textBlot.offset(this.scroll);
      const oldValue = mutations[0].oldValue.replace(CursorBlot.CONTENTS, ' ');
      const oldText = new Delta().insert(oldValue);
      const newText = new Delta().insert(textBlot.value());
      const relativeSelectionInfo = selectionInfo && {
        oldRange: shiftRange(selectionInfo.oldRange, -index),
        newRange: shiftRange(selectionInfo.newRange, -index),
      };
      const diffDelta = new Delta()
        .retain(index)
        .concat(oldText.diff(newText, relativeSelectionInfo));
      change = diffDelta.reduce((delta, op) = > {
        if (op.insert) {
          return delta.insert(op.insert, formats);
        }
        return delta.push(op);
      }, new Delta());
      this.delta = oldDelta.compose(change);
    } else {
      this.delta = this.getDelta();
      if(! change || ! isEqual(oldDelta.compose(change),this.delta)) {
        change = oldDelta.diff(this.delta, selectionInfo); }}returnchange; }}Copy the code

Update methods in the editor github.com/quilljs/qui…

How do I synchronize delta changes to the DOM?

When the delta is modified, the delta array is iterated, the Blot is generated, the Attributor is generated, and then the DOM structure is generated, and then the format operation is performed.

The main API

  • setContents
setContents(delta: Delta, source: String = 'api'): Delta
Copy the code

SetContents overwrites the content of the editor with the given content. Content should end with a new line or newline character. Return a changed Delta. If the given Delta has no invalid operations, it is passed as a new Delta. The source of the action may be ‘user’, ‘API’, or ‘silent’.

Github.com/quilljs/qui…

/ / the source code
 setContents(delta, source = Emitter.sources.API) {
    return modify.call(
      this.() = > {
        delta = new Delta(delta);
        const length = this.getLength();
        // Quill will set empty editor to \n
        const delete1 = this.editor.deleteText(0, length);
        // delta always applied before existing content
        const applied = this.editor.applyDelta(delta);
        // Remove extra \n from empty editor initialization
        const delete2 = this.editor.deleteText(this.getLength() - 1.1);
        return delete1.compose(applied).compose(delete2);
      },
      source,
    );
  }
Copy the code
quill.setContents([
  { insert: 'Hello ' },
  { insert: 'World! '.attributes: { bold: true}}, {insert: '\n'}]);Copy the code
  • applyDelta

ApplyDelta applies and renders the incoming Delta data to the editor.

/ / applyDelta source code
applyDelta(delta) {
    let consumeNextNewline = false;
    this.scroll.update();
    let scrollLength = this.scroll.length();
    this.scroll.batchStart();
    const normalizedDelta = normalizeDelta(delta);
    normalizedDelta.reduce((index, op) = > {
      const length = op.retain || op.delete || op.insert.length || 1;
      let attributes = op.attributes || {};
       // 1. Insert text
      if(op.insert ! =null) {
        if (typeof op.insert === 'string') {
          let text = op.insert;
          if (text.endsWith('\n') && consumeNextNewline) {
            consumeNextNewline = false;
            text = text.slice(0, -1);
          }
          if (
            (index >= scrollLength ||
              this.scroll.descendant(BlockEmbed, index)[0]) &&
            !text.endsWith('\n')
          ) {
            consumeNextNewline = true;
          }
          this.scroll.insertAt(index, text);
          const [line, offset] = this.scroll.line(index);
          let formats = merge({}, bubbleFormats(line));
          if (line instanceof Block) {
            const [leaf] = line.descendant(LeafBlot, offset);
            formats = merge(formats, bubbleFormats(leaf));
          }
          attributes = AttributeMap.diff(formats, attributes) || {};
        } else if (typeof op.insert === 'object') {
          const key = Object.keys(op.insert)[0]; // There should only be one key
          if (key == null) return index;
          this.scroll.insertAt(index, key, op.insert[key]);
        }
        scrollLength += length;
      }
      // 2. Format text
      Object.keys(attributes).forEach(name= > {
        this.scroll.formatAt(index, length, name, attributes[name]);
      });
      return index + length;
    }, 0);
    normalizedDelta.reduce((index, op) = > {
      if (typeof op.delete === 'number') {
        this.scroll.deleteAt(index, op.delete);
        return index;
      }
      return index + (op.retain || op.insert.length || 1);
    }, 0);
    this.scroll.batchEnd();
    this.scroll.optimize();
    return this.update(normalizedDelta);
  }
Copy the code

The sample


    <div id="editor">
      // Content area
    </div>
    <script src="https://cdn.quilljs.com/1.0.0/quill.js"></script><! -- Initialize Quill editor --><script>
        var editor = new Quill('#editor', {
            modules: { toolbar: [['bold'.'italic'], ['link'.'image']]},theme: 'snow'
        });
    </script>
Copy the code

The diagram below:

When we edit the field DOM in the area,

  • setContents
setContents(delta: Delta, source: String = 'api'): Delta
Copy the code

SetContents overwrites the content of the editor with the given content. Content should end with a new line or newline character. Return a changed Delta. If the given Delta has no invalid operations, it is passed as a new Delta. The source of the action may be ‘user’, ‘API’, or ‘silent’. The default is API.

SetContents:github.com/quilljs/qui…

/ / the source code
 setContents(delta, source = Emitter.sources.API) {
    return modify.call(
      this.() = > {
        delta = new Delta(delta);
        const length = this.getLength();
        // When the rich text editor is empty, quill inserts \n;
        const delete1 = this.editor.deleteText(0, length);
        // Delta is always applied before existing content
        const applied = this.editor.applyDelta(delta);
        // Remove extra \n from empty editor initialization
        const delete2 = this.editor.deleteText(this.getLength() - 1.1);
        return delete1.compose(applied).compose(delete2);
      },
      source,
    );
  }
Copy the code
quill.setContents([
  { insert: 'Welcome to wangEditor rich text' },
  { insert: 'Editor! '.attributes: { bold: true}}, {insert: '\n'}]);Copy the code

We know the change in editor text and who triggered it by passing in the Delta object and the source operation source, rendering the incoming Delta data to the editor by calling the applyDelta method in the method.

  • updateContents (delta: Delta, source: String = ‘api’): Delta
updateContents(delta: Delta, source: String = 'api'): Delta
Copy the code
quill/core/quill.js

updateContents(delta, source = Emitter.sources.API) {
    return modify.call(
      this.() = > {
        delta = new Delta(delta);
        return this.editor.applyDelta(delta, source);
      },
      source,
      true,); }Copy the code

Update ontents is relatively simple compared to setContents, and doesn’t do anything to ‘\n’ because setContents handles it. However, there is an additional input parameter ‘true’. By looking at the source code of the modify method, there is an additional input parameter to obtain the user’s selection range.

function modify(modifier, source, index, shift) {...let range = index == null ? null : this.getSelection();// Get the user's selection range
  const oldDelta = this.editor.delta;
  const change = modifier();
  if(range ! =null) {
    if (index === true) {
      index = range.index; // eslint-disable-line prefer-destructuring
    }
    if (shift == null) {
      range = shiftRange(range, change, source);
    } else if(shift ! = =0) {
      range = shiftRange(range, index, shift, source);
    }
    this.setSelection(range, Emitter.sources.SILENT); }...return change;
}
Copy the code

The index parameter is an argument to true, the third argument passed to the updateContents method.

When we select a section of text with the mouse, we use the getSelection method to get the range selected by the mouse. This parameter is passed because Quill needs to know the scope of the update when the user updates the content.

  • getContents
  getContents(index: Number = 0.length: Number = remaining): Delta
Copy the code

The getContents method is used to retrieve the contents of the editor, returning the format data represented by the Delta object. This method takes two parameters, index and length, which indicate the position and length of the content to be retrieved. With these two parameters, the content can be retrieved accurately.

quill/core/quill.js

getContents(index = 0, length = this.getLength() - index){...return this.editor.getContents(index, length);
  }
Copy the code
quill/core/editor.js
  applyDelta(delta){...getContents(index, length) {
    return this.delta.slice(index, index + length);// Return the intercepted content}... }Copy the code

So we know from the source code above that by calling getContents on quill we’re going to call getContents on applyDelta and pass in index, length, The content is retrieved by intercepting the delta passed in the applyDelta method.

  • getSelection
getSelection(focus = false) : {index: Number.length: Number }
Copy the code

GetSelection retrieves the user’s selection range, and may return NULL if the editor has no focus.

/ / the source code
getSelection(focus = false) {
    if (focus) this.focus();
    this.update();
    return this.selection.getRange()[0];// getRange returns an array, the first item of which is a range object
  }
Copy the code

The getSelection method returns a Range object containing the index and Length properties, which tell us which region the user selected.

Format the contents of Quill

The Toolbar Module allows users to format the contents of Quill, which can be configured with a custom container and handler, one of which is listed below.

More configurations quilljs.com/docs/module…

var editor = new Quill('#editor', {
            modules: { toolbar: [['bold'.'italic'], ['link'.'image']]},theme: 'snow'
});
Copy the code

rendering

When we initialize the editor, we first add the class name starting with QL – to the Toolbar option Button button.

Next, register a click event for each button by intercepting the string following the class name QL – as format. With the format retrieved, I can add attributes to the delta.

quill/modules/toolbar.js

// Get all the buttons in the container, select
 Array.from(this.container.querySelectorAll('button, select')).forEach(
      input= > {
        this.attach(input); });attach(input) {
    let format = Array.from(input.classList).find(className= > {
      return className.indexOf('ql-') = = =0;
    });
    if(! format)return;
    // Intercepts the class name ql- followed by a string as format
    format = format.slice('ql-'.length); .const eventName = input.tagName === 'SELECT' ? 'change' : 'click';
    input.addEventListener(eventName, e= > {// Register events. }); . }Copy the code

Using the click event and the format value, and the getSelection method returns an object containing the index subscript and the length length, setting the attributes: {bold: true} attribute to the selected area to make the text bold.

When you click unbold, Quill’s removeFormat method is called, passing in the index, Length, and source parameters.

quill/core/quill.js

 removeFormat(index, length, source) {
    [index, length, , source] = overload(index, length, source);
    return modify.call(
      this.() = > {
        return this.editor.removeFormat(index, length);
      },
      source,
      index,
    );
  }
Copy the code

The removeFormat method of the Editor is then called.

quill/core/editor.js

 removeFormat(index, length) {
    const text = this.getText(index, length);// Get the text to be formatted
    const [line, offset] = this.scroll.line(index + length);
    let suffixLength = 0;
    let suffix = new Delta();
    if(line ! =null) {
      suffixLength = line.length() - offset;
      suffix = line
        .delta()
        .slice(offset, offset + suffixLength - 1)
        .insert('\n');// Gets the delta ops value to remove selected text to be formatted
    }
    const contents = this.getContents(index, length + suffixLength);
    const diff = contents.diff(new Delta().insert(text).concat(suffix));// Perform diff algorithm
    const delta = new Delta().retain(index).concat(diff);
    return this.applyDelta(delta);
  }
Copy the code
  • const text = this.getText(index, length) gets the text to be unformatted

  • suffix = line.delta().slice(offset, offset + suffixLength - 1).insert('\n')Gets the delta value to remove selected text after formatting

  • Const contents = this.getContents(index, length + suffixLength) gets the delta value of the selected unformatted text node and the following node

  • Const diff = contents.diff(new Delta().insert(text).concat(suffix)) calculates the text node attributes to change

  • Const delta = new delta ().retain(index).concat(diff) gets the formatting node and the delta value before it

  • Return this.applyDelta(delta) updates the DOM node

The resources

  • Quilljs website
  • Content rendering mechanism for Quill, a modern rich text editor