This article is a Chinese translation of the original English text link medium.com/swlh/reimpl…

Or dev. To/daviddalbus… (This is accessible)

The translator said

I am the author of wangEditor and am currently working on a V4.0 refactoring with the development team. The code prior to V4.0 is available at WE-Next and will be available on the official website after release.

For those of you who know rich text editors, Document. execCommand is the core API for implementing rich text on web pages. Unfortunately, Document. execCommand has been deprecated by MDN. And, all the time, each browser for its implementation, details are not completely unified, browser compatibility has been a headache.

In order to solve the above problems, various rich text editors have long had their own exploration. The powerful Google Doc was the first to implement its own rich text editing API. Later, with the popularization and use of online Office, rich text editors at home and abroad are also developing rapidly, and some have developed document. WangEditor will inevitably undergo such self-development and upgrades (once V4.0 is released and features become stable).

How do you implement Document. execCommand yourself? Chinese materials are still relatively few, so I found an English article to translate. If the translation is wrong, you can reply to the comments.

(Original addressJuejin. Cn/post / 686491…, reprint must be approved by the author!


Now the translation begins!!

Now the translation begins!!

Now the translation begins!!

This feature (document.execcommand) is deprecated, and although it is still available in many browsers, it can be removed at any time. So avoid — MDN Web Docs

Document.execcommand () has been marked as obsolete in MDN since some time ago or for some reason. Interestingly, it is not marked as deprecated in all languages (e.g., Chinese, English, not programming languages), such as French and Spanish, which do not mention this 😜.

For DeckDeckGo, an open source slide editor, we developed and published a custom WYSIWYG editor that uses document.execcommand ().

I thought a custom implementation of Document.execcommand () might be the way forward, so it took me quite a while to re-implement it 😄.

Even though the way I did it didn’t look so bad (hopefully), I felt like I had to reinvent the wheel. This is also the reason why I write this article, I hope you can put forward some improvement suggestions, let it more mature and perfect 🙏.

introduce

WYSIWYG editor, its cross-device compatibility is better. It works in PC browsers as well as mobile devices. It can be attached to the top of the attempt (iOS) or the bottom (Android), depending on the keyboard’s behavior.

It can modify text styles (bold, italic, underline, and strikeout), font colors and background colors, alignment, and lists. Some custom slots are even supported.

limited

My own implementation of Document.execcommand () works fine, but it doesn’t support undo yet, unfortunately 😕. You can put forward your own good suggestions.

The target

The next API to be implemented in this article is the following, which can be referred to the MDN documentation.

document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)
Copy the code

The functions to be implemented are

  • boldBold/unbold
  • italicItalic/unitalic
  • underlineSet/ununderline
  • strikeThroughSet/cancel the delete line
  • foreColorModify font color
  • backColorModify the background color

implementation

I like to develop in TS, so the rest of the code will be strongly typed and use interfaces.

export interface ExecCommandStyle {
  style: 'color' | 
         'background-color' | 
         'font-size' | 
         'font-weight' | 
         'font-style' | 
         'text-decoration';
  value: string;
  initial: (element: HTMLElement | null) = > Promise<boolean>;
}
Copy the code

By default, executing document.execCommand creates new elements (such as elements in bold), and I decided to modify the CSS to achieve this effect. For example, value: ‘bold’ can indicate style: ‘font-weight’, and if style: “color” value can be # CCC, like this. The interface also contains an initial function that I use to determine whether to set or remove the style.

Now that the interface is defined, let’s start implementing it and see how the style is applied. The selected text is retrieved through Selection, and its Container is identified. Note that the Container could be a text node or the parent element of a selection.

Also, the second argument containers in the function, which defines a list of elements that the function can apply, default to H1, H2, H3, H4, H5,h6,div. This restriction is introduced so that you don’t have to traverse the entire DOM tree when searching.

export async function execCommandStyle(action: ExecCommandStyle, containers: string) {
  const selection: Selection | null = await getSelection();

  if(! selection) {return;
  }

  const anchorNode: Node = selection.anchorNode;

  if(! anchorNode) {return;
  }

  constcontainer: HTMLElement = anchorNode.nodeType ! == Node.TEXT_NODE && anchorNode.nodeType ! == Node.COMMENT_NODE ? (anchorNodeas HTMLElement) : anchorNode.parentElement;
  // TODO: next chapter
}

async function getSelection() :Promise<Selection | null> {
  if (window && window.getSelection) {
    return window.getSelection();
  } else if (document && document.getSelection) {
    return document.getSelection();
  } else if (document && (document as any).selection) {
    return (document as any).selection.createRange().text;
  }

  return null;
}
Copy the code

To set CSS styles, convert the text selected by the user to a SPAN tag.

Also, I think setting CSS styles is better than always adding tags. For example, if the user selects the same text and sets the background color first to red and then to green, it is definitely better to modify the CSS style directly, rather than generate new elements for the same Settings. So, when I implement it, I’m going to decide whether it’s updateSelection or replaceSelection.

const sameSelection: boolean = container && container.innerText === selection.toString();

if(sameSelection && ! isContainer(containers, container) && container.style[action.style] ! = =undefined) {

    await updateSelection(container, action, containers);

    return;
}

await replaceSelection(container, action, selection, containers);
Copy the code

Update Selection Updates the Selection

Simply put, this means setting new CSS styles for existing elements. async function updateSelection(container: HTMLElement, action: ExecCommandStyle, containers: string) { container.style[action.style] = await getStyleValue(container, action, containers); await cleanChildren(action, container); } Copy the code

Changing styles is more difficult than adding them. For example, for bold or ITALic, the user might want to set, then cancel, then set, then cancel again…

async function getStyleValue(container: HTMLElement, action: ExecCommandStyle, containers: string) :Promise<string> {
  if(! container) {return action.value;
  }

  if (await action.initial(container)) {
    return 'initial';
  }

  const style: Node | null = 
        await findStyleNode(container, action.style, containers);

  if (await action.initial(style as HTMLElement)) {
    return 'initial';
  }

  return action.value;
}
Copy the code

For Bold, the Initial function is a simple property check.

{
  style: 'font-weight'.value: 'bold'.initial: (element: HTMLElement | null) = > 
           Promise.resolve(element && 
                           element.style['font-weight'= = ='bold')}Copy the code

For colors, it’s a bit trickier because color values can be in HEX and RBG formats, both of which need to be supported.

{
  style: this.action,
  value: $event.detail.hex, // Color selected by the user
  initial: (element: HTMLElement | null) = > {
    return new Promise<boolean> (async (resolve) => {
      const rgb: string = await hexToRgb($event.detail.hex);
      resolve(element && (element.style[this.action] === 
              $event.detail.hex || 
              element.style[this.action] === `rgb(${rgb}) `));
  });
}
Copy the code

With initial, I can decide whether to add or remove styles.

But that’s not enough. Because the selection style may inherit its parent element, such as

. So I created a new function, findStyleNode, that will recursively search until it finds an element or container with the same style.

async function findStyleNode(
    node: Node, 
    style: string, 
    containers: string
) :Promise<Node | null> {
  // Just in case
  if (node.nodeName.toUpperCase() === 'HTML' || node.nodeName.toUpperCase() === 'BODY') {
    return null;
  }

  if(! node.parentNode) {return null;
  }

  if (DeckdeckgoInlineEditorUtils.isContainer(containers, node)) {
    return null;
  }

  const hasStyle: boolean =
    (node asHTMLElement).style[style] ! = =null && 
    (node asHTMLElement).style[style] ! = =undefined && 
    (node asHTMLElement).style[style] ! = =' ';

  if (hasStyle) {
    return node;
  }

  return await findStyleNode(node.parentNode, style, containers);
}
Copy the code

Finally, the style is set up and cleanChildren is executed. CleanChildren is also a recursive method, but instead of recursing up the DOM tree, it recurses all the children of the container until it is finished.

async function cleanChildren(action: ExecCommandStyle, span: HTMLSpanElement) {
  if(! span.hasChildNodes()) {return;
  }

  // Clean direct (> *) children with same style
  const children: HTMLElement[] = 
        Array.from(span.children)
             .filter((element: HTMLElement) = > {
                returnelement.style[action.style] ! = =undefined&& element.style[action.style] ! = =' ';
              }) as HTMLElement[];

  if (children && children.length > 0) {
    children.forEach((element: HTMLElement) = > {
      element.style[action.style] = ' ';

      if (element.getAttribute('style') = = =' ' || element.style === null) {
        element.removeAttribute('style'); }}); }// Direct children (> *) may have children (*) to be clean too
  const cleanChildrenChildren: Promise<void= > []Array.from(span.children).map((element: HTMLElement) = > {
      return cleanChildren(action, element);
  	});

  if(! cleanChildrenChildren || cleanChildrenChildren.length <=0) {
    return;
  }

  await Promise.all(cleanChildrenChildren);
}
Copy the code

Replace Selection Replace Selection

Replacing Selection styles is slightly simpler (compared to Update Selection), and using range.Extractcontents gives you a fragment that can be added as content to a new SPAN.

async function replaceSelection(container: HTMLElement, 
                                action: ExecCommandStyle, 
                                selection: Selection, 
                                containers: string) {
  const range: Range = selection.getRangeAt(0);

  const fragment: DocumentFragment = range.extractContents();

  const span: HTMLSpanElement = await createSpan(container, action, containers);
  span.appendChild(fragment);

  await cleanChildren(action, span);
  await flattenChildren(action, span);

  range.insertNode(span);
  selection.selectAllChildren(span);
}
Copy the code

To style the new SPAN, I can just reuse the getStyleValue function from above.

async function createSpan(
	container: HTMLElement, 
    action: ExecCommandStyle, 
    containers: string
) :Promise<HTMLSpanElement> {
  const span: HTMLSpanElement = document.createElement('span');
  span.style[action.style] = await getStyleValue(container, action, containers);

  return span;
}
Copy the code

Once you have created a new span and added a fragment, you need to go to cleanChildren so that the style works for all the child elements. CleanChildren is also the same as described above.

Finally, I tried to avoid a span tag that had no pattern, so I created a flattening children function. It checks the span in the child element and, if there is no style left, turns the span into a plain text node. That is, clean up empty SPAN elements that are useless.

async function flattenChildren(action: ExecCommandStyle, span: HTMLSpanElement) {
  if(! span.hasChildNodes()) {return;
  }

  // Flatten direct (> *) children with no style
  const children: HTMLElement[] =    
      Array.from(span.children).filter((element: HTMLElement) = > {
         const style: string | null = element.getAttribute('style');
         return! style || style ===' ';
      }) as HTMLElement[];

  if (children && children.length > 0) {
    children.forEach((element: HTMLElement) = > {
      const styledChildren: NodeListOf<HTMLElement> = element.querySelectorAll('[style]');
      if(! styledChildren || styledChildren.length ===0) {
        const text: Text = document.createTextNode(element.textContent); element.parentElement.replaceChild(text, element); }});return;
  }

  // Direct children (> *) may have children (*) to flatten too
  const flattenChildrenChildren: Promise<void= > []Array.from(span.children).map((element: HTMLElement) = > {
       return flattenChildren(action, element);
    });

  if(! flattenChildrenChildren || flattenChildrenChildren.length <=0) {
    return;
  }

  await Promise.all(flattenChildrenChildren);
}
Copy the code

In a nutshell

Detailed code for this article can be obtained here

  • the WYSIWYG Web Component
  • ExecCommandStyleinterface
  • Function implementation

If you want to download it locally, you need to clone mono-repo.

conclusion

The article is almost finished, I review again, to be honest, I am not sure if anyone can understand what I wrote 😅. I hope this article has at least piqued your curiosity about WYSIWYG

Try DeckDeckGo next time you do a slide show, and give us some suggestions and good ideas.

To infinity and beyond!

By David