DoneJS StealJS jQuery++ FuncUnit DocumentJS
4.3.0
5.0.0 3.13.1 2.3.35
  • About
  • Guides
  • API Docs
  • Community
  • Contributing
  • Bitovi
    • Bitovi.com
    • Blog
    • Design
    • Development
    • Training
    • Open Source
    • About
    • Contact Us
  • About
  • Guides
    • experiment
      • Chat Guide
      • TodoMVC Guide
      • ATM Guide
    • getting started
      • Setting Up CanJS
      • Technology Overview
      • Reading the Docs (API Guide)
      • Experimental ES Module Usage
    • recipes
      • Canvas Clock (Simple)
      • Credit Card Guide (Advanced)
      • Credit Card Guide (Simple)
      • CTA Bus Map (Medium)
      • File Navigator Guide (Advanced)
      • File Navigator Guide (Simple)
      • Playlist Editor (Advanced)
      • Signup and Login (Simple)
      • Text Editor (Medium)
      • Tinder Carousel (Medium)
      • TodoMVC with StealJS
      • Video Player (Simple)
    • topics
      • Debugging
      • Forms
    • upgrade
      • Migrating to CanJS 3
      • Migrating to CanJS 4
      • Using Codemods
  • API Docs
  • Community
  • Contributing
  • GitHub
  • Twitter
  • Chat
  • Forum
  • News
Bitovi

Text Editor (Medium)

  • Edit on GitHub

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

This recipe was first published March 1st, 2018 by Justin Meyer.

Live stream of this recipe recorded 2pm CST on March 1st, 2018:

In this guide you will learn how to:

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

The final widget looks like:

JS Bin on jsbin.com

The following sections are broken down 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 CLONING THE FOLLOWING JS BIN:

Click the JS Bin button. The JS Bin will open in a new window. In that new window, under File, click Clone.

CanJS Text Editor on jsbin.com

This JS Bin:

  • Loads CanJS.
  • 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 JavaScript tab.

The problem

  • Setup a basic CanJS app by creating a <rich-text-editor> element.
  • The <rich-text-editor> element should add a contenteditable <div/> with an editbox className 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-component with a tag that matches the name of your custom element. For example:

can.Component.extend({
  tag: "rich-text-editor"
})

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:

can.Component.extend({
  tag: "rich-text-editor",
  view: `<h2>I am a rich-text-editor!</h2>`
});

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

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:

can.Component.extend({
  tag: "rich-text-editor",
  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>
  `
});

function getElementsInRange(range, wrapNodeName) { }

function rangeContains(outer, inner) { }

Update the HTML <body> element to:

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

<script src="https://unpkg.com/can/dist/global/can.js"></script>
<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 B <button> that when clicked, will bold the text the user selected.
  • The button should have a className 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) are usually methods on the Component’s ViewModel. For example, the following creates a doSomething method on the ViewModel:

    can.Component.extend({
      tag: "some-element",
      view: `<button on:click="doSomething('bold')"></button>`,
      ViewModel: {
        doSomething(cmd) {
          alert("doing "+cmd);
        }
      }
    })
    
  • To bold text selected in a contenteditable element, call:

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

The solution

Update the JavaScript tab to:

can.Component.extend({
  tag: "rich-text-editor",
  view: `
    <div class="controls">
      <button on:click='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>
  `,
  ViewModel: {
    exec(cmd){
      document.execCommand(cmd, false, false);
    }
  }
});

function getElementsInRange(range, wrapNodeName) { }

function rangeContains(outer, inner) { }

Add an italic button

The problem

  • Add a I <button> that when clicked, will italicize the user selected text.
  • The button should have a className 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, call:

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

The solution

Update the JavaScript tab to:

can.Component.extend({
  tag: "rich-text-editor",
  view: `
    <div class="controls">
      <button on:click='exec("bold")' class='bold'>B</button>
      <button on:click='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>
  `,
  ViewModel: {
    exec(cmd){
      document.execCommand(cmd, false, false);
    }
  }
});

function getElementsInRange(range, wrapNodeName) { }

function rangeContains(outer, inner) { }

Add a copy button

The problem

  • Add a Copy All <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

  • To make Copy All work, we need to give the ViewModel access to the <rich-text-editor> element. Usually, ViewModels should not access DOM elements directly. However, for this widget, there’s important state (what the user has typed) that we need to access.

    So to make the component’s element available to the ViewModel, use the following pattern:

    can.Component.extend({
      tag: "some-element",
      view: `...`,
      ViewModel: {
        element: "any",
        connectedCallback(el) {
          this.element = el;
        }
      }
    })
    

    element: "any" declares the element property can be of any value.

    connectedCallback is a lifecycle hook that gets called when the component is inserted into the page. This pattern saves the element property on the ViewModel.

    HINT: This allows you to use this.element within your copyAll() function.

  • Use querySelector to get an element by a css selector.

    this.element.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.

    Ranges allow you to “contain” a fragment of the document that contain 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 the 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 Copy All button. You should be able to paste the contents of the editable area into a text editor.

The solution

Update the JavaScript tab to:

can.Component.extend({
  tag: "rich-text-editor",
  view: `
    <div class="controls">
      <button on:click='exec("bold")' class='bold'>B</button>
      <button on:click='exec("italic")' class='italic'>I</button>
      <button on:click='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>
  `,
  ViewModel: {
    exec(cmd){
      document.execCommand(cmd, false, false);
    },
    element: "any",
    connectedCallback(el) {
      this.element = el;
    },
    copyAll(){
      const editBox = this.element.querySelector(".editbox");
      const editBoxRange = document.createRange();
      editBoxRange.selectNodeContents(editBox);

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

      document.execCommand("copy");
    }
  }
});

function getElementsInRange(range, wrapNodeName) { }

function rangeContains(outer, inner) { }

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

The problem

  • Add a Funky <button> that when clicked, will add funky to the className of the content selected in the editable area.
  • The button should have a className of funky.
  • We are only concerned with Funk-ify text selected within a single element. We will make the Funky 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 className 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. getElementsInRange returns the HTML elements within a range. If a 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> too 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 you can write:
    element.classList.add("funky")
    

The solution

Update the JavaScript tab to:

can.Component.extend({
  tag: "rich-text-editor",
  view: `
    <div class="controls">
      <button on:click='exec("bold")' class='bold'>B</button>
      <button on:click='exec("italic")' class='italic'>I</button>
      <button on:click='copyAll()'>Copy All</button>
      <button on:click='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>
  `,
  ViewModel: {
    exec(cmd){
      document.execCommand(cmd, false, false);
    },
    element: "any",
    connectedCallback(el) {
      this.element = el;
    },
    copyAll(){
      const editBox = this.element.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")
        });
      }
    }
  }
});

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 Funky button will make that text Funky. 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:

can.Component.extend({
  tag: "rich-text-editor",
  view: `
    <div class="controls">
      <button on:click='exec("bold")' class='bold'>B</button>
      <button on:click='exec("italic")' class='italic'>I</button>
      <button on:click='copyAll()'>Copy All</button>
      <button on:click='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>
  `,
  ViewModel: {
    exec(cmd){
      document.execCommand(cmd, false, false);
    },
    element: "any",
    connectedCallback(el) {
      this.element = el;
    },
    copyAll(){
      const editBox = this.element.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.element.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");
          });
        }
      }
    }
  }
});

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 Funky 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 by:
    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.

Lets see how this works with an example. Lets 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 callback 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:

can.Component.extend({
  tag: "rich-text-editor",
  view: `
    <div class="controls">
      <button on:click='exec("bold")' class='bold'>B</button>
      <button on:click='exec("italic")' class='italic'>I</button>
      <button on:click='copyAll()'>Copy All</button>
      <button on:click='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>
  `,
  ViewModel: {
    exec(cmd){
      document.execCommand(cmd, false, false);
    },
    element: "any",
    connectedCallback(el) {
      this.element = el;
    },
    copyAll(){
      const editBox = this.element.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.element.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");
          });
        }
      }
    }
  }
});

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,
      endContainer = range.endContainer,
      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 JS Bin:

JS Bin on jsbin.com

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

On this page

Get help

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