DoneJS StealJS jQuery++ FuncUnit DocumentJS
6.0.1
5.33.2 4.3.0 3.14.1 2.3.35
  • About
  • Guides
  • API Docs
  • Community
  • Contributing
  • Bitovi
    • Bitovi.com
    • Blog
    • Design
    • Development
    • Training
    • Open Source
    • About
    • Contact Us
  • About
  • Guides
    • getting started
      • CRUD Guide
      • Setting Up CanJS
      • Technology Overview
    • topics
      • HTML
      • Routing
      • Service Layer
      • Debugging
      • Forms
      • Testing
      • Logic
      • Server-Side Rendering
    • app guides
      • Chat Guide
      • TodoMVC Guide
      • TodoMVC with StealJS
    • beginner recipes
      • Canvas Clock
      • Credit Card
      • File Navigator
      • Signup and Login
      • Video Player
    • intermediate recipes
      • CTA Bus Map
      • Multiple Modals
      • Text Editor
      • Tinder Carousel
    • advanced recipes
      • Credit Card
      • File Navigator
      • Playlist Editor
      • Search, List, Details
    • upgrade
      • Migrating to CanJS 3
      • Migrating to CanJS 4
      • Migrating to CanJS 5
      • Migrating to CanJS 6
      • Using Codemods
    • other
      • Reading the API Docs
  • API Docs
  • Community
  • Contributing
  • GitHub
  • Twitter
  • Chat
  • Forum
  • News
Bitovi

Text Editor

  • Edit on GitHub

This intermediate guide walks you through building a basic rich text editor.

In this guide you will learn how to:

  • Use document.execCommand to change the HTML and copy text to the clipboard.
  • The basics of the Range and Selection APIs.
  • Walk the DOM in unusual ways.

The final widget looks like:

See the Pen CanJS 6 Text Editor by Bitovi (@bitovi) on CodePen.

The following sections are broken down into the following parts:

  • The problem — A description of what the section is trying to accomplish.
  • What you need to know — Information about CanJS that is useful for solving the problem.
  • How to verify it works - How to make sure the solution works if it’s not obvious.
  • The solution — The solution to the problem.

Setup

START THIS TUTORIAL BY CLICKING THE “EDIT ON CODEPEN” BUTTON IN THE TOP RIGHT CORNER OF THE FOLLOWING EMBED:

See the Pen CanJS 6 Text Editor by Bitovi (@bitovi) on CodePen.

This CodePen:

  • Loads CanJS (import { StacheElement } from "//unpkg.com/can@6/core.mjs").
  • Implements 3 helper functions we will use later: siblingThenParentUntil, splitRangeStart and splitRangeEnd. These are hidden out of sight in the HTML tab.
  • Mocks out the signature for helper functions we will implement later: getElementsInRange and rangeContains. These are in the JS tab.

The problem

  • Set up a basic CanJS app by creating a <rich-text-editor> element.
  • The <rich-text-editor> element should add a contenteditable <div> with an editbox class name to the page. The <div> should have the following default inner content:
    <ol>
      <li>Learn <b>about</b> CanJS.</li>
      <li>Learn <i>execCommand</i>.</li>
      <li>Learn about selection and ranges.</li>
      <li>Get Funky.</li>
    </ol>
    <div>Celebrate!</div>
    
I’m sorry; your browser doesn’t support HTML5 video in WebM with VP8/VP9 or MP4 with H.264.

What you need to know

To set up a basic CanJS application, you define a custom element in JavaScript and use the custom element in your page’s HTML.

To define a custom element, extend can-stache-element and register it with a tag name that matches the name of your custom element. For example:

class RichTextEditor extends StacheElement {}
customElements.define("rich-text-editor", RichTextEditor);

Then you can use this tag in your HTML page:

<rich-text-editor></rich-text-editor>

But this doesn’t do anything. Components add their own HTML through their view property:

class RichTextEditor extends StacheElement {
  static view = `
    <h2>I am a rich-text-editor!</h2>
  `;
}
customElements.define("rich-text-editor", RichTextEditor);

Now the H2 element in the view will show up within the <rich-text-editor> element:

<rich-text-editor><h2>I am a rich-text-editor!</h2></rich-text-editor>

To make an element editable, set the contenteditable property to "true". Once an element’s content is editable, the user can change the text and HTML structure of that element by typing and copying and pasting text.

The solution

Update the JavaScript tab to:

import { StacheElement } from "//unpkg.com/can@5/core.mjs";

class RichTextEditor extends StacheElement {
  static view = `
    <div class="editbox" contenteditable="true">
      <ol>
        <li>Learn <b>about</b> CanJS.</li>
        <li>Learn <i>execCommand</i>.</li>
        <li>Learn about selection and ranges.</li>
        <li>Get Funky.</li>
      </ol>
      <div>Celebrate!</div>
    </div>
  `;
}

customElements.define("rich-text-editor", RichTextEditor);

function getElementsInRange(range, wrapNodeName) {}

function rangeContains(outer, inner) {}

Update the HTML <body> element to:

<h1>Composer</h1>
<rich-text-editor></rich-text-editor>

<script>
// from start, this will try `direction` (nextSibling or previousSibling)
// and call `callback` with each sibling until there are no more siblings
// it will then move up the parent.  It will end with the parent’s parent is `parent`.
function siblingThenParentUntil(direction, start, parent, callback) {
  let cur = start;
  while (cur.parentNode !== parent) {
    if (cur[direction]) {
      // move to sibling
      cur = cur[direction];
      callback(cur);
    } else {
      // move to parent
      cur = cur.parentNode;
    }
  }
  return cur;
}

function splitRangeStart(range, wrapNodeName) {
  const startContainer = range.startContainer;
  const startWrap = document.createElement(wrapNodeName);
  startWrap.textContent = startContainer.nodeValue.substr(range.startOffset);
  startContainer.nodeValue = startContainer.nodeValue.substr(
    0,
    range.startOffset
  );
  startContainer.parentNode.insertBefore(startWrap, startContainer.nextSibling);
  return startWrap;
}

function splitRangeEnd(range, wrapNodeName) {
  const endContainer = range.endContainer;
  const endWrap = document.createElement(wrapNodeName);
  endWrap.textContent = endContainer.nodeValue.substr(0, range.endOffset);
  endContainer.nodeValue = endContainer.nodeValue.substr(range.endOffset);
  endContainer.parentNode.insertBefore(endWrap, endContainer);
  return endWrap;
}
</script>
</body>

Add a bold button

The problem

  • Add a <button> that (when clicked) will bold the text the user selected.
  • The button should have a class name of bold.
  • The button should be within a <div class="controls"> element before the editbox element.
I’m sorry; your browser doesn’t support HTML5 video in WebM with VP8/VP9 or MP4 with H.264.

What you need to know

  • Use on:event to call a function when an element is clicked:

    <button on:click="doSomething('bold')"></button>
    
  • Those functions (example: doSomething) can be added to the component like this:

    class SomeElement extends StacheElement {
      static view = `<button on:click="this.doSomething('bold')"></button>`;
      doSomething(cmd) {
        alert("doing " + cmd);
      }
    }
    customElements.define("some-element", SomeElement);
    
  • To bold text selected in a contenteditable element, use document.execCommand:

    document.execCommand("bold", false, null)
    

The solution

Update the JavaScript tab to:

import { StacheElement } from "//unpkg.com/can@6/core.mjs";

class RichTextEditor extends StacheElement {
  static view = `
    <div class="controls">
      <button on:click="this.exec('bold')" class="bold">B</button>
    </div>
    <div class="editbox" contenteditable="true">
      <ol>
        <li>Learn <b>about</b> CanJS.</li>
        <li>Learn <i>execCommand</i>.</li>
        <li>Learn about selection and ranges.</li>
        <li>Get Funky.</li>
      </ol>
      <div>Celebrate!</div>
    </div>
  `;

  static props = {};

  exec(cmd) {
    document.execCommand(cmd, false, null);
  }
}

customElements.define("rich-text-editor", RichTextEditor);

function getElementsInRange(range, wrapNodeName) {}

function rangeContains(outer, inner) {}

Add an italic button

The problem

  • Add an <button> that (when clicked) will italicize the user selected text.
  • The button should have a class name of italic.
  • The button should be within the <div class="controls"> element before the editbox element.

What you need to know

You know everything you need to know already for this step. The power was inside you all along!

Well… in case you couldn’t guess, to italicize text, use document.execCommand:

document.execCommand("italic", false, null)

The solution

Update the JavaScript tab to:

import { StacheElement } from "//unpkg.com/can@6/core.mjs";

class RichTextEditor extends StacheElement {
  static view = `
    <div class="controls">
            <button on:click="this.exec('bold')" class="bold">B</button>
            <button on:click="this.exec('italic')" class="italic">I</button>
    </div>
    <div class="editbox" contenteditable="true">
      <ol>
        <li>Learn <b>about</b> CanJS.</li>
        <li>Learn <i>execCommand</i>.</li>
        <li>Learn about selection and ranges.</li>
        <li>Get Funky.</li>
      </ol>
      <div>Celebrate!</div>
    </div>
  `;

  static props = {};

  exec(cmd) {
    document.execCommand(cmd, false, null);
  }
}

customElements.define("rich-text-editor", RichTextEditor);

function getElementsInRange(range, wrapNodeName) {}

function rangeContains(outer, inner) {}


Add a copy button

The problem

  • Add a <button> that (when clicked) will select the entire contents of the editbox element and copy the editbox text to the clipboard.
  • The button should be within the <div class="controls"> element before the editbox element.
I’m sorry; your browser doesn’t support HTML5 video in WebM with VP8/VP9 or MP4 with H.264.

What you need to know

  • Use querySelector to get an element by a CSS selector:

    this.querySelector(".someClassName")
    

    HINT: You’ll want to get the editbox element.

  • The Range and the Selection APIs are used to control the text a user is selecting.

    A Range allows you to “contain” a fragment of the document that contains nodes and parts of text nodes.

    The Selection object contains the ranges of text that a user currently has highlighted. Usually there is only one Range within a selection.

    To programmatically select text, first create a range:

    const editBoxRange = document.createRange();
    

    Then you position the range over the elements you would like to select. In our case we want to select the editbox element, so we can use selectNodeContents:

    editBoxRange.selectNodeContents(editBox);
    

    Now we need to make the editBoxRange the only range in the user’s Selection. To do this, we first need to get the selection:

    const selection = window.getSelection();
    

    Then, remove all current selected text with:

    selection.removeAllRanges();
    

    Finally, add the range you want to actually select:

    selection.addRange(editBoxRange);
    
  • To copy to the clipboard the ranges in the user’s Selection, call:

    document.execCommand("copy");
    

How to verify it works

Click the button. You should be able to paste the contents of the editable area into a text editor.

The solution

Update the JavaScript tab to:

import { StacheElement } from "//unpkg.com/can@6/core.mjs";

class RichTextEditor extends StacheElement {
  static view = `
    <div class="controls">
      <button on:click="this.exec('bold')" class="bold">B</button>
      <button on:click="this.exec('italic')" class="italic">I</button>
      <button on:click="this.copyAll()">Copy All</button>
    </div>
    <div class="editbox" contenteditable="true">
      <ol>
        <li>Learn <b>about</b> CanJS.</li>
        <li>Learn <i>execCommand</i>.</li>
        <li>Learn about selection and ranges.</li>
        <li>Get Funky.</li>
      </ol>
      <div>Celebrate!</div>
    </div>
  `;

  exec(cmd) {
    document.execCommand(cmd, false, null);
  }

  copyAll() {
    const editBox = this.querySelector(".editbox");
    const editBoxRange = document.createRange();
    editBoxRange.selectNodeContents(editBox);

    const selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(editBoxRange);

    document.execCommand("copy");
  }
}

customElements.define("rich-text-editor", RichTextEditor);

function getElementsInRange(range, wrapNodeName) {}

function rangeContains(outer, inner) {}

Add a Funky button that works when selecting a single text node

The problem

  • Add a <button> that (when clicked) will add funky to the class name of the content selected in the editable area.
  • The button should have a class name of funky.
  • We are only concerned with Funk-ify text selected within a single element. We will make the button able to Funk-ify text selected across elements later.
I’m sorry; your browser doesn’t support HTML5 video in WebM with VP8/VP9 or MP4 with H.264.

What you need to know

On a high level, we are going to:

  1. Get the text the user has selected represented with a Range.
  2. Wrap the selected text with a span element element.
  3. Add funky to the class name of the span element.

Text Nodes Exist!

It’s critical to understand that the DOM is made up of normal nodes and text nodes. For example, the following UL has 7 child nodes:

<ul>
  <li>First</li>
  <li>Second</li>
  <li>Third</li>
</ul>

The UL has children like:

[
   document.createTextNode("\n  "),
   <li>First</li>
   document.createTextNode("\n  ")
   <li>Second</li>
   document.createTextNode("\n  ")
   <li>Third</li>
   document.createTextNode("\n")
]

If the user selects “about selection” in:

<li>Learn about selection and ranges</li>

They are selecting part of a TextNode. In order to funk-ify "about selection", we need to change that HTML to:

<li>Learn <span class="funky">about selection</span> and ranges</li>

Implementing the getElementsInRange helper

To prepare for the final step, we are going to implement part of this step within a getElementsInRange function, which will return the HTML elements within a range. If the range includes TextNodes, those TextNodes should be wrapped in a wrapNodeName element.

For example, if the aboutSelection Range represents "about selection" in:

<li>Learn about selection and ranges</li>

Calling getElementsInRange(aboutSelection, "span") should:

  • Convert the <li> to look like:

    <li>Learn <span>about selection</span> and ranges</li>
    
  • Return the <span> element above.

Other stuff you need to know

  • To get the user’s current selection as a Range, run:
    const selection = window.getSelection();
    if (selection && selection.rangeCount) {
      const selectedRange = selection.getRangeAt(0);
    }
    
  • To create an element given a tag name, write:
    const wrapper = document.createElement(wrapNodeName);
    
  • To surround a range within a textNode with another node, write:
    selectedRange.surroundContents(wrapper);
    
  • To add a class name to an element’s class list, write:
    element.classList.add("funky")
    

The solution

Update the JavaScript tab to:

import { StacheElement } from "//unpkg.com/can@6/core.mjs";

class RichTextEditor extends StacheElement {
  static view = `
    <div class="controls">
      <button on:click="this.exec('bold')" class="bold">B</button>
      <button on:click="this.exec('italic')" class="italic">I</button>
      <button on:click="this.copyAll()">Copy All</button>
      <button on:click="this.funky()" class="funky">Funky</button>
    </div>
    <div class="editbox" contenteditable="true">
      <ol>
        <li>Learn <b>about</b> CanJS.</li>
        <li>Learn <i>execCommand</i>.</li>
        <li>Learn about selection and ranges.</li>
        <li>Get Funky.</li>
      </ol>
      <div>Celebrate!</div>
    </div>
  `;

  exec(cmd) {
    document.execCommand(cmd, false, null);
  }

  copyAll() {
    const editBox = this.querySelector(".editbox");
    const editBoxRange = document.createRange();
    editBoxRange.selectNodeContents(editBox);

    const selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(editBoxRange);

    document.execCommand("copy");
  }

  funky() {
    const selection = window.getSelection();
    if (selection && selection.rangeCount) {
      const selectedRange = selection.getRangeAt(0);
      getElementsInRange(selectedRange, "span").forEach(el => {
        el.classList.add("funky");
      });
    }
  }
}

customElements.define("rich-text-editor", RichTextEditor);

function getElementsInRange(range, wrapNodeName) {
  const elements = [];
  const wrapper = document.createElement(wrapNodeName);
  range.surroundContents(wrapper);
  elements.push(wrapper);
  return elements;
}

function rangeContains(outer, inner) {}

Make the Funky button only work within the editable area

The problem

As shown in the previous step’s video, selecting text outside the editable area and clicking the button will make that text . In this step, we will only funk-ify the text in the editbox.

I’m sorry; your browser doesn’t support HTML5 video in WebM with VP8/VP9 or MP4 with H.264.

What you need to know

On a high level, we are going to:

  1. Create a range that represents the editbox
  2. Compare the selected range to the editbox range and make sure it’s inside the editbox before adding the funky behavior.

The rangeContains helper

In this step, we will be implementing the rangeContains helper function. Given an outer range and an inner range, it must return true if the outer range is equal to or contains the inner range:

function rangeContains(outer, inner) {
  return // COMPARE RANGES
}

const documentRange = document.createRange();
documentRange.selectContents(document.documentElement);

const bodyRange = document.createRange();
bodyRange.selectContents(document.body)

rangeContains(documentRange, bodyRange) //-> true

Other stuff you need to know

  • Use selectNodeContents to set a range to the contents of an element:

    const bodyRange = document.createRange();
    bodyRange.selectContents(document.body)
    
  • Use compareBoundaryPoints to compare two ranges. The following makes sure outer’s start is before or equal to inner’s start AND outer’s end is after or equal to inner’s end:

    outer.compareBoundaryPoints(Range.START_TO_START,inner) <= 0 &&
    outer.compareBoundaryPoints(Range.END_TO_END,inner) >= 0
    

The solution

Update the JavaScript tab to:

import { StacheElement } from "//unpkg.com/can@6/core.mjs";

class RichTextEditor extends StacheElement {
  static view = `
    <div class="controls">
      <button on:click="this.exec('bold')" class="bold">B</button>
      <button on:click="this.exec('italic')" class="italic">I</button>
      <button on:click="this.copyAll()">Copy All</button>
      <button on:click="this.funky()" class="funky">Funky</button>
    </div>
    <div class="editbox" contenteditable="true">
      <ol>
        <li>Learn <b>about</b> CanJS.</li>
        <li>Learn <i>execCommand</i>.</li>
        <li>Learn about selection and ranges.</li>
        <li>Get Funky.</li>
      </ol>
      <div>Celebrate!</div>
    </div>
  `;

  exec(cmd) {
    document.execCommand(cmd, false, null);
  }

  copyAll() {
    const editBox = this.querySelector(".editbox");
    const editBoxRange = document.createRange();
    editBoxRange.selectNodeContents(editBox);

    const selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(editBoxRange);

    document.execCommand("copy");
  }

  funky() {
    const editBox = this.querySelector(".editbox");
    const editBoxRange = document.createRange();
    editBoxRange.selectNodeContents(editBox);

    const selection = window.getSelection();
    if (selection && selection.rangeCount) {
      const selectedRange = selection.getRangeAt(0);
      if (rangeContains(editBoxRange, selectedRange)) {
        getElementsInRange(selectedRange, "span").forEach(el => {
          el.classList.add("funky");
        });
      }
    }
  }
}

customElements.define("rich-text-editor", RichTextEditor);

function getElementsInRange(range, wrapNodeName) {
  const elements = [];
  const wrapper = document.createElement(wrapNodeName);
  range.surroundContents(wrapper);
  elements.push(wrapper);
  return elements;
}

function rangeContains(outer, inner) {
  return (
    outer.compareBoundaryPoints(Range.START_TO_START, inner) <= 0 &&
    outer.compareBoundaryPoints(Range.END_TO_END, inner) >= 0
  );
}

Make the Funky button work when selecting multiple nodes

The problem

In this section, we will make the button work even if text is selected across multiple nodes.

NOTE: This is hard!

What you need to know

On a high-level, we are going to edit getElementsInRange to work with ranges that span multiple nodes by:

  1. Detect if the range spans multiple nodes.
  2. If the range does span multiple nodes, we will walk the DOM between the range’s start position and end position:
    1. From the range’s start position, collect all nextSiblings. Once out of siblings, move to the parentNode. Do not collect that node, continue collecting siblings and moving to parent nodes until you reach a parent node that is a direct descendent of the commonAncestor of the start and end of the range. This parent node is the start-line node.
    2. From the range’s end position, collect all previousSiblings. Once out of siblings move, to the parentNode. Do not collect that node, continue collecting siblings and moving to parent nodes until you reach a parent node that is a direct descendent of the commonAncestor of the start and end of the range. This parent node is the end-line node.
    3. Collect all sibling nodes between the start-line node and end-line node.
    4. Do not collect TextNodes that only have spaces.
    5. When TextNodes that have characters should be collected, wrap them in an element node of type wrapNodeName.

Let’s see how this works with an example. Let’s say we’ve selected from the out in about to the start of brate in Celebrate. We’ve marked the selection start and end with | below:

<ol>
  <li>Learn <b>ab|out</b> CanJS.</li>
  <li>Learn <i>execCommand</i>.</li>
  <li>Learn about selection and ranges.</li>
  <li>Get Funky.</li>
</ol>
<div>Get Ready To</div>
<div>Cele|brate!</div>

So we first need to "collect" out in elements. To do this, we will do step #2.5 and the DOM will look like:

<ol>
  <li>Learn <b>ab<span class="funky">out</span></b> CanJS.</li>
  <li>Learn <i>execCommand</i>.</li>
  <li>Learn about selection and ranges.</li>
  <li>Get Funky.</li>
</ol>
<div>Get Ready To</div>
<div>Cele|brate!</div>

We will then keep doing step #2.1. This new span has no nextSiblings, so we will walk up to it’s parent <b> element and collect its next siblings. This will update the DOM to:

<ol>
  <li>Learn <b>ab<span class="funky">out</span></b><span class="funky"> CanJS.</span></li>
  <li>Learn <i>execCommand</i>.</li>
  <li>Learn about selection and ranges.</li>
  <li>Get Funky.</li>
</ol>
<div>Get Ready To</div>
<div>Cele|brate!</div>

We will then keep doing step #2.1. This new span has no nextSiblings, so we will walk up to it’s parent <li> element and collect its next siblings. We will only collect Elements and TextNodes with characters, resulting in:

<ol>
  <li>Learn <b>ab<span class="funky">out</span></b><span class="funky"> CanJS.</span></li>
  <li class="funky">Learn <i>execCommand</i>.</li>
  <li class="funky">Learn about selection and ranges.</li>
  <li class="funky">Get Funky.</li>
</ol>
<div>Get Ready To</div>
<div>Cele|brate!</div>

We will then move onto the <ol>. Once we reached the <ol>, we’ve reached the start-line node. Now we will move onto step #2.2. We will perform a similar walk from the end of the range, but in reverse. In this case, we will wrap Cele with a <span> follows:

<ol>
  <li>Learn <b>ab<span class="funky">out</span></b><span class="funky"> CanJS.</span></li>
  <li class="funky">Learn <i>execCommand</i>.</li>
  <li class="funky">Learn about selection and ranges.</li>
  <li class="funky">Get Funky.</li>
</ol>
<div>Get Ready To</div>
<div><span class="funky">Cele</span>|brate!</div>

As this <span> has no previous siblings, we will walk up to its container div. We’ve now reached the end-line node.

Finally, we move onto step #2.3, and collect all nodes between start-line and end-line:

<ol>
  <li>Learn <b>ab<span class="funky">out</span></b><span class="funky"> CanJS.</span></li>
  <li class="funky">Learn <i>execCommand</i>.</li>
  <li class="funky">Learn about selection and ranges.</li>
  <li class="funky">Get Funky.</li>
</ol>
<div class="funky">Get Ready To</div>
<div><span class="funky">Cele</span>|brate!</div>

NOTE: In the final solution, elements are first collected all at once, and then class="funky" is added later. However, we are showing funky being added incrementally here for clarity.

Helpers:

To make the solution easier, we’ve provided several helpers in the HTML tab:

splitRangeStart takes a range and splits the text node at the range start and replaces the selected part with an element. For example, if the range selected "a small" in the following HTML:

<i>It’s a</i><b>small world<b>

Calling splitRangeStart(range, "span") would update the DOM to:

<i>It’s <span>a</span></i><b>small world<b>

…and it would return the wrapping <span>.

splitRangeEnd does the same thing, but in reverse.

siblingThenParentUntil is used to walk the DOM in the pattern described in #2.1 and #2.2. For example, with DOM like:

<div class="editbox" contenteditable="true">
  <ol>
    <li>Learn <b>ab<span id="START">out</span></b> CanJS.</li>
    <li>Learn <i>execCommand</i>.</li>
    <li>Learn about selection and ranges.</li>
    <li>Get Funky.</li>
  </ol>
  <div>Get Ready To</div>
  <div>Cele|brate!</div>
</div>

Calling it as follows:

const start = document.querySelector("#start");
const editbox = document.querySelector(".editbox");
siblingThenParentUntil("nextSibling", start, editbox, function handler(element) {});

…will call back handler with all the TextNodes and Elements that should be either wrapped and collected or simply collected. That is, it would be called with:

TextNode< CanJS.>
<li>Learn <i>execCommand</i>.</li>
<li>Learn about selection and ranges.</li>
<li>Get Funky.</li>
<ol>...

siblingThenParentUntil will return the parent <div> of the <ol> as the start-line node.

Other stuff you need to know:

  • range.commonAncestor returns the DOM node that contains both the start and end of a Range.

  • nextSibling returns a node’s next sibling in the DOM.

  • previousSibling returns a node’s previous sibling in the DOM.

  • parentNode returns a node’s parent element.

  • If you change the DOM, ranges, including the selected ranges, can be messed up. Use range.setStart and range.setEnd to update the start and end of a range after the DOM has finished changing:

    range.setStart(startWrap, 0);
    range.setEnd(endWrap.firstChild,endWrap.textContent.length);
    
  • Use /[^\s\n]/.test(textNode.nodeValue) to test if a TextNode has non-space characters.

Some final clues:

The following can be used to collect (and possibly wrap) nodes into the elements array:

function addSiblingElement(element) {
  // We are going to wrap all text nodes with a span.
  if (element.nodeType === Node.TEXT_NODE) {
    // If there’s something other than a space:
    if (/[^\s\n]/.test(element.nodeValue)) {
      const span = document.createElement(wrapNodeName);
      element.parentNode.insertBefore(span, element);
      span.appendChild(element);
      elements.push(span);
    }
  } else {
    elements.push(element)
  }
}

With this, you could do step #2.1 like:

const startWrap = splitRangeStart(range, wrapNodeName);
addSiblingElement(startWrap);

// Add nested siblings from startWrap up to the first line.
const startLine = siblingThenParentUntil(
  "nextSibling",
  startWrap,
  range.commonAncestor,
  addSiblingElement
);

The solution

Update the JavaScript tab to:

import { StacheElement } from "//unpkg.com/can@pre/core.mjs";

class RichTextEditor extends StacheElement {
  static view = `
    <div class="controls">
      <button on:click="this.exec('bold')" class="bold">B</button>
      <button on:click="this.exec('italic')" class="italic">I</button>
      <button on:click="this.copyAll()">Copy All</button>
      <button on:click="this.funky()" class="funky">Funky</button>
    </div>
    <div class="editbox" contenteditable="true">
      <ol>
        <li>Learn <b>about</b> CanJS.</li>
        <li>Learn <i>execCommand</i>.</li>
        <li>Learn about selection and ranges.</li>
        <li>Get Funky.</li>
      </ol>
      <div>Celebrate!</div>
    </div>
  `;

  exec(cmd) {
    document.execCommand(cmd, false, false);
  }

  copyAll() {
    const editBox = this.querySelector(".editbox");
    const editBoxRange = document.createRange();
    editBoxRange.selectNodeContents(editBox);

    const selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(editBoxRange);

    document.execCommand("copy");
  }

  funky() {
    const editBox = this.querySelector(".editbox");
    const editBoxRange = document.createRange();
    editBoxRange.selectNodeContents(editBox);

    const selection = window.getSelection();
    if (selection && selection.rangeCount) {
      const selectedRange = selection.getRangeAt(0);
      if (rangeContains(editBoxRange, selectedRange)) {
        getElementsInRange(selectedRange, "span").forEach(el => {
          el.classList.add("funky");
        });
      }
    }
  }
}

customElements.define("rich-text-editor", RichTextEditor);

function getElementsInRange(range, wrapNodeName) {
  const elements = [];

  function addSiblingElement(element) {
    // We are going to wrap all text nodes with a span.
    if (element.nodeType === Node.TEXT_NODE) {
      // If there’s something other than a space:
      if (/[^\s\n]/.test(element.nodeValue)) {
        const span = document.createElement(wrapNodeName);
        element.parentNode.insertBefore(span, element);
        span.appendChild(element);
        elements.push(span);
      }
    } else {
      elements.push(element);
    }
  }

  const startContainer = range.startContainer;
  const commonAncestor = range.commonAncestorContainer;

  if (startContainer === commonAncestor) {
    const wrapper = document.createElement(wrapNodeName);
    range.surroundContents(wrapper);
    elements.push(wrapper);
  } else {
    // Split the starting text node.
    const startWrap = splitRangeStart(range, wrapNodeName);
    addSiblingElement(startWrap);

    // Add nested siblings from startWrap up to the first line.
    const startLine = siblingThenParentUntil(
      "nextSibling",
      startWrap,
      commonAncestor,
      addSiblingElement
    );

    // Split the ending text node.
    const endWrap = splitRangeEnd(range, wrapNodeName);
    addSiblingElement(endWrap);

    // Add nested siblings from endWrap up to the last line.
    const endLine = siblingThenParentUntil(
      "previousSibling",
      endWrap,
      commonAncestor,
      addSiblingElement
    );

    // Add lines between start and end to elements.
    let cur = startLine.nextSibling;
    while (cur !== endLine) {
      addSiblingElement(cur);
      cur = cur.nextSibling;
    }

    // Update the ranges
    range.setStart(startWrap, 0);
    range.setEnd(endWrap.firstChild, endWrap.textContent.length);
  }

  return elements;
}

function rangeContains(outer, inner) {
  return (
    outer.compareBoundaryPoints(Range.START_TO_START, inner) <= 0 &&
    outer.compareBoundaryPoints(Range.END_TO_END, inner) >= 0
  );
}

Result

When finished, you should see something like the following CodePen:

See the Pen CanJS 6 Text Editor by Bitovi (@bitovi) on CodePen.

CanJS is part of DoneJS. Created and maintained by the core DoneJS team and Bitovi. Currently 6.0.1.

On this page

Get help

  • Chat with us
  • File an issue
  • Ask questions
  • Read latest news