Text Editor (Medium)
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.execCommandto 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:
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 Binbutton. The JS Bin will open in a new window. In that new window, underFile, clickClone.
CanJS Text Editor on jsbin.com
This JS Bin:
- Loads CanJS.
- Implements 3 helper functions we will use later:
siblingThenParentUntil,splitRangeStartandsplitRangeEnd. These are hidden out of sight in theHTMLtab. - Mocks out the signature for helper functions we will implement later:
getElementsInRangeandrangeContains. These are in theJavaScripttab.
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 aneditboxclassNameto 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:
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 theeditboxelement.
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 adoSomethingmethod 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 theeditboxelement.
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 theeditboxelement and copy theeditboxtext to the clipboard. - The button should be within the
<div class="controls">element before theeditboxelement.
What you need to know
To make
Copy Allwork, we need to give theViewModelaccess 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
elementavailable 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
elementproperty on theViewModel.HINT: This allows you to use
this.elementwithin yourcopyAll()function.Use
querySelectorto get an element by a css selector.this.element.querySelector(".someClassName")HINT: You’ll want to get the
editboxelement.The Range and the Selection APIs are used to control the text a user is selecting.
Rangesallow you to “contain” a fragment of the document that contain nodes and parts of text nodes.The
Selectionobject contains the ranges of text that a user currently has highlighted. Usually there is only onerangewithin 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
editboxelement, so we can use.selectNodeContents:editBoxRange.selectNodeContents(editBox);Now we need to make the
editBoxRangethe 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
Selectioncall: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 addfunkyto the className of the content selected in the editable area. - The button should have a className of
funky. - We are only concerned with
Funk-ifytext selected within a single element. We will make theFunkybutton able toFunk-ifytext 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
spanelement element. - Add
funkyto the className of thespanelement.
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.
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
editboxrange and make sure it’s inside theeditboxbefore adding thefunkybehavior.
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:
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:
- 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 by:
- From the range’s start position, collect all
nextSiblings. Once out of siblings move to theparentNode. 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
commonAncestorof 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 theparentNode. 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
commonAncestorof 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
TextNodesthat have characters should be collected, wrap them in an element node of typewrapNodeName.
- From the range’s start position, collect all
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 showingfunkybeing 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.nextSiblingreturns a node’s next sibling in the DOM.previousSiblingreturns a node’s previous sibling in the DOM.parentNodereturns 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: