Text Editor
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 5 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 5 Text Editor by Bitovi (@bitovi) on CodePen.
This CodePen:
- Loads CanJS (
import { Component } from "//unpkg.com/can@5/core.mjs"
). - Implements 3 helper functions we will use later:
siblingThenParentUntil
,splitRangeStart
andsplitRangeEnd
. These are hidden out of sight in theHTML
tab. - Mocks out the signature for helper functions we will implement later:
getElementsInRange
andrangeContains
. These are in theJS
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 aneditbox
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>
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:
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:
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:
<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 { Component } from "//unpkg.com/can@5/core.mjs";
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:
<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 theeditbox
element.
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 adoSomething
method on the ViewModel: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, use document.execCommand:
document.execCommand("bold", false, null)
The solution
Update the JavaScript tab to:
import { Component } from "//unpkg.com/can@5/core.mjs";
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, null);
}
}
});
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 theeditbox
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 { Component } from "//unpkg.com/can@5/core.mjs";
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, null);
}
}
});
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 theeditbox
element and copy theeditbox
text to the clipboard. - The button should be within the
<div class="controls">
element before theeditbox
element.
What you need to know
To make work, we need to give the
ViewModel
access to the<rich-text-editor>
element. Usually,ViewModel
s 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: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 theViewModel
.HINT: This allows you to use
this.element
within yourcopyAll()
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.
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 oneRange
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’sSelection
. 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 { Component } from "//unpkg.com/can@5/core.mjs";
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, null);
},
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
<button>
that (when clicked) will addfunky
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 toFunk-ify
text selected across elements later.
What you need to know
On a high level, we are going to:
- Get the text the user has selected represented with a
Range
. - Wrap the selected text with a
span
element element. - Add
funky
to the class name of thespan
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
TextNode
s, those TextNode
s 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 { Component } from "//unpkg.com/can@5/core.mjs";
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, null);
},
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 button will make that text . In this step, we will
only funk-ify
the text in the editbox
.
What you need to know
On a high level, we are going to:
- Create a range that represents the
editbox
- Compare the selected range to the
editbox
range and make sure it’s inside theeditbox
before adding thefunky
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 toinner
’s start ANDouter
’s end is after or equal toinner
’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 { Component } from "//unpkg.com/can@5/core.mjs";
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, null);
},
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 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:
- Detect if the range spans multiple nodes.
- If the range does span multiple nodes, we will walk the DOM between the
range’s start position and end position:
- 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. - 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. - Collect all sibling nodes between the start-line node and end-line node.
- Do not collect TextNodes that only have spaces.
- When
TextNodes
that have characters should be collected, wrap them in an element node of typewrapNodeName
.
- 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
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 showingfunky
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 { Component } from "//unpkg.com/can@5/core.mjs";
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, null);
},
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 CodePen:
See the Pen CanJS 5 Text Editor by Bitovi (@bitovi) on CodePen.